524 lines
27 KiB
TypeScript
524 lines
27 KiB
TypeScript
"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<HTMLDivElement | null>(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 (
|
||
<div
|
||
className="absolute z-10"
|
||
style={{
|
||
left: toPx(position.x, size.width),
|
||
top: toPx(position.y, size.height),
|
||
// มี widthPct = กล่องกว้างคงที่ (สไตล์ Smallpdf — ข้อความ wrap ในกล่อง)
|
||
// ไม่มี = shrink-to-fit หุ้มข้อความขยายตามที่พิมพ์ (แถวเดิม)
|
||
// maxWidth บีบตามพื้นที่จริงถึงขอบขวา (ไม่ล้นกระดาษ)
|
||
...(widthPct !== undefined ? { width: `${widthPct}%` } : null),
|
||
maxWidth: `${textboxMaxWidthPct(position.x)}%`,
|
||
minWidth: "1em",
|
||
opacity: opacity ?? 1,
|
||
pointerEvents: enabled ? "auto" : "none",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div
|
||
ref={ref}
|
||
role="textbox"
|
||
aria-multiline="true"
|
||
aria-label="แก้ไขข้อความ"
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
spellCheck={false}
|
||
onBlur={submit}
|
||
onKeyDown={(e) => {
|
||
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 = ขึ้นบรรทัดใหม่เป็น <br> ล้วนเสมอ — ปล่อย browser สร้าง <div> ต่อ
|
||
// บรรทัดเอง (Chrome) แล้ว serializeEditor ต้องเดา newline จากรอยต่อ block
|
||
// จน "abc<div>def</div>" กลายเป็น "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}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** แก้ข้อความตรายางในที่ (ปุ่ม "แก้ไขข้อความ" ในแผง 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 (
|
||
<div
|
||
className="absolute flex items-center justify-center overflow-hidden"
|
||
style={{
|
||
left: toPx(data.position.x, size.width),
|
||
top: toPx(data.position.y, size.height),
|
||
width: w,
|
||
height: stampBoxHeight(lay, w),
|
||
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
|
||
borderRadius: Math.max(w * 0.04, 2),
|
||
pointerEvents: enabled ? "auto" : "none",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<textarea
|
||
autoFocus
|
||
aria-label="แก้ไขข้อความตรายาง"
|
||
value={label}
|
||
maxLength={STAMP_MAX_LABEL_CHARS}
|
||
rows={lay.lines.length}
|
||
spellCheck={false}
|
||
onChange={(e) => setLabel(e.target.value.replace(/\n/g, ""))}
|
||
onBlur={commit}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation(); // ไม่ให้ปุ่มในช่องข้อความชนคีย์ลัดของ viewer
|
||
// Enter ระหว่าง IME compose = ยืนยันคำ ไม่ใช่บันทึก (เหตุผลเดียวกับ TextboxEditor)
|
||
if (e.nativeEvent.isComposing) return;
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
finish(onCancel);
|
||
return;
|
||
}
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
commit();
|
||
}
|
||
}}
|
||
// ไม่ใส่ padding แนวนอน: พื้นที่พิมพ์ = w − border (≈0.96w) กว้างกว่า maxW
|
||
// ของ stampLayout (0.9w) เสมอ ข้อความที่ย่อจนพอดี 0.9w เป๊ะ จึงไม่ wrap ใน
|
||
// editor ทั้งที่ผลหลังบันทึกเป็นบรรทัดเดียว (WYSIWYG — code review 2026-09-28)
|
||
className="w-full resize-none bg-transparent text-center font-bold focus:outline-none"
|
||
style={{
|
||
color: c,
|
||
fontFamily: STAMP_FONT_STACK,
|
||
fontSize: lay.fontSize,
|
||
lineHeight: lay.lineHeight,
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** ตรายางหนึ่งดวง — stampLayout (canvas measure ทีละบรรทัดหลายรอบตามจำนวนบรรทัด
|
||
* ที่ลอง wrap) ถูก useMemo ไว้ dep primitive [label, w] จึงคำนวณใหม่เฉพาะเมื่อ
|
||
* label/ความกว้างจริงเปลี่ยน ไม่ตาม re-render เพราะเหตุอื่น (annotation ตัวอื่น
|
||
* เพิ่ม/ลบ, selectedId, SSE event) — memo ระดับ component ไม่ช่วยเพราะ props
|
||
* (s/size object) เป็น reference ใหม่ทุก render ของ parent (code review
|
||
* 2026-09-28) · ฟอนต์ที่วัดเป็น system-ui (STAMP_FONT_STACK) มีพร้อมตั้งแต่
|
||
* browser เปิด จึงไม่ต้องรอ document.fonts.ready แล้ววัดซ้ำ (review ข้อ 7) */
|
||
function StampItem({
|
||
s,
|
||
size,
|
||
enabled,
|
||
onSelect,
|
||
}: {
|
||
s: InkStamp;
|
||
size: Size;
|
||
/** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events/โฟกัสของทุกรายการ */
|
||
enabled: boolean;
|
||
onSelect: (id: string) => void;
|
||
}) {
|
||
const w = toPx(s.data.scale, size.width);
|
||
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
|
||
// จัดวางข้อความแบบเดียวกับ preview/export: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ
|
||
// ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (จนถึง floor 10%), คำยาวจริงเกิน floor ค่อย wrap
|
||
// ≤3 บรรทัด กรอบสูงขึ้นตามจำนวนบรรทัด (2026-09-28 รอบสอง — ตาม stampLayout)
|
||
const lay = useMemo(
|
||
() => stampLayout(s.data.label, w, measureStampText),
|
||
[s.data.label, w],
|
||
);
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={enabled ? 0 : -1}
|
||
aria-label={`ตรายาง: ${s.data.label}`}
|
||
className="absolute flex flex-col items-center justify-center overflow-hidden text-center font-bold"
|
||
style={{
|
||
left: toPx(s.data.position.x, size.width),
|
||
top: toPx(s.data.position.y, size.height),
|
||
width: w,
|
||
height: stampBoxHeight(lay, w),
|
||
// มุมเอียง (−180..180) หมุนรอบ**กลางกรอบ** — left/top ยังเป็นมุมซ้ายบนของ
|
||
// กรอบ*ก่อนหมุน*เสมอ (bbox = AABB ของกรอบหมุน คำนวณใน geometry)
|
||
...(s.data.rotation
|
||
? { transform: `rotate(${s.data.rotation}deg)`, transformOrigin: "center" }
|
||
: null),
|
||
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
|
||
borderRadius: Math.max(w * 0.04, 2),
|
||
color: c,
|
||
fontFamily: STAMP_FONT_STACK,
|
||
fontSize: lay.fontSize,
|
||
lineHeight: lay.lineHeight,
|
||
pointerEvents: enabled ? "auto" : "none",
|
||
cursor: enabled ? "pointer" : "default",
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (enabled) onSelect(s.id);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (!enabled) return;
|
||
// กลืนเฉพาะคีย์ที่เราจัดการเอง — คีย์อื่น (DEL/Escape/H/D/C/B ของ viewer
|
||
// ที่ฟังที่ window) ต้อง bubble ต่อ เหตุผลเดียวกับ div กล่องข้อความด้านบน
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onSelect(s.id);
|
||
}
|
||
}}
|
||
>
|
||
{lay.lines.map((ln, i) => (
|
||
// บรรทัดว่างจาก wrap ใช้ nbsp กัน div ยุบ — ตัวอักษรใน quotes เป็น U+00A0 จริง ๆ
|
||
// (มองเหมือน space ปกติ — ห้ามลบหรือแทนด้วย space)
|
||
<div key={i}>{ln || " "}</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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";
|
||
/** ความกว้างกล่อง default % (กล่องใหม่เป็นกล่องกว้างคงที่เสมอ) */
|
||
widthPct?: number;
|
||
} | null;
|
||
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
|
||
onCancelComposer: () => void;
|
||
/** คลิกรายการ → parent set selectedId (F1) */
|
||
onSelect: (id: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="no-callout pointer-events-none absolute inset-0 z-[4]">
|
||
{textboxes.map((t) =>
|
||
editingId === t.id ? (
|
||
<TextboxEditor
|
||
key={t.id}
|
||
size={size}
|
||
position={t.data.position}
|
||
fontSizePct={t.data.fontSize}
|
||
color={t.data.color}
|
||
bold={t.data.bold}
|
||
opacity={t.data.opacity}
|
||
align={t.data.align}
|
||
widthPct={t.data.width}
|
||
enabled={enabled}
|
||
initialText={t.data.text}
|
||
initialSegments={t.data.segments}
|
||
onSave={(text, segments) => onCommitEdit(t.id, text, segments)}
|
||
onCancel={() => onStartEdit(null)}
|
||
/>
|
||
) : (
|
||
<div
|
||
key={t.id}
|
||
title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined}
|
||
// a11y: กล่องคลิกเลือกได้ = ปุ่ม — ใช้คีย์บอร์ด (Tab/Enter/Space) เลือกได้ด้วย
|
||
role="button"
|
||
tabIndex={enabled ? 0 : -1}
|
||
aria-label={`กล่องข้อความ${t.data.text ? `: ${t.data.text.slice(0, 40)}` : ""}`}
|
||
className="absolute whitespace-pre-wrap break-words"
|
||
style={{
|
||
left: toPx(t.data.position.x, size.width),
|
||
top: toPx(t.data.position.y, size.height),
|
||
// กล่อง width คงที่ (ถ้ามี) กว้างตามที่กำหนด — align จัดข้อความ*ใน*กล่องอย่างเดียว
|
||
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
|
||
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
|
||
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
|
||
fontFamily: TEXTBOX_FONT_STACK,
|
||
fontSize: toPx(t.data.fontSize, size.width),
|
||
lineHeight: 1.5,
|
||
color: t.data.color,
|
||
fontWeight: t.data.bold ? 700 : 400,
|
||
opacity: t.data.opacity ?? 1,
|
||
textAlign: t.data.align ?? "left",
|
||
pointerEvents: enabled ? "auto" : "none",
|
||
cursor: enabled ? "text" : "default",
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (enabled) onSelect(t.id);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (!enabled) return;
|
||
// กลืนเฉพาะคีย์ที่เราจัดการเอง (Enter/Space เลือกรายการ) — คีย์อื่น
|
||
// (DEL/Backspace ลบ, Escape deselect, H/D/C/B ของ viewer ที่ฟังที่
|
||
// window) ต้อง bubble ต่อ: focus ค้างบน item หลังคลิกเลือก (tabIndex)
|
||
// แล้วกลืนทุกคีย์ = ปุ่มลบ/ยกเลิกตายหมด (code review 2026-09-28)
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
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) => (
|
||
<span key={i} style={s.bold ? { fontWeight: 700 } : undefined}>
|
||
{s.text}
|
||
</span>
|
||
))
|
||
: t.data.text}
|
||
</div>
|
||
),
|
||
)}
|
||
{composer && (
|
||
<TextboxEditor
|
||
size={size}
|
||
position={composer.position}
|
||
fontSizePct={composer.fontSizePct}
|
||
color={composer.color}
|
||
bold={composer.bold}
|
||
opacity={composer.opacity}
|
||
align={composer.align}
|
||
widthPct={composer.widthPct}
|
||
enabled={enabled}
|
||
initialText=""
|
||
onSave={onCommitComposer}
|
||
onCancel={onCancelComposer}
|
||
/>
|
||
)}
|
||
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง กว้าง = scale% ของหน้า สูง 0.4 เท่า
|
||
(§5): คำสั้นอยู่บรรทัดเดียว คำยาว wrap ≤3 บรรทัด (2026-09-28) — ตัว render
|
||
อยู่ใน StampItem (memo), โหมดแก้ข้อความ (ปุ่ม "แก้ไขข้อความ" ในแผง selection)
|
||
= StampEditor แทนกรอบเดิม */}
|
||
{stamps.map((s) =>
|
||
editingId === s.id ? (
|
||
<StampEditor
|
||
key={s.id}
|
||
size={size}
|
||
data={s.data}
|
||
enabled={enabled}
|
||
onSave={(label) => onCommitEdit(s.id, label)}
|
||
onCancel={() => onStartEdit(null)}
|
||
/>
|
||
) : (
|
||
<StampItem key={s.id} s={s} size={size} enabled={enabled} onSelect={onSelect} />
|
||
),
|
||
)}
|
||
</div>
|
||
);
|
||
}
|