eCourt-note/components/pdf/ToolControls.tsx
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 df0392ea45
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
จอสัมผัส (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

269 lines
13 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) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
/** สี 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-[11px] font-medium text-slate-500">{label}</span>
{children}
</div>
);
}
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<div className="color-swatches 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;
/** ขอบบนตอน 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]);
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"
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.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 px-1.5 text-slate-500 hover:bg-slate-100"
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 border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-3 w-3" />
</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 },
];