Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
clamp,
|
|
|
|
|
|
getPointerPercent,
|
|
|
|
|
|
mergeRectsByLine,
|
|
|
|
|
|
pathToPercent,
|
|
|
|
|
|
pathToPx,
|
|
|
|
|
|
pointToPercent,
|
|
|
|
|
|
pointToPx,
|
|
|
|
|
|
rectToPercent,
|
|
|
|
|
|
rectToPx,
|
จอสัมผัส (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
|
|
|
|
rectsInSweptPath,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-11 13:28:35 +07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
จอสัมผัส (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
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-28 11:01:09 +07:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|