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>
This commit is contained in:
Your Name 2026-09-28 11:01:09 +07:00
parent c2ed9dabc1
commit 45e60330b8
7 changed files with 913 additions and 74 deletions

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,24 @@ const CONTAINER_CLASS = "pdf-custom-select";
* ไม่ capture pointer ไม่สร้าง selection — click ของ annotation/pin ใช้ได้ตามเดิม) */
const MOVE_THRESHOLD_PX = 8;
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
* (เฉพาะ 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;
@ -75,11 +91,23 @@ function findFirstTextNode(node: Node): Node | null {
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) */
function resolveTextCaret(x: number, y: number): Range | null {
* เงียบจน 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;
@ -93,6 +121,9 @@ function resolveTextCaret(x: number, y: number): Range | null {
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);
@ -102,12 +133,8 @@ function resolveTextCaret(x: number, y: number): Range | null {
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) {
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม
for (const [dx, dy] of RETRY_OFFSETS.slice(0, maxRetries)) {
const r = fromElement(caretFromPoint(x + dx, y + dy));
if (r) return r;
}
@ -118,17 +145,32 @@ function resolveTextCaret(x: number, y: number): Range | null {
* ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */
const NEAREST_SPAN_MAX_PX = 40;
/** cache รายชื่อ span ของหน้า ต่อ 1 gesture — query ครั้งเดียว (textLayer ไม่
* เปลี่ยนระหว่างลาก) ใช้กับการหา span ใกล้สุดเฉพาะตอน pointerdown */
type SpanCache = { pageEl: HTMLElement; spans: Element[] } | null;
/** รายการ 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 ที่ต้น text node แรกของ span — offset 0 พอ (กลางคำก็ได้ เพราะ
* snapCaretToWord จะขยายเต็มคำให้อยู่แล้ว) */
function caretAtSpanStart(span: Element): Range | 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);
if (!t) return null;
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, 0);
r.setStart(t, off);
r.collapse(true);
return r;
}
@ -143,7 +185,8 @@ function spanFromPointStack(x: number, y: number): Range | null {
// เฉพาะ span ของ textLayer เท่านั้น (ตัว container .textLayer เองไม่ใช่ —
// จุดไม่ได้อยู่บน span ไหนเลยเป็นงานของการหาใกล้สุดในชั้นถัดไป)
if (el.tagName !== "SPAN" || !el.closest(".textLayer")) continue;
const caret = caretAtSpanStart(el);
const rc = el.getBoundingClientRect();
const caret = caretInSpanAt(el, rc.width > 0 ? (x - rc.left) / rc.width : 0);
if (caret) return caret;
}
return null;
@ -161,28 +204,43 @@ function nearestSpanCaret(
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) {
cache.spanCache = {
pageEl,
spans: Array.from(pageEl.querySelectorAll(".textLayer span")),
};
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 };
}
let best: Element | null = null;
// จุดเป็น 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 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);
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 = s;
best = it;
bestDy = dy;
bestDx = dx;
}
}
return best ? caretAtSpanStart(best) : null;
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 →
@ -216,7 +274,8 @@ function getSegmenter(): Intl.Segmenter | null {
* กลางคำแล้วได้ทั้งคำ เหมือน selection ของ OS) — ใช้ Intl.Segmenter ถ้ามี (แยกคำ
* ไทยได้ด้วย dictionary) fallback เป็น regex \S+ — ถ้า caret อยู่บนช่องว่างล้วน
* หรือไม่ใช่ text node = ไม่ขยาย */
function snapCaretToWord(caret: Range): void {
// 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 ?? "";
@ -244,16 +303,21 @@ function snapCaretToWord(caret: Range): void {
}
/** Range จาก caret สองจุด (จัดลำดับ start/end ให้ถูกทิศการลาก) — null เมื่อคนละ
* tree จน compare ไม่ได้ */
function rangeBetween(a: Range, b: Range): Range | 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.startContainer, b.startOffset);
r.setEnd(b.endContainer, b.endOffset);
} else {
r.setStart(b.startContainer, b.startOffset);
r.setEnd(a.startContainer, a.startOffset);
r.setEnd(a.endContainer, a.endOffset);
}
} catch {
return null;
@ -312,16 +376,24 @@ function clampToAnchorPage(
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 ได้ (จอสัมผัส = จุด y−12 หรือจุดแตะจริงลูกใด
* ลูกหนึ่ง) — ใช้เป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง
* (ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨) */
anchorPathPt: ClientPt;
/** จุด 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;
@ -402,17 +474,19 @@ export function useCustomSelection({
setPreviewRects(null);
return;
}
const y = d.curPt.y - (d.pointerType === "touch" ? FINGER_OFFSET_PX : 0);
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);
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, { spanCache: d.spanCache });
focus = resolveTextCaretDeep(d.curPt.x, y, d);
}
}
if (focus) {
@ -431,7 +505,16 @@ export function useCustomSelection({
}
snapCaretToWord(focus);
let range = rangeBetween(d.anchor, focus);
let pageEl = range && !range.collapsed ? pageOfRange(range) : null;
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 ใน
@ -448,18 +531,24 @@ export function useCustomSelection({
return;
}
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จุด y−12 สำหรับ
// จอสัมผัส) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาด 12px จนบรรทัดเป้าหมายโดน
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จอสัมผัส = จุดที่
// หัก fingerOffset แล้ว) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาดจนบรรทัดเป้าหมายโดน
// lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก
// เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨)
const pathEnd: ClientPt =
d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt;
const kept = rectsInSweptPath(rects, d.anchorPathPt, pathEnd);
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 pr = pageEl.getBoundingClientRect();
const rectsPct = mergeRectsByLine(
kept.map((r) =>
rectToPercent(
@ -475,7 +564,7 @@ export function useCustomSelection({
);
lastRef.current = {
range: range.cloneRange(),
start: d.anchorPathPt,
start: anchorNow,
end: pathEnd,
};
const pageIndex = Number(pageEl.dataset.page);
@ -519,7 +608,10 @@ export function useCustomSelection({
Math.max(
1,
Math.ceil(
(into / AUTOSCROLL_MARGIN_PX) * AUTOSCROLL_MAX_SPEED_PX,
// cap ระยะเลยขอบที่ margin — นิ้วอยู่นอกพื้นที่เอกสารไกล ๆ ต้องไม่
// เร็วกว่า AUTOSCROLL_MAX_SPEED_PX (A7.1)
(Math.min(into, AUTOSCROLL_MARGIN_PX) / AUTOSCROLL_MARGIN_PX) *
AUTOSCROLL_MAX_SPEED_PX,
),
);
let dx = 0;
@ -543,9 +635,9 @@ export function useCustomSelection({
scrollRafRef.current = requestAnimationFrame(step);
};
const seenMovePointerIds = new Set<number>();
const onPointerDown = (e: PointerEvent) => {
// อุปกรณ์ hybrid (จอสัมผัส + เมาส์): คลิกขวา/กลางไม่ใช่การเริ่มเลือกข้อความ (A7.4)
if (e.pointerType === "mouse" && e.button !== 0) return;
if (dragRef.current) {
// นิ้วที่สองระหว่างลาก — ไม่สน รวมถึง touch ปลอมจาก palm rejection (มือ
// ที่พักบนจอระหว่างลากด้วย pen) — ห้ามให้ pointer ตัวใหม่แทนที่ drag เดิม
@ -580,16 +672,38 @@ export function useCustomSelection({
// ธรรมดา ไม่เริ่ม 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 },
anchorPathPt: { x: e.clientX, y: resolvedY },
anchorRel: {
x: (e.clientX - anchorPr.left) / anchorPr.width,
y: (resolvedY - anchorPr.top) / anchorPr.height,
},
curPt: { x: e.clientX, y: e.clientY },
anchor: caret,
anchorPageEl,
@ -600,14 +714,13 @@ export function useCustomSelection({
anchorSnapped: false,
active: false,
};
// arm ตั้งแต่ pointerdown ไม่ใช่ตอนเลย threshold — tap ที่ไม่ขยับ (ไม่
// active) ก็ต้องมีตาข่ายรับ pointerup ของตัวเอง ไม่งั้น dragRef ค้างจน
// gesture ถัดไปเงียบหมด (เจอจริงหลังย้าย listener ออกจาก el — e2e 2026-09-28)
armDocUpListeners();
};
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 };
@ -628,11 +741,31 @@ export function useCustomSelection({
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) {
@ -649,17 +782,19 @@ export function useCustomSelection({
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);
// 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);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointercancel", onCancel);
disarmDocUpListeners();
stopAutoScroll();
stopPreviewRaf();
dragRef.current = null;

537
package-lock.json generated
View file

@ -28,6 +28,7 @@
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
@ -47,6 +48,212 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz",
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz",
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz",
"integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@ -932,6 +1139,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -1755,7 +1980,6 @@
"integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.9.0"
}
@ -1925,6 +2149,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2098,6 +2332,20 @@
"node": ">= 6"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2118,6 +2366,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2774,6 +3043,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@ -3000,6 +3282,19 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -3062,6 +3357,13 @@
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@ -3079,6 +3381,48 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@ -3372,6 +3716,16 @@
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lucide-react": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz",
@ -3391,6 +3745,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3583,6 +3944,19 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -3836,6 +4210,16 @@
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -3904,6 +4288,16 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -4005,6 +4399,19 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -4132,6 +4539,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -4262,6 +4676,26 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": {
"version": "7.4.16",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
"integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.16"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.16",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
"integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4275,6 +4709,32 @@
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -4321,6 +4781,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@ -4562,6 +5032,54 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -4578,6 +5096,23 @@
"engines": {
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}

View file

@ -30,6 +30,7 @@
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",

View file

@ -0,0 +1,82 @@
// @vitest-environment jsdom
// เทสต์ pure helpers ของ useCustomSelection ที่ต้องมี DOM Range จริง — แยกจาก
// ชุด pure utils (environment node ตาม vitest.config.ts) ด้วย pragma ต่อไฟล์
// ตัว hook เอง (Pointer Events + rAF + gesture จริง) ยืนยันด้วย Playwright แทน
import { describe, expect, it } from "vitest";
import { rangeBetween, snapCaretToWord } from "@/hooks/useCustomSelection";
/** text node เดียวใน body + collapsed caret ที่ offset (เลียน anchor/focus ของ
* useCustomSelection ก่อนผ่าน word snap) */
function setup(text: string, offset: number): { caret: () => Range } {
const node = document.createTextNode(text);
document.body.replaceChildren(node);
return {
caret: () => {
const r = document.createRange();
r.setStart(node, offset);
r.collapse(true);
return r;
},
};
}
describe("snapCaretToWord", () => {
it("จิ้มกลางคำ → ขยายครอบทั้งคำ (อังกฤษ)", () => {
const { caret } = setup("alpha beta gamma", 2);
const c = caret();
snapCaretToWord(c);
expect(c.toString()).toBe("alpha");
});
it("จิ้มกลางคำไทย (ไม่มีช่องว่าง) → Intl.Segmenter แยกคำให้ (node ≥16 มี full-icu)", () => {
const { caret } = setup("แมวกินปลา", 1);
const c = caret();
snapCaretToWord(c);
expect(c.toString()).toBe("แมว");
});
it("จิ้มกลางช่องว่างล้วน → ไม่ขยาย (ยัง collapsed)", () => {
const { caret } = setup("alpha beta", 6);
const c = caret();
snapCaretToWord(c);
expect(c.collapsed).toBe(true);
});
});
describe("rangeBetween — ฝั่งหลังต้องใช้ end boundary (A1: อย่าทิ้งคำท้าย)", () => {
const TEXT = "alpha beta gamma delta";
// offset: alpha 0–5, beta 6–10, gamma 11–16, delta 17–22
/** เลียน flow จริง: สร้าง caret สองจุด → word snap ทั้งคู่ → rangeBetween */
function between(offA: number, offB: number): string {
const node = document.createTextNode(TEXT);
document.body.replaceChildren(node);
const mk = (off: number) => {
const r = document.createRange();
r.setStart(node, off);
r.collapse(true);
return r;
};
const a = mk(offA);
const b = mk(offB);
snapCaretToWord(a);
snapCaretToWord(b);
return rangeBetween(a, b)?.toString() ?? "<null>";
}
it("ลาก alpha→gamma ได้ครบถึงคำท้าย (เดิมหาย gamma)", () => {
expect(between(2, 13)).toBe("alpha beta gamma");
});
it("ลากย้อน gamma→alpha ได้ผลเดียวกัน", () => {
expect(between(13, 2)).toBe("alpha beta gamma");
});
it("ลากในคำเดียว (beta→beta) ได้ทั้งคำ (เดิม selection ว่าง)", () => {
expect(between(7, 9)).toBe("beta");
});
it("ลาก alpha→beta ได้สองคำ", () => {
expect(between(2, 8)).toBe("alpha beta");
});
});

View file

@ -190,3 +190,67 @@ describe("rectsInSweptPath", () => {
expect(kept).toEqual([...L1]);
});
});
describe("rectsInSweptPath — snap จุดเข้าบรรทัดใกล้สุดก่อนตัดสิน (A3 2026-09-28)", () => {
// พิกัดนิ้วดิบที่ห่าง center บรรทัดเพียงเล็กน้อยแต่คนละบรรทัด เคยถูกตีเป็น
// "บรรทัดเดียวกัน" จนกรองได้ 0 rect — ชุดนี้ยืนยันว่า snap แล้วผลถูกต้อง
// (บรรทัดสูง 20 ระยะห่าง 24 → center = 24*i + 10 + dy, lineTol = 12)
const R2 = (x: number, y: number, width: number, height: number) =>
({ x, y, width, height }) as DOMRect;
const L = (i: number, x0: number, x1: number, dy = 0) =>
R2(x0, 24 * i + dy, x1 - x0, 20);
it("ลากลงหลายบรรทัด มีบรรทัดรบกวนนอกเส้นทาง: เก็บเฉพาะช่วงที่ลากผ่าน", () => {
const noise = [L(0, 0, 500), L(7, 0, 500)];
const ml = [L(1, 200, 500), L(2, 0, 500), L(3, 0, 500), L(4, 0, 300)];
expect(
rectsInSweptPath([...noise, ...ml], { x: 200, y: 34 }, { x: 300, y: 106 }),
).toHaveLength(4);
});
it("ลากขึ้นหลายบรรทัด (ทิศกลับ): ผลเหมือนลากลง", () => {
const noise = [L(0, 0, 500), L(7, 0, 500)];
const ml = [L(1, 200, 500), L(2, 0, 500), L(3, 0, 500), L(4, 0, 300)];
expect(
rectsInSweptPath([...noise, ...ml], { x: 300, y: 106 }, { x: 200, y: 34 }),
).toHaveLength(4);
});
it("ลากย้อนในบรรทัดเดียว (ขวา→ซ้าย): เก็บ rect ที่เส้นทางพาด", () => {
const rects = [L(0, 0, 500), L(1, 100, 400), L(7, 0, 500)];
expect(
rectsInSweptPath(rects, { x: 400, y: 34 }, { x: 100, y: 34 }),
).toHaveLength(1);
});
it("2 บรรทัด dy ใหญ่: เก็บทั้งคู่ (ท้ายบรรทัดบน → ต้นบรรทัดล่าง)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 400, y: 34 }, { x: 100, y: 58 }),
).toHaveLength(2);
});
it("2 บรรทัด dy เล็ก (พิกัดนิ้วไม่ตรง center — เดิมได้ 0 rect)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 400, y: 42 }, { x: 100, y: 50 }),
).toHaveLength(2);
});
it("2 บรรทัด dy เล็ก ลากขึ้น (เดิมได้ 0 rect)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 100, y: 50 }, { x: 400, y: 42 }),
).toHaveLength(2);
});
it("หลัง auto-scroll 300px: anchor ที่ชดเชยแล้ว (สัดส่วนของหน้า A2) ต้องได้ครบทุกบรรทัด", () => {
const sc = -300;
const rects = [L(0, 100, 500, sc + 100)];
for (let i = 1; i <= 24; i++) rects.push(L(i, 0, 500, sc + 100));
rects.push(L(25, 0, 300, sc + 100));
expect(
rectsInSweptPath(rects, { x: 100, y: 110 + sc }, { x: 300, y: 400 }),
).toHaveLength(26);
});
});

View file

@ -69,29 +69,51 @@ export type ClientPt = { x: number; y: number };
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง
* ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส
* (useCustomSelection ลากด้วยนิ้ว) */
* (useCustomSelection ลากด้วยนิ้ว)
*
* ก่อนตัดสิน "บรรทัดเดียวกัน/คนละบรรทัด" จุด start/end ถูก snap เข้า center
* ของบรรทัดที่ใกล้ที่สุดก่อน (snapY) — พิกัดนิ้วดิบที่ห่างจาก center บรรทัดเพียง
* เล็กน้อยแต่คนละบรรทัด (ลากจากท้ายบรรทัดบนไปต้นบรรทัดถัดไประยะสั้น) เคยถูกตี
* เป็น "บรรทัดเดียวกัน" จนกรองได้ 0 rect (bug report A3 2026-09-28) */
export function rectsInSweptPath(
rects: DOMRect[],
start: ClientPt,
end: ClientPt,
): DOMRect[] {
if (rects.length === 0) return rects;
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
const centers = rects.map((r) => r.y + r.height / 2);
const snapY = (y: number) =>
centers.reduce((b, c) => (Math.abs(c - y) < Math.abs(b - y) ? c : b), centers[0]);
const sy = snapY(start.y);
const ey = snapY(end.y);
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
let top: ClientPt;
let bottom: ClientPt;
if (Math.abs(end.y - start.y) < lineTol) {
let top: ClientPt, bottom: ClientPt, topY: number, bottomY: number;
if (Math.abs(ey - sy) < lineTol) {
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
topY = bottomY = Math.min(sy, ey);
} else if (sy < ey) {
top = start;
bottom = end;
topY = sy;
bottomY = ey;
} else {
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
top = end;
bottom = start;
topY = ey;
bottomY = sy;
}
return rects.filter((r) => {
const cy = r.y + r.height / 2;
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (cy < topY - lineTol || cy > bottomY + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (!moved) return true;
const inTop = Math.abs(cy - top.y) <= lineTol;
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
const inTop = Math.abs(cy - topY) <= lineTol;
const inBottom = Math.abs(cy - bottomY) <= lineTol;
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด