"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import type { StampData, TextboxData, TextboxSegment } from "@/db/schema"; import { toPx, type Point, type Size } from "@/utils/coordinates"; import { textboxMaxWidthPct } from "@/utils/geometry"; import { segmentsToHtml, serializeEditor } from "@/utils/textbox-editor"; import { measureStampText, STAMP_COLOR, stampBoxHeight, STAMP_FONT_STACK, stampLayout, STAMP_MAX_LABEL_CHARS, } 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) // ส่วน STAMP แก้ข้อความผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (editingId ใช้ร่วมกับ TEXTBOX) // - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า /** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */ export type InkTextbox = { id: string; userId: string; data: TextboxData }; /** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */ export type InkStamp = { id: string; userId: string; data: StampData }; // ฟอนต์ตรายางบนจอ + canvas measure ย้ายไป utils/annotation-display.ts แล้ว // (2026-09-28) ให้ preview ในเมนูของ tool ใช้ตัววัดเดียวกันกับตอน render จริง /** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง * editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง * จะทำให้ตัดบรรทัดไม่เท่ากัน — ผูกเข้า webfont Sarabun (var จาก next/font) ให้ wrap * ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */ const TEXTBOX_FONT_STACK = "var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif"; /** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B * ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */ function TextboxEditor({ size, position, fontSizePct, color, bold, opacity, align, widthPct, enabled, initialText, initialSegments, onSave, onCancel, }: { size: Size; position: Point; fontSizePct: number; /** สีตัวอักษร (จากตัวตั้งค่าของ tool / สีของกล่องที่แก้) */ color: string; bold?: boolean; opacity?: number; align?: "left" | "center" | "right"; /** ความกว้างกล่องคงที่ % (กล่องใหม่/กล่องที่มี width) — ไม่มี = shrink-to-fit (แถวเดิม) */ widthPct?: number; /** 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); // จบ editor ได้ครั้งเดียว — blur (บันทึกอัตโนมัติ), Escape, unmount แต่ละทางเรียก // ต่างกันและคำสั่งที่สอง (blur ตามหลัง Escape/Enter ตอน element ถูกถอด, Enter ซ้ำ // จาก double-tap บนจอสัมผัสก่อน React ถอด editor) ต้องเงียบ — ไม่งั้น onSave ยิง // ซ้ำจน composer สร้างกล่องซ้ำ / update เดียวกันกลายเป็น 2 version ในประวัติ F19 const doneRef = useRef(false); const finish = (fn: () => void) => { if (doneRef.current) return; doneRef.current = true; fn(); }; // initial HTML freeze ตอน mount (useState initializer) — editor เป็น uncontrolled // (พิมพ์ใน DOM ตรง ๆ) ค่าเริ่มต้นต้องไม่เปลี่ยนตาม props กลางทาง: realtime update // แก้กล่องเดียวกัน (อีกแท็บ/อีกคน) แล้ว useMemo คืน __html ใหม่จะ reset innerHTML // เผลอข้อความที่ผู้ใช้กำลังพิมพ์ const [initialHtml] = useState(() => ({ __html: segmentsToHtml(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; finish(() => { const { text, segments } = serializeEditor(el); if (text.trim()) onSave(text, segments); else onCancel(); }); }; return (
e.stopPropagation()} >
{ e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer // Enter ระหว่าง IME compose (คีย์บอร์ดเสมือนของ iPad/Android, IME ภาษาอื่น) // = ยืนยันคำที่กำลังพิมพ์ ไม่ใช่คำสั่งของ editor if (e.nativeEvent.isComposing) return; if (e.key === "Escape") { e.preventDefault(); finish(onCancel); return; } // Enter = ขึ้นบรรทัดใหม่เป็น
ล้วนเสมอ — ปล่อย browser สร้าง
ต่อ // บรรทัดเอง (Chrome) แล้ว serializeEditor ต้องเดา newline จากรอยต่อ block // จน "abc
def
" กลายเป็น "abcdef" (รายงาน review ข้อ 2, 2026-09-28) if (e.key === "Enter") { e.preventDefault(); document.execCommand("insertLineBreak"); return; } // เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก — ปิดเมื่อกล่อง // หนาทั้งกล่อง: execCommand("bold") จะ toggle ออกเป็น span font-weight:normal // ที่ serialize ไม่เก็บ และตอน render segment ก็ inherit 700 ของกล่องอยู่ดี // (อยากปรับเป็นปกติ = ปิดตัวหนาทั้งกล่องที่แผง selection ก่อน) // FIX-12: document.execCommand ถูก mark deprecated แต่ // ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable // (bold/insertLineBreak/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { e.preventDefault(); if (!bold) document.execCommand("bold"); } }} onPaste={(e) => { // วางเป็น plain text กัน HTML แปลกปลอมเข้า contentEditable e.preventDefault(); document.execCommand( "insertText", false, e.clipboardData.getData("text/plain"), ); }} // ขอบประใช้ outline (ไม่กิน layout): border 1px ใน border-box ทำให้พื้นที่ข้อความ // แคบกว่าตอน render จริง 2px จนตัดบรรทัดต่างจากที่จะบันทึก (review ข้อ 3) className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded outline-1 outline-dashed outline-ink-faint/70 bg-transparent p-0 caret-current" style={{ fontFamily: TEXTBOX_FONT_STACK, fontSize: toPx(fontSizePct, size.width), lineHeight: 1.5, color, fontWeight: bold ? 700 : 400, textAlign: align ?? "left", }} dangerouslySetInnerHTML={initialHtml} />
); } /** แก้ข้อความตรายางในที่ (ปุ่ม "แก้ไขข้อความ" ในแผง selection) — กรอบ/สี/ฟอนต์/การ * จัดวาง (ย่อฟอนต์/wrap หลายบรรทัด) เหมือนตอน render จริงทุกอย่าง (WYSIWYG) * ยกเว้น**มุมเอียง**: editor แสดงตรง (0°) เสมอ — textarea เอียงพิมพ์ลำบากและ * กรอบครอบตำแหน่งเดิมอยู่แล้ว ปิด editor แล้วตรายางกลับไปเอียงตามค่าเดิมทันที · * พิมพ์ยาวขึ้น = ฟอนต์ย่อจนถึง floor แล้วค่อยขึ้นบรรทัดใหม่ กรอบสูงขึ้นตาม * stampLayout ทันที — blur/Enter = บันทึก (ข้อความว่าง = ยกเลิก), Escape = ยกเลิก · * Enter ไม่ใช้ขึ้นบรรทัด (ตรายางจัดบรรทัดอัตโนมัติล้วน ให้ตรงกับ preview/ตอนวางเสมอ) */ function StampEditor({ size, data, enabled, onSave, onCancel, }: { size: Size; data: StampData; enabled: boolean; onSave: (label: string) => void; onCancel: () => void; }) { const [label, setLabel] = useState(data.label); // จบ editor ได้ครั้งเดียว — Enter ซ้ำจาก double-tap บนจอสัมผัส (ก่อน React ถอด // editor) หรือ blur ตามหลัง Enter ต้องเงียบ ไม่งั้น update เดียวกันกลายเป็น // 2 version ในประวัติ F19 (เหตุผลเดียวกับ doneRef ของ TextboxEditor) const doneRef = useRef(false); const finish = (fn: () => void) => { if (doneRef.current) return; doneRef.current = true; fn(); }; const w = toPx(data.scale, size.width); const c = data.color ?? STAMP_COLOR; const lay = useMemo(() => stampLayout(label, w, measureStampText), [label, w]); const commit = () => finish(() => { const t = label.trim(); if (t) onSave(t); else onCancel(); }); return (
e.stopPropagation()} >