eCourt-note/docs/PLAN-FEAT.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 6fd3a1515f ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว
- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ
  จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้
  ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark
  ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes
  เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง
- ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin
  เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ)
  ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests)
- mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่
- Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath
  หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้
  RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
- คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ
  focus อยู่ใน input/textarea/select/contenteditable)
- STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ
  export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง
  selection (uniform scale + clamp รายแกน)
- sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00

286 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 📋 Implementation Plan — Features ต่อยอด (Jude's Note)
> แผนงาน implement ฟีเจอร์ต่อยอดจากเฟสแรกที่ implement ครบแล้วตาม [`docs/PLAN.md`](./PLAN.md) — ยึดสถาปัตยกรรมเดิมทั้งหมด: พิกัด %, JSONB discriminated union, เขียนผ่าน Server Actions + publish event, SSE sync, Non-Destructive
>
> ใช้งาน: `/implement-feat <F-number หรือ feature id เช่น A1>` (skill `.claude/skills/implement-feat`) — อ้างตาราง §4 ของไฟล์นี้
---
## 0. ข้อตกลง (Decisions)
| # | หัวข้อ | ข้อตกลง |
|---|---|---|
| D1 | Realtime | ต่อยอด SSE เดิม — เพิ่ม event variant (§2) ไม่เพิ่มระบบใหม่ |
| D2 | A4 Reply | ตารางใหม่ `annotation_replies` (FK → annotations CASCADE) **ไม่ใช้** `parentId` ในตารางเดิม — union/ตารางเดิมไม่ต้องแตะ และ query เธรดได้ตรง ๆ |
| D3 | B1 line/arrow | เก็บ `from`/`to` (จุด 2 จุด, % ของหน้า) แทน `rect` เฉย ๆ — ทิศทางลูกศร/ความเฉียงของเส้นต้องไม่หาย; ส่วน rect/ellipse ใช้ `rect` |
| D4 | B4 + C1 | ทำรวมเป็น **task เดียว (F8) เสมอ** — redaction ที่ไม่ติดไปกับไฟล์ export คือช่องโหว่การรั่วของข้อมูล |
| D5 | C1 ฟอนต์ไทย | pdf-lib วาดข้อความไทยต้อง embed ฟอนต์: `Sarabun-Regular.ttf` (SIL OFL) วางที่ `public/fonts/` — ถ้าไฟล์ไม่มี → COMMENT/STAMP/TEXTBOX ใน export วาดเป็นกรอบ/หมุดอย่างเดียว **ต้องไม่พัง** |
| D6 | A5 Resolve | ใครก็ได้ toggle (คลายคืนได้ ไม่ทำลายข้อมูล — ต่างจาก delete ที่จำกัดเจ้าของเหมือนเดิม) |
| D7 | B5 Signature | ทำเฉพาะ stamp preset (ข้อความสำเร็จรูป); ลายเซ็น = ใช้ tool DRAW วาด (upload รูปต้องมีที่เก็บไฟล์ → out of scope) |
| D8 | A7 Versions | `annotation_versions.annotation_id` เป็น uuid **ธรรมดา ไม่ FK** — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่; restore = สร้าง annotation ใหม่ **id ใหม่** |
| D9 | A1 ย้าย/resize | ในหน้าเดียวกันเท่านั้น (`pageIndex` ไม่เปลี่ยน — การข้ามหน้าเก็บ pageIndex เดี่ยวไม่ได้ตาม schema เฟสแรก); แก้ได้เฉพาะของตัวเอง (guard `userId` เหมือน delete) |
| D10 | A2 Undo/Redo | history ฝั่ง client ต่อ session ต่อผู้ใช้ — ไม่เก็บใน DB (A7 แยกต่างหาก) |
| D11 | C2 Import | คง `userId`/`userName` **ต้นฉบับ** ไว้ (เห็นที่มาว่าใครเขียน) — ผู้ import ลบรายการที่ไม่ใช่ของตัวเองไม่ได้ตามกติกาเดิม; id สุ่มใหม่ทั้งหมดกันชน PK |
---
## 1. Schema & Type Changes (รวมทุกเฟสไว้ที่เดียว)
ทุก task ที่แตะ `db/schema.ts` ต้องรัน `/sync-db` หลังแก้ และเติม validation ใน `validateAnnotationData` ครบทุก case ใหม่ (invariant #8 — ห้าม `any`)
### Enum `annotation_type` ขยายเป็น
```
HIGHLIGHT | DRAW | COMMENT | SHAPE | REDACTION | TEXTBOX | STAMP
```
### JSONB union เพิ่ม member (เติมใน `AnnotationData`)
```ts
// Point ต้อง export จาก db/schema.ts (ตอนนี้ type Point อยู่ใน utils/coordinates.ts — ย้ายมา schema แล้ว re-export)
type ShapeData =
| { shape: "rect" | "ellipse"; rect: Rect; strokeColor: string; strokeWidth: number }
| { shape: "line" | "arrow"; from: Point; to: Point; strokeColor: string; strokeWidth: number };
type RedactionData = { rect: Rect }; // ดำทึบ — ไม่มีสีให้เปลี่ยน
type TextboxData = { position: Point; text: string; color: string; fontSize: number }; // fontSize = % ของ width
type StampData = { label: string; position: Point; scale: number }; // scale = % ของ width
```
- `strokeWidth` ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2)
- E1 (F7) เพิ่ม field **optional** `widths?: number[]` ใน `DrawData` (ความหนาต่อจุด, % ของ width — ไว้รองรับ pressure)
### Column เพิ่มในตาราง `annotations` (F10)
| column | type | หมายเหตุ |
|---|---|---|
| resolved | boolean NOT NULL default false | |
| resolved_at | timestamptz NULL | |
### ตารางใหม่
**`annotation_replies`** (F9)
| column | type |
|---|---|
| id | uuid PK defaultRandom() |
| annotation_id | uuid FK → annotations ON DELETE CASCADE |
| user_id | text NOT NULL |
| user_name | text NOT NULL |
| message | text NOT NULL |
| created_at | timestamptz defaultNow() |
Index: `(annotation_id)`
**`annotation_versions`** (F19)
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK defaultRandom() | |
| annotation_id | uuid NOT NULL | **ไม่ FK** (D8) |
| file_id | uuid FK → files ON DELETE CASCADE | ลบไฟล์ → ประวัติหายไปด้วย |
| page_index | integer NOT NULL | หน้าของ annotation ตอน snapshot — `restoreVersion` ต้องรู้หน้าจึงสร้างใหม่ได้ (annotations.pageIndex เป็น NOT NULL) |
| user_id / user_name | text NOT NULL | เจ้าของ annotation ตอน snapshot |
| type | annotation_type NOT NULL | |
| data | jsonb NOT NULL `$type<AnnotationData>()` | snapshot ข้อมูลก่อนเปลี่ยน |
| kind | text NOT NULL `'update' \| 'delete'` | |
| created_at | timestamptz defaultNow() | |
Index: `(file_id)`
### DTO
- `AnnotationDTO` เพิ่ม `resolved: boolean` และ `resolvedAt: string | null`
- เพิ่ม `ReplyDTO`, `AnnotationVersionDTO` + mapper `toDTO` — timestamp เป็น ISO string ทั้งหมด (invariant #5)
---
## 2. Realtime Event Changes
`AnnotateEvent` (`lib/annotate-events.ts`) เพิ่ม variant:
```ts
| { table: "annotations"; action: "update"; row: AnnotationDTO }
| { table: "replies"; action: "create"; row: ReplyDTO }
| { table: "replies"; action: "delete"; id: string }
```
- `hooks/useAnnotationRealtime.ts`: `update` → **replace ตาม id** (idempotent — มาก่อน/หลัง optimistic ก็ได้ผลเดิม); เพิ่ม state `replies: ReplyDTO[]`
- `useOptimistic` ใน PDFViewer: `AnnotationAction` เพิ่ม `{ kind: "update"; row }` — ใช้ภายใน `startTransition` เหมือนเดิม (invariant #9)
- Server Actions ตัวใหม่ทุกตัวที่เขียน DB (update / resolve / reply / import / restore) ต้อง **publish หลังเขียนสำเร็จทุกครั้ง** (invariant #4)
---
## 3. สเปกฟีเจอร์ที่ต้องนิยามพฤติกรรมเพิ่ม
### F1 · A1 แก้ไข/ย้าย/ปรับขนาด
- **Server Action** `updateAnnotation(id, userId, data)` — โหลด row เดิม (เจ้าของเท่านั้น D9), ห้ามเปลี่ยน `type`/`pageIndex`, validate `data` ด้วย `validateAnnotationData` เดิม, update `data` + `updatedAt`, publish `{ action: "update", row }`
- **ไฟล์ใหม่** `utils/geometry.ts` (pure functions, ทดสอบได้): `annotationBBox(annotation, size)` / `translateAnnotationData(data, dxPct, dyPct)` / `scaleAnnotationData(data, anchorPct, sx, sy)` / `hitTestPath(points, point, thresholdPx, size)` (ใช้ซ้ำ F6)
- **การเลือก:** คลิกรายการใน sidebar (หรือคลิก comment pin) → state `selectedId` ใน PDFViewer → **`components/pdf/AnnotationSelectionLayer.tsx`** (ใหม่, z-4 เหมือน comment pins) วาดกรอบ bbox + handles:
- container `pointer-events: none`; **handle เท่านั้น** ที่ `pointer-events: auto` → ไม่แตะ invariant #3 (highlight-layer ยัง none เสมอ, textLayer ยัง select ข้อความได้)
- ลากตัวกรอบ = translate ทั้งกลุ่ม; ลากมุม = scale รอบ anchor มุมตรงข้าม (apply กับ rects ทุกอัน / points ทุกจุด / position)
- `pointerup` → คำนวณข้อมูล % ใหม่ → optimistic update + `updateAnnotation`
- **เปลี่ยนสี/ความหนา:** ปุ่มเล็กบนกรอบ selection → ใช้ `updateAnnotation` เดียวกัน
### F2 · A2 Undo/Redo
- **`hooks/useHistory.ts`** (ใหม่): stack ของ `{ undo(): Promise<void>; redo(): Promise<void> }` — push ทุกครั้งที่ create/delete/update **ของตัวเอง** สำเร็จ; action ใหม่ → เคลียร์ redo stack
- undo ของ create = delete(id); ของ delete = create ใหม่จาก data ที่เก็บไว้ (id ใหม่); ของ update = update กลับเป็น data ก่อนหน้า
- UI: ปุ่ม Undo/Redo บน toolbar (disabled ตาม stack) + Ctrl+Z / Ctrl+Shift+Z (จับคู่ F3)
- Edge: undo(create) แต่ row ถูกลบไปแล้วโดยแท็บอื่น → delete โดน reject → แสดง console + ข้าม (ไม่ crash)
### F5 · B1 รูปทรง
- Tool `shape` + sub-state ชนิดรูปทรง (`rect | ellipse | line | arrow`) — ปุ่มเลือกชนิดแสดงเมื่อ tool ใช้งาน
- InkLayer (SVG z-3 เดิม) รับหน้าที่เพิ่ม: pointerdown → ลาก → preview → pointerup → `createAnnotation("SHAPE", ...)` (rect/ellipse เก็บ bbox เป็น `rect`; line/arrow เก็บจุดเริ่ม/จุดปลายเป็น `from`/`to` — D3)
- Render รายการเดิม: `<rect>` / `<ellipse cx cy rx ry>` / `<line>` / arrow = `<line>` + หัวลูกศร `<path>` คำนวณจาก `from→to`
- Sidebar: รายการ SHAPE แสดง label ตามชนิด ("สี่เหลี่ยม", "วงรี", "เส้นตรง", "ลูกศร")
### F6 · B3 Eraser
- Tool `erase`: InkLayer active (รับ pointer, `touch-action: none`), `pointerdown/move` ทำ hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเองเท่านั้น** (ส่ง `userId` มากับ strokes):
- DRAW: ระยะจากจุดถึง segment ทุกท่อน ≤ `strokeWidth/2 + 6px`
- SHAPE: ระยะถึงเส้นขอบ (stroke) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
- โดน = `deleteAnnotation` ทันที (ลากผ่านหลายเส้น = ลบทีละเส้นระหว่างเคลื่อน) — เส้นของคนอื่นไม่ผ่าน hit-test จึงลบไม่ได้
### F8 · B4+C1 Redaction + Export PDF (ทำคู่กันเสมอ — D4)
- **REDACTION บนจอ:** วาดบน InkLayer เป็น `<rect>` fill `#000` (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape
- **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`:
1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal
2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270)
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → COMMENT หมุดวงกลม (+ข้อความถ้ามี Sarabun — D5) → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
4. ตอบ `application/pdf` + `Content-Disposition: attachment; filename="<title>-annotated.pdf"`
- ปุ่ม "Export PDF" บน toolbar → ลิงก์/`window.open` ไป route
### F9 · A4 Reply/Thread
- Actions `createReply({ annotationId, userId, userName, message })` / `deleteReply(id, userId)` (เจ้าของ reply เท่านั้น) + publish `table: "replies"`
- UI: popover ของ comment pin เพิ่มรายการ replies (ผู้เขียน + เวลา) + ช่องตอบ + ปุ่มลบ reply ของตัวเอง; sidebar แสดงจำนวน reply ต่อ comment
- ลบ comment แม่ → replies หายอัตโนมัติ (CASCADE)
### F10 · A5 Resolve
- Action `setAnnotationResolved(id, resolved)` — **ใครก็ได้** (D6), publish update event
- UI: ปุ่ม toggle ใน popover + pin ที่ resolved เปลี่ยนเป็นสีเขียว/จางลง; sidebar แยกกลุ่ม "ค้างดำเนินการ / จัดการแล้ว" (เฉพาะ COMMENT)
### F11 · C2 Export/Import JSON — **ถอดออกแล้ว**
> ถอดออกจากโค้ดแล้ว (2026-09): route `export-json`, action `importAnnotations`/`listFiles`/`ImportReport`, UI ใน sidebar, และ `countPdfPages` ใน `lib/server-pdf.ts` — สเปกด้านล่างเหลือไว้เป็นประวัติเท่านั้น
- **Export:** `app/api/annotations/export-json/route.ts` (GET `?fileId=`) → `{ file: { title, exportedAt }, annotations: AnnotationDTO[], bookmarks: BookmarkDTO[] }` (DTO ตรง ๆ — ISO string อยู่แล้ว)
- **Import:** Server Action `importAnnotations(targetFileId, payload, operatorUserId)` — ทุกแถว validate ด้วย validator เดิม, **สุ่ม id ใหม่ทุกแถว**, คง author เดิม (D11), แถวที่ผิด (malformed / pageIndex เกินจำนวนหน้าไฟล์เป้าหมาย) → skip พร้อมนับ แล้วคืนรายงาน `{ imported, skipped }`; publish create event ต่อแถวที่สำเร็จ — จำนวนหน้าไฟล์เป้าหมายอ่านฝั่ง server ด้วย `pdf-lib` จากไฟล์ใน `public/` (อ่านอย่างเดียว + path guard เดียวกับ export route §5); dialog เลือกไฟล์เป้าหมายใช้ read-only action `listFiles()`
- UI: ปุ่ม export ใน sidebar + ปุ่ม import เปิด dialog เลือกไฟล์เป้าหมาย (ไฟล์เดียวกัน หรือไฟล์อื่นจากรายการ) + `<input type="file">` อ่าน JSON ฝั่ง client แล้วส่งเข้า action
### F12 · C4 Deep Link
- URL: `/annotate/[id]?page=N` (1-based) และ/หรือ `?ann=<uuid>`
- PDFViewer effect แรก (หลัง `pageSizes` พร้อม) อ่าน **`window.location.search`** (ไม่ใช้ `useSearchParams` — เลี่ยงข้อจำกัด prerender ของ Next 14): scroll ไปตำแหน่ง absolute จาก placeholder (คำนวณครั้งเดียว smooth ครั้งเดียว เหมือน bookmark — rules/pdf-viewer.md); ถ้ามี `ann` → set `selectedId` ให้ด้วย
- ปุ่ม "คัดลอกลิงก์" ใน sidebar รายการ annotation (ใช้กับ F15 หน้าสรุปด้วย)
### F19 · A7 Version History
- `updateAnnotation` → snapshot row เดิมเป็น version `kind='update'` **ก่อนเขียนทับ** (transaction เดียวกัน); `deleteAnnotation` → snapshot `kind='delete'` ก่อนลบ (D8 ทำให้ version รอดจาก CASCADE)
- Action `listVersions(annotationId)` + `restoreVersion(versionId)` — **ถ้า annotation ต้นทางยังอยู่: เขียนข้อมูลของ version กลับ row เดิม** (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ → ย้อนกลับไป-มาได้) + publish update / **ถ้าต้นทางถูกลบแล้ว (kind='delete' รอดจาก CASCADE ตาม D8): สร้าง annotation ใหม่ id ใหม่** + publish create (เดิมสร้างสำเนาใหม่ทุกกรณีจนเกิดรายการซ้ำบนหน้า)
- UI: ปุ่ม "ประวัติ" ใน sidebar (เฉพาะรายการของตัวเอง) → dialog รายการ versions (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน
### ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §4)
- **F3 E2 คีย์ลัด:** keydown listener ที่ `window` ใน PDFViewer — `H/D/C` สลับ tool, `B` bookmark, `Ctrl+Z`/`Ctrl+Shift+Z` undo/redo, `Esc` ปิด popup/deselect, `DEL`/`Backspace` ลบรายการที่เลือก (ของตัวเองเท่านั้น); **ข้ามเมื่อ `e.target` เป็น input/textarea/contenteditable**
- **F4 A3 ซ่อน–แสดงรายคน:** state `userFilter: "all" | "mine" | <userId>` + dropdown รายชื่อ (จาก unique userId ใน annotations) — กรองทั้ง viewer และ sidebar ฝั่ง client ล้วน
- **F7 E1 สไตลัส/สัมผัส:** InkLayer ใช้ Pointer Events + `touch-none` อยู่แล้ว — เพิ่ม: pressure (`e.pressure` → `widths` per-point, render เป็น segment เมื่อมี widths), palm rejection (เมื่อเคยเห็น `pointerType === "pen"` → ละเว้น `touch` ในหน้านั้น)
- **F13 A6 แก้ไขข้อความ comment:** ปุ่ม "แก้ไข" ใน popover (เจ้าของเท่านั้น) → textarea → `updateAnnotation` (reuse F1)
- **F14 D1 ค้นหา:** Action `searchAnnotations(fileId, q)` — `ilike` บน `data->>'text'` และ `data->>'message'` (escape `%`/`_` ใน q) — คีย์ `text` ครอบทั้ง HIGHLIGHT และ TEXTBOX อัตโนมัติ (ชื่อฟิลด์ใน JSONB ตรงกัน); กล่องค้นหาบน sidebar → ผลลัพธ์คลิก → goTo + select
- **F15 D2 หน้าสรุป:** `app/annotate/[id]/summary/page.tsx` — server component รวมทุก annotation จัดกลุ่มตามหน้า (หน้า / ชนิด / ผู้เขียน / ข้อความ) + ปุ่มกระโดดเป็น deep link (F12); ทางเข้า = ปุ่มบน toolbar ของ viewer + ลิงก์กลับหน้าอ่านบนหน้าสรุปเอง; label/ข้อความย่อของแต่ละชนิดแชร์กับ sidebar ผ่าน `utils/annotation-display.ts`
- **F16 D3 Export CSV:** `app/api/annotations/export-csv/route.ts?fileId=` — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป
- **F17 B2 Text box:** tool `textbox` → คลิกบนหน้า → inline `<textarea>` วางบนตำแหน่งนั้น → บันทึก TEXTBOX; render เป็น div absolute (fontSize % → px); เจ้าของแก้ได้ (pattern F13); export วาดข้อความด้วย Sarabun (D5)
- **F18 B5 Stamp:** preset คงที่ในโค้ด (`STAMP_PRESETS` เช่น "ได้รับแล้ว", "สำเนาถูกต้อง") → tool `stamp` → คลิกวางที่ตำแหน่ง; render เป็นกรอบข้อความสไตล์ตรายาง (scale % → px); export วาดกรอบ+ข้อความ (D7)
---
## 4. ลำดับ Task + เกณฑ์ Verify (skill `/implement-feat` อ้างตารางนี้)
> ทำตามลำดับเลข F1→F19 (dependency จัดไว้แล้วในลำดับนี้) — ทุก task: `npx tsc --noEmit` ต้องผ่าน + เรียก agent `plan-reviewer`; verify แบบ realtime ใช้ 2 browser แท็บ; แตะ schema → `/sync-db`
| Task | FEAT | งานหลัก | ต้องมีก่อน | Verify เพิ่มเติม |
|---|---|---|---|---|
| F1 | A1 | `updateAnnotation` + update event + `utils/geometry.ts` + `AnnotationSelectionLayer` | – | ลากย้าย pin/เส้น/กรอบไฮไลต์แล้วตำแหน่งถูกต้อง **ทุก zoom และหลัง reload**; แก้ของคนอื่นถูกปฏิเสธ; แท็บ B เห็นการเปลี่ยนแปลงแบบ realtime |
| F2 | A2 | `hooks/useHistory.ts` + ปุ่ม Undo/Redo | F1 | undo วาด/ลบ/แก้ได้ย้อนลำดับ; redo; แท็บ B วาดเข้ามา → แท็บ A กด undo **ต้องไม่ทำอะไร** |
| F3 | E2 | คีย์ลัด H/D/C/B, Ctrl+Z/Ctrl+Shift+Z, Esc | F2 | พิมพ์อยู่ใน composer/textarea แล้วกดปุ่มตัวอักษรต้องไม่สลับ tool |
| F4 | A3 | filter รายคน (viewer + sidebar) | – | เลือกดูเฉพาะตัวเอง/รายคน/ทั้งหมดได้; กรองไม่แตะข้อมูลใน DB |
| F5 | B1 | enum+`ShapeData`+validation+InkLayer รูปทรง+tool `shape` | – | `/sync-db` สำเร็จ; วาด 4 ชนิดได้ครบ; สลับ zoom แล้วสัดส่วน/ตำแหน่งไม่เบี้ยว; แท็บ B เห็น realtime |
| F6 | B3 | tool `erase` + `hitTestPath` | F5 | ลากผ่านเส้น/รูปทรงของตัวเอง → หายทันที; ของคนอื่นไม่หาย; แท็บ B เห็นการลบ |
| F7 | E1 | pressure + palm rejection | F5 | `tsc` ผ่าน; **ค้างทดสอบด้วยมือ** บนอุปกรณ์สัมผัส/สไตลัส: วาดด้วยนิ้วได้ ไม่ scroll ระหว่างวาด, ปากกากับนิ้วพร้อมกันนิ้วไม่รบกวน |
| F8 | B4+C1 | enum REDACTION + export route (pdf-lib + fontkit + Sarabun) | – | `/sync-db`; วาด redaction บังข้อความบนจอ; export แล้วเปิดไฟล์ใหม่: ทุกชนิดติดไปถูกหน้าถูกตำแหน่ง, redaction เป็นดำทึบทับข้อความจริง; **hash ไฟล์ต้นฉบับใน `public/` ไม่เปลี่ยน**; ไม่มี Sarabun → export ยังสำเร็จ (fallback กรอบ/หมุด) |
| F9 | A4 | ตาราง `annotation_replies` + actions + popover เธรด | – | `/sync-db`; ตอบ comment เป็นเธรดได้; แท็บ B เห็น reply realtime; ลบแม่ → replies หาย (CASCADE) |
| F10 | A5 | columns `resolved`/`resolved_at` + action + UI จาง/แยกกลุ่ม | – | `/sync-db`; toggle แล้ว pin จาง/เขียว + sidebar แยกกลุ่ม; ใครก็ได้ toggle (D6); realtime |
| F12 | C4 | อ่าน `?page=`/`?ann=` ตอน mount | F1 | เปิด URL → เลื่อนไปหน้า/เลือก annotation นั้น; ไฟล์ 44 หน้ากระโดดหน้าไกลได้ไม่ jump (placeholder ขนาดจริง) |
| F13 | A6 | ปุ่มแก้ไขข้อความ comment ใน popover | F1 | เจ้าของแก้ได้; ของคนอื่นไม่มีปุ่มแก้; แท็บ B เห็นข้อความใหม่ realtime |
| F14 | D1 | `searchAnnotations` + กล่องค้นหา sidebar | – | หาคำไทยเจอทั้ง highlight text และ comment message; คลิกผลลัพธ์ → ไปตำแหน่ง + เลือก; ไม่พบแสดง "ไม่พบ" |
| F15 | D2 | หน้า `/annotate/[id]/summary` | F12 | แสดงทุก annotation จัดกลุ่มหน้า; ปุ่มกระโดด (deep link) ทำงานจริง |
| F16 | D3 | route export CSV | F15 | เปิด CSV ใน Excel ภาษาไทยอ่านออก (BOM) |
| F17 | B2 | enum TEXTBOX + tool + render + export | F8 | `/sync-db`; พิมพ์ข้อความลงหน้าได้; fontSize % → px ถูกทุก zoom; เจ้าของแก้ได้; ติดไปกับ export |
| F18 | B5 | enum STAMP + preset + tool + export | F8 | `/sync-db`; เลือก preset คลิกวางได้; scale % ถูกทุก zoom; ติดไปกับ export |
| F19 | A7 | ตาราง `annotation_versions` + snapshot + restore | F1 | `/sync-db`; แก้ 2 ครั้ง → ประวัติ 2 versions; ลบ → เกิด version kind delete และ**ไม่หาย**ไปกับ CASCADE; restore → กลับมาเป็น id ใหม่ + realtime |
Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 ← F12 · F16 ← F15 · F17/F18 ← F8 (ใช้ util วาดข้อความ/ฟอนต์จาก export)
---
## 5. Edge Cases ที่ออกแบบไว้ล่วงหน้า
- **Update ชน SSE:** replace by id เป็น idempotent — SSE `update` มาถึงก่อน/หลัง action เสร็จได้ผลเดียวกัน; ห้ามเพิ่ม logic กัน duplicate เกิน dedupe/replace ด้วย id (คงคู่กับ `useOptimistic` เหมือนเดิม)
- **Undo ข้ามผู้ใช้:** history เก็บเฉพาะ action ของตัวเอง — event realtime ของคนอื่นไม่ push เข้า stack; undo(create) ที่ row หายไปก่อน (คนอื่นลบ) → ข้ามพร้อม console
- **Eraser กับรูปทรง:** hit-test เฉพาะเส้น stroke/ขอบ — ผ่านพื้นที่รูปทรงไม่โดนลบ
- **Selection handles กับ tool ที่ InkLayer ใช้ pointer (F6):** เมื่อ tool เป็น draw/shape/erase ให้ปิด handle มุม/ขอบกรอบ/แถบเครื่องมือของ `AnnotationSelectionLayer` (pointer-events none กลับไปเหมือน container — เช่นเดียวกับ pins) — กันลาก eraser โดน strip ขอบกรอบแล้วกลายเป็น move-drag; ตัวกรอบ selection เป็น none อยู่แล้วจึงยังมองเห็นได้ปกติ
- **ปุ่มเปลี่ยนความหนา (F1) กับเส้นที่มี `widths` (F7):** ตอนเปลี่ยน `strokeWidth` หลักของเส้น DRAW ที่มี `widths` ต้อง scale `widths` ทุกจุดตามอัตราส่วน `w / strokeWidth` เดิมด้วย — ไม่เช่นนั้น render ใช้ widths ต่อจุดล้วนแล้วปุ่มดูเหมือนใช้ไม่ได้
- **Redaction บนจอ ≠ ปิดพรางจริง:** TextLayer ยัง select ข้อความใต้ redaction ได้ (PDF ต้นฉบับไม่ถูกแก่ — non-destructive) — ความปลอดภัยจริงอยู่ที่ไฟล์ export เท่านั้น (D4) → ใส่ hint สั้น ๆ ใน UI ตอนเลือก tool redaction
- **Export กับหน้า `/Rotate`:** แปลงพิกัด % ตามมุมหมุนก่อนวาด — ทดสอบด้วย fixture ที่ pdf-lib สร้างขึ้นเอง (หมุน 90°) เพราะไฟล์ตัวอย่างใน `public/samples/` ไม่มีหน้าหมุน
- **Path traversal ใน export route:** `pdfUrl` จาก DB ต้อง resolve แล้วอยู่ใน `public/` เท่านั้น
- **Deep link กับ virtualization:** placeholder มีขนาดจริงทุกหน้า → คำนวณ absolute scroll ได้เสมอแม้หน้ายังไม่ render (กลไกเดียวกับ bookmark)
- **คีย์ลัดกับฟอร์ม:** ตรวจ `e.target` เป็น input/textarea/contenteditable → ข้ามทุก shortcut
- **Font ไทยใน export:** ไฟล์ Sarabun ขนาดใหญ่ (~100KB+) embed ครั้งเดียวต่อเอกสาร (subset ไม่จำเป็นเฟสนี้)
- **SSE event ใหม่กับ hook เดิม:** deploy พร้อมกันทั้ง server+client (single app) — ไม่มีปัญหา version skew
- **TEXTBOX/STAMP geometry (F17/F18):** `position` คือมุมซ้ายบนของกล่อง; TEXTBOX render กว้างสุด 50% ของหน้า (wrap อัตโนมัติ), STAMP กว้าง = `scale`% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง (สีตรายาง/ข้อความคงที่แชร์ระหว่าง render กับ export ผ่าน `utils/annotation-display.ts`); translate/scale ของ F1 แตะเฉพาะ `position` — `fontSize`/`scale` คงเดิมตามแนวทาง "strokeWidth คงเดิม" ของ F1 (ลากมุม = เลื่อน position เท่านั้น); bbox ของ TEXTBOX ประมาณจากข้อความ (อักษรกว้าง ~0.6 × fontSize, wrap ที่ 50% ของหน้า — ค่าประมาณเพื่อกรอบ selection พอ ไม่ต้องตรงเป๊ะ), bbox ของ STAMP คำนวณจากสูตร render จริง; eraser (F6) hit-test เฉพาะ DRAW/SHAPE อยู่แล้วจึงไม่ลบ TEXTBOX/STAMP
- **Version snapshot (F19):** เก็บ `page_index` ของ annotation ตอน snapshot — `restoreVersion` เขียนกลับ row เดิม (หรือสร้างใหม่ **หน้าเดิม เจ้าของเดิม** เมื่อต้นทางถูกลบไปแล้ว) จากข้อมูลใน snapshot; ปุ่ม "ประวัติ" แสดงเฉพาะรายการของตัวเองฝั่ง UI ตามสเปก (action `restoreVersion(versionId)` ไม่มี guard userId ตาม signature ในแผน)
- **Dialog (fixed) ที่ render ใน sidebar (F19):** `aside.sticky` ของ sidebar สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนปุ่มใน dialog คลิกไม่ได้ (พบตอน verify ด้วยเบราว์เซอร์จริง) → dialog ต้อง render ผ่าน **`createPortal` ไป `document.body`** พร้อม guard `typeof document === "undefined"` เพราะ Next ก็ SSR client component (เข้าถึง `document` ตอน SSR = 500)
- **F19 กู้คืนรายการที่ถูกลบ:** action รองรับ (version `kind='delete'` รอดจาก CASCADE ตาม D8) แต่ UI เข้าถึง dialog ได้เฉพาะจากปุ่ม "ประวัติ" ของรายการที่**ยังอยู่**ใน sidebar — รายการที่ถูกลบไปแล้วจึงยังไม่มีทางเข้าถึงจาก UI (ทำตามสเปก §3 F19) หากต้องการกู้คืนของที่ถูกลบ ให้เปิดงานต่อยอดใหม่
---
## 6. Invariant & Rules Sync (ทำพร้อม task — ตามกติกา CLAUDE.md: แก้ invariant ที่แผนก่อน แล้ว sync มา rules)
| Task | ไฟล์ที่ต้องอัปเดตพร้อมงาน |
|---|---|
| F1 | `CLAUDE.md` (Out of Scope: edit/ย้าย อนุญาตตามแผนนี้) · `docs/PLAN.md` §9 (หมายเหตุถูกขยายโดยแผนนี้) · `rules/realtime.md` (update event) · `rules/coordinates.md` (geometry utils) |
| F2 | `CLAUDE.md` (Out of Scope: undo/redo อนุญาต) |
| F5 / F8 / F17 / F18 | `rules/data-layer.md` (enum + union member ใหม่) · F8 เพิ่ม: `CLAUDE.md` invariant #1 หมายเหตุ "export สร้างไฟล์ใหม่ ไม่แตะต้นฉบับ" + Out of Scope export อนุญาต |
| F9 / F10 / F19 | `rules/data-layer.md` (ตาราง/columns ใหม่) · `rules/realtime.md` (replies event + resolved ใน update row) |
| F12 | `rules/pdf-viewer.md` (deep link อ่าน query ตอน mount) |
> แก้/เพิ่ม invariant ที่ไม่ได้ครอบไว้ในตารางนี้ → อัปเดต **ไฟล์นี้** ก่อนเสมอ แล้วค่อยแตะโค้ด/rules
---
## 7. ฟีเจอร์เสริมหลังแผนเดิม (request นอก F1–F19)
### N1 · โน้ตคำอธิบาย (note) ฝังใน annotation — เช่น ลากเส้นแล้ว บอกเหตุผลที่วาดเส้นนั้น
- **ข้อมูล:** field `note?: string` ใน JSONB `data` ของทุกชนิด**ยกเว้น COMMENT** (`HighlightData`/`DrawData`/`ShapeData` ทั้ง 2 variant/`RedactionData`/`TextboxData`/`StampData`) — COMMENT ไม่มีเพราะ `message` ทำหน้าที่นี้อยู่แล้ว — validator (`vNote`) trim ค่า ว่าง/ช่องว่างล้วน = ไม่มีโน้ต (ถูกตัดออกจาก object ก่อนเก็บ)
- **แก้ไข:** ใน sidebar รายการหมายเหตุ — ปุ่ม StickyNote ของรายการตัวเอง (ยกเว้น COMMENT) เปิด textarea inline ใต้แถว (Ctrl/Cmd+Enter บันทึก, Esc ยกเลิก) → ผ่าน `updateAnnotation` เดิม (optimistic + version snapshot F19 + undo/redo F19/F2 อัตโนมัติ) เห็นการเปลี่ยนแปลง realtime ผ่าน SSE update event
- **แสดงผล (ทุกคนเห็น):** sidebar (ใต้รายการ, clamp 2 บรรทัด) · หน้าสรุป F15 (บรรทัด "โน้ต: …") · export CSV F16 (คอลัมน์ "โน้ต") — อ่านค่าผ่าน `annotationNote(a)` ใน `utils/annotation-display.ts` (ใช้ร่วมทั้ง 3 ที่ตาม pattern เดิม)
- **ค้นหา (F14):** `searchAnnotations` เพิ่มเงื่อนไข `data->>'note' ILIKE` ควบคู่ text/message — ค้นคำในโน้ตเจอด้วย
- **Tooltip บนหน้า:** ชี้ mouse เหนือ annotation ใด ๆ บนหน้า (ยกเว้น COMMENT ที่มี popover ของ pin) → tooltip "ชนิด · ชื่อผู้ทำ + โน้ต (ถ้ามี)" ลอยข้าง bbox — ตรวจด้วย `hitTestAnnotation` ชุดเดียวกับการคลิกเลือกบน `onMouseMove` ของ page wrapper, เก็บ state เป็น id เดียว (`hoveredId`) เปลี่ยนเฉพาะเมื่อรายการเปลี่ยน (ไม่ re-render ทุก move, hit-test เดินจากท้าย array โดยไม่สร้าง array ใหม่), `pointer-events: none` + ปิดช่วง tool ที่รับ pointer (draw/shape/erase/redact/pan), ช่วงกดปุ่มเมาส์ค้าง (`e.buttons !== 0`) และช่วง popover ของ comment pin เปิดอยู่ (`openPinId !== null` — กัน tooltip ลอยทับ popover)
- **ประวัติแก้ไขล่าสุด (ถอดกลับ):** เคยทดลองเป็นเธรด reply บนรายการที่เลือก (reuse `annotation_replies` + ปุ่ม 💬) แล้วถูกถอดออกตาม feedback — ใช้ field `note` ฝังใน annotation แทน
### N2 · Bookmark แบบหมุดหัวข้อใหญ่ + sub-bookmark + โน้ต (โครงสร้าง 3 ระดับ)
- **ข้อมูล:** ตาราง `bookmarks` เพิ่ม `parent_id uuid NULL` (NULL = หมุดหัวข้อใหญ่ระดับบนสุด / มีค่า = sub ของ bookmark นั้น — **ไม่ทำ FK** เพราะลบแม่ต้อง re-parent ลูกเอง) + `note text NULL` (โน้ตย่อยของ bookmark) — จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH = 3`): create ต้องมี `depth(parent) < 3`, ย้ายต้องผ่าน `depth(parent) + subtreeHeight(ตัวเอง) ≤ 3` + ห้ามย้ายเข้า subtree ของตัวเอง (cycle) — **ทุก action ที่แตะความสัมพันธ์แม่–ลูกตรวจภายใน transaction พร้อม `.for("update")` ทุกแถวที่เดินผ่าน** (บรรพบุรุษ/ลูกหลาน — helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant`) เพื่อ serialize กัน race เกินความลึก/sub กำพร้า
- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (เว้นว่าง = "หน้า N") + โน้ต (ไม่บังคับ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`)
- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงเว้นว่าง)
- **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock)
- **การลบ:** ลบ **sub** = เหมือนเดิม · ลบ **หัวข้อที่มี sub** = sub ทั้งหมด**ขยับขึ้นระดับบนสุด** (ไม่มีข้อมูลหาย) — re-parent + delete ใน transaction เดียว + publish update event ต่อลูก
- **การแสดง (sidebar tab Bookmarks):** ต้นไม้ 3 ระดับ (indent ตามระดับ 1/2/3), หัวข้อมี chevron กาง/หุ่ม (state local ต่อ client) + จำนวน sub `(n)`, โน้ตแสดงใต้ชื่อ (clamp 2 บรรทัด), กดชื่อ = กระโดดหน้า (หัวข้อใหญ่ชี้หน้าของตัวเองได้), เรียงตาม createdAt
- **Realtime:** SSE event ของ bookmarks เพิ่ม action **update** (แก้ชื่อ/โน้ต/ย้าย + ลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — client replace by id
- **Out of scope ของ N2:** drag เพื่อจัดลำดับ/ย้าย, sub ลึกเกิน 3 ระดับ, bookmark ในหน้าสรุป/CSV