eCourt-note/components/pdf/AnnotationSelectionLayer.tsx

640 lines
27 KiB
TypeScript
Raw Normal View History

"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);
ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว - โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้ ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง - ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ) ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests) - mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่ - Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh - คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ focus อยู่ใน input/textarea/select/contenteditable) - STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง selection (uniform scale + clamp รายแกน) - sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3) Co-Authored-By: Claude Code <noreply@anthropic.com>
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);
} 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>
);
}