"use client"; import { useEffect, useMemo, useRef } from "react"; import type { StampData, TextboxData, TextboxSegment } from "@/db/schema"; import { toPx, type Point, type Size } from "@/utils/coordinates"; import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry"; import { STAMP_COLOR, STAMP_FONT_RATIO } from "@/utils/annotation-display"; // TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins) // - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto` // (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool) // - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง // = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13) // - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า /** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */ export type InkTextbox = { id: string; userId: string; data: TextboxData }; /** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */ export type InkStamp = { id: string; userId: string; data: StampData }; /** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + +
) */ function segmentsToHtml(text: string, segments?: TextboxSegment[]): string { const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const runs = segments?.length ? segments : [{ text }]; return runs .map((s) => (s.bold ? `${esc(s.text)}` : esc(s.text))) .join("") .replace(/\n/g, "
"); } /** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ */ function serializeEditor(root: HTMLElement): { text: string; segments: TextboxSegment[] } { const segs: TextboxSegment[] = []; const push = (text: string, bold: boolean) => { if (!text) return; const last = segs[segs.length - 1]; if (last && !!last.bold === bold) last.text += text; else segs.push({ text, ...(bold ? { bold: true } : {}) }); }; const walk = (node: Node, bold: boolean) => { if (node.nodeType === Node.TEXT_NODE) { push(node.nodeValue ?? "", bold); } else if (node.nodeName === "BR") { push("\n", bold); } else { const b = bold || node.nodeName === "B" || node.nodeName === "STRONG"; const kids = Array.from(node.childNodes); kids.forEach((c, i) => { walk(c, b); // DIV/P sibling จากการกด Enter = คั่นขึ้นบรรทัดใหม่ if (i < kids.length - 1 && /^(DIV|P)$/.test(c.nodeName)) push("\n", b); }); } }; walk(root, false); return { text: segs.map((s) => s.text).join(""), segments: segs }; } /** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B * ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */ function TextboxEditor({ size, position, fontSizePct, color, bold, opacity, align, enabled, initialText, initialSegments, onSave, onCancel, }: { size: Size; position: Point; fontSizePct: number; /** สีตัวอักษร (จากตัวตั้งค่าของ tool / สีของกล่องที่แก้) */ color: string; bold?: boolean; opacity?: number; align?: "left" | "center" | "right"; /** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */ enabled: boolean; initialText: string; initialSegments?: TextboxSegment[]; onSave: (text: string, segments: TextboxSegment[]) => void; onCancel: () => void; }) { const ref = useRef(null); // initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น // uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์ const initialHtml = useMemo( () => ({ __html: segmentsToHtml(initialText, initialSegments) }), [initialText, initialSegments], ); // focus + วาง caret ท้ายข้อความตอนเปิด editor useEffect(() => { const el = ref.current; if (!el) return; el.focus(); const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); sel?.removeAllRanges(); sel?.addRange(range); }, []); /** คลิกที่อื่น/สลับ tool (blur) = บันทึกอัตโนมัติ — ข้อความว่าง = ปิดอย่างเดียวไม่สร้าง */ const submit = () => { const el = ref.current; if (!el) return; const { text, segments } = serializeEditor(el); if (text.trim()) onSave(text, segments); else onCancel(); }; return (
e.stopPropagation()} >
{ e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer if (e.key === "Escape") { e.preventDefault(); onCancel(); } // เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก // FIX-12: document.execCommand ถูก mark deprecated แต่ // ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable // (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { e.preventDefault(); document.execCommand("bold"); } }} onPaste={(e) => { // วางเป็น plain text กัน HTML แปลกปลอมเข้า contentEditable e.preventDefault(); document.execCommand( "insertText", false, e.clipboardData.getData("text/plain"), ); }} className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none" style={{ fontSize: toPx(fontSizePct, size.width), lineHeight: 1.5, color, fontWeight: bold ? 700 : 400, textAlign: align ?? "left", }} dangerouslySetInnerHTML={initialHtml} />
); } export default function TextboxStampLayer({ size, textboxes, stamps, enabled, ownUserId, editingId, onStartEdit, onCommitEdit, composer, onCommitComposer, onCancelComposer, onSelect, }: { /** ขนาด px ของหน้า (ตาม zoom ปัจจุบัน) */ size: Size; textboxes: InkTextbox[]; /** ตรายาง (F18/B5) */ stamps: InkStamp[]; /** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events ของทุกรายการ */ enabled: boolean; ownUserId: string; /** TEXTBOX ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */ editingId: string | null; onStartEdit: (id: string | null) => void; onCommitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void; /** ตำแหน่งที่กำลังสร้างกล่องใหม่ (null = ไม่มี — parent ส่งเฉพาะหน้าที่คลิก) */ composer: { position: Point; fontSizePct: number; color: string; bold?: boolean; opacity?: number; align?: "left" | "center" | "right"; } | null; onCommitComposer: (text: string, segments?: TextboxSegment[]) => void; onCancelComposer: () => void; /** คลิกรายการ → parent set selectedId (F1) */ onSelect: (id: string) => void; }) { return (
{textboxes.map((t) => editingId === t.id ? ( onCommitEdit(t.id, text, segments)} onCancel={() => onStartEdit(null)} /> ) : (
{ e.stopPropagation(); if (enabled) onSelect(t.id); }} onDoubleClick={(e) => { e.stopPropagation(); if (enabled && t.userId === ownUserId) onStartEdit(t.id); }} > {t.data.segments?.length ? t.data.segments.map((s, i) => ( {s.text} )) : t.data.text}
), )} {composer && ( )} {/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5) */} {stamps.map((s) => { const w = toPx(s.data.scale, size.width); const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default return (
{ e.stopPropagation(); if (enabled) onSelect(s.id); }} > {s.data.label}
); })}
); }