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
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type PointerEvent as ReactPointerEvent,
|
|
|
|
|
|
} from "react";
|
|
|
|
|
|
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Bold, Trash2 } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
|
AnnotationData,
|
|
|
|
|
|
AnnotationDTO,
|
|
|
|
|
|
HighlightData,
|
|
|
|
|
|
HighlightStyle,
|
|
|
|
|
|
TextboxData,
|
|
|
|
|
|
} from "@/db/schema";
|
|
|
|
|
|
import {
|
|
|
|
|
|
annotationBBox,
|
|
|
|
|
|
scaleAnnotationData,
|
|
|
|
|
|
translateAnnotationData,
|
|
|
|
|
|
} from "@/utils/geometry";
|
|
|
|
|
|
import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates";
|
|
|
|
|
|
import { STAMP_COLOR } from "@/utils/annotation-display";
|
|
|
|
|
|
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
|
|
|
|
|
|
|
|
|
|
|
|
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
|
|
|
|
|
|
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
|
|
|
|
|
|
// เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto
|
|
|
|
|
|
// → ไม่บังการ select ข้อความของ textLayer (invariant #3)
|
|
|
|
|
|
// - ระหว่างลาก: ส่ง preview data ให้ parent ไป render ที่ layer เดิมของชนิดนั้น
|
|
|
|
|
|
// pointerup → commit ผ่าน onCommit (parent ทำ optimistic + updateAnnotation)
|
|
|
|
|
|
|
|
|
|
|
|
const HANDLE_PX = 10; // ขนาดมุมจับ (scale)
|
|
|
|
|
|
const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
|
|
|
|
|
|
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
|
|
|
|
|
|
const EPS = 1e-6;
|
|
|
|
|
|
|
|
|
|
|
|
type DragState = {
|
|
|
|
|
|
mode: "move" | "scale";
|
|
|
|
|
|
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
|
|
|
|
|
|
startPct: Point;
|
|
|
|
|
|
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
|
|
|
|
|
|
origData: AnnotationData;
|
|
|
|
|
|
/** bbox ก่อนเริ่มลาก (% ของหน้า) */
|
|
|
|
|
|
origBoxPct: Rect;
|
|
|
|
|
|
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
|
|
|
|
|
|
anchorPct: Point;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
|
|
|
|
|
|
function clampAxisScale(
|
|
|
|
|
|
rawS: number,
|
|
|
|
|
|
anchor: number,
|
|
|
|
|
|
edgeA: number,
|
|
|
|
|
|
edgeB: number,
|
|
|
|
|
|
origExtent: number,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
let lo = 0;
|
|
|
|
|
|
let hi = 50;
|
|
|
|
|
|
for (const e of [edgeA, edgeB]) {
|
|
|
|
|
|
const denom = e - anchor;
|
|
|
|
|
|
if (Math.abs(denom) < EPS) continue;
|
|
|
|
|
|
const a = (0 - anchor) / denom;
|
|
|
|
|
|
const b = (100 - anchor) / denom;
|
|
|
|
|
|
lo = Math.max(lo, Math.min(a, b));
|
|
|
|
|
|
hi = Math.min(hi, Math.max(a, b));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (origExtent > EPS) lo = Math.max(lo, MIN_BOX_PCT / origExtent);
|
|
|
|
|
|
if (lo > hi) return 1; // กรอบเดิมใหญ่เต็มหน้า — ไม่ขยาย
|
|
|
|
|
|
return clamp(rawS, lo, hi);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function AnnotationSelectionLayer({
|
|
|
|
|
|
annotation,
|
|
|
|
|
|
size,
|
|
|
|
|
|
editable,
|
|
|
|
|
|
pageBaseWidth,
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
onPreview,
|
|
|
|
|
|
onCommit,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
onBookmark,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
annotation: AnnotationDTO;
|
|
|
|
|
|
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
|
|
|
|
|
|
size: Size;
|
|
|
|
|
|
/** true เฉพาะ annotation ของตัวเอง — ของคนอื่นเห็นแค่กรอบ ไม่มี handle (guard จริงอยู่ที่ action) */
|
|
|
|
|
|
editable: boolean;
|
|
|
|
|
|
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ตอนแสดงในแผง */
|
|
|
|
|
|
pageBaseWidth: number;
|
|
|
|
|
|
/** tool ที่ InkLayer ใช้ pointer (draw/shape/erase) → ปิด handle ไม่ให้ดัก pointer (PLAN-FEAT.md §5) */
|
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
|
/** ระหว่างลาก: data ชั่วคราวให้ parent ไป render ที่ layer เดิม (null = เลิก preview) */
|
|
|
|
|
|
onPreview: (data: AnnotationData | null) => void;
|
|
|
|
|
|
/** pointerup แล้วมีการเปลี่ยนตำแหน่ง/ขนาดจริง → parent ทำ optimistic + updateAnnotation */
|
|
|
|
|
|
onCommit: (id: string, data: AnnotationData) => void;
|
|
|
|
|
|
/** ปุ่มลบบนกรอบ — parent เรียก deleteAnnotation (แสดงเฉพาะรายการของตัวเอง) */
|
|
|
|
|
|
onDelete: (id: string) => void;
|
|
|
|
|
|
/** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */
|
|
|
|
|
|
onBookmark: (yOffsetPct: number) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const dragRef = useRef<DragState | null>(null);
|
|
|
|
|
|
const movedRef = useRef(false);
|
|
|
|
|
|
const latestRef = useRef<AnnotationData | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const boxPx = annotationBBox(annotation, size);
|
|
|
|
|
|
const boxPct = annotationBBox(annotation, { width: 100, height: 100 });
|
|
|
|
|
|
|
|
|
|
|
|
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
|
|
|
|
|
|
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
|
|
|
|
|
|
const pointerPct = (e: ReactPointerEvent): Point => {
|
|
|
|
|
|
const el = rootRef.current;
|
|
|
|
|
|
if (!el) return { x: 0, y: 0 };
|
|
|
|
|
|
return getPointerPercent(e, el);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function beginDrag(
|
|
|
|
|
|
e: ReactPointerEvent<HTMLElement>,
|
|
|
|
|
|
mode: "move" | "scale",
|
|
|
|
|
|
anchorPct: Point,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!editable || e.button !== 0) return;
|
|
|
|
|
|
e.preventDefault(); // กัน native text selection ระหว่างลาก
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
|
|
|
|
dragRef.current = {
|
|
|
|
|
|
mode,
|
|
|
|
|
|
startPct: pointerPct(e),
|
|
|
|
|
|
origData: annotation.data,
|
|
|
|
|
|
origBoxPct: boxPct,
|
|
|
|
|
|
anchorPct,
|
|
|
|
|
|
};
|
|
|
|
|
|
movedRef.current = false;
|
|
|
|
|
|
latestRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function moveDrag(e: ReactPointerEvent<HTMLElement>) {
|
|
|
|
|
|
const st = dragRef.current;
|
|
|
|
|
|
if (!st) return;
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const cur = pointerPct(e);
|
|
|
|
|
|
let next: AnnotationData;
|
|
|
|
|
|
if (st.mode === "move") {
|
|
|
|
|
|
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
|
|
|
|
|
|
const dx = clamp(
|
|
|
|
|
|
cur.x - st.startPct.x,
|
|
|
|
|
|
-st.origBoxPct.x,
|
|
|
|
|
|
100 - st.origBoxPct.x - st.origBoxPct.width,
|
|
|
|
|
|
);
|
|
|
|
|
|
const dy = clamp(
|
|
|
|
|
|
cur.y - st.startPct.y,
|
|
|
|
|
|
-st.origBoxPct.y,
|
|
|
|
|
|
100 - st.origBoxPct.y - st.origBoxPct.height,
|
|
|
|
|
|
);
|
|
|
|
|
|
next = translateAnnotationData(st.origData, dx, dy);
|
2026-09-11 13:28:35 +07:00
|
|
|
|
} else if ("scale" in st.origData) {
|
|
|
|
|
|
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
|
|
|
|
|
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
|
|
|
|
|
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
|
|
|
|
|
|
const rawK =
|
|
|
|
|
|
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
|
|
|
|
|
|
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
|
|
|
|
|
|
2;
|
|
|
|
|
|
const k = clampAxisScale(
|
|
|
|
|
|
clampAxisScale(
|
|
|
|
|
|
rawK,
|
|
|
|
|
|
st.anchorPct.x,
|
|
|
|
|
|
st.origBoxPct.x,
|
|
|
|
|
|
st.origBoxPct.x + st.origBoxPct.width,
|
|
|
|
|
|
st.origBoxPct.width,
|
|
|
|
|
|
),
|
|
|
|
|
|
st.anchorPct.y,
|
|
|
|
|
|
st.origBoxPct.y,
|
|
|
|
|
|
st.origBoxPct.y + st.origBoxPct.height,
|
|
|
|
|
|
st.origBoxPct.height,
|
|
|
|
|
|
);
|
|
|
|
|
|
next = scaleAnnotationData(st.origData, st.anchorPct, k, k);
|
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
|
|
|
|
} else {
|
|
|
|
|
|
const sx = clampAxisScale(
|
|
|
|
|
|
(cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS),
|
|
|
|
|
|
st.anchorPct.x,
|
|
|
|
|
|
st.origBoxPct.x,
|
|
|
|
|
|
st.origBoxPct.x + st.origBoxPct.width,
|
|
|
|
|
|
st.origBoxPct.width,
|
|
|
|
|
|
);
|
|
|
|
|
|
const sy = clampAxisScale(
|
|
|
|
|
|
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS),
|
|
|
|
|
|
st.anchorPct.y,
|
|
|
|
|
|
st.origBoxPct.y,
|
|
|
|
|
|
st.origBoxPct.y + st.origBoxPct.height,
|
|
|
|
|
|
st.origBoxPct.height,
|
|
|
|
|
|
);
|
|
|
|
|
|
next = scaleAnnotationData(st.origData, st.anchorPct, sx, sy);
|
|
|
|
|
|
}
|
|
|
|
|
|
movedRef.current = true;
|
|
|
|
|
|
latestRef.current = next;
|
|
|
|
|
|
onPreview(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function endDrag(e: ReactPointerEvent<HTMLElement>) {
|
|
|
|
|
|
const st = dragRef.current;
|
|
|
|
|
|
if (!st) return;
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
dragRef.current = null;
|
|
|
|
|
|
onPreview(null);
|
|
|
|
|
|
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
|
|
|
|
|
|
movedRef.current = false;
|
|
|
|
|
|
latestRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelDrag() {
|
|
|
|
|
|
if (!dragRef.current) return;
|
|
|
|
|
|
dragRef.current = null;
|
|
|
|
|
|
movedRef.current = false;
|
|
|
|
|
|
latestRef.current = null;
|
|
|
|
|
|
onPreview(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
|
|
|
|
|
|
function commitColor(color: string) {
|
|
|
|
|
|
const d = annotation.data;
|
|
|
|
|
|
if ("rects" in d) onCommit(annotation.id, { ...d, color });
|
|
|
|
|
|
else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color });
|
|
|
|
|
|
// STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน)
|
|
|
|
|
|
else if ("label" in d) onCommit(annotation.id, { ...d, color });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function commitStrokeWidth(w: number) {
|
|
|
|
|
|
const d = annotation.data;
|
|
|
|
|
|
if ("paths" in d) {
|
|
|
|
|
|
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
|
|
|
|
|
|
onCommit(annotation.id, {
|
|
|
|
|
|
...d,
|
|
|
|
|
|
strokeWidth: w,
|
|
|
|
|
|
...(d.widths
|
|
|
|
|
|
? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if ("shape" in d) {
|
|
|
|
|
|
onCommit(annotation.id, { ...d, strokeWidth: w });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
|
|
|
|
|
|
function commitHighlightStyle(style: HighlightStyle) {
|
|
|
|
|
|
const d = annotation.data;
|
|
|
|
|
|
if (!("rects" in d)) return;
|
|
|
|
|
|
onCommit(annotation.id, { ...d, style });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
|
|
|
|
|
|
function commitHighlightOpacity(opacityPct: number) {
|
|
|
|
|
|
const d = annotation.data;
|
|
|
|
|
|
if (!("rects" in d)) return;
|
|
|
|
|
|
const { opacity: _prev, ...rest } = d;
|
|
|
|
|
|
onCommit(annotation.id, {
|
|
|
|
|
|
...rest,
|
|
|
|
|
|
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
|
|
|
|
|
|
function commitStampScale(scale: number) {
|
|
|
|
|
|
const d = annotation.data;
|
|
|
|
|
|
if (!("label" in d)) return;
|
|
|
|
|
|
onCommit(annotation.id, { ...d, scale });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
|
|
|
|
|
|
function commitTextboxPatch(
|
|
|
|
|
|
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!textboxData) return;
|
|
|
|
|
|
onCommit(annotation.id, { ...textboxData, ...patch });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
|
|
|
|
|
|
// paths/shape = เส้น/รูปทรง, label = STAMP) — เก็บเป็นตัวแปรท้องถิ่นเพื่อให้ TS
|
|
|
|
|
|
// narrow type คงอยู่ใน callback ของ JSX
|
|
|
|
|
|
// HIGHLIGHT ห้ามย้าย/ขยาย — boolean แยกไว้ใช้กับเงื่อนไข handle (ไม่แตะการ narrow ของตัวแปรอื่น)
|
|
|
|
|
|
const isHighlightMark = "rects" in annotation.data;
|
|
|
|
|
|
const highlightData: HighlightData | null =
|
|
|
|
|
|
"rects" in annotation.data ? annotation.data : null;
|
|
|
|
|
|
const strokeData =
|
|
|
|
|
|
"paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
|
|
|
|
|
|
const stampData = "label" in annotation.data ? annotation.data : null;
|
|
|
|
|
|
const textboxData: TextboxData | null =
|
|
|
|
|
|
"fontSize" in annotation.data ? annotation.data : null;
|
|
|
|
|
|
|
|
|
|
|
|
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
|
|
|
|
|
|
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
|
|
|
|
|
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "nw",
|
|
|
|
|
|
x: boxPx.x,
|
|
|
|
|
|
y: boxPx.y,
|
|
|
|
|
|
cursor: "nwse-resize",
|
|
|
|
|
|
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "ne",
|
|
|
|
|
|
x: boxPx.x + boxPx.width,
|
|
|
|
|
|
y: boxPx.y,
|
|
|
|
|
|
cursor: "nesw-resize",
|
|
|
|
|
|
anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "sw",
|
|
|
|
|
|
x: boxPx.x,
|
|
|
|
|
|
y: boxPx.y + boxPx.height,
|
|
|
|
|
|
cursor: "nesw-resize",
|
|
|
|
|
|
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "se",
|
|
|
|
|
|
x: boxPx.x + boxPx.width,
|
|
|
|
|
|
y: boxPx.y + boxPx.height,
|
|
|
|
|
|
cursor: "nwse-resize",
|
|
|
|
|
|
anchor: { x: boxPct.x, y: boxPct.y },
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const toolbarAbove = boxPx.y >= 36;
|
|
|
|
|
|
|
|
|
|
|
|
// แผงเครื่องมือปกติเปิดขึ้นด้านบน bbox — แต่ถ้ารายการอยู่ใกล้บนสุดของ viewport
|
|
|
|
|
|
// (หน้าเลื่อนขึ้นจนกรอบอยู่ใต้ sticky header) แผงจะเปิดขึ้นไปโดน header บัง
|
|
|
|
|
|
// → วัดตำแหน่งจริงหลัง render แล้วพลิกลงด้านล่างแทน (รีเซ็ตทุกครั้งที่เปลี่ยนรายการ)
|
|
|
|
|
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
const [flipBelow, setFlipBelow] = useState(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setFlipBelow(false);
|
|
|
|
|
|
}, [annotation.id]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!toolbarAbove || flipBelow || !panelRef.current) return;
|
|
|
|
|
|
const headerH = document.querySelector("header")?.offsetHeight ?? 0;
|
|
|
|
|
|
if (panelRef.current.getBoundingClientRect().top < headerH) {
|
|
|
|
|
|
setFlipBelow(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [toolbarAbove, flipBelow, annotation.id, boxPx.x, boxPx.y]);
|
|
|
|
|
|
const openAbove = toolbarAbove && !flipBelow;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={rootRef}
|
|
|
|
|
|
className="absolute inset-0 z-[4] select-none"
|
|
|
|
|
|
style={{ pointerEvents: "none" }}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute border-2 border-dashed border-primary"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: boxPx.x,
|
|
|
|
|
|
top: boxPx.y,
|
|
|
|
|
|
width: boxPx.width,
|
|
|
|
|
|
height: boxPx.height,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* HIGHLIGHT ผูกกับตำแหน่งข้อความจริงบนหน้า — ห้ามย้าย/ขยาย (เลือกเพื่อเปลี่ยน
|
|
|
|
|
|
สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */}
|
|
|
|
|
|
{editable && !disabled && !isHighlightMark && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute cursor-move"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: boxPx.x - STRIP_PX / 2,
|
|
|
|
|
|
top: boxPx.y - STRIP_PX / 2,
|
|
|
|
|
|
width: boxPx.width + STRIP_PX,
|
|
|
|
|
|
height: STRIP_PX,
|
|
|
|
|
|
pointerEvents: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
|
|
|
|
|
onPointerMove={moveDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={cancelDrag}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute cursor-move"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: boxPx.x - STRIP_PX / 2,
|
|
|
|
|
|
top: boxPx.y + boxPx.height - STRIP_PX / 2,
|
|
|
|
|
|
width: boxPx.width + STRIP_PX,
|
|
|
|
|
|
height: STRIP_PX,
|
|
|
|
|
|
pointerEvents: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
|
|
|
|
|
onPointerMove={moveDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={cancelDrag}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute cursor-move"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: boxPx.x - STRIP_PX / 2,
|
|
|
|
|
|
top: boxPx.y,
|
|
|
|
|
|
width: STRIP_PX,
|
|
|
|
|
|
height: boxPx.height,
|
|
|
|
|
|
pointerEvents: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
|
|
|
|
|
onPointerMove={moveDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={cancelDrag}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute cursor-move"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
|
|
|
|
|
top: boxPx.y,
|
|
|
|
|
|
width: STRIP_PX,
|
|
|
|
|
|
height: boxPx.height,
|
|
|
|
|
|
pointerEvents: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
|
|
|
|
|
onPointerMove={moveDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={cancelDrag}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */}
|
|
|
|
|
|
{corners.map((c) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={c.key}
|
|
|
|
|
|
className="absolute rounded-sm border border-primary bg-primary/50"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: c.x - HANDLE_PX / 2,
|
|
|
|
|
|
top: c.y - HANDLE_PX / 2,
|
|
|
|
|
|
width: HANDLE_PX,
|
|
|
|
|
|
height: HANDLE_PX,
|
|
|
|
|
|
cursor: c.cursor,
|
|
|
|
|
|
pointerEvents: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
|
|
|
|
|
|
onPointerMove={moveDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={cancelDrag}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
|
|
|
|
|
|
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
|
|
|
|
|
|
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */}
|
|
|
|
|
|
{editable && !disabled && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
|
className="absolute w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: boxPx.x,
|
|
|
|
|
|
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
|
|
|
|
|
|
transform: openAbove ? "translateY(-100%)" : undefined,
|
|
|
|
|
|
pointerEvents: "auto",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{highlightData && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PanelRow label="สี">
|
|
|
|
|
|
<ColorSwatches value={highlightData.color} onChange={commitColor} />
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="รูปแบบ">
|
|
|
|
|
|
<div className="flex flex-1 gap-1">
|
|
|
|
|
|
{HIGHLIGHT_STYLE_BTNS.map(({ key, label, Icon }) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
title={label}
|
|
|
|
|
|
aria-pressed={(highlightData.style ?? "highlight") === key}
|
|
|
|
|
|
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${
|
|
|
|
|
|
(highlightData.style ?? "highlight") === key
|
|
|
|
|
|
? "bg-primary text-white"
|
|
|
|
|
|
: "text-slate-500 hover:bg-slate-100"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => commitHighlightStyle(key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="ความทึบ">
|
|
|
|
|
|
<NumberCombo
|
|
|
|
|
|
value={Math.round((highlightData.opacity ?? 1) * 100)}
|
|
|
|
|
|
options={[25, 50, 75, 100]}
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
max={100}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
suffix="%"
|
|
|
|
|
|
onChange={commitHighlightOpacity}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{strokeData && (
|
|
|
|
|
|
<PanelRow label="สี">
|
|
|
|
|
|
<ColorSwatches value={strokeData.strokeColor} onChange={commitColor} />
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{strokeData && (
|
|
|
|
|
|
<PanelRow label="ความหนา">
|
|
|
|
|
|
{/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับ dropdown ของ tool draw/shape */}
|
|
|
|
|
|
<NumberCombo
|
|
|
|
|
|
value={Number((strokeData.strokeWidth * 10).toFixed(2))}
|
|
|
|
|
|
options={[1, 2, 4, 8, 12]}
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
max={30}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
onChange={(v) => commitStrokeWidth(v / 10)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{stampData && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PanelRow label="สี">
|
|
|
|
|
|
<ColorSwatches
|
|
|
|
|
|
value={stampData.color ?? STAMP_COLOR}
|
|
|
|
|
|
onChange={commitColor}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="ขนาด">
|
|
|
|
|
|
<NumberCombo
|
|
|
|
|
|
value={stampData.scale}
|
|
|
|
|
|
options={[12, 18, 25, 40]}
|
|
|
|
|
|
min={5}
|
|
|
|
|
|
max={60}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
suffix="%"
|
|
|
|
|
|
onChange={commitStampScale}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{textboxData && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PanelRow label="สี">
|
|
|
|
|
|
<ColorSwatches
|
|
|
|
|
|
value={textboxData.color}
|
|
|
|
|
|
onChange={(c) => commitTextboxPatch({ color: c })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="ขนาด">
|
|
|
|
|
|
{/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง — เก็บใน DB เป็น % ของความกว้างหน้า */}
|
|
|
|
|
|
<NumberCombo
|
|
|
|
|
|
value={Number(((textboxData.fontSize / 100) * pageBaseWidth).toFixed(1))}
|
|
|
|
|
|
options={[10, 12, 14, 18, 24]}
|
|
|
|
|
|
min={6}
|
|
|
|
|
|
max={48}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
suffix="px"
|
|
|
|
|
|
onChange={(v) => commitTextboxPatch({ fontSize: (v / pageBaseWidth) * 100 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="ความทึบ">
|
|
|
|
|
|
<NumberCombo
|
|
|
|
|
|
value={Math.round((textboxData.opacity ?? 1) * 100)}
|
|
|
|
|
|
options={[25, 50, 75, 100]}
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
max={100}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
suffix="%"
|
|
|
|
|
|
onChange={(v) => commitTextboxPatch({ opacity: v / 100 })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="ตัวหนา">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="สลับตัวหนาทั้งกล่อง"
|
|
|
|
|
|
aria-pressed={!!textboxData.bold}
|
|
|
|
|
|
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${
|
|
|
|
|
|
textboxData.bold
|
|
|
|
|
|
? "border-primary bg-primary text-white"
|
|
|
|
|
|
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Bold className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
<PanelRow label="จัดแนว">
|
|
|
|
|
|
<div className="flex overflow-hidden rounded-md border border-slate-300">
|
|
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
["left", AlignLeft, "ชิดซ้าย"],
|
|
|
|
|
|
["center", AlignCenter, "กึ่งกลาง"],
|
|
|
|
|
|
["right", AlignRight, "ชิดขวา"],
|
|
|
|
|
|
] as const
|
|
|
|
|
|
).map(([a, AlignIcon, aLabel]) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={a}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={`จัดแนว${aLabel}`}
|
|
|
|
|
|
aria-pressed={(textboxData.align ?? "left") === a}
|
|
|
|
|
|
className={`px-2 py-1 transition ${
|
|
|
|
|
|
(textboxData.align ?? "left") === a
|
|
|
|
|
|
? "bg-primary text-white"
|
|
|
|
|
|
: "text-slate-500 hover:bg-slate-100"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => commitTextboxPatch({ align: a })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AlignIcon className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PanelRow>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="my-1 border-t border-slate-100" />
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
|
|
|
|
|
|
title="Bookmark ตำแหน่งนี้"
|
|
|
|
|
|
className="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
|
|
|
|
|
|
onClick={() => onBookmark(boxPct.y)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="ลบรายการนี้"
|
|
|
|
|
|
title="ลบ"
|
|
|
|
|
|
className="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50"
|
|
|
|
|
|
onClick={() => onDelete(annotation.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|