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>
2026-09-10 14:29:40 +07:00
|
|
|
"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";
|
2026-09-11 17:41:22 +07:00
|
|
|
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
|
2026-09-11 13:28:35 +07:00
|
|
|
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
|
|
|
|
|
// 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 };
|
|
|
|
|
|
2026-09-11 13:28:35 +07:00
|
|
|
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
|
|
|
|
|
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
|
|
|
|
|
const STAMP_FONT_STACK = "system-ui, sans-serif";
|
UX audit ทั้งแอป + typography audit 2026-09-16: toast แจ้ง error การบันทึก (H1 — เดิมรายการหายเงียบ + ReplyBox เก็บ draft ตอน fail), คำเตือน redaction แสดงทุกขนาดจอ (H2), toolbar แสดงชื่อ tool active + aria-pressed บนจอสัมผัส (H3/M9), ปุ่ม popover จิ้มง่าย 36px + focus ring เติมครบ (H4) + M1-M9 (ปิด sidebar ตอน navigate, bookmark หัวแถวจอเล็ก, hint โหมดมาร์กบนจอสัมผัส, การ์ดหน้ารายการคลิกได้ทั้งใบ, เช็ค 100MB ฝั่ง client, feedback ✓/pending ตอนบันทึก bookmark, dropdown วางที่ sort ตามหน้า+เลขหน้า, slate-500 แทน slate-400) + webfont Sarabun ผ่าน next/font self-host 400/600 (Option B — แสดงผลตรงกันทุกอุปกรณ์ รวม TEXTBOX ที่ผูก stack นี้) + NumberCombo preset เป็น chips แทน dropdown ล้นเมนู (L2) + leading-relaxed/13px ข้อความไทยที่ wrap + tabular-nums + prefers-reduced-motion (L7) + L1/L4/L5 + แถว bookmark แสดงชื่อผู้บันทึกเหมือนผลการค้นหา
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 14:28:53 +07:00
|
|
|
|
|
|
|
|
/** 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";
|
2026-09-11 13:28:35 +07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
/** แปลงข้อความ/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,
|
2026-09-11 17:41:22 +07:00
|
|
|
widthPct,
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
enabled,
|
|
|
|
|
initialText,
|
|
|
|
|
initialSegments,
|
|
|
|
|
onSave,
|
|
|
|
|
onCancel,
|
|
|
|
|
}: {
|
|
|
|
|
size: Size;
|
|
|
|
|
position: Point;
|
|
|
|
|
fontSizePct: number;
|
|
|
|
|
/** สีตัวอักษร (จากตัวตั้งค่าของ tool / สีของกล่องที่แก้) */
|
|
|
|
|
color: string;
|
|
|
|
|
bold?: boolean;
|
|
|
|
|
opacity?: number;
|
|
|
|
|
align?: "left" | "center" | "right";
|
2026-09-11 17:41:22 +07:00
|
|
|
/** ความกว้างกล่องคงที่ % (กล่องใหม่/กล่องที่มี width) — ไม่มี = shrink-to-fit (แถวเดิม) */
|
|
|
|
|
widthPct?: number;
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
/** 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),
|
2026-09-11 17:41:22 +07:00
|
|
|
// มี widthPct = กล่องกว้างคงที่ (สไตล์ Smallpdf — ข้อความ wrap ในกล่อง)
|
|
|
|
|
// ไม่มี = shrink-to-fit หุ้มข้อความขยายตามที่พิมพ์ (แถวเดิม)
|
|
|
|
|
// maxWidth บีบตามพื้นที่จริงถึงขอบขวา (ไม่ล้นกระดาษ)
|
|
|
|
|
...(widthPct !== undefined ? { width: `${widthPct}%` } : null),
|
|
|
|
|
maxWidth: `${textboxMaxWidthPct(position.x)}%`,
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
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={{
|
UX audit ทั้งแอป + typography audit 2026-09-16: toast แจ้ง error การบันทึก (H1 — เดิมรายการหายเงียบ + ReplyBox เก็บ draft ตอน fail), คำเตือน redaction แสดงทุกขนาดจอ (H2), toolbar แสดงชื่อ tool active + aria-pressed บนจอสัมผัส (H3/M9), ปุ่ม popover จิ้มง่าย 36px + focus ring เติมครบ (H4) + M1-M9 (ปิด sidebar ตอน navigate, bookmark หัวแถวจอเล็ก, hint โหมดมาร์กบนจอสัมผัส, การ์ดหน้ารายการคลิกได้ทั้งใบ, เช็ค 100MB ฝั่ง client, feedback ✓/pending ตอนบันทึก bookmark, dropdown วางที่ sort ตามหน้า+เลขหน้า, slate-500 แทน slate-400) + webfont Sarabun ผ่าน next/font self-host 400/600 (Option B — แสดงผลตรงกันทุกอุปกรณ์ รวม TEXTBOX ที่ผูก stack นี้) + NumberCombo preset เป็น chips แทน dropdown ล้นเมนู (L2) + leading-relaxed/13px ข้อความไทยที่ wrap + tabular-nums + prefers-reduced-motion (L7) + L1/L4/L5 + แถว bookmark แสดงชื่อผู้บันทึกเหมือนผลการค้นหา
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 14:28:53 +07:00
|
|
|
fontFamily: TEXTBOX_FONT_STACK,
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
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";
|
2026-09-11 17:41:22 +07:00
|
|
|
/** ความกว้างกล่อง default % (กล่องใหม่เป็นกล่องกว้างคงที่เสมอ) */
|
|
|
|
|
widthPct?: number;
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
} | null;
|
|
|
|
|
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
|
|
|
|
|
onCancelComposer: () => void;
|
|
|
|
|
/** คลิกรายการ → parent set selectedId (F1) */
|
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
<div className="no-callout pointer-events-none absolute inset-0 z-[4]">
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
{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}
|
2026-09-11 17:41:22 +07:00
|
|
|
widthPct={t.data.width}
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
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),
|
2026-09-11 17:41:22 +07:00
|
|
|
// กล่อง width คงที่ (ถ้ามี) กว้างตามที่กำหนด — align จัดข้อความ*ใน*กล่องอย่างเดียว
|
|
|
|
|
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
|
|
|
|
|
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
|
|
|
|
|
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
|
UX audit ทั้งแอป + typography audit 2026-09-16: toast แจ้ง error การบันทึก (H1 — เดิมรายการหายเงียบ + ReplyBox เก็บ draft ตอน fail), คำเตือน redaction แสดงทุกขนาดจอ (H2), toolbar แสดงชื่อ tool active + aria-pressed บนจอสัมผัส (H3/M9), ปุ่ม popover จิ้มง่าย 36px + focus ring เติมครบ (H4) + M1-M9 (ปิด sidebar ตอน navigate, bookmark หัวแถวจอเล็ก, hint โหมดมาร์กบนจอสัมผัส, การ์ดหน้ารายการคลิกได้ทั้งใบ, เช็ค 100MB ฝั่ง client, feedback ✓/pending ตอนบันทึก bookmark, dropdown วางที่ sort ตามหน้า+เลขหน้า, slate-500 แทน slate-400) + webfont Sarabun ผ่าน next/font self-host 400/600 (Option B — แสดงผลตรงกันทุกอุปกรณ์ รวม TEXTBOX ที่ผูก stack นี้) + NumberCombo preset เป็น chips แทน dropdown ล้นเมนู (L2) + leading-relaxed/13px ข้อความไทยที่ wrap + tabular-nums + prefers-reduced-motion (L7) + L1/L4/L5 + แถว bookmark แสดงชื่อผู้บันทึกเหมือนผลการค้นหา
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 14:28:53 +07:00
|
|
|
fontFamily: TEXTBOX_FONT_STACK,
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
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}
|
2026-09-11 17:41:22 +07:00
|
|
|
widthPct={composer.widthPct}
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
enabled={enabled}
|
|
|
|
|
initialText=""
|
|
|
|
|
onSave={onCommitComposer}
|
|
|
|
|
onCancel={onCancelComposer}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-09-11 13:28:35 +07:00
|
|
|
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
|
|
|
|
|
ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
{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,
|
2026-09-11 13:28:35 +07:00
|
|
|
fontFamily: STAMP_FONT_STACK,
|
|
|
|
|
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
lineHeight: 1.2,
|
2026-09-11 13:28:35 +07:00
|
|
|
whiteSpace: "nowrap",
|
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>
2026-09-10 14:29:40 +07:00
|
|
|
pointerEvents: enabled ? "auto" : "none",
|
|
|
|
|
cursor: enabled ? "pointer" : "default",
|
|
|
|
|
}}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (enabled) onSelect(s.id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{s.data.label}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|