From 79b38fa152edac8e70dd840aa63309e18f323445 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 15 Sep 2026 17:35:13 +0700 Subject: [PATCH] fix: selection preview not appearing when dragging with pen --- hooks/useCustomSelection.ts | 1351 +++++++++++++++++------------------ 1 file changed, 669 insertions(+), 682 deletions(-) diff --git a/hooks/useCustomSelection.ts b/hooks/useCustomSelection.ts index af0a243..2f82e98 100644 --- a/hooks/useCustomSelection.ts +++ b/hooks/useCustomSelection.ts @@ -1,682 +1,669 @@ -"use client"; - -import { useEffect, useRef, useState, type RefObject } from "react"; -import { - mergeRectsByLine, - rectToPercent, - rectsInSweptPath, - type ClientPt, - type Rect, -} from "@/utils/coordinates"; - -// Custom text selection สำหรับจอสัมผัส (tool highlight) — ไม่พึ่ง native selection -// ของ OS เพราะ browser ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release -// ทำไม่ได้ — hook นี้ลากด้วย Pointer Events ชุดเดียว (mouse + touch), สร้าง Range เอง -// จาก caret ตำแหน่งนิ้ว, วาด live preview ผ่าน rAF แล้ว commit ทันทีตอนปล่อย -// (desktop ไม่ใช้ hook นี้ — enabled ผู้เรียกเป็น tool highlight + pointer หลัก coarse) - -/** class ที่ container ได้ตอน enabled — ปิด native selection/callout/scroll ของ OS - * ให้ gesture ลากเป็นของเราทั้งหมด (นิยามใน app/globals.css) */ -const CONTAINER_CLASS = "pdf-custom-select"; - -/** เลื่อนเกินระยะนี้จากจุดกดจึงถือว่าเริ่ม select จริง (ต่ำกว่านี้ = tap ธรรมดา - * ไม่ capture pointer ไม่สร้าง selection — click ของ annotation/pin ใช้ได้ตามเดิม) */ -const MOVE_THRESHOLD_PX = 8; -/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก - * (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */ -const FINGER_OFFSET_PX = 12; -/** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = auto-scroll ต่อ (rAF loop) */ -const AUTOSCROLL_MARGIN_PX = 60; -const AUTOSCROLL_MAX_SPEED_PX = 12; - -/** preview ที่วาดซ้อนบนหน้า — rects เป็น % ของหน้า รวบบรรทัดแล้ว (รูปร่างเดียว - * กับ liveMark ของ desktop) ผู้เรียกเอาไป render ใน highlight-layer ได้เลย */ -export type CustomSelectionPreview = { pageIndex: number; rects: Rect[] } | null; - -/** สิ่งที่ส่งเข้า onCommit ตอนปล่อยนิ้ว — start/end เป็นเส้นทางลากจริง (client - * coords) สำหรับกรอง rect ตามแนวลากด้วย rectsInSweptPath เหมือน desktop */ -export type CustomSelectionCommit = { - range: Range; - start: ClientPt; - end: ClientPt; -}; - -/** caret (collapsed Range) ที่จุด (x, y) ของ viewport — WebKit ก่อนแล้วค่อย spec API - * ของ Firefox ถ้าไม่มีทั้งคู่ = null (bail เงียบ ๆ ไม่เริ่ม selection) */ -function caretFromPoint(x: number, y: number): Range | null { - const doc = document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - caretPositionFromPoint?: ( - x: number, - y: number, - ) => { offsetNode: Node; offset: number } | null; - }; - if (typeof doc.caretRangeFromPoint === "function") { - return doc.caretRangeFromPoint(x, y); - } - if (typeof doc.caretPositionFromPoint === "function") { - const pos = doc.caretPositionFromPoint(x, y); - if (!pos) return null; - const r = document.createRange(); - r.setStart(pos.offsetNode, pos.offset); - r.collapse(true); - return r; - } - return null; -} - -/** หา text node แรกใน subtree (DFS) */ -function findFirstTextNode(node: Node): Node | null { - if (node.nodeType === Node.TEXT_NODE) return node; - for (const child of Array.from(node.childNodes)) { - const found = findFirstTextNode(child); - if (found) return found; - } - return null; -} - -/** เหมือน caretFromPoint แต่ guarantee ว่าถ้าหา text node ได้จะคืน Range ที่ - * startContainer เป็น TEXT_NODE เสมอ — กันเคสตกรอยต่อ span/margin/padding - * ที่ caretRangeFromPoint คืน element แทน text node (ทำ onPointerDown bail - * เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1) */ -function resolveTextCaret(x: number, y: number): Range | null { - const first = caretFromPoint(x, y); - const fromElement = (r: Range | null): Range | null => { - if (!r) return null; - if (r.startContainer.nodeType === Node.TEXT_NODE) return r; - // startContainer เป็น element (เช่น span คำ, หรือ textLayer เอง) — - // หา text node ลูกที่ offset ชี้ถึง แล้วสร้าง Range ใหม่ที่ต้นของมัน - const el = r.startContainer; - const child = - el.childNodes[r.startOffset] ?? - el.childNodes[el.childNodes.length - 1]; - const textNode = - child?.nodeType === Node.TEXT_NODE ? child : findFirstTextNode(child ?? el); - if (!textNode) return null; - const nr = document.createRange(); - nr.setStart(textNode, 0); - nr.collapse(true); - return nr; - }; - - const resolved = fromElement(first); - if (resolved) return resolved; - - // ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม (รอยต่อบางกว่า 2-3px มักพอ) - const offsets: Array<[number, number]> = [ - [0, -2], [0, 2], [-2, 0], [2, 0], - [0, -3], [0, 3], [-3, 0], [3, 0], - ]; - for (const [dx, dy] of offsets) { - const r = fromElement(caretFromPoint(x + dx, y + dy)); - if (r) return r; - } - return null; -} - -/** รัศมีค้นหา span ข้อความใกล้สุดจากจุดแตะ (px แนวตั้ง) — เกินนี้ถือว่าจุดนั้นไม่มี - * ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */ -const NEAREST_SPAN_MAX_PX = 40; - -/** cache รายชื่อ span ของหน้า ต่อ 1 gesture — query ครั้งเดียว (textLayer ไม่ - * เปลี่ยนระหว่างลาก) ใช้กับการหา span ใกล้สุดเฉพาะตอน pointerdown */ -type SpanCache = { pageEl: HTMLElement; spans: Element[] } | null; - -/** caret ที่ต้น text node แรกของ span — offset 0 พอ (กลางคำก็ได้ เพราะ - * snapCaretToWord จะขยายเต็มคำให้อยู่แล้ว) */ -function caretAtSpanStart(span: Element): Range | null { - const t = findFirstTextNode(span); - if (!t) return null; - const r = document.createRange(); - r.setStart(t, 0); - r.collapse(true); - return r; -} - -/** ชั้น 3: เจาะผ่าน overlay ที่ซ้อนอยู่เหนือข้อความ (SVG ของ InkLayer / มาร์ก / - * กรอบ selection — ล้วนไร้ text node ใน subtree จน caret API คืนอะไรมาใช้ไม่ได้) - * ด้วย elementsFromPoint ที่ได้ทุก element ใต้จุด ไม่ใช่แค่ตัวบนสุด แล้วจับ span - * ข้อความที่อยู่ใต้จุดพอดีตามเรขาคณิตตัวแรก */ -function spanFromPointStack(x: number, y: number): Range | null { - for (const el of document.elementsFromPoint(x, y)) { - if (!(el instanceof Element)) continue; - // เฉพาะ span ของ textLayer เท่านั้น (ตัว container .textLayer เองไม่ใช่ — - // จุดไม่ได้อยู่บน span ไหนเลยเป็นงานของการหาใกล้สุดในชั้นถัดไป) - if (el.tagName !== "SPAN" || !el.closest(".textLayer")) continue; - const caret = caretAtSpanStart(el); - if (caret) return caret; - } - return null; -} - -/** ชั้น 4 (สุดท้าย): span ข้อความที่ใกล้จุดที่สุดในแนวตั้งของหน้าที่จุดนั้นอยู่ — - * บรรทัดถัดไป/ก่อนหน้าที่ใกล้เจตนาผู้ใช้ที่สุด (gap ระหว่างบรรทัด/section - * กว้างกว่า retry radius ±3px) จำกัดระยะไม่เกิน NEAREST_SPAN_MAX_PX — - * cache รายชื่อ span ไว้ใน drag เพื่อไม่ query ซ้ำต่อการเรียกแต่ละจุด */ -function nearestSpanCaret( - x: number, - y: number, - cache: { spanCache: SpanCache }, -): Range | null { - const pageEl = - document.elementFromPoint(x, y)?.closest("[data-page]") ?? null; - if (!pageEl) return null; - if (!cache.spanCache || cache.spanCache.pageEl !== pageEl) { - cache.spanCache = { - pageEl, - spans: Array.from(pageEl.querySelectorAll(".textLayer span")), - }; - } - let best: Element | null = null; - let bestDy = Infinity; - let bestDx = Infinity; - for (const s of cache.spanCache.spans) { - const r = s.getBoundingClientRect(); - if (r.width === 0 && r.height === 0) continue; - const dy = y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0; - if (dy > NEAREST_SPAN_MAX_PX) continue; - const dx = Math.abs(r.left + r.width / 2 - x); - if (dy < bestDy || (dy === bestDy && dx < bestDx)) { - best = s; - bestDy = dy; - bestDx = dx; - } - } - return best ? caretAtSpanStart(best) : null; -} - -/** resolveTextCaret ครบทั้ง 4 ชั้น (เรียงตามต้นทุน): caret API + ขยับ ±3px → - * เจาะผ่าน overlay ด้วย elementsFromPoint → span ใกล้สุด ≤40px — ชั้นที่เพิ่มมา - * แพงกว่า caret API (query DOM หลาย element + วัด rect) จึงใช้เฉพาะตอน - * pointerdown เท่านั้น ระหว่างลากใช้ชั้นถูก + คง focus เดิมแทน (perf) */ -function resolveTextCaretDeep( - x: number, - y: number, - cache: { spanCache: SpanCache }, -): Range | null { - return ( - resolveTextCaret(x, y) ?? - spanFromPointStack(x, y) ?? - nearestSpanCaret(x, y, cache) - ); -} - -let segmenter: Intl.Segmenter | null | undefined; -function getSegmenter(): Intl.Segmenter | null { - if (segmenter === undefined) { - segmenter = - typeof Intl !== "undefined" && "Segmenter" in Intl - ? new Intl.Segmenter(undefined, { granularity: "word" }) - : null; - } - return segmenter; -} - -/** ขยาย caret ไปขอบคำที่ครอบตำแหน่งนั้น (word snap — ชดเชยความหนาของนิ้วที่จิ้ม - * กลางคำแล้วได้ทั้งคำ เหมือน selection ของ OS) — ใช้ Intl.Segmenter ถ้ามี (แยกคำ - * ไทยได้ด้วย dictionary) fallback เป็น regex \S+ — ถ้า caret อยู่บนช่องว่างล้วน - * หรือไม่ใช่ text node = ไม่ขยาย */ -function snapCaretToWord(caret: Range): void { - const node = caret.startContainer; - if (node.nodeType !== Node.TEXT_NODE) return; - const text = node.textContent ?? ""; - const off = caret.startOffset; - const seg = getSegmenter(); - if (seg) { - // offset ตรงปลายคำ = นับเป็นคำถัดไป (ถ้าเป็นคำ → snap คำนั้น, ช่องว่าง → ไม่ขยาย) - // (ใช้ Array.from แทน for..of — tsconfig ไม่ตั้ง target จึงห้าม iterate ตรง ๆ) - const s = Array.from(seg.segment(text)).find( - (seg2) => off >= seg2.index && off < seg2.index + seg2.segment.length, - ); - if (s?.isWordLike) { - caret.setStart(node, s.index); - caret.setEnd(node, s.index + s.segment.length); - } - return; - } - let s = off; - while (s > 0 && /\S/.test(text[s - 1] ?? "")) s--; - let e = off; - while (e < text.length && /\S/.test(text[e] ?? "")) e++; - if (s === e) return; // กลางช่องว่าง — ไม่ขยาย - caret.setStart(node, s); - caret.setEnd(node, e); -} - -/** Range จาก caret สองจุด (จัดลำดับ start/end ให้ถูกทิศการลาก) — null เมื่อคนละ - * tree จน compare ไม่ได้ */ -function rangeBetween(a: Range, b: Range): Range | null { - const r = document.createRange(); - try { - if (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0) { - r.setStart(a.startContainer, a.startOffset); - r.setEnd(b.startContainer, b.startOffset); - } else { - r.setStart(b.startContainer, b.startOffset); - r.setEnd(a.startContainer, a.startOffset); - } - } catch { - return null; - } - return r; -} - -/** หน้า PDF ที่ range นี้อยู่ (เฉพาะ selection ใน page เดียว — พ้นหน้า/ข้ามหน้า = null) */ -function pageOfRange(range: Range): HTMLElement | null { - const c = range.commonAncestorContainer; - const el = c instanceof Element ? c : c.parentElement; - return el?.closest("[data-page]") ?? null; -} - -/** ลากล้ำเข้าหน้าอื่น (range คร่อม 2 หน้า) — clamp ฝั่ง focus ไปที่ท้ายหน้า (ลาก - * ลง) หรือต้นหน้า (ลากขึ้น) ของ anchor เพื่อให้ selection จบที่ขอบหน้า anchor - * แทนการหายทั้งเส้น (viewer เลื่อนต่อเนื่อง เกิดบ่อยตอนลากใกล้ขอบล่างหน้า) - * คืน range ที่ clamp แล้ว หรือ null เมื่อหน้า anchor ไม่มี text node เลย (สแกน) - * boundary ไม่เปลี่ยนระหว่างลากจึง cache ต่อ gesture (เดิน TreeWalker ทุก text - * node ของ textLayer แพงเกินจะทำทุก rAF frame — code review 💡4) */ -function clampToAnchorPage( - anchorPageEl: HTMLElement, - anchor: Range, - focus: Range, - cache: { clampBounds: { first: Text; last: Text } | null }, -): Range | null { - let bounds = cache.clampBounds; - if (!bounds || !bounds.first.isConnected || !bounds.last.isConnected) { - const layer = anchorPageEl.querySelector(".textLayer"); - if (!layer) return null; - const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT); - let first: Text | null = null; - let last: Text | null = null; - for (let n = walker.nextNode(); n; n = walker.nextNode()) { - const t = n as Text; - if ((t.textContent ?? "").trim().length === 0) continue; - if (!first) first = t; - last = t; - } - if (!first || !last) return null; - bounds = { first, last }; - cache.clampBounds = bounds; - } - const boundary = document.createRange(); - // ทิศลาก: focus อยู่หลัง anchor (ลากลง) = จบที่ท้ายหน้า anchor ก่อนข้ามไปหน้าถัดไป - // กลับกัน (ลากขึ้นจากหน้าถัดไป) = เริ่มที่ต้นหน้า anchor แทนหน้าที่อยู่เหนือกว่า - if (anchor.compareBoundaryPoints(Range.START_TO_START, focus) < 0) { - boundary.setStart(bounds.last, bounds.last.length); - } else { - boundary.setStart(bounds.first, 0); - } - boundary.collapse(true); - return rangeBetween(anchor, boundary); -} - -type DragState = { - pointerId: number; - pointerType: string; - /** จุด pointerdown จริง (client) — ground truth ของเส้นทางลาก ใช้กรอง rect */ - anchorPt: ClientPt; - /** ตำแหน่ง pointer ล่าสุด */ - curPt: ClientPt; - /** caret ที่จุดกด (word-snap ตอนเริ่ม select จริง — ครั้งเดียว) */ - anchor: Range; - /** จุด probe ที่ resolve anchor ได้ (จอสัมผัส = จุด y−12 หรือจุดแตะจริงลูกใด - * ลูกหนึ่ง) — ใช้เป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง - * (ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨) */ - anchorPathPt: ClientPt; - /** หน้าของ anchor — เก็บตั้งแต่ pointerdown (anchor ไม่ย้ายหน้าระหว่างลาก) - * ใช้ clamp ตอนลากล้ำเข้าหน้าถัดไป (จุดที่ 3 ของ bug report) */ - anchorPageEl: HTMLElement; - /** boundary text node แรก/สุดท้ายของ textLayer หน้า anchor — cache ต่อ gesture - * ให้ clampToAnchorPage ไม่เดิน TreeWalker ใหม่ทุก frame (code review 💡4) */ - clampBounds: { first: Text; last: Text } | null; - /** focus caret ล่าสุดที่ resolve สำเร็จ — ระหว่างลากผ่านพื้นที่ว่าง (ชั้นถูก - * ของ resolveTextCaret หาไม่เจอ) ให้คงตำแหน่งเดิมไว้แทน re-scan ทุก frame */ - lastFocus: Range | null; - anchorSnapped: boolean; - /** เลย threshold 8px แล้วหรือยัง (false = tap ที่ยังไม่ขยับ) */ - active: boolean; -}; - -/** - * @param containerRef element ที่รับ pointer ของทั้งหน้า (ผ่านการ bubble จาก - * textLayer) — ตอน enabled จะได้ class .pdf-custom-select และถอดทันทีเมื่อปิด - * @param enabled เปิดเฉพาะช่วงที่ต้องการลากเลือกข้อความเอง (tool highlight + จอสัมผัส) - * @param onCommit เรียกตอนปล่อยนิ้วเมื่อ range ไม่ collapsed (identity เปลี่ยนกี่ครั้ง - * ก็ได้ — hook เก็บใน ref ไม่ถอด listener ใหม่) - */ -export function useCustomSelection({ - containerRef, - enabled, - onCommit, -}: { - containerRef: RefObject; - enabled: boolean; - onCommit: (payload: CustomSelectionCommit) => void; -}): { previewRects: CustomSelectionPreview } { - const [previewRects, setPreviewRects] = - useState(null); - - const dragRef = useRef(null); - /** range + เส้นทางลากล่าสุดที่ preview คำนวณแล้ว — ใช้ commit ตอนปล่อยนิ้ว */ - const lastRef = useRef(null); - const previewRafRef = useRef(null); - const scrollRafRef = useRef(null); - - // onCommit เก็บใน ref — effect ผูก listener ครั้งเดียวต่อช่วง enabled ไม่ re-attach - const onCommitRef = useRef(onCommit); - onCommitRef.current = onCommit; - - useEffect(() => { - const el = containerRef.current; - if (!el || !enabled) return; - el.classList.add(CONTAINER_CLASS); - - const stopPreviewRaf = () => { - if (previewRafRef.current !== null) { - cancelAnimationFrame(previewRafRef.current); - previewRafRef.current = null; - } - }; - const stopAutoScroll = () => { - if (scrollRafRef.current !== null) { - cancelAnimationFrame(scrollRafRef.current); - scrollRafRef.current = null; - } - }; - - /** คำนวณ preview จาก anchor↔focus ปัจจุบัน — เรียกจาก rAF (throttle) และตอน - * ปล่อยนิ้ว (synchronous เพื่อเก็บตำแหน่งสุดท้ายก่อน commit) */ - const updatePreview = () => { - const d = dragRef.current; - if (!d || !d.active) return; - // หน้า anchor ถูก virtualization unmount กลางลาก (auto-scroll ไกลจนพ้น - // ช่วง render) — anchor/focus ชี้ detached nodes, rects ว่างหมด: เคลียร์ - // lastRef ด้วย กัน commit ของเก่าเงียบ ๆ (code review ⚠️ — จุดเคลียร์ - // lastRef เพิ่มจาก pointerdown/finish ตามเหตุการณ์ "หน้าหาย" เท่านั้น) - if (!d.anchorPageEl.isConnected) { - lastRef.current = null; - setPreviewRects(null); - return; - } - const y = d.curPt.y - (d.pointerType === "touch" ? FINGER_OFFSET_PX : 0); - // ระหว่างลากใช้เฉพาะชั้นถูกของ resolveTextCaret — ผ่านพื้นที่ว่าง (gap กว้าง / - // ช่องว่างระหว่างหน้า) หาไม่เจอก็คง focus เดิมจาก frame ก่อนไว้แทน re-scan - // ด้วยชั้นแพง (perf note ของ bug report — selection หยุดรอที่ข้อความสุดท้าย - // แล้วขยายต่อเมื่อนิ้วถึงข้อความอีกครั้ง) - const focus = resolveTextCaret(d.curPt.x, y) ?? d.lastFocus; - if (!focus) { - console.warn("bail: focus caret null"); // TEMP-DEBUG - return; - } - d.lastFocus = focus.cloneRange(); - if (!d.anchorSnapped) { - snapCaretToWord(d.anchor); - d.anchorSnapped = true; - } - snapCaretToWord(focus); - let range = rangeBetween(d.anchor, focus); - let pageEl = range && !range.collapsed ? pageOfRange(range) : null; - // ลากล้ำเข้าหน้าอื่น (คร่อม 2 หน้า หรือ focus ข้ามไปหน้าถัดไปแล้ว) — clamp - // ฝั่ง focus ไว้ที่ท้าย/ต้นหน้าของ anchor แทนการทิ้ง selection ทั้งเส้น - // (จุดที่ 3 ของ bug report — ข้ามเมื่อ range collapsed: anchor==focus ใน - // หน้าเดียว ไม่ใช่ข้ามหน้า ห้ามนำไป clamp กลายเป็น selection ตั้งแต่ต้นหน้า) - if (range && !range.collapsed && pageEl !== d.anchorPageEl) { - const clamped = clampToAnchorPage(d.anchorPageEl, d.anchor, focus, d); - console.warn("clamp: cross-page"); // TEMP-DEBUG - range = clamped && !clamped.collapsed ? clamped : null; - pageEl = range ? d.anchorPageEl : null; - } - // หมายเหตุ (จุดที่ 2): branch พวกนี้เคลียร์แค่ preview — ห้ามแตะ lastRef - // เพื่อให้ commit ใช้ selection ล่าสุดที่ไม่ว่างจาก frame ก่อนหน้า - if (!range || !pageEl) { - setPreviewRects(null); - return; - } - const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0); - // เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จุด y−12 สำหรับ - // จอสัมผัส) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาด 12px จนบรรทัดเป้าหมายโดน - // lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก - // เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨) - const pathEnd: ClientPt = - d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt; - const kept = rectsInSweptPath(rects, d.anchorPathPt, pathEnd); - if (kept.length === 0) { - console.warn("bail: swept path empty"); // TEMP-DEBUG - setPreviewRects(null); - return; - } - const pr = pageEl.getBoundingClientRect(); - const rectsPct = mergeRectsByLine( - kept.map((r) => - rectToPercent( - { - x: r.left - pr.left, - y: r.top - pr.top, - width: r.width, - height: r.height, - }, - { width: pr.width, height: pr.height }, - ), - ), - ); - lastRef.current = { - range: range.cloneRange(), - start: d.anchorPathPt, - end: pathEnd, - }; - const pageIndex = Number(pageEl.dataset.page); - setPreviewRects((prev) => - prev !== null && - prev.pageIndex === pageIndex && - prev.rects.length === rectsPct.length && - prev.rects.every( - (r, i) => - r.x === rectsPct[i].x && - r.y === rectsPct[i].y && - r.width === rectsPct[i].width && - r.height === rectsPct[i].height, - ) - ? prev - : { pageIndex, rects: rectsPct }, - ); - }; - - const schedulePreview = () => { - if (previewRafRef.current !== null) return; - previewRafRef.current = requestAnimationFrame(() => { - previewRafRef.current = null; - updatePreview(); - }); - }; - - /** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview - * ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */ - const startAutoScroll = () => { - if (scrollRafRef.current !== null) return; - const step = () => { - scrollRafRef.current = null; - const d = dragRef.current; - if (!d?.active) return; - const vh = window.innerHeight; - let dy = 0; - if (d.curPt.y < AUTOSCROLL_MARGIN_PX) { - dy = -Math.max( - 1, - Math.ceil( - ((AUTOSCROLL_MARGIN_PX - d.curPt.y) / AUTOSCROLL_MARGIN_PX) * - AUTOSCROLL_MAX_SPEED_PX, - ), - ); - } else if (d.curPt.y > vh - AUTOSCROLL_MARGIN_PX) { - dy = Math.max( - 1, - Math.ceil( - ((d.curPt.y - (vh - AUTOSCROLL_MARGIN_PX)) / AUTOSCROLL_MARGIN_PX) * - AUTOSCROLL_MAX_SPEED_PX, - ), - ); - } - if (dy !== 0) { - window.scrollBy(0, dy); - schedulePreview(); - } - scrollRafRef.current = requestAnimationFrame(step); - }; - scrollRafRef.current = requestAnimationFrame(step); - }; - - /** TEMP-DEBUG: เก็บ pointerId ที่เคย log move แรกแล้ว — ทำลายทุก pointerdown - * ใหม่ (กัน hover ของ pencil กินสล็อต "move แรก" ก่อนปากกาแตะจอ) */ - const seenMovePointerIds = new Set(); - - const onPointerDown = (e: PointerEvent) => { - console.warn("[debug] pointerdown", { - pointerType: e.pointerType, - clientX: e.clientX, - clientY: e.clientY, - }); // TEMP-DEBUG - seenMovePointerIds.clear(); // TEMP-DEBUG - if (dragRef.current) { - // นิ้วที่สองระหว่างลาก — ไม่สน รวมถึง touch ปลอมจาก palm rejection (มือ - // ที่พักบนจอระหว่างลากด้วย pen) — ห้ามให้ pointer ตัวใหม่แทนที่ drag เดิม - console.warn("[debug] ignored second pointer", { - activeType: dragRef.current.pointerType, - newType: e.pointerType, - }); // TEMP-DEBUG - return; - } - // เริ่ม selection ได้เฉพาะที่เริ่มบน textLayer (แตะ pin/ปุ่ม/กล่องข้อความ = งานของมัน) - if ( - !(e.target instanceof Element) || - !e.target.closest(".textLayer") - ) { - console.warn( - "bail: down ไม่ได้เริ่มบน textLayer", - (e.target instanceof Element ? e.target.className.toString() : "").slice(0, 40), - ); // TEMP-DEBUG - return; - } - const y = e.clientY - (e.pointerType === "touch" ? FINGER_OFFSET_PX : 0); - // ค้น anchor ครบทั้ง 4 ชั้น ทีละจุด: จุดชดเชยนิ้ว (y-12) ก่อน แล้วจุดที่แตะ - // จริง (finger offset เป็น heuristic — บางจังหวะจุดจริงต่างหากที่อยู่บนข้อความ) - // ชั้น 3-4 (เจาะ overlay / span ใกล้สุด ≤40px) แพงกว่า caret API จึงรัน - // เฉพาะตรงนี้ตอนกด — ระหว่างลากใช้ชั้นถูกอย่างเดียว (cache ต่อ 1 gesture) - const cache: { spanCache: SpanCache } = { spanCache: null }; - const probes = - e.pointerType === "touch" ? [y, e.clientY] : [y]; - let caret: Range | null = null; - let resolvedY = y; - for (const py of probes) { - caret = resolveTextCaretDeep(e.clientX, py, cache); - if (caret) { - resolvedY = py; - break; - } - } - if (!caret) { - // จุดกดไม่เกี่ยวกับข้อความเลย (แม้ค้น text node รอบ ๆ แล้ว) — เป็น tap - // ธรรมดา ไม่เริ่ม selection (click ของ annotation/pin ทำงานตามเดิม) - console.warn("bail: resolveTextCaret failed after retries", { x: e.clientX, y }); // TEMP-DEBUG - return; - } - const anchorPageEl = pageOfRange(caret); - if (!anchorPageEl) return; - // gesture ใหม่ = ล้างผลล่าสุดของ gesture ก่อน (จุดที่ 2 ของ bug report: - // lastRef เคลียร์เฉพาะตรงนี้กับตอน finish เท่านั้น) - lastRef.current = null; - dragRef.current = { - pointerId: e.pointerId, - pointerType: e.pointerType, - anchorPt: { x: e.clientX, y: e.clientY }, - anchorPathPt: { x: e.clientX, y: resolvedY }, - curPt: { x: e.clientX, y: e.clientY }, - anchor: caret, - anchorPageEl, - clampBounds: null, - lastFocus: null, - anchorSnapped: false, - active: false, - }; - }; - - const onPointerMove = (e: PointerEvent) => { - // TEMP-DEBUG: log เฉพาะ move แรกของ pointer หลัง pointerdown ล่าสุด — - // pen/touch ยิง 60–120Hz ถ้า log ทุก event จะท่วม console และหน่วงเครื่อง - // จนเปลี่ยนพฤติกรรมที่กำลังวินิจฉัยอยู่เอง - if (!seenMovePointerIds.has(e.pointerId)) { - seenMovePointerIds.add(e.pointerId); - console.warn("[debug] pointermove (แรกของ pointer นี้หลัง down)", { - pointerType: e.pointerType, - pointerId: e.pointerId, - hasActiveDrag: !!dragRef.current, - }); // TEMP-DEBUG - } - const d = dragRef.current; - if (!d || e.pointerId !== d.pointerId) return; - d.curPt = { x: e.clientX, y: e.clientY }; - if (!d.active) { - if ( - Math.hypot(e.clientX - d.anchorPt.x, e.clientY - d.anchorPt.y) < - MOVE_THRESHOLD_PX - ) { - return; - } - d.active = true; - console.warn("debug: select เริ่ม (activated)"); // TEMP-DEBUG - // capture ตอน"เริ่ม select จริง"ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ - // retarget click ไปที่ container จน tap เลือก annotation / ปุ่ม pin ใช้ไม่ได้ - // (tap ที่ไม่ขยับเลย threshold = ไม่ capture → click ไปถึงเป้าหมายตามเดิม) - el.setPointerCapture(d.pointerId); - startAutoScroll(); - } - schedulePreview(); - }; - - /** เคลียร์ state ทุกทาง — commit เฉพาะตอน pointerup ที่มี range ไม่ collapsed - * (pointercancel จาก gesture ของระบบ = ทิ้ง selection ไม่ commit) */ - const finish = (e: PointerEvent, commit: boolean) => { - // TEMP-DEBUG: แยก pointerup กับ pointercancel — cancel = ระบบยึด gesture ไป - // (คู่สมมติฐานหลักของ "ลากไม่ติด" บน pencil นอกเหนือจาก move ไม่มาถึง) - console.warn(`[debug] ${commit ? "pointerup" : "pointercancel"}`, { - pointerType: e.pointerType, - pointerId: e.pointerId, - }); // TEMP-DEBUG - const d = dragRef.current; - if (!d || e.pointerId !== d.pointerId) return; - stopAutoScroll(); - stopPreviewRaf(); - if (d.active) { - updatePreview(); // synchronous — ตำแหน่งสุดท้ายก่อน commit (rAF อาจยังค้าง) - } - dragRef.current = null; - setPreviewRects(null); - const last = lastRef.current; - lastRef.current = null; - if (commit && last && !last.range.collapsed) { - onCommitRef.current(last); - } - }; - - el.addEventListener("pointerdown", onPointerDown); - el.addEventListener("pointermove", onPointerMove); - const onUp = (e: PointerEvent) => finish(e, true); - const onCancel = (e: PointerEvent) => finish(e, false); - el.addEventListener("pointerup", onUp); - el.addEventListener("pointercancel", onCancel); - - return () => { - el.classList.remove(CONTAINER_CLASS); - el.removeEventListener("pointerdown", onPointerDown); - el.removeEventListener("pointermove", onPointerMove); - el.removeEventListener("pointerup", onUp); - el.removeEventListener("pointercancel", onCancel); - stopAutoScroll(); - stopPreviewRaf(); - dragRef.current = null; - lastRef.current = null; - setPreviewRects(null); - }; - // containerRef เป็น ref object ที่ identity คงที่ตลอด life ของ component - }, [enabled, containerRef]); - - return { previewRects }; -} +"use client"; + +import { useEffect, useRef, useState, type RefObject } from "react"; +import { + mergeRectsByLine, + rectToPercent, + rectsInSweptPath, + type ClientPt, + type Rect, +} from "@/utils/coordinates"; + +// Custom text selection สำหรับจอสัมผัส (tool highlight) — ไม่พึ่ง native selection +// ของ OS เพราะ browser ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release +// ทำไม่ได้ — hook นี้ลากด้วย Pointer Events ชุดเดียว (mouse + touch), สร้าง Range เอง +// จาก caret ตำแหน่งนิ้ว, วาด live preview ผ่าน rAF แล้ว commit ทันทีตอนปล่อย +// (desktop ไม่ใช้ hook นี้ — enabled ผู้เรียกเป็น tool highlight + pointer หลัก coarse) + +/** class ที่ container ได้ตอน enabled — ปิด native selection/callout/scroll ของ OS + * ให้ gesture ลากเป็นของเราทั้งหมด (นิยามใน app/globals.css) */ +const CONTAINER_CLASS = "pdf-custom-select"; + +/** เลื่อนเกินระยะนี้จากจุดกดจึงถือว่าเริ่ม select จริง (ต่ำกว่านี้ = tap ธรรมดา + * ไม่ capture pointer ไม่สร้าง selection — click ของ annotation/pin ใช้ได้ตามเดิม) */ +const MOVE_THRESHOLD_PX = 8; +/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก + * (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */ +const FINGER_OFFSET_PX = 12; +/** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = auto-scroll ต่อ (rAF loop) */ +const AUTOSCROLL_MARGIN_PX = 60; +const AUTOSCROLL_MAX_SPEED_PX = 12; + +/** preview ที่วาดซ้อนบนหน้า — rects เป็น % ของหน้า รวบบรรทัดแล้ว (รูปร่างเดียว + * กับ liveMark ของ desktop) ผู้เรียกเอาไป render ใน highlight-layer ได้เลย */ +export type CustomSelectionPreview = { pageIndex: number; rects: Rect[] } | null; + +/** สิ่งที่ส่งเข้า onCommit ตอนปล่อยนิ้ว — start/end เป็นเส้นทางลากจริง (client + * coords) สำหรับกรอง rect ตามแนวลากด้วย rectsInSweptPath เหมือน desktop */ +export type CustomSelectionCommit = { + range: Range; + start: ClientPt; + end: ClientPt; +}; + +/** caret (collapsed Range) ที่จุด (x, y) ของ viewport — WebKit ก่อนแล้วค่อย spec API + * ของ Firefox ถ้าไม่มีทั้งคู่ = null (bail เงียบ ๆ ไม่เริ่ม selection) */ +function caretFromPoint(x: number, y: number): Range | null { + const doc = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretPositionFromPoint?: ( + x: number, + y: number, + ) => { offsetNode: Node; offset: number } | null; + }; + if (typeof doc.caretRangeFromPoint === "function") { + return doc.caretRangeFromPoint(x, y); + } + if (typeof doc.caretPositionFromPoint === "function") { + const pos = doc.caretPositionFromPoint(x, y); + if (!pos) return null; + const r = document.createRange(); + r.setStart(pos.offsetNode, pos.offset); + r.collapse(true); + return r; + } + return null; +} + +/** หา text node แรกใน subtree (DFS) */ +function findFirstTextNode(node: Node): Node | null { + if (node.nodeType === Node.TEXT_NODE) return node; + for (const child of Array.from(node.childNodes)) { + const found = findFirstTextNode(child); + if (found) return found; + } + return null; +} + +/** เหมือน caretFromPoint แต่ guarantee ว่าถ้าหา text node ได้จะคืน Range ที่ + * startContainer เป็น TEXT_NODE เสมอ — กันเคสตกรอยต่อ span/margin/padding + * ที่ caretRangeFromPoint คืน element แทน text node (ทำ onPointerDown bail + * เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1) */ +function resolveTextCaret(x: number, y: number): Range | null { + const first = caretFromPoint(x, y); + const fromElement = (r: Range | null): Range | null => { + if (!r) return null; + if (r.startContainer.nodeType === Node.TEXT_NODE) return r; + // startContainer เป็น element (เช่น span คำ, หรือ textLayer เอง) — + // หา text node ลูกที่ offset ชี้ถึง แล้วสร้าง Range ใหม่ที่ต้นของมัน + const el = r.startContainer; + const child = + el.childNodes[r.startOffset] ?? + el.childNodes[el.childNodes.length - 1]; + const textNode = + child?.nodeType === Node.TEXT_NODE ? child : findFirstTextNode(child ?? el); + if (!textNode) return null; + const nr = document.createRange(); + nr.setStart(textNode, 0); + nr.collapse(true); + return nr; + }; + + const resolved = fromElement(first); + if (resolved) return resolved; + + // ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม (รอยต่อบางกว่า 2-3px มักพอ) + const offsets: Array<[number, number]> = [ + [0, -2], [0, 2], [-2, 0], [2, 0], + [0, -3], [0, 3], [-3, 0], [3, 0], + ]; + for (const [dx, dy] of offsets) { + const r = fromElement(caretFromPoint(x + dx, y + dy)); + if (r) return r; + } + return null; +} + +/** รัศมีค้นหา span ข้อความใกล้สุดจากจุดแตะ (px แนวตั้ง) — เกินนี้ถือว่าจุดนั้นไม่มี + * ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */ +const NEAREST_SPAN_MAX_PX = 40; + +/** cache รายชื่อ span ของหน้า ต่อ 1 gesture — query ครั้งเดียว (textLayer ไม่ + * เปลี่ยนระหว่างลาก) ใช้กับการหา span ใกล้สุดเฉพาะตอน pointerdown */ +type SpanCache = { pageEl: HTMLElement; spans: Element[] } | null; + +/** caret ที่ต้น text node แรกของ span — offset 0 พอ (กลางคำก็ได้ เพราะ + * snapCaretToWord จะขยายเต็มคำให้อยู่แล้ว) */ +function caretAtSpanStart(span: Element): Range | null { + const t = findFirstTextNode(span); + if (!t) return null; + const r = document.createRange(); + r.setStart(t, 0); + r.collapse(true); + return r; +} + +/** ชั้น 3: เจาะผ่าน overlay ที่ซ้อนอยู่เหนือข้อความ (SVG ของ InkLayer / มาร์ก / + * กรอบ selection — ล้วนไร้ text node ใน subtree จน caret API คืนอะไรมาใช้ไม่ได้) + * ด้วย elementsFromPoint ที่ได้ทุก element ใต้จุด ไม่ใช่แค่ตัวบนสุด แล้วจับ span + * ข้อความที่อยู่ใต้จุดพอดีตามเรขาคณิตตัวแรก */ +function spanFromPointStack(x: number, y: number): Range | null { + for (const el of document.elementsFromPoint(x, y)) { + if (!(el instanceof Element)) continue; + // เฉพาะ span ของ textLayer เท่านั้น (ตัว container .textLayer เองไม่ใช่ — + // จุดไม่ได้อยู่บน span ไหนเลยเป็นงานของการหาใกล้สุดในชั้นถัดไป) + if (el.tagName !== "SPAN" || !el.closest(".textLayer")) continue; + const caret = caretAtSpanStart(el); + if (caret) return caret; + } + return null; +} + +/** ชั้น 4 (สุดท้าย): span ข้อความที่ใกล้จุดที่สุดในแนวตั้งของหน้าที่จุดนั้นอยู่ — + * บรรทัดถัดไป/ก่อนหน้าที่ใกล้เจตนาผู้ใช้ที่สุด (gap ระหว่างบรรทัด/section + * กว้างกว่า retry radius ±3px) จำกัดระยะไม่เกิน NEAREST_SPAN_MAX_PX — + * cache รายชื่อ span ไว้ใน drag เพื่อไม่ query ซ้ำต่อการเรียกแต่ละจุด */ +function nearestSpanCaret( + x: number, + y: number, + cache: { spanCache: SpanCache }, +): Range | null { + const pageEl = + document.elementFromPoint(x, y)?.closest("[data-page]") ?? null; + if (!pageEl) return null; + if (!cache.spanCache || cache.spanCache.pageEl !== pageEl) { + cache.spanCache = { + pageEl, + spans: Array.from(pageEl.querySelectorAll(".textLayer span")), + }; + } + let best: Element | null = null; + let bestDy = Infinity; + let bestDx = Infinity; + for (const s of cache.spanCache.spans) { + const r = s.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + const dy = y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0; + if (dy > NEAREST_SPAN_MAX_PX) continue; + const dx = Math.abs(r.left + r.width / 2 - x); + if (dy < bestDy || (dy === bestDy && dx < bestDx)) { + best = s; + bestDy = dy; + bestDx = dx; + } + } + return best ? caretAtSpanStart(best) : null; +} + +/** resolveTextCaret ครบทั้ง 4 ชั้น (เรียงตามต้นทุน): caret API + ขยับ ±3px → + * เจาะผ่าน overlay ด้วย elementsFromPoint → span ใกล้สุด ≤40px — ชั้นที่เพิ่มมา + * แพงกว่า caret API (query DOM หลาย element + วัด rect) จึงใช้เฉพาะตอน + * pointerdown เท่านั้น ระหว่างลากใช้ชั้นถูก + คง focus เดิมแทน (perf) */ +function resolveTextCaretDeep( + x: number, + y: number, + cache: { spanCache: SpanCache }, +): Range | null { + return ( + resolveTextCaret(x, y) ?? + spanFromPointStack(x, y) ?? + nearestSpanCaret(x, y, cache) + ); +} + +let segmenter: Intl.Segmenter | null | undefined; +function getSegmenter(): Intl.Segmenter | null { + if (segmenter === undefined) { + segmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "word" }) + : null; + } + return segmenter; +} + +/** ขยาย caret ไปขอบคำที่ครอบตำแหน่งนั้น (word snap — ชดเชยความหนาของนิ้วที่จิ้ม + * กลางคำแล้วได้ทั้งคำ เหมือน selection ของ OS) — ใช้ Intl.Segmenter ถ้ามี (แยกคำ + * ไทยได้ด้วย dictionary) fallback เป็น regex \S+ — ถ้า caret อยู่บนช่องว่างล้วน + * หรือไม่ใช่ text node = ไม่ขยาย */ +function snapCaretToWord(caret: Range): void { + const node = caret.startContainer; + if (node.nodeType !== Node.TEXT_NODE) return; + const text = node.textContent ?? ""; + const off = caret.startOffset; + const seg = getSegmenter(); + if (seg) { + // offset ตรงปลายคำ = นับเป็นคำถัดไป (ถ้าเป็นคำ → snap คำนั้น, ช่องว่าง → ไม่ขยาย) + // (ใช้ Array.from แทน for..of — tsconfig ไม่ตั้ง target จึงห้าม iterate ตรง ๆ) + const s = Array.from(seg.segment(text)).find( + (seg2) => off >= seg2.index && off < seg2.index + seg2.segment.length, + ); + if (s?.isWordLike) { + caret.setStart(node, s.index); + caret.setEnd(node, s.index + s.segment.length); + } + return; + } + let s = off; + while (s > 0 && /\S/.test(text[s - 1] ?? "")) s--; + let e = off; + while (e < text.length && /\S/.test(text[e] ?? "")) e++; + if (s === e) return; // กลางช่องว่าง — ไม่ขยาย + caret.setStart(node, s); + caret.setEnd(node, e); +} + +/** Range จาก caret สองจุด (จัดลำดับ start/end ให้ถูกทิศการลาก) — null เมื่อคนละ + * tree จน compare ไม่ได้ */ +function rangeBetween(a: Range, b: Range): Range | null { + const r = document.createRange(); + try { + if (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0) { + r.setStart(a.startContainer, a.startOffset); + r.setEnd(b.startContainer, b.startOffset); + } else { + r.setStart(b.startContainer, b.startOffset); + r.setEnd(a.startContainer, a.startOffset); + } + } catch { + return null; + } + return r; +} + +/** หน้า PDF ที่ range นี้อยู่ (เฉพาะ selection ใน page เดียว — พ้นหน้า/ข้ามหน้า = null) */ +function pageOfRange(range: Range): HTMLElement | null { + const c = range.commonAncestorContainer; + const el = c instanceof Element ? c : c.parentElement; + return el?.closest("[data-page]") ?? null; +} + +/** ลากล้ำเข้าหน้าอื่น (range คร่อม 2 หน้า) — clamp ฝั่ง focus ไปที่ท้ายหน้า (ลาก + * ลง) หรือต้นหน้า (ลากขึ้น) ของ anchor เพื่อให้ selection จบที่ขอบหน้า anchor + * แทนการหายทั้งเส้น (viewer เลื่อนต่อเนื่อง เกิดบ่อยตอนลากใกล้ขอบล่างหน้า) + * คืน range ที่ clamp แล้ว หรือ null เมื่อหน้า anchor ไม่มี text node เลย (สแกน) + * boundary ไม่เปลี่ยนระหว่างลากจึง cache ต่อ gesture (เดิน TreeWalker ทุก text + * node ของ textLayer แพงเกินจะทำทุก rAF frame — code review 💡4) */ +function clampToAnchorPage( + anchorPageEl: HTMLElement, + anchor: Range, + focus: Range, + cache: { clampBounds: { first: Text; last: Text } | null }, +): Range | null { + let bounds = cache.clampBounds; + if (!bounds || !bounds.first.isConnected || !bounds.last.isConnected) { + const layer = anchorPageEl.querySelector(".textLayer"); + if (!layer) return null; + const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT); + let first: Text | null = null; + let last: Text | null = null; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const t = n as Text; + if ((t.textContent ?? "").trim().length === 0) continue; + if (!first) first = t; + last = t; + } + if (!first || !last) return null; + bounds = { first, last }; + cache.clampBounds = bounds; + } + const boundary = document.createRange(); + // ทิศลาก: focus อยู่หลัง anchor (ลากลง) = จบที่ท้ายหน้า anchor ก่อนข้ามไปหน้าถัดไป + // กลับกัน (ลากขึ้นจากหน้าถัดไป) = เริ่มที่ต้นหน้า anchor แทนหน้าที่อยู่เหนือกว่า + if (anchor.compareBoundaryPoints(Range.START_TO_START, focus) < 0) { + boundary.setStart(bounds.last, bounds.last.length); + } else { + boundary.setStart(bounds.first, 0); + } + boundary.collapse(true); + return rangeBetween(anchor, boundary); +} + +type DragState = { + pointerId: number; + pointerType: string; + /** จุด pointerdown จริง (client) — ground truth ของเส้นทางลาก ใช้กรอง rect */ + anchorPt: ClientPt; + /** ตำแหน่ง pointer ล่าสุด */ + curPt: ClientPt; + /** caret ที่จุดกด (word-snap ตอนเริ่ม select จริง — ครั้งเดียว) */ + anchor: Range; + /** จุด probe ที่ resolve anchor ได้ (จอสัมผัส = จุด y−12 หรือจุดแตะจริงลูกใด + * ลูกหนึ่ง) — ใช้เป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง + * (ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨) */ + anchorPathPt: ClientPt; + /** หน้าของ anchor — เก็บตั้งแต่ pointerdown (anchor ไม่ย้ายหน้าระหว่างลาก) + * ใช้ clamp ตอนลากล้ำเข้าหน้าถัดไป (จุดที่ 3 ของ bug report) */ + anchorPageEl: HTMLElement; + /** boundary text node แรก/สุดท้ายของ textLayer หน้า anchor — cache ต่อ gesture + * ให้ clampToAnchorPage ไม่เดิน TreeWalker ใหม่ทุก frame (code review 💡4) */ + clampBounds: { first: Text; last: Text } | null; + /** focus caret ล่าสุดที่ resolve สำเร็จ — ระหว่างลากผ่านพื้นที่ว่าง (ชั้นถูก + * ของ resolveTextCaret หาไม่เจอ) ให้คงตำแหน่งเดิมไว้แทน re-scan ทุก frame */ + lastFocus: Range | null; + /** จำนวน frame ต่อเนื่องที่ cheap resolver fail — ใช้ throttle การ escalate + * ไป deep resolver (แพงกว่า ไม่ควรเรียกทุก frame) */ + failStreak: number; + /** cache รายชื่อ span ของหน้า ต่อ 1 gesture — ใช้กับ deep resolver ตอน drag */ + spanCache: SpanCache; + anchorSnapped: boolean; + /** เลย threshold 8px แล้วหรือยัง (false = tap ที่ยังไม่ขยับ) */ + active: boolean; +}; + +/** + * @param containerRef element ที่รับ pointer ของทั้งหน้า (ผ่านการ bubble จาก + * textLayer) — ตอน enabled จะได้ class .pdf-custom-select และถอดทันทีเมื่อปิด + * @param enabled เปิดเฉพาะช่วงที่ต้องการลากเลือกข้อความเอง (tool highlight + จอสัมผัส) + * @param onCommit เรียกตอนปล่อยนิ้วเมื่อ range ไม่ collapsed (identity เปลี่ยนกี่ครั้ง + * ก็ได้ — hook เก็บใน ref ไม่ถอด listener ใหม่) + */ +export function useCustomSelection({ + containerRef, + enabled, + onCommit, +}: { + containerRef: RefObject; + enabled: boolean; + onCommit: (payload: CustomSelectionCommit) => void; +}): { previewRects: CustomSelectionPreview } { + const [previewRects, setPreviewRects] = + useState(null); + + const dragRef = useRef(null); + /** range + เส้นทางลากล่าสุดที่ preview คำนวณแล้ว — ใช้ commit ตอนปล่อยนิ้ว */ + const lastRef = useRef(null); + const previewRafRef = useRef(null); + const scrollRafRef = useRef(null); + + // onCommit เก็บใน ref — effect ผูก listener ครั้งเดียวต่อช่วง enabled ไม่ re-attach + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + + useEffect(() => { + const el = containerRef.current; + if (!el || !enabled) return; + el.classList.add(CONTAINER_CLASS); + + const stopPreviewRaf = () => { + if (previewRafRef.current !== null) { + cancelAnimationFrame(previewRafRef.current); + previewRafRef.current = null; + } + }; + const stopAutoScroll = () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + }; + + /** คำนวณ preview จาก anchor↔focus ปัจจุบัน — เรียกจาก rAF (throttle) และตอน + * ปล่อยนิ้ว (synchronous เพื่อเก็บตำแหน่งสุดท้ายก่อน commit) */ + const updatePreview = () => { + const d = dragRef.current; + if (!d || !d.active) return; + // หน้า anchor ถูก virtualization unmount กลางลาก (auto-scroll ไกลจนพ้น + // ช่วง render) — anchor/focus ชี้ detached nodes, rects ว่างหมด: เคลียร์ + // lastRef ด้วย กัน commit ของเก่าเงียบ ๆ (code review ⚠️ — จุดเคลียร์ + // lastRef เพิ่มจาก pointerdown/finish ตามเหตุการณ์ "หน้าหาย" เท่านั้น) + if (!d.anchorPageEl.isConnected) { + lastRef.current = null; + setPreviewRects(null); + return; + } + const y = d.curPt.y - (d.pointerType === "touch" ? FINGER_OFFSET_PX : 0); + // ระหว่างลากใช้เฉพาะชั้นถูกของ resolveTextCaret — ผ่านพื้นที่ว่าง (gap กว้าง / + // ช่องว่างระหว่างหน้า) หาไม่เจอก็คง focus เดิมจาก frame ก่อนไว้แทน re-scan + // ด้วยชั้นแพง (perf note ของ bug report — selection หยุดรอที่ข้อความสุดท้าย + // แล้วขยายต่อเมื่อนิ้วถึงข้อความอีกครั้ง) + let focus = resolveTextCaret(d.curPt.x, y); + if (!focus) { + d.failStreak++; + // fail ต่อเนื่องเกิน 3 frame = ลองชั้นแพงกว่าก่อนยอม fallback ไปตำแหน่งเดิม + if (d.failStreak > 3 && d.failStreak % 5 === 0) { + focus = resolveTextCaretDeep(d.curPt.x, y, { spanCache: d.spanCache }); + } + } + if (focus) { + d.failStreak = 0; + } else { + focus = d.lastFocus; + } + + if (!focus) { + return; + } + d.lastFocus = focus.cloneRange(); + if (!d.anchorSnapped) { + snapCaretToWord(d.anchor); + d.anchorSnapped = true; + } + snapCaretToWord(focus); + let range = rangeBetween(d.anchor, focus); + let pageEl = range && !range.collapsed ? pageOfRange(range) : null; + // ลากล้ำเข้าหน้าอื่น (คร่อม 2 หน้า หรือ focus ข้ามไปหน้าถัดไปแล้ว) — clamp + // ฝั่ง focus ไว้ที่ท้าย/ต้นหน้าของ anchor แทนการทิ้ง selection ทั้งเส้น + // (จุดที่ 3 ของ bug report — ข้ามเมื่อ range collapsed: anchor==focus ใน + // หน้าเดียว ไม่ใช่ข้ามหน้า ห้ามนำไป clamp กลายเป็น selection ตั้งแต่ต้นหน้า) + if (range && !range.collapsed && pageEl !== d.anchorPageEl) { + const clamped = clampToAnchorPage(d.anchorPageEl, d.anchor, focus, d); + range = clamped && !clamped.collapsed ? clamped : null; + pageEl = range ? d.anchorPageEl : null; + } + // หมายเหตุ (จุดที่ 2): branch พวกนี้เคลียร์แค่ preview — ห้ามแตะ lastRef + // เพื่อให้ commit ใช้ selection ล่าสุดที่ไม่ว่างจาก frame ก่อนหน้า + if (!range || !pageEl) { + setPreviewRects(null); + return; + } + const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0); + // เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จุด y−12 สำหรับ + // จอสัมผัส) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาด 12px จนบรรทัดเป้าหมายโดน + // lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก + // เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨) + const pathEnd: ClientPt = + d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt; + const kept = rectsInSweptPath(rects, d.anchorPathPt, pathEnd); + if (kept.length === 0) { + setPreviewRects(null); + return; + } + const pr = pageEl.getBoundingClientRect(); + const rectsPct = mergeRectsByLine( + kept.map((r) => + rectToPercent( + { + x: r.left - pr.left, + y: r.top - pr.top, + width: r.width, + height: r.height, + }, + { width: pr.width, height: pr.height }, + ), + ), + ); + lastRef.current = { + range: range.cloneRange(), + start: d.anchorPathPt, + end: pathEnd, + }; + const pageIndex = Number(pageEl.dataset.page); + setPreviewRects((prev) => + prev !== null && + prev.pageIndex === pageIndex && + prev.rects.length === rectsPct.length && + prev.rects.every( + (r, i) => + r.x === rectsPct[i].x && + r.y === rectsPct[i].y && + r.width === rectsPct[i].width && + r.height === rectsPct[i].height, + ) + ? prev + : { pageIndex, rects: rectsPct }, + ); + }; + + const schedulePreview = () => { + if (previewRafRef.current !== null) return; + previewRafRef.current = requestAnimationFrame(() => { + previewRafRef.current = null; + updatePreview(); + }); + }; + + /** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview + * ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */ + const startAutoScroll = () => { + if (scrollRafRef.current !== null) return; + const step = () => { + scrollRafRef.current = null; + const d = dragRef.current; + if (!d?.active) return; + const vh = window.innerHeight; + let dy = 0; + if (d.curPt.y < AUTOSCROLL_MARGIN_PX) { + dy = -Math.max( + 1, + Math.ceil( + ((AUTOSCROLL_MARGIN_PX - d.curPt.y) / AUTOSCROLL_MARGIN_PX) * + AUTOSCROLL_MAX_SPEED_PX, + ), + ); + } else if (d.curPt.y > vh - AUTOSCROLL_MARGIN_PX) { + dy = Math.max( + 1, + Math.ceil( + ((d.curPt.y - (vh - AUTOSCROLL_MARGIN_PX)) / AUTOSCROLL_MARGIN_PX) * + AUTOSCROLL_MAX_SPEED_PX, + ), + ); + } + if (dy !== 0) { + window.scrollBy(0, dy); + schedulePreview(); + } + scrollRafRef.current = requestAnimationFrame(step); + }; + scrollRafRef.current = requestAnimationFrame(step); + }; + + const seenMovePointerIds = new Set(); + + const onPointerDown = (e: PointerEvent) => { + if (dragRef.current) { + // นิ้วที่สองระหว่างลาก — ไม่สน รวมถึง touch ปลอมจาก palm rejection (มือ + // ที่พักบนจอระหว่างลากด้วย pen) — ห้ามให้ pointer ตัวใหม่แทนที่ drag เดิม + return; + } + // เริ่ม selection ได้เฉพาะที่เริ่มบน textLayer (แตะ pin/ปุ่ม/กล่องข้อความ = งานของมัน) + if ( + !(e.target instanceof Element) || + !e.target.closest(".textLayer") + ) { + return; + } + const y = e.clientY - (e.pointerType === "touch" ? FINGER_OFFSET_PX : 0); + // ค้น anchor ครบทั้ง 4 ชั้น ทีละจุด: จุดชดเชยนิ้ว (y-12) ก่อน แล้วจุดที่แตะ + // จริง (finger offset เป็น heuristic — บางจังหวะจุดจริงต่างหากที่อยู่บนข้อความ) + // ชั้น 3-4 (เจาะ overlay / span ใกล้สุด ≤40px) แพงกว่า caret API จึงรัน + // เฉพาะตรงนี้ตอนกด — ระหว่างลากใช้ชั้นถูกอย่างเดียว (cache ต่อ 1 gesture) + const cache: { spanCache: SpanCache } = { spanCache: null }; + const probes = + e.pointerType === "touch" ? [y, e.clientY] : [y]; + let caret: Range | null = null; + let resolvedY = y; + for (const py of probes) { + caret = resolveTextCaretDeep(e.clientX, py, cache); + if (caret) { + resolvedY = py; + break; + } + } + if (!caret) { + // จุดกดไม่เกี่ยวกับข้อความเลย (แม้ค้น text node รอบ ๆ แล้ว) — เป็น tap + // ธรรมดา ไม่เริ่ม selection (click ของ annotation/pin ทำงานตามเดิม) + return; + } + const anchorPageEl = pageOfRange(caret); + if (!anchorPageEl) return; + // gesture ใหม่ = ล้างผลล่าสุดของ gesture ก่อน (จุดที่ 2 ของ bug report: + // lastRef เคลียร์เฉพาะตรงนี้กับตอน finish เท่านั้น) + lastRef.current = null; + dragRef.current = { + pointerId: e.pointerId, + pointerType: e.pointerType, + anchorPt: { x: e.clientX, y: e.clientY }, + anchorPathPt: { x: e.clientX, y: resolvedY }, + curPt: { x: e.clientX, y: e.clientY }, + anchor: caret, + anchorPageEl, + clampBounds: null, + lastFocus: caret.cloneRange(), + failStreak: 0, + spanCache: cache.spanCache, + anchorSnapped: false, + active: false, + }; + }; + + const onPointerMove = (e: PointerEvent) => { + // pen/touch ยิง 60–120Hz ถ้า log ทุก event จะท่วม console และหน่วงเครื่อง + // จนเปลี่ยนพฤติกรรมที่กำลังวินิจฉัยอยู่เอง + if (!seenMovePointerIds.has(e.pointerId)) { + seenMovePointerIds.add(e.pointerId); + } + const d = dragRef.current; + if (!d || e.pointerId !== d.pointerId) return; + d.curPt = { x: e.clientX, y: e.clientY }; + if (!d.active) { + if ( + Math.hypot(e.clientX - d.anchorPt.x, e.clientY - d.anchorPt.y) < + MOVE_THRESHOLD_PX + ) { + return; + } + d.active = true; + // capture ตอน"เริ่ม select จริง"ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ + // retarget click ไปที่ container จน tap เลือก annotation / ปุ่ม pin ใช้ไม่ได้ + // (tap ที่ไม่ขยับเลย threshold = ไม่ capture → click ไปถึงเป้าหมายตามเดิม) + el.setPointerCapture(d.pointerId); + startAutoScroll(); + } + schedulePreview(); + }; + + /** เคลียร์ state ทุกทาง — commit เฉพาะตอน pointerup ที่มี range ไม่ collapsed + * (pointercancel จาก gesture ของระบบ = ทิ้ง selection ไม่ commit) */ + const finish = (e: PointerEvent, commit: boolean) => { + const d = dragRef.current; + if (!d || e.pointerId !== d.pointerId) return; + stopAutoScroll(); + stopPreviewRaf(); + if (d.active) { + updatePreview(); // synchronous — ตำแหน่งสุดท้ายก่อน commit (rAF อาจยังค้าง) + } + dragRef.current = null; + setPreviewRects(null); + const last = lastRef.current; + lastRef.current = null; + if (commit && last && !last.range.collapsed) { + onCommitRef.current(last); + } + }; + + el.addEventListener("pointerdown", onPointerDown); + el.addEventListener("pointermove", onPointerMove); + const onUp = (e: PointerEvent) => finish(e, true); + const onCancel = (e: PointerEvent) => finish(e, false); + el.addEventListener("pointerup", onUp); + el.addEventListener("pointercancel", onCancel); + + return () => { + el.classList.remove(CONTAINER_CLASS); + el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("pointermove", onPointerMove); + el.removeEventListener("pointerup", onUp); + el.removeEventListener("pointercancel", onCancel); + stopAutoScroll(); + stopPreviewRaf(); + dragRef.current = null; + lastRef.current = null; + setPreviewRects(null); + }; + // containerRef เป็น ref object ที่ identity คงที่ตลอด life ของ component + }, [enabled, containerRef]); + + return { previewRects }; +}