eCourt-note/hooks/useCustomSelection.ts
Your Name 45e60330b8 highlight-touch: แก้กลุ่มบั๊กจอสัมผัสของ useCustomSelection + rectsInSweptPath ตามรายงาน review 12 ข้อ + follow-up 3 ข้อ — ลากแนวนอนบรรทัดเดียวบน tablet ต้องสร้างมาร์กได้ทุกครั้ง และ gesture ต้องไม่ตายถาวรกลางsession — (1) fingerOffsetFor: FINGER_OFFSET_PX=12 คงที่ทำ probe ที่ y−12 หลุดบรรทัด (บรรทัด PDF บน tablet zoom-fit สูงจริง ~14px — วัดจาก viewport 820px) ไปโดน heading จนลากแนวนอนเงียบไม่สร้าง — offset กลายเป็น min(12, ครึ่งความสูงบรรทัด−2) วัดจาก span ของ anchor ตอน pointerdown (บรรทัด ≥28px ยังได้ 12 เท่าเดิม) และถ้าน้อยกว่า 12 จะ resolve anchor ซ้ำที่จุดที่หักจริง — ยืนยันด้วย Playwright emulate iPad + CDP touch จริง (ก่อนแก้ 9/11 หลังแก้ 11/11) (2) A1 rangeBetween ฝั่งหลังใช้ boundary end แทน start — anchor/focus ผ่าน word snap แล้วเป็น range ครอบคำ การอ่าน start ทิ้งคำท้าย (ลาก alpha→gamma ได้แค่ alpha beta) และลากในคำเดียวได้ selection ว่าง (3) A2 anchorPathPt(client คงที่)→anchorRel สัดส่วน 0–1 ของหน้า แปลงกลับทุกเฟรม — พิกัด client โกหกทันทีที่ auto-scroll เลื่อนเนื้อหากลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก (จำลอง 26 บรรทัดเคยได้ 14) (4) A3 rectsInSweptPath (utils/coordinates.ts) rewrite: snap จุด start/end เข้า center บรรทัดใกล้สุด (snapY) ก่อนตัดสินบรรทัดเดียวกัน/คนละบรรทัด — พิกัดนิ้วที่คนละบรรทัดแต่ dy น้อยกว่า lineTol เคยถูกตีเป็นบรรทัดเดียวจนกรองได้ 0 rect (ลากท้ายบรรทัดบน→ต้นบรรทัดถัดไประยะสั้น) — ใช้ร่วม desktop เหมือนเดิม เคส test เดิม 3 เคสยังผ่าน (5) A4 range.collapsed (ย่อจนว่างจริง) = เคลียร์ lastRef + preview แล้ว return — commit ตรง preview เสมอ แยกจากเคสกรองว่างชั่วคราวที่ยังคง lastRef ตามเจตนาเดิม (6) A5 caretInSpanAt วาง caret ตามสัดส่วน x ใน span แทนต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว) ใช้ทั้งชั้น ③ elementsFromPoint และ ④ nearest span (7) A6 fromElement ปฏิเสธ text node ที่อยู่นอก .textLayer (กัน DFS หลุดไป text node แรกของหน้า) (8) A7.1 auto-scroll speed cap ระยะเลยขอบที่ margin / A7.2 ลบ seenMovePointerIds debug / A7.4 mouse ปุ่มซ้ายเท่านั้น (hybrid) / A7.5 ส่ง DragState ตรงให้ resolveTextCaretDeep ให้ spanCache ถูกเก็บกลับ (9) B1 ระหว่างลาก retry resolver แค่ 2 ทิศแรก (ครบ 8 ทิศ ~6ms/เฟรม เก็บไว้ pointerdown — fail ระหว่างลากมีทางออกคือคง lastFocus) (10) B2+2.3 spanCache เก็บ fraction 0–1 ของหน้า (แบบเดียวกับ anchorRel — ทน re-fit ความกว้างกลาง gesture เช่น นิ้วที่สอง toggle sidebar) วัดครั้งเดียวต่อ gesture แทน getBoundingClientRect ทั้งหน้าทุกครั้ง (~2.3ms) (11) code review 2.1: pointerup/pointercancel ย้ายจาก <main> เป็นตาข่าย document แบบ capture phase ผูกตั้งแต่ pointerdown จน finish — Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง พอ capture หาด pointerup ยิงนอก <main> จน dragRef ค้าง gesture ถัดไปเงียบหมด (arm เฉพาะตอน active ไม่พอ — tap ที่ไม่ขยับก็ต้องมีตาข่ายรับ ไม่งั้นค้างเหมือนกัน เจอจริงตอนแก้ e2e 9/13) (12) A7.3 ไม่ใช้: ห้ามผูก finish เข้า lostpointercapture — กับ touch ของ Chromium ยิงกลาง drag ได้เอง (implicit capture ของ span + re-hit-test) ผูกแล้ว dragRef ถูกเคลียร์กลางทาง gesture ตายหมด — ยืนยันด้วย CDP touch event log จริง (13) 2.2 แก้ comment fingerOffsetFor ให้ตรงกลไก (เคส probe โดน heading ≥28px ยังใช้ heuristic เดิม) (14) tests: coordinates +7 เคส (รวม 2 บรรทัด dy เล็กที่เคยได้ 0 และ auto-scroll 26 บรรทัด) + ไฟล์ใหม่ tests/hooks/useCustomSelection-dom.test.ts 7 เคส snapCaretToWord/rangeBetween (export เพิ่มเพื่อ test พร้อม comment) ผ่าน pragma @vitest-environment jsdom รวมคำไทยกับ Intl.Segmenter "แมวกินปลา"→"แมว" — เพิ่ม devDependency jsdom (test-only) (15) rules/pdf-viewer.md sync จุดที่พฤติกรรมเปลี่ยนครบ — ผ่าน code review รอบ 2 (ไม่มี bug ระดับต้องแก้) + tsc + vitest 157/157 + e2e tablet 12/13 (เคส F fail ที่การจัดเตรียมเงื่อนไขของ script เอง ไม่ใช่ฟังก์ชัน — A3 ครอบโดย unit tests) (คำขอผู้ใช้ 2026-09-28)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:01:09 +07:00

808 lines
48 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; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) — ค่านี้เป็น "เพดาน" เท่านั้น:
* offset จริงของแต่ละ gesture ถูก cap ด้วยความสูงบรรทัดผ่าน fingerOffsetFor (บรรทัด
* PDF บน tablet zoom-fit สูงแค่ ~14px — หักตายตัว 12px ทำ probe หลุดขอบบนบรรทัด
* ไปโดน heading/บรรทัดอื่น จนลากแนวนอนบรรทัดเดียวแล้วเงียบไม่สร้างมาร์ก — รายงาน
* 2026-09-28) */
const FINGER_OFFSET_PX = 12;
/** offset ของ gesture นี้ = min(12, ครึ่งความสูงบรรทัด − 2) วัดจาก span ของ anchor
* ที่ resolve ได้ (span ของ textLayer สูงเท่าบรรทัด) — บรรทัดใหญ่ (≥28px) ได้ 12 เท่า
* เดิม, บรรทัดเตี้ย 14px ได้ 5px จนยังอยู่ในบรรทัด; วัดไม่ได้ = 12 ตามเดิม */
function fingerOffsetFor(caret: Range): number {
const node = caret.startContainer;
const el = node instanceof Element ? node : node.parentElement;
const span = el?.closest(".textLayer span");
const h = span?.getBoundingClientRect().height ?? 0;
if (!(h > 4)) return FINGER_OFFSET_PX;
return Math.min(FINGER_OFFSET_PX, Math.max(2, h / 2 - 2));
}
/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = 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;
}
/** ทิศ retry ของ resolveTextCaret — รอยต่อระหว่าง span บางกว่า 2–3px มักพอ */
const RETRY_OFFSETS: ReadonlyArray<readonly [number, number]> = [
[0, -2], [0, 2], [-2, 0], [2, 0],
[0, -3], [0, 3], [-3, 0], [3, 0],
];
/** เหมือน caretFromPoint แต่ guarantee ว่าถ้าหา text node ได้จะคืน Range ที่
* startContainer เป็น TEXT_NODE เสมอ — กันเคสตกรอยต่อ span/margin/padding
* ที่ caretRangeFromPoint คืน element แทน text node (ทำ onPointerDown bail
* เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1)
* maxRetries: ระหว่างลากส่งค่าน้อย (2) — fail แล้วมีทางออกอยู่แล้วคือคง
* lastFocus ของเฟรมก่อน ไม่ควรเสีย ~6ms/เฟรม retry ครบ 8 ทิศเหมือนตอนกด (B1) */
function resolveTextCaret(
x: number,
y: number,
maxRetries = RETRY_OFFSETS.length,
): 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;
// container ใหญ่จน DFS หลุดออกนอก textLayer (เช่นตก page div) — text node
// แรกของหน้าไม่ใช่ข้อความที่จุดนั้น ทิ้งดีกว่าเด้งไปมุมบนซ้าย (A6)
if (!textNode.parentElement?.closest(".textLayer")) return null;
const nr = document.createRange();
nr.setStart(textNode, 0);
nr.collapse(true);
return nr;
};
const resolved = fromElement(first);
if (resolved) return resolved;
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม
for (const [dx, dy] of RETRY_OFFSETS.slice(0, maxRetries)) {
const r = fromElement(caretFromPoint(x + dx, y + dy));
if (r) return r;
}
return null;
}
/** รัศมีค้นหา span ข้อความใกล้สุดจากจุดแตะ (px แนวตั้ง) — เกินนี้ถือว่าจุดนั้นไม่มี
* ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */
const NEAREST_SPAN_MAX_PX = 40;
/** รายการ span ของหน้าพร้อมพิกัดแบบสัดส่วน 0–1 ของหน้า — วัดครั้งเดียวต่อ gesture
* (getBoundingClientRect ทั้งหน้า ~3,000 span แพง ~2.3ms ต่อครั้ง) เก็บเป็น
* fraction แบบเดียวกับ anchorRel: ทนทั้งการเลื่อนหน้าระหว่างลาก และ re-fit
* ความกว้างหน้ากลาง gesture (นิ้วที่สอง toggle sidebar — code review 2.3
* 2026-09-28) โดยบวก rect ปัจจุบันของหน้าคืนตอนใช้ (B2) */
type SpanItem = {
el: Element;
top: number;
bottom: number;
left: number;
right: number;
};
type SpanCache = { pageEl: HTMLElement; items: SpanItem[] } | null;
/** caret ใน span ที่ตำแหน่งสัดส่วน x (0–1) ของความกว้าง span — ชั้น 3–4 ของ
* resolver เดิมวางที่ต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว) จน
* anchor/focus เด้งไปต้นบรรทัดแทนตำแหน่งนิ้ว; ประมาณ offset จากสัดส่วนตัวอักษร
* (ฟอนต์ไม่ monospace คลาดได้เล็กน้อย — snapCaretToWord ปรับลงขอบคำให้)
* len−1 กัน offset ตกท้าย text node ที่ word snap ไม่ขยาย (A5) */
function caretInSpanAt(span: Element, ratio: number): Range | null {
const t = findFirstTextNode(span);
const len = t?.textContent?.length ?? 0;
if (!t || len === 0) return null;
const off = Math.min(len - 1, Math.max(0, Math.round(ratio * len)));
const r = document.createRange();
r.setStart(t, off);
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 rc = el.getBoundingClientRect();
const caret = caretInSpanAt(el, rc.width > 0 ? (x - rc.left) / rc.width : 0);
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;
const pr = pageEl.getBoundingClientRect();
if (!cache.spanCache || cache.spanCache.pageEl !== pageEl) {
const items: SpanItem[] = [];
for (const el of Array.from(pageEl.querySelectorAll(".textLayer span"))) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
items.push({
el,
top: (r.top - pr.top) / pr.height,
bottom: (r.bottom - pr.top) / pr.height,
left: (r.left - pr.left) / pr.width,
right: (r.right - pr.left) / pr.width,
});
}
cache.spanCache = { pageEl, items };
}
// จุดเป็น fraction เดียวกับ cache — pr อ่านใหม่ทุกครั้งจึงตามทั้งการเลื่อนหน้า
// และ re-fit ความกว้าง; รัศมีค้นหา (px) แปลงเป็น fraction ของความสูงหน้าปัจจุบัน
const fx = (x - pr.left) / pr.width;
const fy = (y - pr.top) / pr.height;
const maxDy = NEAREST_SPAN_MAX_PX / pr.height;
let best: SpanItem | null = null;
let bestDy = Infinity;
let bestDx = Infinity;
for (const it of cache.spanCache.items) {
const dy = fy < it.top ? it.top - fy : fy > it.bottom ? fy - it.bottom : 0;
if (dy > maxDy) continue;
const dx = Math.abs((it.left + it.right) / 2 - fx);
if (dy < bestDy || (dy === bestDy && dx < bestDx)) {
best = it;
bestDy = dy;
bestDx = dx;
}
}
if (!best) return null;
const w = best.right - best.left;
return caretInSpanAt(best.el, w > 0 ? (fx - best.left) / w : 0);
}
/** 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 = ไม่ขยาย */
// export เพื่อ unit test (tests/hooks/) — ตัว hook เองยังใช้ภายในอย่างเดียว
export 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 ไม่ได้
* ฝั่งหลัง (b หรือ a ตามทิศ) ต้องใช้ boundary **end** ไม่ใช่ start: anchor/focus
* ผ่าน snapCaretToWord แล้วเป็น range ครอบคำ (ไม่ collapsed) การอ่าน start จะตัด
* คำท้ายทิ้ง (ลาก alpha→gamma ได้แค่ alpha beta) และลากในคำเดียว (a==b) จะได้
* selection ว่าง — bug report A1 2026-09-28 */
// export เพื่อ unit test (tests/hooks/) — ตัว hook เองยังใช้ภายในอย่างเดียว
export 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.endContainer, b.endOffset);
} else {
r.setStart(b.startContainer, b.startOffset);
r.setEnd(a.endContainer, a.endOffset);
}
} 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;
/** ระยะหักขึ้นของจุด probe (เฉพาะ touch — กันนิ้วบังข้อความ) คำนวณครั้งเดียวตอน
* pointerdown จากความสูงบรรทัดของ anchor (fingerOffsetFor) ใช้ทั้ง probe
* ระหว่างลากและเส้นทางกรอง rect; เมาส์/สไตลัส = 0 */
fingerOffset: number;
/** จุด pointerdown จริง (client) — ground truth ของเส้นทางลาก ใช้กรอง rect */
anchorPt: ClientPt;
/** ตำแหน่ง pointer ล่าสุด */
curPt: ClientPt;
/** caret ที่จุดกด (word-snap ตอนเริ่ม select จริง — ครั้งเดียว) */
anchor: Range;
/** จุด probe ที่ resolve anchor ได้ เก็บเป็นสัดส่วน 0–1 ของหน้า anchor (จอสัมผัส =
* จุด y−offset หรือจุดแตะจริงลูกใดลูกหนึ่ง) — แปลงกลับเป็นพิกัดจอใหม่ทุกเฟรม
* เพื่อเป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง (ใช้พิกัดนิ้ว
* raw ตรง ๆ จะคลาดเท่า offset จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨;
* เก็บเป็นสัดส่วนเพราะพิกัด client คงที่จะโกหกทันทีที่ auto-scroll เลื่อนเนื้อหา
* กลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก — bug report A2
* 2026-09-28) */
anchorRel: { x: number; y: number };
/** หน้าของ 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<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.fingerOffset;
// ระหว่างลากใช้เฉพาะชั้นถูกของ resolveTextCaret — ผ่านพื้นที่ว่าง (gap กว้าง /
// ช่องว่างระหว่างหน้า) หาไม่เจอก็คง focus เดิมจาก frame ก่อนไว้แทน re-scan
// ด้วยชั้นแพง (perf note ของ bug report — selection หยุดรอที่ข้อความสุดท้าย
// แล้วขยายต่อเมื่อนิ้วถึงข้อความอีกครั้ง)
let focus = resolveTextCaret(d.curPt.x, y, 2);
if (!focus) {
d.failStreak++;
// fail ต่อเนื่องเกิน 3 frame = ลองชั้นแพงกว่าก่อนยอม fallback ไปตำแหน่งเดิม
// (ส่ง d ตรง ๆ ให้ cache ที่ชั้นแพงสร้างถูกเก็บกลับที่ gesture ใช้ต่อ — เดิม
// ห่อ object ใหม่ทุกครั้ง cache จึงไม่เคยติด A7.5)
if (d.failStreak > 3 && d.failStreak % 5 === 0) {
focus = resolveTextCaretDeep(d.curPt.x, y, d);
}
}
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);
if (range && range.collapsed) {
// ผู้ใช้ย่อ selection จนว่างจริง (anchor==focus) — เคลียร์ payload ให้
// commit ตรงกับ preview เสมอ: ปล่อยนิ้วตอนนี้ = ไม่สร้างมาร์ก ต่างจาก
// frame ที่ rectsInSweptPath กรองว่าง "ชั่วคราว" ซึ่งยังคง lastRef ตาม
// เจตนาเดิมของ bug report จุดที่ 2 (A4)
lastRef.current = null;
setPreviewRects(null);
return;
}
let pageEl = range ? 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 อิง (จอสัมผัส = จุดที่
// หัก fingerOffset แล้ว) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาดจนบรรทัดเป้าหมายโดน
// lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก
// เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨)
const pathEnd: ClientPt =
d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt;
const pr = pageEl.getBoundingClientRect();
// ต้นเส้นทางแปลงจากสัดส่วนของหน้าใหม่ทุกเฟรม (A2) — anchor ต้องเลื่อนตาม
// เนื้อหาตอน auto-scroll ไม่ใช่ค้างที่พิกัดจอตอนกดนิ้ว
const anchorNow: ClientPt = {
x: pr.left + d.anchorRel.x * pr.width,
y: pr.top + d.anchorRel.y * pr.height,
};
const kept = rectsInSweptPath(rects, anchorNow, pathEnd);
if (kept.length === 0) {
setPreviewRects(null);
return;
}
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: anchorNow,
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 เลื่อน scroll container ของ
* เอกสาร (el = <main> ตัวเลื่อนเดียวของ viewer, ทั้งแกนตั้งและแกนนอนกรณี
* ซูมจนหน้ากว้างเกินจอ) แล้วคำนวณ preview ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง
* scroll) จบเองเมื่อเลิกลาก — ขอบวัดจากกรอบของ el เอง (ใต้ header) ไม่ใช่
* ขอบจอ เพราะส่วนบนถูก header บังอยู่ */
const startAutoScroll = () => {
if (scrollRafRef.current !== null) return;
const step = () => {
scrollRafRef.current = null;
const d = dragRef.current;
if (!d?.active) return;
const br = el.getBoundingClientRect();
const speed = (into: number) =>
Math.max(
1,
Math.ceil(
// cap ระยะเลยขอบที่ margin — นิ้วอยู่นอกพื้นที่เอกสารไกล ๆ ต้องไม่
// เร็วกว่า AUTOSCROLL_MAX_SPEED_PX (A7.1)
(Math.min(into, AUTOSCROLL_MARGIN_PX) / AUTOSCROLL_MARGIN_PX) *
AUTOSCROLL_MAX_SPEED_PX,
),
);
let dx = 0;
let dy = 0;
if (d.curPt.x < br.left + AUTOSCROLL_MARGIN_PX) {
dx = -speed(br.left + AUTOSCROLL_MARGIN_PX - d.curPt.x);
} else if (d.curPt.x > br.right - AUTOSCROLL_MARGIN_PX) {
dx = speed(d.curPt.x - (br.right - AUTOSCROLL_MARGIN_PX));
}
if (d.curPt.y < br.top + AUTOSCROLL_MARGIN_PX) {
dy = -speed(br.top + AUTOSCROLL_MARGIN_PX - d.curPt.y);
} else if (d.curPt.y > br.bottom - AUTOSCROLL_MARGIN_PX) {
dy = speed(d.curPt.y - (br.bottom - AUTOSCROLL_MARGIN_PX));
}
if (dx !== 0 || dy !== 0) {
el.scrollBy(dx, dy);
schedulePreview();
}
scrollRafRef.current = requestAnimationFrame(step);
};
scrollRafRef.current = requestAnimationFrame(step);
};
const onPointerDown = (e: PointerEvent) => {
// อุปกรณ์ hybrid (จอสัมผัส + เมาส์): คลิกขวา/กลางไม่ใช่การเริ่มเลือกข้อความ (A7.4)
if (e.pointerType === "mouse" && e.button !== 0) return;
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;
}
// offset จริงของ gesture นี้จากความสูงบรรทัดของ anchor ที่เพิ่ง resolve ได้ —
// บรรทัดเตี้ยกว่านิ้ว (span ที่ probe ที่ −12 โดนสูง < 28px) ให้หักน้อยลงแล้ว
// resolve anchor ใหม่ที่จุดที่หักจริง เพื่อไม่ให้ anchor ค้างบรรทัดที่ probe
// เต็มพลาดไปโดน; resolve ใหม่ไม่ได้ = ใช้ anchor เดิมต่อ — ขอบเขต: ถ้า probe
// ที่ −12 โดน span สูง ≥ 28px (เช่น heading ใหญ่เหนือบรรทัดเป้าหมาย) offset
// ยังเป็น 12 จึงไม่ re-resolve — anchor ค้าง heading ตาม heuristic เดิม
// (code review 2.2 2026-09-28: comment เดิมอ้างว่าแก้เคส heading — เกินจริง)
const fingerOffset =
e.pointerType === "touch" ? fingerOffsetFor(caret) : 0;
if (fingerOffset < FINGER_OFFSET_PX) {
const adjustedY = e.clientY - fingerOffset;
const re = resolveTextCaretDeep(e.clientX, adjustedY, cache);
if (re) {
caret = re;
resolvedY = adjustedY;
}
}
const anchorPageEl = pageOfRange(caret);
if (!anchorPageEl) return;
const anchorPr = anchorPageEl.getBoundingClientRect();
// gesture ใหม่ = ล้างผลล่าสุดของ gesture ก่อน (จุดที่ 2 ของ bug report:
// lastRef เคลียร์เฉพาะตรงนี้กับตอน finish เท่านั้น)
lastRef.current = null;
dragRef.current = {
pointerId: e.pointerId,
pointerType: e.pointerType,
fingerOffset,
anchorPt: { x: e.clientX, y: e.clientY },
anchorRel: {
x: (e.clientX - anchorPr.left) / anchorPr.width,
y: (resolvedY - anchorPr.top) / anchorPr.height,
},
curPt: { x: e.clientX, y: e.clientY },
anchor: caret,
anchorPageEl,
clampBounds: null,
lastFocus: caret.cloneRange(),
failStreak: 0,
spanCache: cache.spanCache,
anchorSnapped: false,
active: false,
};
// arm ตั้งแต่ pointerdown ไม่ใช่ตอนเลย threshold — tap ที่ไม่ขยับ (ไม่
// active) ก็ต้องมีตาข่ายรับ pointerup ของตัวเอง ไม่งั้น dragRef ค้างจน
// gesture ถัดไปเงียบหมด (เจอจริงหลังย้าย listener ออกจาก el — e2e 2026-09-28)
armDocUpListeners();
};
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;
// capture ตอน"เริ่ม select จริง"ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ
// retarget click ไปที่ container จน tap เลือก annotation / ปุ่ม pin ใช้ไม่ได้
// (tap ที่ไม่ขยับเลย threshold = ไม่ capture → click ไปถึงเป้าหมายตามเดิม)
el.setPointerCapture(d.pointerId);
startAutoScroll();
}
schedulePreview();
};
const onUp = (e: PointerEvent) => finish(e, true);
const onCancel = (e: PointerEvent) => finish(e, false);
/** ตาข่ายรับ pointerup/pointercancel ระดับ document (capture phase) — ผูกตั้งแต่
* pointerdown ที่เริ่ม gesture จนถึง finish (รวม tap ที่ไม่ activate):
* Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง (lostpointercapture
* ยิงทั้งที่ยังไม่ปล่อยนิ้ว — เห็นจริงใน e2e 2026-09-28) เมื่อ capture หาด
* pointerup ยิงที่ element ใต้นิ้วจริง ซึ่งอาจอยู่นอก <main> (header/sidebar
* ตอน auto-scroll ดันนิ้วค้างใกล้ขอบ) — ผูกที่ el อย่างเดียวจะไม่ได้ยิน จน
* dragRef ค้างและ gesture ถัดไปทุกตัวเงียบหมด (code review 2.1 2026-09-28) */
const armDocUpListeners = () => {
document.addEventListener("pointerup", onUp, true);
document.addEventListener("pointercancel", onCancel, true);
};
const disarmDocUpListeners = () => {
document.removeEventListener("pointerup", onUp, true);
document.removeEventListener("pointercancel", onCancel, true);
};
/** เคลียร์ 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;
disarmDocUpListeners();
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);
// pointerup/pointercancel ไม่ผูกที่ el — ใช้ตาข่าย document capture phase ต่อ
// ช่วง active (armDocUpListeners ด้านบน) เพื่อรอดเคส capture หลุดกลาง drag
// ห้ามผูก finish เข้า lostpointercapture: กับ touch ของ Chromium event นี้ยิง
// "กลาง drag" ได้เอง (การ re-hit-test ของ touch pointer ที่ implicit capture
// อยู่ที่ span ตั้งแต่ pointerdown แล้วถูกย้ายมา container ตอน setPointerCapture)
// ผูกแล้ว dragRef ถูกเคลียร์กลางทางจนทุก gesture เงียบหมด — ยืนยันด้วย
// Playwright + CDP touch แล้ว 2026-09-28 (A7.3 ของรายงาน review — ไม่ใช้)
return () => {
el.classList.remove(CONTAINER_CLASS);
el.removeEventListener("pointerdown", onPointerDown);
el.removeEventListener("pointermove", onPointerMove);
disarmDocUpListeners();
stopAutoScroll();
stopPreviewRaf();
dragRef.current = null;
lastRef.current = null;
setPreviewRects(null);
};
// containerRef เป็น ref object ที่ identity คงที่ตลอด life ของ component
}, [enabled, containerRef]);
return { previewRects };
}