eCourt-note/components/pdf/AnnotationSelectionLayer.tsx
Your Name c5c6b65bd2 genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00

766 lines
37 KiB
TypeScript
Raw 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, Trash2 } from "lucide-react";
import type {
AnnotationData,
AnnotationDTO,
CommentData,
HighlightData,
HighlightStyle,
TextboxData,
} from "@/db/schema";
import {
COMMENT_PIN_COARSE_SIZE_PX,
COMMENT_PIN_SIZE_PX,
TEXTBOX_MAX_WIDTH_PCT,
annotationBBox,
commentPinBoxPct,
commentPinBoxPx,
scaleAnnotationData,
translateAnnotationData,
} from "@/utils/geometry";
import {
clamp,
getPointerPercent,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import { annotationTypeLabel, STAMP_COLOR } from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
/** 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 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";
/** ตำแหน่ง 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,
onStartEdit,
}: {
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;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null);
// อุปกรณ์จอสัมผัส (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;
const boxPx = isCommentPin
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, size);
const boxPct = isCommentPin
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
: 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" | "wleft" | "wright",
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 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 — สัดส่วนกว้าง:สูงตายตัว (สูง = 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;
// 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, 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 ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
<div
className="absolute cursor-move touch-none sel-strip"
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 touch-none sel-strip"
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 touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: boxPx.height,
pointerEvents: "auto",
}}
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: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: boxPx.height,
pointerEvents: "auto",
}}
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",
}}
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
))}
</>
)}
{/* 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 && (
<>
<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>
</>
)}
{textboxData && (
<>
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
<PanelRow label="ข้อความ">
<button
type="button"
className={`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>
);
}