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
ชื่อ 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>
186 lines
11 KiB
TypeScript
186 lines
11 KiB
TypeScript
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;
|
||
}
|