eCourt-note/.claude/rules/pdf-viewer.md
Your Name fa880b314e
All checks were successful
Deploy eCourt Note Web to UAT Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to UAT Server / Build Web Docker Image (push) Successful in 48s
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to UAT Server / Deploy eCourt Note Web to UAT Server (push) Successful in 2s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 48s
Deploy eCourt Note Web to UAT Server / Notify Deployment Status (push) Successful in 1s
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 0s
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00

32 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 ทิ้ง
  • 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) — 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) ปิดท้ายด้วย 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 = withBasePath("/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)
  • เมนู 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 ไม่ข้ามแล้วคลิกในเมนู = ปิดเมนูเอง
  • 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 บน 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 ที่จุดนิ้วเลื่อนขึ้น 12px (FINGER_OFFSET_PX เฉพาะ pointerType touch — นิ้วบังข้อความ), word snap ขอบคำทั้ง anchor/focus (Intl.Segmenter แยกคำไทยได้ → fallback regex \S+, กลางช่องว่าง = ไม่ขยาย), live preview ผ่าน rAF throttle (Range → getClientRects → rectsInSweptPath กรองแนวเส้นทางนิ้ว → rectToPercent+mergeRectsByLine — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า customSelPreview ?? liveMark — จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−12): เก็บ anchorPathPt = จุด probe ที่ resolve anchor สำเร็จ และ pathEnd = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), auto-scroll rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px (เลื่อนแล้วคำนวณ 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 ค้าง — กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (resolveTextCaretDeep — ใช้เฉพาะตอน pointerdown กับทั้งจุดชดเชยนิ้ว y−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) ใช้เฉพาะชั้น ①② ที่ถูก — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้คง focus เดิมจาก frame ก่อน (dragRef.lastFocus) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (cache รายชื่อ span ต่อ 1 gesture สำหรับชั้น ④) (2) lastRef (payload commit ล่าสุด) เคลียร์เฉพาะตอน pointerdown, finish และเมื่อหน้า 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 หลงเหลือให้กลืน) และเฉย 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 เดิม; popover ของ COMMENT เปิดอยู่ (openPinId === selectedId) = ไม่ render กรอบ selection/แผงเครื่องมือของตัวนั้น — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId ไฮไลต์เหมือนเดิม)
  • คลุมข้อความได้ 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 อยู่แล้ว) — บน 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), ปุ่มเล็กใน 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/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 (คลุมข้อความบนรูปไม่ได้ = no-op มี hint อธิบาย); 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 ได้