eCourt-note/components/pdf/TextboxStampLayer.tsx
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 6fd3a1515f ซ่อม text ไทย PDF (ActualText) + revalidatePath ทุก action + คีย์ลัด DEL + ตรายาง label บรรทัดเดียว
- โหลด PDF ผ่าน /api/pdf?fileId= ที่สร้าง ToUnicode CMap ใหม่บนสำเนาในหน่วยความจำ
  จาก ActualText ใน marked content (lib/pdf-actual-text.ts + unit tests) — แก้
  ฟอนต์ไทยที่ cmap เพี้ยน (คำสั่งศาล) ให้ textLayer select/copy/ค้นหา/ชื่อ bookmark
  ถูกต้องตั้งแต่ต้น ไฟล์ใน public/ ไม่ถูกแตะ (invariant #1) — PDF ปกติได้ bytes
  เดิม ไม่มี cost และ fail แล้ว PDFViewer fallback fetch ไฟล์ดิบเอง
- ข้อความจาก selection ห้าม selection.toString() ตรง ๆ: ใช้ selectionTextWithin
  เดินทีละอักษร เรียงตามตำแหน่งบนจอ (จัดการ Thai combining marks + ctrl ขยะ)
  ปิดท้ายด้วย sanitizePdfText ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด (มี tests)
- mergeRectsByLine ใช้ median ความสูง rect คำนวณเกณฑ์บรรทัดแทนค่าคงที่
- Server Actions ทุกตัวที่เขียน DB เรียก revalidateFilePages (revalidatePath
  หน้า viewer+summary) คู่กับ publish event — แก้กด back แล้วเข้าไฟล์ใหม่ได้
  RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh
- คีย์ลัด DEL/Backspace ลบ annotation ที่เลือกอยู่ (ของตัวเองเท่านั้น ข้ามเมื่อ
  focus อยู่ใน input/textarea/select/contenteditable)
- STAMP: ย่อฟอนต์ label ให้อยู่บรรทัดเดียวพอดีกรอบทั้งบนจอ (canvas measure) และ
  export PDF (stampFitFontSize แชร์กัน) + เพิ่ม handle ปรับขนาดตรายางในแผง
  selection (uniform scale + clamp รายแกน)
- sync invariant/กติกา: CLAUDE.md #4, rules/*.md, PLAN-FEAT.md (F3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-11 13:28:35 +07:00

346 lines
15 KiB
TypeScript

"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, stampFitFontSize } 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 };
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
const STAMP_FONT_STACK = "system-ui, sans-serif";
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
function measureStampText(text: string, fontSize: number): number {
if (typeof document === "undefined") return 0;
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
if (!stampMeasureCtx) return 0;
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
return stampMeasureCtx.measureText(text).width;
}
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const runs = segments?.length ? segments : [{ text }];
return runs
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
.join("")
.replace(/\n/g, "<br>");
}
/** อ่าน 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<HTMLDivElement | null>(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 (
<div
className="absolute z-10"
style={{
left: toPx(position.x, size.width),
top: toPx(position.y, size.height),
// กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก
// แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
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
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}
/>
</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";
} | null;
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
onCancelComposer: () => void;
/** คลิกรายการ → parent set selectedId (F1) */
onSelect: (id: string) => void;
}) {
return (
<div className="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}
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}
className="absolute whitespace-pre-wrap break-words"
style={{
left: toPx(t.data.position.x, size.width),
top: toPx(t.data.position.y, size.height),
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
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);
}}
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}
enabled={enabled}
initialText=""
onSave={onCommitComposer}
onCancel={onCancelComposer}
/>
)}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
{stamps.map((s) => {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
return (
<div
key={s.id}
className="absolute flex 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: w * STAMP_HEIGHT_RATIO,
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontFamily: STAMP_FONT_STACK,
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
lineHeight: 1.2,
whiteSpace: "nowrap",
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
>
{s.data.label}
</div>
);
})}
</div>
);
}