- app/uploads/[name]/route.ts: Next production (next start/standalone) อ่านรายชื่อ public/ เก็บใน Set ครั้งเดียวตอน startup เท่านั้น ไฟล์ที่ uploadFile เขียน ตอน runtime GET /uploads/* แล้ว 404 (dev ไม่ผ่าน branch นี้ = local ผ่าน Docker พัง) → เสิร์ฟผ่าน route handler แทน: path guard lib/server-pdf + whitelist ext + content-type จาก utils/file-kind + cache immutable - drizzle/0001: bookmarks.user_name (N6) + backfill ตาม PLAN-BOOKMARK.md — image ใช้ migrate.mjs จากไฟล์ SQL ไม่ใช่ drizzle push ของ dev ทำให้ schema ตกรุ่นจนหน้า annotate 500 ทั้งหน้า; แก้ schema.ts ครั้งต่อไปต้อง drizzle-kit generate ทุกครั้ง (กติกาใหม่ใน .claude/rules/data-layer.md) - docker/web.Dockerfile: mkdir public/uploads + chown nextjs — volume ที่ compose mount ทับได้ ownership จาก image (ไม่งั้น root เป็นเจ้าของ = EACCES) + .dockerignore กัน public/uploads ของเครื่อง dev ติดไปใน image - docker-compose.test.yml: compose ทดสอบบนเครื่อง (port 6704 + volume uploads) verify บน Docker จริง: migration apply บน DB เดิมได้, annotate 500→200, Playwright อัปโหลด JPG+PDF→เปิด viewer ได้ 7/7, tsc ผ่าน, vitest 120/120 Co-Authored-By: Claude Code <noreply@anthropic.com>
61 KiB
📋 Implementation Plan — Features ต่อยอด (Judge'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) - 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+ 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) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
- STAMP: จุดอยู่ใน bbox ของตรา (
annotationBBoxสูตรเดียวกับ render) + 6px — เช็คก่อนเส้น/รูปทรงเพราะตรา render อยู่ layer บน (TextboxStampLayer) ให้ตัวบนสุดชนะ
- 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 ทับจริง → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
- 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) - Bookmarks (N2) → PDF outline (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ
/Outlinesdict เอง (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 เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์ - ตอบ
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,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→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 เป็นดำทึบทับข้อความจริง, 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ต้อง 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 ทุกครั้ง — ชื่อ (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 ใช้พิกัด % +pageIndex0-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(unionpdf|image) ในPdfPage— รูป:<img>ที่ z-1pointer-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 กว้าง-สูง +drawImagerotate) — 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.mjssync ทุก 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) → insertfiles (title, pdf_url)→revalidatePath("/")— ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE) - next.config:
experimental.serverActions.bodySizeLimit = "100mb"(default 1MB ไม่พอสำหรับ PDF/สแกนศาล) - เสิร์ฟไฟล์อัปโหลดผ่าน
app/uploads/[name]/route.ts(GET — อ่านpublic/uploads/<uuid>.<ext>+ path guard ของlib/server-pdf.ts+ content-type จากutils/file-kind.ts): Next.js production (next start/standalone) ไล่รายชื่อไฟล์ในpublic/เก็บใน Set ครั้งเดียวตอน startup (router-utils/filesystem.jssetupFsCheck) — ไฟล์ที่ action เขียนตอน runtime GET ตรง ๆ แล้ว 404 (dev ไม่เป็น → อาการ "local ผ่าน Docker พัง"); ไฟล์ที่ bake ตอน build (pdf.worker/ฟอนต์/samples) ถูกเสิร์ฟโดย static path ก่อน route นี้อยู่แล้วตามลำดับ routing; PDF ใน viewer โหลดผ่าน/api/pdfอยู่แล้ว ตัวที่ต้องใช้ route นี้จริง = รูปภาพ (<img src={pdfUrl}>) - ความสัมพันธ์กับ 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) — comparatorbyPageOrderในAnnotationSidebar.tsxsort ตอน 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 ''(mirrorannotations— เดิมมีแค่user_idจนกรองตามผู้บันทึกแล้วไม่มีชื่อจะแสดง) —createBookmarkรับ+validateuserNameเพิ่ม; 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ในbookmarksprop — ห้ามปนกับ dropdown กรอง F4 ที่ derive จาก annotations / วันที่ from–to แบบtype="date"+ ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่นค้นได้ (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดงแทนต้นไม้ชั่วคราว (แถว: title + "หน้า N · ผู้บันทึก" + "ใน:<ชื่อแม่>" ถ้าเป็น sub + โน้ต clamp, คลิก =onGoToBookmark) จนกว่าจะกดปิด — input ทุกตัวonKeyDownstopPropagationกันชนคีย์ลัด 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