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, useRef, useState, type ComponentType, type ReactNode } from "react";
|
|
|
|
|
|
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
|
|
import type { HighlightStyle } from "@/db/schema";
|
|
|
|
|
|
|
|
|
|
|
|
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
|
|
|
|
|
|
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
|
|
|
|
|
|
|
|
|
|
|
|
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
|
|
|
|
|
|
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
|
|
|
|
|
|
export const INK_COLORS = [
|
|
|
|
|
|
"#ef4444", // แดง (default ปากกา/รูปทรง)
|
|
|
|
|
|
"#f97316", // ส้ม
|
|
|
|
|
|
"#fde047", // เหลือง (default ไฮไลต์)
|
|
|
|
|
|
"#22c55e", // เขียว
|
|
|
|
|
|
"#14b8a6", // เขียวอมฟ้า
|
|
|
|
|
|
"#3b82f6", // น้ำเงิน
|
|
|
|
|
|
"#8b5cf6", // ม่วง
|
|
|
|
|
|
"#ec4899", // ชมพู
|
|
|
|
|
|
"#111827", // ดำ (default textbox)
|
|
|
|
|
|
"#6b7280", // เทา
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย + ตัวควบคุมขวา */
|
|
|
|
|
|
export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-start gap-2 px-1 py-1">
|
|
|
|
|
|
<span className="w-11 shrink-0 pt-1 text-[10px] font-medium text-slate-400">{label}</span>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
|
|
|
|
|
|
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-1 flex-wrap items-center gap-1.5">
|
|
|
|
|
|
{INK_COLORS.map((c) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={c}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={`เลือกสี ${c}`}
|
|
|
|
|
|
title={c}
|
|
|
|
|
|
className={`h-4 w-4 rounded-full border border-slate-300 transition ${
|
|
|
|
|
|
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
style={{ backgroundColor: c }}
|
|
|
|
|
|
onClick={() => onChange(c)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<label
|
|
|
|
|
|
title="เลือกสีอื่น"
|
|
|
|
|
|
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition ${
|
|
|
|
|
|
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background:
|
|
|
|
|
|
"conic-gradient(#ef4444, #f97316, #fde047, #22c55e, #14b8a6, #3b82f6, #8b5cf6, #ec4899, #ef4444)",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
aria-label="เลือกสีอื่น"
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={(e) => onChange(e.target.value)}
|
|
|
|
|
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ช่องตัวเลข combobox — กดที่ input เปิดรายการค่า preset ให้เลือก (หรือพิมพ์เองได้)
|
|
|
|
|
|
* + ปุ่ม ▲▼ ปรับค่าทีละ stepBy (clamp ใน min–max) */
|
|
|
|
|
|
export function NumberCombo({
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
min,
|
|
|
|
|
|
max,
|
|
|
|
|
|
step,
|
|
|
|
|
|
stepBy,
|
|
|
|
|
|
suffix,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: number;
|
|
|
|
|
|
options: number[];
|
|
|
|
|
|
min: number;
|
2026-09-11 14:04:58 +07:00
|
|
|
|
/** ขอบบนตอน clamp — ไม่ส่ง = ไม่จำกัด (เช่น ความหนาเส้นวาด) */
|
|
|
|
|
|
max?: 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
|
|
|
|
step: number;
|
|
|
|
|
|
/** ละขั้นของปุ่ม ▲▼ (default 1 — เช่น fontSize ใช้ 0.1) */
|
|
|
|
|
|
stepBy?: number;
|
|
|
|
|
|
/** ต่อท้ายค่าในแถวรายการ (เช่น "%") */
|
|
|
|
|
|
suffix?: string;
|
|
|
|
|
|
onChange: (v: number) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
|
|
|
|
|
|
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
|
|
|
|
|
|
const [draft, setDraft] = useState(String(value));
|
|
|
|
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => setDraft(String(value)), [value]);
|
|
|
|
|
|
|
2026-09-11 14:04:58 +07:00
|
|
|
|
// FIX-3 (ปรับ): หยุดพิมพ์แล้ว ~0.5 วิ commit เอง — ไม่ต้องรอ Enter/blur แต่ยัง
|
|
|
|
|
|
// รวมเป็น commit เดียวต่อชุดที่พิมพ์เสร็จ (ไม่ยิง server action + version
|
|
|
|
|
|
// snapshot ต่อ keystroke — สำคัญกับแผง selection ที่ commit ทัพหลังถึง DB)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (draft === String(value)) return; // sync กลับจาก value ไม่นับเป็นการพิมพ์
|
|
|
|
|
|
const t = setTimeout(() => {
|
|
|
|
|
|
const v = Number(draft);
|
|
|
|
|
|
if (draft.trim() === "" || !Number.isFinite(v)) return; // คั่นกลางพิมพ์/ลบหมด — ให้ blur จัดการ
|
|
|
|
|
|
const clamped = Math.min(max ?? Infinity, Math.max(min, Number(v.toFixed(2))));
|
|
|
|
|
|
if (clamped !== value) onChange(clamped); // value เปลี่ยน → effect ด้านบน sync draft กลับ
|
|
|
|
|
|
else setDraft(String(clamped)); // เกินขอบแต่ clamped แล้วเท่าค่าเดิม → แสดงค่าจริง
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
return () => clearTimeout(t);
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- draft/value/onChange ใช้ค่าล่าสุดตอน timer ยิง
|
|
|
|
|
|
}, [draft]);
|
|
|
|
|
|
|
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
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
|
|
|
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("mousedown", onMouseDown);
|
|
|
|
|
|
return () => document.removeEventListener("mousedown", onMouseDown);
|
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
|
|
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
|
|
|
|
|
|
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
|
|
|
|
|
|
function commitDraft() {
|
|
|
|
|
|
const v = Number(draft);
|
|
|
|
|
|
if (draft.trim() !== "" && Number.isFinite(v)) {
|
2026-09-11 14:04:58 +07:00
|
|
|
|
const clamped = Math.min(max ?? Infinity, Math.max(min, Number(v.toFixed(2))));
|
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
|
|
|
|
setDraft(String(clamped));
|
|
|
|
|
|
if (clamped !== value) onChange(clamped);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setDraft(String(value));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ▲▼ ปรับค่าทีละ stepBy จาก draft ล่าสุด (ค่าที่พิมพ์เองก็ต่อจากนั้นได้) — clamp ใน min–max
|
|
|
|
|
|
* ชนขอบแล้วค่าไม่เดิน (next === value) ไม่ยิง onChange เช่นเดียวกับ commitDraft */
|
|
|
|
|
|
function stepValue(dir: 1 | -1) {
|
|
|
|
|
|
const by = stepBy ?? 1;
|
|
|
|
|
|
const base = draft.trim() !== "" && Number.isFinite(Number(draft)) ? Number(draft) : value;
|
2026-09-11 14:04:58 +07:00
|
|
|
|
const next = Math.min(max ?? Infinity, Math.max(min, Number((base + dir * by).toFixed(2))));
|
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
|
|
|
|
setDraft(String(next));
|
|
|
|
|
|
if (next !== value) onChange(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={rootRef}
|
|
|
|
|
|
className="relative"
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
|
|
|
|
|
|
if (e.key === "Escape" && open) {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={draft}
|
|
|
|
|
|
min={min}
|
|
|
|
|
|
max={max}
|
|
|
|
|
|
step={step}
|
|
|
|
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
|
|
|
|
onBlur={commitDraft}
|
|
|
|
|
|
onFocus={(e) => {
|
|
|
|
|
|
setOpen(true); // กดที่ช่อง = เปิดรายการให้เลือกเลย (ไม่ต้องมีปุ่มเปิดแยก)
|
|
|
|
|
|
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
|
|
|
|
|
|
}}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
|
|
|
|
|
|
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="w-14 border-0 px-1.5 py-1 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-col border-l border-slate-300">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="เพิ่มค่า"
|
|
|
|
|
|
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
|
|
|
|
|
|
className="flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
|
|
|
|
|
|
onClick={() => stepValue(1)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronUp className="h-2.5 w-2.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="ลดค่า"
|
|
|
|
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
|
|
|
|
className="flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
|
|
|
|
|
|
onClick={() => stepValue(-1)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronDown className="h-2.5 w-2.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{open && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
role="listbox"
|
|
|
|
|
|
className="absolute right-0 top-full z-[60] mt-1 w-16 overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
|
|
|
|
|
|
>
|
|
|
|
|
|
{options.map((opt) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={opt}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="option"
|
|
|
|
|
|
aria-selected={value === opt}
|
|
|
|
|
|
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
|
|
|
|
|
|
className={`flex w-full items-center justify-end gap-1 px-2 py-1 text-right text-[11px] font-medium transition ${
|
|
|
|
|
|
value === opt
|
|
|
|
|
|
? "bg-slate-100 text-slate-900"
|
|
|
|
|
|
: "text-slate-600 hover:bg-slate-100"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onChange(opt);
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{opt}
|
|
|
|
|
|
{suffix}
|
|
|
|
|
|
{value === opt && <Check className="h-3 w-3 shrink-0" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ไอคอนเส้นขยุกขยิก (squiggle) — วาดเองเพราะ lucide ไม่มี */
|
|
|
|
|
|
export function SquiggleIcon({ className }: { className?: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<svg
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
className={className}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path d="M2 15 q2.5 -5 5 0 t5 0 t5 0 t5 0" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** รายการรูปแบบการมาร์กข้อความ + ไอคอน — ใช้ใน dropdown ของ tool highlight และแผงของกรอบ selection */
|
|
|
|
|
|
export const HIGHLIGHT_STYLE_BTNS: {
|
|
|
|
|
|
key: HighlightStyle;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
Icon: ComponentType<{ className?: string }>;
|
|
|
|
|
|
}[] = [
|
|
|
|
|
|
{ key: "highlight", label: "ไฮไลต์", Icon: Highlighter },
|
|
|
|
|
|
{ key: "underline", label: "เส้นใต้", Icon: Underline },
|
|
|
|
|
|
{ key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough },
|
|
|
|
|
|
{ key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon },
|
|
|
|
|
|
];
|