refactor(toolbar): extract toolbar tool logic to module

ย้าย logic ของแถบ tool ออกจาก PDFViewer.tsx ไป pure module ใหม่
components/pdf/toolbar-tools.ts (behavior-preserving) ตามแผน TDD
docs/PLAN-TOOLBAR.md ขั้น 1+2 รวม findings #1-#6:

- ขั้น 1: ย้าย type Tool/TOOLS/visibleToolsFor/canSwitchTo/
  toolButtonFace/menuIntentAfterClick ออกจาก PDFViewer.tsx + rewire +
  ตัด icon imports ว่าง 9 ตัว + spec test
  tests/components/toolbar-tools.test.ts
- ขั้น 2 (findings #2/#5): TOOL_MENU_KEYS + type MenuTool +
  TOOL_MENU_TABLE (Record บังคับครบทุก key — เพิ่มเมนูลืมแถว =
  compile fail) + closeToolMenus()/closePagePopups() ใช้ร่วมครบทุก
  กลุ่มจุดเดิม รวม 2 จุดที่เดิมไม่เคลียร์เลย (scannedOnly + สลับไฟล์
  ภาพ) — popup bookmark/composer/popover ของ pin/แผง selection/editor
  ข้อความไม่ค้างเปิดซ้อนกันครบทุกทางเข้า
- findings #1: คีย์ลัดจับคู่ด้วย e.code แทน e.key (TOOL_SHORTCUTS
  KeyH/KeyD/KeyC + ขยายครอบ Ctrl+Z undo/redo) — layout ไทยให้ e.key
  เป็นอักษรไทยจนคีย์ลัด H/D/C/B และ undo ใช้ไม่ได้
- findings #3/#4/#6: ปรับให้ปุ่มใช้ตัวแปร active เดียวกันทั้งชุด
  (aria-pressed)

ผ่าน tsc + vitest 207/207 + e2e Playwright ตามแผน (เมนูค้าง/สลับไฟล์
ภาพ/popup bookmark ลำดับปิด-เปิด/คีย์ลัด synthetic layout ไทย)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-29 14:47:12 +07:00
parent 8571ef9aaf
commit 0681e056fd
5 changed files with 561 additions and 148 deletions

View file

@ -11,6 +11,7 @@ import {
startTransition,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import {
@ -27,22 +28,13 @@ import {
Circle,
ClipboardList,
Download,
Eraser,
EyeOff,
Hand,
Highlighter,
MessageSquare,
MessageSquarePlus,
Minus,
MousePointer2,
PanelLeft,
Pencil,
Redo2,
Shapes,
Square,
Trash2,
Type,
Stamp,
Undo2,
X,
ZoomIn,
@ -153,17 +145,16 @@ import {
PanelRow,
} from "./ToolControls";
type Tool =
| "select"
| "pan"
| "highlight"
| "draw"
| "comment"
| "shape"
| "erase"
| "redact"
| "textbox"
| "stamp";
import {
TOOL_MENU_KEYS,
TOOL_SHORTCUTS,
canSwitchTo,
menuIntentAfterClick,
toolButtonFace,
visibleToolsFor,
type MenuTool,
type Tool,
} from "./toolbar-tools";
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
@ -376,27 +367,6 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000));
}
/** label = ชื่อเต็มสำหรับ title/aria-label (พร้อมคีย์ลัด/คำอังกฤษประกอบ) —
* text = คำสั้น ๆ แสดงเป็นข้อความหลังไอคอนบนปุ่ม (คำขอผู้ใช้ 2026-09-21: ผู้ใช้ศาล
* ไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span ชื่อ
* tool-active ของ H3 ที่ถอดออก) — สำหรับปุ่ม dropdown ที่มีตัวเลือกย่อย (มาร์ก
* ข้อความ/วาดเส้น/รูปทรง/ตรายาง) เคยแสดงชื่อตัวเลือกล่าสุดแทน text ตลอด (คำขอผู้ใช้
* 2026-09-23) แต่ทำให้ชื่อ tool หายไปจากแถบ ("วาดเส้น" เห็นเป็น "ปากกา" ตั้งแต่
* ยังไม่ได้เลือกอะไร) — 2026-09-29 จึงแสดงค่าล่าสุด**เฉพาะตอน tool active**
* (ค่าที่จะใช้จริงเมื่อวาด) ไม่ active กลับมาแสดง text ชื่อ tool เหมือนปุ่มอื่น */
const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [
{ key: "select", label: "เลือก", text: "เลือก", Icon: MousePointer2 },
{ key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand },
{ key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil },
{ key: "comment", label: "คอมเมนต์ (C)", text: "คอมเมนต์", Icon: MessageSquarePlus },
{ key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes },
{ key: "erase", label: "ยางลบ (เส้น/รูปทรง/ตรายางของตัวเอง)", text: "ยางลบ", Icon: Eraser },
{ key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff },
{ key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type },
{ key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp },
];
/** แบนเนอร์แนะนำวิธีใช้ tool ที่วางของด้วยคลิกบนหน้า (UX audit 2026-09-22): เดิมเลือก tool
* แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที
* ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน
@ -1536,11 +1506,7 @@ export default function PDFViewer({
if (!anyMenuOpen) return;
const close = () => {
markMenuClosed(); // M4: เลื่อนแถบ/resize จนเมนูปิด — tap ถัดไปใกล้ ๆ ไม่สร้างรายการ
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
closeToolMenus();
setBookmarkComposer(null);
};
const onResize = () => {
@ -1615,7 +1581,11 @@ export default function PDFViewer({
// ระหว่างอยู่ tool มาร์กข้อความแล้วผลว่าเป็นไฟล์สแกน (เช่น กด H ตอนหน้าแรกยัง
// ไม่ render) — กลับเครื่องมือเลือกให้ ไม่ค้างเป็น tool ที่ปุ่มหายไปแล้ว
useEffect(() => {
if (scannedOnly) setTool((prev) => (prev === "highlight" ? "select" : prev));
if (!scannedOnly) return;
setTool((prev) => (prev === "highlight" ? "select" : prev));
// ปุ่ม highlight กำลังถูกซ่อน — เมนู dropdown ที่เปิดค้างอยู่ (portal fixed) จะลอย
// ไร้ปุ่มยึด ปิดด้วยชุดเดียวกับ switchTool (PLAN-TOOLBAR.md ขั้น 2)
closeToolMenus();
}, [scannedOnly]);
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
@ -1689,6 +1659,9 @@ export default function PDFViewer({
// เปลี่ยน = Next reuse component เดิม ไม่ reset state): tool นี้ถูกซ่อนบนไฟล์
// ภาพ ต้องกลับไปเครื่องมือเลือก ไม่งั้นค้างเป็น tool ที่กดไม่ได้
setTool((prev) => (prev === "highlight" ? "select" : prev));
// ปุ่ม highlight ถูกซ่อนบนไฟล์ภาพ — เมนูที่เปิดค้างจากไฟล์เดิมจะลอยไร้ปุ่มยึด
// ปิดด้วยชุดเดียวกับ switchTool (PLAN-TOOLBAR.md ขั้น 2)
closeToolMenus();
const objectUrls: string[] = [];
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
@ -1939,6 +1912,40 @@ export default function PDFViewer({
function markMenuClosed() {
menuClosedAtRef.current = Date.now();
}
/** ตารางเมนู dropdown ของ tool — key ครบตาม TOOL_MENU_KEYS โดย Record บังคับที่ระดับ
* type: เพิ่มเมนูใหม่ใน toolbar-tools.ts แล้วลืมแถวนี้ = compile fail ทันที แทนการ
* enumerate setter ด้วยมือ 6 แห่งเดิม (เพิ่มเมนู/แก้พฤติกรรมแล้วลืมแห่งเดียว = drift
* เงียบ — เคยเกิดจริงกับ title ของปุ่ม dropdown ตั้งแต่ 2026-09-23, commit 8571ef9) */
const TOOL_MENU_TABLE: Record<
MenuTool,
{ open: boolean; ref: RefObject<HTMLDivElement | null>; setOpen: (open: boolean) => void }
> = {
highlight: { open: highlightMenuOpen, ref: highlightMenuRef, setOpen: setHighlightMenuOpen },
draw: { open: drawMenuOpen, ref: drawMenuRef, setOpen: setDrawMenuOpen },
shape: { open: shapeMenuOpen, ref: shapeMenuRef, setOpen: setShapeMenuOpen },
textbox: { open: textboxMenuOpen, ref: textboxMenuRef, setOpen: setTextboxMenuOpen },
stamp: { open: stampMenuOpen, ref: stampMenuRef, setOpen: setStampMenuOpen },
};
/** ปิดเมนู dropdown ของ tool ทั้งชุด — จุดที่เคย enumerate setter ด้วยมือ (switchTool/
* outside-click/เลื่อนแถบ·resize/auto-select/เปิด popup bookmark/reset tool ตรง) เรียก
* ตัวเดียวกันหมดจากนี้ */
function closeToolMenus() {
for (const key of TOOL_MENU_KEYS) TOOL_MENU_TABLE[key].setOpen(false);
}
/** เคลียร์ popup ที่เปิดอยู่บนหน้าเอกสารทั้งชุด — popup bookmark + composer + popover
* ของ pin + แผง selection + editor ข้อความ (comment/textbox) + preview ตามหลัก
* "popup เปิดได้ทีละชุด ห้ามค้างเปิดซ้อนกัน" (rules/pdf-viewer.md) */
function closePagePopups() {
setBookmarkComposer(null);
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
}
// Major 1 (code review): ความกว้าง viewport ตอนเปิดเมนูล่าสุด — resize ของมือถือจาก URL bar
// ยุบ/กาง หรือคีย์บอร์ดขึ้น เปลี่ยนแค่ความสูง (เมนู fixed ยึด sticky header ยังอยู่กับที่)
// จะปิดเมนูเฉพาะเมื่อความกว้างเปลี่ยน (หมุนจอ/split view) เท่านั้น
@ -1953,11 +1960,10 @@ export default function PDFViewer({
const [grabbing, setGrabbing] = useState(false);
useEffect(() => {
const menus = [
{ open: stampMenuOpen, ref: stampMenuRef, close: () => setStampMenuOpen(false) },
{ open: shapeMenuOpen, ref: shapeMenuRef, close: () => setShapeMenuOpen(false) },
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
...TOOL_MENU_KEYS.map((key) => {
const m = TOOL_MENU_TABLE[key];
return { open: m.open, ref: m.ref, close: () => m.setOpen(false) };
}),
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
];
if (!menus.some((m) => m.open)) return;
@ -1983,7 +1989,7 @@ export default function PDFViewer({
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen, bookmarkComposer]);
}, [...TOOL_MENU_KEYS.map((k) => TOOL_MENU_TABLE[k].open), bookmarkComposer]);
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
@ -2024,19 +2030,15 @@ export default function PDFViewer({
if (e.key === "Escape") {
if (inForm) return; // ฟอร์ม (เช่น composer) จัดการ Esc เอง
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
setBookmarkComposer(null);
// ชุดเดียวกับ switchTool — จุด enumerate setter ด้วยมือจุดสุดท้ายของฝั่ง popup
closePagePopups();
return;
}
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === "z" && !inForm) {
// KeyZ = ตำแหน่งกายภาพของปุ่ม — layout ไทยให้ e.key "ฎ" ทำให้ undo/redo
// ใช้ไม่ได้ถ้าเช็ค e.key (เหตุผลเดียวกับคีย์ลัด H/D/C/B — PLAN-TOOLBAR #1)
if (e.code === "KeyZ" && !inForm) {
e.preventDefault();
if (e.shiftKey) void h.redoHistory();
else void h.undoHistory();
@ -2045,29 +2047,24 @@ export default function PDFViewer({
}
if (e.altKey || inForm) return;
const k = e.key.toLowerCase();
if (k === "delete" || k === "backspace") {
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
h.deleteSelected();
return;
}
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กด B เปิด popup bookmark แล้ว
// React focus input ทันทีใน keydown เดียวกัน browser จึงพิมพ์ "b" ตามลงไป
if (k !== "h" && k !== "d" && k !== "c" && k !== "b") return;
e.preventDefault();
switch (k) {
case "h": // H = ไฮไลต์ — สลับไป tool highlight (คลุมข้อความ = สร้างทันที)
h.switchTool("highlight");
break;
case "d":
h.switchTool("draw");
break;
case "c":
h.switchTool("comment");
break;
case "b":
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
break;
// คีย์ลัดจับด้วย e.code (ตำแหน่งปุ่ม) ไม่ใช่ e.key — layout ไทยพิมพ์ "ภ"/"น"
// ไม่ใช่ "h"/"b" ทำให้ทั้งชุดตายบน layout ไทย (PLAN-TOOLBAR #1)
const shortcutTool = TOOL_SHORTCUTS[e.code];
if (shortcutTool) {
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กดคีย์ลัดแล้ว React focus
// ช่องใหม่ทันทีใน keydown เดียวกัน browser จึงพิมพ์ตัวอักษรตามลงไป
e.preventDefault();
h.switchTool(shortcutTool);
return;
}
if (e.code === "KeyB") {
e.preventDefault();
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
}
};
window.addEventListener("keydown", onKey);
@ -2143,11 +2140,7 @@ export default function PDFViewer({
// แผง selection กำลังจะโผล่ = ปิดเมนู tool/popup bookmark ที่เปิดค้างก่อน — บน iOS
// การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนู
// เอง (Android/desktop ปิดไปแล้วตั้งแต่เริ่มกด) ไม่ปิดตรงนี้ = เมนูซ้อนแผงบนจอสัมผัส
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
closeToolMenus();
setBookmarkComposer(null);
}
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
@ -2439,11 +2432,11 @@ export default function PDFViewer({
// ปิดเมนู tool ที่เปิดอยู่ก่อน (เช่นคีย์ลัด B ระหว่างเมนูเปิด) — popup bookmark (z เดียวกัน
// ทั้งคู่ fixed ใต้แถบ tool) เปิดซ้อนบนเมนูได้; ไม่ markMenuClosed เพราะ B เป็นคีย์บอร์ด
// ไม่มี click หลงเหลือให้กลืน
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
closeToolMenus();
// เคลียร์ popup บนหน้าที่ค้างเปิดด้วย (popover ของ pin/แผง selection/editor ข้อความ) —
// ไม่งั้น popup bookmark ซ้อนของเก่า และ autoFocus ของช่องชื่อแย่ง focus จาก editor
// ที่ยังเปิดอยู่ (finding #2 ของ PLAN-TOOLBAR.md)
closePagePopups();
const pos = currentViewPosition();
if (!pos) return;
setBookmarkError(null);
@ -2460,6 +2453,11 @@ export default function PDFViewer({
/** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น
* ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้น = "หน้า N" */
function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) {
// ผ่านชุดเคลียร์เดียวกับ openBookmarkComposer — ปิดเมนู+popup บนหน้า (แผง selection/
// editor ที่กำลังเปิดอยู่นั้นเอง) ก่อนเปิด popup bookmark กันซ้อนกันและ autoFocus
// แย่ง focus (code review ขั้น 2 — ทางเปิด popup bookmark ทางเดียวที่เหลือ)
closeToolMenus();
closePagePopups();
const meaningful =
a.type === "HIGHLIGHT" ||
a.type === "TEXTBOX" ||
@ -3110,30 +3108,26 @@ export default function PDFViewer({
function switchTool(t: Tool) {
// ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
// ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H จึงกันตรงนี้ให้จบ (สลับ tool อื่นไม่กระทบ)
if (t === "highlight" && hideHighlightTool) return;
setTool(t);
// ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H: กดแล้วไม่สลับ tool แต่ยังเคลียร์เมนู/
// popup/selection เหมือนสลับ tool จริง — ไม่ปล่อย no-op เงียบสนิทจนนึกว่าคีย์ลัด
// ไม่ทำงาน (finding #4 ของ PLAN-TOOLBAR.md)
const blocked = !canSwitchTo(t, hideHighlightTool);
if (!blocked) setTool(t);
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
// ⚠️ anyMenuOpen อ่านจาก closure ของ render ปัจจุบัน — เวิร์กเพราะ outside-click
// ผูก mousedown ซึ่ง flush ก่อน click ของปุ่มยิงถึงเสมอ อย่าขยับ ordering นี้
if (anyMenuOpen) markMenuClosed();
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
// popup bookmark ก็ปิดตาม — สลับ tool แล้ว popup ค้างเปิดจะซ้อนกับสิ่งที่ทำต่อ
// (anyMenuOpen นับ bookmarkComposer อยู่แล้ว พฤติกรรม M4 เท่าเดิม)
setBookmarkComposer(null);
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
closeToolMenus();
// popup บนหน้า (รวม popup bookmark) ปิดตาม — สลับ tool แล้ว popup ค้างเปิดจะซ้อนกับ
// สิ่งที่ทำต่อ (anyMenuOpen นับ bookmarkComposer อยู่แล้ว พฤติกรรม M4 เท่าเดิม)
closePagePopups();
window.getSelection()?.removeAllRanges();
// M3: เข้า tool highlight บนจอสัมผัส (ปุ่มหรือคีย์ H) — โชว์ hint ตามโควตาครั้งแรก
if (t === "highlight" && coarseTouch && coarseHlHintCountRef.current < 3) {
// นับเฉพาะตอน "เพิ่งสลับเข้ามาจาก tool อื่น" เท่านั้น: กดปุ่ม dropdown ตอน tool
// นี้ active อยู่แล้ว (เปิด/พับเมนู) หรือกด H ซ้ำ ๆ ต้องไม่กินโควตา (finding #3)
const enteringHighlight = !blocked && t === "highlight" && tool !== "highlight";
if (enteringHighlight && coarseTouch && coarseHlHintCountRef.current < 3) {
coarseHlHintCountRef.current += 1;
setCoarseHlHintVisible(true);
} else {
@ -3158,7 +3152,7 @@ export default function PDFViewer({
// คีย์ลัด H ที่ switchTool และ reset tool ที่ค้างอยู่ (effect ของแต่ละกรณี)
const isImageFile = fileKindFromUrl(file.pdfUrl) === "image";
const hideHighlightTool = isImageFile || scannedOnly;
const visibleTools = hideHighlightTool ? TOOLS.filter((t) => t.key !== "highlight") : TOOLS;
const visibleTools = visibleToolsFor(hideHighlightTool);
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่
const drawStyle = {
@ -3388,20 +3382,21 @@ export default function PDFViewer({
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
if (key === "highlight") {
const btnText = active
? (HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.label ?? text)
: text;
const face = toolButtonFace(
active,
{ label, text },
HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.label,
);
const CurrentIcon = active
? (HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon)
: Icon;
const btnTitle = active ? `${label} — ${btnText}` : label;
return (
<div key={key} ref={highlightMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={btnTitle}
aria-label={btnTitle}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={highlightMenuOpen}
@ -3411,13 +3406,13 @@ export default function PDFViewer({
// เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด
// (editor/แผง selection/popover) ไม่ให้ซ้อนกับเมนู; จำ intent เปิด/พับ
// ไว้ก่อนเพราะ switchTool ปิดเมนูทุกตัวก่อน
const reopen = tool !== "highlight" || !highlightMenuOpen;
const reopen = menuIntentAfterClick(tool, "highlight", highlightMenuOpen);
switchTool("highlight");
if (reopen) setHighlightMenuOpen(true);
}}
>
<CurrentIcon className="h-4 w-4" />
<span className="text-xs font-medium">{btnText}</span>
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{highlightMenuOpen &&
@ -3484,16 +3479,15 @@ export default function PDFViewer({
// DRAW: ปุ่มวาดเส้น = dropdown เลือกชนิดปากกา (ปากกา/ไฮไลต์)
if (key === "draw") {
const face = toolButtonFace(active, { label, text }, DRAW_PENS[drawPen].label);
const CurrentPenIcon = active ? DRAW_PEN_ICONS[drawPen] : Icon;
const btnText = active ? DRAW_PENS[drawPen].label : text;
const btnTitle = active ? `${label} — ${btnText}` : label;
return (
<div key={key} ref={drawMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={btnTitle}
aria-label={btnTitle}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={drawMenuOpen}
@ -3501,13 +3495,13 @@ export default function PDFViewer({
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "draw" || !drawMenuOpen;
const reopen = menuIntentAfterClick(tool, "draw", drawMenuOpen);
switchTool("draw");
if (reopen) setDrawMenuOpen(true);
}}
>
<CurrentPenIcon className="h-4 w-4" />
<span className="text-xs font-medium">{btnText}</span>
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{drawMenuOpen &&
@ -3594,17 +3588,18 @@ export default function PDFViewer({
// F5/B1: ปุ่มรูปทรง = dropdown เลือกชนิดรูปทรง
if (key === "shape") {
const btnText = active
? (SHAPE_KIND_BTNS.find((k) => k.key === shapeKind)?.label ?? text)
: text;
const btnTitle = active ? `${label} — ${btnText}` : label;
const face = toolButtonFace(
active,
{ label, text },
SHAPE_KIND_BTNS.find((k) => k.key === shapeKind)?.label,
);
return (
<div key={key} ref={shapeMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={btnTitle}
aria-label={btnTitle}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={shapeMenuOpen}
@ -3612,13 +3607,13 @@ export default function PDFViewer({
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "shape" || !shapeMenuOpen;
const reopen = menuIntentAfterClick(tool, "shape", shapeMenuOpen);
switchTool("shape");
if (reopen) setShapeMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
<span className="text-xs font-medium">{btnText}</span>
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{shapeMenuOpen &&
@ -3683,12 +3678,11 @@ export default function PDFViewer({
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — ตอน active เลือกคำกำหนด
// เองแล้วปุ่มแสดง "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือก)
if (key === "stamp") {
const btnText = active
? STAMP_PRESETS.includes(stampPreset)
? stampPreset
: "กำหนดเอง"
: text;
const btnTitle = active ? `${label} — ${btnText}` : label;
const face = toolButtonFace(
active,
{ label, text },
STAMP_PRESETS.includes(stampPreset) ? stampPreset : "กำหนดเอง",
);
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
@ -3701,8 +3695,8 @@ export default function PDFViewer({
<button
type="button"
data-tool={key}
title={btnTitle}
aria-label={btnTitle}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={stampMenuOpen}
@ -3710,7 +3704,7 @@ export default function PDFViewer({
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "stamp" || !stampMenuOpen;
const reopen = menuIntentAfterClick(tool, "stamp", stampMenuOpen);
switchTool("stamp");
if (reopen) {
setStampMenuOpen(true);
@ -3719,7 +3713,7 @@ export default function PDFViewer({
}}
>
<Icon className="h-4 w-4" />
<span className="text-xs font-medium">{btnText}</span>
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{stampMenuOpen &&
@ -3931,14 +3925,14 @@ export default function PDFViewer({
data-tool={key}
title={label}
aria-label={label}
aria-pressed={tool === "textbox"}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={textboxMenuOpen}
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "textbox" || !textboxMenuOpen;
const reopen = menuIntentAfterClick(tool, "textbox", textboxMenuOpen);
switchTool("textbox");
if (reopen) setTextboxMenuOpen(true);
}}