--- 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` — **ตัวเลื่อนเดียวของหน้าคือ `
` ของเอกสาร** (`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: `` → `div.highlight-layer` (`mix-blend-mode: multiply`) → `div.textLayer` → `` 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=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` - 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): ลากบน `
` = `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 บน `
`: `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 ของ `
` ใต้ header ไม่ใช่ขอบจอ) 60px — เลื่อน scroll container ของ `
` ทั้งแกนตั้ง/แนวนอน แล้วคำนวณ 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 ให้ `
` ได้ 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 ค้าง) แทนการผูกที่ `
`: Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง พอ capture หาด pointerup ยิงที่ element ใต้นิ้วจริงซึ่งอาจอยู่นอก `
` (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 `
` แทน 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 ได้