- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
11 KiB
11 KiB
| paths | ||
|---|---|---|
|
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+ rendertransform: [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 })กับ eventcopy(กรองด้วยเส้นทางลากล่าสุดจากlastSweepRef) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วยsanitizePdfText(utils/annotation-display.ts— ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด - คีย์ลัด H/D/C/B ต้อง
e.preventDefault()เสมอ — B เปิด popup bookmark ที่ inputautoFocusทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ DEL/Backspaceลบ annotation ที่เลือกอยู่ (selectedId) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (inFormเดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ)- Page Virtualization:
IntersectionObserverrender เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholderdivขนาดจริง (จาก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+ guardtypeof 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 ชุดเดียวกับคลิก →hoveredIdstate เดี่ยวระดับ 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 เปิดอยู่)