จอสัมผัส (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
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:
parent
b3b884c736
commit
df0392ea45
17 changed files with 1085 additions and 215 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
>
|
||||
บันทึก
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue