eCourt-note/hooks/useCustomSelection.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 0c4af09e85
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
จอสัมผัส (iOS/Android): ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที แทน native selection + ปุ่มยืนยันลอย (code review ครบ):
(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>
2026-09-15 12:13:05 +07:00

647 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement | null>;
enabled: boolean;
onCommit: (payload: CustomSelectionCommit) => void;
}): { previewRects: CustomSelectionPreview } {
const [previewRects, setPreviewRects] =
useState<CustomSelectionPreview>(null);
const dragRef = useRef<DragState | null>(null);
/** range + เส้นทางลากล่าสุดที่ preview คำนวณแล้ว — ใช้ commit ตอนปล่อยนิ้ว */
const lastRef = useRef<CustomSelectionCommit | null>(null);
const previewRafRef = useRef<number | null>(null);
const scrollRafRef = useRef<number | null>(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);
};
const onPointerDown = (e: PointerEvent) => {
if (dragRef.current) 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) => {
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) => {
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 };
}