2026-09-15 17:35:13 +07:00
|
|
|
|
"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;
|
|
|
|
|
|
/** จำนวน 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.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<number>();
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|