eCourt-note/docs/PLAN-FEAT.md
Your Name df502088b7 groups+summary: redesign หน้า /groups/[typeId] + หน้าสรุปเป็น explorer 2 คอลัมน์ (ตัวกรองซ้าย + list เดียว) — หน้าสรุปใช้มิติแรก "หน้า" ตามแบบหน้ากลุ่มที่ใช้ "ไฟล์" — (1) หน้า /groups/[typeId]: ถอด navbar (เหลือ back link — AppNav เหลือใช้ที่หน้า / อย่างเดียว) + ถอดหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้", query ทั้งหมดฝั่ง server ส่ง GroupFileSummary[]+GroupListItem[] ให้ GroupExplorer กรองใน memory — ตัวกรอง tree menu 2 ระดับสไตล์ Quasar docs 4 กลุ่ม (ไฟล์/ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก — ชนิดรายการเป็น checkbox คู่ ไม่เลือก = แสดงทั้งหมด, ติ๊กชนิด → เลือก Annotation อัตโนมัติ, เลิกเลือก Annotation → ล้างชนิดค้าง) + GroupList แถว passive จุดคลิกเดียวคือปุ่ม "View" (2) แยกแผงตัวกรองเป็น component กลาง components/explorer/FacetFilterPanel.tsx — มิติแรกส่งเข้าทาง prop firstGroup (groups = ไฟล์ / สรุป = หน้า พร้อม optionsScroll ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ) + TYPE_SHORT/TYPE_ORDER รวมจาก 2 ที่ที่เคย define ซ้ำมาอยู่ utils/annotation-display.ts จุดเดียว (3) หน้าสรุป /annotate/[id]/summary: server ส่ง ExplorerItem[] (หน้า → bookmark ก่อน annotation → ในหน้าเดียวกัน bookmark เรียง yOffset ตาม N5/annotation ตาม createdAt) ให้ SummaryExplorer + SummaryList การ์ดรายหน้าหัวการ์ด sticky (เลขหน้า + บรรทัดสรุปหน้าคำนวณจากรายการที่เห็นจริงหลังกรอง + ปุ่มเปิดหน้านี้) + แถว passive ปุ่ม View deep link ?ann=/?page= เหมือนหน้ากลุ่ม — ถอดรายการตามหน้าจุดไข่ปลา + การ์ด folio + CollapsibleBlock ออก (anchor #page-N ของ section คงไว้ให้ลิงก์เก่ายังใช้ได้) (4) ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ (ตอนกรองอยู่ render ชุดบนจอ print:hidden + ชุดเต็ม hidden print:block, prop anchorIds กัน duplicate id) + กฎ print ใหม่ใน globals.css (section>ul>li break-inside:avoid-page + .page-head position:static) (5) ผ่าน code review 2026-09-24 (tsc/build/vitest 144 tests): คืน N5 yOffset ให้ bookmark ต่อหน้าที่ sort createdAt ทับไปโดยไม่ตั้งใจ + จดรับความเสี่ยง localeCompare "th" รันทั้ง SSR/hydration ไว้ที่ comment พร้อมทางแก้ + docs sync ครบ (CLAUDE.md โครงสร้างไฟล์ + PLAN-FEAT ถอดหมายเหตุ + PLAN-DOCSET อ้าง FacetFilterPanel) (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 17:34:23 +07:00

77 KiB
Raw Blame History

📋 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 + 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) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
    • 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) — เพิ่มพารามิเตอร์ &user=<userId> (2026-09-22): export ตาม filter รายคน F4 ที่กำลังดู ("mine" resolve เป็น userId ฝั่ง client ก่อนส่ง) — กรองเฉพาะ annotation ของ userId นั้น ยกเว้น REDACTION ติดไปเสมอ (D4) และเติมชื่อผู้ใช้ (จาก user_name ที่ mirror ในตาราง) ท้ายชื่อไฟล์ …-annotated-<ชื่อ>.pdf; bookmarks outline กรองตามผู้บันทึกด้วย (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่ ลูกขึ้น root
    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
  • 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 (เวลา + ชนิดการเปลี่ยนแปลง + ผู้แก้) + ปุ่มกู้คืน — แต่ละแถว kind='update' แสดงสรุปสิ่งที่เปลี่ยนผ่าน describeAnnotationChange (utils/annotation-display.ts — เพิ่มตามคำขอผู้ใช้ 2026-09-17): snapshot "ก่อนแก้" ของรอบนั้นเทียบกับ state ถัดไป (= snapshot ของรอบแก้ที่ใหม่กว่าถัดไป หรือค่าปัจจุบันของรายการสำหรับรอบล่าสุด) — สรุปเป็นบรรทัดสั้นต่อ field ที่เปลี่ยน (สีเป็นชื่อไทยจาก palette/ความหนา ×10/ความทึบ % ตามหน่วย UI, แก้ข้อความ/โน้ต, ย้ายตำแหน่ง/ปรับขนาดจาก bbox ทน noise 0.5%, DRAW ที่จุดเปลี่ยนแต่ bbox เดิม = "แก้เส้น" ของ eraser) ส่วนแถว kind='delete' เป็น snapshot สุดท้ายก่อนลบ ไม่ diff

ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §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" ตอนบันทึก) + โน้ต (ไม่บังคับ — ช่องชื่อ/โน้ตดักความยาวด้วย maxLength ตาม limit ของ server จาก utils/bookmark-limits.ts: ชื่อ 120 / โน้ต 200 ตัวอักษร ไม่รอ error ตอนบันทึก) + บรรทัดบอกจุดที่จะชี้ (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ 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)

เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): ช่อง "กลุ่ม typeId" ทางเลือกในฟอร์ม (ไม่กรอก = ไม่มีกลุ่ม) — ไฟล์อัปโหลดเข้ากลุ่ม N9 ได้โดยไม่ต้องผ่านลิงก์ e-Court; normalize เหมือน N8 (trim → ว่าง = NULL, cap 200 — normTypeId mirroring normGroup)

  • UI: ฟอร์มเดียวบนหน้า / (components/UploadFileForm.tsx) — <input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"> + ช่อง "กลุ่ม typeId" ทางเลือก (uncontrolled ส่งไปกับ FormData) + ปุ่มอัปโหลด (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, type_id) (typeId จากช่องกลุ่ม — ว่าง = NULL, insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีทางแก้กลุ่มไฟล์เดิม) → 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.js setupFsCheck) — ไฟล์ที่ 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) — 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 (ส่วนส่งออก bookmark ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search

N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17)

แผนงานฉบับเต็ม (ตอนเป็น PDF): docs/PLAN-BOOKMARK-PDF.md — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF

  • Route: app/api/bookmarks/export/route.ts (GET ?fileId=) — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1): validate fileId (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query bookmarks ทั้งไฟล์ orderBy asc(pageIndex), asc(yOffset), asc(createdAt) (ลำดับเดียวกับ sidebar/N5) → response JSON { fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] } — item เป็น toBookmarkDTO ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ parentId ครบ lossless reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = "bookmarks": [] (ไม่ 404 — ปุ่มทำงานเสมอ); content-type: application/json; charset=utf-8 + attachment + filename* จาก encodeRFC5987("{title}-bookmarks.json") (pattern F8/F16)
  • UI: ปุ่มไอคอน Braces ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — <a href="/api/bookmarks/export?fileId="> ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + coarse-36 — ส่งออกทุก bookmark ของไฟล์เสมอ (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
  • Read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB — เหมือน searchBookmarks/route export เดิม)
  • Out of scope ของ N7: export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม docs/PLAN-BOOKMARK-PDF.md), export ข้ามหลายไฟล์ (gap 5.3.10)

N8 · เปิดเอกสารจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= (2026-09-22 · implement แล้ว)

แผนงานฉบับเต็ม: docs/PLAN-ECOURT.md

  • สัญญาลิงก์: fileUrl จำเป็น + fileId แนะนำ (dedupe key external_id = "ecourt:<fileId>", ไม่มี fileId fallback "url:<href>") + typeId แนะนำ (รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน — ส่งต่อให้ N9 docs/PLAN-DOCSET.md จัดกลุ่มตอนแสดงผล)
  • ไม่เก็บสำเนาไฟล์ (metadata-only): ลง DB แค่ title (จาก basename ของ URL) + external_id + URL เต็มใน pdf_url + type_id — ตอนเปิดดู/export server ดึงไฟล์สดจาก e-Court ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) — invariant #1 ครบ
  • หลักเวอร์ชัน — id เปลี่ยน = ไฟล์ใหม่: fileId เดิม + URL เปลี่ยน (Temp link อายุใหม่) = row เดิม annotation ครบ + refresh pdf_url; fileId ใหม่ = row ใหม่เริ่ม blank ไม่สืบทอด; ลิงก์ส่ง typeId มา = ย้ายกลุ่มตามค่าล่าสุด (ไม่ส่ง = คงเดิม ห้ามเคลียร์)
  • Entry point 2 จุด: หน้า / และ /annotate อ่าน query แล้ว registerExternalFile (lib/external-import.ts — dedupe + catch 23505 พร้อมกัน 2 แท็บ) → redirect("/annotate/<uuid>") — เรียกระหว่าง render ของ server component ไม่ใช่ Server Action (carve-out invariant #4: หน้า force-dynamic + redirect ออกทันที ไม่ต้อง publish event/revalidatePath) / fail = กล่อง error ไทย role="alert" เหนือ section เพิ่มเอกสาร หน้ารายการยังแสดง
  • Validation (pure utils/external-file.ts): allowlist โฮสต์ admincourt.go.th (suffix match กัน admincourt.go.th.evil.com/xadmincourt.go.th) + env EXTERNAL_FILE_HOSTS เพิ่มได้, https: เท่านั้น, ห้าม user:pass@ — และรับ path ในเครื่อง (/samples/… /uploads/… — decode + ห้าม .. + ตรวจไฟล์มีจริง) เพื่อทดสอบ grouping ใน dev + เติม https:// ให้ URL ที่ไม่มี scheme
  • ดึงไฟล์สด (lib/remote-file.ts): fetchSourceBytes — validate ซ้ำชุดเต็มหลัง redirect (res.url), Content-Length pre-check + นับ byte ระหว่าง stream จำกัด 100MB, timeout 60s, guard body null/0 ไบต์ — ใช้ 2 จุด: /api/pdf และ /api/annotations/export (F8) ก่อนเข้า pipeline เดิม
  • Viewer: withBasePath ปล่อย absolute http(s):// ผ่านเฉย ๆ (invariant #12), PDF branch โหลดผ่าน /api/pdf?fileId= อยู่แล้ว, รูปรีโมต (N3) เปลี่ยนไปโหลดผ่าน /api/pdf?fileId= ด้วย (แยกทางด้วย isHttpUrl)
  • Login วนกลับ (D5): middleware.ts เดิมล้าง query ทิ้ง — แก้เป็นเก็บ next = pathname + search แนบไป /login (เฉพาะ next !== "/") → หน้า login sanitizeNextPath (เฉพาะขึ้นต้น / และไม่ใช่ ////\ — กัน open-redirect) → มี session แล้ว redirect(next ?? "/") / login ผ่าน LoginForm router.replace(next ?? "/") — deep link ของคนยังไม่ login ไม่หาย
  • การ์ดจำลองลิงก์: components/EcourtLinkForm.tsx บนหน้ารายการ / (กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" → router.push("/annotate?fileUrl=&fileId=&typeId=")) + แสดงลิงก์เต็มสด ๆ — ทดสอบก่อนมีลิงก์จริงจาก e-Court
  • Out of scope ของ N8: auth/credential ต่อ e-Court (ส่งต่อ token/cookie ถ้าปลายทางต้องมี session จริง), ระบบหลายเวอร์ชัน + สืบทอด annotation ข้ามเวอร์ชัน, หน้าจัดการ/ลบ/แก้ typeId ด้วยมือใน UI (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9), แคชสำเนาไฟล์รีโมตลงดิสก์, โฮสต์นอก allowlist, API ดึงข้อมูลกลับให้ e-Court (N10 docs/PLAN-API.md) — known risk ที่รับไว้: /api/pdf + export เป็น proxy ไม่ต้อง login (รู้ fileId = ดึง bytes ได้)

N9 · กลุ่มเอกสารตาม typeId จาก e-Court + หน้า /groups/[typeId] (2026-09-23)

แผนงานฉบับเต็ม: docs/PLAN-DOCSET.md — ต่อยอด type_id ที่ N8 เขียนตอน registerExternalFile

  • กลุ่มระดับเดียว กำหนดด้วย files.type_id (จากลิงก์ e-Court ?typeId= — เขียนที่ registerExternalFile ของ N8 เท่านั้น: insert ครั้งแรก + ย้ายกลุ่มเมื่อลิงก์รอบใหม่ส่ง typeId คนละค่า, ไม่ส่ง = คงเดิม ห้ามเคลียร์) — ไม่มีตารางกลุ่ม/many-to-many/หลายระดับ: กลุ่ม = ค่า distinct ของ type_id ณ ตอน query, แสดงผลด้วยรหัสกลุ่มโดยตรง (ไม่มีคอลัมน์ชื่อกลุ่ม — ถอด type_name ตามคำขอผู้ใช้) — ไฟล์ seed/อัปโหลด N4/ลิงก์ไม่มี typeId = NULL เสมอ
  • หน้ารายการ / จัดกลุ่ม: section ต่อกลุ่ม (type_id ไม่ NULL) เรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง createdAt asc เหมือนเดิม + ปุ่ม Annotate เดิม; หัวกลุ่ม = รหัสกลุ่ม + จำนวนไฟล์ + ลิงก์ "เอกสารในกลุ่ม" → /groups/{encodeURIComponent(typeId)}; ไฟล์ type_id NULL → section "ไม่มีกลุ่ม" ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร — แสดงเสมอถ้ามีไฟล์) — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม ไม่มี client component/action ใหม่
  • หน้า /groups/[typeId] (server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว): query files where type_id orderBy createdAt — ไม่มี = 404 (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ / + กล่อง info "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (ถอดออกตามคำขอผู้ใช้ 2026-09-24); รายการ = ชื่อไฟล์ + วันที่นำเข้า (formatThaiDate) + จำนวน annotation/bookmark + ปุ่ม Annotate → /annotate/{fileId}; การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (คำขอผู้ใช้ 2026-09-23 — select รายการเต็ม 2 query แทนการ groupBy นับอย่างเดียว, รวมเป็นรายการเดียวเรียงตามหน้า หน้าเดียวกันให้ที่คั่นหน้าก่อน, แต่ละแถว = deep link /annotate/{fileId}?ann=… / ?page=… เดียวกับหน้าสรุป F12 + วันเวลาที่สร้าง (formatThaiDate withTime) ในบรรทัด meta, ใช้ <details> ไม่มี client component — ปิด gap 5.3.10 ครึ่งหลัง "bookmark รวมทั้งกลุ่ม"); generateMetadata ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
  • ไม่มี Server Action/SSE/migration ใหม่ (schema type_id + index มากับ N8 ใน migration เดียวกัน) — การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก / ทันที ไม่มี Router Cache ค้าง
  • Out of scope ของ N9: จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI, ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ, export JSON ตามกลุ่ม (N7 ยังรับ fileId เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (N10 docs/PLAN-API.md)