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>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
617
components/pdf/AnnotationSelectionLayer.tsx
Normal file
617
components/pdf/AnnotationSelectionLayer.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
"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);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue