Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight, freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo, shapes, redaction, export PDF/CSV, reply, resolve, search, summary, textbox, stamp, version history) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
329
components/pdf/TextboxStampLayer.tsx
Normal file
329
components/pdf/TextboxStampLayer.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"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 + <b> + <br>) */
|
||||
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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) */}
|
||||
{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,
|
||||
fontSize: w * STAMP_FONT_RATIO,
|
||||
lineHeight: 1.2,
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (enabled) onSelect(s.id);
|
||||
}}
|
||||
>
|
||||
{s.data.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue