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
แก้ "ลากย้ายเส้น/กล่องข้อความ/ตรายาง/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>
982 lines
52 KiB
TypeScript
982 lines
52 KiB
TypeScript
"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>
|
||
);
|
||
}
|