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
82
tests/hooks/useCustomSelection-dom.test.ts
Normal file
82
tests/hooks/useCustomSelection-dom.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue