Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Failing after 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Has been skipped
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
(1) hooks/useCustomSelection.ts (ใหม่): tool highlight บนอุปกรณ์ pointer หลัก coarse ลากเลือกข้อความเองผ่าน Pointer Events ชุดเดียวคุม mouse+touch — pointerdown เก็บ anchor caret (caretRangeFromPoint → caretPositionFromPoint fallback) ยังไม่ทำอะไร → เลย threshold 8px จึง setPointerCapture + เริ่ม select จริง (จับตอนเริ่ม select ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/pin ใช้ไม่ได้) → pointerup = commit ทันทีผ่าน commitHighlight เดิม (ห้ามแก้ signature — ไปทาง readTextSelection(pi, { range }) ที่เพิ่ม opts.range ให้อ่าน Range จากภายนอกแทน native selection) — ชดเชย UX ที่หายจาก native: word snap ขอบคำทั้ง anchor/focus (Intl.Segmenter แยกคำไทย dictionary → fallback regex \S+, กลางช่องว่างไม่ขยาย), finger offset อ่าน caret ที่ y−12 เพราะนิ้วบังข้อความ (เฉพาะ pointerType touch), auto-scroll rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px, live preview rAF throttle (getClientRects → rectsInSweptPath → % + mergeRectsByLine รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า customSelPreview ?? liveMark)
(2) กัน "ลากแล้วบางทีไม่ commit" ตาม bug report 3 จุด + code review: การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (resolveTextCaretDeep ใช้เฉพาะ pointerdown กับทั้งจุด y−12 และจุดแตะจริง): caret API + descend หา text node (childNodes[startOffset] + findFirstTextNode DFS) → retry 8 ทิศ ±2/3px → elementsFromPoint เจาะ overlay ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ใต้จุด → span ของหน้านั้นที่ใกล้สุดแนวตั้ง ≤40px (เกินถือว่าไม่มีข้อความจริง; cache span list ต่อ 1 gesture) — ระหว่างลากทุก rAF frame ใช้เฉพาะ 2 ชั้นถูก หาไม่เจอ (ผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้คง focus เดิมจาก frame ก่อน (lastFocus) ไม่ re-scan / lastRef เคลียร์เฉพาะ pointerdown, finish และเมื่อหน้า anchor โดน virtualization unmount กลางลาก (anchorPageEl.isConnected — กัน commit ของเก่าเงียบ ๆ) — frame ที่ rectsInSweptPath กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview / ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า) ห้าม bail — clampToAnchorPage clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) boundary cache ต่อ gesture (clampBounds ไม่เดิน TreeWalker ทุก frame) / จุดเส้นทางกรอง rectsInSweptPath ใช้จุด offset เดียวกับ caret (anchorPathPt = จุด probe ที่ resolve anchor + pathEnd = จุด focus หัก 12px) — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกินบรรทัด (ลากลง) เมื่อฟอนต์เล็ก
(3) ถอด path เดิมทั้งหมด: touchConfirm state, touchSelRef/touchSelTimerRef/touchHideTimerRef, debounce 400ms ใน effect selectionchange, handleTouchConfirmHighlight, ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — desktop (pointer หลัก fine) ใช้ native mouse selection + mouseup commit ตามเดิมไม่เปลี่ยน (enabled = tool highlight + matchMedia coarse) / ระหว่าง enabled ให้ <main> ได้ class .pdf-custom-select (user-select/-webkit-user-select/-webkit-touch-callout: none + touch-action: none กัน browser ยึด gesture เป็น scroll → pointercancel) ถอดทันทีเมื่อเปลี่ยน tool (tool อื่น native selection + long-press copy ของ OS ตามปกติ) + restore user-select:text และ touch-action:auto ให้ input/textarea/contenteditable ใต้ main (reply ของ comment pin ยังเลือก/เลื่อนข้อความในช่องได้)
(4) ย้าย ClientPt + rectsInSweptPath จาก PDFViewer ไป utils/coordinates.ts (hook import ได้โดยไม่ circular) + unit tests rectsInSweptPath 4 เคส (ลากบรรทัดเดียว/ลากขึ้นหลายบรรทัด/บรรทัดนอกเส้นทาง/ไม่ขยับ) / gap-analysis.md แถว 5.3.18 อัปเดตเป็นกลไกใหม่ (รวมงานฉบับแก้ไขข้อความข้อ 5.3.18–5.3.25 ของเจ้าของเอกสารที่ค้างใน working tree) / sync rules/pdf-viewer.md ครบทั้งกลไก 4 ชั้น + path points + กรณีหน้า unmount
verify: tsc ผ่าน + vitest 104/104 + Playwright touch emulation 3 ชุด (รอยต่อ span คำติดกัน gap=0/ขอบซ้าย-ขวาสุดบรรทัด/ช่องว่าง line-height, ลากช้า-เร็ว flick/ล้ำหน้าถัดไปทั้งลงและขึ้น/ขยับต่ำกว่า threshold ไม่สร้าง/tap annotation ได้, จุดว่างใกล้ 20px → commit vs ไกล 46px → ไม่สร้าง) — console.warn ชุด TEMP-DEBUG คงไว้ชั่วคราวตาม protocol รอยืนยันบนมือถือจริง (Safari iOS + Chrome Android) ก่อนถอด
Co-Authored-By: Claude Code <noreply@anthropic.com>
27 KiB
27 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บน 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→ fallbackcaretPositionFromPoint→ ไม่มี = 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; restoreuser-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]+findFirstTextNodeDFS → ② 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) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch) - คลุมข้อความได้ 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 เปิดอยู่)