eCourt-note/.claude/rules/pdf-viewer.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 eddcec28c7 Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00

8.2 KiB
Raw Blame History

paths
components/pdf/**
app/annotate/**

PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8)

  • Layer stack ตายตัวตาม DOM order: <canvas> → div.highlight-layer (mix-blend-mode: multiply) → div.textLayer → <svg> InkLayer → div.comment-pins — textLayer ต้องอยู่หลัง highlight-layer เสมอ (text select ได้และ highlight ซ้อนหลัง text)
  • pointer-events: canvas และ highlight-layer = none เสมอ; textLayer/InkLayer สลับตาม tool ตามตาราง §1 (select → textLayer, draw → InkLayer, comment → จับ event ที่ page wrapper)
  • Canvas Retina: canvas.width = viewport.width × devicePixelRatio + render transform: [dpr,0,0,dpr,0,0] แต่ style.width คง viewport px
  • Canvas + TextLayer re-render เมื่อ dep [doc, pageNumber, scale] เปลี่ยน + cancel render task ตอน unmount (กัน memory leak)
  • Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (commonAncestorContainer.closest('[data-page]')) และกรอง rect กว้าง 0 ทิ้ง
  • Page Virtualization: IntersectionObserver render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder div ขนาดจริง (จาก pdfPage.getViewport() ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state
  • pdfjs: dynamic import("pdfjs-dist") ภายใน useEffect เท่านั้น; GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs" (ไฟล์คัดลอกไว้ที่ public/ ด้วย postinstall) — ห้าม new URL(..., import.meta.url)
  • Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
  • Deep link (F12/C4): PDFViewer อ่าน window.location.search ครั้งเดียวหลัง pageSizes พร้อม (guard ด้วย ref — ไม่ใช้ useSearchParams เลี่ยงข้อจำกัด prerender ของ Next 14) — ?page=N (1-based) scroll ไปหน้านั้น, ?ann=<uuid> scroll ไปหน้าของ annotation นั้น + setSelectedId — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป /annotate/<fileId>?page=<N>&ann=<id>
  • Dialog fixed z-50 ใน sidebar (ประวัติ) ต้อง render ผ่าน createPortal ไป document.body + guard typeof document === "undefined" ตอน SSR — aside.sticky สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ fixed z-50 แพ้ textLayer (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5)
  • DRAW วาดเส้นตรง: ปล่อยเมาส์แล้ว snap อัตโนมัติ เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (isNearlyStraight) — หรือกดค้าง Shift ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ widths จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand
  • Tool pan (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน <main> = window.scrollBy สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร overlayFrozen (inkActive || pan) ยกเว้น InkLayer ต้องไม่ active (ไม่งั้น pan กลายเป็นวาดเส้น)
  • HIGHLIGHT มี style? 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (squigglePath) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก tool "highlight" ทางเดียว (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน selectionchange + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด H สลับไป tool highlight
  • กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมีแผงเครื่องมือ (w-60 เหมือน dropdown ของ tool) ที่ใช้ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง (จาก components/pdf/ToolControls.tsx — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย ปุ่มลบ (onDelete) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — HIGHLIGHT ไม่มี handle ย้าย/ขยาย (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว)
  • คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = setSelectedId ผ่าน hitTestAnnotation (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ visibleAnnotations (เคารพ filter รายคน F4)
  • Tooltip hover (N1): onMouseMove ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → hoveredId state เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (pointer-events: none เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (draw/shape/erase/redact/pan), e.buttons !== 0 และตอน openPinId !== null (popover ของ pin เปิดอยู่)