"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 >; } /** 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(null); const dragRef = useRef(null); const movedRef = useRef(false); const latestRef = useRef(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; // 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, 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) { 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; 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); } /** ปุ่มสี/ความหนาบนกรอบ — ส่ง 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>, ) { 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(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 ได้ · STAMP เอียง วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
{/* 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 กว้างคงที่ไม่มีมุม — ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ; COMMENT ไม่ มีมุมเช่นกัน — pin ไม่มี field ขนาด scale จึงไร้ความหมาย และ anchor จาก boxPct (% รอบจุด) กับ handle บนกล่อง pin คนละกล่องทำให้ลากแล้วเหลื่อมจากนิ้ว) */} {!isFixedTextbox && !isCommentPin && corners.map((c) => (
beginDrag(e, "scale", c.anchor)} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} /> ))} {/* STAMP — จุดจับหมุนเหนือกึ่งกลางขอบบนของกรอบ (หมุนตามกรอบผ่าน rotOn): ลากวนรอบกลางกรอบ = ปรับมุมเอียงแทนการพิมพ์ในแผงอย่างเดียว (คำขอผู้ใช้ 2026-09-28) — anchor ของ mode "rotate" = กลางกรอบ (% จาก AABB) */} {stampData && (
beginDrag(e, "rotate", { x: boxPct.x + boxPct.width / 2, y: boxPct.y + boxPct.height / 2, }) } onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} >
)} )} {/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้ ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */} {!editable && (
{annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"}
)} {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ 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 && ( <> {/* ทางเข้า StampEditor (แก้คำตรายางหลังวาง — เหมือนปุ่มเดียวกันของ TEXTBOX): แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */} )} {textboxData && ( <> {/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */} 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 ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} {/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */} {/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */} {!hasStyleControls && (
)}
); }