eCourt-note/.claude/rules/pdf-viewer.md
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 b3b884c736
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
จอสัมผัส (iOS/Android): ไฮไลต์ commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection + ลาก handle กรอบ selection ได้จริง: (1) บน touch การลากคลุมข้อความไม่ยิง synthetic mouseup (ยิงเฉพาะ tap) ทำให้ HIGHLIGHT ไม่ถูกสร้างเลย — เพิ่มปุ่ม "ไฮไลต์" ลอยใต้บรรทัดสุดท้ายของ selection (แพทเทิร์น Acrobat mobile; ด้านบนถูกเมนู/handles ของ OS ยึด ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) แสดงเฉพาะ pointer:coarse + tool highlight เมื่อ selection หยุดนิ่ง ~400ms (debounce ใน effect selectionchange ข้ามช่วงลาก handles) (2) แตะปุ่ม commit จาก snapshot ใน touchSelRef ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่มก่อน tap จะถึง — การซ่อนปุ่ม+ล้าง snapshot จึงหน่วง 250ms (scheduleTouchHide) ให้ tap นั้น commit ได้ก่อน และผูก commit ที่ pointerdown (เร็วกว่า click); desktop ยังใช้ mouseup ตามเดิม — สอง path ผ่าน commitHighlight ชุดเดียวกัน + ตั้ง lastSelectionTextRef (ชื่อ bookmark default) ใน debounce เหมือน mouseup (3) เติม touch-none ที่ handle ทั้ง 8 ของ AnnotationSelectionLayer (ขอบ 4 + มุม 4 — InkLayer มีอยู่แล้ว) ไม่งั้น browser ยึด gesture เป็น scroll ส่ง pointercancel กลายเป็นเลื่อนหน้าแทนการลากย้าย/ปรับขนาด (4) sync rules/pdf-viewer.md — verify ด้วย Playwright touch emulation: ปุ่มโผล่หลัง debounce, แตะแล้วสร้าง HIGHLIGHT จริง (server action + DOM + sidebar), วาดเส้นด้วยนิ้วได้, handles 9 จุด touch-action:none ครบ, tsc ผ่าน
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 13:07:08 +07:00

16 KiB
Raw Permalink 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 ทิ้ง
  • PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน /api/pdf?fileId= เสมอ (ไม่ใช่ file.pdfUrl ตรง ๆ): lib/pdf-actual-text.ts เรียน glyph-code → ข้อความจาก /ActualText ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer)
  • ข้อความจาก selection ห้ามใช้ selection.toString() ตรง ๆ — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ selectionTextWithin(keptRects, range) (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ readTextSelection(pageIndex, { withText: true }) กับ event copy (กรองด้วยเส้นทางลากล่าสุดจาก lastSweepRef) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วย sanitizePdfText (utils/annotation-display.ts — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด
  • คีย์ลัด H/D/C/B ต้อง e.preventDefault() เสมอ — B เปิด popup bookmark ที่ input autoFocus ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ
  • DEL/Backspace ลบ annotation ที่เลือกอยู่ (selectedId) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (inForm เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ)
  • 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
  • จอสัมผัส (iOS/Android) ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่ matchMedia("(pointer: coarse)") จริง + tool highlight → effect selectionchange debounce ~400ms หลัง selection หยุดนิ่ง แล้ว snapshot (rects + ข้อความผ่าน readTextSelection(pi, { withText: true })) เก็บใน touchSelRef + แสดงปุ่ม "ไฮไลต์" ลอยใต้บรรทัดสุดท้ายของ selection (ด้านบนถูกเมนู/handles ของ OS ยึด — ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) — commit ต้องใช้ snapshot ใน ref เท่านั้น ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง; ทั้ง desktop (mouseup) และ touch (ปุ่มยืนยัน) ผ่าน commitHighlight ชุดเดียวกัน; เคลียร์ปุ่ม+timer ทุกครั้งที่ selection หาย/ออกนอกหน้า/เปลี่ยน tool
  • คลุมข้อความได้ 5 tool (TEXT_SELECTION_TOOLS set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject <style> ซ่อน native selection + ฟัง selectionchange วาดของเราเอง (highlight = preview มาร์ก, ที่เหลือ = selection สีฟ้า) + tracking เส้นทางลาก (selDragStart/EndRef ที่ mousedown/move) + Ctrl+C กรองข้อความตามเส้นทาง — comment/textbox/stamp ปล่อยเมาส์จากการลากคลุมไม่วางรายการ (guard !selection.isCollapsed ต้น handlePageClick: click ที่ยิงต่อจาก drag คลุมต้องเฉยไป เหมือน tool เลือกข้อความ คลิกล้วน ๆ ต่างหากที่เปิด composer/วางตรายาง)
  • กรอบ 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 ย้าย/ขยาย (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว) — handle ทุกตัว (ขอบ 4 + มุม 4) ต้องมี touch-none: จอสัมผัสไม่มี touch-action:none แล้ว browser ยึด gesture เป็น scroll ส่ง pointercancel กลายเป็นเลื่อนหน้าแทนการลาก (InkLayer มี touch-none อยู่แล้ว)
  • TEXTBOX (F17) position เป็นมุมซ้ายบนของกล่องเสมอ — align จัดข้อความในกล่องอย่างเดียว (textAlign, แบบ Smallpdf: กดจัดแนวแล้วกล่องไม่ขยับ ขยับแค่ข้อความข้างใน — เคยทดลองให้ align เป็น "จุดยึด" ที่ขยับทั้งกล่องแล้วถอดออกเพราะผู้ใช้รู้สึกว่ากล่องเด้ง); ความกว้างสูงสุดจาก textboxMaxWidthPct(x) (utils/geometry.ts) บีบตามพื้นที่จริงถึงขอบขวา (cap 50% ของหน้า, ขั้นต่ำ 1%) กันข้อความล้นขอบกระดาษ — ใช้สูตรเดียวกันทั้ง render (TextboxStampLayer), bbox กรอบ selection/hit-test (annotationBBox) และ export PDF
  • TEXTBOX กล่องกว้างคงที่ (width? สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct) — ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง (mode wleft/wright ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (position.x = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้ซ่อนมุมกรอบ (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี width = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด
  • คลิกเลือกบนหน้า: 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 เปิดอยู่)