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
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
|
|
|||
537
package-lock.json
generated
537
package-lock.json
generated
|
|
@ -28,6 +28,7 @@
|
|||
"@types/react-dom": "^18.3.7",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
@ -47,6 +48,212 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
|
||||
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz",
|
||||
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.1",
|
||||
"@csstools/css-tokenizer": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.2",
|
||||
"@csstools/css-calc": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.1",
|
||||
"@csstools/css-tokenizer": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz",
|
||||
"integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
|
||||
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@drizzle-team/brocli": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
|
|
@ -932,6 +1139,24 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
|
@ -1755,7 +1980,6 @@
|
|||
"integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
|
|
@ -1925,6 +2149,16 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
@ -2098,6 +2332,20 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -2118,6 +2366,27 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
|
@ -2774,6 +3043,19 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
|
|
@ -3000,6 +3282,19 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
|
|
@ -3062,6 +3357,13 @@
|
|||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
|
|
@ -3079,6 +3381,48 @@
|
|||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
|
@ -3372,6 +3716,16 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.42.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz",
|
||||
|
|
@ -3391,6 +3745,13 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
@ -3583,6 +3944,19 @@
|
|||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
|
|
@ -3836,6 +4210,16 @@
|
|||
"url": "https://github.com/sponsors/porsager"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -3904,6 +4288,16 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
|
|
@ -4005,6 +4399,19 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
|
@ -4132,6 +4539,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
|
|
@ -4262,6 +4676,26 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.16",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
|
||||
"integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.16"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.16",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
|
||||
"integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -4275,6 +4709,32 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
|
|
@ -4321,6 +4781,16 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.30.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
|
||||
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
|
|
@ -4562,6 +5032,54 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
|
@ -4578,6 +5096,23 @@
|
|||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"@types/react-dom": "^18.3.7",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,29 +69,51 @@ export type ClientPt = { x: number; y: number };
|
|||
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
|
||||
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง
|
||||
* ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส
|
||||
* (useCustomSelection ลากด้วยนิ้ว) */
|
||||
* (useCustomSelection ลากด้วยนิ้ว)
|
||||
*
|
||||
* ก่อนตัดสิน "บรรทัดเดียวกัน/คนละบรรทัด" จุด start/end ถูก snap เข้า center
|
||||
* ของบรรทัดที่ใกล้ที่สุดก่อน (snapY) — พิกัดนิ้วดิบที่ห่างจาก center บรรทัดเพียง
|
||||
* เล็กน้อยแต่คนละบรรทัด (ลากจากท้ายบรรทัดบนไปต้นบรรทัดถัดไประยะสั้น) เคยถูกตี
|
||||
* เป็น "บรรทัดเดียวกัน" จนกรองได้ 0 rect (bug report A3 2026-09-28) */
|
||||
export function rectsInSweptPath(
|
||||
rects: DOMRect[],
|
||||
start: ClientPt,
|
||||
end: ClientPt,
|
||||
): DOMRect[] {
|
||||
if (rects.length === 0) return rects;
|
||||
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
|
||||
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
|
||||
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
|
||||
|
||||
const centers = rects.map((r) => r.y + r.height / 2);
|
||||
const snapY = (y: number) =>
|
||||
centers.reduce((b, c) => (Math.abs(c - y) < Math.abs(b - y) ? c : b), centers[0]);
|
||||
const sy = snapY(start.y);
|
||||
const ey = snapY(end.y);
|
||||
|
||||
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
|
||||
let top: ClientPt;
|
||||
let bottom: ClientPt;
|
||||
if (Math.abs(end.y - start.y) < lineTol) {
|
||||
let top: ClientPt, bottom: ClientPt, topY: number, bottomY: number;
|
||||
if (Math.abs(ey - sy) < lineTol) {
|
||||
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
|
||||
topY = bottomY = Math.min(sy, ey);
|
||||
} else if (sy < ey) {
|
||||
top = start;
|
||||
bottom = end;
|
||||
topY = sy;
|
||||
bottomY = ey;
|
||||
} else {
|
||||
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
|
||||
top = end;
|
||||
bottom = start;
|
||||
topY = ey;
|
||||
bottomY = sy;
|
||||
}
|
||||
|
||||
return rects.filter((r) => {
|
||||
const cy = r.y + r.height / 2;
|
||||
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
|
||||
if (cy < topY - lineTol || cy > bottomY + lineTol) return false; // บรรทัดนอกเส้นทางลาก
|
||||
if (!moved) return true;
|
||||
const inTop = Math.abs(cy - top.y) <= lineTol;
|
||||
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
|
||||
const inTop = Math.abs(cy - topY) <= lineTol;
|
||||
const inBottom = Math.abs(cy - bottomY) <= lineTol;
|
||||
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
|
||||
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
|
||||
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue