"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, HighlightData, HighlightStyle, TextboxData, } from "@/db/schema"; import { TEXTBOX_MAX_WIDTH_PCT, 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"; /** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ * PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */ const FOCUS_RING_CLS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"; // กรอบ 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 = { // 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(null); const dragRef = useRef(null); const movedRef = useRef(false); const latestRef = useRef(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, 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) { 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) { 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>, ) { 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(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 (
e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page > {/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
{/* HIGHLIGHT ผูกกับตำแหน่งข้อความจริงบนหน้า — ห้ามย้าย/ขยาย (เลือกเพื่อเปลี่ยน สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */} {editable && !disabled && !isHighlightMark && ( <> {/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
beginDrag(e, "move", { x: 0, y: 0 })} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} />
beginDrag(e, "move", { x: 0, y: 0 })} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} />
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 }) } onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} />
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 }) } onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} /> {/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม — ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */} {!isFixedTextbox && corners.map((c) => (
beginDrag(e, "scale", c.anchor)} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} /> ))} )} {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) กรณี w-fit ใช้ 240 เป็น upper bound ต่อ (แคบกว่า w-60 เสมอ — clamp หลวมเกินไม่มีผลร้าย) */} {editable && !disabled && (
{highlightData && ( <>
{HIGHLIGHT_STYLE_BTNS.map(({ key, label, Icon }) => ( ))}
)} {strokeData && ( )} {strokeData && ( {/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับ dropdown ของ tool draw/shape */} commitStrokeWidth(v / 10)} /> )} {stampData && ( <> )} {textboxData && ( <> {/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */} commitTextboxPatch({ color: c })} /> {/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง — เก็บใน DB เป็น % ของความกว้างหน้า */} commitTextboxPatch({ fontSize: (v / pageBaseWidth) * 100 })} /> commitTextboxPatch({ opacity: v / 100 })} />
{( [ ["left", AlignLeft, "ชิดซ้าย"], ["center", AlignCenter, "กึ่งกลาง"], ["right", AlignRight, "ชิดขวา"], ] as const ).map(([a, AlignIcon, aLabel]) => ( ))}
)} {hasStyleControls &&
}
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} {/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */} {!hasStyleControls && (
)}
); }