eCourt-note/docs/PLAN-FEAT.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

44 KiB
Raw Blame History

📋 Implementation Plan — Features ต่อยอด (Jude's Note)

แผนงาน implement ฟีเจอร์ต่อยอดจากเฟสแรกที่ implement ครบแล้วตาม docs/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)

// 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:

| { 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
  • 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; ข้ามเมื่อ 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