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>
44 KiB
📋 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+ mappertoDTO— 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 ก็ได้ผลเดิม); เพิ่ม statereplies: 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, validatedataด้วยvalidateAnnotationDataเดิม, updatedata+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
- container
- เปลี่ยนสี/ความหนา: ปุ่มเล็กบนกรอบ 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) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
- DRAW: ระยะจากจุดถึง segment ทุกท่อน ≤
- โดน =
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:- อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก
path.join(process.cwd(), "public", pdfUrl)— อ่านอย่างเดียว ห้ามเขียนทับ (invariant #1) + guard path ต้อง resolve อยู่ในpublic/กัน path traversal - โหลด annotations ทั้งหมด (ทุก user) → วาดลง
PDFDocumentต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (page.getSize()+ แปลงตามpage.getRotation()กรณี /Rotate 90/180/270) - ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION ดำทึบ #000 ทับจริง → COMMENT หมุดวงกลม (+ข้อความถ้ามี Sarabun — D5) → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
- ตอบ
application/pdf+Content-Disposition: attachment; filename="<title>-annotated.pdf"
- อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก
- ปุ่ม "Export PDF" บน toolbar → ลิงก์/
window.openไป route
F9 · A4 Reply/Thread
- Actions
createReply({ annotationId, userId, userName, message })/deleteReply(id, userId)(เจ้าของ reply เท่านั้น) + publishtable: "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, actionimportAnnotations/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 actionlistFiles() - 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→ setselectedIdให้ด้วย - ปุ่ม "คัดลอกลิงก์" ใน sidebar รายการ annotation (ใช้กับ F15 หน้าสรุปด้วย)
F19 · A7 Version History
updateAnnotation→ snapshot row เดิมเป็น versionkind='update'ก่อนเขียนทับ (transaction เดียวกัน);deleteAnnotation→ snapshotkind='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,Bbookmark,Ctrl+Z/Ctrl+Shift+Zundo/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→widthsper-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เช่น "ได้รับแล้ว", "สำเนาถูกต้อง") → toolstamp→ คลิกวางที่ตำแหน่ง; render เป็นกรอบข้อความสไตล์ตรายาง (scale % → px); export วาดกรอบ+ข้อความ (D7)
4. ลำดับ Task + เกณฑ์ Verify (skill /implement-feat อ้างตารางนี้)
ทำตามลำดับเลข F1→F19 (dependency จัดไว้แล้วในลำดับนี้) — ทุก task:
npx tsc --noEmitต้องผ่าน + เรียก agentplan-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ต้อง scalewidthsทุกจุดตามอัตราส่วน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 ตามสเปก (actionrestoreVersion(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พร้อม guardtypeof 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ใน JSONBdataของทุกชนิดยกเว้น 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 — ใช้ fieldnoteฝังใน 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")ทุกแถวที่เดินผ่าน (บรรพบุรุษ/ลูกหลาน — helperlockedDepth/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