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 { AnnotationData, AnnotationType, ShapeData } from "@/db/schema";
|
|
|
|
|
|
import {
|
|
|
|
|
|
clamp,
|
|
|
|
|
|
pointToPx,
|
|
|
|
|
|
rectToPx,
|
|
|
|
|
|
toPx,
|
|
|
|
|
|
type Point,
|
|
|
|
|
|
type Rect,
|
|
|
|
|
|
type Size,
|
|
|
|
|
|
} from "@/utils/coordinates";
|
|
|
|
|
|
|
|
|
|
|
|
// Geometry ของ annotation (PLAN-FEAT.md §3 F1) — pure functions ทดสอบได้
|
|
|
|
|
|
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
|
|
|
|
|
|
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
|
|
|
|
|
|
|
|
|
|
|
|
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data */
|
|
|
|
|
|
export const COMMENT_PIN_BOX_PCT = 4;
|
|
|
|
|
|
|
|
|
|
|
|
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
|
|
|
|
|
|
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
|
|
|
|
|
|
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
|
|
|
|
|
|
export const TEXTBOX_MAX_WIDTH_PCT = 50;
|
|
|
|
|
|
export const TEXTBOX_CHAR_W = 0.6;
|
|
|
|
|
|
export const TEXTBOX_LINE_H = 1.5;
|
|
|
|
|
|
|
|
|
|
|
|
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
|
|
|
|
|
|
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
|
|
|
|
|
|
export const STAMP_HEIGHT_RATIO = 0.4;
|
|
|
|
|
|
|
|
|
|
|
|
type AnnotationLike = { type: AnnotationType; data: AnnotationData };
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* bbox ของ annotation (หน่วย px ตาม size ที่ส่งเข้ามา)
|
|
|
|
|
|
* - HIGHLIGHT: union ของ rects ทุกอัน
|
|
|
|
|
|
* - DRAW: union ของจุดทุกจุด ขยายออกด้วย strokeWidth/2 (เส้นมีความหนา)
|
|
|
|
|
|
* - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position
|
|
|
|
|
|
* เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function annotationBBox(a: AnnotationLike, size: Size): Rect {
|
|
|
|
|
|
const d = a.data;
|
|
|
|
|
|
// union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant
|
|
|
|
|
|
if ("rects" in d) {
|
|
|
|
|
|
const rects = d.rects.map((r) => rectToPx(r, size));
|
|
|
|
|
|
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
|
|
|
|
|
|
const x1 = Math.min(...rects.map((r) => r.x));
|
|
|
|
|
|
const y1 = Math.min(...rects.map((r) => r.y));
|
|
|
|
|
|
const x2 = Math.max(...rects.map((r) => r.x + r.width));
|
|
|
|
|
|
const y2 = Math.max(...rects.map((r) => r.y + r.height));
|
|
|
|
|
|
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("paths" in d) {
|
|
|
|
|
|
const pts = d.paths.map((p) => pointToPx(p, size));
|
|
|
|
|
|
const half = toPx(d.strokeWidth, size.width) / 2;
|
|
|
|
|
|
const x1 = Math.min(...pts.map((p) => p.x)) - half;
|
|
|
|
|
|
const y1 = Math.min(...pts.map((p) => p.y)) - half;
|
|
|
|
|
|
const x2 = Math.max(...pts.map((p) => p.x)) + half;
|
|
|
|
|
|
const y2 = Math.max(...pts.map((p) => p.y)) + half;
|
|
|
|
|
|
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("shape" in d) {
|
|
|
|
|
|
const half = toPx(d.strokeWidth, size.width) / 2;
|
|
|
|
|
|
if ("rect" in d) {
|
|
|
|
|
|
const r = rectToPx(d.rect, size);
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: r.x - half,
|
|
|
|
|
|
y: r.y - half,
|
|
|
|
|
|
width: r.width + half * 2,
|
|
|
|
|
|
height: r.height + half * 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
const a1 = pointToPx(d.from, size);
|
|
|
|
|
|
const b1 = pointToPx(d.to, size);
|
|
|
|
|
|
const x1 = Math.min(a1.x, b1.x) - half;
|
|
|
|
|
|
const y1 = Math.min(a1.y, b1.y) - half;
|
|
|
|
|
|
const x2 = Math.max(a1.x, b1.x) + half;
|
|
|
|
|
|
const y2 = Math.max(a1.y, b1.y) + half;
|
|
|
|
|
|
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("rect" in d) {
|
|
|
|
|
|
// REDACTION — กรอบดำทึบ ไม่มีความหนาเส้นให้ขยาย
|
|
|
|
|
|
return rectToPx(d.rect, size);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("fontSize" in d) {
|
|
|
|
|
|
// TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง)
|
|
|
|
|
|
const fontSizePx = toPx(d.fontSize, size.width);
|
|
|
|
|
|
const charW = fontSizePx * TEXTBOX_CHAR_W;
|
|
|
|
|
|
const lineH = fontSizePx * TEXTBOX_LINE_H;
|
|
|
|
|
|
const maxW = toPx(TEXTBOX_MAX_WIDTH_PCT, size.width);
|
|
|
|
|
|
let widest = 0;
|
|
|
|
|
|
let lines = 0;
|
|
|
|
|
|
for (const raw of d.text.split(/\r?\n/)) {
|
|
|
|
|
|
widest = Math.max(widest, raw.length * charW);
|
|
|
|
|
|
lines += Math.max(1, Math.ceil((raw.length * charW) / maxW));
|
|
|
|
|
|
}
|
|
|
|
|
|
const p = pointToPx(d.position, size);
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: p.x,
|
|
|
|
|
|
y: p.y,
|
|
|
|
|
|
width: Math.min(widest, maxW) || charW,
|
|
|
|
|
|
height: Math.max(lines, 1) * lineH,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("scale" in d) {
|
|
|
|
|
|
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
|
|
|
|
|
|
const w = toPx(d.scale, size.width);
|
|
|
|
|
|
const p = pointToPx(d.position, size);
|
|
|
|
|
|
return { x: p.x, y: p.y, width: w, height: w * STAMP_HEIGHT_RATIO };
|
|
|
|
|
|
}
|
|
|
|
|
|
const p = pointToPx(d.position, size);
|
|
|
|
|
|
// กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space %
|
|
|
|
|
|
// (size={100,100}) ไม่เกิดหน่วยเพี้ยนตอน clamp translate/scale ของ COMMENT
|
|
|
|
|
|
const box = toPx(COMMENT_PIN_BOX_PCT, size.width);
|
|
|
|
|
|
return { x: p.x - box / 2, y: p.y - box / 2, width: box, height: box };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* เลื่อน annotation ทั้งชิ้นตาม offset (หน่วย % ของหน้า) — ใช้ตอนลากย้าย
|
|
|
|
|
|
* strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)
|
|
|
|
|
|
* ผู้เรียกต้อง clamp dx/dy เองให้ bbox ยังอยู่ใน 0–100 ก่อนส่งเข้า action
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function translateAnnotationData(
|
|
|
|
|
|
data: AnnotationData,
|
|
|
|
|
|
dxPct: number,
|
|
|
|
|
|
dyPct: number,
|
|
|
|
|
|
): AnnotationData {
|
|
|
|
|
|
if ("rects" in data) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
rects: data.rects.map((r) => ({ ...r, x: r.x + dxPct, y: r.y + dyPct })),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("paths" in data) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
paths: data.paths.map((p) => ({ x: p.x + dxPct, y: p.y + dyPct })),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("shape" in data) {
|
|
|
|
|
|
if ("rect" in data) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
from: { x: data.from.x + dxPct, y: data.from.y + dyPct },
|
|
|
|
|
|
to: { x: data.to.x + dxPct, y: data.to.y + dyPct },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("rect" in data) {
|
|
|
|
|
|
// REDACTION
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
position: { x: data.position.x + dxPct, y: data.position.y + dyPct },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ย่อ–ขยาย annotation รอบจุด anchor (หน่วย % ของหน้า) ด้วยตัวคูณแกน x/y
|
|
|
|
|
|
* — ใช้ตอนลากมุมกรอบ (anchor = มุมตรงข้าม) ครอบ rects ทุกอัน / จุดทุกจุด / position
|
|
|
|
|
|
* strokeWidth คงเดิมตามแผน (สเปก F1 ระบุแค่ rects/points/position)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function scaleAnnotationData(
|
|
|
|
|
|
data: AnnotationData,
|
|
|
|
|
|
anchorPct: Point,
|
|
|
|
|
|
sx: number,
|
|
|
|
|
|
sy: number,
|
|
|
|
|
|
): AnnotationData {
|
|
|
|
|
|
const fx = (x: number) => anchorPct.x + (x - anchorPct.x) * sx;
|
|
|
|
|
|
const fy = (y: number) => anchorPct.y + (y - anchorPct.y) * sy;
|
|
|
|
|
|
if ("rects" in data) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
rects: data.rects.map((r) => ({
|
|
|
|
|
|
x: fx(r.x),
|
|
|
|
|
|
y: fy(r.y),
|
|
|
|
|
|
width: r.width * sx,
|
|
|
|
|
|
height: r.height * sy,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("paths" in data) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
paths: data.paths.map((p) => ({ x: fx(p.x), y: fy(p.y) })),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("shape" in data) {
|
|
|
|
|
|
if ("rect" in data) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
rect: {
|
|
|
|
|
|
x: fx(data.rect.x),
|
|
|
|
|
|
y: fy(data.rect.y),
|
|
|
|
|
|
width: data.rect.width * sx,
|
|
|
|
|
|
height: data.rect.height * sy,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
from: { x: fx(data.from.x), y: fy(data.from.y) },
|
|
|
|
|
|
to: { x: fx(data.to.x), y: fy(data.to.y) },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if ("rect" in data) {
|
|
|
|
|
|
// REDACTION
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
rect: {
|
|
|
|
|
|
x: fx(data.rect.x),
|
|
|
|
|
|
y: fy(data.rect.y),
|
|
|
|
|
|
width: data.rect.width * sx,
|
|
|
|
|
|
height: data.rect.height * sy,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-09-11 13:28:35 +07:00
|
|
|
|
if ("scale" in data) {
|
|
|
|
|
|
// STAMP — ขนาดทั้งกว้าง-สูงเก็บใน scale % ตัวเดียว (สัดส่วนตายตัว
|
|
|
|
|
|
// STAMP_HEIGHT_RATIO) จึงขยายด้วยตัวคูณเดียว: caller ส่ง sx = sy = k
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
position: { x: fx(data.position.x), y: fy(data.position.y) },
|
|
|
|
|
|
scale: data.scale * sx,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
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
|
|
|
|
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function distToSegment(p: Point, a: Point, b: Point): number {
|
|
|
|
|
|
const dx = b.x - a.x;
|
|
|
|
|
|
const dy = b.y - a.y;
|
|
|
|
|
|
const lenSq = dx * dx + dy * dy;
|
|
|
|
|
|
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
|
|
|
|
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq, 0, 1);
|
|
|
|
|
|
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* hit-test จุด (px) กับ polyline (% ของหน้า) — ระยะถึง segment ใด ๆ ≤ thresholdPx
|
|
|
|
|
|
* ใช้ใน F6 (eraser); threshold หน่วย px เพราะความคาดหวังของผู้ใช้อิงขนาดจอ ไม่ใช่ % ของหน้า
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function hitTestPath(
|
|
|
|
|
|
pointsPct: Point[],
|
|
|
|
|
|
pointPct: Point,
|
|
|
|
|
|
thresholdPx: number,
|
|
|
|
|
|
size: Size,
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
if (pointsPct.length === 0) return false;
|
|
|
|
|
|
const pts = pointsPct.map((p) => pointToPx(p, size));
|
|
|
|
|
|
const pt = pointToPx(pointPct, size);
|
|
|
|
|
|
if (pts.length === 1) return Math.hypot(pt.x - pts[0].x, pt.y - pts[0].y) <= thresholdPx;
|
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
|
if (distToSegment(pt, pts[i], pts[i + 1]) <= thresholdPx) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ระยะจากจุดถึงเส้นขอบสี่เหลี่ยม (px) — อยู่ใน "พื้นที่" กรอบจะได้ระยะถึงขอบ ไม่ใช่ 0 */
|
|
|
|
|
|
function distToRectOutline(p: Point, r: Rect): number {
|
|
|
|
|
|
const inside =
|
|
|
|
|
|
p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
|
|
|
|
|
|
if (inside) {
|
|
|
|
|
|
const dx = Math.min(p.x - r.x, r.x + r.width - p.x);
|
|
|
|
|
|
const dy = Math.min(p.y - r.y, r.y + r.height - p.y);
|
|
|
|
|
|
return Math.min(dx, dy);
|
|
|
|
|
|
}
|
|
|
|
|
|
const cx = Math.max(r.x, Math.min(p.x, r.x + r.width));
|
|
|
|
|
|
const cy = Math.max(r.y, Math.min(p.y, r.y + r.height));
|
|
|
|
|
|
return Math.hypot(p.x - cx, p.y - cy);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ประมาณระยะจากจุดถึงเส้นรอบวงรี (px) — scale-space: |r−1|×min(rx,ry) พอสำหรับ eraser */
|
|
|
|
|
|
function distToEllipseOutline(p: Point, r: Rect): number {
|
|
|
|
|
|
const cx = r.x + r.width / 2;
|
|
|
|
|
|
const cy = r.y + r.height / 2;
|
|
|
|
|
|
const rx = r.width / 2;
|
|
|
|
|
|
const ry = r.height / 2;
|
|
|
|
|
|
if (rx === 0 || ry === 0) return Math.hypot(p.x - cx, p.y - cy);
|
|
|
|
|
|
const ratio = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry);
|
|
|
|
|
|
return Math.abs(ratio - 1) * Math.min(rx, ry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* hit-test จุดกับรูปทรง (F6 eraser) — เฉพาะ "เส้น stroke/ขอบ" เท่านั้น
|
|
|
|
|
|
* ผ่านพื้นที่ตรงกลางรูปทรงไม่โดน (PLAN-FEAT.md §5)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function hitTestShape(
|
|
|
|
|
|
data: ShapeData,
|
|
|
|
|
|
pointPct: Point,
|
|
|
|
|
|
thresholdPx: number,
|
|
|
|
|
|
size: Size,
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
const p = pointToPx(pointPct, size);
|
|
|
|
|
|
if ("rect" in data) {
|
|
|
|
|
|
const r = rectToPx(data.rect, size);
|
|
|
|
|
|
if (data.shape === "ellipse") return distToEllipseOutline(p, r) <= thresholdPx;
|
|
|
|
|
|
return distToRectOutline(p, r) <= thresholdPx;
|
|
|
|
|
|
}
|
|
|
|
|
|
const a = pointToPx(data.from, size);
|
|
|
|
|
|
const b = pointToPx(data.to, size);
|
|
|
|
|
|
return distToSegment(p, a, b) <= thresholdPx;
|
|
|
|
|
|
}
|