eCourt-note/docs/PLAN.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 6ddc35d6d5 เลือก annotation บนหน้า PDF → sidebar ตามไปเน้นรายการนั้น + สร้างเสร็จเลือกให้ทันที + กัน duplicate ชั่วขณะตอนสร้าง: (1) ส่ง selectedId ให้ AnnotationSidebar — ไฮไลต์แถว (ทั้งลิสต์หลักและผลค้นหา) + scrollIntoView block:nearest + สลับมาแท็บ Annotations ให้เองถ้ากำลังอยู่ Bookmarks (rAF รอแถว render หลังสลับแท็บค่อยเลื่อน) (2) AUTO_SELECT_TYPES — สร้าง DRAW/SHAPE/TEXTBOX/STAMP/HIGHLIGHT/REDACTION เสร็จ = เลือกตัวใหม่ทันทีด้วย saved.id (กรอบ selection + sidebar ตามไปเลย; COMMENT ยกเว้นเพราะ composer/pin ทำหน้าที่นั้นอยู่แล้ว) — แก้ปัญหา "วาดเสร็จคลิกที่เส้น tool ยังเป็นวาด sidebar นิ่ง" (3) กันอาการ row โผล่แล้วหาย (0→2→1): เดิม optimistic row กับ row จาก server คนละ id ทำให้ SSE event มาก่อน action เสร็จ dedupe ไม่ได้ — ตอนนี้ client ส่ง id ของ optimistic row ให้ createAnnotation ใช้ซ้ำ (validate UUID, ไม่ส่ง = สุ่มเองตามเดิมสำหรับ redo path) ทุกชั้น dedupe ด้วย id เดียวกันตั้งแต่แรก — sync PLAN.md §4 + rules/realtime.md ให้ตรงพฤติกรรมใหม่
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 11:35:05 +07:00

24 KiB
Raw Blame History

📋 Implementation Plan — Judge's Note (PDF Annotation System for e-Court)

แผนงานก่อนเริ่ม implement โปรเจกต์ Next.js แยกต่างหากจากโปรเจกต์ Nuxt (dms-main)


0. สมมติฐาน & ข้อตกลงเบื้องต้น (Assumptions)

# หัวข้อ ข้อตกลง
A1 ตำแหน่งโปรเจกต์ D:\dms\judes-note\ — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ app/ อยู่แล้ว
A2 Framework Next.js 14 (App Router) + TypeScript strict
A3 PDF Renderer pdfjs-dist เวอร์ชัน v4 (^4.10.38) — ใช้ built-in TextLayer class; worker ตั้ง path ตรง ๆ: คัดลอก pdf.worker.min.mjs (จาก node_modules/pdfjs-dist/build/) มาไว้ที่ public/ ด้วย postinstall script แล้วตั้ง pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs" — ไม่ใช้ new URL(..., import.meta.url) เพราะ v4 เป็นไฟล์ .mjs และมักชนกับ Turbopack/webpack ใน Next 14
A4 Identity สเปกไม่ระบุระบบ auth → ใช้ anonymous user สร้างครั้งเดียวเก็บใน localStorage (userId = UUID, userName = "ผู้ใช้ XXXX") เพื่อเติม field userId/userName ในตาราง
A5 ไม่มีไฟล์ปุ่ม Upload หน้า / อ่านรายการจาก DB เท่านั้น (ตามสเปก) — sample PDF เก็บใน public/samples/ แล้ว seed ด้วย SQL ใน README (รายละเอียดด้านล่าง)
A6 ทุก annotation ลบได้ แสดงปุ่มลบเฉพาะรายการของตัวเอง (userId ตรงกัน) — ไม่มี role/permission ในเฟสนี้
A7 Database & Realtime (ปรับปรุง 2026-09-08) PostgreSQL แบบ plain container เดียว (judes-note-db, Docker, image postgres:17-alpine, พอร์ต 54322) — เลิกใช้ Supabase stack ทั้งหมด (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ SSE (Server-Sent Events) + pub/sub in-process แทน Supabase Realtime
A8 Event ของ SSE เป็น DTO ตรง ๆ (2026-09-08) Server Actions publish event ผ่าน lib/annotate-events.ts หลังเขียน DB สำเร็จ — payload เป็น DTO (camelCase + ISO string) โดยตรง ไม่มีการแปลง snake_case เหมือนสมัยใช้ Supabase Realtime

ไฟล์ตัวอย่าง (อยู่ที่ public/samples/ แล้ว)

ไฟล์ ที่มา หน้า ขนาด ลักษณะ
01012-672427-4f-690609-0000840227.pdf judgement-api.admincourt.go.th 15 ~1.8 MB สแกนภาพล้วน — ไม่มี text layer
01012-672427-1f-690609-0000840223.pdf judgement-api.admincourt.go.th 44 ~38 MB สแกนภาพล้วน — ไม่มี text layer
dashboard-report-2026-09-03.pdf เพิ่มโดยผู้ใช้ (2026-09-08) 2 ~150 KB text-based ภาษาไทย (ตรวจด้วย pdfjs: 285 text items หน้า 1) — ใช้ทดสอบ HIGHLIGHT ได้

⚠️ ไฟล์คดีทั้งสองเป็นภาพสแกนทุกหน้า (ตรวจแล้ว: 0 หน้ามี /Font) → ใช้ทดสอบ DRAW / COMMENT / BOOKMARK / Page Virtualization (ไฟล์ 44 หน้า 38 MB เหมาะกับทดสอบ virtualization มาก) แต่ทดสอบ HIGHLIGHT ไม่ได้ เพราะ pdfjs สร้าง TextLayer ไม่ได้เมื่อไม่มี text

สถานะ (2026-09-08): มี text-based PDF แล้ว — dashboard-report-2026-09-03.pdf (เพิ่มโดยผู้ใช้) ใช้ทดสอบ HIGHLIGHT ได้ ปัญหาเดิมเกี่ยวกับการหาไฟล์ text-based ยุติแล้ว


1. System Architecture — Multi-Layer Non-Destructive Overlay

หนึ่งหน้า PDF = หนึ่ง <div data-page="N"> ที่ซ้อน layer ทั้งหมดไว้ใน viewport เดียวกัน ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไข (Non-Destructive) — annotation เก็บใน PostgreSQL เป็นพิกัด % เท่านั้น

┌─ div.relative[data-page=N] (ขนาด = viewport.width × viewport.height)
│
├── z-1  <canvas>                     ← pdfjs page.render()        | pointer-events: none เสมอ
├── z-2  div.highlight-layer          ← วาด rects จาก HIGHLIGHT %  | pointer-events: none เสมอ
│       (mix-blend-mode: multiply)
├── z-2  div.textLayer                ← pdfjs TextLayer (span โปร่งใส) | pointer-events: สลับตาม tool
├── z-3  <svg> InkLayer               ← freehand DRAW + live stroke | pointer-events: สลับตาม tool
└── z-4  div.comment-pins             ← Sticky note pin COMMENT     | pointer-events: auto (ยกเว้นตอน draw)

ตารางสลับ pointer-events ตาม Tool

Tool Base (canvas) TextLayer (z-2) InkLayer (z-3) Pins (z-4) พฤติกรรม
select (ค่าเริ่มต้น) none auto none auto คลุมดำข้อความได้ → เด้ง Floating Toolbar → Highlight
draw none none auto none เมาส์วาดเส้นอิสระ (pointer capture)
comment none auto* none auto คลิกจุดบนหน้า → เปิด composer → บันทึก COMMENT
shape (PLAN-FEAT.md F5) none none auto none ลากคลุมวาดรูปทรง rect/ellipse/line/arrow บน InkLayer (เหมือน draw)
erase (PLAN-FEAT.md F6) none none auto none ลากผ่านเส้น/รูปทรงของตัวเองบน InkLayer → hit-test โดน = ลบทันที (comment/highlight ไม่เกี่ยว)

* โหมด comment ให้ TextLayer ยัง select ได้ ส่วนการคลิกวาง pin จับ event ที่ page wrapper (bubble ขึ้นจาก layer ล่าง)

จุดสำคัญ: กฎ z-index 2 ตัว (highlight-layer กับ textLayer) ไม่ทับกันเพราะ DOM order กำหนด — textLayer อยู่ทีหลัง = อยู่บนสุดของ z-2 → ข้อความ select ได้และ highlight ซ้อนหลัง text เสมอ (text เป็นสีโปร่งใสอยู่แล้ว)


2. Coordinate System — Relative Percent (%)

  • ทุกพิกัดใน JSONB (rects, paths, position) เป็น % ของขนาดหน้า PDF ณ scale ปัจจุบัน ณ ตอนวาด → นำไป render ที่ zoom/จอเล็กจอใหญ่ได้ไม่เบี้ยว
  • strokeWidth ของ DRAW เก็บเป็น % ของความกว้างหน้า (เช่น 0.4 = 0.4% ของ width) → แปลงกลับเป็น px ตอน render
  • yOffset ของ bookmark เก็บเป็น % ของความสูงหน้า (0–100)

กลไกแปลง (utils/coordinates.ts)

Pixel (event/getBoundingClientRect)  ──÷size×100──▶  Percent (เก็บลง DB)
Percent (จาก DB)  ──×size÷100──▶  Pixel (render ลง SVG/div)

เหตุผลที่ InkLayer ใช้ SVG แบบไม่มี viewBox

กลเล่น viewBox="0 0 100 100" preserveAspectRatio="none" จะทำให้ ความหนาเส้นบิดเบี้ยว (scale แกน x,y ไม่เท่ากัน) → จึง render ในระบบ px จริง: แปลง % → px ด้วยขนาดที่วัดจาก ResizeObserver แล้ววาด <path> ตรง ๆ (ไม่ต้องใช้ vector-effect)


3. Database Schema (Drizzle ORM + PostgreSQL)

ไฟล์ db/schema.ts

ตาราง files

column type หมายเหตุ
id uuid PK defaultRandom()
title text NOT NULL
description text nullable
pdf_url text NOT NULL
created_at timestamptz defaultNow()

Enum annotation_type = 'HIGHLIGHT' | 'DRAW' | 'COMMENT'

ตาราง annotations

column type หมายเหตุ
id uuid PK
file_id uuid FK → files ON DELETE CASCADE
page_index integer NOT NULL 0-based
user_id text NOT NULL anonymous id จาก localStorage
user_name text NOT NULL
type annotation_type NOT NULL
data jsonb NOT NULL $type<AnnotationData>() strict typed union (ด้านล่าง)
created_at / updated_at timestamptz

Index: (file_id, page_index) สำหรับ query หลักตอนเปิด viewer

ตาราง bookmarks

column type หมายเหตุ
id uuid PK
file_id uuid FK → files CASCADE
page_index integer NOT NULL
user_id text NOT NULL
title text NOT NULL
y_offset double precision NOT NULL % ของความสูงหน้า (0–100)
created_at timestamptz

Strict JSONB Union (export เป็น TypeScript types)

type Rect = { x: number; y: number; width: number; height: number };   // ทุกค่าเป็น %
type HighlightData = { text: string; color: string; rects: Rect[] };
type DrawData     = { strokeColor: string; strokeWidth: number; paths: { x: number; y: number }[] }; // % + strokeWidth เป็น % ของ width
type CommentData  = { position: { x: number; y: number }; message: string };  // %
type AnnotationData = HighlightData | DrawData | CommentData;   // เลือกตาม column `type` (discriminated โดย enum)

→ พร้อม DTO types (AnnotationDTO, BookmarkDTO) ที่ timestamp เป็น ISO string เพื่อให้ server → client → realtime payload ใช้ type เดียวกัน


4. Data Flow & Realtime Sync

Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT PostgreSQL
                        │ (เขียนสำเร็จ)
                        ▼
              publish ใน process (lib/annotate-events.ts)
                        │
                        ▼
        SSE /api/annotations/stream?fileId=<uuid> ──▶ Client B ได้ event ทันที
  • เขียน: ผ่าน Server Actions เท่านั้น (ต่อ DATABASE_URL ตรง ๆ) — และหลังเขียนสำเร็จทุกครั้ง ต้อง publish event ให้ lib/annotate-events.ts
  • อ่าน/ sync: useAnnotationRealtime hook เปิด EventSource ไปที่ SSE route /api/annotations/stream?fileId=<id> — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; EventSource ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง)
  • pub/sub เป็น in-memory ของ Next.js process เดียว (dev / next start ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน
  • Optimistic update ด้วย useOptimistic (React 18): ตอนผู้ใช้ปล่อยเมาส์ → startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) }) → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ rollback ให้เองหาก action ล้มเหลว (ไม่ต้องจัดการ id temp-* เอง) — client ส่ง id ของ optimistic row ให้ createAnnotation ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที ไม่เห็น duplicate ชั่วขณะ (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด)

5. โครงสร้างไฟล์ทั้งหมด

judes-note/
├── PLAN.md                          ← ไฟล์นี้
├── README.md                        ← วิธี setup (docker run postgres, env, drizzle push, seed, run)
├── package.json / tsconfig.json / next.config.mjs
├── tailwind.config.ts / postcss.config.js / drizzle.config.ts
├── .env.example
├── db/
│   ├── schema.ts                    ← ตาราง + Strict JSONB types + DTO types + mapper toDTO
│   └── index.ts                     ← postgres.js client + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
├── lib/
│   └── annotate-events.ts           ← pub/sub in-process (Server Actions publish → SSE route subscribe)
├── utils/
│   ├── coordinates.ts               ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก)
│   └── user.ts                      ← anonymous user identity (localStorage)
├── hooks/
│   └── useAnnotationRealtime.ts     ← EventSource ต่อ SSE stream + dedupe + expose setters
├── app/
│   ├── layout.tsx / globals.css     ← globals.css มี CSS ของ .textLayer ที่ pdfjs ต้องการ
│   ├── page.tsx                     ← รายการไฟล์ (server component, drizzle query)
│   ├── actions/annotations.ts       ← Server Actions: CRUD annotations + bookmarks (+ validation guards
│   │                                  + publish event หลังเขียนสำเร็จทุกครั้ง)
│   ├── api/annotations/stream/route.ts  ← SSE endpoint (GET ?fileId=) ยิง event จาก lib/annotate-events
│   └── annotate/[id]/page.tsx       ← โหลด file + initial data → ส่งเข้า <PDFViewer/>
├── components/
│   ├── pdf/
│   │   ├── PDFViewer.tsx            ← client root: โหลดเอกสาร, tool state, zoom, selection→highlight,
│   │   │                               bookmark, comment composer, sidebar layout, Page Virtualization
│   │   │                               (IntersectionObserver render เฉพาะหน้าใน viewport ± 1 หน้า)
│   │   ├── InkLayer.tsx             ← SVG freehand (pointer capture, live stroke, ResizeObserver re-render)
│   │   └── HighlightToolbar.tsx     ← Floating popup เลือกสี (fixed ตำแหน่งจาก selection rect)
│   └── sidebar/
│       └── AnnotationSidebar.tsx    ← แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ลบรายการตัวเอง

6. พฤติกรรมหลักของแต่ละ Feature

6.1 Text Selection → Highlight

  1. Tool select → คลุมดำบน TextLayer (span โปร่งใส + ::selection)
  2. mouseup → window.getSelection() → range.getClientRects() → กรอง rect กว้าง 0 → mergeRectsByLine: รวบ rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) เหลือ 1 rect ต่อบรรทัด กัน JSONB บวม → แปลงเป็น % เทียบ pageEl.getBoundingClientRect()
  3. เด้ง HighlightToolbar (position: fixed ที่มุม rect แรก) เลือกสี 5 สี
  4. กดสี → createAnnotation('HIGHLIGHT', { text, color, rects }) + optimistic
  5. Render: div สีทึบนิดหน่อย + mix-blend-mode: multiply ที่ z-2 (อยู่หลัง text เสมอ)

6.2 Freehand Drawing (InkLayer)

  1. Tool draw → svg รับ pointerdown → setPointerCapture (เส้นไม่ขาดเวลาเลื่อนออกนอก page)
  2. เก็บจุดทั้ง px (render) และ % (save) — สร้าง path M x y L ...
  3. pointerup → ถ้าจุด ≥ 2 → createAnnotation('DRAW', { strokeColor, strokeWidth, paths })
  4. Path เดิมจาก DB แปลง % → px ด้วยขนาดที่ ResizeObserver วัด → re-render เมื่อ resize/zoom

6.3 Comment (Sticky Note)

  1. Tool comment → คลิกตำแหน่งบนหน้า → เปิด composer เล็ก ๆ (fixed) ใส่ข้อความ
  2. บันทึก → วาด pin (icon MessageSquare) ที่ตำแหน่ง % → คลิก pin เพื่อดู popover (ข้อความ + ผู้เขียน + เวลา)

6.4 Bookmark

  1. ปุ่ม BookmarkPlus บน toolbar → หา "หน้าปัจจุบัน" (page ที่ midpoint ใกล้ viewport center สุด จาก scroll listener)
  2. yOffset = ตำแหน่งกึ่งกลาง viewport เทียบหน้านั้น (% ของความสูงหน้า)
  3. Sidebar คลิก bookmark → window.scrollTo (smooth) ไปที่ pageTop + pageHeight × yOffset% (คำนวณ absolute ก่อนแล้ว smooth-scroll ครั้งเดียว ไม่ scroll ซ้อนสองจังหวะ)

7. ลำดับการ Implement (ตาม Task Output Order) + เกณฑ์ตรวจ

# งาน ไฟล์ Verify
1 Scaffolding + Schema package.json, config ทั้งหมด, db/schema.ts, db/index.ts tsc ผ่าน, drizzle-kit push สร้างตารางได้
2 Coordinate helpers utils/coordinates.ts, utils/user.ts Unit เช็คตาโค้ด: 1000px → 50% ที่ width 2000 (round-trip ต้องได้ค่าเดิม ± ε); mergeRectsByLine รวม rects หลายอันบรรทัดเดียวกัน → เหลือ 1 rect ครอบข้อความเดิม
3 Server Actions app/actions/annotations.ts เรียก action สร้าง/ลบ annotation แล้ว row ใน DB เปลี่ยนตาม; input ผิด type โดน reject
4 Realtime hook hooks/useAnnotationRealtime.ts เปิด 2 browser แท็บ — แท็บ A highlight แล้วแท็บ B เห็นภายใน ~1s โดยไม่ refresh
5 UI หลัก globals.css, layout.tsx, page.tsx, annotate/[id]/page.tsx, PDFViewer.tsx, InkLayer.tsx, HighlightToolbar.tsx, AnnotationSidebar.tsx คลุมดำ→toolbar→สีติดหน้า / วาดเส้นลื่น ไม่เบี้ยวตอน zoom / bookmark กดแล้ว scroll ลื่น / pin comment แสดงถูกตำแหน่ง / PDF ยาว 100 หน้า เลื่อนลื่น (canvas ของหน้าที่พ้นจอถูก unmount)
6 README README.md ทำตามขั้นตอนจากศูนย์แล้วรัน npm run dev ได้

8. Edge Cases ที่ออกแบบไว้ล่วงหน้า

  • TextLayer ต้อง re-render ทุกครั้งที่ zoom (scale เปลี่ยน = viewport เปลี่ยน) → effect dep [doc, pageNumber, scale] + cancel render task ตอน unmount กัน memory leak
  • Canvas คมชัดบนจอ Retina → canvas.width × devicePixelRatio + transform: [dpr,0,0,dpr,0,0] แต่ style.width คง viewport px
  • Selection ข้ามหน้า → ยอมรับเฉพาะ rects ใน page เดียว (หา page จาก commonAncestorContainer.closest('[data-page]')) — สเปกเก็บ pageIndex ค่าเดียวต่อ annotation
  • SSE event ชน optimistic row → duplicate ชั่วขณะหายเองเมื่อ useOptimistic ถอด row + dedupe ด้วย id ใน hook (ดู §4)
  • Server component ส่ง Date ไม่ได้ตรง ๆ กับ realtime string → normalize เป็น ISO string DTO ทั้งระบบ
  • pdfjs บน SSR → dynamic import("pdfjs-dist") ภายใน useEffect เท่านั้น (ไม่ import top-level ใน module scope ที่ server evaluate ด้วย)
  • PDF ยาว (20–100+ หน้า): Page Virtualization → IntersectionObserver ใน PDFViewer render เฉพาะหน้าใน viewport ± 1 หน้า (canvas + TextLayer + InkLayer); หน้าที่พ้นจอเก็บเป็น placeholder div ขนาดจริง (วัดจาก pdfPage.getViewport() ทุกหน้าตอนโหลดครั้งเดียว — cost ถูก) เพื่อไม่ให้ scroll jump และให้ bookmark ยังคำนวณ absolute scroll ได้ แล้ว unmount layer ทั้งหมดของหน้านั้นคืน memory — annotation ของหน้าที่ยังไม่ render อยู่ใน state ตามปกติ จะแสดงเมื่อหน้าเข้าจอ
  • pdf.worker v4 เป็นไฟล์ .mjs → คัดลอก pdf.worker.min.mjs จาก node_modules/pdfjs-dist/build/ ไป public/ ด้วย postinstall script (version ตรงกับ lockfile เสมอ) และตั้ง pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs" — อย่าใช้ new URL(..., import.meta.url) ซึ่งมักชน Turbopack/webpack ใน Next 14

9. ขอบเขตที่ ไม่ ทำในเฟสนี้ (Out of Scope)

  • ระบบ login/roles (ใช้ anonymous identity)
  • แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — ข้อนี้ถูกขยายโดย docs/PLAN-FEAT.md F1: อนุญาตแก้ไข data (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน updateAnnotation
  • Undo/redo, export PDF ที่ฝัง annotation
  • แสดงรายชื่อ "ใครออนไลน์อยู่" (presence)

หากต้องการเพิ่มข้อใด กรุณาบอกก่อนเริ่ม implement