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 แทน * กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ, * บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/ * เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด) * ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง * ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส * (useCustomSelection ลากด้วยนิ้ว) */ export function rectsInSweptPath( rects: DOMRect[], start: ClientPt, end: ClientPt, ): DOMRect[] { 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; // เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก let top: ClientPt; let bottom: ClientPt; if (Math.abs(end.y - start.y) < lineTol) { [top, bottom] = start.x <= end.x ? [start, end] : [end, start]; } else { [top, bottom] = start.y < end.y ? [start, end] : [end, start]; } return rects.filter((r) => { const cy = r.y + r.height / 2; if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก if (!moved) return true; const inTop = Math.abs(cy - top.y) <= lineTol; const inBottom = Math.abs(cy - bottom.y) <= 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); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด if (inBottom) return crosses(-Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ return true; // บรรทัดกลาง: ครอบทั้งบรรทัด }); } /** * รวบ 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; }