eCourt-note/tests/utils/coordinates.test.ts
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 0c4af09e85
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Failing after 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Has been skipped
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
จอสัมผัส (iOS/Android): ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที แทน native selection + ปุ่มยืนยันลอย (code review ครบ):
(1) hooks/useCustomSelection.ts (ใหม่): tool highlight บนอุปกรณ์ pointer หลัก coarse ลากเลือกข้อความเองผ่าน Pointer Events ชุดเดียวคุม mouse+touch — pointerdown เก็บ anchor caret (caretRangeFromPoint → caretPositionFromPoint fallback) ยังไม่ทำอะไร → เลย threshold 8px จึง setPointerCapture + เริ่ม select จริง (จับตอนเริ่ม select ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/pin ใช้ไม่ได้) → pointerup = commit ทันทีผ่าน commitHighlight เดิม (ห้ามแก้ signature — ไปทาง readTextSelection(pi, { range }) ที่เพิ่ม opts.range ให้อ่าน Range จากภายนอกแทน native selection) — ชดเชย UX ที่หายจาก native: word snap ขอบคำทั้ง anchor/focus (Intl.Segmenter แยกคำไทย dictionary → fallback regex \S+, กลางช่องว่างไม่ขยาย), finger offset อ่าน caret ที่ y−12 เพราะนิ้วบังข้อความ (เฉพาะ pointerType touch), auto-scroll rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px, live preview rAF throttle (getClientRects → rectsInSweptPath → % + mergeRectsByLine รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า customSelPreview ?? liveMark)

(2) กัน "ลากแล้วบางทีไม่ commit" ตาม bug report 3 จุด + code review: การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (resolveTextCaretDeep ใช้เฉพาะ pointerdown กับทั้งจุด y−12 และจุดแตะจริง): caret API + descend หา text node (childNodes[startOffset] + findFirstTextNode DFS) → retry 8 ทิศ ±2/3px → elementsFromPoint เจาะ overlay ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ใต้จุด → span ของหน้านั้นที่ใกล้สุดแนวตั้ง ≤40px (เกินถือว่าไม่มีข้อความจริง; cache span list ต่อ 1 gesture) — ระหว่างลากทุก rAF frame ใช้เฉพาะ 2 ชั้นถูก หาไม่เจอ (ผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้คง focus เดิมจาก frame ก่อน (lastFocus) ไม่ re-scan / lastRef เคลียร์เฉพาะ pointerdown, finish และเมื่อหน้า anchor โดน virtualization unmount กลางลาก (anchorPageEl.isConnected — กัน commit ของเก่าเงียบ ๆ) — frame ที่ rectsInSweptPath กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview / ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า) ห้าม bail — clampToAnchorPage clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) boundary cache ต่อ gesture (clampBounds ไม่เดิน TreeWalker ทุก frame) / จุดเส้นทางกรอง rectsInSweptPath ใช้จุด offset เดียวกับ caret (anchorPathPt = จุด probe ที่ resolve anchor + pathEnd = จุด focus หัก 12px) — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกินบรรทัด (ลากลง) เมื่อฟอนต์เล็ก

(3) ถอด path เดิมทั้งหมด: touchConfirm state, touchSelRef/touchSelTimerRef/touchHideTimerRef, debounce 400ms ใน effect selectionchange, handleTouchConfirmHighlight, ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — desktop (pointer หลัก fine) ใช้ native mouse selection + mouseup commit ตามเดิมไม่เปลี่ยน (enabled = tool highlight + matchMedia coarse) / ระหว่าง enabled ให้ <main> ได้ class .pdf-custom-select (user-select/-webkit-user-select/-webkit-touch-callout: none + touch-action: none กัน browser ยึด gesture เป็น scroll → pointercancel) ถอดทันทีเมื่อเปลี่ยน tool (tool อื่น native selection + long-press copy ของ OS ตามปกติ) + restore user-select:text และ touch-action:auto ให้ input/textarea/contenteditable ใต้ main (reply ของ comment pin ยังเลือก/เลื่อนข้อความในช่องได้)

(4) ย้าย ClientPt + rectsInSweptPath จาก PDFViewer ไป utils/coordinates.ts (hook import ได้โดยไม่ circular) + unit tests rectsInSweptPath 4 เคส (ลากบรรทัดเดียว/ลากขึ้นหลายบรรทัด/บรรทัดนอกเส้นทาง/ไม่ขยับ) / gap-analysis.md แถว 5.3.18 อัปเดตเป็นกลไกใหม่ (รวมงานฉบับแก้ไขข้อความข้อ 5.3.18–5.3.25 ของเจ้าของเอกสารที่ค้างใน working tree) / sync rules/pdf-viewer.md ครบทั้งกลไก 4 ชั้น + path points + กรณีหน้า unmount

verify: tsc ผ่าน + vitest 104/104 + Playwright touch emulation 3 ชุด (รอยต่อ span คำติดกัน gap=0/ขอบซ้าย-ขวาสุดบรรทัด/ช่องว่าง line-height, ลากช้า-เร็ว flick/ล้ำหน้าถัดไปทั้งลงและขึ้น/ขยับต่ำกว่า threshold ไม่สร้าง/tap annotation ได้, จุดว่างใกล้ 20px → commit vs ไกล 46px → ไม่สร้าง) — console.warn ชุด TEMP-DEBUG คงไว้ชั่วคราวตาม protocol รอยืนยันบนมือถือจริง (Safari iOS + Chrome Android) ก่อนถอด

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:13:05 +07:00

192 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from "vitest";
import {
clamp,
getPointerPercent,
mergeRectsByLine,
pathToPercent,
pathToPx,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
rectsInSweptPath,
toPercent,
toPx,
type Size,
} from "@/utils/coordinates";
const size: Size = { width: 800, height: 600 };
describe("toPercent / toPx", () => {
it("px → % ตามสัดส่วนขนาดที่ส่งเข้ามา", () => {
expect(toPercent(400, 800)).toBe(50);
expect(toPercent(300, 600)).toBe(50);
expect(toPercent(0, 800)).toBe(0);
});
it("roundtrip px → % → px ได้ค่าเดิม", () => {
expect(toPx(toPercent(123, 800), 800)).toBeCloseTo(123);
});
});
describe("clamp", () => {
it("ต่ำกว่า min → min, อยู่ในช่วง → ค่าเดิม, เกิน max → max", () => {
expect(clamp(-5, 0, 100)).toBe(0);
expect(clamp(42, 0, 100)).toBe(42);
expect(clamp(120, 0, 100)).toBe(100);
});
});
describe("point/rect/path converters", () => {
it("point: x อิง width, y อิง height (แกนแยกกัน)", () => {
expect(pointToPercent({ x: 400, y: 300 }, size)).toEqual({ x: 50, y: 50 });
expect(pointToPx({ x: 25, y: 75 }, size)).toEqual({ x: 200, y: 450 });
});
it("rect: แปลงครบทั้ง 4 ค่า", () => {
expect(rectToPercent({ x: 80, y: 60, width: 160, height: 120 }, size)).toEqual({
x: 10,
y: 10,
width: 20,
height: 20,
});
expect(rectToPx({ x: 10, y: 10, width: 20, height: 20 }, size)).toEqual({
x: 80,
y: 60,
width: 160,
height: 120,
});
});
it("path: แปลงทุกจุดในลิสต์", () => {
expect(
pathToPercent(
[
{ x: 0, y: 0 },
{ x: 800, y: 600 },
],
size,
),
).toEqual([
{ x: 0, y: 0 },
{ x: 100, y: 100 },
]);
expect(pathToPx([{ x: 50, y: 50 }], size)).toEqual([{ x: 400, y: 300 }]);
});
});
describe("getPointerPercent", () => {
const makeEl = (left: number, top: number, width: number, height: number) =>
({ getBoundingClientRect: () => ({ left, top, width, height }) }) as HTMLElement;
it("คิดจากขอบ element ไม่ใช่ขอบจอ", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 200, clientY: 100 }, el)).toEqual({ x: 50, y: 50 });
});
it("เกินขอบ element → clamp 100 / ต่ำกว่าขอบ → clamp 0", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 999, clientY: 999 }, el)).toEqual({ x: 100, y: 100 });
expect(getPointerPercent({ clientX: 0, clientY: 0 }, el)).toEqual({ x: 0, y: 0 });
});
});
describe("mergeRectsByLine", () => {
it("rects บรรทัดเดียวกัน (y overlap) → รวมเป็น 1 rect ครอบกว้าง/สูงสุด", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 45, y: 21, width: 20, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toEqual({ x: 10, y: 20, width: 55, height: 6 });
});
it("rects คนละบรรทัด (y ไม่ overlap) → คงแยกกัน", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 12, y: 30, width: 30, height: 5 },
]);
expect(out).toHaveLength(2);
});
it("บรรทัดติดกันที่กล่องซ้อนกันเล็กน้อย (leading แน่น/ฟอนต์ไทย) → ยังแยกบรรทัดกัน", () => {
// ซ้อน 1 หน่วยจากความสูง 5 (20%) — ไม่ใช่บรรทัดเดียวกัน
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 12, y: 24, width: 30, height: 5 },
]);
expect(out).toHaveLength(2);
});
it("ลำดับ input ไม่สำคัญ — rect ซ้ายมาทีหลังก็รวมถูกจุดเริ่ม", () => {
const out = mergeRectsByLine([
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ x: 10, width: 55 });
});
it("ช่องว่างระหว่างคำในบรรทัดเดียวกันถูกครอบด้วย (union แกน x)", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 10, height: 5 },
{ x: 80, y: 20, width: 10, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0].width).toBe(80); // 10 → 90
});
it("array ว่าง → ว่าง / rect เดี่ยว → ค่าเดิม", () => {
expect(mergeRectsByLine([])).toEqual([]);
expect(mergeRectsByLine([{ x: 1, y: 2, width: 3, height: 4 }])).toEqual([
{ x: 1, y: 2, width: 3, height: 4 },
]);
});
it("ไม่แก้ array และ rect ต้นฉบับ", () => {
const input = [
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
];
const snapshot = input.map((r) => ({ ...r }));
mergeRectsByLine(input);
expect(input).toEqual(snapshot);
});
});
describe("rectsInSweptPath", () => {
// DOMRect ใน node env ไม่มี — function อ่านแค่ x/y/width/height จึงใช้ plain object แทน
const R = (x: number, y: number, width: number, height: number) =>
({ x, y, width, height }) as DOMRect;
// บรรทัดสูง 12px ที่ y=100/120/140 (lineTol = 12×0.6 = 7.2)
const L1 = [R(50, 100, 100, 12), R(200, 100, 100, 12)];
const L2 = [R(20, 120, 280, 12)];
const L3 = [R(50, 140, 100, 12), R(200, 140, 100, 12)];
it("ลากในบรรทัดเดียว เก็บเฉพาะ rect ที่เส้นทางพาดผ่าน (ตัดซ้าย/ขวาที่ไม่ถึง)", () => {
const kept = rectsInSweptPath(L1, { x: 60, y: 106 }, { x: 150, y: 106 });
expect(kept).toEqual([L1[0]]); // rect 50–150 พาด 60–150 ✓, rect 200–300 ไม่ถึง
});
it("ลากขึ้นหลายบรรทัด: บรรทัดแรก(บน)เก็บจากจุดจบ→ท้ายบรรทัด, บรรทัดล่างเก็บต้นบรรทัด→จุดเริ่ม, บรรทัดกลางทั้งบรรทัด", () => {
const kept = rectsInSweptPath(
[...L1, ...L2, ...L3],
{ x: 100, y: 146 }, // เริ่มกลางบรรทัดล่าง (y=140)
{ x: 150, y: 106 }, // จบกลางบรรทัดบน (y=100)
);
expect(kept).toEqual([L1[1], ...L2, L3[0]]); // บนเก็บ 200–300 (จาก 150→ท้าย), ล่างเก็บ 50–150 (ต้น→100)
});
it("บรรทัดนอกช่วงแนวตั้งของเส้นทางถูกทิ้ง (กันมาร์กเด้งไปบรรทัดอื่น)", () => {
const far = [R(50, 60, 100, 12)]; // เหนือบรรทัดบนเกิน lineTol
const kept = rectsInSweptPath([...far, ...L2], { x: 100, y: 126 }, { x: 100, y: 146 });
expect(kept).toEqual(L2);
});
it("ไม่ขยับ (≤4px — ดับเบิลคลิกเลือกคำ): เก็บทุก rect ของบรรทัดนั้นโดยไม่ตัดแกน x", () => {
// จุดคลิกอยู่บนบรรทัด L1 (cy=106) ขยับนิดเดียว — rect ทั้งคู่ของบรรทัดเก็บครบ
// แม้ x ไม่พาดผ่านจุดคลิก (ไม่มีแนวลากให้อิง) ส่วนบรรทัดอื่นนอกแนวตั้งถูกทิ้ง
const kept = rectsInSweptPath([...L1, ...L3], { x: 100, y: 106 }, { x: 102, y: 107 });
expect(kept).toEqual([...L1]);
});
});