"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 }; }