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:
parent
c2ed9dabc1
commit
45e60330b8
7 changed files with 913 additions and 74 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue