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 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),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
จอสัมผัส (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
|
|
|
|
/** จุดบน 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; // บรรทัดกลาง: ครอบทั้งบรรทัด
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* รวบ 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];
|
2026-09-11 13:28:35 +07:00
|
|
|
|
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 นี้ด้วย
|
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
|
|
|
|
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;
|
|
|
|
|
|
}
|