# 📋 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](https://judgement-api.admincourt.go.th/v1/media/uploads/CaseFile/view/01012-672427-4f-690609-0000840227.pdf) | 15 | ~1.8 MB | **สแกนภาพล้วน** — ไม่มี text layer | | `01012-672427-1f-690609-0000840223.pdf` | [judgement-api.admincourt.go.th](https://judgement-api.admincourt.go.th/v1/media/uploads/CaseFile/view/01012-672427-1f-690609-0000840223.pdf) | 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 = หนึ่ง `
` ที่ซ้อน layer ทั้งหมดไว้ใน viewport เดียวกัน **ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไข** (Non-Destructive) — annotation เก็บใน PostgreSQL เป็นพิกัด % เท่านั้น ``` ┌─ div.relative[data-page=N] (ขนาด = viewport.width × viewport.height) │ ├── z-1 ← 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 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` แล้ววาด `` ตรง ๆ (ไม่ต้องใช้ `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()` | **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) ```ts 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= ──▶ Client B ได้ event ทันที ``` - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` - **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=` — 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 → ส่งเข้า ├── 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