eCourt-note/docs/PLAN.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 eddcec28c7 Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00

24 KiB
Raw Blame History

📋 Implementation Plan — Jude'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-* เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่

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