eCourt-note/utils/coordinates.ts
Your Name b123ecc9b2
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
fix(pdf-selection): match text to dragged lines
ชื่อ bookmark/ข้อความ copy จากการคลุมข้อความเพี้ยน 3 รูปแบบบน
เอกสารไทย (คำขอผู้ใช้ 2026-10-05 รายงานบนฟอร์มราชการ
"แบบบันทึกผลการทดลองปฏิบัติหน้าที่ราชการ" ที่บรรทัดคุมชิด ~16px)

(1) สองบรรทัดสอดแทรกตัวต่อตัว ("แบบบันทึกผ(สำลกหารัรบท…") —
    บรรทัดไทยคุมชิด rect สูง ~16px เท่าระยะห่างบรรทัด ตัวอักษร
    ของบรรทัดถัดไป (สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อน
    หน้าจริง 1-3px: hit-test เดิม (findIndex ตัวแรกที่เจอ + eps)
    จับข้ามบรรทัดจนโดนเรียงรวมกันด้วย x — แก้ selectionTextWithin
    เป็นประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรต้องอยู่ในแถบ
    ของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับมากสุด

(2) ลากบรรทัดเดียวแต่ได้หลายบรรทัด — DOM range ของ pdf.js มักครอบ
    span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret
    ปลายทางหลุดเข้า span ถัดไป: range.toString() ได้ 3 บรรทัดทั้งที่
    ลากบรรทัดเดียว) — ประตูจุดกึ่งกลางใน (1) ตัดตัวอักษรนอกแถบ
    ทิ้งเอง ได้เฉพาะข้อความที่เห็นบนจอจริง (ดีกว่า native)

(3) ✓ checkbox คอลัมน์ขวาติดมากับบล็อกที่เลือก — pdf.js วาง span ✓
    ไว้ระหว่างบรรทัดข้อความพอดีจน native selection ครอบมันตาม DOM
    (raw toString มี ✓ เหมือนกัน) — rectsInSweptPath: บรรทัดกลาง
    เดิมเก็บทั้งบรรทัดแบบไม่ดูแกน x เปลี่ยนเป็นเก็บเฉพาะช่วงคอลัมน์
    เดียวกับข้อความที่เก็บได้จริง: ยึดขอบซ้าย/ขวาจริงของ rect ที่เก็บ
    ได้จากบรรทัดแรก/สุดท้าย + ขยายริมละ 2×lineTol (ทน justify gap
    ระหว่าง rect, ตาม zoom อัตโนมัติ) — span ที่ห่างเกิน (✓ gap
    30px+) ถูกตัดทั้งจากข้อความและกรอบมาร์กเหลือง (rect ชุดเดียวกัน)

(4) แยกบรรทัด/เรียงลำดับกลุ่ม rect ใช้เกณฑ์เดียวกัน — เดิม sameLine
    (แทรก \n) ใช้ |midY| ≤ max(height) ซึ่งตัดสินผิดกับบรรทัดคุมชิด
    (16 ≤ 16 = บรรทัดเดียวกัน) และ comparator เรียง rectOrder ก็ใช้
    heuristic เดิมจนย่อหน้าที่บรรทัดแรกเยื้องเข้า (left มากกว่า
    บรรทัดถัดไป) โดนเรียงตาม left กลายเป็นข้อความสลับลำดับบรรทัด —
    ทั้งคู่เปลี่ยนมาใช้ "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด"

(5) แก้ตาม code review 2026-10-05 ข้อ 1: หน้าต่างคอลัมน์ของบรรทัด
    กลางห้ามยึดพิกัดจุดลากตรง ๆ (ลากชัน/ลาก margin ซ้ายแล้ว rect
    ครึ่งขวาของบรรทัดกลางโดนตัด) — ยึดขอบจริงตาม (3) แทน; doc comment
    ของ rectsInSweptPath ที่ยังบอก "ไม่ตัดแกน x" sync ให้ตรงพฤติกรรม

(6) tests: coordinates +3 เคส (✓ คนละคอลัมน์ถูกตัด + rect คอลัมน์
    เดียวกันยังเก็บ / ลากชันผ่าน justify gap ยังเก็บเต็มบรรทัด /
    ลาก margin ซ้ายบรรทัดกลางไม่หาย) — รวม 215/215 ผ่าน + tsc

(7) docs sync: rules/pdf-viewer.md + rules/coordinates.md

ยืนยันใน browser จริง (Playwright) บนไฟล์ฟอร์มราชการของผู้ใช้:
heading บรรทัดเดียว / 2 บรรทัด / ลากเลยขอบขวา / บล็อก ๑+๒ ปกติ
และลากชัน — ชื่อ bookmark ตรงเป๊ะ ไม่มีข้อความปน/สลับ/✓ ติดมา

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-05 11:31:33 +07:00

186 lines
11 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 type { Point, Rect } from "@/db/schema";
// พิกัดทุกอย่างที่เก็บลง DB เป็น % ของขนาดหน้า PDF (PLAN.md §2, CLAUDE.md invariant #2)
// ทิศทางแปลง: px → % = ÷size×100, % → px = ×size÷100 — ผ่านไฟล์นี้เท่านั้น
// Point/Rect define ที่ db/schema.ts (PLAN-FEAT.md §1) แล้ว re-export ที่นี่
export type { Rect, Point };
export type Size = { width: number; height: number };
export const toPercent = (px: number, size: number): number => (px / size) * 100;
export const toPx = (percent: number, size: number): number => (percent / 100) * size;
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export const pointToPercent = (p: Point, size: Size): Point => ({
x: toPercent(p.x, size.width),
y: toPercent(p.y, size.height),
});
export const pointToPx = (p: Point, size: Size): Point => ({
x: toPx(p.x, size.width),
y: toPx(p.y, size.height),
});
export const rectToPercent = (r: Rect, size: Size): Rect => ({
x: toPercent(r.x, size.width),
y: toPercent(r.y, size.height),
width: toPercent(r.width, size.width),
height: toPercent(r.height, size.height),
});
export const rectToPx = (r: Rect, size: Size): Rect => ({
x: toPx(r.x, size.width),
y: toPx(r.y, size.height),
width: toPx(r.width, size.width),
height: toPx(r.height, size.height),
});
export const pathToPercent = (path: Point[], size: Size): Point[] =>
path.map((p) => pointToPercent(p, size));
export const pathToPx = (path: Point[], size: Size): Point[] =>
path.map((p) => pointToPx(p, size));
/** พิกัด pointer จาก event เทียบ page element → % (clamp 0–100 เสมอ) */
export function getPointerPercent(
e: { clientX: number; clientY: number },
pageEl: HTMLElement,
): Point {
const rect = pageEl.getBoundingClientRect();
return {
x: clamp(toPercent(e.clientX - rect.left, rect.width), 0, 100),
y: clamp(toPercent(e.clientY - rect.top, rect.height), 0, 100),
};
}
/** จุดบน viewport (client coords) — ใช้ติดตามเส้นทางลากของ pointer */
export type ClientPt = { x: number; y: number };
/** กรอง clientRects ของ selection ให้เหลือเฉพาะแนวเส้นทางที่ผู้ใช้ลากจริง —
* span ของ textLayer ในบาง PDF (เอกสารไทย) เรียงใน DOM ไม่ตรงลำดับบรรทัด และ
* anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node)
* จึงใช้จุด pointerdown + ตำแหน่ง pointer ล่าสุด (client coords) เป็น ground truth แทน
* กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ,
* บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง (ตัด span
* ประกอบที่ลอยคนละคอลัมน์ เช่น ✓ checkbox ขอบขวาของฟอร์ม — bug report 2026-10-05),
* บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ
* (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด) ตัดเฉพาะแกนตั้งเป็นหลัก แกน x ตัด
* เฉพาะบรรทัดกลางที่ลอยคนละคอลัมน์กับที่ลาก
* ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส
* (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, 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 = end;
bottom = start;
topY = ey;
bottomY = sy;
}
const crosses = (r: DOMRect, from: number, to: number) =>
r.x < to && r.x + r.width > from;
const cyOf = (r: DOMRect) => r.y + r.height / 2;
// pass 1: บรรทัดแรก/สุดท้ายตัดตามจุดปลายของการลาก + จดขอบซ้าย/ขวาจริงของสิ่งที่เก็บได้
// (บรรทัดกลางเก็บไว้ตัดสินใน pass 2)
const firstPass: (boolean | null)[] = rects.map((r) => {
const cy = cyOf(r);
if (cy < topY - lineTol || cy > bottomY + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (!moved) return true;
const inTop = Math.abs(cy - topY) <= lineTol;
const inBottom = Math.abs(cy - bottomY) <= lineTol;
if (inTop && inBottom) return crosses(r, top.x, bottom.x); // ลากในบรรทัดเดียว
if (inTop) return crosses(r, top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
if (inBottom) return crosses(r, -Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ
return null; // บรรทัดกลาง
});
// pass 2: บรรทัดกลาง = ครอบทั้งบรรทัด ยกเว้น rect ที่ลอยคนละคอลัมน์ — ช่วงคอลัมน์
// ยึดจาก**ขอบจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย** (ไม่ใช่พิกัดจุดลากตรง ๆ:
// ลากชัน ๆ หรือลากใน margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มบรรทัดตามพฤติกรรม selection
// แบบ native) + ขยายริมทั้งสองข้างด้วย 2×lineTol ให้ทนช่องว่างระหว่าง rect ของข้อความ
// ที่ justify จนแตกหลายก้อน (pad อิงความสูงบรรทัดจึงตาม zoom อัตโนมัติ) — span ประกอบ
// ที่ห่างจากก้อนข้อความเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js
// วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ยังถูกตัด
// (bug report 2026-10-05 + code review ข้อ 1)
let rightEdge = -Infinity;
let leftEdge = Infinity;
rects.forEach((r, i) => {
if (firstPass[i] !== true) return;
const cy = cyOf(r);
if (Math.abs(cy - topY) <= lineTol) rightEdge = Math.max(rightEdge, r.x + r.width);
if (Math.abs(cy - bottomY) <= lineTol) leftEdge = Math.min(leftEdge, r.x);
});
if (rightEdge === -Infinity) rightEdge = Math.max(top.x, bottom.x);
if (leftEdge === Infinity) leftEdge = Math.min(top.x, bottom.x);
const pad = lineTol * 2;
return rects.filter((r, i) => {
const v = firstPass[i];
if (v === null) return crosses(r, leftEdge - pad, rightEdge + pad); // บรรทัดกลาง
return v;
});
}
/**
* รวบ rects ที่อยู่บรรทัดเดียวกัน (แนวตั้ง overlap กัน = y/height ใกล้กัน)
* เหลือ 1 rect ต่อบรรทัด โดย union แกน x ให้ครอบข้อความเดิมทั้งบรรทัด
* เรียกทุกครั้งก่อนบันทึก HIGHLIGHT ลง DB กัน JSONB บวม
*/
export function mergeRectsByLine(rects: Rect[]): Rect[] {
const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x);
const merged: Rect[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (!last) {
merged.push({ ...r });
continue;
}
// บรรทัดเดียวกัน = ซ้อนแนวตั้งกัน "เกินครึ่ง" ของความสูงที่เตี้ยกว่า — กล่อง span
// ของ textLayer สูงตาม font metrics (ฟอนต์ไทยมีสระบน/ล่าง) จึงชนกันเล็กน้อยระหว่าง
// บรรทัดที่ leading แน่นได้ ต้องไม่ถือเป็นบรรทัดเดียวกัน ไม่งั้นมาร์กเกิน/เด้งไปบรรทัดอื่น
const overlap = Math.min(last.y + last.height, r.y + r.height) - Math.max(last.y, r.y);
if (overlap > Math.min(last.height, r.height) / 2) {
// ขยายกรอบเดิมให้ครอบ rect นี้ด้วย
const right = Math.max(last.x + last.width, r.x + r.width);
last.x = Math.min(last.x, r.x);
last.width = right - last.x;
const bottom = Math.max(last.y + last.height, r.y + r.height);
last.height = bottom - last.y;
} else {
merged.push({ ...r });
}
}
return merged;
}