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
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
20 KiB
20 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) - เมนู 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+ ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัดHสลับไป tool highlight - จอสัมผัส (iOS/Android) ไม่มี
mouseupหลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่matchMedia("(pointer: coarse)")จริง + tool highlight → effectselectionchangedebounce ~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 - tap/คลิกที่ใช้ "ปิดเมนู" ต้องไม่สร้างรายการ: outside-click ปิดเมนูที่
mousedownก่อนclickยิงถึง page wrapper ทำให้ tool comment/textbox/stamp วางรายการจาก tap ปิดเมนูโดยไม่ตั้งใจ —handlePageClickเช็คmenuClosedAtRef(เขียนทุก path ปิดเมนูผ่านmarkMenuClosed: outside-click/Escape/switchTool/เลื่อนแถบ/resize) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch — pattern เดียวกับtouchHideTimerRef) - คลุมข้อความได้ 5 tool (
TEXT_SELECTION_TOOLSset ใน 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(hooksel-handlehit 28px,sel-striphit 16px), ปุ่มแผงใช้sel-panel-btn(36px), swatch ในแผง.sel-panel .color-swatches(32px), ปุ่ม icon ใน sidebaraside[data-sidebar] button.shrink-0(32px), ปุ่มเล็กใน composer/popover/pin ใช้ classcoarse-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 = ปรับความกว้าง (modewleft/wrightใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (position.x= ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้ซ่อนมุมกรอบ (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มีwidth= shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด; ฟอนต์ default ตอนสร้าง =TEXTBOX_DEFAULT_FONT_SIZE_PCT2.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 ชุดเดียวกับคลิก →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 เปิดอยู่)