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

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;