eCourt-note/.claude/rules/pdf-viewer.md
Your Name 8cc15ea75d
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 52s
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
refactor(routes): move routes under /note, APIs under /api/note
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน
อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้
(Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ

(1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า
app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า
public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง
คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/…
ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้)

(2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note
ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม);
resolvePublicPdfPath root ที่ public/note (traversal guard เดิม);
middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads,
note/samples, note/pdf.worker, _next, fonts, favicon) + redirect
/note/login; next.config เพิ่ม redirects /docedit -> /docedit/note;
navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note;
pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload
เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard)

(3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง),
seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/
.dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง
disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree,
README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill)

(4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads ->
/app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์
ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image

(5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc,
vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker,
samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep
link), Playwright browser จริง (canvas + textLayer ไทย render, browser
ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer +
code-reviewer 3 รายงาน 0 blocking

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

56 KiB
Raw Blame History

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

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

  • Scroll architecture (2026-09-22): root ของ PDFViewer เป็น app shell flex h-dvh overflow-hidden — ตัวเลื่อนเดียวของหน้าคือ <main> ของเอกสาร (pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain ทั้งแกนตั้ง+แนวนอน ซูมจนหน้าเกินจอแล้วเลื่อนดูได้, sidebar/header อยู่นอกกรอบ scroll จึงนิ่ง) — ห้ามกลับไปเลื่อนที่ window/document (window.scrollY เป็น 0 เสมอ): ทุก scroll API (bookmark/deep link/goToPage/goToAnnotation/pan/auto-scroll) ต้องเลื่อน mainRef.current และแปลงพิกัดด้วย contentTopInScroll(main, clientTop) (แทน r.top + window.scrollY); .pdf-scroll-main ตั้ง touch-action: pan-x pan-y กัน browser pinch-zoom ทั้งหน้าเว็บ (ต้องอยู่ก่อน .pdf-custom-select ใน globals.css เพื่อให้ touch-action:none ของโหมดมาร์กข้อความชนะ) — IO ของ virtualization ตั้ง root: mainRef.current
  • 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/note/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) — fetch/img.src ทุกตัว (รวม fallback และรูป N3 ที่โหลด static pdfUrl ตรง ๆ) ต้องผ่าน withBasePath() (invariant #12)
  • ข้อความจาก 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) — hit-test ของแต่ละตัวอักษรต้องผ่านประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรอยู่ในแถบของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับแนวตั้งมากที่สุด ไม่ใช่ตัวแรกที่เจอ; แยกบรรทัดระหว่างกลุ่มด้วย "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด" (แทรก \n) ไม่ใช่ heuristic เทียบ midY กับความสูง — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรบรรทัดถัดไป (โดยเฉพาะตัวที่สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อนหน้าจริง 1-3px การเช็คแค่การเหลื่อมไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret ปลายทางหลุดเข้า span ถัดไป — range.toString() ได้ข้อความหลายบรรทัดทั้งที่ลากบรรทัดเดียว) ตัวอักษรเหล่านั้นเคยถูกดูดเข้ากลุ่มเดียวกันแล้วเรียงรวมด้วย x จนสองบรรทัดสอดแทรกตัวต่อตัวเป็น mojibake ในชื่อ bookmark/copy (bug report 2026-10-05: "แบบบันทึกผลการทดลอง…" กลายเป็น "แบบบันทึกผ(สำลกหารัรบท…") — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปอยู่นอกแถบเสมอ (เหลื่อมแค่ขอบ) จึงตัดทิ้งให้เหลือเฉพาะข้อความที่เห็นบนจอจริง ปิดท้ายด้วย 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" ตามลงในช่องชื่อ — จับคู่ด้วย e.code (ตำแหน่งกายภาพของปุ่ม — map TOOL_SHORTCUTS ใน toolbar-tools.ts + KeyB/KeyZ ตรง ๆ) ไม่ใช่ e.key (2026-09-29, PLAN-TOOLBAR #1): ผู้ใช้ศาลส่วนใหญ่สลับ keyboard เป็น layout ไทย ซึ่งปุ่มเดียวกันให้ e.key เป็น "ภ"/"ว"/"แ"/"น" — เช็ค e.key ทำให้คีย์ลัดทั้งชุด (รวม Ctrl+Z undo/redo) ใช้ไม่ได้เลยบน layout ไทย
  • 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 = withBasePath("/pdf.worker.min.mjs") — ไฟล์ที่ public/note/ ไม่ใช่สำเนาตรง ๆ ของ node_modules: postinstall (scripts/patch-pdf-worker.mjs) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด if (scaledDim) รอบ textChunk.prevTransform = getCurrentTextTransform() ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน addFakeSpaces แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ tests/scripts/patch-pdf-worker.test.ts (fixture tests/fixtures/thai_letterspacing.pdf — ห้ามวางใน public/note/samples/: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม 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)
  • เมนู dropdown ของ tool ทั้ง 5 (highlight/draw/shape/stamp/textbox) + popup bookmark ก็ render ผ่าน createPortal → document.body เป็น position: fixed เหมือนกัน แต่ยึดพิกัดจริงของปุ่ม (fixedMenuPos — getBoundingClientRect() ตอนคลิก + clamp ให้อยู่ในจอ; popup bookmark คำนวณจาก ref ของปุ่ม 🔖 เพราะเปิดได้จากคีย์ลัด B/ปุ่มในแผง selection ด้วย) — ห้ามกลับไปเป็น absolute ใน scroll container ของแถบ tool และห้ามสลับ overflow ของแถบตอนเปิดเมนู (สลับแล้ว scroll position ของแถบรีเซ็ต เมนูเปิดนอกจอบนมือถือ และทั้ง app เอียงเลื่อนแนวนอนได้); เลื่อนแถบต่อหรือ ความกว้างจอเปลี่ยน (หมุนจอ/split view — เทียบ menuOpenWRef ตอนเปิด) = ปิดเมนูทั้งหมด แต่ resize ที่เปลี่ยนแค่ความสูง (URL bar ยุบ/คีย์บอร์ดของมือถือ) ปิดไม่ได้ เพราะเมนู fixed ยึด sticky header ยังอยู่กับที่ (ตำแหน่งคำนวณตอนเปิดเท่านั้น ไม่ตามปุ่มที่เลื่อนไป); outside-click ต้องข้ามโหนดใน [data-tool-menu] ก่อน — ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู = ปิดเมนูเอง; เมนูตรายางเป็น 2 หน้าใน portal เดียว (2026-09-28 — preset + แถว "กำหนดเอง…" → หน้า popup stampCustomMode reset กลับหน้า preset ทุกครั้งที่เปิดเมนูใหม่): หน้ากำหนดเอง = ช่องข้อความ (พิมพ์ live ไป stampPreset เดียวกับ preset, maxLength STAMP_MAX_LABEL_CHARS) + สี + ขนาด + preview ตรายางตามสัดส่วนจริงบนจอ (ความกว้างหน้าแรก × scale% — ใหญ่เกินเมนูย่อทั้งชุด; กรอบ/ฟอนต์/การจัดวาง/measure ใช้ตัวเดียวกับ render จริง: stampLayout/stampBoxHeight จาก utils/geometry.ts + STAMP_FONT_STACK/measureStampText จาก utils/annotation-display.ts — คำยาวจัดบรรทัดแบบถอยหลัง: ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อนลงได้จนถึง floor 10% ของความกว้างกรอบ (คำอย่าง "ตรวจสอบแล้ว" ย่อ ~25% ก็พอ = บรรทัดเดียว ไม่ตก 2 บรรทัด — คำขอผู้ใช้ 2026-09-28 รอบสอง) คำยาวเกิน floor ค่อย wrap ≤3 บรรทัด กรอบสูงขึ้นตามจำนวนบรรทัด คำสั้น 1 บรรทัดฟอนต์เต็มสูง 0.4w เหมือนเดิมเป๊ะ — ทุกมิติเป็นสัดส่วนของความกว้างกรอบ จำนวนบรรทัดจึงไม่ขึ้นกับ scale ปิดท้ายด้วยปุ่ม "ใช้ตรายางนี้" — ปุ่มนี้และ Enter ในช่อง = ปิดเมนูแบบเลือกค่าจบ (setState ตรง ไม่ผ่าน markMenuClosed — แตะหน้าวางได้ทันที ไม่โดน guard กลืน tap), Escape ในช่อง = ย้อนกลับหน้า preset; ปุ่ม tool แสดง label "กำหนดเอง" แทนคำเต็มเมื่อค่าปัจจุบันเป็นคำกำหนดเอง (ปุ่ม dropdown ทั้ง 4 — มาร์กข้อความ/วาดเส้น/รูปทรง/ตรายาง — แสดงค่าที่เลือกเฉพาะตอน tool active ไม่ active แสดงชื่อ tool เหมือนปุ่มอื่น เพราะแสดงค่าตลอดทำให้ชื่อ tool หายจากแถบ เช่น "วาดเส้น" เห็นเป็น "ปากกา" ตั้งแต่ยังไม่ได้เลือก, คำขอผู้ใช้ 2026-09-29)
  • 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> = mainRef.current.scrollBy สวนทางการลาก (ทั้งแกน x/y) + 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 บน desktop / custom selection ของ useCustomSelection บนจอสัมผัส + ซ่อน native selection) — tool "เลือก" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด H สลับไป tool highlight
  • จอสัมผัส (iOS/Android) + tool highlight ใช้ custom selection ผ่าน hooks/useCustomSelection.ts (ไม่พึ่ง native selection ของ OS อีก — OS ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release ทำไม่ได้): เปิดเฉพาะอุปกรณ์ที่ matchMedia("(pointer: coarse)") จริง (enabled = tool highlight + coarse — desktop ใช้ native mouse selection + commit ตอน mouseup ตามเดิม ไม่เปลี่ยน; tool อื่นบนจอสัมผัสก็ native selection + long-press copy ของ OS ตามปกติ) — Pointer Events ชุดเดียวคุม mouse+touch บน <main>: pointerdown เก็บ anchor caret (จาก caretRangeFromPoint → fallback caretPositionFromPoint → ไม่มี = bail เงียบ ๆ; ต้องเป็น text node + เริ่มบน .textLayer เท่านั้น) ยังไม่ทำอะไร → เลย threshold 8px จึง setPointerCapture + เริ่ม select จริง (จับ pointer ตอนเริ่ม select ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/ปุ่ม pin ใช้ไม่ได้; tap ที่ไม่ขยับ = ไม่ capture click ไปถึงเป้าหมายตามเดิม และ click หลังลากจริง retarget ไป main = ไม่หลุดเข้า handlePageClick) — ระหว่างลาก: อ่าน focus caret ที่จุดนิ้วเลื่อนขึ้นเท่า offset ของ gesture (เฉพาะ pointerType touch — นิ้วบังข้อความ; offset = min(FINGER_OFFSET_PX=12, ครึ่งความสูงบรรทัด−2) คำนวณครั้งเดียวตอน pointerdown จาก span ของ anchor ผ่าน fingerOffsetFor — บรรทัด PDF บน tablet zoom-fit สูงแค่ ~14px การหักตายตัว 12px ทำ probe หลุดขอบบนบรรทัดไปโดน heading จนลากแนวนอนแล้วเงียบ, รายงาน 2026-09-28; บรรทัดใหญ่ ≥28px ยังได้ 12 เท่าเดิม), word snap ขอบคำทั้ง anchor/focus (Intl.Segmenter แยกคำไทยได้ → fallback regex \S+, กลางช่องว่าง = ไม่ขยาย; หลัง snap แล้ว rangeBetween ประกบ anchor↔focus โดยฝั่งหลังใช้ boundary end ของ range ครอบคำ — ใช้ start จะทิ้งคำท้าย "ลาก alpha→gamma ได้แค่ alpha beta" และลากในคำเดียวได้ selection ว่าง, A1 2026-09-28), live preview ผ่าน rAF throttle (Range → getClientRects → rectsInSweptPath กรองแนวเส้นทางนิ้ว — จุด start/end ถูก snap เข้า center บรรทัดใกล้สุดก่อนตัดสิน กันพิกัดนิ้วที่คนละบรรทัดแต่ dy น้อยถูกตีเป็นบรรทัดเดียวจนกรองได้ 0 rect, A3 2026-09-28 — ใช้ร่วมกับ desktop เหมือนเดิม มี unit tests ที่ tests/utils/coordinates.test.ts) → rectToPercent+mergeRectsByLine — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า customSelPreview ?? liveMark — จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−offset ของ gesture): จุดเริ่มเก็บเป็นสัดส่วน 0–1 ของหน้า anchor (anchorRel — ตำแหน่ง probe ที่ resolve anchor สำเร็จ) แล้วแปลงกลับเป็นพิกัดจอใหม่ทุกเฟรมด้วย rect ปัจจุบันของหน้า (พิกัด client คงที่โกหกทันทีที่ auto-scroll เลื่อนเนื้อหากลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก, A2 2026-09-28) ส่วน pathEnd = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาดเท่า offset จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), auto-scroll rAF loop เมื่อนิ้วใกล้ขอบทั้ง 4 ด้านของพื้นที่เอกสาร (rect ของ <main> ใต้ header ไม่ใช่ขอบจอ) 60px — เลื่อน scroll container ของ <main> ทั้งแกนตั้ง/แนวนอน แล้วคำนวณ preview ใหม่ที่จุดนิ้วเดิม — pointerup = commit ทันทีผ่าน onCommit(range,start,end) → handleCustomSelectionCommit (ยืม selDragStart/EndRef ชั่วคราว + readTextSelection(pi, { withText: true, range }) ชุดเดียวกับ desktop) → commitHighlight — ไม่มีปุ่มยืนยันลอย/debounce/snapshot ใน ref อีกต่อไป (selectionchange ของ tool highlight บน coarse ไม่ยิงเพราะไม่มี native selection); ระหว่าง enabled ให้ <main> ได้ class .pdf-custom-select (globals.css: user-select/-webkit-user-select/-webkit-touch-callout: none + touch-action: none กัน browser ยึด gesture เป็น scroll → pointercancel; restore user-select:text ให้ input/textarea/contenteditable ใต้ main) ถอดทันทีเมื่อ tool เปลี่ยน/ปิด — ผลคือช่วง tool highlight บนจอสัมผัส ลากที่ไหนก็ไม่เลื่อนหน้า (gesture เป็นของ selection หมด เลื่อนหน้าต่อได้ด้วย auto-scroll ระหว่างลาก หรือสลับ tool) — เคลียร์ state ครบทุกทาง: pointerup/pointercancel (cancel = ทิ้ง selection ไม่ commit), enabled เปลี่ยน, unmount — ยกเลิก rAF ทั้ง preview/auto-scroll ไม่มี loop ค้าง — pointerup/pointercancel ผูกเป็นตาข่ายระดับ document แบบ capture phase (armDocUpListeners ตั้งแต่ pointerdown ที่เริ่ม gesture จนถึง finish — รวม tap ที่ไม่ activate ไม่งั้น dragRef ค้าง) แทนการผูกที่ <main>: Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง พอ capture หาด pointerup ยิงที่ element ใต้นิ้วจริงซึ่งอาจอยู่นอก <main> (code review 2.1 2026-09-28) — และห้ามผูก finish เข้า lostpointercapture: กับ touch ของ Chromium event นี้ยิงกลาง drag ได้เอง (re-hit-test ของ touch pointer ที่ implicit capture อยู่ที่ span) ผูกแล้ว dragRef ถูกเคลียร์กลางทางจน gesture ทุกตัวเงียบหมด — ยืนยันด้วย Playwright + CDP touch 2026-09-28 (ข้อ A7.3 ของ review ไม่ใช้); ชั้น ③④ ของ resolver วาง caret ตามสัดส่วน x ใน span (caretInSpanAt) ไม่ใช่ต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว — วางต้น span จน anchor เด้งไปต้นบรรทัด, A5) — กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (resolveTextCaretDeep — ใช้เฉพาะตอน pointerdown กับทั้งจุดชดเชยนิ้ว y−12 และจุดแตะจริง; หลัง resolve สำเร็จครั้งแรกถ้า fingerOffsetFor ได้ offset น้อยกว่า 12 จะ resolve anchor ซ้ำที่ y−offset จริง — กัน anchor ค้างอยู่บรรทัดที่ probe −12 พลาดไปโดน): ① caretRangeFromPoint/caretPositionFromPoint → startContainer เป็น Element (จุดตก gap/padding ระหว่าง span) ให้ดำดิ่งหา text node ผ่าน childNodes[startOffset] + findFirstTextNode DFS → ② retry ขยับพิกัด 8 ทิศ (±2/±3px) → ③ elementsFromPoint เจาะผ่าน overlay ที่ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ข้อความใต้จุด → ④ span ของหน้านั้นที่ใกล้สุดแนวตั้ง ≤40px (NEAREST_SPAN_MAX_PX — เกินถือว่าจุดนั้นไม่มีข้อความจริง) — หมดทั้ง 4 ชั้นค่อย bail เป็น tap ธรรมดา; ระหว่างลาก (ทุก rAF frame) ใช้เฉพาะชั้น ①② ที่ถูก และ retry แค่ 2 ทิศแรก (resolveTextCaret(x, y, 2) — retry ครบ 8 ทิศเปลือง ~6ms/เฟรม เอาไว้เฉพาะ pointerdown ที่ anchor ต้องเจอ, B1 2026-09-28) — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้คง focus เดิมจาก frame ก่อน (dragRef.lastFocus) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (ชั้น ④ ใช้ cache พิกัด span สัมพัทธ์ของหน้า วัดครั้งเดียวต่อ gesture — getBoundingClientRect ทั้งหน้าแพง ~2.3ms/ครั้ง, บวก rect ปัจจุบันของหน้าคืนตอนใช้จึงทน auto-scroll, B2; ต้องส่ง d ตรง ๆ ให้ resolver ไม่ใช่ object ห่อใหม่ — ไม่งั้น cache ไม่ถูกเก็บกลับ) (2) lastRef (payload commit ล่าสุด) เคลียร์เฉพาะตอน pointerdown, finish, เมื่อ range กลายเป็น collapsed (ผู้ใช้ย่อ selection จนว่างจริง — ปล่อยนิ้วต้องไม่ commit ให้ตรงกับ preview, A4 2026-09-28) และเมื่อหน้า anchor ถูก virtualization unmount กลางลาก (anchorPageEl.isConnected === false — rects ของ detached nodes ว่างหมด ต้องไม่ commit ของเก่าเงียบ ๆ) เท่านั้น — frame ที่ rectsInSweptPath กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview เพื่อให้ pointerup ยัง commit selection ล่าสุดที่ไม่ว่างได้ (3) ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า → หา [data-page] ไม่ได้) ห้าม bail — clampToAnchorPage: clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) ด้วย text node แรก/สุดท้ายของ textLayer หน้า anchor (anchorPageEl เก็บตั้งแต่ pointerdown, boundary cache ต่อ gesture ใน clampBounds — ห้ามเดิน TreeWalker ใหม่ทุก rAF frame) — selection จบที่ขอบหน้า anchor แทนการหายทั้งเส้น
  • tap/คลิกที่ใช้ "ปิดเมนู" ต้องไม่สร้างรายการ: outside-click ปิดเมนูที่ mousedown ก่อน click ยิงถึง page wrapper ทำให้ tool comment/textbox/stamp วางรายการจาก tap ปิดเมนูโดยไม่ตั้งใจ — handlePageClick เช็ค menuClosedAtRef (เขียนทุก path ปิดเมนูผ่าน markMenuClosed: outside-click/Escape/switchTool/เลื่อนแถบ/resize; ยกเว้นคีย์ B ปิดเมนูเพื่อเปิด popup bookmark — คีย์บอร์ดไม่มี click หลงเหลือให้กลืน และยกเว้นการเลือกแถวเดี่ยวในเมนู (รูปแบบมาร์ก/ชนิดปากกา/ชนิดรูปทรง/preset ตรายาง — UX audit 2026-09-22) ที่ปิดเมนูด้วย setState ตรง ๆ ไม่ผ่าน markMenuClosed: tap นั้นอยู่บนเมนูเองไม่มี click หลงเหลือถึงหน้า และผู้ใช้เลือกค่าแล้วตั้งใจแตะหน้าวางต่อทันที — ส่วนแถวปรับค่า สี/ความหนา/ความทึบ/ขนาด ยังคงเมนูเปิดให้ปรับต่อ) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch)
  • popup บนหน้าเปิดได้ทีละชุด ห้ามค้างเปิดซ้อนกัน (bug "กดแก้ไขแล้วกดปุ่ม tool popup ไม่ปิด/ซ้อนกัน"): ปุ่ม dropdown ทั้ง 5 เรียก switchTool เสมอแม้เป็น tool เดิม (จำ intent เปิด/พับเมนูไว้ก่อนเรียก เพราะ switchTool ปิดเมนูทุกตัว) เนื่องจาก switchTool เป็นที่เดียวที่เคลียร์ popup บนหน้าทั้งหมด — composer/pin popover/แผง selection/editing comment·textbox + popup bookmark (setBookmarkComposer(null)); สร้างรายการแล้ว auto-select (AUTO_SELECT_TYPES) ก็ปิดเมนู+popup bookmark เช่นกัน — บน iOS การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนูเอง (Android/desktop ปิดตั้งแต่เริ่มกดแล้ว) ไม่ปิดตรงนี้ = เมนูค้างเปิดซ้อนแผงที่โผล่หลังสร้าง; คลิกหน้าว่าง (handlePageClick) ปิด popover ของ pin + popup bookmark; เลือกรายการอื่น (handleSelectAnnotation/goToAnnotation) ปิด popover เดิม; ทางเปิด popup bookmark ทุกทาง (คีย์ B/openBookmarkComposer + ปุ่ม bookmark ในแผง selection handleAnnotationBookmark) เคลียร์ popup บนหน้าทั้งชุดก่อนเปิด (2026-09-29) — ไม่งั้น popup bookmark ซ้อน popover/editor ที่ค้างเปิด และ autoFocus ของช่องชื่อแย่ง focus จาก editor; popover ของ COMMENT เปิดอยู่ (openPinId === selectedId) = ไม่ render กรอบ selection/แผงเครื่องมือของตัวนั้น — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId ไฮไลต์เหมือนเดิม)
  • ชุดปิดเมนู dropdown รวมศูนย์จุดเดียว (2026-09-29, PLAN-TOOLBAR ขั้น 2): closeToolMenus() วนจาก TOOL_MENU_KEYS (toolbar-tools.ts) + TOOL_MENU_TABLE (Record ใน PDFViewer — ผูก open/ref/setter ครบทุก key โดย type บังคับ: เพิ่มเมนูใหม่แล้วลืมแถว = compile fail ไม่ drift เงียบ แบบ title bug 2026-09-23) ใช้ร่วมทุกจุดที่เคย enumerate setter ด้วยมือ 6 แห่ง — switchTool / outside-click / เลื่อนแถบ·resize ความกว้าง / auto-select / openBookmarkComposer / reset tool ตรง (effect scannedOnly + สลับไฟล์ภาพ — เมนู portal ต้องไม่ค้างลอยไร้ปุ่มยึดเมื่อปุ่ม highlight ถูกซ่อน) ส่วน popup บนหน้าทั้งชุดอยู่ที่ closePagePopups() (switchTool + ทางเปิด popup bookmark ทั้งสอง + Esc ของคีย์ลัด ใช้ร่วม — code review ขั้น 2)
  • คลุมข้อความได้ 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 = สี+ขนาด+มุมเอียง+ปุ่ม "แก้ไขข้อความ" (StampEditor แก้คำตรายางในที่ — ใช้ editingId/onStartEdit เดียวกับ TEXTBOX, 2026-09-28) — มุมเอียง rotation? (องศา −180..180 หมุนรอบกลางกรอบ, patch stampRotation, 0° = ถอด field): StampItem/กรอบ selection+handles ทั้งชุดหมุนด้วย CSS transform: rotate() — origin เป็นพิกัดใน element นั้น (กลางกรอบ − left/top ของ div ผ่าน helper rotOn()) ไม่ใช่พิกัด page: ใส่พิกัด page แล้ว div แต่ละตัวหมุนรอบจุดคนละที่ กรอบไม่ตรงตัวตรายาง (bug report 2026-09-28) ส่วนแผง/ป้าย/tooltip ยึด AABB + จุดจับหมุนวงกลม (RotateCw) เหนือกึ่งกลางขอบบนของกรอบ ลากวนรอบกลางกรอบได้เลย (mode "rotate": มุมใหม่ = มุมเดิม + atan2 delta ของ pointer รอบกลางกรอบ, snap ทวีคูณ 15° เมื่อใกล้ ≤4°, wrap เข้า (−180,180], 0° = ถอด field — คำขอผู้ใช้ 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect+rotatedRectAABB) ใช้ hit-test/clamp ย้าย (มุม anchor ของ handle scale = มุมกรอบหลังหมุนบนจอ และตัวคูณ k คำนวณใน frame ของกรอบ — ย้อนหมุน pointer รอบ center ก่อน ไม่งั้นแกน x/y ผิดจากสายตา), StampEditor แสดงตรง (0°) พิมพ์ง่าย ปิดแล้วกลับไปเอียงตามค่าเดิม, export PDF ใช้ pdf-lib rotate: degrees(−θ) (แกน y PDF ชี้ขึ้น) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน เพราะ pdf-lib หมุนรอบ anchor ไม่ใช่รอบ center (2026-09-28), TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ตัวควบคุมเล็กของแผง (สี/ความหนา/ขนาด/รูปแบบ/ทึบ/patch กล่องข้อความ) และ editor ข้อความ commit เป็น patch (AnnotationPatch export จาก AnnotationSelectionLayer → prop onPatch → handlePatchAnnotation) ไม่ใช่ full object — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดในแผงต่อทันที (ทั้งคู่ยิงก่อน React render optimistic row) คอมมิตแบบ full จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน — PDFViewer จึงคิว update ต่อ id (updateChainRef + pendingDataBaseRef: update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน คำนวณ base ตอนถึงตาของคิว จาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — enqueueUpdate/applyUpdate — drag/preview ที่ส่ง full data ก็ผ่านคิวเดียวกันให้ server action เรียงลำดับ — state preview ของการลากอยู่ระดับหน้า (PdfPage) ไม่ใช่ root ของ viewer (2026-09-29): เดิม setPreviewData อยู่ root ทำให้ทุก pointermove ระหว่างลาก re-render ทั้งต้นไม้ (toolbar/sidebar/ทุกหน้า mounted) จนลากเป็น step — ตอนนี้ preview เป็น {id, data} ท้องถิ่นของหน้า (เทียบ id เจ้าของ preview ตอน substitute ไม่ใช่ selectedId — กัน multi-touch เปลี่ยน selection กลาง drag แล้ว preview ของรายการเก่าไปทดแทนรายการใหม่หนึ่งเฟรม เพราะการเคลียร์จาก root มาเป็น passive effect หลัง paint) + onPreview ของ AnnotationSelectionLayer throttle ด้วย rAF ≤1 ครั้ง/เฟรม (pattern FIX-8 ของ InkLayer — ค่า commit ที่ pointerup ยังมาจาก latestRef ที่อัปเดตทุก move เต็มร้อย) ส่วน root สั่งเคลียร์ผ่าน counter previewClear (bump จากทุกจุดที่เดิมเรียก setPreviewData(null) — ปิด popup/เปลี่ยน selection/ก่อนคิว update) — กลไก getter-แบบ-props (getLatestData) เคยใช้แล้วถอด: getter กับ props มาจาก visibleAnnotations render เดียวกันเป็น reference เดียวกัน จึงเก่าเท่ากันเสมอ ไม่ช่วยอะไร (code review 2026-09-28 รอบสอง) — ปิดท้ายด้วย ปุ่มลบ (onDelete) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — HIGHLIGHT ไม่มี handle ย้าย/ขยาย (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว) — handle ทุกตัว (ขอบ 4 + มุม 4) ต้องมี touch-none: จอสัมผัสไม่มี touch-action:none แล้ว browser ยึด gesture เป็น scroll ส่ง pointercancel กลายเป็นเลื่อนหน้าแทนการลาก (InkLayer มี touch-none อยู่แล้ว) — บน pointer: coarse ขยาย hit area โดยไม่ขยายของที่มองเห็น (rule ใน globals.css เท่านั้น desktop ไม่แตะ): มุม/แถบเป็น div ขนาดจาก inline style (10px/8px) แก้ตรง ๆ ไม่ได้ จึงใช้ ::after (hook sel-handle hit 28px, sel-strip hit 16px), ปุ่มแผงใช้ sel-panel-btn (36px), swatch ในแผง .sel-panel .color-swatches (32px), ปุ่ม icon ใน sidebar aside[data-sidebar] button.shrink-0 (32px — ยกเว้น chevron หุบ/กางของต้นไม้ bookmark ที่ใส่ class bm-toggle: คงขนาดจริง 14px เท่า spacer ของแถวลูก + ขยาย hit 32px ด้วย ::after แทน — คอลัมน์ต้นไม้ต้องเท่ากันทุกระดับ ไม่งั้น min-width ดันไอคอนแม่ไปชนแนวไอคอนลูก ลำดับชั้นหาย, bug report 2026-09-22), ปุ่มเล็กใน composer/popover/pin ใช้ class coarse-36 — ปุ่มในเมนูที่ portal ต้องครอบด้วย selector [data-tool-menu] คู่กับ header .touch-44 .z-50 เดิม
  • 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 หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด; ฟอนต์ default ตอนสร้าง = TEXTBOX_DEFAULT_FONT_SIZE_PCT 2.5% (≈15px — เดิม 1.2% ≈ 7px อ่านไม่ออก และเล็กกว่า preset ต่ำสุด 10px ของตัวเอง) และเข้าโหมดแก้ข้อความได้จากปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit) นอกเหนือจาก double-click (จอสัมผัส iOS double-tap = zoom หน้า dblclick จึงใช้ไม่ได้)
  • คลิกเลือกบนหน้า: 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 เปิดอยู่) และเมื่อรายการที่ชี้ = selectedId (แผง selection เปิดอยู่แถวนั้นแล้ว — เจอตอนลากคลุมจบแล้ว auto-select โผล่ที่ตำแหน่งเคอร์เซอร์พอดี ซ้อนเป็นสองกล่อง)
  • N3 ไฟล์รูปภาพ (jpg/jpeg/png/tif — fileKindFromUrl จาก utils/file-kind.ts): รูปโหลดจาก static pdfUrl ตรง ๆ ไม่ผ่าน /api/note/pdf (ไม่มี ToUnicode ให้ซ่อม — route นั้นมี defensive branch คืน bytes ดิบ + content-type รูป กัน stray call เข้า fixPdfActualText) — หน้ารูป render เป็น <img> แทน canvas ที่ z-1 (pointer-events:none เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + ไม่มี textLayer (conditional render เฉพาะ pdf) → PdfPage เรียก onTextLayerRendered(pageIndex, 0) ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — toolbar ซ่อน tool มาร์กข้อความ (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบเมื่อไม่มีข้อความให้คลุม — ทั้งไฟล์ภาพและ PDF สแกนล้วน (คำขอผู้ใช้ 2026-09-22 แทนแผนเดิมที่คงไว้พร้อม hint): hideHighlightTool = ไฟล์ภาพ หรือ scannedOnly = ทุกหน้าที่ render แล้วไม่มี span เลย (entry ของ pageSpanCount สะสมต่อหน้า ไม่ลบตอน virtualization unmount = latch: เคยเจอหน้ามีข้อความแม้หน้าเดียว ไฟล์ถือว่ามีข้อความ ปุ่มกลับมาค้าง — ไฟล์ผสมสแกนนำหน้าโผล่ครั้งเดียวพอ ไม่โผล่-หายตามหน้าที่เปิดดู) — กันคีย์ลัด H ที่ switchTool (เก็บปุ่มเดิมไว้ แต่ยังเคลียร์เมนู/popup/selection เหมือนสลับ tool จริง — ไม่ no-op เงียบสนิท, finding #4 ของ PLAN-TOOLBAR 2026-09-29) + reset กลับ "select" ทั้งตอนสลับมาเป็นไฟล์ภาพขณะ tool นี้ active (dynamic param เปลี่ยน = reuse component ไม่ reset state) และ effect ตอน scannedOnly กลายเป็น true — reset ทั้งสองจุดปิดเมนู dropdown ที่ค้างเปิดด้วย closeToolMenus() (2026-09-29 — เมนู portal fixed จะลอยไร้ปุ่มยึดเมื่อปุ่มถูกซ่อน); แถบ hint M6 "ภาพสแกน" เหลือเป็น defensive ช่วง transition เท่านั้น; pageSizes จาก naturalWidth/Height (browser คิด EXIF orientation ให้แล้ว) normalize กว้าง IMAGE_PAGE_WIDTH=595 สัดส่วนเดิม — scale contract เดียวกับ export (utils/image-to-pdf.ts) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน utif2 dynamic import เฉพาะไฟล์ tif (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (หลายเฟรม = หลายหน้า) และ revoke ทุก blob URL ตอน cleanup ของ load effect; เฟรมที่ decode พัง = src: null → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้