"use client"; import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { 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", // ชมพู "#111827", // ดำ (default textbox) "#6b7280", // เทา ]; /** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย + ตัวควบคุมขวา */ export function PanelRow({ label, children }: { label: string; children: ReactNode }) { return (
{label} {children}
); } /** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) { return (
{INK_COLORS.map((c) => (
); } /** ช่องตัวเลข 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; }) { // FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter // ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน const [draft, setDraft] = useState(String(value)); 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]); /** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — 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 (
setDraft(e.target.value)} onBlur={commitDraft} onFocus={(e) => 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.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" />
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
{/* L2 (UX audit 2026-09-16): preset เดิมเป็น dropdown เปิดลงจากช่อง — แถวท้าย ของเมนู tool (ความทึบ/ความหนา) ล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น ครบ เปลี่ยนเป็น chips แถวเดียวใต้ช่อง: เห็นค่าที่เลือกได้ทันที ไม่มี popup ซ้อนในเมนูอีก (จอสัมผัสได้ min 36px จาก rule [data-tool-menu] โดยอัตโนมัติ) */}
{options.map((opt) => { const active = value === opt; return ( ); })}
); } /** ไอคอนเส้นขยุกขยิก (squiggle) — วาดเองเพราะ lucide ไม่มี */ export function SquiggleIcon({ className }: { className?: string }) { return ( ); } /** รายการรูปแบบการมาร์กข้อความ + ไอคอน — ใช้ใน 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 }, ];