Compare commits
2 commits
8571ef9aaf
...
e4ecc026b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ecc026b0 | ||
|
|
0681e056fd |
6 changed files with 640 additions and 171 deletions
File diff suppressed because one or more lines are too long
|
|
@ -183,6 +183,26 @@ export default function AnnotationSelectionLayer({
|
||||||
const dragRef = useRef<DragState | null>(null);
|
const dragRef = useRef<DragState | null>(null);
|
||||||
const movedRef = useRef(false);
|
const movedRef = useRef(false);
|
||||||
const latestRef = useRef<AnnotationData | null>(null);
|
const latestRef = useRef<AnnotationData | null>(null);
|
||||||
|
// rAF ของ preview ระหว่างลาก — ยิง onPreview ≤1 ครั้ง/เฟรม (pattern เดียวกับ FIX-8
|
||||||
|
// ของ InkLayer): pointermove ยิงถี่กว่าเฟรมได้ ทุก event ยังคำนวณ+เก็บค่าล่าสุดใน
|
||||||
|
// latestRef เสมอ (ค่าตอน commit ต้องล่าสุดสุด) เฉพาะการ render ที่จัดคิวตามเฟรม
|
||||||
|
const previewRafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
/** ยกเลิก rAF preview ที่ค้าง — เรียกก่อนเคลียร์ preview ทุกทางออกของ drag (ไม่งั้น
|
||||||
|
* callback ยิงตามหลัง ดัน preview เก่ากลับมาตอน dragRef เคลียร์ไปแล้ว) */
|
||||||
|
function cancelPreviewRaf() {
|
||||||
|
if (previewRafRef.current === null) return;
|
||||||
|
cancelAnimationFrame(previewRafRef.current);
|
||||||
|
previewRafRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// เลิกลากกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย (เหมือน InkLayer)
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelPreviewRaf();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
|
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
|
||||||
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
|
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
|
||||||
|
|
@ -395,7 +415,14 @@ export default function AnnotationSelectionLayer({
|
||||||
}
|
}
|
||||||
movedRef.current = true;
|
movedRef.current = true;
|
||||||
latestRef.current = next;
|
latestRef.current = next;
|
||||||
onPreview(next);
|
// render preview ครั้งเดียวต่อ animation frame — callback อ่าน latestRef ตอนเฟรม
|
||||||
|
// ยิงจริง ๆ เพื่อให้ได้ค่าใหม่สุดเสมอแม้ event ถี่ (แก้ลากไม่ smooth 2026-09-29)
|
||||||
|
if (previewRafRef.current === null) {
|
||||||
|
previewRafRef.current = requestAnimationFrame(() => {
|
||||||
|
previewRafRef.current = null;
|
||||||
|
if (dragRef.current && latestRef.current) onPreview(latestRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endDrag(e: ReactPointerEvent<HTMLElement>) {
|
function endDrag(e: ReactPointerEvent<HTMLElement>) {
|
||||||
|
|
@ -403,6 +430,7 @@ export default function AnnotationSelectionLayer({
|
||||||
if (!st) return;
|
if (!st) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
|
cancelPreviewRaf();
|
||||||
onPreview(null);
|
onPreview(null);
|
||||||
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
|
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
|
||||||
movedRef.current = false;
|
movedRef.current = false;
|
||||||
|
|
@ -412,6 +440,7 @@ export default function AnnotationSelectionLayer({
|
||||||
function cancelDrag() {
|
function cancelDrag() {
|
||||||
if (!dragRef.current) return;
|
if (!dragRef.current) return;
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
|
cancelPreviewRaf();
|
||||||
movedRef.current = false;
|
movedRef.current = false;
|
||||||
latestRef.current = null;
|
latestRef.current = null;
|
||||||
onPreview(null);
|
onPreview(null);
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
useOptimistic,
|
||||||
|
useReducer,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
startTransition,
|
startTransition,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,22 +29,13 @@ import {
|
||||||
Circle,
|
Circle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Download,
|
Download,
|
||||||
Eraser,
|
|
||||||
EyeOff,
|
|
||||||
Hand,
|
|
||||||
Highlighter,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MessageSquarePlus,
|
|
||||||
Minus,
|
Minus,
|
||||||
MousePointer2,
|
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
Pencil,
|
Pencil,
|
||||||
Redo2,
|
Redo2,
|
||||||
Shapes,
|
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
Type,
|
|
||||||
Stamp,
|
|
||||||
Undo2,
|
Undo2,
|
||||||
X,
|
X,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
|
|
@ -153,17 +146,16 @@ import {
|
||||||
PanelRow,
|
PanelRow,
|
||||||
} from "./ToolControls";
|
} from "./ToolControls";
|
||||||
|
|
||||||
type Tool =
|
import {
|
||||||
| "select"
|
TOOL_MENU_KEYS,
|
||||||
| "pan"
|
TOOL_SHORTCUTS,
|
||||||
| "highlight"
|
canSwitchTo,
|
||||||
| "draw"
|
menuIntentAfterClick,
|
||||||
| "comment"
|
toolButtonFace,
|
||||||
| "shape"
|
visibleToolsFor,
|
||||||
| "erase"
|
type MenuTool,
|
||||||
| "redact"
|
type Tool,
|
||||||
| "textbox"
|
} from "./toolbar-tools";
|
||||||
| "stamp";
|
|
||||||
|
|
||||||
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
|
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
|
||||||
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
|
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
|
||||||
|
|
@ -376,27 +368,6 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
|
||||||
return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000));
|
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
|
/** แบนเนอร์แนะนำวิธีใช้ tool ที่วางของด้วยคลิกบนหน้า (UX audit 2026-09-22): เดิมเลือก tool
|
||||||
* แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที
|
* แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที
|
||||||
* ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน
|
* ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน
|
||||||
|
|
@ -630,11 +601,14 @@ function PdfPage({
|
||||||
liveMark,
|
liveMark,
|
||||||
liveMarkStyle,
|
liveMarkStyle,
|
||||||
selectPreview,
|
selectPreview,
|
||||||
annotations,
|
annotations: annotationsProp,
|
||||||
replies,
|
replies,
|
||||||
openPinId,
|
openPinId,
|
||||||
ownUserId,
|
ownUserId,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
/** root สั่งเคลียร์ preview ของการลาก (bump counter — ปิด popup/เปลี่ยน selection/ก่อน
|
||||||
|
* คิว update): ทุกครั้งที่ค่าเปลี่ยน หน้าเคลียร์ previewData ท้องถิ่นของตัวเอง */
|
||||||
|
previewClear,
|
||||||
editingCommentId,
|
editingCommentId,
|
||||||
commentEditDraft,
|
commentEditDraft,
|
||||||
onCommentEditDraft,
|
onCommentEditDraft,
|
||||||
|
|
@ -642,7 +616,6 @@ function PdfPage({
|
||||||
textbox,
|
textbox,
|
||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
onPinToggle,
|
onPinToggle,
|
||||||
onAnnotationPreview,
|
|
||||||
onAnnotationUpdate,
|
onAnnotationUpdate,
|
||||||
onAnnotationPatch,
|
onAnnotationPatch,
|
||||||
onSetResolved,
|
onSetResolved,
|
||||||
|
|
@ -689,6 +662,8 @@ function PdfPage({
|
||||||
openPinId: string | null;
|
openPinId: string | null;
|
||||||
ownUserId: string;
|
ownUserId: string;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
|
/** root สั่งเคลียร์ preview ของการลาก (bump counter — ดูตัวแปร previewClear ของ viewer) */
|
||||||
|
previewClear: number;
|
||||||
/** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
|
/** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
|
||||||
editingCommentId: string | null;
|
editingCommentId: string | null;
|
||||||
commentEditDraft: string;
|
commentEditDraft: string;
|
||||||
|
|
@ -699,7 +674,6 @@ function PdfPage({
|
||||||
/** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */
|
/** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */
|
||||||
onSelectAnnotation: (id: string) => void;
|
onSelectAnnotation: (id: string) => void;
|
||||||
onPinToggle: (id: string | null) => void;
|
onPinToggle: (id: string | null) => void;
|
||||||
onAnnotationPreview: (data: AnnotationData | null) => void;
|
|
||||||
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
|
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
|
||||||
/** ตัวควบคุมเล็กของแผง commit เป็น patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว
|
/** ตัวควบคุมเล็กของแผง commit เป็น patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว
|
||||||
* update กัน stale base ทับค่าที่เพิ่งเปลี่ยน (AnnotationPatch, code review 2026-09-28) */
|
* update กัน stale base ทับค่าที่เพิ่งเปลี่ยน (AnnotationPatch, code review 2026-09-28) */
|
||||||
|
|
@ -720,6 +694,22 @@ function PdfPage({
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const textLayerRef = useRef<HTMLDivElement | null>(null);
|
const textLayerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// ระหว่างลากย้าย/ปรับขนาดรายการที่เลือก: data ชั่วคราวให้ layer เดิมของชนิดนั้น render
|
||||||
|
// ตาม (null = เลิก preview) — state อยู่ที่หน้านี้ ไม่ใช่ root ของ viewer: ต่อ pointermove
|
||||||
|
// ตอนลากต้อง re-render เฉพาะหน้าที่ลากเท่านั้น (เดิม setPreviewData อยู่ root แล้วแทนค่า
|
||||||
|
// กลับลงมาให้ทุกหน้า = จ่าย render ทั้งต้นไม้ต่อเฟรม จนลากเป็น step — แก้ 2026-09-29)
|
||||||
|
// เก็บพร้อม id เจ้าของไม่อิง selectedId ตอน substitute: การเคลียร์จาก root มาเป็น
|
||||||
|
// passive effect หลัง paint — อิง selectedId แล้วจอสัมผัสเปลี่ยน selection กลาง drag
|
||||||
|
// (นิ้วหนึ่งลากค้าง อีกนิ้วเลือกอื่น) จะเอา preview ของรายการเก่าไปทดแทนรายการใหม่
|
||||||
|
// หนึ่งเฟรม (code review 2026-09-29)
|
||||||
|
const [preview, setPreview] = useState<{ id: string; data: AnnotationData } | null>(null);
|
||||||
|
|
||||||
|
// root สั่งเคลียร์ preview ผ่าน counter (ปิด popup/เปลี่ยน selection/ก่อนคิว update) —
|
||||||
|
// ครั้งแรกตอน mount preview ยัง null อยู่แล้ว จึงเป็น no-op
|
||||||
|
useEffect(() => {
|
||||||
|
setPreview(null);
|
||||||
|
}, [previewClear]);
|
||||||
|
|
||||||
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
|
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
|
||||||
const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null;
|
const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null;
|
||||||
const pdfDoc = content.kind === "pdf" ? content.doc : null;
|
const pdfDoc = content.kind === "pdf" ? content.doc : null;
|
||||||
|
|
@ -791,6 +781,15 @@ function PdfPage({
|
||||||
if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0);
|
if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0);
|
||||||
}, [content.kind, pageNumber]);
|
}, [content.kind, pageNumber]);
|
||||||
|
|
||||||
|
// ระหว่างลาก: แทน data ของรายการที่เลือกด้วย preview ก่อนแยกไป layer ต่าง ๆ +
|
||||||
|
// กรอบ selection (เดิมแทนตอน root ส่ง prop — ย้ายลงมาพร้อม state ของหน้า, 2026-09-29)
|
||||||
|
// เทียบ id กับเจ้าของ preview โดยตรง (ไม่ใช่ selectedId ปัจจุบัน) — ดู comment ของ state
|
||||||
|
const annotations = preview
|
||||||
|
? annotationsProp.map((a) =>
|
||||||
|
a.id === preview.id ? { ...a, data: preview.data } : a,
|
||||||
|
)
|
||||||
|
: annotationsProp;
|
||||||
|
|
||||||
const highlights = annotations.filter(isHighlight);
|
const highlights = annotations.filter(isHighlight);
|
||||||
const inkStrokes: InkStroke[] = annotations
|
const inkStrokes: InkStroke[] = annotations
|
||||||
.filter(isDraw)
|
.filter(isDraw)
|
||||||
|
|
@ -1188,7 +1187,8 @@ function PdfPage({
|
||||||
editable={ownUserId !== "" && selected.userId === ownUserId}
|
editable={ownUserId !== "" && selected.userId === ownUserId}
|
||||||
pageBaseWidth={pageBaseWidth}
|
pageBaseWidth={pageBaseWidth}
|
||||||
disabled={overlayFrozen}
|
disabled={overlayFrozen}
|
||||||
onPreview={onAnnotationPreview}
|
// ผูก id เจ้าของ preview ตอนรับค่า (ไม่ใช่ตอน substitute) — ดู comment ของ state preview
|
||||||
|
onPreview={(d) => setPreview(d && selected ? { id: selected.id, data: d } : null)}
|
||||||
onCommit={onAnnotationUpdate}
|
onCommit={onAnnotationUpdate}
|
||||||
onDelete={onErase}
|
onDelete={onErase}
|
||||||
onBookmark={(y) => onAnnotationBookmark(selected, y)}
|
onBookmark={(y) => onAnnotationBookmark(selected, y)}
|
||||||
|
|
@ -1388,7 +1388,11 @@ export default function PDFViewer({
|
||||||
const bookmarkSavedTimerRef = useRef<number | null>(null);
|
const bookmarkSavedTimerRef = useRef<number | null>(null);
|
||||||
/** L6: สถานะกำลังบันทึกของปุ่มใน popup bookmark — กันกดซ้ำเพราะคิดว่ากดไม่เข้า */
|
/** L6: สถานะกำลังบันทึกของปุ่มใน popup bookmark — กันกดซ้ำเพราะคิดว่ากดไม่เข้า */
|
||||||
const [bookmarkSaving, setBookmarkSaving] = useState(false);
|
const [bookmarkSaving, setBookmarkSaving] = useState(false);
|
||||||
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
|
// "เคลียร์ preview ของการลาก" จาก root — preview เป็น state ท้องถิ่นของหน้า (PdfPage)
|
||||||
|
// ตั้งแต่ 2026-09-29 root จึงสั่งเคลียร์ผ่าน counter นี้แทน setter ตรง ๆ: ทุกจุดที่เดิม
|
||||||
|
// setPreviewData(null) (ปิด popup/เปลี่ยน selection/ก่อนคิว update — กัน preview เก่าค้าง
|
||||||
|
// ทับรายการที่เลือกใหม่) bump ค่านี้ แล้วทุกหน้า effect เคลียร์ previewData ตัวเอง
|
||||||
|
const [previewClear, bumpPreviewClear] = useReducer((c: number) => c + 1, 0);
|
||||||
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
||||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||||
const [commentEditDraft, setCommentEditDraft] = useState("");
|
const [commentEditDraft, setCommentEditDraft] = useState("");
|
||||||
|
|
@ -1536,11 +1540,7 @@ export default function PDFViewer({
|
||||||
if (!anyMenuOpen) return;
|
if (!anyMenuOpen) return;
|
||||||
const close = () => {
|
const close = () => {
|
||||||
markMenuClosed(); // M4: เลื่อนแถบ/resize จนเมนูปิด — tap ถัดไปใกล้ ๆ ไม่สร้างรายการ
|
markMenuClosed(); // M4: เลื่อนแถบ/resize จนเมนูปิด — tap ถัดไปใกล้ ๆ ไม่สร้างรายการ
|
||||||
setStampMenuOpen(false);
|
closeToolMenus();
|
||||||
setShapeMenuOpen(false);
|
|
||||||
setDrawMenuOpen(false);
|
|
||||||
setTextboxMenuOpen(false);
|
|
||||||
setHighlightMenuOpen(false);
|
|
||||||
setBookmarkComposer(null);
|
setBookmarkComposer(null);
|
||||||
};
|
};
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
|
|
@ -1615,7 +1615,11 @@ export default function PDFViewer({
|
||||||
// ระหว่างอยู่ tool มาร์กข้อความแล้วผลว่าเป็นไฟล์สแกน (เช่น กด H ตอนหน้าแรกยัง
|
// ระหว่างอยู่ tool มาร์กข้อความแล้วผลว่าเป็นไฟล์สแกน (เช่น กด H ตอนหน้าแรกยัง
|
||||||
// ไม่ render) — กลับเครื่องมือเลือกให้ ไม่ค้างเป็น tool ที่ปุ่มหายไปแล้ว
|
// ไม่ render) — กลับเครื่องมือเลือกให้ ไม่ค้างเป็น tool ที่ปุ่มหายไปแล้ว
|
||||||
useEffect(() => {
|
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]);
|
}, [scannedOnly]);
|
||||||
|
|
||||||
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
|
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
|
||||||
|
|
@ -1689,6 +1693,9 @@ export default function PDFViewer({
|
||||||
// เปลี่ยน = Next reuse component เดิม ไม่ reset state): tool นี้ถูกซ่อนบนไฟล์
|
// เปลี่ยน = Next reuse component เดิม ไม่ reset state): tool นี้ถูกซ่อนบนไฟล์
|
||||||
// ภาพ ต้องกลับไปเครื่องมือเลือก ไม่งั้นค้างเป็น tool ที่กดไม่ได้
|
// ภาพ ต้องกลับไปเครื่องมือเลือก ไม่งั้นค้างเป็น tool ที่กดไม่ได้
|
||||||
setTool((prev) => (prev === "highlight" ? "select" : prev));
|
setTool((prev) => (prev === "highlight" ? "select" : prev));
|
||||||
|
// ปุ่ม highlight ถูกซ่อนบนไฟล์ภาพ — เมนูที่เปิดค้างจากไฟล์เดิมจะลอยไร้ปุ่มยึด
|
||||||
|
// ปิดด้วยชุดเดียวกับ switchTool (PLAN-TOOLBAR.md ขั้น 2)
|
||||||
|
closeToolMenus();
|
||||||
const objectUrls: string[] = [];
|
const objectUrls: string[] = [];
|
||||||
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
||||||
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
|
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
|
||||||
|
|
@ -1939,6 +1946,40 @@ export default function PDFViewer({
|
||||||
function markMenuClosed() {
|
function markMenuClosed() {
|
||||||
menuClosedAtRef.current = Date.now();
|
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);
|
||||||
|
bumpPreviewClear();
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setTextboxComposer(null);
|
||||||
|
setEditingTextboxId(null);
|
||||||
|
}
|
||||||
// Major 1 (code review): ความกว้าง viewport ตอนเปิดเมนูล่าสุด — resize ของมือถือจาก URL bar
|
// Major 1 (code review): ความกว้าง viewport ตอนเปิดเมนูล่าสุด — resize ของมือถือจาก URL bar
|
||||||
// ยุบ/กาง หรือคีย์บอร์ดขึ้น เปลี่ยนแค่ความสูง (เมนู fixed ยึด sticky header ยังอยู่กับที่)
|
// ยุบ/กาง หรือคีย์บอร์ดขึ้น เปลี่ยนแค่ความสูง (เมนู fixed ยึด sticky header ยังอยู่กับที่)
|
||||||
// จะปิดเมนูเฉพาะเมื่อความกว้างเปลี่ยน (หมุนจอ/split view) เท่านั้น
|
// จะปิดเมนูเฉพาะเมื่อความกว้างเปลี่ยน (หมุนจอ/split view) เท่านั้น
|
||||||
|
|
@ -1953,11 +1994,10 @@ export default function PDFViewer({
|
||||||
const [grabbing, setGrabbing] = useState(false);
|
const [grabbing, setGrabbing] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const menus = [
|
const menus = [
|
||||||
{ open: stampMenuOpen, ref: stampMenuRef, close: () => setStampMenuOpen(false) },
|
...TOOL_MENU_KEYS.map((key) => {
|
||||||
{ open: shapeMenuOpen, ref: shapeMenuRef, close: () => setShapeMenuOpen(false) },
|
const m = TOOL_MENU_TABLE[key];
|
||||||
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
|
return { open: m.open, ref: m.ref, close: () => m.setOpen(false) };
|
||||||
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
|
}),
|
||||||
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
|
|
||||||
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
|
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
|
||||||
];
|
];
|
||||||
if (!menus.some((m) => m.open)) return;
|
if (!menus.some((m) => m.open)) return;
|
||||||
|
|
@ -1983,7 +2023,7 @@ export default function PDFViewer({
|
||||||
document.removeEventListener("mousedown", onMouseDown);
|
document.removeEventListener("mousedown", onMouseDown);
|
||||||
document.removeEventListener("keydown", onKey);
|
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,
|
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
|
||||||
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
|
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
|
||||||
|
|
@ -2024,19 +2064,15 @@ export default function PDFViewer({
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
if (inForm) return; // ฟอร์ม (เช่น composer) จัดการ Esc เอง
|
if (inForm) return; // ฟอร์ม (เช่น composer) จัดการ Esc เอง
|
||||||
setComposer(null);
|
// ชุดเดียวกับ switchTool — จุด enumerate setter ด้วยมือจุดสุดท้ายของฝั่ง popup
|
||||||
setOpenPinId(null);
|
closePagePopups();
|
||||||
setSelectedId(null);
|
|
||||||
setPreviewData(null);
|
|
||||||
setEditingCommentId(null);
|
|
||||||
setTextboxComposer(null);
|
|
||||||
setEditingTextboxId(null);
|
|
||||||
setBookmarkComposer(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
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();
|
e.preventDefault();
|
||||||
if (e.shiftKey) void h.redoHistory();
|
if (e.shiftKey) void h.redoHistory();
|
||||||
else void h.undoHistory();
|
else void h.undoHistory();
|
||||||
|
|
@ -2045,29 +2081,24 @@ export default function PDFViewer({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.altKey || inForm) return;
|
if (e.altKey || inForm) return;
|
||||||
const k = e.key.toLowerCase();
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
if (k === "delete" || k === "backspace") {
|
|
||||||
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
|
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
|
||||||
h.deleteSelected();
|
h.deleteSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กด B เปิด popup bookmark แล้ว
|
// คีย์ลัดจับด้วย e.code (ตำแหน่งปุ่ม) ไม่ใช่ e.key — layout ไทยพิมพ์ "ภ"/"น"
|
||||||
// React focus input ทันทีใน keydown เดียวกัน browser จึงพิมพ์ "b" ตามลงไป
|
// ไม่ใช่ "h"/"b" ทำให้ทั้งชุดตายบน layout ไทย (PLAN-TOOLBAR #1)
|
||||||
if (k !== "h" && k !== "d" && k !== "c" && k !== "b") return;
|
const shortcutTool = TOOL_SHORTCUTS[e.code];
|
||||||
e.preventDefault();
|
if (shortcutTool) {
|
||||||
switch (k) {
|
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กดคีย์ลัดแล้ว React focus
|
||||||
case "h": // H = ไฮไลต์ — สลับไป tool highlight (คลุมข้อความ = สร้างทันที)
|
// ช่องใหม่ทันทีใน keydown เดียวกัน browser จึงพิมพ์ตัวอักษรตามลงไป
|
||||||
h.switchTool("highlight");
|
e.preventDefault();
|
||||||
break;
|
h.switchTool(shortcutTool);
|
||||||
case "d":
|
return;
|
||||||
h.switchTool("draw");
|
}
|
||||||
break;
|
if (e.code === "KeyB") {
|
||||||
case "c":
|
e.preventDefault();
|
||||||
h.switchTool("comment");
|
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
|
||||||
break;
|
|
||||||
case "b":
|
|
||||||
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
@ -2139,15 +2170,11 @@ export default function PDFViewer({
|
||||||
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
|
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
|
||||||
if (AUTO_SELECT_TYPES.has(type)) {
|
if (AUTO_SELECT_TYPES.has(type)) {
|
||||||
setSelectedId(saved.id);
|
setSelectedId(saved.id);
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
// แผง selection กำลังจะโผล่ = ปิดเมนู tool/popup bookmark ที่เปิดค้างก่อน — บน iOS
|
// แผง selection กำลังจะโผล่ = ปิดเมนู tool/popup bookmark ที่เปิดค้างก่อน — บน iOS
|
||||||
// การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนู
|
// การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนู
|
||||||
// เอง (Android/desktop ปิดไปแล้วตั้งแต่เริ่มกด) ไม่ปิดตรงนี้ = เมนูซ้อนแผงบนจอสัมผัส
|
// เอง (Android/desktop ปิดไปแล้วตั้งแต่เริ่มกด) ไม่ปิดตรงนี้ = เมนูซ้อนแผงบนจอสัมผัส
|
||||||
setStampMenuOpen(false);
|
closeToolMenus();
|
||||||
setShapeMenuOpen(false);
|
|
||||||
setDrawMenuOpen(false);
|
|
||||||
setTextboxMenuOpen(false);
|
|
||||||
setHighlightMenuOpen(false);
|
|
||||||
setBookmarkComposer(null);
|
setBookmarkComposer(null);
|
||||||
}
|
}
|
||||||
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
|
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
|
||||||
|
|
@ -2191,12 +2218,12 @@ export default function PDFViewer({
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending.cancelled = true;
|
pending.cancelled = true;
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
|
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
await startTransition(async () => {
|
await startTransition(async () => {
|
||||||
applyAnnotation({ kind: "remove", id });
|
applyAnnotation({ kind: "remove", id });
|
||||||
try {
|
try {
|
||||||
|
|
@ -2252,7 +2279,7 @@ export default function PDFViewer({
|
||||||
if (!user) return resolve();
|
if (!user) return resolve();
|
||||||
const current = annotations.find((a) => a.id === id);
|
const current = annotations.find((a) => a.id === id);
|
||||||
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
|
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
applyAnnotation({
|
applyAnnotation({
|
||||||
|
|
@ -2439,11 +2466,11 @@ export default function PDFViewer({
|
||||||
// ปิดเมนู tool ที่เปิดอยู่ก่อน (เช่นคีย์ลัด B ระหว่างเมนูเปิด) — popup bookmark (z เดียวกัน
|
// ปิดเมนู tool ที่เปิดอยู่ก่อน (เช่นคีย์ลัด B ระหว่างเมนูเปิด) — popup bookmark (z เดียวกัน
|
||||||
// ทั้งคู่ fixed ใต้แถบ tool) เปิดซ้อนบนเมนูได้; ไม่ markMenuClosed เพราะ B เป็นคีย์บอร์ด
|
// ทั้งคู่ fixed ใต้แถบ tool) เปิดซ้อนบนเมนูได้; ไม่ markMenuClosed เพราะ B เป็นคีย์บอร์ด
|
||||||
// ไม่มี click หลงเหลือให้กลืน
|
// ไม่มี click หลงเหลือให้กลืน
|
||||||
setStampMenuOpen(false);
|
closeToolMenus();
|
||||||
setShapeMenuOpen(false);
|
// เคลียร์ popup บนหน้าที่ค้างเปิดด้วย (popover ของ pin/แผง selection/editor ข้อความ) —
|
||||||
setDrawMenuOpen(false);
|
// ไม่งั้น popup bookmark ซ้อนของเก่า และ autoFocus ของช่องชื่อแย่ง focus จาก editor
|
||||||
setTextboxMenuOpen(false);
|
// ที่ยังเปิดอยู่ (finding #2 ของ PLAN-TOOLBAR.md)
|
||||||
setHighlightMenuOpen(false);
|
closePagePopups();
|
||||||
const pos = currentViewPosition();
|
const pos = currentViewPosition();
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
setBookmarkError(null);
|
setBookmarkError(null);
|
||||||
|
|
@ -2460,6 +2487,11 @@ export default function PDFViewer({
|
||||||
/** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น
|
/** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น
|
||||||
* ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้น = "หน้า N" */
|
* ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้น = "หน้า N" */
|
||||||
function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) {
|
function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) {
|
||||||
|
// ผ่านชุดเคลียร์เดียวกับ openBookmarkComposer — ปิดเมนู+popup บนหน้า (แผง selection/
|
||||||
|
// editor ที่กำลังเปิดอยู่นั้นเอง) ก่อนเปิด popup bookmark กันซ้อนกันและ autoFocus
|
||||||
|
// แย่ง focus (code review ขั้น 2 — ทางเปิด popup bookmark ทางเดียวที่เหลือ)
|
||||||
|
closeToolMenus();
|
||||||
|
closePagePopups();
|
||||||
const meaningful =
|
const meaningful =
|
||||||
a.type === "HIGHLIGHT" ||
|
a.type === "HIGHLIGHT" ||
|
||||||
a.type === "TEXTBOX" ||
|
a.type === "TEXTBOX" ||
|
||||||
|
|
@ -2937,7 +2969,7 @@ export default function PDFViewer({
|
||||||
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
|
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
|
||||||
if (selectedId !== null) {
|
if (selectedId !== null) {
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
}
|
}
|
||||||
if (tool === "comment" && user) {
|
if (tool === "comment" && user) {
|
||||||
const position = getPointerPercent(e, pageEl);
|
const position = getPointerPercent(e, pageEl);
|
||||||
|
|
@ -3021,7 +3053,7 @@ export default function PDFViewer({
|
||||||
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
|
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
|
||||||
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
|
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
|
||||||
setSelectedId(a.id);
|
setSelectedId(a.id);
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
|
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
|
||||||
const el = pageWrapRefs.current[a.pageIndex];
|
const el = pageWrapRefs.current[a.pageIndex];
|
||||||
const main = mainRef.current;
|
const main = mainRef.current;
|
||||||
|
|
@ -3050,7 +3082,7 @@ export default function PDFViewer({
|
||||||
/** คลิกรายการ overlay (TEXTBOX ฯลฯ) = เลือกรายการนั้น (F1) */
|
/** คลิกรายการ overlay (TEXTBOX ฯลฯ) = เลือกรายการนั้น (F1) */
|
||||||
function handleSelectAnnotation(id: string) {
|
function handleSelectAnnotation(id: string) {
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
setPreviewData(null);
|
bumpPreviewClear();
|
||||||
// เลือกรายการอื่น = ปิด popover ของ pin เดิม (คลิก pin จะไม่ผ่านที่นี่ — มัน toggle เอง)
|
// เลือกรายการอื่น = ปิด popover ของ pin เดิม (คลิก pin จะไม่ผ่านที่นี่ — มัน toggle เอง)
|
||||||
setOpenPinId(null);
|
setOpenPinId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -3110,30 +3142,26 @@ export default function PDFViewer({
|
||||||
|
|
||||||
function switchTool(t: Tool) {
|
function switchTool(t: Tool) {
|
||||||
// ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
|
// ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
|
||||||
// ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H จึงกันตรงนี้ให้จบ (สลับ tool อื่นไม่กระทบ)
|
// ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H: กดแล้วไม่สลับ tool แต่ยังเคลียร์เมนู/
|
||||||
if (t === "highlight" && hideHighlightTool) return;
|
// popup/selection เหมือนสลับ tool จริง — ไม่ปล่อย no-op เงียบสนิทจนนึกว่าคีย์ลัด
|
||||||
setTool(t);
|
// ไม่ทำงาน (finding #4 ของ PLAN-TOOLBAR.md)
|
||||||
|
const blocked = !canSwitchTo(t, hideHighlightTool);
|
||||||
|
if (!blocked) setTool(t);
|
||||||
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
|
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
|
||||||
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
|
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
|
||||||
|
// ⚠️ anyMenuOpen อ่านจาก closure ของ render ปัจจุบัน — เวิร์กเพราะ outside-click
|
||||||
|
// ผูก mousedown ซึ่ง flush ก่อน click ของปุ่มยิงถึงเสมอ อย่าขยับ ordering นี้
|
||||||
if (anyMenuOpen) markMenuClosed();
|
if (anyMenuOpen) markMenuClosed();
|
||||||
setStampMenuOpen(false);
|
closeToolMenus();
|
||||||
setShapeMenuOpen(false);
|
// popup บนหน้า (รวม popup bookmark) ปิดตาม — สลับ tool แล้ว popup ค้างเปิดจะซ้อนกับ
|
||||||
setDrawMenuOpen(false);
|
// สิ่งที่ทำต่อ (anyMenuOpen นับ bookmarkComposer อยู่แล้ว พฤติกรรม M4 เท่าเดิม)
|
||||||
setTextboxMenuOpen(false);
|
closePagePopups();
|
||||||
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);
|
|
||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
// M3: เข้า tool highlight บนจอสัมผัส (ปุ่มหรือคีย์ H) — โชว์ hint ตามโควตาครั้งแรก
|
// 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;
|
coarseHlHintCountRef.current += 1;
|
||||||
setCoarseHlHintVisible(true);
|
setCoarseHlHintVisible(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3158,7 +3186,7 @@ export default function PDFViewer({
|
||||||
// คีย์ลัด H ที่ switchTool และ reset tool ที่ค้างอยู่ (effect ของแต่ละกรณี)
|
// คีย์ลัด H ที่ switchTool และ reset tool ที่ค้างอยู่ (effect ของแต่ละกรณี)
|
||||||
const isImageFile = fileKindFromUrl(file.pdfUrl) === "image";
|
const isImageFile = fileKindFromUrl(file.pdfUrl) === "image";
|
||||||
const hideHighlightTool = isImageFile || scannedOnly;
|
const hideHighlightTool = isImageFile || scannedOnly;
|
||||||
const visibleTools = hideHighlightTool ? TOOLS.filter((t) => t.key !== "highlight") : TOOLS;
|
const visibleTools = visibleToolsFor(hideHighlightTool);
|
||||||
|
|
||||||
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่
|
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่
|
||||||
const drawStyle = {
|
const drawStyle = {
|
||||||
|
|
@ -3388,20 +3416,21 @@ export default function PDFViewer({
|
||||||
|
|
||||||
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
|
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
|
||||||
if (key === "highlight") {
|
if (key === "highlight") {
|
||||||
const btnText = active
|
const face = toolButtonFace(
|
||||||
? (HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.label ?? text)
|
active,
|
||||||
: text;
|
{ label, text },
|
||||||
|
HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.label,
|
||||||
|
);
|
||||||
const CurrentIcon = active
|
const CurrentIcon = active
|
||||||
? (HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon)
|
? (HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon)
|
||||||
: Icon;
|
: Icon;
|
||||||
const btnTitle = active ? `${label} — ${btnText}` : label;
|
|
||||||
return (
|
return (
|
||||||
<div key={key} ref={highlightMenuRef} className="relative">
|
<div key={key} ref={highlightMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tool={key}
|
data-tool={key}
|
||||||
title={btnTitle}
|
title={face.title}
|
||||||
aria-label={btnTitle}
|
aria-label={face.title}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={highlightMenuOpen}
|
aria-expanded={highlightMenuOpen}
|
||||||
|
|
@ -3411,13 +3440,13 @@ export default function PDFViewer({
|
||||||
// เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด
|
// เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด
|
||||||
// (editor/แผง selection/popover) ไม่ให้ซ้อนกับเมนู; จำ intent เปิด/พับ
|
// (editor/แผง selection/popover) ไม่ให้ซ้อนกับเมนู; จำ intent เปิด/พับ
|
||||||
// ไว้ก่อนเพราะ switchTool ปิดเมนูทุกตัวก่อน
|
// ไว้ก่อนเพราะ switchTool ปิดเมนูทุกตัวก่อน
|
||||||
const reopen = tool !== "highlight" || !highlightMenuOpen;
|
const reopen = menuIntentAfterClick(tool, "highlight", highlightMenuOpen);
|
||||||
switchTool("highlight");
|
switchTool("highlight");
|
||||||
if (reopen) setHighlightMenuOpen(true);
|
if (reopen) setHighlightMenuOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CurrentIcon className="h-4 w-4" />
|
<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" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{highlightMenuOpen &&
|
{highlightMenuOpen &&
|
||||||
|
|
@ -3484,16 +3513,15 @@ export default function PDFViewer({
|
||||||
|
|
||||||
// DRAW: ปุ่มวาดเส้น = dropdown เลือกชนิดปากกา (ปากกา/ไฮไลต์)
|
// DRAW: ปุ่มวาดเส้น = dropdown เลือกชนิดปากกา (ปากกา/ไฮไลต์)
|
||||||
if (key === "draw") {
|
if (key === "draw") {
|
||||||
|
const face = toolButtonFace(active, { label, text }, DRAW_PENS[drawPen].label);
|
||||||
const CurrentPenIcon = active ? DRAW_PEN_ICONS[drawPen] : Icon;
|
const CurrentPenIcon = active ? DRAW_PEN_ICONS[drawPen] : Icon;
|
||||||
const btnText = active ? DRAW_PENS[drawPen].label : text;
|
|
||||||
const btnTitle = active ? `${label} — ${btnText}` : label;
|
|
||||||
return (
|
return (
|
||||||
<div key={key} ref={drawMenuRef} className="relative">
|
<div key={key} ref={drawMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tool={key}
|
data-tool={key}
|
||||||
title={btnTitle}
|
title={face.title}
|
||||||
aria-label={btnTitle}
|
aria-label={face.title}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={drawMenuOpen}
|
aria-expanded={drawMenuOpen}
|
||||||
|
|
@ -3501,13 +3529,13 @@ export default function PDFViewer({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
openToolMenu(e.currentTarget);
|
openToolMenu(e.currentTarget);
|
||||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||||
const reopen = tool !== "draw" || !drawMenuOpen;
|
const reopen = menuIntentAfterClick(tool, "draw", drawMenuOpen);
|
||||||
switchTool("draw");
|
switchTool("draw");
|
||||||
if (reopen) setDrawMenuOpen(true);
|
if (reopen) setDrawMenuOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CurrentPenIcon className="h-4 w-4" />
|
<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" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{drawMenuOpen &&
|
{drawMenuOpen &&
|
||||||
|
|
@ -3594,17 +3622,18 @@ export default function PDFViewer({
|
||||||
|
|
||||||
// F5/B1: ปุ่มรูปทรง = dropdown เลือกชนิดรูปทรง
|
// F5/B1: ปุ่มรูปทรง = dropdown เลือกชนิดรูปทรง
|
||||||
if (key === "shape") {
|
if (key === "shape") {
|
||||||
const btnText = active
|
const face = toolButtonFace(
|
||||||
? (SHAPE_KIND_BTNS.find((k) => k.key === shapeKind)?.label ?? text)
|
active,
|
||||||
: text;
|
{ label, text },
|
||||||
const btnTitle = active ? `${label} — ${btnText}` : label;
|
SHAPE_KIND_BTNS.find((k) => k.key === shapeKind)?.label,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div key={key} ref={shapeMenuRef} className="relative">
|
<div key={key} ref={shapeMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tool={key}
|
data-tool={key}
|
||||||
title={btnTitle}
|
title={face.title}
|
||||||
aria-label={btnTitle}
|
aria-label={face.title}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={shapeMenuOpen}
|
aria-expanded={shapeMenuOpen}
|
||||||
|
|
@ -3612,13 +3641,13 @@ export default function PDFViewer({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
openToolMenu(e.currentTarget);
|
openToolMenu(e.currentTarget);
|
||||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||||
const reopen = tool !== "shape" || !shapeMenuOpen;
|
const reopen = menuIntentAfterClick(tool, "shape", shapeMenuOpen);
|
||||||
switchTool("shape");
|
switchTool("shape");
|
||||||
if (reopen) setShapeMenuOpen(true);
|
if (reopen) setShapeMenuOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<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" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{shapeMenuOpen &&
|
{shapeMenuOpen &&
|
||||||
|
|
@ -3683,12 +3712,11 @@ export default function PDFViewer({
|
||||||
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — ตอน active เลือกคำกำหนด
|
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — ตอน active เลือกคำกำหนด
|
||||||
// เองแล้วปุ่มแสดง "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือก)
|
// เองแล้วปุ่มแสดง "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือก)
|
||||||
if (key === "stamp") {
|
if (key === "stamp") {
|
||||||
const btnText = active
|
const face = toolButtonFace(
|
||||||
? STAMP_PRESETS.includes(stampPreset)
|
active,
|
||||||
? stampPreset
|
{ label, text },
|
||||||
: "กำหนดเอง"
|
STAMP_PRESETS.includes(stampPreset) ? stampPreset : "กำหนดเอง",
|
||||||
: text;
|
);
|
||||||
const btnTitle = active ? `${label} — ${btnText}` : label;
|
|
||||||
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
|
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
|
||||||
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
|
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
|
||||||
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
|
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
|
||||||
|
|
@ -3701,8 +3729,8 @@ export default function PDFViewer({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tool={key}
|
data-tool={key}
|
||||||
title={btnTitle}
|
title={face.title}
|
||||||
aria-label={btnTitle}
|
aria-label={face.title}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={stampMenuOpen}
|
aria-expanded={stampMenuOpen}
|
||||||
|
|
@ -3710,7 +3738,7 @@ export default function PDFViewer({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
openToolMenu(e.currentTarget);
|
openToolMenu(e.currentTarget);
|
||||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||||
const reopen = tool !== "stamp" || !stampMenuOpen;
|
const reopen = menuIntentAfterClick(tool, "stamp", stampMenuOpen);
|
||||||
switchTool("stamp");
|
switchTool("stamp");
|
||||||
if (reopen) {
|
if (reopen) {
|
||||||
setStampMenuOpen(true);
|
setStampMenuOpen(true);
|
||||||
|
|
@ -3719,7 +3747,7 @@ export default function PDFViewer({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<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" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{stampMenuOpen &&
|
{stampMenuOpen &&
|
||||||
|
|
@ -3931,14 +3959,14 @@ export default function PDFViewer({
|
||||||
data-tool={key}
|
data-tool={key}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={tool === "textbox"}
|
aria-pressed={active}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={textboxMenuOpen}
|
aria-expanded={textboxMenuOpen}
|
||||||
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
|
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
openToolMenu(e.currentTarget);
|
openToolMenu(e.currentTarget);
|
||||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||||
const reopen = tool !== "textbox" || !textboxMenuOpen;
|
const reopen = menuIntentAfterClick(tool, "textbox", textboxMenuOpen);
|
||||||
switchTool("textbox");
|
switchTool("textbox");
|
||||||
if (reopen) setTextboxMenuOpen(true);
|
if (reopen) setTextboxMenuOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|
@ -4396,18 +4424,12 @@ export default function PDFViewer({
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
selectPreview={selectPreview}
|
selectPreview={selectPreview}
|
||||||
annotations={visibleAnnotations
|
annotations={visibleAnnotations.filter((a) => a.pageIndex === i)}
|
||||||
.filter((a) => a.pageIndex === i)
|
|
||||||
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
|
|
||||||
.map((a) =>
|
|
||||||
a.id === selectedId && previewData
|
|
||||||
? { ...a, data: previewData }
|
|
||||||
: a,
|
|
||||||
)}
|
|
||||||
replies={visibleReplies}
|
replies={visibleReplies}
|
||||||
openPinId={openPinId}
|
openPinId={openPinId}
|
||||||
ownUserId={user?.userId ?? ""}
|
ownUserId={user?.userId ?? ""}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
|
previewClear={previewClear}
|
||||||
editingCommentId={editingCommentId}
|
editingCommentId={editingCommentId}
|
||||||
commentEditDraft={commentEditDraft}
|
commentEditDraft={commentEditDraft}
|
||||||
onCommentEditDraft={setCommentEditDraft}
|
onCommentEditDraft={setCommentEditDraft}
|
||||||
|
|
@ -4422,7 +4444,6 @@ export default function PDFViewer({
|
||||||
}}
|
}}
|
||||||
onSelectAnnotation={handleSelectAnnotation}
|
onSelectAnnotation={handleSelectAnnotation}
|
||||||
onPinToggle={handlePinToggle}
|
onPinToggle={handlePinToggle}
|
||||||
onAnnotationPreview={setPreviewData}
|
|
||||||
onAnnotationUpdate={(id, data) => {
|
onAnnotationUpdate={(id, data) => {
|
||||||
void handleUpdateAnnotation(id, data);
|
void handleUpdateAnnotation(id, data);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
108
components/pdf/toolbar-tools.ts
Normal file
108
components/pdf/toolbar-tools.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// pure module ของแถบ tool หน้า annotate — ย้ายจาก PDFViewer.tsx แบบ
|
||||||
|
// behavior-preserving (PLAN-TOOLBAR.md ขั้น 1) ไม่มี React/state เพื่อ unit
|
||||||
|
// test ได้ (tests/components/toolbar-tools.test.ts)
|
||||||
|
import {
|
||||||
|
Eraser,
|
||||||
|
EyeOff,
|
||||||
|
Hand,
|
||||||
|
Highlighter,
|
||||||
|
MessageSquarePlus,
|
||||||
|
MousePointer2,
|
||||||
|
Pencil,
|
||||||
|
Shapes,
|
||||||
|
Stamp,
|
||||||
|
Type,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export type Tool =
|
||||||
|
| "select"
|
||||||
|
| "pan"
|
||||||
|
| "highlight"
|
||||||
|
| "draw"
|
||||||
|
| "comment"
|
||||||
|
| "shape"
|
||||||
|
| "erase"
|
||||||
|
| "redact"
|
||||||
|
| "textbox"
|
||||||
|
| "stamp";
|
||||||
|
|
||||||
|
export type ToolEntry = { key: Tool; label: string; text: string; Icon: LucideIcon };
|
||||||
|
|
||||||
|
/** 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 เหมือนปุ่มอื่น */
|
||||||
|
export const TOOLS: ToolEntry[] = [
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** N3 ไฟล์ภาพ + PDF สแกนล้วน: ไม่มีข้อความให้คลุม — ซ่อน tool มาร์กข้อความ
|
||||||
|
* (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบ (hideHighlightTool ของ PDFViewer) */
|
||||||
|
export function visibleToolsFor(hideHighlight: boolean): ToolEntry[] {
|
||||||
|
return hideHighlight ? TOOLS.filter((t) => t.key !== "highlight") : TOOLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
|
||||||
|
* ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H จึงกันที่ switchTool ให้จบ (สลับ tool
|
||||||
|
* อื่นไม่กระทบ) */
|
||||||
|
export function canSwitchTo(t: Tool, hideHighlight: boolean): boolean {
|
||||||
|
return !(t === "highlight" && hideHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** หน้าตาปุ่ม tool dropdown ที่มีค่าย่อย (มาร์กข้อความ/วาดเส้น/รูปทรง/ตรายาง) —
|
||||||
|
* ไม่ active → text ชื่อ tool + title = label เหมือนปุ่มเดี่ยวอื่น;
|
||||||
|
* active + มีค่า (ค่าที่จะใช้จริงเมื่อวาด เช่น ชื่อปากกา/preset ตรายาง "กำหนดเอง")
|
||||||
|
* → text = ค่า + title = "label — ค่า" ตาม pattern เดิม;
|
||||||
|
* ไม่ส่งค่า (textbox ไม่มีค่าย่อย) → ชื่อ tool แม้ active */
|
||||||
|
export function toolButtonFace(
|
||||||
|
active: boolean,
|
||||||
|
tool: Pick<ToolEntry, "label" | "text">,
|
||||||
|
value?: string | null,
|
||||||
|
): { text: string; title: string } {
|
||||||
|
const showValue = active && value != null;
|
||||||
|
return {
|
||||||
|
text: showValue ? value : tool.text,
|
||||||
|
title: showValue ? `${tool.label} — ${value}` : tool.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** จำ intent เปิด/พับเมนูของปุ่ม dropdown ก่อนเรียก switchTool (ซึ่งปิดเมนูทุกตัว):
|
||||||
|
* กดปุ่มจาก tool อื่น = เปิดเมนูของ tool ที่เพิ่ง active, tool เดิม+เมนูปิด = เปิด,
|
||||||
|
* tool เดิม+เมนูเปิด = พับ (toggle) */
|
||||||
|
export function menuIntentAfterClick(current: Tool, key: Tool, menuOpen: boolean): boolean {
|
||||||
|
return current !== key || !menuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tool ที่ปุ่มเป็น dropdown มีเมนูของตัวเอง — ทุกจุดที่ "ปิดเมนูทั้งชุด" (switchTool/
|
||||||
|
* outside-click/resize/auto-select/เปิด popup bookmark/reset tool ตรง) ต้องวนจากชุด
|
||||||
|
* ค่านี้จุดเดียว: แต่ก่อน enumerate setter ด้วยมือ 6 แห่ง จนเมนูใหม่/พฤติกรรมใหม่ลืม
|
||||||
|
* แห่งเดียว = drift เงียบ (เคยเกิดจริง: effect เลื่อนปุ่ม active หาปุ่มไม่เจอเงียบ ๆ
|
||||||
|
* ตั้งแต่ 2026-09-23) — ฝั่ง PDFViewer ผูก setter/ref เป็น Record<MenuTool,…> ให้
|
||||||
|
* ลืมแถว = compile error, ฝั่งนี้ test cross-check กับ TOOLS */
|
||||||
|
export const TOOL_MENU_KEYS = ["highlight", "draw", "shape", "textbox", "stamp"] as const satisfies readonly Tool[];
|
||||||
|
export type MenuTool = (typeof TOOL_MENU_KEYS)[number];
|
||||||
|
|
||||||
|
/** คีย์ลัดสลับ tool (F3) — จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่มตาม layout
|
||||||
|
* สหรัฐ) ไม่ใช่ `e.key` (ตัวอักษรที่ layout ปัจจุบันจะพิมพ์): ผู้ใช้ศาลส่วนใหญ่สลับ
|
||||||
|
* keyboard เป็นภาษาไทย ซึ่งปุ่มเดียวกันให้ e.key = "ภ" ไม่ใช่ "h" — เช็ค e.key
|
||||||
|
* ทำให้คีย์ลัดทั้งชุดใช้ไม่ได้เลยบน layout ไทย (PLAN-TOOLBAR.md finding #1,
|
||||||
|
* 2026-09-29) */
|
||||||
|
export const TOOL_SHORTCUTS: Readonly<Record<string, Tool>> = {
|
||||||
|
KeyH: "highlight",
|
||||||
|
KeyD: "draw",
|
||||||
|
KeyC: "comment",
|
||||||
|
};
|
||||||
155
docs/PLAN-TOOLBAR.md
Normal file
155
docs/PLAN-TOOLBAR.md
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
# PLAN-TOOLBAR — Test + แก้ไข logic แถบ tool ของหน้า annotate
|
||||||
|
|
||||||
|
> สร้าง 2026-09-29 จาก TDD session — ขั้นตอนไหนจะทำเมื่อไรให้รอคำสั่ง
|
||||||
|
> (อ้างอิงบรรทัดจาก HEAD 8571ef9)
|
||||||
|
>
|
||||||
|
> **ความคืบหน้า:** ขั้น 1 + ขั้น 2 (รวม findings #2/#5) + findings #1/#3/#4/#6
|
||||||
|
> เสร็จแล้ว 2026-09-29 (ยังไม่ commit) — เหลือเฉพาะขั้น 4 (รออาการ bug เฉพาะ)
|
||||||
|
> และรายการ "จดไว้" #7–#10, #13–#14
|
||||||
|
|
||||||
|
## สถานะปัจจุบัน
|
||||||
|
|
||||||
|
- **ขั้น 1 ทำเสร็จ 2026-09-29:** spec test ฉบับแรกถูก revert ไปพร้อมโค้ดรอบก่อน
|
||||||
|
(git มีแต่แผนนี้) จึงเขียนใหม่ตามเกณฑ์เดิม (literal จาก rules + 8571ef9
|
||||||
|
ไม่คัดลอกสูตร — กัน tautological) → ยืนยันแดงที่ import ก่อน implement →
|
||||||
|
สร้าง `components/pdf/toolbar-tools.ts` + rewire PDFViewer + ตัด icon imports
|
||||||
|
ว่าง 9 ตัว — vitest **204/204** + `tsc --noEmit` สะอาด + code-reviewer ผ่าน
|
||||||
|
(ไม่มี findings ต้องแก้/ควรแก้ — จุดจดไว้อยู่ท้ายตารางขั้น 3 #11–#12)
|
||||||
|
- **ขั้น 2 + findings #2/#5 ทำเสร็จ 2026-09-29:** ทางเลือก (ก) — `TOOL_MENU_KEYS`
|
||||||
|
+ `type MenuTool` ใน toolbar-tools.ts, `TOOL_MENU_TABLE` (Record บังคับครบทุก
|
||||||
|
key = เพิ่มเมนูลืมแถวก็ compile fail) + `closeToolMenus()`/`closePagePopups()`
|
||||||
|
ใน PDFViewer ใช้ร่วมครบ 6 กลุ่มจุดเดิม (switchTool/outside-click/เลื่อนแถบ·
|
||||||
|
resize/auto-select/openBookmarkComposer/reset tool ตรง 2 จุดที่เดิมไม่เคลียร์
|
||||||
|
เลย — scannedOnly + สลับไฟล์ภาพ) — finding #2 แก้: `openBookmarkComposer`
|
||||||
|
เรียก `closePagePopups()` ก่อนเปิด popup bookmark — test เพิ่ม TOOL_MENU_KEYS
|
||||||
|
cross-check + truth table เคสที่ 4 (**205/205**) + e2e Playwright 8/8
|
||||||
|
(กดแก้ไข comment → กด B = popover/editor ปิดก่อน popup bookmark โผล่ + focus
|
||||||
|
ย้ายเข้าช่องชื่อ; เมนู highlight ค้าง → สลับไฟล์ภาพ = ปุ่มหาย เมนูไม่ค้าง) +
|
||||||
|
sync rules/pdf-viewer.md 2 จุด
|
||||||
|
- **findings #1 แก้เสร็จ 2026-09-29:** คีย์ลัดจับคู่ด้วย `e.code` แทน `e.key` —
|
||||||
|
`TOOL_SHORTCUTS` (KeyH/KeyD/KeyC → tool) ใน toolbar-tools.ts, handler ใช้
|
||||||
|
`e.code` ครบ H/D/C/B **+ Ctrl+Z undo/redo** (ขยายครอบตามเหตุผลเดียวกัน —
|
||||||
|
layout ไทยให้ e.key "ฎ" ทำให้ undo ตายเหมือนกัน), delete/backspace ใช้
|
||||||
|
canonical `e.key` ต่อ (เหมือนทุก layout) — unit 207/207 (เพิ่ม 2 เคส
|
||||||
|
TOOL_SHORTCUTS + cross-check คู่ label `(H)/(D)/(C)` กัน drift) + e2e
|
||||||
|
synthetic Thai keydown (e.key อักษรไทย + e.code ตำแหน่งปุ่ม) **6/6** +
|
||||||
|
rules sync (bullet คีย์ลัด)
|
||||||
|
- **แก้ตาม code review ขั้น 2 (รอบเดียวกัน):** ไม่มี bug — แก้ 3 จุด: (a) ⚠️
|
||||||
|
`handleAnnotationBookmark` (ปุ่ม bookmark ในแผง selection N2 — ทางเปิด popup
|
||||||
|
bookmark ทางเดียวที่ยังไม่ผ่านชุดเคลียร์) เพิ่ม `closeToolMenus()` +
|
||||||
|
`closePagePopups()` ต้น function ให้จบ finding #2 ครบทุกทาง (b) ตัด unused
|
||||||
|
import `TOOLS` ที่ขั้น 1 ตกค้าง (c) ก้อน Esc ของคีย์ลัด (7 setter) แทนด้วย
|
||||||
|
`closePagePopups()` — reviewer เทียบ 1:1 เป๊ะ behavior-preserving — จุด
|
||||||
|
enumerate setter ด้วยมือฝั่ง popup หมดสิ้น — tsc + 205/205 + e2e rerun เคส
|
||||||
|
A (B/Esc path) **5/5**
|
||||||
|
- ขั้น 3 ขึ้นไป: codebase ยังไม่ถูกแตะ รอคำสั่ง
|
||||||
|
- reviewer 2 ตัว (code-reviewer + plan-reviewer) ตรวจพื้นที่ toolbar แล้ว —
|
||||||
|
สรุป findings ในขั้น 3
|
||||||
|
|
||||||
|
## ขั้น 1 — ทำ spec แดงให้เขียว: สร้าง `components/pdf/toolbar-tools.ts`
|
||||||
|
|
||||||
|
pure module (ไม่มี React/state) export 5 อย่างตาม spec:
|
||||||
|
|
||||||
|
| API | เนื้อที่ย้ายจาก PDFViewer.tsx (behavior-preserving 100%) |
|
||||||
|
|---|---|
|
||||||
|
| `type Tool` + `ToolEntry` | 156-166 |
|
||||||
|
| `TOOLS` + comment เต็ม | 379-398 |
|
||||||
|
| `visibleToolsFor(hide)` | 3161 |
|
||||||
|
| `canSwitchTo(t, hide)` | guard 3114 |
|
||||||
|
| `toolButtonFace(active, {label,text}, value?)` | pattern เดียวกัน 3391-3397 / 3487-3489 / 3597-3600 / 3686-3691 |
|
||||||
|
| `menuIntentAfterClick(cur, key, open)` | `reopen` 3414 / 3504 / 3615 / 3713 / 3941 |
|
||||||
|
|
||||||
|
Rewire ใน PDFViewer: import แทน definition เดิม + แทน inline ternary 4 ปุ่ม
|
||||||
|
dropdown ด้วย `toolButtonFace` + แทน `reopen` 5 จุด + **ตัด icon imports ที่ว่าง
|
||||||
|
9 ตัว** (Eraser/EyeOff/Hand/Highlighter/MessageSquarePlus/MousePointer2/Shapes/
|
||||||
|
Type/Stamp — เหลือ Pencil, LucideIcon ที่ยังใช้อื่น)
|
||||||
|
|
||||||
|
**Verify:** test 11 เคสเขียว + ทั้งชุด 204/204 + `tsc --noEmit` สะอาด
|
||||||
|
(เคย implement ผ่านเต็มมาแล้วรอบหนึ่งก่อนคืนตามคำสั่ง — ขั้นตอนนี้ทำซ้ำได้เลย)
|
||||||
|
|
||||||
|
## ขั้น 2 — switchTool cascade: รวมชุด "ปิดเมนู/popup" ที่เขียนซ้ำ 6 แห่ง
|
||||||
|
|
||||||
|
**ปัญหา:** ชุด `set*MenuOpen(false)` ×5 enumerate ด้วยมือซ้ำกัน 6 จุด —
|
||||||
|
3118-3126 (switchTool), 1968-1971 (outside-click), 1538-1544 (เลื่อนแถบ/resize),
|
||||||
|
2442-2446 (openBookmarkComposer), 2146-2151 (auto-select), + setTool ตรง 2 จุด
|
||||||
|
(1617-1619 scannedOnly, 1691 สลับไฟล์ภาพ) ที่**ไม่เคลียร์อะไรเลย** → เพิ่มเมนู
|
||||||
|
ใหม่/แก้พฤติกรรมแล้วลืมแห่งเดียว = drift เงียบ (เคยเกิดจริง: effect เลื่อนปุ่ม
|
||||||
|
active ใช้ title match เสียเงียบตั้งแต่ 2026-09-23 — commit 8571ef9)
|
||||||
|
|
||||||
|
**ทางเลือก (เลือกตอน implement):**
|
||||||
|
- **(ก) เบา — แนะนำ:** helper เดียวใน toolbar-tools.ts เช่น `TOOL_MENU_KEYS`
|
||||||
|
+ `resetMenus()`/`resetPagePopups()` ใช้ร่วมทั้ง 6 จุด; test ยืนยัน helper ครบ
|
||||||
|
ทุกเมนูที่มี dropdown (cross-check กับ TOOLS — กันลืมตัวใหม่)
|
||||||
|
- **(ข) หนัก:** ย้ายก้อน state menu/popup (~10 ตัว) ไป `useReducer` เดียว —
|
||||||
|
test ครบทั้ง transition แต่ diff ใหญ่ในไฟล์ ~4,000 บรรทัด เสี่ยงเกินผล
|
||||||
|
|
||||||
|
**แก้พวงในขั้นเดียวกัน (ต้องแตะจุดเดียวกัน):**
|
||||||
|
- `openBookmarkComposer` (2438-2458) ปิดเมนูแต่**ไม่เคลียร์ popup บนหน้า**
|
||||||
|
(`editingCommentId`/`openPinId`/`selectedId`/`composer`/`textboxComposer`/
|
||||||
|
`editingTextboxId`) → popup bookmark ซ้อน popover/editor ที่ค้างเปิด +
|
||||||
|
autoFocus แย่ง focus — ผิดหลัก "popup เปิดได้ทีละชุด" (rules ข้อ 30)
|
||||||
|
- setTool ตรง 2 จุด (1617-1619, 1691) ให้ผ่านชุดเคลียร์เดียวกัน — edge:
|
||||||
|
เมนู highlight ค้างเปิด → ไฟล์จริงเป็นสแกนล้วน → ปุ่มหายแต่เมนู portal
|
||||||
|
ยังลอยไร้ปุ่มยึด
|
||||||
|
- จุด `anyMenuOpen` อ่านจาก closure ของ render (3118) — ปัจจุบันเวิร์กเพราะ
|
||||||
|
outside-click flush ก่อนเสมอ แต่ไม่ enforced ที่จุดเดียว; ใส่ comment
|
||||||
|
กำกับ ordering นี้ตอนรวม helper
|
||||||
|
|
||||||
|
**Verify:** test helper ครบทุกเมนู + เดินมือ/Playwright: กด "แก้ไข" comment →
|
||||||
|
กด B = popover ปิดก่อน popup bookmark โผล่; เปิดเมนู highlight → เปิดไฟล์ภาพ
|
||||||
|
(สลับ dynamic param) = เมนูไม่ค้าง
|
||||||
|
|
||||||
|
## ขั้น 3 — findings จาก reviewer แปลงเป็นรายการแก้ (จัดลำดับ)
|
||||||
|
|
||||||
|
| # | ประเด็น | ที่ | แนวทาง | ลำดับ |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | ~~**คีย์ลัด H/D/C/B ใช้ไม่ได้บน layout ภาษาไทย**~~ **แก้เสร็จ 2026-09-29** — `TOOL_SHORTCUTS` (map `e.code`→tool) ใน toolbar-tools.ts + handler ใช้ `e.code` ทั้ง H/D/C/B และ **Ctrl+Z undo/redo ด้วย** (ขยายครอบตามเหตุผลเดียวกัน — e.key บน layout ไทยเป็น "ฎ" ทำให้ undo ตายเหมือนกัน) + test cross-check คู่ label `(H)/(D)/(C)` + e2e synthetic Thai keydown 6/6 | เดิม 2056-2070 | — | **เสร็จ** |
|
||||||
|
| 2 | popup bookmark ซ้อน editor (รายละเอียดขั้น 2) | 2438-2458 | รวมกับขั้น 2 | สูง (ทำพร้อมขั้น 2) |
|
||||||
|
| 3 | ~~โควตา coarse hint 3 ครั้ง นับเกิน~~ **แก้เสร็จ 2026-09-29** — นับเฉพาะ `enteringHighlight` (เพิ่งสลับเข้าจาก tool อื่น — กดปุ่ม dropdown เปิด/พับเมนูตอน active อยู่แล้ว และกด H ซ้ำ ไม่กินโควตา) | เดิม 3136-3138 | — | **เสร็จ** |
|
||||||
|
| 4 | ~~กด H บนไฟล์ภาพ/สแกนล้วน = no-op เงียบสนิท~~ **แก้เสร็จ 2026-09-29 (ทางเบา)** — เก็บ tool เดิมไว้ แต่ผ่านชุดเคลียร์เมนู+popup+selection เหมือนสลับ tool จริง (toast บอกเหตุผลเป็นทางเลือกที่**ยังไม่ทำ** — จิตวิทยา minimal ของผู้ใช้) | เดิม 3114 | — | **เสร็จ** |
|
||||||
|
| 5 | โครงซ้ำ 6 แห่ง (ขั้น 2) | ดูขั้น 2 | ขั้น 2 | สูง (พร้อมขั้น 2) |
|
||||||
|
| 6 | ~~`aria-pressed={tool === "textbox"}` ไม่ใช้ตัวแปร `active`~~ **แก้เสร็จ 2026-09-29** — เปลี่ยนเป็น `active` พร้อมชุด #3/#4 | เดิม 3934 | — | **เสร็จ** |
|
||||||
|
| 7 | `aria-pressed` คู่ `aria-haspopup="menu"` ชนกันตาม WAI-ARIA (toggle vs menu button) ทั้ง 5 ปุ่ม | 3405-3407 ฯลฯ | พิจารณาคง `aria-expanded` เดี่ยว — **เป็น behavior change ต้องขออนุมัติแยก** | ต่ำ/เลือกได้ |
|
||||||
|
| 8 | `liveMark`/`selectPreview` เคลียร์ผ่าน side effect ของ `removeAllRanges()` → `selectionchange` → effect ไม่ใช่ state ตรง — เวิร์กแต่ coupling ซ่อนอยู่ | 2681-2735, 3134 | ไม่แก้ — บันทึกความเสี่ยงไว้ใน comment ใกล้ switchTool | จดไว้ |
|
||||||
|
| 9 | ทางทฤษฎี: ความกว้าง `main` เปลี่ยนโดย window คงเดิม ไม่ปิดเมนู (ทางเข้าสลับ sidebar ผ่าน outside-click ก่อนเสมอจึงไม่เจอจริง) | 1546-1548 | ไม่แก้ — จดไว้ | จดไว้ |
|
||||||
|
| 10 | เคสจิ๋ว: แอนิเมชันเลื่อนปุ่ม active (guard 600ms) กลืน scroll ของผู้ใช้ → เมนูเพี้ยนจากปุ่ม | 1552, 1586 | ต้อง track จบจริง (`scrollend`) — แพงเกินปัญหา | จดไว้ |
|
||||||
|
| 11 | ช่องว่าง exhaustiveness ของ BTNS arrays: ถ้าขยาย `HighlightStyle`/`ShapeKind` ใน schema โดยไม่เพิ่มแถว `HIGHLIGHT_STYLE_BTNS`/`SHAPE_KIND_BTNS` (array ธรรมดา ไม่มี type บังคับครอบ union) ปุ่ม active จะแสดง `text` + title `label` เฉย ๆ (เดิม: title `"label — text"`) — ทางแก้ยาว: BTNS เป็น `Record<HighlightStyle, …>` หรือ `satisfies` เต็ม union | ToolControls.tsx:280, PDFViewer (SHAPE_KIND_BTNS) | ไม่แก้ตอนนี้ (ขัด behavior-preserving ของขั้น 1) — แก้เมื่อขั้นไหนแตะ BTNS | จดไว้ (code review ขั้น 1) |
|
||||||
|
| 12 | ~~test `menuIntentAfterClick` ยังไม่ครอบ truth table เคสที่ 4~~ **แก้แล้ว 2026-09-29 ระหว่างขั้น 2** | tests/components/toolbar-tools.test.ts | — | เสร็จ |
|
||||||
|
| 13 | deps ของ outside-click effect เป็น spread array `[...TOOL_MENU_KEYS.map(...)]` — runtime ถูกต้อง (ความยาว/ลำดับคงที่ ค่า primitive) แต่ react-hooks/exhaustive-deps lint มองไม่เห็นค่าใน spread — โปรเจกต์ไม่มี lint script จึงไร้ผลตอนนี้ | PDFViewer outside-click effect deps | จดไว้กันงงถ้าอนาคตเปิด eslint | จดไว้ (code review ขั้น 2) |
|
||||||
|
| 14 | `closeToolMenus`/`TOOL_MENU_TABLE` นิยามหลังจุดใช้เมื่อไล่อ่านจากบน (เรียกครั้งแรกที่ effect resize ~1538 นิยามที่ ~1962) — ไม่ใช่ bug (hoisted + effect รัน post-render + setter เสถียร) แค่อ่านยาก | PDFViewer | ย้ายขึ้กลุ่ม state เมนูเมื่อแตะจุดนั้นครั้งถัดไป | จดไว้ (code review ขั้น 2) |
|
||||||
|
| 15 | Ctrl+Alt+Z / Cmd+Option+Z ยิง undo เพิ่มจาก HEAD ในบาง layout (Windows จำลอง AltGr เป็น Ctrl+Alt — HEAD เช็ค e.key ที่ AltGr+Z แผ่อักขระพิเศษจึงไม่ตรง แต่ e.code ยิงเสมอ) — แทบไม่เกิดกับ layout ไทย/อังกฤษ ไม่ทำลายอะไร | PDFViewer ctrl branch | ถ้าต้องกัน: `!e.altKey` ในเงื่อนไข KeyZ บรรทัดเดียว | จดไว้ (code review #1/#3/#4/#6) |
|
||||||
|
| 16 | `TOOL_SHORTCUTS[e.code]` type เป็น `Tool` ไม่ใช่ `Tool \| undefined` (ไม่มี `noUncheckedIndexedAccess`) — ปลอดภัยเพราะมี guard runtime แต่ถอด guard ทิ้ง tsc ไม่ช่วยจับ | toolbar-tools.ts | เปลี่ยนเป็น `Partial<Record<string, Tool>>` ถ้าอยากให้ compiler บังคับ | จดไว้ (code review #1/#3/#4/#6) |
|
||||||
|
| 17 | `e.code` บนคีย์บอร์ดจอ (software keyboard) ของ iPad เคยมีรายงานว่าเป็นค่าว่างใน WebKit บางเวอร์ชัน — e2e ยืนยันด้วย synthetic event บน desktop เท่านั้น (hardware keyboard ปลอดภัยแน่นอน) | PDFViewer keydown | ถ้าได้รายงาน "คีย์ลัดตายบนคีย์บอร์ดจอ iPad" เช็คจุดนี้อันดับแรก + ทดสอบบนเครื่องจริง | จดไว้ (code review #1/#3/#4/#6) |
|
||||||
|
|
||||||
|
**ช่องว่าง test เพิ่ม (ตาม plan-reviewer):** ค่า face ของ stamp (`"กำหนดเอง"` เมื่อ
|
||||||
|
`stampPreset` ไม่ตรง preset), การ reset ค่ากลับ preset ตอนเลือก pen (3541-3546),
|
||||||
|
ยืนยัน textbox ไม่มีค่าย่อย — เพิ่มใน suite เดียวกันตอนขั้น 1/2; DOM-level
|
||||||
|
(toggle เมนู / outside-click + tap 300ms / Escape สองชั้น / `data-tool` ครบ 10 ปุ่ม)
|
||||||
|
ต้อง jsdom/e2e — แยกเป็นรอบหลัง
|
||||||
|
|
||||||
|
## ขั้น 4 — อาการเฉพาะจากผู้ใช้
|
||||||
|
|
||||||
|
**รอระบุ** — ผู้ใช้เลือกไว้ว่ามีอาการ bug เฉพาะที่เจอเอง แต่ยังไม่ได้บอกรายละเอียด
|
||||||
|
เมื่อได้อาการแล้ว: เพิ่มเป็นหัวข้อ "เคส F" พร้อม test reproduce ก่อนแก้ตามลูป TDD
|
||||||
|
|
||||||
|
## Carve-outs ที่ห้าม "แก้ให้ตรงกติกา" (จาก plan-reviewer — ทำลายได้ถ้ารวมเช่นเดียวกัน)
|
||||||
|
|
||||||
|
1. แถวเดี่ยวเลือกจบ 4 เมนู (3451-3455, 3541-3549, 3650-3654, 3869-3873) +
|
||||||
|
`finishCustom` (3695-3698) **ปิดเมนูไม่ผ่าน `markMenuClosed` โดยเจตนา** —
|
||||||
|
tap ถัดไปต้องวางรายการได้ทันที (กลืน tap 300ms จะฆ่า flow วางตรายาง)
|
||||||
|
2. คีย์ B ปิดเมนูไม่ mark — คีย์บอร์ดไม่มี click หลงเหลือ
|
||||||
|
3. Escape สองชั้น (1973-1979 มี stopPropagation) — ครั้งแรกปิดเมนูอย่างเดียว
|
||||||
|
4. ลำดับ guard ของ `handlePageClick` (2916-2988): isCollapsed → 300ms → ปิด
|
||||||
|
bookmark/pin → deselect → branch ตาม tool — ห้ามสลับ
|
||||||
|
5. `menuOpenWRef` ตั้ง 2 ทาง (openToolMenu + positionBookmarkPopup) — resize
|
||||||
|
เฉพาะความกว้างครอบ popup bookmark ด้วย
|
||||||
|
6. `selfScrollUntilRef` ต้องไม่ trigger การปิดเมนู (1529, 1586)
|
||||||
|
7. `stampCustomMode` reset เฉพาะตอนเปิดเมนูใหม่ผ่านปุ่ม (3717)
|
||||||
|
8. `scannedOnly` เป็น latch ของ `pageSpanCount` — ไม่ลบตอน virtualization unmount
|
||||||
|
|
||||||
|
## ลำดับการทำเมื่ออนุมัติ
|
||||||
|
|
||||||
|
1. ขั้น 1 (test เขียว) → 2. ขั้น 2 + findings #2/#5 → 3. findings #1 (คีย์ลัด
|
||||||
|
ไทย — แจ้ง behavior change ก่อน) → #3 → #4 → #6 → 4. ขั้น 4 เมื่อได้อาการ
|
||||||
|
ทุกขั้นจบด้วย: `tsc --noEmit` สะอาด + vitest ทั้งชุดผ่าน + sync
|
||||||
|
`.claude/rules/pdf-viewer.md` ถ้าพฤติกรรมเปลี่ยน + code review
|
||||||
155
tests/components/toolbar-tools.test.ts
Normal file
155
tests/components/toolbar-tools.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
TOOLS,
|
||||||
|
TOOL_MENU_KEYS,
|
||||||
|
TOOL_SHORTCUTS,
|
||||||
|
canSwitchTo,
|
||||||
|
menuIntentAfterClick,
|
||||||
|
toolButtonFace,
|
||||||
|
visibleToolsFor,
|
||||||
|
} from "@/components/pdf/toolbar-tools";
|
||||||
|
|
||||||
|
// spec ตาม PLAN-TOOLBAR.md ขั้น 1 — ค่า expected เป็น literal จาก
|
||||||
|
// .claude/rules/pdf-viewer.md + พฤติกรรม toolbar ของ commit 8571ef9
|
||||||
|
// (ไม่คัดลอกสูตรจากโค้ดปลายทาง กัน tautological)
|
||||||
|
|
||||||
|
describe("TOOLS", () => {
|
||||||
|
it("ครบ 10 ตัวตาม union และลำดับปุ่มบนแถบ", () => {
|
||||||
|
expect(TOOLS.map((t) => t.key)).toEqual([
|
||||||
|
"select",
|
||||||
|
"pan",
|
||||||
|
"highlight",
|
||||||
|
"draw",
|
||||||
|
"comment",
|
||||||
|
"shape",
|
||||||
|
"erase",
|
||||||
|
"redact",
|
||||||
|
"textbox",
|
||||||
|
"stamp",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("text สั้นแสดงบนปุ่มครบทุกตัว (คำขอผู้ใช้ 2026-09-21: ปุ่มต้องมีชื่อ ไม่ใช่ icon ล้วน)", () => {
|
||||||
|
expect(Object.fromEntries(TOOLS.map((t) => [t.key, t.text]))).toEqual({
|
||||||
|
select: "เลือก",
|
||||||
|
pan: "เลื่อนหน้า",
|
||||||
|
highlight: "มาร์กข้อความ",
|
||||||
|
draw: "วาดเส้น",
|
||||||
|
comment: "คอมเมนต์",
|
||||||
|
shape: "รูปทรง",
|
||||||
|
erase: "ยางลบ",
|
||||||
|
redact: "ปิดพราง",
|
||||||
|
textbox: "กล่องข้อความ",
|
||||||
|
stamp: "ตรายาง",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("label เต็ม (title/aria-label) มีคีย์ลัด H/D/C ตาม rules และทุกตัวมี Icon", () => {
|
||||||
|
const byKey = Object.fromEntries(TOOLS.map((t) => [t.key, t]));
|
||||||
|
expect(byKey.highlight.label).toBe("มาร์กข้อความ (H)");
|
||||||
|
expect(byKey.draw.label).toBe("วาดเส้น (D)");
|
||||||
|
expect(byKey.comment.label).toBe("คอมเมนต์ (C)");
|
||||||
|
for (const t of TOOLS) {
|
||||||
|
expect(t.label.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(t.Icon).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("visibleToolsFor", () => {
|
||||||
|
it("hide=false → ทั้งชุด 10 ตัว", () => {
|
||||||
|
expect(visibleToolsFor(false).map((t) => t.key)).toHaveLength(10);
|
||||||
|
expect(visibleToolsFor(false)[0].key).toBe("select");
|
||||||
|
expect(visibleToolsFor(false).at(-1)?.key).toBe("stamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hide=true (ไฟล์ภาพ/สแกนล้วน) → ตัดเฉพาะ highlight เหลือ 9 ตัว ลำดับเดิม", () => {
|
||||||
|
expect(visibleToolsFor(true).map((t) => t.key)).toEqual([
|
||||||
|
"select",
|
||||||
|
"pan",
|
||||||
|
"draw",
|
||||||
|
"comment",
|
||||||
|
"shape",
|
||||||
|
"erase",
|
||||||
|
"redact",
|
||||||
|
"textbox",
|
||||||
|
"stamp",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canSwitchTo", () => {
|
||||||
|
it("highlight บนไฟล์ที่ซ่อนปุ่ม (ภาพ/สแกนล้วน) → false กันคีย์ลัด H", () => {
|
||||||
|
expect(canSwitchTo("highlight", true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlight บนไฟล์ปกติ → true", () => {
|
||||||
|
expect(canSwitchTo("highlight", false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool อื่นทุกตัวสลับได้แม้ไฟล์ภาพ/สแกนล้วน", () => {
|
||||||
|
for (const t of TOOLS.map((x) => x.key)) {
|
||||||
|
if (t === "highlight") continue;
|
||||||
|
expect(canSwitchTo(t, true)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toolButtonFace", () => {
|
||||||
|
it("ไม่ active → ชื่อ tool เสมอแม้มีค่าย่อย (คำขอผู้ใช้ 2026-09-29: 'วาดเส้น' ต้องไม่กลายเป็น 'ปากกา')", () => {
|
||||||
|
expect(toolButtonFace(false, { label: "วาดเส้น (D)", text: "วาดเส้น" }, "ปากกา")).toEqual({
|
||||||
|
text: "วาดเส้น",
|
||||||
|
title: "วาดเส้น (D)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active + มีค่าย่อย → แสดงค่าที่เลือก + title 'label — ค่า' (stamp คำกำหนดเองก็เป็นค่าหนึ่ง)", () => {
|
||||||
|
expect(
|
||||||
|
toolButtonFace(true, { label: "ตรายาง (stamp)", text: "ตรายาง" }, "กำหนดเอง"),
|
||||||
|
).toEqual({ text: "กำหนดเอง", title: "ตรายาง (stamp) — กำหนดเอง" });
|
||||||
|
// textbox ไม่มีค่าย่อย — active อยู่ชื่อเดิม ไม่กลายเป็น "กล่องข้อความ — กล่องข้อความ"
|
||||||
|
expect(toolButtonFace(true, { label: "กล่องข้อความ", text: "กล่องข้อความ" })).toEqual({
|
||||||
|
text: "กล่องข้อความ",
|
||||||
|
title: "กล่องข้อความ",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("menuIntentAfterClick", () => {
|
||||||
|
it("กดจาก tool อื่น / tool เดิมที่เมนูปิด = เปิดเมนู; tool เดิมที่เมนูเปิด = พับ", () => {
|
||||||
|
expect(menuIntentAfterClick("select", "highlight", false)).toBe(true);
|
||||||
|
expect(menuIntentAfterClick("select", "highlight", true)).toBe(true);
|
||||||
|
expect(menuIntentAfterClick("highlight", "highlight", false)).toBe(true);
|
||||||
|
expect(menuIntentAfterClick("highlight", "highlight", true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TOOL_MENU_KEYS", () => {
|
||||||
|
it("ครบเฉพาะ tool ที่ปุ่มเป็น dropdown (มาร์กข้อความ/วาดเส้น/รูปทรง/กล่องข้อความ/ตรายาง) — จุดที่ปิดเมนูทั้งชุดวนจากชุดนี้ชุดเดียว", () => {
|
||||||
|
expect([...TOOL_MENU_KEYS].sort()).toEqual(["draw", "highlight", "shape", "stamp", "textbox"]);
|
||||||
|
// ทุก key เป็นค่าของ Tool จริง (กันชื่อคำนวณพลาด)
|
||||||
|
for (const k of TOOL_MENU_KEYS) {
|
||||||
|
expect(TOOLS.some((t) => t.key === k)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TOOL_SHORTCUTS", () => {
|
||||||
|
it("จับคู่ e.code ตำแหน่งปุ่ม (layout-independent) — บน layout ไทย e.key เป็น 'ภ' ก็ยังใช้ได้", () => {
|
||||||
|
expect(TOOL_SHORTCUTS.KeyH).toBe("highlight");
|
||||||
|
expect(TOOL_SHORTCUTS.KeyD).toBe("draw");
|
||||||
|
expect(TOOL_SHORTCUTS.KeyC).toBe("comment");
|
||||||
|
// คีย์อื่น/ตัวอักษร layout ไทยไม่ใช่คีย์ลัด
|
||||||
|
expect(TOOL_SHORTCUTS.KeyZ).toBeUndefined();
|
||||||
|
expect(TOOL_SHORTCUTS.KeyB).toBeUndefined(); // B = bookmark ไม่ใช่ tool
|
||||||
|
expect(TOOL_SHORTCUTS["ภ"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ตรงกับคีย์ลัดที่ label ของ TOOLS ประกาศ ((H)/(D)/(C)) — กัน drift สองฝั่ง", () => {
|
||||||
|
for (const [code, tool] of Object.entries(TOOL_SHORTCUTS)) {
|
||||||
|
const entry = TOOLS.find((t) => t.key === tool);
|
||||||
|
expect(entry, `tool ${tool} ต้องอยู่ใน TOOLS`).toBeTruthy();
|
||||||
|
expect(entry!.label).toContain(`(${code.replace("Key", "")})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue