จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)

(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)

(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่

(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)

(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ

(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)

(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export

(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ

verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-14 18:04:40 +07:00
parent b3b884c736
commit df0392ea45
17 changed files with 1085 additions and 215 deletions

View file

@ -6,7 +6,7 @@ import {
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Trash2 } from "lucide-react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
import type {
AnnotationData,
@ -83,6 +83,7 @@ export default function AnnotationSelectionLayer({
onCommit,
onDelete,
onBookmark,
onStartEdit,
}: {
annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
@ -101,6 +102,9 @@ export default function AnnotationSelectionLayer({
onDelete: (id: string) => void;
/** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */
onBookmark: (yOffsetPct: number) => void;
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
onStartEdit?: (id: string) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
@ -401,7 +405,7 @@ export default function AnnotationSelectionLayer({
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
<div
className="absolute cursor-move touch-none"
className="absolute cursor-move touch-none sel-strip"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2,
@ -415,7 +419,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move touch-none"
className="absolute cursor-move touch-none sel-strip"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2,
@ -429,7 +433,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag}
/>
<div
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
@ -445,7 +449,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag}
/>
<div
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
@ -466,7 +470,7 @@ export default function AnnotationSelectionLayer({
{!isFixedTextbox && corners.map((c) => (
<div
key={c.key}
className="absolute touch-none rounded-sm border border-primary bg-primary/50"
className="absolute touch-none sel-handle rounded-sm border border-primary bg-primary/50"
style={{
left: c.x - HANDLE_PX / 2,
top: c.y - HANDLE_PX / 2,
@ -487,13 +491,15 @@ export default function AnnotationSelectionLayer({
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */}
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */}
{editable && !disabled && (
<div
ref={panelRef}
className="absolute w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
className="absolute w-60 sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
left: boxPx.x,
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
transform: openAbove ? "translateY(-100%)" : undefined,
pointerEvents: "auto",
@ -578,6 +584,18 @@ export default function AnnotationSelectionLayer({
)}
{textboxData && (
<>
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
<PanelRow label="ข้อความ">
<button
type="button"
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100"
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
แก้ไขข้อความ
</button>
</PanelRow>
<PanelRow label="สี">
<ColorSwatches
value={textboxData.color}
@ -660,7 +678,7 @@ export default function AnnotationSelectionLayer({
type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้"
className="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
onClick={() => onBookmark(boxPct.y)}
>
<BookmarkPlus className="h-3.5 w-3.5" />
@ -669,7 +687,7 @@ export default function AnnotationSelectionLayer({
type="button"
aria-label="ลบรายการนี้"
title="ลบ"
className="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50"
className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50"
onClick={() => onDelete(annotation.id)}
>
<Trash2 className="h-3.5 w-3.5" />

View file

@ -2,6 +2,7 @@
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useOptimistic,
@ -11,6 +12,7 @@ import {
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
AlignCenter,
AlignLeft,
@ -42,6 +44,7 @@ import {
Type,
Stamp,
Undo2,
X,
ZoomIn,
ZoomOut,
type LucideIcon,
@ -195,6 +198,21 @@ function viewerHeaderHeight(): number {
return document.querySelector("header")?.offsetHeight ?? 0;
}
/** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) —
* ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */
const TOOL_MENU_W = 240;
const BOOKMARK_MENU_W = 288;
/** ตำแหน่ง fixed ของเมนูที่เปิดจากปุ่มนี้ — ตรงใต้ปุ่ม + clamp ไม่ให้ล้นขอบจอ
* (เมนู render ผ่าน createPortal ที่ document.body ออกจาก scroll container ของแถบ tool) */
function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number } {
const vw = window.innerWidth;
return {
left: clamp(rect.left, 8, Math.max(8, vw - menuW - 8)),
top: rect.bottom + 4,
};
}
/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
const SCROLL_BELOW_HEADER_GAP = 40;
@ -356,9 +374,9 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
{ key: "highlight", label: "มาร์กข้อความ (highlight)", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น", Icon: Pencil },
{ key: "comment", label: "วาง comment", Icon: MessageSquarePlus },
{ key: "highlight", label: "มาร์กข้อความ (H)", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น (D)", Icon: Pencil },
{ key: "comment", label: "วาง comment (C)", Icon: MessageSquarePlus },
{ key: "shape", label: "รูปทรง", Icon: Shapes },
{ key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser },
{ key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff },
@ -366,6 +384,13 @@ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
];
/** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible
* ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน
* ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง
* (z-10 ให้ ring ลอยเหนือปุ่มข้าง ๆ — flex item ใช้ z-index ได้) */
const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
@ -580,6 +605,7 @@ function PdfPage({
onShapeEnd,
onRedactionEnd,
onErase,
onTextLayerRendered,
}: {
pdfjs: typeof import("pdfjs-dist");
doc: import("pdfjs-dist").PDFDocumentProxy;
@ -637,6 +663,9 @@ function PdfPage({
onShapeEnd: (data: ShapeData) => void;
onRedactionEnd: (data: RedactionData) => void;
onErase: (id: string) => void;
/** M6: รายงานจำนวน span ของ textLayer หลัง render หน้าเสร็จ (0 = หน้าสแกนไม่มีข้อความ
* — ใช้แสดง hint ว่าไฮไลต์ไม่ได้) identity ต้องคงที่ กัน effect render รันซ้ำ */
onTextLayerRendered?: (pageIndex: number, spanCount: number) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const textLayerRef = useRef<HTMLDivElement | null>(null);
@ -683,6 +712,8 @@ function PdfPage({
viewport,
});
await textLayer.render();
// M6: รายงาน span count ของหน้านี้ (0 = ไฟล์สแกนไม่มี text layer — hint ไฮไลต์ไม่ได้)
if (!cancelled) onTextLayerRendered?.(pageNumber - 1, container.querySelectorAll("span").length);
})().catch((err) => {
if (!cancelled) {
console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err);
@ -693,6 +724,9 @@ function PdfPage({
cancelled = true;
renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md)
};
// onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร
// เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย
// ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า
}, [pdfjs, doc, pageNumber, width, height]);
const highlights = annotations.filter(isHighlight);
@ -924,7 +958,7 @@ function PdfPage({
<button
type="button"
aria-label="ดู comment"
className={`-translate-x-1/2 -translate-y-full rounded-full border p-1 shadow ${
className={`-translate-x-1/2 -translate-y-full coarse-36 rounded-full border p-1 shadow ${
a.resolved
? "border-emerald-300 bg-emerald-50 text-emerald-600 opacity-60 hover:opacity-100"
: "border-amber-300 bg-amber-100 text-amber-600 hover:bg-amber-200"
@ -987,7 +1021,7 @@ function PdfPage({
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
className="coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
onClick={() => onStartCommentEdit(null)}
>
ยกเลิก
@ -995,7 +1029,7 @@ function PdfPage({
<button
type="button"
disabled={!commentEditDraft.trim()}
className="rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
className="coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
onClick={() => {
const msg = commentEditDraft.trim();
if (!msg) return;
@ -1061,6 +1095,7 @@ function PdfPage({
onCommit={onAnnotationUpdate}
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
onStartEdit={textbox.startEdit}
/>
);
})()}
@ -1112,11 +1147,12 @@ export default function PDFViewer({
const [highlightOpacityPct, setHighlightOpacityPct] = useState(100);
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
const [highlightMenuOpen, setHighlightMenuOpen] = useState(false);
// UX review 2026-09-09 ข้อ 2: เมนู w-60 ของปุ่ม tool ที่อยู่ครึ่งขวาของจอ (หรือ wrap ลงแถวล่าง)
// อาจล้นขอบขวา — วัดตำแหน่งปุ่มตอนคลิก แล้วเลือก anchor left-0/right-0 ให้เมนูอยู่ในจอ
// (anchor ตายตัวใช้ไม่ได้: ปุ่มเดียวกันอยู่ชิดขวาบนจอกว้าง แต่ชิดซ้ายบนจอแคบที่ wrap)
// ค่าเขียนทับใหม่ทุกครั้งที่เปิดเมนู จึงไม่ต้องเคลียร์ตอนปิด
const [menuFlip, setMenuFlip] = useState<Record<string, boolean>>({});
// C1: เมนู tool ทั้ง 5 + popup bookmark render ผ่าน createPortal เป็น position:fixed ตาม
// พิกัดจริงของปุ่มตอนคลิก (fixedMenuPos clamp ไว้ในจอ) — เดิมเป็น absolute ใน scroll
// container ของแถบ tool ที่ต้องสลับ overflow ตอนเปิด ทำให้ scroll position รีเซ็ต เมนู
// เปิดนอกจอบนมือถือ และทั้ง app เอียงเลื่อนแนวนอนได้ / ค่าเขียนใหม่ทุกครั้งที่เปิดเมนู
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null);
const [scale, setScale] = useState(1);
const [user, setUser] = useState<LocalUser | null>(null);
const [currentPage, setCurrentPage] = useState(0);
@ -1244,6 +1280,70 @@ export default function PDFViewer({
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
useEffect(() => {
setUser(getLocalUser());
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
}, []);
// UX review 2026-09-14 M5: จอ < lg แถบ tool เป็นแถวเดียวเลื่อนแนวนอน — เฝ้าว่าเลื่อน
// ถึงท้ายแถวหรือยัง เพื่อซ่อนแถบเงาขวา (บอกว่ายังมีปุ่มถัดไป) เมื่อเลื่อนสุดแล้ว
const toolbarRowRef = useRef<HTMLDivElement | null>(null);
const [toolbarAtEnd, setToolbarAtEnd] = useState(false);
const syncToolbarEnd = useCallback(() => {
const el = toolbarRowRef.current;
if (el) setToolbarAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 4);
}, []);
useEffect(() => {
syncToolbarEnd();
window.addEventListener("resize", syncToolbarEnd);
return () => window.removeEventListener("resize", syncToolbarEnd);
}, [syncToolbarEnd]);
// เมนู tool ใด ๆ เปิดอยู่ — C1: เมนูเป็น position:fixed ผ่าน portal แล้ว แถบ tool คง
// overflow-x-auto เสมอไม่ต้องสลับ overflow — เหลือใช้เป็นเงื่อนไข effect ปิดเมนู
// ตอนเลื่อนแถบ/resize จอ (ตำแหน่ง fixed คำนวณตอนเปิด ไม่ตามปุ่มที่เลื่อนไปทีหลัง)
const anyMenuOpen =
highlightMenuOpen ||
drawMenuOpen ||
shapeMenuOpen ||
stampMenuOpen ||
textboxMenuOpen ||
!!bookmarkComposer;
// C1: ปิดเมนูทั้งหมดเมื่อเลื่อนแถบ tool ต่อ หรือ resize/หมุนจอ — ตำแหน่ง fixed จะเพี้ยน
// จากปุ่มทันทีที่แถบเลื่อน (header sticky → เลื่อนหน้าเอกสารไม่กระทบ ปุ่มไม่ขยับเทียบ viewport)
// Major 1 (code review): resize ของมือถือจาก URL bar/คีย์บอร์ดเปลี่ยนแค่ความสูงและเมนูยัง
// ยึด sticky header อยู่กับที่ — จึงปิดจาก resize เฉพาะเมื่อความกว้างจอเปลี่ยนจากตอนเปิดเท่านั้น
useEffect(() => {
if (!anyMenuOpen) return;
const close = () => {
markMenuClosed(); // M4: เลื่อนแถบ/resize จนเมนูปิด — tap ถัดไปใกล้ ๆ ไม่สร้างรายการ
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
setBookmarkComposer(null);
};
const onResize = () => {
if (window.innerWidth !== menuOpenWRef.current) close();
};
toolbarRowRef.current?.addEventListener("scroll", close, { passive: true });
window.addEventListener("resize", onResize);
return () => {
toolbarRowRef.current?.removeEventListener("scroll", close);
window.removeEventListener("resize", onResize);
};
}, [anyMenuOpen]);
// M6: จำนวน span ของ textLayer ต่อหน้า (0 = หน้าสแกนไม่มีข้อความ) — ใช้แสดง hint ตอน
// tool highlight ว่าไฟล์นี้คลุมข้อความไม่ได้ / เขียนค่าเดิมคืน object เดิมให้ callback
// identity คงที่ (useCallback []) กัน effect render ของทุกหน้ารันซ้ำเพราะ prop เปลี่ยน
const [pageSpanCount, setPageSpanCount] = useState<Record<number, number>>({});
const handleTextLayerRendered = useCallback((pageIndex: number, spanCount: number) => {
setPageSpanCount((prev) =>
prev[pageIndex] === spanCount ? prev : { ...prev, [pageIndex]: spanCount },
);
}, []);
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
@ -1407,6 +1507,27 @@ export default function PDFViewer({
const drawMenuRef = useRef<HTMLDivElement | null>(null);
const textboxMenuRef = useRef<HTMLDivElement | null>(null);
const highlightMenuRef = useRef<HTMLDivElement | null>(null);
// C1: popup bookmark — ref ของปุ่ม 🔖 (คำนวณตำแหน่ง fixed แทนที่จะยึด absolute ใน wrapper)
// + ref ของ wrapper ให้ outside-click รู้จัก "คลิกที่ปุ่ม = ไม่ใช่ข้างนอก"
const bookmarkBtnRef = useRef<HTMLButtonElement | null>(null);
const bookmarkWrapRef = useRef<HTMLDivElement | null>(null);
// M4: เวลาที่เมนู/popup ปิดล่าสุด — tap/คลิกที่ใช้ปิดเมนูยังยิง click ต่อถึงหน้ากระดาษ
// ในลำดับเดียวกัน (mousedown ปิดเมนูก่อน → click ไปถึง page) กลายเป็นวาง comment/
// textbox/stamp โดยไม่ตั้งใจ — handlePageClick จึงเฉย 300ms แรกหลังปิดเมนู
// (pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์จอสัมผัส)
const menuClosedAtRef = useRef(0);
function markMenuClosed() {
menuClosedAtRef.current = Date.now();
}
// Major 1 (code review): ความกว้าง viewport ตอนเปิดเมนูล่าสุด — resize ของมือถือจาก URL bar
// ยุบ/กาง หรือคีย์บอร์ดขึ้น เปลี่ยนแค่ความสูง (เมนู fixed ยึด sticky header ยังอยู่กับที่)
// จะปิดเมนูเฉพาะเมื่อความกว้างเปลี่ยน (หมุนจอ/split view) เท่านั้น
const menuOpenWRef = useRef(0);
/** C1: เปิดเมนู tool ตรงตำแหน่งปุ่ม + จำความกว้างจอตอนเปิด (ใช้แยก resize จากหมุนจอ vs URL bar) */
const openToolMenu = useCallback((btn: HTMLElement) => {
menuOpenWRef.current = window.innerWidth;
setMenuPos(fixedMenuPos(btn.getBoundingClientRect(), TOOL_MENU_W));
}, []);
// tool pan — ลากเลื่อนหน้า: เก็บตำแหน่ง pointer ล่าสุดระหว่างลาก (scrollBy สวนทางการเลื่อน)
const panRef = useRef<{ x: number; y: number } | null>(null);
const [grabbing, setGrabbing] = useState(false);
@ -1417,9 +1538,14 @@ export default function PDFViewer({
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
];
if (!menus.some((m) => m.open)) return;
const onMouseDown = (e: MouseEvent) => {
// เมนู render ผ่าน portal ที่ document.body — ref ของ wrapper มองไม่เห็นโหนดใน portal
// ต้องข้ามก่อน ไม่งั้นคลิกในเมนูถูกนับเป็น "ข้างนอก" แล้วเมนูปิดตัวเอง (C1)
if (e.target instanceof Element && e.target.closest("[data-tool-menu]")) return;
markMenuClosed(); // M4: tap นี้ใช้ปิดเมนู — click ที่ตามมาต้องไม่สร้างรายการ
for (const m of menus) {
if (m.open && !m.ref.current?.contains(e.target as Node)) m.close();
}
@ -1427,6 +1553,7 @@ export default function PDFViewer({
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
markMenuClosed(); // M4: Escape แล้วแตะหน้าเร็ว ๆ ก็ต้องไม่สร้างรายการ
for (const m of menus) m.close();
}
};
@ -1436,7 +1563,7 @@ export default function PDFViewer({
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen]);
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen, bookmarkComposer]);
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
@ -1778,7 +1905,7 @@ export default function PDFViewer({
/** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน —
* บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection แทน — ปุ่มอ่านจาก
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection แทน — ปุ่มอ่านจาก
* snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */
const [touchConfirm, setTouchConfirm] = useState<{
pageIndex: number;
@ -1807,10 +1934,22 @@ export default function PDFViewer({
return depth;
}
/** C1: ตำแหน่ง fixed ของ popup bookmark — จาก rect ของปุ่ม 🔖 (คลิกปุ่ม/ปุ่มในแผง selection/
* คีย์ลัด B เรียกจากไหนก็ยึดปุ่มเดียวกัน เหมือน anchor เดิมที่ absolute ใน wrapper ของปุ่ม) */
const positionBookmarkPopup = useCallback(() => {
menuOpenWRef.current = window.innerWidth;
setBookmarkPos(
bookmarkBtnRef.current
? fixedMenuPos(bookmarkBtnRef.current.getBoundingClientRect(), BOOKMARK_MENU_W)
: { left: Math.max(8, window.innerWidth - BOOKMARK_MENU_W - 8), top: 72 }
);
}, []);
function openBookmarkComposer() {
const pos = currentViewPosition();
if (!pos) return;
setBookmarkError(null);
positionBookmarkPopup();
// ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที)
setBookmarkComposer({
...pos,
@ -1830,6 +1969,7 @@ export default function PDFViewer({
a.type === "COMMENT";
const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "";
setBookmarkError(null);
positionBookmarkPopup();
setBookmarkComposer({
pageIndex: a.pageIndex,
yOffset: clamp(yOffsetPct, 0, 100),
@ -1844,11 +1984,15 @@ export default function PDFViewer({
const el = pageWrapRefs.current[currentPage];
if (!el) return null;
const r = el.getBoundingClientRect();
const centerY = window.scrollY + window.innerHeight / 2;
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
// แถบมองเห็นใต้ header (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) ตอนกด
// bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
const viewTop =
window.scrollY + viewerHeaderHeight() + SCROLL_BELOW_HEADER_GAP;
const pageTop = r.top + window.scrollY;
return {
pageIndex: currentPage,
yOffset: clamp(((centerY - pageTop) / r.height) * 100, 0, 100),
yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
};
}
@ -2016,7 +2160,7 @@ export default function PDFViewer({
return;
}
// จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์" ลอยใต้ selection
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection
// แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์
// ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน)
const coarse = window.matchMedia("(pointer: coarse)").matches;
@ -2174,7 +2318,7 @@ export default function PDFViewer({
}, [tool]);
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
* และจอสัมผัส (ปุ่ม "ไฮไลต์" ลอยใต้ selection) */
* และจอสัมผัส (ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection) */
function commitHighlight(
pageIndex: number,
sel: { rects: HighlightData["rects"]; text: string },
@ -2190,7 +2334,7 @@ export default function PDFViewer({
});
}
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์" ลอยใต้ selection — commit จาก snapshot ใน ref
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — commit จาก snapshot ใน ref
* (ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ก่อน tap จะถึงปุ่ม —
* จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */
function handleTouchConfirmHighlight() {
@ -2282,6 +2426,9 @@ export default function PDFViewer({
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
// M4: tap/คลิกเพื่อ "ปิดเมนู" (outside-click ปิดที่ mousedown ก่อน click ยิงถึงตรงนี้)
// ต้องไม่กลายเป็นการวาง comment/textbox/stamp — คลิกที่สอง (หลัง 300ms) ค่อยวาง
if (Date.now() - menuClosedAtRef.current < 300) return;
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
@ -2349,14 +2496,10 @@ export default function PDFViewer({
if (!el) return;
const r = el.getBoundingClientRect();
const pageTop = r.top + window.scrollY;
// คืนมุมมองตอนบันทึก: จุด yOffset (ซึ่งเก็บจากกึ่งกลางจอตอนสร้าง) กลับไปอยู่
// กึ่งกลางพื้นที่มองเห็นใต้ header — เดิมวางจุดนี้ไว้ที่ขอบบน viewport เลยเลื่อน
// เกินลงล่างครึ่งจอ (bookmark หน้าแรกจึงดูเหมือน "ไม่ขึ้นบนสุด")
const center = (viewerHeaderHeight() + window.innerHeight) / 2;
window.scrollTo({
top: Math.max(0, pageTop + (r.height * b.yOffset) / 100 - center),
behavior: "smooth",
});
// ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
}
function goToAnnotation(a: AnnotationDTO) {
@ -2437,6 +2580,9 @@ export default function PDFViewer({
function switchTool(t: Tool) {
setTool(t);
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
if (anyMenuOpen) markMenuClosed();
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
@ -2504,32 +2650,48 @@ export default function PDFViewer({
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
<aside
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
data-sidebar=""
className={`sticky top-0 flex h-screen w-72 flex-col shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
sidebarOpen
? "max-lg:translate-x-0"
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
}`}
>
<AnnotationSidebar
annotations={visibleAnnotations}
bookmarks={optimisticBookmarks}
fileId={file.id}
userId={user?.userId ?? ""}
userFilter={userFilter}
users={users}
onUserFilterChange={setUserFilter}
replyCounts={replyCounts}
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
onUpdateAnnotation={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
onGoToBookmark={goToBookmark}
onGoToAnnotation={goToAnnotation}
selectedId={selectedId}
/>
{/* M6: ปิด sidebar บนมือถือได้อีกทาง — overlay (z-50) กลบปุ่ม toggle ใน header
(z-40) ไปด้วย เหลือทางออกเดิมคือจิ้ม backdrop 25% ขวา ซึ่งไม่ชัดเท่าปุ่มปิดตรง ๆ */}
<div className="flex items-center justify-end px-2 pt-2 lg:hidden">
<button
type="button"
aria-label="ปิดแผงข้าง"
className={`flex h-11 w-11 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
{/* min-h-0 flex-1 ให้ root h-full ของ sidebar อยู่ในคอลัมน์ flex โดยไม่ล้น h-screen */}
<div className="min-h-0 flex-1">
<AnnotationSidebar
annotations={visibleAnnotations}
bookmarks={optimisticBookmarks}
fileId={file.id}
userId={user?.userId ?? ""}
userFilter={userFilter}
users={users}
onUserFilterChange={setUserFilter}
replyCounts={replyCounts}
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
onUpdateAnnotation={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
onGoToBookmark={goToBookmark}
onGoToAnnotation={goToAnnotation}
selectedId={selectedId}
/>
</div>
</aside>
<div className="min-w-0 flex-1">
@ -2543,13 +2705,14 @@ export default function PDFViewer({
title="สลับแผงข้าง"
aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"}
aria-expanded={sidebarOpen}
className="shrink-0 rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
className={`shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setSidebarOpen((o) => !o)}
>
<PanelLeft className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold">
{/* M6: ซ่อนชื่อไฟล์บนจอเล็ก (แถว title พับ 2 บรรทัดทำ header สูงเกินครึ่งจอ) */}
<h1 className="hidden min-w-0 flex-1 truncate text-sm font-semibold sm:block">
{file.title}
</h1>
@ -2560,7 +2723,7 @@ export default function PDFViewer({
type="button"
aria-label="หน้าก่อนหน้า"
disabled={currentPage <= 0}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => goToPage(currentPage - 1)}
>
<ChevronLeft className="h-4 w-4" />
@ -2572,7 +2735,7 @@ export default function PDFViewer({
type="button"
aria-label="หน้าถัดไป"
disabled={currentPage >= pageSizes.length - 1}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => goToPage(currentPage + 1)}
>
<ChevronRight className="h-4 w-4" />
@ -2582,7 +2745,7 @@ export default function PDFViewer({
<button
type="button"
aria-label="ซูมออก"
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100"
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3))}
>
<ZoomOut className="h-4 w-4" />
@ -2593,7 +2756,7 @@ export default function PDFViewer({
<button
type="button"
aria-label="ซูมเข้า"
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100"
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3))}
>
<ZoomIn className="h-4 w-4" />
@ -2606,25 +2769,36 @@ export default function PDFViewer({
<Link
href={`/annotate/${file.id}/summary`}
title="หน้าสรุปหมายเหตุ"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
>
<ClipboardList className="h-4 w-4" />
</Link>
</div>
</div>
<div className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2">
{/* UX review 2026-09-14 M5: จอ < lg ทั้งแถวเป็นแถวเดียวเลื่อนแนวนอนได้ (wrap เดิม
ทำ header สูง ~32% ของจอมือถือ) — C1: เมนู dropdown/popup bookmark ย้ายไป
createPortal (position:fixed ที่ document.body) แล้ว แถบจึงเป็น overflow-x-auto
เสมอ ไม่ต้องสลับ overflow ตอนเปิดเมนู (สลับแล้ว scroll position รีเซ็ต เมนูเปิด
นอกจอ และทั้ง app เอียงเลื่อนแนวนอนได้) */}
<div className="relative">
<div
ref={toolbarRowRef}
onScroll={syncToolbarEnd}
className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2 max-lg:flex-nowrap max-lg:overflow-x-auto"
>
{/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
UX review 2026-09-09: ปุ่ม wrap ภายในกรอบได้บนจอแคบ */}
<div className="flex flex-wrap rounded-lg border border-slate-300">
UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14:
จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
<div className="flex max-lg:shrink-0 flex-wrap gap-0.5 rounded-lg border border-slate-300 max-lg:flex-nowrap">
{TOOLS.map(({ key, label, Icon }, i) => {
const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
const activeCls = tool === key
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100";
const activeCls = `${
tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100"
} ${FOCUS_RING_CLS}`;
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
if (key === "highlight") {
@ -2638,10 +2812,9 @@ export default function PDFViewer({
aria-label={label}
aria-haspopup="menu"
aria-expanded={highlightMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, highlight: r.left + 248 > window.innerWidth }));
openToolMenu(e.currentTarget);
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
else {
switchTool("highlight");
@ -2652,10 +2825,24 @@ export default function PDFViewer({
<CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{highlightMenuOpen && (
{highlightMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่าการมาร์กข้อความ"
className={`absolute ${menuFlip.highlight ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
// เหนือ aside sidebar/composer (z-50) และ backdrop (z-[45])
zIndex: 60,
// จอเตี้ย (มือถือแนวนอน) เมนูยาวเกินพื้นที่ใต้แถบ → เลื่อนภายในได้
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
>
{HIGHLIGHT_STYLE_BTNS.map(({ key: sKey, label: sLabel, Icon: SIcon }) => (
<button
@ -2688,7 +2875,8 @@ export default function PDFViewer({
onChange={setHighlightOpacityPct}
/>
</PanelRow>
</div>
</div>,
document.body
)}
</div>
);
@ -2705,10 +2893,9 @@ export default function PDFViewer({
aria-label={label}
aria-haspopup="menu"
aria-expanded={drawMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, draw: r.left + 248 > window.innerWidth }));
openToolMenu(e.currentTarget);
if (tool === "draw") setDrawMenuOpen((o) => !o);
else {
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
@ -2719,10 +2906,22 @@ export default function PDFViewer({
<CurrentPenIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{drawMenuOpen && (
{drawMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่าปากกา"
className={`absolute ${menuFlip.draw ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
>
{(Object.keys(DRAW_PENS) as DrawPen[]).map((p) => {
const PenIcon = DRAW_PEN_ICONS[p];
@ -2778,7 +2977,8 @@ export default function PDFViewer({
<p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400">
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
</p>
</div>
</div>,
document.body
)}
</div>
);
@ -2794,10 +2994,9 @@ export default function PDFViewer({
aria-label={label}
aria-haspopup="menu"
aria-expanded={shapeMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, shape: r.left + 248 > window.innerWidth }));
openToolMenu(e.currentTarget);
if (tool === "shape") setShapeMenuOpen((o) => !o);
else {
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
@ -2808,10 +3007,22 @@ export default function PDFViewer({
<Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{shapeMenuOpen && (
{shapeMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่ารูปทรง"
className={`absolute ${menuFlip.shape ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
>
{SHAPE_KIND_BTNS.map(({ key: kind, label: kindLabel, Icon: KindIcon }) => (
<button
@ -2843,7 +3054,8 @@ export default function PDFViewer({
onChange={(v) => setShapeWidthPct(v / 10)}
/>
</PanelRow>
</div>
</div>,
document.body
)}
</div>
);
@ -2859,10 +3071,9 @@ export default function PDFViewer({
aria-label={label}
aria-haspopup="menu"
aria-expanded={stampMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${corner} ${activeCls}`}
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, stamp: r.left + 248 > window.innerWidth }));
openToolMenu(e.currentTarget);
if (tool === "stamp") setStampMenuOpen((o) => !o);
else {
switchTool("stamp");
@ -2873,10 +3084,22 @@ export default function PDFViewer({
<Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{stampMenuOpen && (
{stampMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่าตรายาง"
className={`absolute ${menuFlip.stamp ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
>
{STAMP_PRESETS.map((preset) => (
<button
@ -2909,7 +3132,8 @@ export default function PDFViewer({
onChange={setStampScalePct}
/>
</PanelRow>
</div>
</div>,
document.body
)}
</div>
);
@ -2925,10 +3149,9 @@ export default function PDFViewer({
aria-label={label}
aria-haspopup="menu"
aria-expanded={textboxMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, textbox: r.left + 248 > window.innerWidth }));
openToolMenu(e.currentTarget);
if (tool === "textbox") setTextboxMenuOpen((o) => !o);
else {
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
@ -2939,10 +3162,22 @@ export default function PDFViewer({
<Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
{textboxMenuOpen && (
{textboxMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่ากล่องข้อความ"
className={`absolute ${menuFlip.textbox ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
>
<PanelRow label="สี">
<ColorSwatches value={textboxColor} onChange={setTextboxColor} />
@ -3015,7 +3250,8 @@ export default function PDFViewer({
))}
</div>
</PanelRow>
</div>
</div>,
document.body
)}
</div>
);
@ -3027,7 +3263,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
className={`px-2 py-1.5 transition ${corner} ${activeCls}`}
className={`px-2 py-2 transition ${corner} ${activeCls}`}
onClick={() => switchTool(key)}
>
<Icon className="h-4 w-4" />
@ -3044,14 +3280,17 @@ export default function PDFViewer({
</p>
)}
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack */}
<div className="flex items-center rounded-lg border border-slate-300">
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง
เลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order
ลำดับ/หน้าตาเดิมทุกประการ */}
<div className="flex max-lg:order-first max-lg:shrink-0 items-center rounded-lg border border-slate-300">
<button
type="button"
title="Undo (Ctrl+Z)"
aria-label="ย้อนการกระทำล่าสุด"
disabled={!canUndo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => void undoHistory()}
>
<Undo2 className="h-4 w-4" />
@ -3061,7 +3300,7 @@ export default function PDFViewer({
title="Redo (Ctrl+Shift+Z)"
aria-label="ทำซ้ำการกระทำ"
disabled={!canRedo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => void redoHistory()}
>
<Redo2 className="h-4 w-4" />
@ -3070,24 +3309,49 @@ export default function PDFViewer({
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
<div className="relative ml-auto">
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:shrink-0">
<button
ref={bookmarkBtnRef}
type="button"
title={`เพิ่ม bookmark (หน้า ${currentPage + 1})`}
title={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
aria-label={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
disabled={!user}
onClick={openBookmarkComposer}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40"
className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
<BookmarkPlus className="h-4 w-4" />
</button>
{bookmarkComposer && (
{bookmarkComposer &&
bookmarkPos &&
typeof document !== "undefined" &&
createPortal(
<div
className="absolute right-0 top-full z-50 mt-1.5 w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
data-tool-menu=""
className="w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
style={{
position: "fixed",
left: bookmarkPos.left,
top: bookmarkPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - bookmarkPos.top - 8),
overflowY: "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<p className="text-[11px] font-semibold text-slate-700">
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
</p>
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
<p className="mt-0.5 text-[11px] text-slate-400">
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
{bookmarkComposer.yOffset <= 25
? "ส่วนบน"
: bookmarkComposer.yOffset <= 75
? "ราวกลาง"
: "ส่วนล่าง"}{" "}
ของหน้า
</p>
<input
autoFocus
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
@ -3162,15 +3426,17 @@ export default function PDFViewer({
บันทึก
</button>
</div>
</div>
</div>,
document.body
)}
</div>
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
<button
type="button"
title="Export PDF พร้อม annotation"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
title="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
aria-label="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() =>
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
}
@ -3178,6 +3444,11 @@ export default function PDFViewer({
<Download className="h-4 w-4" />
</button>
</div>
{/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}
{!toolbarAtEnd && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-r from-transparent to-white lg:hidden" />
)}
</div>
</header>
<main
@ -3214,6 +3485,16 @@ export default function PDFViewer({
setGrabbing(false);
}}
>
{/* M6: hint ไฟล์สแกน — ทุกหน้าที่ render แล้วไม่มี span เลย (virtualization เห็นเฉพาะ
หน้าใกล้ viewport — ไฟล์ผสมหน้าสแกน/หน้า text จึงพูดแบบระดับ "หน้าที่เปิดดู" เท่านั้น)
โชว์เฉพาะตอน tool highlight เพื่อไม่รบกวน tool อื่น — คดีศาลมี PDF สแกนเยอะ */}
{tool === "highlight" &&
Object.keys(pageSpanCount).length > 0 &&
Object.values(pageSpanCount).every((n) => n === 0) && (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
</p>
)}
{pageSizes.length === 0 ? (
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
กำลังโหลดเอกสาร…
@ -3330,6 +3611,7 @@ export default function PDFViewer({
if (!user) return;
void handleCreateAnnotation(i, "REDACTION", data);
}}
onTextLayerRendered={handleTextLayerRendered}
onErase={(id) => void handleDeleteAnnotation(id)}
/>
) : null}
@ -3391,7 +3673,7 @@ export default function PDFViewer({
className="h-3.5 w-3.5 rounded-[2px]"
style={{ backgroundColor: highlightColor }}
/>
ไฮไลต์
ไฮไลต์ข้อความ
</button>
</div>
)}
@ -3404,7 +3686,7 @@ export default function PDFViewer({
{composer && (
<div
className="fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl"
className="comment-composer fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl"
style={{
left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)),
top: clamp(composer.y, 8, Math.max(8, window.innerHeight - 180)),
@ -3427,7 +3709,7 @@ export default function PDFViewer({
<div className="mt-2 flex justify-end gap-2 text-xs">
<button
type="button"
className="rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
className="coarse-36 rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
onClick={() => setComposer(null)}
>
ยกเลิก
@ -3435,7 +3717,7 @@ export default function PDFViewer({
<button
type="button"
disabled={!draft.trim()}
className="rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
className="coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
onClick={() => void handleComposerSave()}
>
บันทึก

View file

@ -244,7 +244,7 @@ export default function TextboxStampLayer({
onSelect: (id: string) => void;
}) {
return (
<div className="pointer-events-none absolute inset-0 z-[4]">
<div className="no-callout pointer-events-none absolute inset-0 z-[4]">
{textboxes.map((t) =>
editingId === t.id ? (
<TextboxEditor

View file

@ -27,7 +27,7 @@ export const INK_COLORS = [
export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-start gap-2 px-1 py-1">
<span className="w-11 shrink-0 pt-1 text-[10px] font-medium text-slate-400">{label}</span>
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
{children}
</div>
);
@ -36,7 +36,7 @@ export function PanelRow({ label, children }: { label: string; children: ReactNo
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<div className="flex flex-1 flex-wrap items-center gap-1.5">
<div className="color-swatches flex flex-1 flex-wrap items-center gap-1.5">
{INK_COLORS.map((c) => (
<button
key={c}
@ -155,7 +155,7 @@ export function NumberCombo({
return (
<div
ref={rootRef}
className="relative"
className="number-combo relative"
onKeyDown={(e) => {
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
if (e.key === "Escape" && open) {
@ -181,26 +181,28 @@ export function NumberCombo({
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}}
className="w-14 border-0 px-1.5 py-1 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
className="w-14 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<div className="flex flex-col border-l border-slate-300">
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
<button
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className="flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
className="combo-step flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(1)}
>
<ChevronUp className="h-2.5 w-2.5" />
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className="flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
className="combo-step flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-2.5 w-2.5" />
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
@ -260,7 +262,7 @@ export const HIGHLIGHT_STYLE_BTNS: {
label: string;
Icon: ComponentType<{ className?: string }>;
}[] = [
{ key: "highlight", label: "ไฮไลต์", Icon: Highlighter },
{ key: "highlight", label: "ไฮไลต์ข้อความ", Icon: Highlighter },
{ key: "underline", label: "เส้นใต้", Icon: Underline },
{ key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough },
{ key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon },