eCourt-note/components/pdf/ToolControls.tsx

287 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
* อยู่แล้ว ห้ามวนกลับ) */
const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
export const INK_COLORS = [
"#ef4444", // แดง (default ปากกา/รูปทรง)
"#f97316", // ส้ม
"#fde047", // เหลือง (default ไฮไลต์)
"#22c55e", // เขียว
"#14b8a6", // เขียวอมฟ้า
"#3b82f6", // น้ำเงิน
"#8b5cf6", // ม่วง
"#ec4899", // ชมพู
"#b45309", // น้ำตาล
"#111827", // ดำ (default textbox)
"#6b7280", // เทา
];
/** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย (optional: แถวสีไม่ใส่ ให้ตารางกินเต็มแถว)
* + ตัวควบคุมขวา */
export function PanelRow({ label, children }: { label?: string; children: ReactNode }) {
return (
<div className="panel-row flex items-start gap-2 px-1 py-1">
{label ? (
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
) : null}
{children}
</div>
);
}
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้งวงกลม (color picker เลือกสีอื่นได้ไม่จำกัด)
* grid 4 ต่อแถว — 11 สี preset + สีรุ้ง = 12 วง เรียงจบพอดี 3 แถว (4+4+4)
* ช่อง ~37px พ้นมาตรฐาน touch 36px เลยไม่ต้องปลด min ใน CSS จอสัมผัส */
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<div className="color-swatches grid flex-1 grid-cols-4 gap-1.5">
{INK_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`เลือกสี ${c}`}
title={c}
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}
style={{ backgroundColor: c }}
onClick={() => onChange(c)}
/>
))}
<label
title="เลือกสีอื่น"
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
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;
/** ขอบบนตอน clamp — ไม่ส่ง = ไม่จำกัด (เช่น ความหนาเส้นวาด) */
max?: number;
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]);
// 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]);
// คลิกนอกตัวควบคุม = ปิด dropdown (คลิกใน dropdown เองไม่นับ — อยู่ใน rootRef)
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)) {
const clamped = Math.min(max ?? Infinity, Math.max(min, Number(v.toFixed(2))));
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;
const next = Math.min(max ?? Infinity, Math.max(min, Number((base + dir * by).toFixed(2))));
setDraft(String(next));
if (next !== value) onChange(next);
}
return (
<div
ref={rootRef}
className="number-combo relative flex-1"
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); // กดที่ช่อง = เปิดรายการ preset ให้เลือกเลย (พิมพ์ค่าอื่นเองก็ได้)
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
}}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}}
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 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">
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
<button
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
{open && (
<div
role="listbox"
// L2 (UX audit 2026-09-16): เปิด "ขึ้นบน" (bottom-full) เสมอ — แถวท้ายเมนู
// tool (ความทึบ/ความหนา) เปิดลงจะล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
// ครบ / ทุกจุดใช้งานมีแถวอื่นอยู่ข้างบนเสมอ จึงไม่หลุดขอบบนของเมนูเช่นกัน
className="absolute bottom-full right-0 z-[60] mb-1 w-full 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 gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
value === opt
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => {
onChange(opt);
setOpen(false);
}}
>
<span className="flex-1">
{opt}
{suffix}
</span>
{value === opt && <Check className="h-3.5 w-3.5 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 },
];