eCourt-note/components/pdf/AnnotationSelectionLayer.tsx
Your Name e4ecc026b0
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 45s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
perf(pdf-viewer): limit drag preview re-render to its page
แก้ "ลากย้ายเส้น/กล่องข้อความ/ตรายาง/pin ไม่ smooth เป็น step"
(คำขอผู้ใช้ 2026-09-29):

- สาเหตุ: preview ของการลาก (onPreview → setPreviewData) เป็น state
  ระดับ root ของ PDFViewer — ทุก pointermove ระหว่างลาก re-render ทั้ง
  ต้นไม้ (toolbar + sidebar + ทุกหน้าที่ mounted ตาม virtualization)
  ทั้งที่ผู้ใช้ลากอยู่หน้าเดียว และค่าถูกอ่านแค่จุดเดียวคือ substitution
  ของหน้าที่ลาก — เทียบกับการวาดเส้นที่ smooth เพราะ live state อยู่ใน
  InkLayer เอง + rAF throttle (FIX-8) อยู่แล้ว
- แก้: ย้าย preview state ลงเป็น state ท้องถิ่นของ PdfPage (substitution
  ย้ายตามมาอยู่ก่อน derive layers ทั้งหมด) — ระหว่างลาก re-render เฉพาะ
  หน้าที่ลาก; root สั่งเคลียร์ผ่าน counter previewClear (useReducer)
  แทน setter ตรง ครบทุกจุดเดิม 9 จุด (ปิด popup/Escape/auto-select/
  delete/ก่อนคิว update/deselect/goToAnnotation/เลือกรายการ)
- onPreview ของ AnnotationSelectionLayer throttle ด้วย rAF ≤1 ครั้ง/
  เฟรม (pattern FIX-8 ของ InkLayer): ทุก pointermove ยังคำนวณ + เก็บ
  latestRef เสมอ (ค่า commit ตอน pointerup เป็นค่าล่าสุดสุดเสมอ) เฉพาะ
  การ render ที่จัดคิวตามเฟรม + cancel ครบทุกทางออกของ drag
  (pointerup/pointercancel/unmount)
- แก้ตาม code review 2026-09-29: preview เก็บเป็น {id, data} เทียบ id
  เจ้าของตอน substitute แทน selectedId — กันจอสัมผัสเปลี่ยน selection
  กลาง drag (นิ้วหนึ่งลากค้าง อีกนิ้วเลือกอื่น) แล้ว preview ของรายการเก่า
  ไปทดแทนรายการใหม่หนึ่งเฟรม (การเคลียร์จาก root เป็น passive effect
  หลัง paint)
- sync สถาปัตยกรรมใหม่ใน .claude/rules/pdf-viewer.md (หัวข้อกรอบ
  selection F1)

ผ่าน code-reviewer + plan-reviewer (ไม่มี bug ค้าง/ไม่ละเมิด invariant) +
tsc + vitest 207/207 + e2e Playwright: ลาก DRAW FPS≈60.6 (commit
เลื่อนตรงระยะ/undo/redo/reload คงอยู่) + ลาก TEXTBOX FPS≈59-60.9 ไม่มี
เฟรม drop (commit/reload ตรงเป๊ะ)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:49:05 +07:00

982 lines
52 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
AlignCenter,
AlignLeft,
AlignRight,
BookmarkPlus,
Pencil,
RotateCw,
Trash2,
} from "lucide-react";
import type {
AnnotationData,
AnnotationDTO,
CommentData,
HighlightData,
HighlightStyle,
TextboxData,
TextboxSegment,
} from "@/db/schema";
import {
COMMENT_PIN_COARSE_SIZE_PX,
COMMENT_PIN_SIZE_PX,
TEXTBOX_MAX_WIDTH_PCT,
annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
commentPinBoxPx,
rotatePoint,
scaleAnnotationData,
stampRect,
translateAnnotationData,
} from "@/utils/geometry";
import {
clamp,
getPointerPercent,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import {
annotationTypeLabel,
measureStampText,
STAMP_COLOR,
} from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
/** Patch ของตัวควบคุมเล็กในแผง selection / editor ข้อความ — ส่งเฉพาะ field ที่แตะ
* ให้ viewer ผสานเข้ากับ data ล่าสุด**ตอนถึงคิว update** ไม่ใช่ snapshot ของ render
* นี้ — คอมมิตแบบ full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (จอสัมผัส:
* blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผงต่อทันทีก่อน React render
* optimistic row — code review 2026-09-28 รอบสอง) */
export type AnnotationPatch =
/** สี — ชื่อ field ตามชนิด: HIGHLIGHT/STAMP = `color`, DRAW/SHAPE = `strokeColor` */
| { kind: "color"; color: string }
/** ความหนา — DRAW สเกล `widths` ต่อจุดตามอัตราส่วนตอนผสาน (เหมือนเดิม) */
| { kind: "strokeWidth"; strokeWidth: number }
| { kind: "highlightStyle"; style: HighlightStyle }
/** ความทึบของ HIGHLIGHT — 100 = ถอด field `opacity` ทิ้งเหมือนตอนสร้างใหม่ */
| { kind: "highlightOpacity"; opacityPct: number }
| { kind: "stampScale"; scale: number }
/** STAMP — มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ; 0 = ถอด field เหมือนแถวเดิม) */
| { kind: "stampRotation"; rotation: number }
| {
kind: "textbox";
fields: Partial<
Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">
>;
}
/** StampEditor — แก้คำตรายาง */
| { kind: "label"; label: string }
/** TextboxEditor — แก้ข้อความกล่องข้อความ (segments null = เคลียร์ตัวหนาทั้งก้อน) */
| { kind: "textboxText"; text: string; segments: TextboxSegment[] | null };
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
// กรอบ 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 ROT_HANDLE_PX = 18; // จุดจับหมุนตรายาง (STAMP) — วงกลมเหนือกึ่งกลางขอบบน
const ROT_HANDLE_GAP_PX = 20; // ระยะแนวฉากจากขอบบนกรอบถึงจุดจับหมุน
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
const EPS = 1e-6;
// COMMENT: ขนาด pin px + bbox px/% ของ pin ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ
// goToAnnotation ของ PDFViewer — single source ตาม responsive audit 2026-09-24)
type DragState = {
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
mode: "move" | "scale" | "wleft" | "wright" | "rotate";
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
startPct: Point;
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
origData: AnnotationData;
/** bbox ก่อนเริ่มลาก (% ของหน้า) — STAMP เอียง = AABB ของกรอบหมุนแล้ว (ครอบกรอบจริง) */
origBoxPct: Rect;
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า ตำแหน่งจริงบนจอ) */
anchorPct: Point;
/** STAMP เอียงเท่านั้น — มุมกรอบตอนเริ่มลาก (องศา): คำนวณ k ของ scale ต้อง
* แปลง pointer เข้า frame ของกรอบ (ย้อนหมุนรอบ center) ก่อน ไม่งั้นลากมุมของ
* กรอบเอียงแล้วสัดส่วนแกน x/y ผิดจากสายตา */
rot?: number;
};
/** จำกัดตัวคูณ 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,
onStartEdit,
onPatch,
}: {
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;
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
onStartEdit?: (id: string) => void;
/** ตัวควบคุมเล็กของแผง (สี/ความหนา/ขนาด/รูปแบบ/ทึบ/patch กล่องข้อความ) commit เป็น
* patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว update กัน stale base ทับค่าที่เพิ่ง
* เปลี่ยน (ดู AnnotationPatch) */
onPatch: (id: string, patch: AnnotationPatch) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null);
// rAF ของ preview ระหว่างลาก — ยิง onPreview ≤1 ครั้ง/เฟรม (pattern เดียวกับ FIX-8
// ของ InkLayer): pointermove ยิงถี่กว่าเฟรมได้ ทุก event ยังคำนวณ+เก็บค่าล่าสุดใน
// latestRef เสมอ (ค่าตอน commit ต้องล่าสุดสุด) เฉพาะการ render ที่จัดคิวตามเฟรม
const previewRafRef = useRef<number | null>(null);
/** ยกเลิก rAF preview ที่ค้าง — เรียกก่อนเคลียร์ preview ทุกทางออกของ drag (ไม่งั้น
* callback ยิงตามหลัง ดัน preview เก่ากลับมาตอน dragRef เคลียร์ไปแล้ว) */
function cancelPreviewRaf() {
if (previewRafRef.current === null) return;
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = null;
}
// เลิกลากกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย (เหมือน InkLayer)
useEffect(
() => () => {
cancelPreviewRaf();
},
[],
);
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
const [coarseTouch] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches,
);
// COMMENT ใช้กล่องครอบ pin จริง (px คงที่ตามอุปกรณ์) — ชนิดอื่นคือ bbox ตามขนาดหน้า
// boxPct ก็ใช้มาตราฐานเดียวกัน (% จาก px จริง): move-clamp กับปุ่ม bookmark ของ pin
// ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24)
const isCommentPin = annotation.type === "COMMENT";
const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX;
// bbox ของ STAMP ส่ง measureStampText ให้ความสูงตรงกับที่ StampItem render จริง
// (ประมาณแล้วขอบล่างกรอบเกินตรายาง — bug 2026-09-28)
const boxPx = isCommentPin
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, size, measureStampText);
const boxPct = isCommentPin
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, { width: 100, height: 100 }, measureStampText);
// STAMP เอียง (rotation ≠ 0) — กรอบ+handles วาดที่ตำแหน่งกรอบ*ก่อนหมุน* แล้วหมุน
// ทั้งชุดด้วย CSS transform รอบกลางกรอบ (ตรงกับที่ StampItem render) ส่วน boxPx/
// boxPct ด้านบนเป็น AABB ของกรอบหมุนแล้ว ใช้กับ clamp ย้าย/แผงเครื่องมือ/ป้ายชื่อ
// stampData narrow ด้วย "label" in ตรงนี้แล้วส่งเข้า stampRect ตรง ๆ — ไม่ as-cast
// (cast พังเงียบถ้าเงื่อนไขแวดข้างเปลี่ยน — code review 2026-09-29)
const stampData = "label" in annotation.data ? annotation.data : null;
const stampRot = stampData?.rotation ?? 0;
const isRotStamp = stampRot !== 0;
const framePx =
stampData && isRotStamp ? stampRect(stampData, size, measureStampText) : boxPx;
const framePct =
stampData && isRotStamp
? stampRect(stampData, { width: 100, height: 100 }, measureStampText)
: boxPct;
// transform หมุนทั้งชุดกรอบ — origin เป็น**กลางกรอบในพิกัดของ element นั้น ๆ** (CSS
// transform-origin วัดจากมุมซ้ายบนของ element เอง ไม่ใช่ของ page — จุดกลางกรอบ
// ในพิกัด page ลบด้วย left/top ของ element จึงได้ origin ที่ทุก div หมุนไปตรงจุด
// เดียวกัน ตามตัวตรายาง — bug "กรอบไม่ตรงตัวเมื่อเอียง" 2026-09-28)
const rotC = {
x: framePx.x + framePx.width / 2,
y: framePx.y + framePx.height / 2,
};
const rotOn = (elLeft: number, elTop: number) =>
isRotStamp
? {
transform: `rotate(${stampRot}deg)`,
transformOrigin: `${rotC.x - elLeft}px ${rotC.y - elTop}px`,
}
: undefined;
// 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" | "wleft" | "wright" | "rotate",
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,
...(stampRot !== 0 ? { rot: stampRot } : null),
};
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 === "rotate") {
// STAMP — ลากหมุน: มุมใหม่ = มุมเดิม + มุมที่ pointer กวาดรอบ**กลางกรอบ**
// (atan2 ในพิกัดจอ y ลง = ทิศเดียวกับ CSS rotate) — snap ดูดเข้าทวีคูณ 15°
// เมื่อใกล้ ≤4° ให้ตั้ง 0/±45/±90 เร็ว แล้ว wrap เข้า (−180, 180] เก็บ 1 ทศนิยม
// (0° ถอด field เหมือน patch ของแผง — anchorPct = กลางกรอบจาก AABB)
const ctr = st.anchorPct;
const a0 = Math.atan2(st.startPct.y - ctr.y, st.startPct.x - ctr.x);
const a1 = Math.atan2(cur.y - ctr.y, cur.x - ctr.x);
let deg = (st.rot ?? 0) + ((a1 - a0) * 180) / Math.PI;
const snap = Math.round(deg / 15) * 15;
if (Math.abs(deg - snap) <= 4) deg = snap;
deg = Number(((((deg + 180) % 360) + 360) % 360 - 180).toFixed(1));
if ("label" in st.origData) {
const { rotation: _prev, ...rest } = st.origData;
// หมุนจนกรอบยื่นเกินขอบหน้า = เลื่อนกลับเข้าหน้าทันทีระหว่างลาก (เดียวกับ
// patch ของแผง — validator ตรวจ AABB ไม่ติดลบ, code review 2026-09-28)
next =
deg === 0
? rest
: { ...rest, rotation: deg, position: clampStampIntoPage({ ...rest, rotation: deg }) };
} else {
next = st.origData;
}
} else 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 if (st.mode === "wleft" || st.mode === "wright") {
// TEXTBOX กว้างคงที่ — ขอบที่จับขยับ อีกขอบตายตัว (position = มุมซ้ายบนเสมอ:
// ลากขอบซ้าย = เลื่อนกล่อง+เปลี่ยนกว้าง, ลากขอบขวา = เปลี่ยนกว้างอย่างเดียว)
// ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ
const d = st.origData;
if (!("fontSize" in d)) {
next = d;
} else {
const MIN_W = 2; // % ของหน้า — กล่องแคบสุดที่ยังใช้ได้จริง
const right = st.origBoxPct.x + st.origBoxPct.width;
let left = st.origBoxPct.x;
let w = st.origBoxPct.width;
if (st.mode === "wright") {
const r = clamp(
cur.x,
st.origBoxPct.x + MIN_W,
Math.min(100, st.origBoxPct.x + TEXTBOX_MAX_WIDTH_PCT),
);
w = r - st.origBoxPct.x;
} else {
const l = clamp(
cur.x,
Math.max(0, right - TEXTBOX_MAX_WIDTH_PCT),
right - MIN_W,
);
left = l;
w = right - l;
}
next = { ...d, width: w, position: { ...d.position, x: left } };
}
} else if ("scale" in st.origData) {
// STAMP — ความกว้างมีมาจาก scale เพียงตัวเดียว (สูงตามจำนวนบรรทัดของ label,
// stampLayout) จึงขยายด้วยตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
// · STAMP เอียง: อัตราส่วนต้องวัดในแกนของ*กรอบ* — ย้อนหมุน pointer รอบกลางกรอบ
// ก่อน (แกน x/y ของสูตรคือแกนกรอบ ไม่ใช่แกนจอ) ส่วน clamp กับ scaleAnnotationData
// ใช้พิกัดจอ: มุมกรอบหมุนทั้งชุดโดย scale-uniform รอบ anchor จริง ๆ (AABB ของ
// กรอบหมุน scale ตาม k รอบ anchor เช่นกัน) clamp ด้วยขอบ AABB จึงยังถูกต้อง
const rot = st.rot ?? 0;
let curL = cur;
let startL = st.startPct;
let anchorL = st.anchorPct;
if (rot) {
const ctr = {
x: st.origBoxPct.x + st.origBoxPct.width / 2,
y: st.origBoxPct.y + st.origBoxPct.height / 2,
};
curL = rotatePoint(cur, ctr, -rot);
startL = rotatePoint(st.startPct, ctr, -rot);
anchorL = rotatePoint(st.anchorPct, ctr, -rot);
}
const rawK =
((curL.x - anchorL.x) / (startL.x - anchorL.x || EPS) +
(curL.y - anchorL.y) / (startL.y - anchorL.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;
// render preview ครั้งเดียวต่อ animation frame — callback อ่าน latestRef ตอนเฟรม
// ยิงจริง ๆ เพื่อให้ได้ค่าใหม่สุดเสมอแม้ event ถี่ (แก้ลากไม่ smooth 2026-09-29)
if (previewRafRef.current === null) {
previewRafRef.current = requestAnimationFrame(() => {
previewRafRef.current = null;
if (dragRef.current && latestRef.current) onPreview(latestRef.current);
});
}
}
function endDrag(e: ReactPointerEvent<HTMLElement>) {
const st = dragRef.current;
if (!st) return;
e.stopPropagation();
dragRef.current = null;
cancelPreviewRaf();
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;
cancelPreviewRaf();
movedRef.current = false;
latestRef.current = null;
onPreview(null);
}
/** ปุ่มสี/ความหนาบนกรอบ — ส่ง patch ให้ viewer ผสานกับ data ล่าสุด (ชื่อ field ตาม
* ชนิด: HIGHLIGHT/STAMP = color, DRAW/SHAPE = strokeColor — ตัดสินตอนผสาน) */
function commitColor(color: string) {
onPatch(annotation.id, { kind: "color", color });
}
function commitStrokeWidth(w: number) {
onPatch(annotation.id, { kind: "strokeWidth", strokeWidth: w });
}
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
function commitHighlightStyle(style: HighlightStyle) {
onPatch(annotation.id, { kind: "highlightStyle", style });
}
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ถอด field opacity ตอนผสาน) */
function commitHighlightOpacity(opacityPct: number) {
onPatch(annotation.id, { kind: "highlightOpacity", opacityPct });
}
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
function commitStampScale(scale: number) {
onPatch(annotation.id, { kind: "stampScale", scale });
}
/** STAMP — ปรับมุมเอียงหลังสร้าง (องศา −180..180 หมุนรอบกลางกรอบ) */
function commitStampRotation(rotation: number) {
onPatch(annotation.id, { kind: "stampRotation", rotation });
}
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
function commitTextboxPatch(
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
) {
if (!textboxData) return;
onPatch(annotation.id, { kind: "textbox", fields: 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;
// (stampData ย้ายไปขึ้นบน — ใช้ร่วมกับ stampRot/framePx ของตรายางเอียง)
const textboxData: TextboxData | null =
"fontSize" in annotation.data ? annotation.data : null;
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
// และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง
const isFixedTextbox = textboxData?.width !== undefined;
// แผงมี row ตัวควบคุมสไตล์หรือเหลือแค่ปุ่ม bookmark+ลบ (COMMENT/REDACTION) —
// กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between
const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData);
// มุมทั้ง 4 — ตำแหน่งวางเป็น px ของกรอบ*ก่อนหมุน* (STAMP เอียง: div ทั้งชุดถูกหมุน
// ทีหลังผ่าน rotStyle), anchor มุมตรงข้ามเป็น % **ตำแหน่งจริงบนจอ** — STAMP เอียง
// ต้องหมุนมุมกลับออกมาก่อน (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
const frameCtrPct = isRotStamp
? {
x: framePct.x + framePct.width / 2,
y: framePct.y + framePct.height / 2,
}
: null;
const anchorOnScreen = (p: Point): Point =>
frameCtrPct ? rotatePoint(p, frameCtrPct, stampRot) : p;
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
{
key: "nw",
x: framePx.x,
y: framePx.y,
cursor: "nwse-resize",
anchor: anchorOnScreen({
x: framePct.x + framePct.width,
y: framePct.y + framePct.height,
}),
},
{
key: "ne",
x: framePx.x + framePx.width,
y: framePx.y,
cursor: "nesw-resize",
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y + framePct.height }),
},
{
key: "sw",
x: framePx.x,
y: framePx.y + framePx.height,
cursor: "nesw-resize",
anchor: anchorOnScreen({ x: framePct.x + framePct.width, y: framePct.y }),
},
{
key: "se",
x: framePx.x + framePx.width,
y: framePx.y + framePx.height,
cursor: "nwse-resize",
anchor: anchorOnScreen({ x: framePct.x, y: framePct.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 ได้ · STAMP เอียง
วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
<div
className="absolute border-2 border-dashed border-primary"
style={{
left: framePx.x,
top: framePx.y,
width: framePx.width,
height: framePx.height,
...rotOn(framePx.x, framePx.y),
}}
/>
{/* HIGHLIGHT ผูกกับตำแหน่งข้อความจริงบนหน้า — ห้ามย้าย/ขยาย (เลือกเพื่อเปลี่ยน
สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */}
{editable && !disabled && !isHighlightMark && (
<>
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
<div
className="absolute cursor-move touch-none sel-strip"
style={{
left: framePx.x - STRIP_PX / 2,
top: framePx.y - STRIP_PX / 2,
width: framePx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y - STRIP_PX / 2),
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move touch-none sel-strip"
style={{
left: framePx.x - STRIP_PX / 2,
top: framePx.y + framePx.height - STRIP_PX / 2,
width: framePx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y + framePx.height - STRIP_PX / 2),
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: framePx.x - STRIP_PX / 2,
top: framePx.y,
width: STRIP_PX,
height: framePx.height,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y),
}}
onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: framePx.x + framePx.width - STRIP_PX / 2,
top: framePx.y,
width: STRIP_PX,
height: framePx.height,
pointerEvents: "auto",
...rotOn(framePx.x + framePx.width - STRIP_PX / 2, framePx.y),
}}
onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ; COMMENT ไม่
มีมุมเช่นกัน — pin ไม่มี field ขนาด scale จึงไร้ความหมาย และ anchor จาก boxPct
(% รอบจุด) กับ handle บนกล่อง pin คนละกล่องทำให้ลากแล้วเหลื่อมจากนิ้ว) */}
{!isFixedTextbox && !isCommentPin && corners.map((c) => (
<div
key={c.key}
className="absolute touch-none sel-handle 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",
...rotOn(c.x - HANDLE_PX / 2, c.y - HANDLE_PX / 2),
}}
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
))}
{/* STAMP — จุดจับหมุนเหนือกึ่งกลางขอบบนของกรอบ (หมุนตามกรอบผ่าน rotOn):
ลากวนรอบกลางกรอบ = ปรับมุมเอียงแทนการพิมพ์ในแผงอย่างเดียว (คำขอผู้ใช้
2026-09-28) — anchor ของ mode "rotate" = กลางกรอบ (% จาก AABB) */}
{stampData && (
<div
className="absolute flex touch-none sel-handle cursor-grab items-center justify-center rounded-full border border-primary bg-white text-primary shadow-lg"
style={{
left: rotC.x - ROT_HANDLE_PX / 2,
top: framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
width: ROT_HANDLE_PX,
height: ROT_HANDLE_PX,
pointerEvents: "auto",
...rotOn(
rotC.x - ROT_HANDLE_PX / 2,
framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
),
}}
title="ลากเพื่อหมุนตรายาง"
aria-label="ลากเพื่อหมุนตรายาง"
onPointerDown={(e) =>
beginDrag(e, "rotate", {
x: boxPct.x + boxPct.width / 2,
y: boxPct.y + boxPct.height / 2,
})
}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
>
<RotateCw className="h-3 w-3" />
</div>
)}
</>
)}
{/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย
เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar
อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้
ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */}
{!editable && (
<div
className="absolute z-10 max-w-[200px] truncate rounded-md border border-line bg-white px-1.5 py-0.5 text-[11px] text-ink-soft shadow-lg"
style={{
left: Math.max(0, Math.min(boxPx.x, size.width - 204)),
top: boxPx.y - 24 < 0 ? boxPx.y + boxPx.height + 4 : boxPx.y - 24,
pointerEvents: "none",
}}
>
{annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"}
</div>
)}
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก)
กรณี w-fit ใช้ 240 เป็น upper bound ต่อ (แคบกว่า w-60 เสมอ — clamp หลวมเกินไม่มีผลร้าย) */}
{editable && !disabled && (
<div
ref={panelRef}
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-line bg-white p-1.5 shadow-lg`}
style={{
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
transform: openAbove ? "translateY(-100%)" : undefined,
pointerEvents: "auto",
}}
>
{highlightData && (
<>
<PanelRow>
<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 ${FOCUS_RING_CLS} ${
(highlightData.style ?? "highlight") === key
? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
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>
<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}
step={1}
onChange={(v) => commitStrokeWidth(v / 10)}
/>
</PanelRow>
)}
{stampData && (
<>
{/* ทางเข้า StampEditor (แก้คำตรายางหลังวาง — เหมือนปุ่มเดียวกันของ TEXTBOX):
แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ /
root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย
hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
<PanelRow label="ข้อความ">
<button
type="button"
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
แก้ไขข้อความ
</button>
</PanelRow>
<PanelRow>
<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>
<PanelRow label="มุมเอียง">
<NumberCombo
value={stampData.rotation ?? 0}
options={[-45, -15, 0, 15, 45]}
min={-180}
max={180}
step={1}
suffix="°"
onChange={commitStampRotation}
/>
</PanelRow>
</>
)}
{textboxData && (
<>
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect
· sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
<PanelRow label="ข้อความ">
<button
type="button"
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
แก้ไขข้อความ
</button>
</PanelRow>
<PanelRow>
<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 ${FOCUS_RING_CLS} ${
textboxData.bold
? "border-primary bg-primary text-white"
: "border-line bg-white text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
>
{/* B ทึบ — เหมือน dropdown ของ tool (ตัวควบคุมชุดเดียวกัน) */}
<span aria-hidden className="text-[13px] font-black leading-none">
B
</span>
</button>
</PanelRow>
<PanelRow label="จัดแนว">
<div className="flex overflow-hidden rounded-md border border-line">
{(
[
["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 ${FOCUS_RING_CLS} ${
(textboxData.align ?? "left") === a
? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => commitTextboxPatch({ align: a })}
>
<AlignIcon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
</>
)}
{hasStyleControls && <div className="my-1 border-t border-line/60" />}
<div className={hasStyleControls ? "flex items-center justify-between" : "flex items-center"}>
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
{/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover
แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */}
<button
type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-ink-faint hover:bg-ink/[0.05] hover:text-ink ${FOCUS_RING_CLS}`}
onClick={() => onBookmark(boxPct.y)}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
{/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */}
{!hasStyleControls && (
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-line" />
)}
<button
type="button"
aria-label="ลบรายการนี้"
title="ลบ"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`}
onClick={() => onDelete(annotation.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
);
}