eCourt-note/docs/PLAN-FEAT.md

328 lines
60 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 ต่อยอด (Judge'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)
- F17 (B2) ภายหลังเพิ่ม `bold?/opacity?/align?/segments?/width?` ใน `TextboxData` — `position` เป็น**มุมซ้ายบนของกล่องเสมอ** (`align` จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ): กล่องกว้างคงที่เมื่อมี `width` (คลิกเดียวได้กล่อง default 30% ลากขอบซ้าย/ขวาปรับความกว้าง ข้อความ wrap ตาม ฟอนต์คงขนาด) ไม่มี `width` = shrink-to-fit หุ้มข้อความ (แถวเดิม) — ความกว้างสูงสุดคำนวณด้วย `textboxMaxWidthPct` (cap 50% ของหน้าและบีบตามพื้นที่จริงถึงขอบขวา — กันข้อความล้นกระดาษ) ใช้สูตรเดียวกันทั้ง render จอ / bbox กรอบ selection / export PDF
### 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) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
- STAMP: จุดอยู่ใน bbox ของตรา (`annotationBBox` สูตรเดียวกับ render) + 6px — เช็ค**ก่อน**เส้น/รูปทรงเพราะตรา render อยู่ layer บน (TextboxStampLayer) ให้ตัวบนสุดชนะ
- โดน = `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 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
4. COMMENT = **sticky note จริงของ PDF** (annotation dict `/Subtype /Text` — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): `/Contents` = message + replies ต่อท้ายทีละบรรทัด (`— ชื่อ: ข้อความ`, F9), `/T` = ผู้เขียน, `/C` สีเหลืองเดียวกับหมุดบนจอ, `/M` เวลาแก้ไขล่าสุด, `/F 4` (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ `note` ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก `/Contents`, Acrobat คลิกเปิด popup)
5. Bookmarks (N2) → **PDF outline** (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ `/Outlines` dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม `parent_id` เรียงตาม `createdAt` เหมือน sidebar, `/Title` เป็น PDFHexString (ภาษาไทยได้), `/Dest [pageRef /XYZ x y null]` จาก pageIndex + yOffset (แปลงผ่าน `displayToPdfPoint`) ใช้ตำแหน่ง yOffset **เป๊ะ ๆ** — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน `scrollToBelowHeader` แบบเดียวกับ `goToAnnotation`) ซึ่งตรงกับพฤติกรรม `/XYZ` ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์
6. ตอบ `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 เป็นดำทึบทับข้อความจริง, COMMENT เป็น sticky note `/Text` เปิด popup อ่านได้ (message+replies), bookmarks โผล่เป็น outline แผง Bookmarks ของ reader; **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 ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ) + บรรทัดบอก**จุดที่จะชี้** (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`) ไม่มีข้อความที่คลุม = "หน้า N"
- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N")
- **แก้ไข (✏ เจ้าของเท่านั้น):** 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 บรรทัด), กดชื่อ = กระโดดหน้า (หัวข้อใหญ่ชี้หน้าของตัวเองได้), เรียงตามลำดับหน้า (N5)
- **Realtime:** SSE event ของ bookmarks เพิ่ม action **update** (แก้ชื่อ/โน้ต/ย้าย + ลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — client replace by id
- **Out of scope ของ N2:** drag เพื่อจัดลำดับ/ย้าย, sub ลึกเกิน 3 ระดับ, bookmark ในหน้าสรุป/CSV
### N3 · รองรับไฟล์รูปภาพ (jpg/jpeg/png/tif) เป็นเอกสารที่ annotate ได้เหมือน PDF (2026-09-15)
> แผนงานฉบับเต็ม: `docs/PLAN-IMAGE.md` — requirement 5.3.4
- **ข้อมูล (ไม่แตะ schema):** `files.pdf_url` เก็บ path รูปใน `public/` ได้เหมือน PDF — ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` (`fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl`) ใช้ร่วม viewer/export/หน้ารายการ/seed — annotation/bookmark ใช้พิกัด % + `pageIndex` 0-based เหมือนเดิม (**TIFF หลายเฟรม = หลายหน้า** เหมือน PDF หลายหน้า) — invariant #1/#2/#10 ครอบคลุมรูปอัตโนมัติ
- **การโหลด (viewer):** jpg/png โหลดจาก **static `pdfUrl` ตรง ๆ** (ไม่ผ่าน `/api/pdf` — ไม่มี ToUnicode ให้ซ่อม) ด้วย `new Image()` + `decode()` ขนาดจาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว); TIFF (`utils/tiff-client.ts`): fetch bytes → `utif2` (**dynamic import เฉพาะ tif** ตาม pattern invariant #6) decode ทุก IFD → `toRGBA8` → canvas (หมุนตาม orientation tag 274, cap ด้านยาว 4096px กัน memory) → PNG blob URL ต่อเฟรม — **revoke ทุก blob URL ตอน unload/สลับไฟล์**; ทุกหน้ารูป normalize กว้าง **595** สัดส่วนเดิม (scale contract กับ export) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4
- **การแสดง:** prop `content: PageContent` (union `pdf` | `image`) ใน `PdfPage` — รูป: `<img>` ที่ z-1 `pointer-events:none` เหมือน canvas (invariant #3 — DOM order ไม่ขยับ, textLayer เป็น conditional render เฉพาะ pdf), ไม่มี TextLayer → เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง; **toolbar คงทุก tool รวม HIGHLIGHT** (คลุมข้อความบนรูปไม่ได้ = no-op มี hint อธิบาย); เฟรม TIFF ที่ decode พัง = placeholder เทาแต่เอาขนาดจาก IFD header → page count/scroll/bookmark/deep link เสถียร ไฟล์ไม่พังทั้งเล่ม
- **Export PDF (ต่อยอด F8):** branch ก่อน `PDFDocument.load` → `imageDocFromBytes` (`utils/image-to-pdf.ts`): `PDFDocument.create` + `embedJpg`/`embedPng` + `addPage([595, 595·h/w])` + `drawImage` เต็มหน้า (rotation=0) แล้ววาด annotation + bookmark outline ด้วย pipeline เดิมทั้งหมด; TIFF decode ทุกเฟรม → หมุน/mirror ครบทุก orientation 1–8 (`rotateRgba` — ตรงกับฝั่ง client) → PNG (`pngjs`) → embedPng (เฟรมพัง = หน้าขาวให้ pageIndex map ตรง); **JPEG EXIF orientation:** exact สำหรับ 3/6/8 (parse APP1 → swap กว้าง-สูง + `drawImage` rotate) — **mirror 2/4/5/7 เป็น known limitation** (pdf-lib วาด mirror ไม่ได้ และพบน้อยมากในไฟล์สแกน — มักเจอจากกล้องหน้ามือถือ: 5/7 ประมาณด้วยการหมุนของ 8/6, 2/4 แสดงตรง ๆ); `/api/pdf` เพิ่ม defensive branch คืน bytes ดิบ + content-type ถูกสำหรับรูป (กัน stray call เข้า `fixPdfActualText`)
- **Realtime:** ไม่มี event/schema ใหม่ — SSE ผูก `fileId` เหมือนเดิม
- **หน้ารายการ/seed:** หน้า `/` แสดงไอคอน + badge ชนิด (PDF/JPG/PNG/TIFF); `scripts/seed-samples.mjs` sync ทุก extension ใน `SAMPLE_FILE_EXTENSIONS`
- **Out of scope ของ N3:** รูปแบบอื่น (gif/webp/bmp/heic), หมุน/ครอปรูปใน viewer, OCR, TIFF CCITT G3/G4 + JPEG-in-TIFF (เฟรมพัง = placeholder เทา), แปลงหรือเขียนไฟล์ต้นฉบับเด็ดขาด
### N4 · อัปโหลดไฟล์แบบง่าย (2026-09-15)
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url)` → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
- **next.config:** `experimental.serverActions.bodySizeLimit = "100mb"` (default 1MB ไม่พอสำหรับ PDF/สแกนศาล)
- **ความสัมพันธ์กับ seed:** seed-samples จัดการเฉพาะ `/samples/%` — ไฟล์อัปโหลดใน `/uploads/` ไม่ถูก seed ลบ
- **Out of scope ของ N4:** หน้าจัดการ/ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file พร้อมกัน, auth ผูกเจ้าของไฟล์, สิทธิ์ต่อไฟล์
### N5 · เรียง bookmark ตามลำดับหน้า (2026-09-16)
> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK.md` — requirement 5.3.11
- **การเรียง:** ต้นไม้ bookmark ใน sidebar (root และ sub ทุกระดับ) เรียงตาม **หน้า (`pageIndex`) → ตำแหน่งบนหน้า (`yOffset` บน→ล่าง) → เวลาสร้าง** (tiebreak ให้ deterministic) — comparator `byPageOrder` ใน `AnnotationSidebar.tsx` sort **ตอน render ฝั่ง client เท่านั้น** (realtime แทรก row ต่อท้าย state — เชื่อลำดับจาก server query อย่างเดียวไม่ได้); ไม่มีปุ่มสลับโหมด
- **ไม่แตะ:** query ฝั่ง server (ยัง `orderBy createdAt` — client sort ทับเสมอ), export outline, หน้าสรุป F15, CSV
- **Out of scope ของ N5:** ปุ่มสลับกลับไปเรียงตามเวลาสร้าง, drag จัดลำดับ (ของเดิมใน N2), แสดงชื่อผู้บันทึกในแถวของต้นไม้ปกติ
### N6 · ค้นหา bookmark พร้อมเงื่อนไข (2026-09-16)
> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK.md` — requirement 5.3.12
- **ข้อมูล:** ตาราง `bookmarks` เพิ่ม `user_name text NOT NULL DEFAULT ''` (mirror `annotations` — เดิมมีแค่ `user_id` จนกรองตามผู้บันทึกแล้วไม่มีชื่อจะแสดง) — `createBookmark` รับ+validate `userName` เพิ่ม; row เดิม backfill จาก `annotations` (จับคู่ `user_id`) ครั้งเดียวหลัง `/sync-db` ตาม SQL ในแผน; ค่า `''` = แสดง "ไม่ทราบชื่อ" ฝั่ง UI
- **Server Action `searchBookmarks`** (read-only — **ไม่ publish event ไม่ `revalidatePath`** เพราะไม่เขียน DB): คำค้น ILIKE บน `title`+`note` (escape `%`/`_`/`\` เหมือน F14) AND ช่วงหน้า (input **1-based** ตรงที่ผู้ใช้เห็น) AND ผู้บันทึก (`user_id`) AND ช่วงวันที่บันทึก (`YYYY-MM-DD` ตีความเป็นเขตเวลา **+07:00 คงที่** — ไทยไม่มี DST) — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง; คู่ช่วงกลับด้าน (from > to) = swap เงียบ ๆ; `orderBy pageIndex, yOffset, createdAt` ตรงลำดับการแสดงผลของ N5
- **UI:** block ค้นหา**เฉพาะแท็บ "ที่คั่นหน้า"** ใน sidebar (แยกจากช่อง F14 ที่อยู่เหนือแท็บ — ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน) — ช่องคำค้น + ปุ่มกางแถบเงื่อนไข (ช่วงหน้า from–to / ผู้บันทึก Select จาก unique `userId` ใน `bookmarks` prop — **ห้ามปนกับ dropdown กรอง F4** ที่ derive จาก annotations / วันที่ from–to แบบ `type="date"` + ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่น**ค้นได้** (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดง**แทนต้นไม้ชั่วคราว** (แถว: title + "หน้า N · ผู้บันทึก" + "ใน: `<ชื่อแม่>`" ถ้าเป็น sub + โน้ต clamp, คลิก = `onGoToBookmark`) จนกว่าจะกดปิด — input ทุกตัว `onKeyDown` `stopPropagation` กันชนคีย์ลัด H/D/C/B ของ viewer
- **Realtime:** ไม่มี event ใหม่ — `user_name` ไหลไปกับ DTO ของ create/update event เดิมอัตโนมัติ
- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV/รายงาน PDF (gap 5.3.15), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search