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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:47:12 +07:00

4576 lines
263 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
startTransition,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import {
AlignCenter,
AlignLeft,
AlignRight,
ArrowRight,
BookmarkPlus,
Brush,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
ClipboardList,
Download,
MessageSquare,
Minus,
PanelLeft,
Pencil,
Redo2,
Square,
Trash2,
Undo2,
X,
ZoomIn,
ZoomOut,
type LucideIcon,
} from "lucide-react";
import {
createAnnotation,
createBookmark,
createReply,
deleteAnnotation,
deleteBookmark,
deleteReply,
setAnnotationResolved,
updateAnnotation,
updateBookmark,
} from "@/app/actions/annotations";
import {
useAnnotationRealtime,
} from "@/hooks/useAnnotationRealtime";
import { useHistory } from "@/hooks/useHistory";
import {
useCustomSelection,
type CustomSelectionCommit,
} from "@/hooks/useCustomSelection";
import type {
AnnotationData,
AnnotationDTO,
AnnotationType,
BookmarkDTO,
CommentData,
DrawData,
DrawPen,
HighlightData,
HighlightStyle,
TextboxSegment,
RedactionData,
ReplyDTO,
ShapeData,
ShapeKind,
StampData,
TextboxData,
} from "@/db/schema";
import {
annotationNote,
annotationPreviewText,
annotationTypeLabel,
DRAW_PENS,
effectiveDrawOpacity,
HIGHLIGHT_DEFAULT_COLOR,
sanitizePdfText,
SHAPE_DEFAULT_COLOR,
measureStampText,
stampBoxHeight,
stampLayout,
STAMP_COLOR,
STAMP_DEFAULT_SCALE_PCT,
STAMP_FONT_STACK,
STAMP_MAX_LABEL_CHARS,
STAMP_PRESETS,
TEXTBOX_DEFAULT_COLOR,
TEXTBOX_DEFAULT_FONT_SIZE_PCT,
} from "@/utils/annotation-display";
import {
clamp,
getPointerPercent,
mergeRectsByLine,
rectToPercent,
rectToPx,
rectsInSweptPath,
toPx,
type ClientPt,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import type { OacUser } from "@/utils/user";
import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
import { isHttpUrl } from "@/utils/external-file";
import { withBasePath } from "@/utils/base-path";
import { formatThaiDate } from "@/utils/date-format";
import { decodeTiffPages } from "@/utils/tiff-client";
import {
COMMENT_PIN_COARSE_SIZE_PX,
COMMENT_PIN_SIZE_PX,
TEXTBOX_DEFAULT_WIDTH_PCT,
annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
hitTestPath,
hitTestShape,
textboxMaxWidthPct,
} from "@/utils/geometry";
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
import AnnotationSelectionLayer, { type AnnotationPatch } from "./AnnotationSelectionLayer";
import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar";
import Select from "../Select";
import {
ColorSwatches,
HIGHLIGHT_STYLE_BTNS,
NumberCombo,
PanelRow,
} from "./ToolControls";
import {
TOOL_MENU_KEYS,
TOOL_SHORTCUTS,
canSwitchTo,
menuIntentAfterClick,
toolButtonFace,
visibleToolsFor,
type MenuTool,
type Tool,
} from "./toolbar-tools";
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
* select/comment/textbox/stamp = selection สีฟ้าสะอาด (กรองแนวเส้นทางลาก) */
const TEXT_SELECTION_TOOLS = new Set<Tool>([
"select",
"highlight",
"comment",
"textbox",
"stamp",
]);
type Composer = {
pageIndex: number;
position: Point; // % ของหน้า
x: number; // ตำแหน่งจอสำหรับวางกล่อง (fixed)
y: number;
};
/** F17/B2: กล่องข้อความที่กำลังสร้างใหม่ — textarea วางบนหน้า ณ ตำแหน่งที่คลิก */
type TextboxComposer = {
pageIndex: number;
position: Point; // % ของหน้า
};
type AnnotationAction =
| { kind: "add"; row: AnnotationDTO }
| { kind: "update"; row: AnnotationDTO }
| { kind: "remove"; id: string };
/** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2)
* ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน)
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง
* (editingId/commitEdit ใช้ร่วมกับ STAMP ด้วย — StampEditor แก้คำตรายางผ่าน id เดียวกัน) */
type TextboxUI = {
composer: TextboxComposer | null;
commitComposer: (text: string, segments?: TextboxSegment[]) => void;
cancelComposer: () => void;
editingId: string | null;
startEdit: (id: string | null) => void;
commitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void;
};
/** N2: เหลือแค่ remove (create รอผลจาก action ตรง ๆ) */
type BookmarkAction = { kind: "remove"; id: string };
/** สี selection preview ตอนลากคลุมข้อความ (tool เลือก/comment/textbox/stamp) —
* tint ของ primary #0033A1 (ค่าเดียวกับ tailwind.config.ts) แทน blue-500 ดิบของ
* Tailwind ให้น้ำเงินทั้ง viewer เป็นเฉดเดียวกัน (design audit 2026-09-24)
* alpha 0.3 ฝังในตัวสีแล้ว — ห้ามส่ง opacity ซ้ำให้ renderMark (จะคูณกันจางเกิน) */
const SELECT_PREVIEW_COLOR = "rgb(0 51 161 / 0.3)";
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
* และตัดยาวไม่เกิน MAX_BOOKMARK_TITLE_CHARS ตัวอักษร (รวม … ที่ต่อท้าย —
* เดิม slice 120 แล้วต่อ … กลายเป็น 121 ตัว โดน limit ของ server ตีกลับตอนบันทึก) */
function normalizeBookmarkTitle(s: string): string {
const t = sanitizePdfText(s).replace(/\s+/g, " ").trim();
return t.length > MAX_BOOKMARK_TITLE_CHARS
? `${t.slice(0, MAX_BOOKMARK_TITLE_CHARS - 1)}…`
: t;
}
/** ความกว้างจริงของเมนู 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,
};
}
/** เลื่อน scroll container ของเอกสาร (<main> — ตัวเลื่อนเดียวของ viewer) ให้ตำแหน่ง
* contentTop (px จากบนสุดของเนื้อหาใน container) ไปอยู่แถวบนสุดของพื้นที่มองเห็น —
* header อยู่นอกกรอบ scroll อยู่แล้ว จึงเว้นแค่ช่องว่าง 40px ให้รายการไม่ติดขอบบนเกินไป */
const SCROLL_BELOW_HEADER_GAP = 40;
function scrollToBelowHeader(main: HTMLElement | null, contentTop: number): void {
main?.scrollTo({
top: Math.max(0, contentTop - SCROLL_BELOW_HEADER_GAP),
// L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
}
/** แปลง client Y (getBoundingClientRect().top) → พิกัดแนวตั้งในเนื้อหาของ scroll
* container ของเอกสาร — แทนสูตร r.top + window.scrollY ยุคที่ window เป็นตัวเลื่อน
* (app shell สูงจอควบ lock การ scroll ของ document ไว้ ทุกอย่างเลื่อนใน <main>) */
function contentTopInScroll(main: HTMLElement, clientTop: number): number {
return clientTop - main.getBoundingClientRect().top + main.scrollTop;
}
/** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */
function squigglePath(w: number): string {
let d = "M0 2.5";
let up = true;
for (let x = 0; x + 4 <= Math.max(w, 4); x += 4) {
d += up ? " q2 -2.5 4 0" : " q2 2.5 4 0";
up = !up;
}
return d;
}
/** วรรณยุกต์/สระบน-ล่างไทย (combining mark) — ตามหลังพยัญชนะฐานเสมอ แต่เดี่ยว ๆ
* rect อาจกว้าง 0 หรือลอยเหนือบรรทัด จึงต้องให้ตามสถานะของตัวฐานก่อนหน้า */
function isThaiCombiningMark(c: string): boolean {
const v = c.charCodeAt(0);
return v === 0x0e31 || (v >= 0x0e34 && v <= 0x0e3a) || (v >= 0x0e47 && v <= 0x0e4e);
}
/** ข้อความเฉพาะส่วนที่อยู่ใน rects ที่กรองแล้ว — DOM range ของ textLayer ไทยครอบ
* ข้อความเกินบริเวณที่ลากจริง selection.toString() ดิบเลยได้คำเกินมา (rects ถูกกรอง
* ด้วย rectsInSweptPath แต่ข้อความยังไม่) จึงเดินทีละตัวอักษรใน text nodes ของ range
* เช็คว่า rect ของตัวอักษรนั้นตกใน rect ที่ render บนจอไหม — ข้อความตรงกับที่เห็น
* จากนั้นเรียงตามตำแหน่ง rect (บน→ล่าง, ซ้าย→ขวา) เพราะ DOM order ของ span ใน PDF
* ไทยบางไฟล์ไม่ตรงลำดับบนจอ แล้วปิดท้ายด้วย sanitizePdfText ตัด control char ขยะ
* แพงกว่า toString() มาก ใช้เฉพาะตอน mouseup/copy เท่านั้น (live preview เรียกถี่เกิน) */
const TEXT_PROBE_MAX_CHARS = 3000;
function selectionTextWithin(kept: DOMRect[], range: Range): string {
const raw = range.toString();
// เลือกยาวเกินขีดนี้ = คุ้มกว่าใช้ข้อความดิบ (กันหน่วงตอนปล่อยเมาส์)
if (raw.length > TEXT_PROBE_MAX_CHARS) return sanitizePdfText(raw.slice(0, 5000));
const ancestor = range.commonAncestorContainer;
const root = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode : ancestor;
if (!root) return sanitizePdfText(raw.slice(0, 5000));
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const probe = document.createRange();
const eps = 1; // ทนพลาดจากการปัดทศนิยมของ client rects
// ตัวอักษรที่เลือก: rect ที่ hit (ใช้เป็นกลุ่มบรรทัด) + ตำแหน่ง x + ลำดับ DOM
// (สำรองตอนจัดเรียง — combining mark ที่ไม่มี rect เอา x ของตัวฐานก่อนหน้า)
type Entry = { ch: string; rect: number; left: number; dom: number };
const entries: Entry[] = [];
let dom = 0;
let prevRect = -1;
let prevLeft = 0;
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const node = n as Text;
if (!range.intersectsNode(node)) continue;
const s = node === range.startContainer ? range.startOffset : 0;
const e = node === range.endContainer ? range.endOffset : node.data.length;
for (let i = s; i < e && entries.length < 6000; i++) {
const ch = node.data[i];
probe.setStart(node, i);
probe.setEnd(node, i + 1);
const r = probe.getClientRects()[0];
let hit = -1;
if (r !== undefined) {
hit = kept.findIndex(
(k) =>
r.left < k.right + eps &&
r.right > k.left - eps &&
r.top < k.bottom + eps &&
r.bottom > k.top - eps,
);
}
// combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น
const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0);
if (hit < 0 && sticky) hit = prevRect;
if (hit < 0) {
prevRect = -1;
continue;
}
entries.push({
ch,
rect: hit,
left: hit === prevRect && sticky ? prevLeft : r ? r.left : 0,
dom: dom++,
});
prevRect = hit;
prevLeft = entries[entries.length - 1].left;
}
}
if (entries.length === 0) return sanitizePdfText(raw.slice(0, 5000));
// เรียงกลุ่ม rect ตามตำแหน่งบนจอ: คนละบรรทัด (กลางแกน y ห่างเกินความสูงบรรทัด)
// เรียงบน→ล่าง, บรรทัดเดียวกันเรียงซ้าย→ขวา — DOM order ใช้เป็น tie-break เท่านั้น
const midY = (r: DOMRect) => r.top + r.height / 2;
const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => {
const ra = kept[a];
const rb = kept[b];
if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) {
return midY(ra) - midY(rb);
}
return ra.left - rb.left;
});
let out = "";
let prev = -1;
for (const ri of rectOrder) {
if (prev >= 0) {
const a = kept[prev];
const b = kept[ri];
const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height);
if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString()
}
out += entries
.filter((en) => en.rect === ri)
.sort((a, b) => a.left - b.left || a.dom - b.dom)
.map((en) => en.ch)
.join("");
prev = ri;
}
// กรองแล้วว่างเปล่า (เรขาคณิตผิดปกติ) = ใช้ข้อความดิบดีกว่าไม่มีเลย
return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000));
}
/** แบนเนอร์แนะนำวิธีใช้ tool ที่วางของด้วยคลิกบนหน้า (UX audit 2026-09-22): เดิมเลือก tool
* แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที
* ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน
* (เงื่อนไข tool เดี่ยว ๆ จึงไม่มีวันซ้อนกับแบนเนอร์ M3/M6/H2 ข้างบน) */
const PLACE_TOOL_HINTS: Partial<Record<Tool, string>> = {
comment: "โหมดคอมเมนต์: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่องคอมเมนต์",
textbox: "โหมดกล่องข้อความ: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่อง แล้วพิมพ์ข้อความ",
stamp: "โหมดตรายาง: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อประทับตรายาง",
};
/** Responsive audit 2026-09-24: แถบเตือน redact/erase อยู่บนสุดของ main — ผู้ใช้ที่เลื่อน
* ไปกลาง/ท้ายเอกสารแล้วสลับ tool จะไม่เห็น (โดยเฉพาะคำเตือน redaction ที่เป็นความเสี่ยง
* ข้อมูลส่วนบุคคล) — สลับ tool แล้วถ้าแถบพ้นจอจะโชว์ toast สั้น 4 วิแทน (ข้อความใช้
* ร่วมกับแถบต้น main เพื่อไม่ให้สองชุด drift กัน) */
const REDACT_WARN_TEXT =
'การปิดพรางบนหน้าจอเป็นการชั่วคราว — ข้อความเดิมยังคัดลอกได้ ต้องกด "Export PDF" เพื่อปิดพรางถาวรก่อนนำไฟล์ไปเผยแพร่';
const ERASE_HINT_TEXT =
'ยางลบลบได้เฉพาะเส้น รูปทรง และตรายางของตัวเองเท่านั้น — ไฮไลต์และรายการอื่น ๆ (รวมของผู้อื่นทุกชนิด) ให้ใช้เครื่องมือ "เลือก" คลิกรายการแล้วกดปุ่มลบ';
/** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible
* ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน
* Genesis 2026-09-23: outline น้ำเงินทึบ 2px + offset (ผ่าน WCAG 3:1 — halo alpha จางเกิน)
* (z-10 ให้ outline ลอยเหนือปุ่มข้าง ๆ — flex item ใช้ z-index ได้) */
const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary focus-visible:z-10";
/** สเกลที่ทำให้หน้ากระดาษพอดีความกว้างของ main (ตัด padding px-4 ซ้าย-ขวา = 32px — สูตร
* เดิมของ effect fit-width ตอนโหลด ย้ายมาเป็น helper ใช้ร่วมกับปุ่ม % ที่ header ด้วย
* (UX audit 2026-09-22: ผู้ใช้ซูมดูรายละเอียดแล้วไม่มีทางกลับมาดูทั้งหน้า) — คิดจากความ
* กว้าง main ปัจจุบันทุกครั้งเพราะ sidebar/จออาจเปลี่ยนไปแล้ว; ยังไม่รู้ขนาดหน้า = null */
function fitWidthScale(mainW: number | undefined, pageW: number | undefined): number | null {
const avail = (mainW ?? 0) - 32;
if (!avail || !pageW) return null;
return pageW > avail ? clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1) : 1;
}
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
"DRAW",
"SHAPE",
"TEXTBOX",
"STAMP",
"HIGHLIGHT",
"REDACTION",
]);
/** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน
* แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */
const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0A0A0A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>',
)}") 2 17, auto`;
/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS
* (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
pencil: Pencil,
highlighter: Brush,
};
/** ชนิดรูปทรงย่อยของ tool shape (F5/B1) — ปุ่มแสดงเมื่อ tool ใช้งาน */
const SHAPE_KIND_BTNS: { key: ShapeKind; label: string; Icon: LucideIcon }[] = [
{ key: "rect", label: "สี่เหลี่ยม", Icon: Square },
{ key: "ellipse", label: "วงรี", Icon: Circle },
{ key: "line", label: "เส้นตรง", Icon: Minus },
{ key: "arrow", label: "ลูกศร", Icon: ArrowRight },
];
// AnnotationDTO เป็น flat type (type/data แยกกัน) → ใช้ type predicate แคบ scope
function isHighlight(a: AnnotationDTO): a is AnnotationDTO & { type: "HIGHLIGHT"; data: HighlightData } {
return a.type === "HIGHLIGHT";
}
function isDraw(a: AnnotationDTO): a is AnnotationDTO & { type: "DRAW"; data: DrawData } {
return a.type === "DRAW";
}
function isComment(a: AnnotationDTO): a is AnnotationDTO & { type: "COMMENT"; data: CommentData } {
return a.type === "COMMENT";
}
function isShape(a: AnnotationDTO): a is AnnotationDTO & { type: "SHAPE"; data: ShapeData } {
return a.type === "SHAPE";
}
function isRedaction(a: AnnotationDTO): a is AnnotationDTO & { type: "REDACTION"; data: RedactionData } {
return a.type === "REDACTION";
}
function isTextbox(a: AnnotationDTO): a is AnnotationDTO & { type: "TEXTBOX"; data: TextboxData } {
return a.type === "TEXTBOX";
}
function isStamp(a: AnnotationDTO): a is AnnotationDTO & { type: "STAMP"; data: StampData } {
return a.type === "STAMP";
}
/** ตรวจว่าจุดคลิก (% ของหน้า) ชน annotation นี้หรือไม่ — hit-test แม่นตามชนิด
* (DRAW/SHAPE ใช้ตัวตรวจเดียวกับ eraser, HIGHLIGHT ดู rect ต่อบรรทัด,
* ที่เหลือดูจาก bbox + padding เผื่อรายการเล็ก) */
function hitTestAnnotation(a: AnnotationDTO, p: Point, size: Size): boolean {
if (a.type === "HIGHLIGHT") {
return (a.data as HighlightData).rects.some(
(r) =>
p.x >= r.x &&
p.x <= r.x + r.width &&
p.y >= r.y &&
p.y <= r.y + r.height,
);
}
if (a.type === "DRAW") {
const d = a.data as DrawData;
const threshold = toPx(d.strokeWidth, size.width) / 2 + 6;
return hitTestPath(d.paths, p, threshold, size);
}
if (a.type === "SHAPE") {
const d = a.data as ShapeData;
const threshold = toPx(d.strokeWidth, size.width) / 2 + 6;
return hitTestShape(d, p, threshold, size);
}
// ที่เหลือ (REDACTION/TEXTBOX/STAMP) — bbox ในหน่วย % เทียบกับ p (%) หน่วยเดียวกัน
// (เดิมเทียบ bbox หน่วย px ตาม size จริงกับ p ที่เป็น % หน่วยไม่ตรงกัน จนคลิกเลือก
// และ tooltip จับได้เฉพาะกรอบที่ชิดมุมซ้ายบนของหน้า) — padding 4px เดิมแปลงเป็น % ตามแกน
const box = annotationBBox(a, { width: 100, height: 100 }, measureStampText);
const padX = (4 / size.width) * 100;
const padY = (4 / size.height) * 100;
return (
p.x >= box.x - padX &&
p.x <= box.x + box.width + padX &&
p.y >= box.y - padY &&
p.y <= box.y + box.height + padY
);
}
/** กล่องเธรดคำตอบใน popover ของ comment pin (F9/A4) — state draft ของตัวเอง */
function ReplyBox({
annotationId,
replies,
ownUserId,
onCreateReply,
onDeleteReply,
}: {
annotationId: string;
replies: ReplyDTO[];
ownUserId: string;
onCreateReply: (annotationId: string, message: string) => Promise<boolean>;
onDeleteReply: (id: string) => void;
}) {
const [draft, setDraft] = useState("");
async function submit() {
const msg = draft.trim();
if (!msg) return;
// H1 (UX audit 2026-09-16): เคลียร์ draft เฉพาะตอนสำเร็จ — fail แล้วข้อความ
// ที่พิมพ์เองต้องอยู่ต่อให้กดส่งใหม่ได้ (เดิมเคลียร์ก่อนเรียก action = หายทั้งข้อ)
if (await onCreateReply(annotationId, msg)) setDraft("");
}
return (
<div className="mt-2 border-t border-line/60 pt-2" onClick={(e) => e.stopPropagation()}>
{replies.length > 0 && (
<div className="max-h-36 space-y-1.5 overflow-y-auto">
{replies.map((r) => (
<div key={r.id} className="group flex items-start gap-1">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-ink-soft">
{r.userName}
<span className="ml-1 font-normal text-ink-soft" suppressHydrationWarning>
{formatThaiDate(r.createdAt, true)}
</span>
</p>
<p className="whitespace-pre-wrap break-words text-[11px]">{r.message}</p>
</div>
{r.userId === ownUserId && (
<button
type="button"
aria-label="ลบคำตอบ"
className={`coarse-36 shrink-0 rounded text-ink-faint hover:text-error-ink ${FOCUS_RING_CLS}`}
onClick={(e) => {
e.stopPropagation();
onDeleteReply(r.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
<div className="mt-1.5 flex gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก
if (e.key === "Enter") void submit();
}}
placeholder="ตอบ comment…"
className="min-w-0 flex-1 rounded-md border border-line px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<button
type="button"
disabled={!draft.trim()}
onClick={() => void submit()}
className={`coarse-36 shrink-0 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
ตอบ
</button>
</div>
</div>
);
}
// ─── หน้าเดียว: canvas + highlight-layer + textLayer + InkLayer + pins ──────
/** N3: เนื้อหาหนึ่งหน้า — pdf (pdfjs) หรือรูปภาพ (src = URL รูป; null = เฟรม TIFF decode
* ไม่ได้ → placeholder เทา แต่หน้ายังมีขนาดจาก IFD header ให้ scroll/bookmark เสถียร) */
type PageContent =
| { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy }
| { kind: "image"; src: string | null };
/** N3: หน้ารูปภาพ normalize กว้าง 595 (เท่าหน้า A4 pt) — scale contract เดียวกับ export
* (utils/image-to-pdf.ts) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า PDF */
const IMAGE_PAGE_WIDTH = 595;
function PdfPage({
content,
pageNumber,
width,
height,
tool,
shapeKind,
drawStyle,
shapeStyle,
textboxStyle,
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
pageBaseWidth,
liveMark,
liveMarkStyle,
selectPreview,
annotations,
replies,
openPinId,
ownUserId,
selectedId,
editingCommentId,
commentEditDraft,
onCommentEditDraft,
onStartCommentEdit,
textbox,
onSelectAnnotation,
onPinToggle,
onAnnotationPreview,
onAnnotationUpdate,
onAnnotationPatch,
onSetResolved,
onAnnotationBookmark,
onCreateReply,
onDeleteReply,
onStrokeEnd,
onShapeEnd,
onRedactionEnd,
onErase,
onTextLayerRendered,
}: {
content: PageContent;
/** 1-based */
pageNumber: number;
width: number;
height: number;
tool: Tool;
shapeKind: ShapeKind;
/** ค่าปากกาปัจจุบันของ tool draw — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่ */
drawStyle: { color: string; strokeWidthPct: number; opacity: number };
/** ค่าสี/ความหนาของ tool shape — ใช้ตอนสร้างรูปทรงใหม่ */
shapeStyle: { color: string; strokeWidthPct: number };
/** ค่าสี/ขนาดฟอนต์/หนา-ทึบ-จัดแนวของ tool textbox — ใช้ทั้ง composer และตอนบันทึก */
textboxStyle: {
color: string;
fontSizePct: number;
bold: boolean;
opacity: number;
align: "left" | "center" | "right";
/** ความกว้างกล่อง default (สไตล์ Smallpdf — คลิกเดียวได้กล่อง ลากขอบขยายทีหลัง) */
widthPct: number;
};
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
liveMark: { pageIndex: number; rects: Rect[] } | null;
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
/** selection สีฟ้าสะอาด ๆ ของ tool เลือก — กรองแนวเส้นทางลากเหมือน liveMark
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
selectPreview: { pageIndex: number; rects: Rect[] } | null;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
pageBaseWidth: number;
annotations: AnnotationDTO[];
replies: ReplyDTO[];
openPinId: string | null;
ownUserId: string;
selectedId: string | null;
/** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
editingCommentId: string | null;
commentEditDraft: string;
onCommentEditDraft: (text: string) => void;
onStartCommentEdit: (id: string | null) => void;
/** F17/B2: state/handler ของกล่องข้อความ (composer จะ render เฉพาะหน้าที่ตรง pageIndex) */
textbox: TextboxUI;
/** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */
onSelectAnnotation: (id: string) => void;
onPinToggle: (id: string | null) => void;
onAnnotationPreview: (data: AnnotationData | null) => void;
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
/** ตัวควบคุมเล็กของแผง commit เป็น patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว
* update กัน stale base ทับค่าที่เพิ่งเปลี่ยน (AnnotationPatch, code review 2026-09-28) */
onAnnotationPatch: (id: string, patch: AnnotationPatch) => void;
onSetResolved: (id: string, resolved: boolean) => void;
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
onCreateReply: (annotationId: string, message: string) => Promise<boolean>;
onDeleteReply: (id: string) => void;
onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void;
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);
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null;
const pdfDoc = content.kind === "pdf" ? content.doc : null;
useEffect(() => {
let cancelled = false;
let renderTask: import("pdfjs-dist").RenderTask | null = null;
if (!pdfjsLib || !pdfDoc) return;
(async () => {
const page = await pdfDoc.getPage(pageNumber);
if (cancelled) return;
const scale = width / page.getViewport({ scale: 1 }).width;
const viewport = page.getViewport({ scale });
// Canvas Retina: backing store ×dpr + transform แต่ style คง css px
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
renderTask = page.render({
canvasContext: ctx,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
});
await renderTask.promise.catch(() => undefined); // cancel ระหว่าง unmount ≠ error
if (cancelled) return;
// TextLayer — span ใช้ calc(var(--scale-factor)*…) → ต้องตั้งบน container
const container = textLayerRef.current;
if (!container) return;
const textContent = await page.getTextContent();
if (cancelled) return;
container.replaceChildren();
container.style.setProperty("--scale-factor", String(scale));
const textLayer = new pdfjsLib.TextLayer({
textContentSource: textContent,
container,
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);
}
});
return () => {
cancelled = true;
renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md)
};
// onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร
// เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย
// ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า
}, [pdfjsLib, pdfDoc, pageNumber, width, height]);
// N3: หน้ารูปไม่มี textLayer — รายงาน span count = 0 ให้ hint "ภาพสแกน" ของ tool
// highlight ทำงาน (พฤติกรรมเดียวกับ PDF สแกน) — onTextLayerRendered ไม่อยู่ใน deps
// เพราะเป็น useCallback [] เสถียร (เหมือน effect ด้านบน)
useEffect(() => {
if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0);
}, [content.kind, pageNumber]);
const highlights = annotations.filter(isHighlight);
const inkStrokes: InkStroke[] = annotations
.filter(isDraw)
.map((a) => ({
id: a.id,
userId: a.userId,
color: a.data.strokeColor,
strokeWidthPct: a.data.strokeWidth,
points: a.data.paths,
widthsPct: a.data.widths,
opacity: effectiveDrawOpacity(a.data),
}));
const inkShapes: InkShape[] = annotations
.filter(isShape)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
const inkRedactions: InkRedaction[] = annotations
.filter(isRedaction)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
const comments = annotations.filter(isComment);
const inkTextboxes: InkTextbox[] = annotations
.filter(isTextbox)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
const inkStamps: InkStamp[] = annotations
.filter(isStamp)
.map((a) => ({ id: a.id, userId: a.userId, data: a.data }));
// tool ที่ InkLayer รับ pointer (วาดเส้น/รูปทรง/eraser/redact) → textLayer กับ pins ต้องปล่อยทาง
const inkActive =
tool === "draw" || tool === "shape" || tool === "erase" || tool === "redact";
// tool pan — ปิด pointer ของทุก overlay (textLayer/pins/textbox/selection) ให้ลากเลื่อนได้ทั้งหน้า
// แต่ InkLayer ต้องไม่ active ด้วย pan (ไม่งั้นการลากกลายเป็นวาดเส้น)
const overlayFrozen = inkActive || tool === "pan";
/** วาดมาร์ก 1 rect ตามรูปแบบ (ทึบ/เส้นใต้/ขีดฆ่า/ขยุกขยิก) — ใช้ทั้งของเดิมและ live preview
* opacity 0–1 (optional — ไม่มี = ทึบเต็ม) ใช้กับทุกรูปแบบ */
function renderMark(
key: string,
px: Rect,
color: string,
hStyle: HighlightStyle,
opacity?: number,
) {
const op = opacity ?? 1;
if (hStyle === "underline") {
return (
<div
key={key}
className="absolute"
style={{
left: px.x,
top: px.y + px.height - 1.5,
width: px.width,
height: 1.5,
backgroundColor: color,
opacity: op,
}}
/>
);
}
if (hStyle === "strikethrough") {
return (
<div
key={key}
className="absolute"
style={{
left: px.x,
top: px.y + px.height / 2 - 0.75,
width: px.width,
height: 1.5,
backgroundColor: color,
opacity: op,
}}
/>
);
}
if (hStyle === "squiggle") {
const w = Math.max(px.width, 4);
return (
<svg
key={key}
className="absolute"
style={{ left: px.x, top: px.y + px.height - 5, width: w, height: 5, opacity: op }}
viewBox={`0 0 ${w} 5`}
>
<path
d={squigglePath(w)}
stroke={color}
strokeWidth={1.4}
fill="none"
strokeLinecap="round"
/>
</svg>
);
}
return (
<div
key={key}
className="absolute"
style={{
left: px.x,
top: px.y,
width: px.width,
height: px.height,
backgroundColor: color,
opacity: op,
}}
/>
);
}
return (
<>
{/* z-1: canvas (pdf) / <img> (รูปภาพ N3) — pointer-events: none เสมอ (invariant #3)
รูป: browser render ที่ devicePixelRatio เต็มเอง เทียบเท่า canvas×dpr ของ pdfjs */}
{content.kind === "pdf" ? (
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
) : content.src ? (
<img
src={content.src}
alt=""
draggable={false}
className="pointer-events-none absolute inset-0 z-[1] h-full w-full select-none"
/>
) : (
<div className="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center bg-ink/10 text-xs text-ink-soft">
ถอดรหัสหน้านี้ไม่ได้
</div>
)}
{/* z-2: highlight-layer — pointer-events: none เสมอ, อยู่หน้า textLayer ใน DOM order
แสดงผลตาม style: ทึบ / เส้นใต้ (ขอบล่าง) / ขีดฆ่า (กลางบรรทัด) / ขยุกขยิก (svg) */}
<div className="pointer-events-none absolute inset-0 z-[2] mix-blend-multiply">
{highlights.map((a) =>
a.data.rects.map((r, i) =>
renderMark(
`${a.id}-${i}`,
rectToPx(r, { width, height }),
a.data.color,
a.data.style ?? "highlight",
a.data.opacity,
),
),
)}
{/* live preview ของ tool highlight ระหว่างลากคลุมข้อความ */}
{liveMark &&
liveMark.pageIndex === pageNumber - 1 &&
liveMark.rects.map((r, i) =>
renderMark(
`live-${i}`,
rectToPx(r, { width, height }),
liveMarkStyle.color,
liveMarkStyle.style,
liveMarkStyle.opacity,
),
)}
{/* selection สีฟ้าของ tool เลือก — กรองแนวเส้นทางลากแล้ว วาดเองแทน
สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */}
{selectPreview &&
selectPreview.pageIndex === pageNumber - 1 &&
selectPreview.rects.map((r, i) =>
renderMark(
`selprev-${i}`,
rectToPx(r, { width, height }),
SELECT_PREVIEW_COLOR,
"highlight",
),
)}
</div>
{/* z-2: textLayer (pdfjs — pdf เท่านั้น, N3: หน้ารูปไม่มี) — อยู่หลัง highlight-layer
ใน DOM → อยู่บนสุดของ z-2 (conditional render ไม่ย้ายโหนด — invariant #3) */}
{content.kind === "pdf" && (
<div
ref={textLayerRef}
className="textLayer absolute inset-0 z-[2]"
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
/>
)}
{/* z-3: InkLayer (SVG freehand + รูปทรง F5) */}
<InkLayer
size={{ width, height }}
strokes={inkStrokes}
shapes={inkShapes}
redactions={inkRedactions}
stamps={inkStamps}
active={inkActive}
nextStroke={{
color: drawStyle.color,
strokeWidthPct: drawStyle.strokeWidthPct,
opacity: drawStyle.opacity,
}}
shapeTool={
tool === "shape"
? { kind: shapeKind, color: shapeStyle.color, strokeWidthPct: shapeStyle.strokeWidthPct }
: null
}
onStrokeEnd={onStrokeEnd}
onShapeEnd={onShapeEnd}
onRedactionEnd={onRedactionEnd}
eraseMode={tool === "erase"}
redactMode={tool === "redact"}
ownUserId={ownUserId}
onErase={onErase}
/>
{/* z-4: TEXTBOX overlay (F17/B2) — ก่อน comment pins ใน DOM */}
<TextboxStampLayer
size={{ width, height }}
textboxes={inkTextboxes}
stamps={inkStamps}
enabled={!overlayFrozen}
ownUserId={ownUserId}
editingId={textbox.editingId}
onStartEdit={textbox.startEdit}
onCommitEdit={textbox.commitEdit}
composer={
textbox.composer && textbox.composer.pageIndex === pageNumber - 1
? {
position: textbox.composer.position,
fontSizePct: textboxStyle.fontSizePct,
color: textboxStyle.color,
bold: textboxStyle.bold,
opacity: textboxStyle.opacity,
align: textboxStyle.align,
widthPct: textboxStyle.widthPct,
}
: null
}
onCommitComposer={textbox.commitComposer}
onCancelComposer={textbox.cancelComposer}
onSelect={onSelectAnnotation}
/>
{/* z-4: comment pins — คอนเทนเนอร์โปร่งใส ปุ่ม pin รับ event เอง */}
<div className="pointer-events-none absolute inset-0 z-[4]">
{comments.map((a) => {
const x = toPx(a.data.position.x, width);
const y = toPx(a.data.position.y, height);
const open = openPinId === a.id;
// L3 (UX audit 2026-09-16): pin ชิดขวาหน้า — popover เปิดขวาของ pin จะยื่น
// เกินกระดาษ 200px+ พลิกไปซ้ายของ pin แทน (pattern เดียวกับ tooltip N1)
const flipLeft = x + 12 + 224 > width;
// Responsive audit 2026-09-24: pin ใกล้ท้ายหน้ากระดาษ — เปิด popover ขึ้นด้านบน
// แทนลงล่างเสมอ (เดิมยื่นทับหน้าถัดไป/ต่ำกว่าสายตา) ประมาณความสูงเต็ม ~280px
const flipUp = y + 280 > height;
return (
<div key={a.id} className="absolute" style={{ left: x, top: y }}>
<button
type="button"
aria-label="ดู comment"
className={`-translate-x-1/2 -translate-y-full coarse-36 rounded-full border p-1 shadow ${FOCUS_RING_CLS} ${
a.resolved
? "border-success/40 bg-success/10 text-success-ink opacity-60 hover:opacity-100"
: "border-warning/40 bg-warning/15 text-warning-ink hover:bg-warning/25"
}`}
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
onClick={(e) => {
e.stopPropagation();
onPinToggle(open ? null : a.id);
}}
>
<MessageSquare className="h-4 w-4" />
</button>
{open && (
<div
className="absolute z-10 w-56 rounded-lg border border-line bg-white p-2 text-xs shadow-xl"
style={{
...(flipLeft
? { left: width - 232 - x } // ปลายขวา popover อยู่ห่างขอบขวาหน้า 8px
: { left: 12 }),
// ขึ้นบน: ปุ่ม pin ลอยเหนือจุด ~26px (translate -y-full) เว้น 4px
...(flipUp ? { bottom: 30 } : { top: 0 }),
pointerEvents: overlayFrozen ? "none" : "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{a.userName}</p>
{/* F13/A6: ปุ่มแก้ไขข้อความ — เจ้าของเท่านั้น */}
{a.userId === ownUserId && editingCommentId !== a.id && (
<button
type="button"
aria-label="แก้ไขข้อความ"
title="แก้ไขข้อความ"
className={`coarse-36 shrink-0 rounded text-ink-faint hover:text-ink-soft ${FOCUS_RING_CLS}`}
onClick={(e) => {
e.stopPropagation();
onStartCommentEdit(a.id);
}}
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
{editingCommentId === a.id ? (
<div className="mt-1">
<textarea
autoFocus
rows={3}
value={commentEditDraft}
onChange={(e) => onCommentEditDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของข้างนอก
if (e.key === "Escape") onStartCommentEdit(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
const msg = commentEditDraft.trim();
if (msg) {
onAnnotationUpdate(a.id, {
position: a.data.position,
message: msg,
});
onStartCommentEdit(null);
}
}
}}
className="w-full resize-none rounded-md border border-line p-1.5 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className={`coarse-36 rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onStartCommentEdit(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={!commentEditDraft.trim()}
className={`coarse-36 rounded-md bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
onClick={() => {
const msg = commentEditDraft.trim();
if (!msg) return;
onAnnotationUpdate(a.id, {
position: a.data.position,
message: msg,
});
onStartCommentEdit(null);
}}
>
บันทึก
</button>
</div>
</div>
) : (
// T2/T3 (typography audit): ข้อความหลักไทยที่อ่านต่อเนื่อง = 13px +
// leading-relaxed (สระบน+วรรณยุกต์ซ้อนชนกันที่ leading เดิม)
<p className="mt-1 whitespace-pre-wrap break-words text-[13px] leading-relaxed">
{a.data.message}
</p>
)}
<p className="mt-1 text-[11px] text-ink-soft" suppressHydrationWarning>
{formatThaiDate(a.createdAt, true)}
</p>
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
<button
type="button"
className={`coarse-36 mt-1.5 w-full rounded-md px-2 py-1 text-[11px] font-medium ${FOCUS_RING_CLS} ${
a.resolved
? "bg-success/15 text-success-ink hover:bg-success/25"
: "border border-line text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={(e) => {
e.stopPropagation();
onSetResolved(a.id, !a.resolved);
}}
>
{a.resolved ? "✓ จัดการแล้ว (เลิกทำเครื่องหมาย)" : "ทำเครื่องหมายจัดการแล้ว"}
</button>
{/* F9: เธรดคำตอบ */}
<ReplyBox
annotationId={a.id}
replies={replies.filter((r) => r.annotationId === a.id)}
ownUserId={ownUserId}
onCreateReply={onCreateReply}
onDeleteReply={onDeleteReply}
/>
</div>
)}
</div>
);
})}
</div>
{/* z-4: กรอบ selection + handles ของรายการที่เลือก (F1) — หลัง pins ใน DOM */}
{(() => {
const selected = annotations.find((a) => a.id === selectedId);
// COMMENT ที่เปิด popover อยู่ไม่วาดกรอบ/แผง — bbox ของ COMMENT ครอบพื้นที่ pin
// พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId เหมือนเดิม)
if (!selected || (selected.type === "COMMENT" && openPinId === selected.id)) return null;
return (
<AnnotationSelectionLayer
annotation={selected}
size={{ width, height }}
editable={ownUserId !== "" && selected.userId === ownUserId}
pageBaseWidth={pageBaseWidth}
disabled={overlayFrozen}
onPreview={onAnnotationPreview}
onCommit={onAnnotationUpdate}
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
onStartEdit={textbox.startEdit}
onPatch={onAnnotationPatch}
/>
);
})()}
</>
);
}
// ─── Client root ─────────────────────────────────────────────────────────────
/** ผสาน patch ของตัวควบคุมเล็ก (AnnotationPatch ของ AnnotationSelectionLayer) เข้ากับ
* data ตามชนิด (เช็ค field เหมือนตัวควบคุมของแผง: rects = HIGHLIGHT, paths/shape =
* เส้น/รูปทรง, label = STAMP, fontSize = TEXTBOX — AnnotationData ไม่มี field type)
* — เรียกตอนถึงตาของคิว update จึงได้ base ล่าสุดเสมอ (ไม่ใช่ snapshot ตอนกดปุ่ม)
* ชนิดไม่ตรงกับ patch = เจตนาเดิมของตัวควบคุมนั้น → คืน base เปล่า ๆ */
function applyAnnotationPatch(base: AnnotationData, patch: AnnotationPatch): AnnotationData {
switch (patch.kind) {
case "color":
// HIGHLIGHT ("rects") กับ STAMP ("label") = field `color`, เส้น/รูปทรง = `strokeColor`
if ("rects" in base || "label" in base) return { ...base, color: patch.color };
if ("paths" in base || "shape" in base) return { ...base, strokeColor: patch.color };
return base;
case "strokeWidth":
if ("paths" in base) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
return {
...base,
strokeWidth: patch.strokeWidth,
...(base.widths
? {
widths: base.widths.map(
(pw) => (pw / base.strokeWidth) * patch.strokeWidth,
),
}
: {}),
};
}
if ("shape" in base) return { ...base, strokeWidth: patch.strokeWidth };
return base;
case "highlightStyle":
if (!("rects" in base)) return base;
return { ...base, style: patch.style };
case "highlightOpacity": {
if (!("rects" in base)) return base;
// 100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่
const { opacity: _prev, ...rest } = base;
return { ...rest, ...(patch.opacityPct < 100 ? { opacity: patch.opacityPct / 100 } : {}) };
}
case "stampScale": {
if (!("label" in base)) return base;
const scaled = { ...base, scale: patch.scale };
// ตรายางเอียง: ขยายแล้ว AABB ของกรอบหมุนยื่นเกินหน้าได้ (position = มุมก่อนหมุน) —
// เลื่อนกลับเข้าหน้าทันทีเหมือน stampRotation ไม่งั้น validator ปฏิเสธแล้วเด้ง
// ทั้งที่บนจอกรอบยังอยู่ในหน้า (code review 2026-09-29)
return scaled.rotation
? { ...scaled, position: clampStampIntoPage(scaled) }
: scaled;
}
case "stampRotation": {
if (!("label" in base)) return base;
// 0° = ถอด field ทิ้งเหมือนแถวเดิม (schema/validator ถือว่าไม่มี = ไม่เอียง)
const { rotation: _prev, ...rest } = base;
if (patch.rotation === 0) return rest;
// หมุนที่ตำแหน่งชิดขอบแล้ว AABB ของกรอบอาจยื่นเกินหน้า — เลื่อนกลับเข้าทันที
// (validator ตรวจ AABB ไม่ติดลบ ไม่งั้น update โดนปฏิเสธแล้วเด้ง — review 2026-09-28)
return {
...rest,
rotation: patch.rotation,
position: clampStampIntoPage({ ...rest, rotation: patch.rotation }),
};
}
case "textbox":
if (!("fontSize" in base)) return base;
return { ...base, ...patch.fields };
case "label": {
if (!("label" in base)) return base;
const relabeled = { ...base, label: patch.label };
// ข้อความใหม่เปลี่ยนจำนวนบรรทัด/ความสูงกรอบ — เอียงแล้ว AABB ยื่นเกินหน้าได้
// เช่นเดียวกับ stampScale จึง clamp กลับเข้าหน้าทันที (code review 2026-09-29)
return relabeled.rotation
? { ...relabeled, position: clampStampIntoPage(relabeled) }
: relabeled;
}
case "textboxText": {
if (!("fontSize" in base)) return base;
// segments null = เคลียร์ตัวหนาเฉพาะส่วนทั้งก้อน (ไม่มีส่วนตัวหนาเหลืออยู่แล้ว)
const { segments: _prev, ...rest } = base;
return { ...rest, text: patch.text, ...(patch.segments ? { segments: patch.segments } : {}) };
}
}
}
export default function PDFViewer({
file,
initialAnnotations,
initialBookmarks,
initialReplies,
user,
}: {
file: { id: string; title: string; pdfUrl: string };
initialAnnotations: AnnotationDTO[];
initialBookmarks: BookmarkDTO[];
initialReplies: ReplyDTO[];
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */
user: OacUser;
}) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
// N3: src ของรูปภาพต่อหน้า (คู่ขนานกับ doc ของ pdf — ไฟล์รูปใช้อันนี้แทน pdfjs/doc)
const [imageSrcs, setImageSrcs] = useState<(string | null)[] | null>(null);
// โหลดเอกสารพัง (ไฟล์เสีย/decode ไม่ได้) — แสดงข้อความแทน "กำลังโหลด…" ค้างตลอดไป
const [loadError, setLoadError] = useState<string | null>(null);
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
const [actionError, setActionError] = useState<string | null>(null);
const actionErrorTimerRef = useRef<number | null>(null);
// Responsive audit 2026-09-24: hint/คำเตือนของ tool ที่เพิ่งสลับ เมื่อแถบที่หัว main
// พ้นจอไปแล้ว — โชว์ toast สั้น 4 วิแทน (จอสัมผัสไม่มี hover tooltip ช่วยชี้ทาง)
const [toolHintToast, setToolHintToast] = useState<string | null>(null);
const toolHintTimerRef = useRef<number | null>(null);
const [pageSizes, setPageSizes] = useState<Size[]>([]);
const [tool, setTool] = useState<Tool>("select");
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
const [drawPen, setDrawPen] = useState<DrawPen>("pencil"); // ชนิดปากกาของ tool draw (ปากกา/ไฮไลต์)
// ค่าปากกาปัจจุบัน — เลือก pen = รีเซ็ตเป็น preset ของ pen นั้น แล้วปรับแต่ต่อได้ (เมนูของปุ่ม DRAW)
const [drawColor, setDrawColor] = useState(DRAW_PENS.pencil.color);
const [drawWidthPct, setDrawWidthPct] = useState(DRAW_PENS.pencil.strokeWidthPct);
const [drawOpacityPct, setDrawOpacityPct] = useState(DRAW_PENS.pencil.opacity * 100);
// ค่าตั้งตอนสร้างของ shape/textbox/stamp — เมนูตั้งค่าบนปุ่ม tool แบบเดียวกับปากกาของ draw
const [shapeColor, setShapeColor] = useState(SHAPE_DEFAULT_COLOR);
const [shapeWidthPct, setShapeWidthPct] = useState(0.4); // % ของความกว้างหน้า
const [textboxColor, setTextboxColor] = useState(TEXTBOX_DEFAULT_COLOR);
const [textboxFontSizePct, setTextboxFontSizePct] = useState(TEXTBOX_DEFAULT_FONT_SIZE_PCT);
const [textboxBold, setTextboxBold] = useState(false);
const [textboxOpacityPct, setTextboxOpacityPct] = useState(100);
const [textboxAlign, setTextboxAlign] = useState<"left" | "center" | "right">("left");
const [stampColor, setStampColor] = useState(STAMP_COLOR);
const [stampScalePct, setStampScalePct] = useState(STAMP_DEFAULT_SCALE_PCT);
const [stampPreset, setStampPreset] = useState<string>(STAMP_PRESETS[0]); // F18: preset ตรายาง
const [stampMenuOpen, setStampMenuOpen] = useState(false); // F18: dropdown เลือก preset ตรายาง
// หน้า popup "กำหนดเอง" ในเมนูตรายาง (ข้อความ/สี/ขนาด + preview) — reset กลับหน้า preset ทุกครั้งที่เปิดเมนูใหม่
const [stampCustomMode, setStampCustomMode] = useState(false);
// ปุ่มแถว "กำหนดเอง…" ของหน้ารายการ — ย้อนกลับจากหน้ากำหนดเองแล้วคืน focus มาที่นี่
// (input ถูก unmount พร้อม focus ที่อยู่บนมัน ไม่คืนแล้ว focus ตกไป body — UX audit 2026-09-28)
const stampCustomRowRef = useRef<HTMLButtonElement | null>(null);
const [shapeMenuOpen, setShapeMenuOpen] = useState(false); // F5: dropdown เลือกชนิดรูปทรง
const [drawMenuOpen, setDrawMenuOpen] = useState(false); // dropdown เลือกชนิดปากกา (draw)
const [textboxMenuOpen, setTextboxMenuOpen] = useState(false); // dropdown ตั้งค่ากล่องข้อความ
// tool highlight — คลุมข้อความแล้วสร้างทันทีตามรูปแบบ/สีที่ตั้งไว้ใน dropdown ของปุ่ม
const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>("highlight");
/** ความทึบของมาร์กใหม่ 0–100 (เก็บเป็น % — เหมือน drawOpacityPct ของ tool draw) */
const [highlightOpacityPct, setHighlightOpacityPct] = useState(100);
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_DEFAULT_COLOR);
const [highlightMenuOpen, setHighlightMenuOpen] = useState(false);
// 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 [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
// tool เลือก — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์)
const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
const [composer, setComposer] = useState<Composer | null>(null);
const [draft, setDraft] = useState("");
const [openPinId, setOpenPinId] = useState<string | null>(null);
// F1 แก้ไข/ย้าย/ปรับขนาด — รายการที่เลือก + data ชั่วคราวระหว่างลาก (render ที่ layer เดิม)
const [selectedId, setSelectedId] = useState<string | null>(null);
// N1: annotation ที่ mouse ชี้อยู่บนหน้า — แสดง tooltip ใครทำ + โน้ต (เปลี่ยนค่าเฉพาะเมื่อรายการเปลี่ยน)
const [hoveredId, setHoveredId] = useState<string | null>(null);
// N2: popup บันทึก bookmark (ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ) — null = ปิด
const [bookmarkComposer, setBookmarkComposer] = useState<{
pageIndex: number;
yOffset: number;
title: string;
note: string;
parentId: string | null;
} | null>(null);
/** error ล่าสุดของการบันทึก bookmark — แสดงใน popup ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนเงียบ */
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
const bookmarkPendingRef = useRef(false);
/** M6 (UX audit 2026-09-16): feedback สั้น ๆ บนปุ่ม 🔖 หลังบันทึกสำเร็จ — sidebar ที่ปิด
* อยู่ (ค่า default ของ iPad) ทำให้ไม่มีอะไรบอกว่าเซฟแล้ว ผู้ใช้กดซ้ำจนได้ bookmark ซ้ำ */
const [bookmarkSaved, setBookmarkSaved] = useState(false);
const bookmarkSavedTimerRef = useRef<number | null>(null);
/** L6: สถานะกำลังบันทึกของปุ่มใน popup bookmark — กันกดซ้ำเพราะคิดว่ากดไม่เข้า */
const [bookmarkSaving, setBookmarkSaving] = useState(false);
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [commentEditDraft, setCommentEditDraft] = useState("");
// F17/B2: กล่องข้อความ — composer ตอนสร้าง + โหมดแก้ไขข้อความของตัวเอง (double-click)
const [textboxComposer, setTextboxComposer] = useState<TextboxComposer | null>(null);
const [editingTextboxId, setEditingTextboxId] = useState<string | null>(null);
// F4 ซ่อน–แสดงรายคน — "all" | "mine" | <userId> กรองฝั่ง client ล้วน (A3)
const [userFilter, setUserFilter] = useState<string>("all");
// UX review 2026-09-09: สลับ sidebar — จอ < xl เป็น overlay, desktop กดซ่อนเพื่อดู PDF เต็มจอ
// (เริ่ม true เพื่อให้ SSR เรนเดอร์แบบเปิดเหมือน desktop เดิม กัน hydration mismatch)
const [sidebarOpen, setSidebarOpen] = useState(true);
const {
annotations,
bookmarks,
replies,
setAnnotations,
setBookmarks,
setReplies,
} = useAnnotationRealtime(file.id, initialAnnotations, initialBookmarks, initialReplies);
// F2: undo/redo ต่อ session ต่อผู้ใช้ฝั่ง client (D10 — ไม่เก็บใน DB)
const {
push: pushHistory,
undo: undoHistory,
redo: redoHistory,
canUndo,
canRedo,
} = useHistory();
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
const [optimisticAnnotations, applyAnnotation] = useOptimistic(
annotations,
(state, action: AnnotationAction) => {
if (action.kind === "add") {
return state.some((a) => a.id === action.row.id)
? state
: [...state, action.row];
}
if (action.kind === "update") {
// replace ตาม id — idempotent เหมือน SSE update event (PLAN-FEAT.md §2)
return state.map((a) => (a.id === action.row.id ? action.row : a));
}
return state.filter((a) => a.id !== action.id);
},
);
// N2: การสร้าง bookmark รอผลจาก action ตรง ๆ (ไม่ optimistic) — เหลือแค่ remove
const [optimisticBookmarks, applyBookmark] = useOptimistic(
bookmarks,
(state, action: BookmarkAction) =>
action.kind === "remove" ? state.filter((b) => b.id !== action.id) : state,
);
// F4: รายชื่อผู้เขียน (unique ตาม userId จาก annotations — ตัวเองอยู่ใน option "ของฉัน" แล้ว)
const users = useMemo(() => {
const seen = new Map<string, string>();
for (const a of optimisticAnnotations) {
if (!seen.has(a.userId) && a.userId !== user?.userId) seen.set(a.userId, a.userName);
}
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
}, [optimisticAnnotations, user]);
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3) — REDACTION คงไว้เสมอแม้
// กรองดูของคนอื่น (D4 สูตรเดียวกับ export: กล่องดำหายจากจอ = เนื้อหาที่ปิดกั้น
// กลับมาอ่านได้ และจอจะไม่ตรงกับไฟล์ export ที่คงกล่องไว้)
const visibleAnnotations = useMemo(() => {
if (userFilter === "all") return optimisticAnnotations;
const target = userFilter === "mine" ? user?.userId : userFilter;
return optimisticAnnotations.filter(
(a) => a.userId === target || a.type === "REDACTION",
);
}, [optimisticAnnotations, userFilter, user]);
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
// (O(points) ของเส้น DRAW) ครั้งเดียวต่อ list แทนคำนวณใหม่ทุก mousemove
const hoverBBoxPct = useMemo(() => {
const m = new Map<AnnotationDTO["id"], Rect>();
for (const a of visibleAnnotations) {
if (a.type === "COMMENT") continue; // pin มี popover ของตัวเอง ไม่เข้า hit-test
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }, measureStampText));
}
return m;
}, [visibleAnnotations]);
// F9: จำนวน reply ต่อ comment (สำหรับ sidebar)
const replyCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const r of replies) {
counts[r.annotationId] = (counts[r.annotationId] ?? 0) + 1;
}
return counts;
}, [replies]);
// F9: replies ของแม่ที่ยังแสดงอยู่ (ตาม filter ของ F4) — กรองครั้งเดียว ไม่ใช่ทุกหน้า
const visibleReplies = useMemo(
() =>
replies.filter((r) => visibleAnnotations.some((a) => a.id === r.annotationId)),
[replies, visibleAnnotations],
);
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
// (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect)
useEffect(() => {
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;
/** A2 (code review 2026-09-21): เวลาที่แอนิเมชัน smooth scroll เลื่อนปุ่ม tool active
* (effect ด้านล่าง) จะหมด — listener ปิดเมนูของ C1 เช็ค ref นี้ก่อน ไม่งั้น scroll
* event ที่เหลือจากแอนิเมชันจะปิดเมนูที่ผู้ใช้เพิ่งเปิดระหว่างนั้นทันที (และ
* markMenuClosed กลืน tap ถัดไป 300ms ด้วย) */
const selfScrollUntilRef = useRef(0);
// 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 ถัดไปใกล้ ๆ ไม่สร้างรายการ
closeToolMenus();
setBookmarkComposer(null);
};
const onResize = () => {
if (window.innerWidth !== menuOpenWRef.current) close();
};
const onScroll = () => {
// A2: ข้าม scroll event ที่มาจากแอนิเมชันเลื่อนปุ่ม active ของเราเอง —
// กันเมนูที่ผู้ใช้เพิ่งเปิดกลางแอนิเมชันโดนปิดทันที
if (performance.now() < selfScrollUntilRef.current) return;
close();
};
toolbarRowRef.current?.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
return () => {
toolbarRowRef.current?.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
};
}, [anyMenuOpen]);
// UX review 2026-09-21 A2: เมื่อ tool เปลี่ยนแล้วปุ่มของ tool นั้นหลุดออกนอกจอ (แถบเลื่อน
// ของจอ < lg) ให้เลื่อนกลับเข้ามากึ่งกลางแถบ — กันกรณีเลื่อนแถบไปแตะ tool ท้ายแถว
// (เช่น ตรายาง) แล้วเลื่อนกลับหา undo จนปุ่ม active อยู่นอกจอ ผู้ใช้ลืมว่า tool ไหน
// กำลังทำงานแล้วแตะหน้าเอกสาร = สร้างรายการโดยไม่ตั้งใจ — ข้ามเมื่อมีเมนูเปิดอยู่
// เพราะ smooth scroll ของแถบจะ trigger listener ปิดเมนูด้านบนทันที (ปุ่ม dropdown
// ที่เพิ่งแตะเห็นอยู่แล้วจึงไม่ต้องเลื่อน), ทำเฉพาะจอแคบ < lg (desktop แถบไม่เลื่อน)
useEffect(() => {
if (anyMenuOpen) return;
// เงื่อนไขเป็นส่วนเติมเต็มของ min-width: 1024px (จุดเดียวกับ effect ปิด sidebar ด้านบน)
// ให้ตรงกับ max-lg ของ Tailwind (= not all and (min-width: 1024px)) — max-width: 1023px
// ธรรมดาจะพลาดจอกว้างเศษส่วน เช่น 1023.5px จาก display scaling 125%/150%
if (!window.matchMedia("not all and (min-width: 1024px)").matches) return;
const row = toolbarRowRef.current;
if (!row) return;
// หาปุ่มด้วย data-tool — ห้ามยึด title ของปุ่ม (title ของปุ่ม dropdown เปลี่ยนตาม
// ค่าที่เลือก/สถานะ active ไม่ match แบบ exact — เคยเสียเงียบ ๆ ตั้งแต่ suffix
// " — ค่าย่อย" ถูกใส่ใน title 2026-09-23)
const btn = row.querySelector<HTMLButtonElement>(`button[data-tool="${tool}"]`);
if (!btn) return;
const b = btn.getBoundingClientRect();
const r = row.getBoundingClientRect();
if (b.left >= r.left - 1 && b.right <= r.right + 1) return; // เห็นอยู่แล้ว
// บอก listener ของ C1 ให้เมิน scroll event ช่วง ~600ms นี้ (แอนิเมชันยาว ~450ms)
selfScrollUntilRef.current = performance.now() + 600;
row.scrollTo({
// กึ่งกลางแถบ — ปุ่มกว้างกว่าแถว (จอเล็กมาก) ก็ชิดซ้ายขอบแถบพอ
left: row.scrollLeft + b.left - r.left - Math.max(0, (r.width - b.width) / 2),
// L7: เคารพ prefers-reduced-motion (pattern เดียวกับ scrollToBelowHeader)
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
}, [tool, 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 },
);
}, []);
// PDF สแกนล้วน — ซ่อน tool มาร์กข้อความเหมือนไฟล์ภาพ (คำขอผู้ใช้ 2026-09-22):
// "ทุกหน้าที่ render แล้วไม่มี span เลย" โดย entry ของ pageSpanCount สะสมต่อหน้า
// และไม่ถูกลบตอน virtualization unmount (รีเซ็ตเฉพาะตอนสลับไฟล์) จึงเป็น latch
// ธรรมชาติ — เคยเจอหน้าที่มีข้อความแม้หน้าเดียว ไฟล์นี้ถือว่ามีข้อความ ปุ่มกลับ
// มาและไม่หายอีก (ไฟล์ผสมที่สแกนนำหน้า: ซ่อนช่วงแรก โผล่ครั้งเดียวเมื่อถึงหน้า
// ข้อความ — ดีกว่าปุ่มโผล่-หายตามหน้าที่กำลังเปิดดู)
const scannedOnly =
Object.keys(pageSpanCount).length > 0 && Object.values(pageSpanCount).every((n) => n === 0);
// ระหว่างอยู่ tool มาร์กข้อความแล้วผลว่าเป็นไฟล์สแกน (เช่น กด H ตอนหน้าแรกยัง
// ไม่ render) — กลับเครื่องมือเลือกให้ ไม่ค้างเป็น tool ที่ปุ่มหายไปแล้ว
useEffect(() => {
if (!scannedOnly) return;
setTool((prev) => (prev === "highlight" ? "select" : prev));
// ปุ่ม highlight กำลังถูกซ่อน — เมนู dropdown ที่เปิดค้างอยู่ (portal fixed) จะลอย
// ไร้ปุ่มยึด ปิดด้วยชุดเดียวกับ switchTool (PLAN-TOOLBAR.md ขั้น 2)
closeToolMenus();
}, [scannedOnly]);
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
// และ error พา viewer ไปโผล่แท็บนั้น): fetch เป็น blob แล้วกด <a download> โดยรับชื่อ
// ไฟล์จาก Content-Disposition ของ route (รองรับภาษาไทยผ่าน filename*) — พัง = toast ในหน้า
const handleExportPdf = async () => {
if (exporting) return;
setExporting(true);
setExportError(null);
try {
// F4: export ตาม filter รายคนที่กำลังดู ("mine" resolve เป็น userId จริงก่อนส่ง —
// route กรองฝั่ง server + เติมชื่อผู้ใช้ท้ายชื่อไฟล์, REDACTION ติดไปเสมอ)
const filterUser =
userFilter === "all"
? null
: userFilter === "mine"
? (user?.userId ?? null)
: userFilter;
const res = await fetch(
withBasePath(
`/api/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
),
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
const blob = await res.blob();
const disposition = res.headers.get("Content-Disposition") ?? "";
const utf8 = /filename\*=UTF-8''([^;\s]+)/i.exec(disposition);
const ascii = /filename="([^"]+)"/i.exec(disposition);
const name = utf8 ? decodeURIComponent(utf8[1]) : (ascii?.[1] ?? `${file.title}.pdf`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
console.error("export PDF ไม่สำเร็จ", err);
setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)");
window.setTimeout(() => setExportError(null), 6000);
} finally {
setExporting(false);
}
};
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
useEffect(() => {
let cancelled = false;
let loadedDoc: import("pdfjs-dist").PDFDocumentProxy | null = null;
// สลับไฟล์ (Next reuse component เดิมเมื่อเปลี่ยน dynamic param): เคลียร์เอกสาร
// ก่อนหน้าก่อนโหลดใหม่ — กัน state เก่า (doc ที่ถูก destroy / blob URL ที่ถูก revoke
// / span count ของหน้าเดิม) ถูก render ชั่วขณะกลางทาง แล้วกลับไป "กำลังโหลด…" ตามปกติ
setLoadError(null);
setPageSizes([]);
setImageSrcs(null);
setPageSpanCount({});
// N3: ไฟล์รูปภาพไม่ผ่าน pdfjs — โหลด static pdfUrl ตรง ๆ (ไม่มี ToUnicode ให้ซ่อม
// ตาม rules/pdf-viewer.md) แล้วผลิต pageSizes จาก naturalWidth/Height (browser คิด
// EXIF orientation ให้แล้ว) normalize กว้าง IMAGE_PAGE_WIDTH ให้ zoom/fit-width
// ทำงานเหมือนหน้า A4 — jpg/png คือ 1 หน้าเสมอ
if (fileKindFromUrl(file.pdfUrl) === "image") {
setDoc(null);
setPdfjs(null);
// สลับจากไฟล์ PDF ที่ tool มาร์กข้อความ active อยู่มาเป็นไฟล์ภาพ (dynamic param
// เปลี่ยน = Next reuse component เดิม ไม่ reset state): tool นี้ถูกซ่อนบนไฟล์
// ภาพ ต้องกลับไปเครื่องมือเลือก ไม่งั้นค้างเป็น tool ที่กดไม่ได้
setTool((prev) => (prev === "highlight" ? "select" : prev));
// ปุ่ม highlight ถูกซ่อนบนไฟล์ภาพ — เมนูที่เปิดค้างจากไฟล์เดิมจะลอยไร้ปุ่มยึด
// ปิดด้วยชุดเดียวกับ switchTool (PLAN-TOOLBAR.md ขั้น 2)
closeToolMenus();
const objectUrls: string[] = [];
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
const fileUrl = isHttpUrl(file.pdfUrl)
? withBasePath(`/api/pdf?fileId=${file.id}`)
: withBasePath(file.pdfUrl);
(async () => {
let pages: { src: string | null; width: number; height: number }[];
if (imageFormatFromUrl(file.pdfUrl) === "tiff") {
// N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์
// (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ
const buffer = await (await fetch(fileUrl)).arrayBuffer();
pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH);
for (const p of pages) if (p.src) objectUrls.push(p.src);
} else {
const img = new Image();
img.src = fileUrl;
await img.decode();
const factor = IMAGE_PAGE_WIDTH / img.naturalWidth;
pages = [
{ src: fileUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor },
];
}
if (cancelled) {
// cleanup ของ effect รันไปแล้วตอน objectUrls ยังว่าง (สลับไฟล์กลาง decode)
// — ต้อง revoke เอง ไม่งั้น blob ทั้งชุดรั่ว (plan-reviewer)
objectUrls.forEach((u) => URL.revokeObjectURL(u));
return;
}
setImageSrcs(pages.map((p) => p.src));
setPageSizes(pages.map((p) => ({ width: p.width, height: p.height })));
})().catch((err) => {
console.error("โหลดรูปไม่สำเร็จ", err);
if (!cancelled) setLoadError("โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ");
objectUrls.forEach((u) => URL.revokeObjectURL(u));
});
return () => {
cancelled = true;
// N3: revoke ทุก blob URL ตอนสลับไฟล์/unmount กัน memory leak (rules/pdf-viewer.md)
objectUrls.forEach((u) => URL.revokeObjectURL(u));
};
}
(async () => {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs");
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
let data: ArrayBuffer;
try {
const res = await fetch(withBasePath(`/api/pdf?fileId=${file.id}`));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.arrayBuffer();
} catch {
data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer();
}
const doc = await pdfjs.getDocument({ data }).promise;
loadedDoc = doc;
if (cancelled) {
void doc.destroy();
return;
}
// ขนาดทุกหน้าที่ scale 1 ครั้งเดียว — ใช้ทำ placeholder + คำนวณ bookmark
const sizes: Size[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
const vp = page.getViewport({ scale: 1 });
sizes.push({ width: vp.width, height: vp.height });
}
if (cancelled) {
void doc.destroy();
return;
}
setPdfjs(pdfjs);
setDoc(doc);
setPageSizes(sizes);
})().catch((err) => {
console.error("โหลดเอกสารไม่สำเร็จ", err);
if (!cancelled) setLoadError("โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ");
});
return () => {
cancelled = true;
if (loadedDoc) void loadedDoc.destroy();
};
}, [file.id, file.pdfUrl]);
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
// scroll container ของเอกสาร (<main>) — ตัวเลื่อนเดียวของ viewer (sidebar/header นิ่ง)
const mainRef = useRef<HTMLElement>(null);
// Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้า (invariant #10)
const [renderPages, setRenderPages] = useState<Set<number>>(new Set([0]));
useEffect(() => {
const n = pageSizes.length;
if (n === 0) return;
const visible = new Set<number>();
const io = new IntersectionObserver(
(entries) => {
for (const en of entries) {
const p = Number((en.target as HTMLElement).dataset.page);
if (en.isIntersecting) visible.add(p);
else visible.delete(p);
}
if (visible.size === 0) return;
const next = new Set<number>();
for (const p of Array.from(visible)) {
for (const d of [-1, 0, 1]) {
const q = p + d;
if (q >= 0 && q < n) next.add(q);
}
}
setRenderPages(next);
},
// root = scroll container ของเอกสาร ไม่ใช่ viewport (window ไม่ scroll แล้ว)
{ root: mainRef.current, rootMargin: "100% 0px" },
);
for (const el of pageWrapRefs.current) {
if (el) io.observe(el);
}
return () => io.disconnect();
}, [pageSizes]);
// หน้าปัจจุบัน = หน้าที่ midpoint ใกล้กึ่งกลาง viewport สุด (สำหรับ bookmark — PLAN.md §6.4)
useEffect(() => {
if (pageSizes.length === 0) return;
const onScroll = () => {
const main = mainRef.current;
if (!main) return;
const mr = main.getBoundingClientRect();
// กึ่งกลางจอ (client) แปลงเป็นพิกัดในเนื้อหาของ <main> เพื่อเทียบกับ midpoint หน้า
const center = main.scrollTop + (window.innerHeight / 2 - mr.top);
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < pageWrapRefs.current.length; i++) {
const el = pageWrapRefs.current[i];
if (!el) continue;
const r = el.getBoundingClientRect();
const mid = contentTopInScroll(main, r.top + r.height / 2);
const d = Math.abs(mid - center);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
setCurrentPage(best);
};
onScroll();
mainRef.current?.addEventListener("scroll", onScroll, { passive: true });
return () => mainRef.current?.removeEventListener("scroll", onScroll);
}, [pageSizes]);
// F12/C4: deep link `?page=N` (1-based) และ/หรือ `?ann=<uuid>` — อ่าน
// window.location.search ครั้งเดียวหลัง pageSizes พร้อม (ไม่ใช้ useSearchParams
// เลี่ยงข้อจำกัด prerender ของ Next 14) — placeholder มีขนาดจริงทุกหน้าจึงคำนวณ
// absolute scroll ได้เสมอแม้หน้ายังไม่ render (กลไกเดียวกับ bookmark — §5)
const deepLinkDoneRef = useRef(false);
useEffect(() => {
if (pageSizes.length === 0 || deepLinkDoneRef.current) return;
deepLinkDoneRef.current = true;
const params = new URLSearchParams(window.location.search);
let targetPage: number | null = null;
const annId = params.get("ann");
if (annId) {
const a = annotations.find((x) => x.id === annId);
if (a) {
// กระโดด + เลือกด้วยกลไกเดียวกับคลิกใน sidebar (เลื่อนถึง bbox ใต้ header)
targetPage = a.pageIndex;
goToAnnotation(a);
}
}
const pageParam = Number(params.get("page"));
if (targetPage === null && Number.isInteger(pageParam) && pageParam >= 1) {
targetPage = Math.min(pageParam - 1, pageSizes.length - 1);
const el = pageWrapRefs.current[targetPage];
const main = mainRef.current;
if (el && main) {
scrollToBelowHeader(main, contentTopInScroll(main, el.getBoundingClientRect().top));
}
}
}, [pageSizes, annotations]);
// จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง
// ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) —
// อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก
const fitWidthDoneRef = useRef(false);
useEffect(() => {
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
fitWidthDoneRef.current = true;
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0].width);
if (s !== null && s !== 1) setScale(s);
}, [pageSizes]);
// Responsive audit 2026-09-24: ผู้ใช้เคยซูมเองหรือยัง — toggle sidebar บน desktop
// เปลี่ยนความกว้าง main แล้ว re-fit ให้เฉพาะคนที่ยังไม่เคยซูมเอง (ปุ่ม % กลับ
// fit-width ได้ = ปลด flag ให้ re-fit ทำงานอีก)
const userZoomedRef = useRef(false);
useEffect(() => {
if (pageSizes.length === 0 || !fitWidthDoneRef.current || userZoomedRef.current)
return;
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0].width);
if (s !== null) setScale(s);
// sidebarOpen: จอ ≥ lg สลับ sidebar เปลี่ยนความกว้าง main; จอ < lg sidebar เป็น
// overlay ความกว้าง main ไม่เปลี่ยน — คำนวณได้ค่าเดิม setScale ค่าเท่าเดิม = no-op
}, [sidebarOpen, pageSizes]);
// hint/คำเตือนของ tool ที่เพิ่งสลับ: ถ้า main เลื่อนลงจนแถบที่หัวเอกสารหลุดจอไปแล้ว
// (scrollTop สูง) โชว์ toast สั้นแทน — สลับ tool กลางเอกสารเป็นเรื่องปกติของจอสัมผัส
useEffect(() => {
const hint =
PLACE_TOOL_HINTS[tool] ??
(tool === "redact"
? REDACT_WARN_TEXT
: tool === "erase"
? ERASE_HINT_TEXT
: null);
if (!hint) return;
const main = mainRef.current;
if (!main || main.scrollTop < 120) return; // แถบที่หัว main ยังอยู่ในจอ ไม่ต้องซ้ำ
setToolHintToast(hint);
if (toolHintTimerRef.current) window.clearTimeout(toolHintTimerRef.current);
toolHintTimerRef.current = window.setTimeout(() => setToolHintToast(null), 4000);
return () => {
if (toolHintTimerRef.current) window.clearTimeout(toolHintTimerRef.current);
};
}, [tool]);
useEffect(() => {
setDraft("");
}, [composer]);
// F5/F18 + DRAW: dropdown บนปุ่ม tool (ชนิดปากกา/รูปทรง/ตรายาง) — ปิดเมื่อคลิกข้างนอก
// ส่วน Esc ปิดแค่เมนูก่อน (stopPropagation กันชนคีย์ลัด F3 ที่จะ deselect ทุกอย่างพร้อมกัน)
const stampMenuRef = useRef<HTMLDivElement | null>(null);
const shapeMenuRef = useRef<HTMLDivElement | null>(null);
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 แรกหลังปิดเมนู
const menuClosedAtRef = useRef(0);
function markMenuClosed() {
menuClosedAtRef.current = Date.now();
}
/** ตารางเมนู dropdown ของ tool — key ครบตาม TOOL_MENU_KEYS โดย Record บังคับที่ระดับ
* type: เพิ่มเมนูใหม่ใน toolbar-tools.ts แล้วลืมแถวนี้ = compile fail ทันที แทนการ
* enumerate setter ด้วยมือ 6 แห่งเดิม (เพิ่มเมนู/แก้พฤติกรรมแล้วลืมแห่งเดียว = drift
* เงียบ — เคยเกิดจริงกับ title ของปุ่ม dropdown ตั้งแต่ 2026-09-23, commit 8571ef9) */
const TOOL_MENU_TABLE: Record<
MenuTool,
{ open: boolean; ref: RefObject<HTMLDivElement | null>; setOpen: (open: boolean) => void }
> = {
highlight: { open: highlightMenuOpen, ref: highlightMenuRef, setOpen: setHighlightMenuOpen },
draw: { open: drawMenuOpen, ref: drawMenuRef, setOpen: setDrawMenuOpen },
shape: { open: shapeMenuOpen, ref: shapeMenuRef, setOpen: setShapeMenuOpen },
textbox: { open: textboxMenuOpen, ref: textboxMenuRef, setOpen: setTextboxMenuOpen },
stamp: { open: stampMenuOpen, ref: stampMenuRef, setOpen: setStampMenuOpen },
};
/** ปิดเมนู dropdown ของ tool ทั้งชุด — จุดที่เคย enumerate setter ด้วยมือ (switchTool/
* outside-click/เลื่อนแถบ·resize/auto-select/เปิด popup bookmark/reset tool ตรง) เรียก
* ตัวเดียวกันหมดจากนี้ */
function closeToolMenus() {
for (const key of TOOL_MENU_KEYS) TOOL_MENU_TABLE[key].setOpen(false);
}
/** เคลียร์ popup ที่เปิดอยู่บนหน้าเอกสารทั้งชุด — popup bookmark + composer + popover
* ของ pin + แผง selection + editor ข้อความ (comment/textbox) + preview ตามหลัก
* "popup เปิดได้ทีละชุด ห้ามค้างเปิดซ้อนกัน" (rules/pdf-viewer.md) */
function closePagePopups() {
setBookmarkComposer(null);
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
}
// Major 1 (code review): ความกว้าง viewport ตอนเปิดเมนูล่าสุด — resize ของมือถือจาก URL bar
// ยุบ/กาง หรือคีย์บอร์ดขึ้น เปลี่ยนแค่ความสูง (เมนู fixed ยึด sticky header ยังอยู่กับที่)
// จะปิดเมนูเฉพาะเมื่อความกว้างเปลี่ยน (หมุนจอ/split view) เท่านั้น
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);
useEffect(() => {
const menus = [
...TOOL_MENU_KEYS.map((key) => {
const m = TOOL_MENU_TABLE[key];
return { open: m.open, ref: m.ref, close: () => m.setOpen(false) };
}),
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
];
if (!menus.some((m) => m.open)) return;
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();
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
markMenuClosed(); // M4: Escape แล้วแตะหน้าเร็ว ๆ ก็ต้องไม่สร้างรายการ
for (const m of menus) m.close();
}
};
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [...TOOL_MENU_KEYS.map((k) => TOOL_MENU_TABLE[k].open), bookmarkComposer]);
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
// contenteditable (PLAN-FEAT.md §3 F3, §5)
const shortcutHandlersRef = useRef({
switchTool,
openBookmarkComposer,
undoHistory,
redoHistory,
deleteSelected: () => {},
});
useEffect(() => {
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
shortcutHandlersRef.current = {
switchTool,
openBookmarkComposer,
undoHistory,
redoHistory,
// DEL/Backspace = ลบรายการที่เลือก (ของตัวเองเท่านั้น — เงื่อนไขเดียวกับปุ่มลบในแผง selection)
deleteSelected: () => {
if (selectedId === null || !user) return;
const target = annotations.find((a) => a.id === selectedId);
if (!target || target.userId !== user.userId) return;
void handleDeleteAnnotation(selectedId);
},
};
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
const inForm =
t !== null &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" || // รวม dropdown (เช่น "วางที่" ของ popup bookmark N2) — กดคีย์ลัดใน select ต้องข้าม
t.isContentEditable);
const h = shortcutHandlersRef.current;
if (e.key === "Escape") {
if (inForm) return; // ฟอร์ม (เช่น composer) จัดการ Esc เอง
// ชุดเดียวกับ switchTool — จุด enumerate setter ด้วยมือจุดสุดท้ายของฝั่ง popup
closePagePopups();
return;
}
if (e.ctrlKey || e.metaKey) {
// KeyZ = ตำแหน่งกายภาพของปุ่ม — layout ไทยให้ e.key "ฎ" ทำให้ undo/redo
// ใช้ไม่ได้ถ้าเช็ค e.key (เหตุผลเดียวกับคีย์ลัด H/D/C/B — PLAN-TOOLBAR #1)
if (e.code === "KeyZ" && !inForm) {
e.preventDefault();
if (e.shiftKey) void h.redoHistory();
else void h.undoHistory();
}
return; // Ctrl/Cmd + อื่น (เช่น Ctrl+C) ไม่ใช่คีย์ลัดของเรา
}
if (e.altKey || inForm) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
h.deleteSelected();
return;
}
// คีย์ลัดจับด้วย e.code (ตำแหน่งปุ่ม) ไม่ใช่ e.key — layout ไทยพิมพ์ "ภ"/"น"
// ไม่ใช่ "h"/"b" ทำให้ทั้งชุดตายบน layout ไทย (PLAN-TOOLBAR #1)
const shortcutTool = TOOL_SHORTCUTS[e.code];
if (shortcutTool) {
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กดคีย์ลัดแล้ว React focus
// ช่องใหม่ทันทีใน keydown เดียวกัน browser จึงพิมพ์ตัวอักษรตามลงไป
e.preventDefault();
h.switchTool(shortcutTool);
return;
}
if (e.code === "KeyB") {
e.preventDefault();
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// ─── เขียนผ่าน Server Actions + optimistic (invariant #4, #9) ──────────────
// FIX-2: create ที่ยังรอ network อยู่ (key = temp id) —
// ลบทันทีก่อน action เสร็จ → mark cancelled แล้วฝั่ง create ลบ row จริงต่อเมื่อ
// action กลับมา (id ชั่วคราวยังไม่มีใน DB — ยิง delete ตอนนี้จะพัง แล้วรายการ
// โผล่กลับมาเมื่อ create เสร็จช้ากว่า)
const pendingCreatesRef = useRef(new Map<string, { cancelled: boolean }>());
/** H1: toast แจ้ง error ของ action 6 วินาที (pattern เดียวกับ exportError) */
function showActionError(message: string) {
setActionError(message);
if (actionErrorTimerRef.current !== null) window.clearTimeout(actionErrorTimerRef.current);
actionErrorTimerRef.current = window.setTimeout(() => {
actionErrorTimerRef.current = null;
setActionError(null);
}, 6000);
}
async function handleCreateAnnotation(
pageIndex: number,
type: AnnotationType,
data: AnnotationData,
) {
if (!user) return;
const now = new Date().toISOString();
const temp: AnnotationDTO = {
id: crypto.randomUUID(),
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
createdAt: now,
updatedAt: now,
resolved: false,
resolvedAt: null,
};
const pending = { cancelled: false };
pendingCreatesRef.current.set(temp.id, pending);
await startTransition(async () => {
applyAnnotation({ kind: "add", row: temp });
try {
const saved = await createAnnotation({
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
});
if (pending.cancelled) {
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
await deleteAnnotation(saved.id, user.userId);
return;
}
setAnnotations((prev) =>
prev.some((a) => a.id === saved.id) ? prev : [...prev, saved],
);
// สร้าง "วัตถุ" เสร็จ = เลือกตัวใหม่ทันที (ใช้ id จาก server — id ชั่วคราวของ
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
if (AUTO_SELECT_TYPES.has(type)) {
setSelectedId(saved.id);
setPreviewData(null);
// แผง selection กำลังจะโผล่ = ปิดเมนู tool/popup bookmark ที่เปิดค้างก่อน — บน iOS
// การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนู
// เอง (Android/desktop ปิดไปแล้วตั้งแต่เริ่มกด) ไม่ปิดตรงนี้ = เมนูซ้อนแผงบนจอสัมผัส
closeToolMenus();
setBookmarkComposer(null);
}
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
let currentId = saved.id;
pushHistory({
undo: async () => {
await deleteAnnotation(currentId, user.userId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
},
redo: async () => {
const re = await createAnnotation({
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
});
currentId = re.id;
setAnnotations((prev) =>
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
);
},
});
} catch (err) {
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
} finally {
pendingCreatesRef.current.delete(temp.id);
}
});
}
async function handleDeleteAnnotation(id: string) {
if (!user) return;
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
// optimistic row จะหายเองเมื่อ transition ของ create จบลง
const pending = pendingCreatesRef.current.get(id);
if (pending) {
pending.cancelled = true;
setSelectedId(null);
setPreviewData(null);
return;
}
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
setSelectedId(null);
setPreviewData(null);
await startTransition(async () => {
applyAnnotation({ kind: "remove", id });
try {
await deleteAnnotation(id, user.userId);
setAnnotations((prev) => prev.filter((a) => a.id !== id));
if (target) {
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
let currentId: string | null = null;
pushHistory({
undo: async () => {
const re = await createAnnotation({
fileId: file.id,
pageIndex: target.pageIndex,
userId: target.userId,
userName: target.userName,
type: target.type,
data: target.data,
});
currentId = re.id;
setAnnotations((prev) =>
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
);
},
redo: async () => {
if (!currentId) return;
await deleteAnnotation(currentId, user.userId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
},
});
}
} catch (err) {
console.error("ลบ annotation ไม่สำเร็จ", err);
showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
// ─── คิว update ต่อรายการ (code review 2026-09-28 รอบสอง) ────────────────────
// จอสัมผัส: blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผง selection ต่อทันที —
// ทั้งสอง event วิ่งก่อน React render optimistic row คอมมิตตัวหลังจึงอ่าน base จาก
// snapshot เก่าจนทับค่าที่เพิ่งเปลี่ยน (และ server action สองตัวแข่งกันไม่เรียงลำดับ)
// — update ทุกตัวของ id เดียวกันจึงเข้าคิวเดียวกัน โดย base คำนวณ**ตอนถึงคิว**จาก
// data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ (pendingDataBaseRef) ไม่ใช่ state ณ ตอน event
const updateChainRef = useRef(new Map<string, Promise<void>>());
const pendingDataBaseRef = useRef(new Map<string, AnnotationData>());
/** แกน update เดิม (optimistic + action + history) — เรียกเมื่อถึงตาของคิวเท่านั้น
* คืน promise ที่ resolve เมื่อ server action จบจริง (resolve ใน finally ข้างใน scope —
* `await startTransition(async …)` ของ React 18 คืน control ตั้งแต่ callback สลับไปรอ
* await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */
function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> {
return new Promise((resolve) => {
if (!user) return resolve();
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
setPreviewData(null);
startTransition(async () => {
try {
applyAnnotation({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
});
const saved = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
// ทิ้ง base ที่คิวเก็บไว้ — เป็น data ที่ action นี้ตั้งใจเขียนแต่ไม่เคยติด DB
// ถ้าคงไว้ update ถัดไปจะผสานจน commit การเปลี่ยนที่ล้มไปด้วย และ undo
// history กู้สถานะที่ไม่มีอยู่จริง (code review 2026-09-29) — รอบหน้าเริ่ม
// จาก state จริงหลัง rollback แทน
pendingDataBaseRef.current.delete(id);
} finally {
resolve();
}
});
});
}
/** เข้าคิว update ของ id นี้ — make คำนวณ data จาก base ล่าสุดตอนถึงตาของคิว */
function enqueueUpdate(id: string, make: (base: AnnotationData) => AnnotationData) {
const prev = updateChainRef.current.get(id) ?? Promise.resolve();
const run = prev
.catch(() => {}) // action ล้มจัดการใน applyUpdate แล้ว — กัน reject ตกทอดตัดคิวที่เหลือ
.then(() => {
const fromState = annotations.find((a) => a.id === id)?.data;
const base = pendingDataBaseRef.current.get(id) ?? fromState;
if (!base) return;
const data = make(base);
pendingDataBaseRef.current.set(id, data);
return applyUpdate(id, data, base);
});
updateChainRef.current.set(id, run);
const cleanup = () => {
// คิวว่างแล้ว (ไม่มีตัวใหม่แทรกระหว่างรอ) — ทิ้ง base ค้างให้รอบหน้าเริ่มจาก state จริง
if (updateChainRef.current.get(id) === run) {
updateChainRef.current.delete(id);
pendingDataBaseRef.current.delete(id);
}
};
// cleanup ทั้งสองทาง — ถ้า run reject (คำนวณ base/applyUpdate โยน) ก็ต้องไม่ค้างคิวของ
// id นี้ตลอดกาลจนการแก้ไขรายการนี้เงียบไปหมด (code review 2026-09-29)
void run.then(cleanup, cleanup);
}
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
// (data เต็มจาก drag/preview — ผ่านคิวเดียวกันให้ server action เรียงลำดับเสมอ)
function handleUpdateAnnotation(id: string, data: AnnotationData) {
enqueueUpdate(id, () => data);
}
/** ตัวควบคุมเล็กของแผง selection / editor ข้อความ — patch ผสานกับ base ล่าสุดตอนถึงคิว */
function handlePatchAnnotation(id: string, patch: AnnotationPatch) {
enqueueUpdate(id, (base) => applyAnnotationPatch(base, patch));
}
// ─── Resolve (F10/A5 — ใครก็ได้ toggle ตาม D6; ไม่เข้า history ของ F2
// เพราะ undo ผ่าน updateAnnotation จำกัดเจ้าของ และสเปก F2 ก็ไม่รวม resolve) ──
async function handleSetResolved(id: string, resolved: boolean) {
const current = annotations.find((a) => a.id === id);
if (!current) return;
const now = new Date().toISOString();
await startTransition(async () => {
applyAnnotation({
kind: "update",
row: {
...current,
resolved,
resolvedAt: resolved ? now : null,
updatedAt: now,
},
});
try {
const saved = await setAnnotationResolved(id, resolved);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
} catch (err) {
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
async function handleCreateReply(
annotationId: string,
message: string,
): Promise<boolean> {
if (!user) return false;
try {
const saved = await createReply({
annotationId,
userId: user.userId,
userName: user.userName,
message,
});
setReplies((prev) =>
prev.some((r) => r.id === saved.id) ? prev : [...prev, saved],
);
return true;
} catch (err) {
console.error("ตอบ comment ไม่สำเร็จ", err);
showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
return false;
}
}
async function handleDeleteReply(id: string) {
if (!user) return;
try {
await deleteReply(id, user.userId);
setReplies((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
console.error("ลบคำตอบไม่สำเร็จ", err);
showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
}
// ─── Bookmarks (N2: หมุดหัวข้อใหญ่ + sub-bookmark + โน้ต) ────────────────────
/** ข้อความที่เพิ่งคลุมบนหน้าล่าสุด — ชื่อ default ของ popup bookmark (จาก mouseup บนหน้า) */
const lastSelectionTextRef = useRef("");
/** เส้นทางลากเมาส์ของ tool highlight/select (client coords) — จุดกดเมาส์ + ตำแหน่ง
* ล่าสุด ใช้เป็น ground truth กรอง selection rects (DOM order ของ textLayer บาง PDF
* มั่วจน range ครอบบรรทัดที่ไม่ได้ลากผ่าน) — เคลียร์ตอนปล่อยเมาส์ (document mouseup
* ยิงหลัง React onMouseUp ของ page เพราะ root อยู่ใต้ document) */
const selDragStartRef = useRef<{ x: number; y: number } | null>(null);
const selDragEndRef = useRef<{ x: number; y: number } | null>(null);
/** เส้นทางลากล่าสุด (px อิงหน้า) + ข้อความดิบ/ความกว้างหน้า ณ mouseup — ให้ event
* copy นำไปกรองแนวที่ลากจริงแบบเดียวกับตอนสร้างมาร์ก (DOM range ของ textLayer
* ไทยครอบ span เกินบริเวณที่เห็น Ctrl+C เลยได้ข้อความเกินมา) — เทียบข้อความ
* ดิบ + ความกว้างหน้าก่อนใช้ กันข้อมูลเก่าหลัง selection/zoom เปลี่ยน */
const lastSweepRef = useRef<{
pageIndex: number;
start: ClientPt;
end: ClientPt;
pageWidth: number;
raw: string;
} | null>(null);
/** ความลึกของ bookmark (root = 1) บน state ปัจจุบัน — popup ใช้กรองเป้าหมายระดับ 1–2
* (cap 100 กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) */
function bookmarkDepthOf(b: BookmarkDTO): number {
let depth = 1;
let cur: BookmarkDTO | undefined = b;
while (cur?.parentId && depth <= 100) {
const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId);
if (!parent) break;
depth += 1;
cur = parent;
}
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() {
// ปิดเมนู tool ที่เปิดอยู่ก่อน (เช่นคีย์ลัด B ระหว่างเมนูเปิด) — popup bookmark (z เดียวกัน
// ทั้งคู่ fixed ใต้แถบ tool) เปิดซ้อนบนเมนูได้; ไม่ markMenuClosed เพราะ B เป็นคีย์บอร์ด
// ไม่มี click หลงเหลือให้กลืน
closeToolMenus();
// เคลียร์ popup บนหน้าที่ค้างเปิดด้วย (popover ของ pin/แผง selection/editor ข้อความ) —
// ไม่งั้น popup bookmark ซ้อนของเก่า และ autoFocus ของช่องชื่อแย่ง focus จาก editor
// ที่ยังเปิดอยู่ (finding #2 ของ PLAN-TOOLBAR.md)
closePagePopups();
const pos = currentViewPosition();
if (!pos) return;
setBookmarkError(null);
positionBookmarkPopup();
// ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที)
setBookmarkComposer({
...pos,
title: lastSelectionTextRef.current || `หน้า ${pos.pageIndex + 1}`,
note: "",
parentId: null,
});
}
/** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น
* ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้น = "หน้า N" */
function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) {
// ผ่านชุดเคลียร์เดียวกับ openBookmarkComposer — ปิดเมนู+popup บนหน้า (แผง selection/
// editor ที่กำลังเปิดอยู่นั้นเอง) ก่อนเปิด popup bookmark กันซ้อนกันและ autoFocus
// แย่ง focus (code review ขั้น 2 — ทางเปิด popup bookmark ทางเดียวที่เหลือ)
closeToolMenus();
closePagePopups();
const meaningful =
a.type === "HIGHLIGHT" ||
a.type === "TEXTBOX" ||
a.type === "STAMP" ||
a.type === "COMMENT";
const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "";
setBookmarkError(null);
positionBookmarkPopup();
setBookmarkComposer({
pageIndex: a.pageIndex,
yOffset: clamp(yOffsetPct, 0, 100),
title: text || `หน้า ${a.pageIndex + 1}`,
note: "",
parentId: null,
});
}
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
function currentViewPosition() {
const el = pageWrapRefs.current[currentPage];
const main = mainRef.current;
if (!el || !main) return null;
const r = el.getBoundingClientRect();
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
// แถบมองเห็นของ scroll container (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader)
// ตอนกด bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
const viewTop = main.scrollTop + SCROLL_BELOW_HEADER_GAP;
const pageTop = contentTopInScroll(main, r.top);
return {
pageIndex: currentPage,
yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
};
}
/** M6: ปุ่ม 🔖 เปลี่ยนเป็น ✓ เขียว 1.5 วิ (pattern เดียวกับ copiedId ของ sidebar) */
function flashBookmarkSaved() {
setBookmarkSaved(true);
if (bookmarkSavedTimerRef.current !== null) {
window.clearTimeout(bookmarkSavedTimerRef.current);
}
bookmarkSavedTimerRef.current = window.setTimeout(() => {
bookmarkSavedTimerRef.current = null;
setBookmarkSaved(false);
}, 1500);
}
async function handleSaveBookmark() {
const c = bookmarkComposer;
if (!c || !user || bookmarkPendingRef.current) return;
bookmarkPendingRef.current = true;
setBookmarkSaving(true);
try {
const saved = await createBookmark({
fileId: file.id,
pageIndex: c.pageIndex,
userId: user.userId,
userName: user.userName,
title: c.title.trim() || `หน้า ${c.pageIndex + 1}`,
yOffset: c.yOffset,
parentId: c.parentId,
note: c.note.trim() || null,
});
// ปิด popup เฉพาะเมื่อสำเร็จ — ถ้า fail (เช่น หัวข้อแม่ถูกคนอื่นลบไประหว่างกรอก)
// คงข้อมูลที่พิมพ์ไว้ + แสดง error ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนหายเงียบ ๆ
setBookmarkComposer(null);
setBookmarkError(null);
setBookmarks((prev) =>
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
);
flashBookmarkSaved();
} catch (err) {
console.error("บันทึก bookmark ไม่สำเร็จ", err);
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
} finally {
bookmarkPendingRef.current = false;
setBookmarkSaving(false);
}
}
/** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */
async function handleAddSubBookmark(parentId: string) {
if (!user || bookmarkPendingRef.current) return;
const pos = currentViewPosition();
if (!pos) return;
bookmarkPendingRef.current = true;
try {
const saved = await createBookmark({
fileId: file.id,
pageIndex: pos.pageIndex,
userId: user.userId,
userName: user.userName,
title: `หน้า ${pos.pageIndex + 1}`,
yOffset: pos.yOffset,
parentId,
});
setBookmarks((prev) =>
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
);
} catch (err) {
console.error("เพิ่ม sub-bookmark ไม่สำเร็จ", err);
} finally {
bookmarkPendingRef.current = false;
}
}
/** แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (✏ ใน sidebar, N2) */
async function handleUpdateBookmark(
id: string,
patch: { title?: string; note?: string | null; parentId?: string | null },
) {
if (!user) return;
try {
const saved = await updateBookmark({ id, userId: user.userId, ...patch });
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
} catch (err) {
console.error("แก้ไข bookmark ไม่สำเร็จ", err);
}
}
async function handleDeleteBookmark(id: string) {
if (!user) return;
await startTransition(async () => {
applyBookmark({ kind: "remove", id });
try {
await deleteBookmark(id, user.userId);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
console.error("ลบ bookmark ไม่สำเร็จ", err);
}
});
}
// ─── Text selection → highlight (PLAN.md §6.1) ─────────────────────────────
/** อ่าน text selection ปัจจุบันบนหน้าที่ระบุ → rects % (รวบบรรทัดแล้ว) + ข้อความ + rect แรก
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก — ข้อความ (กรองให้เหลือ
* เฉพาะตัวอักษรใน rects ที่เห็นบนจอ) คำนวณเฉพาะเมื่อ withText เพราะแพง ส่วน live
* preview ที่เรียกถี่ระหว่างลากใช้แค่ rects
* opts.range = ใช้ Range จากภายนอกแทน native selection (custom selection จอสัมผัส
* ของ useCustomSelection — ตอนนั้น user-select:none ไม่มี native selection ให้อ่าน) */
function readTextSelection(
pageIndex: number,
opts?: { withText?: boolean; range?: Range },
) {
let range: Range;
if (opts?.range) {
range = opts.range;
} else {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return null;
}
range = selection.getRangeAt(0);
}
// รับเฉพาะ selection ใน page เดียว (PLAN.md §8)
const container = range.commonAncestorContainer;
const node =
container instanceof Element ? container : container.parentElement;
const pageEl = node?.closest<HTMLElement>("[data-page]");
if (!pageEl || Number(pageEl.dataset.page) !== pageIndex) return null;
const clientRects = Array.from(range.getClientRects()).filter(
(r) => r.width > 0,
);
if (clientRects.length === 0) return null;
// จำกัดเฉพาะแนวเส้นทางที่ลากเมาส์จริง — DOM range ของ textLayer ที่ span เรียง
// มั่วครอบ rect ของบรรทัดอื่นที่ไม่ได้ลากผ่าน (ไม่มีเส้นทางเมาส์ เช่น เลือกด้วย
// คีย์บอร์ด = ปล่อยผ่านตามเดิม ไม่ให้พังยิ่งกว่าเดิม)
const startPt = selDragStartRef.current;
const endPt = selDragEndRef.current;
const sweptRects =
startPt && endPt
? rectsInSweptPath(clientRects, startPt, endPt)
: clientRects;
if (sweptRects.length === 0) return null;
const pageRect = pageEl.getBoundingClientRect();
// จำเส้นทางลาก (px อิงหน้า) ไว้ให้ event copy ใช้กรองแบบเดียวกันนี้ — ครอบ
// ความกว้างหน้าด้วยเพื่อการ์ดตอน zoom เปลี่ยนหลังจากนี้ (ค่า px อิงหน้าจะสเกลตาม)
if (opts?.withText) {
lastSweepRef.current =
startPt && endPt
? {
pageIndex,
start: { x: startPt.x - pageRect.left, y: startPt.y - pageRect.top },
end: { x: endPt.x - pageRect.left, y: endPt.y - pageRect.top },
pageWidth: pageRect.width,
raw: range.toString(),
}
: null; // เลือกด้วยคีย์บอร์ด (ไม่มีเส้นทาง) — copy ใช้ rect ของ range ตรง ๆ
}
const rectsPct = sweptRects.map((r) =>
rectToPercent(
{
x: r.left - pageRect.left,
y: r.top - pageRect.top,
width: r.width,
height: r.height,
},
{ width: pageRect.width, height: pageRect.height },
),
);
return {
rects: mergeRectsByLine(rectsPct), // รวบเหลือ 1 rect ต่อบรรทัดก่อนบันทึก
// ข้อความกรองให้เหลือเฉพาะตัวอักษรใน rects ข้างบน (DOM range ไทยครอบคำเกิน
// บริเวณที่ลาก) และตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4)
// — text ใช้แค่ค้นหา/preview/ชื่อ bookmark
text: opts?.withText ? selectionTextWithin(sweptRects, range) : "",
first: sweptRects[0],
last: sweptRects[sweptRects.length - 1],
};
}
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
// tool ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
// จอสัมผัส (coarse) + tool highlight ไม่ผ่านทางนี้ — native selection ถูกปิดไว้
// ใช้ custom selection ของ useCustomSelection แทน (ด้านล่าง)
useEffect(() => {
if (!TEXT_SELECTION_TOOLS.has(tool)) {
setLiveMark(null);
setSelectPreview(null);
return;
}
const onSelectionChange = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setLiveMark(null);
setSelectPreview(null);
return;
}
const node = selection.getRangeAt(0).commonAncestorContainer;
const el = node instanceof Element ? node : node.parentElement;
const pageEl = el?.closest<HTMLElement>("[data-page]");
if (!pageEl) {
setLiveMark(null);
setSelectPreview(null);
return;
}
const pi = Number(pageEl.dataset.page);
const sel = readTextSelection(pi);
const setPreview = tool === "highlight" ? setLiveMark : setSelectPreview;
setPreview((prev) => {
const next = sel ? { pageIndex: pi, rects: sel.rects } : null;
// ค่าเดิมทุกอย่าง = คืน object เดิมให้ React ข้าม re-render (ลากทีละนิดไม่หนัก)
const same =
prev !== null &&
next !== null &&
prev.pageIndex === next.pageIndex &&
prev.rects.length === next.rects.length &&
prev.rects.every(
(r, idx) =>
r.x === next.rects[idx].x &&
r.y === next.rects[idx].y &&
r.width === next.rects[idx].width &&
r.height === next.rects[idx].height,
);
return same ? prev : next;
});
};
document.addEventListener("selectionchange", onSelectionChange);
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — listener นี้ที่ document ยิงหลัง React
// onMouseUp ของหน้า (root ของ React อยู่ใต้ document) ค่าจึงยังใช้ได้ตอนสร้างมาร์ก
const onMouseUpDoc = () => {
selDragStartRef.current = null;
selDragEndRef.current = null;
};
document.addEventListener("mouseup", onMouseUpDoc);
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("mouseup", onMouseUpDoc);
};
}, [tool]);
// Copy (Ctrl+C) ข้อความจาก textLayer — ข้อความดิบของ PDF ไทยบางไฟล์เพี้ยน (glyph
// ต่อ glyph + control char ขยะ + span เรียงไม่ตรงลำดับบนจอ) และ DOM range ครอบ
// span เกินบริเวณที่ลาก จึงแทนที่คลิปบอร์ดด้วยข้อความที่กรองตามเส้นทางลากล่าสุด
// (เทียบข้อความดิบ + ความกว้างหน้า — ต่างจากตอน mouseup = selection/zoom เปลี่ยน
// ไปแล้ว กลับไปใช้ rect ของ range เองทั้งหมด) เรียงตามตำแหน่งบนจอแล้วล้างขยะ
useEffect(() => {
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
const onCopy = (e: ClipboardEvent) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const node = range.commonAncestorContainer;
const el = node instanceof Element ? node : node.parentElement;
const pageEl = el?.closest<HTMLElement>("[data-page]");
if (!pageEl) return; // เฉพาะ selection ในหน้า PDF ของเรา
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
const sweep = lastSweepRef.current;
const pageRect = pageEl.getBoundingClientRect();
const fresh =
sweep !== null &&
sweep.pageIndex === Number(pageEl.dataset.page) &&
Math.abs(sweep.pageWidth - pageRect.width) < 1 &&
sweep.raw === selection.toString();
const kept =
sweep && fresh && rects.length > 0
? rectsInSweptPath(
rects,
{ x: sweep.start.x + pageRect.left, y: sweep.start.y + pageRect.top },
{ x: sweep.end.x + pageRect.left, y: sweep.end.y + pageRect.top },
)
: rects;
const text =
kept.length > 0
? selectionTextWithin(kept, range)
: sanitizePdfText(selection.toString());
e.preventDefault();
e.clipboardData?.setData("text/plain", text);
};
document.addEventListener("copy", onCopy);
return () => document.removeEventListener("copy", onCopy);
}, [tool]);
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
* และจอสัมผัส (custom selection ของ useCustomSelection commit ตอนปล่อยนิ้ว) */
function commitHighlight(
pageIndex: number,
sel: { rects: HighlightData["rects"]; text: string },
) {
setLiveMark(null);
window.getSelection()?.removeAllRanges();
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
text: sel.text,
color: highlightColor,
rects: sel.rects,
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
});
}
/** custom selection (จอสัมผัส + tool highlight) ปล่อยนิ้ว — hook สร้าง Range จาก
* caret ตำแหน่งนิ้วให้ อ่านเป็น rects+ข้อความผ่าน readTextSelection ชุดเดียวกับ
* desktop (อุดหนุเส้นทางลากให้ rectsInSweptPath กรองแนวที่ลากจริง) แล้ว commit
* ทันทีไม่มีปุ่มยืนยัน — สร้างเสร็จเลือกให้ทันทีผ่าน AUTO_SELECT_TYPES ของ
* handleCreateAnnotation เหมือน desktop */
function handleCustomSelectionCommit({
range,
start,
end,
}: CustomSelectionCommit) {
const node = range.commonAncestorContainer;
const el = node instanceof Element ? node : node.parentElement;
const pageEl = el?.closest<HTMLElement>("[data-page]");
if (!pageEl) return;
const pi = Number(pageEl.dataset.page);
// ยืม selDrag refs ชั่วคราวให้ readTextSelection กรองตามเส้นทางนิ้ว (desktop
// เขียนคู่นี้จาก mousedown/mousemove ของ page wrapper)
selDragStartRef.current = start;
selDragEndRef.current = end;
const sel = readTextSelection(pi, { withText: true, range });
selDragStartRef.current = null;
selDragEndRef.current = null;
if (!sel) return;
// ชื่อ bookmark default จาก selection เหมือน mouseup ของ desktop
lastSelectionTextRef.current = normalizeBookmarkTitle(sel.text);
commitHighlight(pi, sel);
}
function handleMouseUpOnPage(pageIndex: number) {
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
// อ่านครั้งเดียวพร้อมข้อความ (กรองตาม rects แล้ว) — bookmark กับมาร์กใช้ชุดเดียวกัน
const sel = readTextSelection(pageIndex, { withText: true });
lastSelectionTextRef.current = normalizeBookmarkTitle(sel?.text ?? "");
// มาร์กข้อความสร้างจาก tool highlight อย่างเดียว (มี live preview ระหว่างลาก)
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
if (tool !== "highlight") return;
if (!sel) return;
commitHighlight(pageIndex, sel);
}
/** อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount: เฉพาะพวกนี้
* ที่ใช้ custom selection ตอน tool highlight (desktop ใช้ native mouse selection
* + commit ตอน mouseup ตามเดิม ไม่เปลี่ยนพฤติกรรม) */
const [coarseTouch] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches,
);
// M3 (UX audit 2026-09-16): hint วิธีเลื่อนหน้าของ tool highlight บนจอสัมผัส (ช่วง
// tool นี้ลากที่ไหนก็ไม่เลื่อน — gesture เป็นของ selection) แสดง 3 ครั้งแรกต่อ
// session พอ ไม่รบกวนผู้ใช้ที่คุ้นแล้ว
const coarseHlHintCountRef = useRef(0);
const [coarseHlHintVisible, setCoarseHlHintVisible] = useState(false);
// จอสัมผัส + tool highlight — ลากเลือกข้อความเองผ่าน Pointer Events (ไม่พึ่ง
// native selection ของ OS ที่ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles): ลาก → ปล่อย
// = ไฮไลต์ทันที ผ่าน commitHighlight ชุดเดียวกับ desktop / preview ของ hook
// (สร้างสดระหว่างลาก) ทดแทน liveMark ของ selectionchange ที่ใช้ไม่ได้ตอนนั้น
const { previewRects: customSelPreview } = useCustomSelection({
containerRef: mainRef,
enabled: tool === "highlight" && coarseTouch,
onCommit: handleCustomSelectionCommit,
});
// ─── Comment (PLAN.md §6.3) ────────────────────────────────────────────────
/** N1: ชี้ mouse เหนือ annotation บนหน้า → เก็บ id ไว้แสดง tooltip (ใครทำ + โน้ต)
* hit-test ชุดเดียวกับการคลิกเลือก (ตัวบนสุดชนะ) — เปลี่ยน state เฉพาะเมื่อ id เปลี่ยน
* จึงไม่ re-render ทุก mousemove; ปิดช่วง tool ที่รับ pointer (วาด/ลบ), กดปุ่มค้าง
* หรือตอน popover ของ comment pin เปิดอยู่ (กัน tooltip ลอยทับ popover) */
function handlePageHoverMove(
e: ReactMouseEvent<HTMLDivElement>,
pageIndex: number,
) {
const frozen =
tool === "draw" ||
tool === "shape" ||
tool === "erase" ||
tool === "redact" ||
tool === "pan";
if (frozen || e.buttons !== 0 || openPinId !== null) {
if (hoveredId !== null) setHoveredId(null);
return;
}
const base = pageSizes[pageIndex];
if (!base) return;
const size = { width: base.width * scale, height: base.height * scale };
const p = getPointerPercent(e, e.currentTarget);
// เดินจากรายการหลังสุดก่อน (ตัวบนสุดชนะ) — for-loop ตรง ๆ ไม่สร้าง array ใหม่ต่อ mousemove
let id: string | null = null;
for (let k = visibleAnnotations.length - 1; k >= 0; k--) {
const a = visibleAnnotations[k];
if (a.pageIndex !== pageIndex || a.type === "COMMENT") continue; // pin มี popover ของตัวเองอยู่แล้ว
// FIX-8: bbox pre-filter ก่อน hit-test แม่น — hitTestPath ของเส้น DRAW เป็น
// O(points) ต่อเส้น ตัดรายการที่จุดอยู่ไกลทิ้งก่อนด้วย bbox + margin เผื่อ threshold
// ของ hit-test (generic +4px, DRAW/SHAPE +6px — bbox ของเส้นขยายความหนาครึ่งหนึ่งแล้ว)
const box = hoverBBoxPct.get(a.id);
if (box) {
const marginPx = 6;
const mx = (marginPx / size.width) * 100;
const my = (marginPx / size.height) * 100;
if (
p.x < box.x - mx ||
p.x > box.x + box.width + mx ||
p.y < box.y - my ||
p.y > box.y + box.height + my
) {
continue;
}
}
if (hitTestAnnotation(a, p, size)) {
id = a.id;
break;
}
}
setHoveredId((prev) => (prev === id ? prev : id));
}
function handlePageClick(
e: ReactMouseEvent<HTMLDivElement>,
pageIndex: number,
pageEl: HTMLElement,
) {
// ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก —
// selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือก ไม่เปิด
// 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)
// คลิกหน้าว่าง = ปิด popover ของ comment pin ด้วย — คลิกใน popover/pin ถูก
// stopPropagation ไว้แล้วจึงไม่โดนบรรทัดนี้
if (openPinId) setOpenPinId(null);
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
// logic เลิกเลือกท้ายฟังก์ชัน แผงจึงค้างเปิด) — คลิกบนตัว annotation เองถูก
// stopPropagation ที่ layer ของมันแล้ว ส่วน tool select/highlight ตั้ง selectedId
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
if (selectedId !== null) {
setSelectedId(null);
setPreviewData(null);
}
if (tool === "comment" && user) {
const position = getPointerPercent(e, pageEl);
setComposer({ pageIndex, position, x: e.clientX, y: e.clientY });
return;
}
// F17/B2: คลิกบนหน้า = เปิด textarea สร้างกล่องข้อความ ณ ตำแหน่งนั้น
if (tool === "textbox" && user) {
const position = getPointerPercent(e, pageEl);
setTextboxComposer({ pageIndex, position });
return;
}
// F18/B5: คลิกบนหน้า = วางตรายางคำที่เลือกไว้ทันที (preset หรือคำที่พิมพ์เองจากเมนู —
// สี/ขนาดจากเมนูตั้งค่าของ tool เช่นกัน; ช่องคำว่าง/เว้นวรรคล้วน = preset แรก กัน label ว่าง)
if (tool === "stamp" && user) {
const position = getPointerPercent(e, pageEl);
void handleCreateAnnotation(pageIndex, "STAMP", {
label: stampPreset.trim() || STAMP_PRESETS[0],
position,
scale: stampScalePct,
color: stampColor,
});
return;
}
// tool select/highlight — คลิกบน annotation ตัวไหน = เลือกตัวนั้น (กรอบ F1 + ปุ่มลบ)
// เช็คจากรายการหลังสุดก่อนเพื่อให้ตัวที่วาดทีหลัง (อยู่บน) ถูกเลือกก่อน
// COMMENT ไม่เข้า hit-test เหมือน hover — pin รับคลิกเอง (มี popover ของตัวนั้น)
// และ bbox % รอบจุดไม่ตรงกล่อง pin จริง คลิกพื้นที่ว่างใกล้ pin จึงเลือกเงียบ ๆ
if (tool === "select" || tool === "highlight") {
const base = pageSizes[pageIndex];
if (base) {
const size = { width: base.width * scale, height: base.height * scale };
const p = getPointerPercent(e, pageEl);
const hit = [...visibleAnnotations]
.reverse()
.find(
(a) =>
a.pageIndex === pageIndex &&
a.type !== "COMMENT" &&
hitTestAnnotation(a, p, size),
);
if (hit) {
setSelectedId(hit.id);
return;
}
}
}
}
async function handleComposerSave() {
const c = composer;
if (!c || !draft.trim() || !user) return;
setComposer(null);
await handleCreateAnnotation(c.pageIndex, "COMMENT", {
position: c.position,
message: draft.trim(),
});
}
// ─── Scroll-to จาก sidebar (PLAN.md §6.4 — คำนวณ absolute ครั้งเดียว) ──────
function goToBookmark(b: BookmarkDTO) {
// M1 (UX audit 2026-09-16): จอ < lg sidebar เป็น overlay — กระโดดแล้วปิดให้
// เห็นเป้าหมายทันที ไม่ต้องปิดเองก่อน (deep link ผ่าน goToAnnotation ก็เช่นกัน)
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
const el = pageWrapRefs.current[b.pageIndex];
const main = mainRef.current;
if (!el || !main) return;
const r = el.getBoundingClientRect();
const pageTop = contentTopInScroll(main, r.top);
// ดันจุด yOffset กลับมาชิดขอบบนของพื้นที่มองเห็น — pattern เดียวกับ goToAnnotation
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
scrollToBelowHeader(main, pageTop + (r.height * b.yOffset) / 100);
}
function goToAnnotation(a: AnnotationDTO) {
// M1: จอ < lg sidebar เป็น overlay — กระโดดแล้วปิดให้ เห็นเป้าหมายทันที
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
// คลิกใน sidebar = กระโดดไปหน้า + เลือกรายการนั้น (F1) — เลื่อนให้ขอบบน bbox
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
setSelectedId(a.id);
setPreviewData(null);
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
const el = pageWrapRefs.current[a.pageIndex];
const main = mainRef.current;
if (!el || !main) return;
const r = el.getBoundingClientRect();
// COMMENT: bbox ตาม pin px จริง (มาตราฐานเดียวกับที่ render/กรอบ selection) — pin
// คงที่ 26/36px ไม่ scale ตาม zoom จึงต้องคิด % จากขนาดหน้าที่ render อยู่ ไม่ใช่
// COMMENT_PIN_BOX_PCT ประมาณ (responsive audit 2026-09-24: ตอนซูมออก pin จมใต้ header)
const boxPct =
a.type === "COMMENT"
? commentPinBoxPct(
a.data as CommentData,
{ width: r.width, height: r.height },
coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX,
)
: annotationBBox(a, { width: 100, height: 100 }, measureStampText);
scrollToBelowHeader(main, contentTopInScroll(main, r.top) + (r.height * boxPct.y) / 100);
}
/** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */
function handlePinToggle(id: string | null) {
setOpenPinId(id);
if (id) setSelectedId(id);
}
/** คลิกรายการ overlay (TEXTBOX ฯลฯ) = เลือกรายการนั้น (F1) */
function handleSelectAnnotation(id: string) {
setSelectedId(id);
setPreviewData(null);
// เลือกรายการอื่น = ปิด popover ของ pin เดิม (คลิก pin จะไม่ผ่านที่นี่ — มัน toggle เอง)
setOpenPinId(null);
}
/** F13/A6: เข้าสู่โหมดแก้ไขข้อความ comment (null = ยกเลิก) — draft เริ่มจากข้อความปัจจุบัน */
function startCommentEdit(id: string | null) {
setEditingCommentId(id);
if (id) {
const a = annotations.find((x) => x.id === id && x.type === "COMMENT");
setCommentEditDraft(a ? (a.data as CommentData).message : "");
}
}
// ─── Text box (F17/B2) ─────────────────────────────────────────────────────
/** เข้าสู่โหมดแก้ไขข้อความ inline (TEXTBOX double-click / STAMP ปุ่ม "แก้ไขข้อความ" — null = ยกเลิก) */
function startTextboxEdit(id: string | null) {
setEditingTextboxId(id);
}
/** บันทึกข้อความที่แก้ (TEXTBOX inline / STAMP ผ่าน StampEditor) — ส่งเป็น patch ให้คิว
* update ผสานกับ data ล่าสุด (สี/ขนาดที่เพิ่งเปลี่ยนในแผงก่อนหน้าไม่ถูกทิ้ง); segments
* (ตัวหนาเฉพาะส่วน) เก็บเฉพาะ TEXTBOX เมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
function commitTextboxEdit(id: string, text: string, segments?: TextboxSegment[]) {
setEditingTextboxId(null);
const current = annotations.find((a) => a.id === id);
if (!current) return;
const hasBold = !!segments?.some((s) => s.bold);
handlePatchAnnotation(
id,
current.type === "STAMP"
? { kind: "label", label: text }
: { kind: "textboxText", text, segments: hasBold ? segments ?? [] : null },
);
}
/** บันทึกกล่องข้อความใหม่จาก composer ณ ตำแหน่งที่คลิก (ค่าทั้งหมดจากเมนูตั้งค่าของ tool) */
function commitTextboxComposer(text: string, segments?: TextboxSegment[]) {
const c = textboxComposer;
if (!c) return;
setTextboxComposer(null);
const hasBold = !!segments?.some((s) => s.bold);
void handleCreateAnnotation(c.pageIndex, "TEXTBOX", {
position: c.position,
text,
color: textboxColor,
fontSize: textboxFontSizePct,
...(textboxBold ? { bold: true } : {}),
opacity: textboxOpacityPct / 100,
align: textboxAlign,
// กล่องกว้างคงที่ (สไตล์ Smallpdf) — บีบด้วยพื้นที่จริงถึงขอบขวาตอนสร้าง
// ให้ตรงกับที่ editor แสดง (maxWidth ของ CSS clamp ให้อยู่แล้วระหว่างพิมพ์)
width: Math.min(TEXTBOX_DEFAULT_WIDTH_PCT, textboxMaxWidthPct(c.position.x)),
...(hasBold ? { segments } : {}),
});
}
function switchTool(t: Tool) {
// ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
// ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H: กดแล้วไม่สลับ tool แต่ยังเคลียร์เมนู/
// popup/selection เหมือนสลับ tool จริง — ไม่ปล่อย no-op เงียบสนิทจนนึกว่าคีย์ลัด
// ไม่ทำงาน (finding #4 ของ PLAN-TOOLBAR.md)
const blocked = !canSwitchTo(t, hideHighlightTool);
if (!blocked) setTool(t);
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
// ⚠️ anyMenuOpen อ่านจาก closure ของ render ปัจจุบัน — เวิร์กเพราะ outside-click
// ผูก mousedown ซึ่ง flush ก่อน click ของปุ่มยิงถึงเสมอ อย่าขยับ ordering นี้
if (anyMenuOpen) markMenuClosed();
closeToolMenus();
// popup บนหน้า (รวม popup bookmark) ปิดตาม — สลับ tool แล้ว popup ค้างเปิดจะซ้อนกับ
// สิ่งที่ทำต่อ (anyMenuOpen นับ bookmarkComposer อยู่แล้ว พฤติกรรม M4 เท่าเดิม)
closePagePopups();
window.getSelection()?.removeAllRanges();
// M3: เข้า tool highlight บนจอสัมผัส (ปุ่มหรือคีย์ H) — โชว์ hint ตามโควตาครั้งแรก
// นับเฉพาะตอน "เพิ่งสลับเข้ามาจาก tool อื่น" เท่านั้น: กดปุ่ม dropdown ตอน tool
// นี้ active อยู่แล้ว (เปิด/พับเมนู) หรือกด H ซ้ำ ๆ ต้องไม่กินโควตา (finding #3)
const enteringHighlight = !blocked && t === "highlight" && tool !== "highlight";
if (enteringHighlight && coarseTouch && coarseHlHintCountRef.current < 3) {
coarseHlHintCountRef.current += 1;
setCoarseHlHintVisible(true);
} else {
setCoarseHlHintVisible(false);
}
}
/** ตัวเลื่อนหน้า — scroll ไปต้นหน้าที่ระบุ (กลไกเดียวกับ bookmark/deep link:
* placeholder ทุกหน้ามีขนาดจริงจึงคำนวณ absolute scroll ได้เสมอ) */
function goToPage(pageIndex: number) {
const el = pageWrapRefs.current[pageIndex];
const main = mainRef.current;
if (!el || !main) return;
const r = el.getBoundingClientRect();
scrollToBelowHeader(main, contentTopInScroll(main, r.top));
}
// ─── Render ────────────────────────────────────────────────────────────────
// N3 ไฟล์ภาพ + PDF สแกนล้วน (คำขอผู้ใช้ 2026-09-22): ไม่มีข้อความให้คลุม — ซ่อน tool
// มาร์กข้อความ (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) ทั้งปุ่มและเมนูรูปแบบ — กันเข้าทาง
// คีย์ลัด H ที่ switchTool และ reset tool ที่ค้างอยู่ (effect ของแต่ละกรณี)
const isImageFile = fileKindFromUrl(file.pdfUrl) === "image";
const hideHighlightTool = isImageFile || scannedOnly;
const visibleTools = visibleToolsFor(hideHighlightTool);
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่
const drawStyle = {
color: drawColor,
strokeWidthPct: drawWidthPct,
opacity: drawOpacityPct / 100,
};
// ค่าตั้งตอนสร้างของ shape/textbox — ส่งลง PdfPage (stamp ใช้ตรงจุดสร้างใน viewer เอง)
const shapeStyle = { color: shapeColor, strokeWidthPct: shapeWidthPct };
const textboxStyle = {
color: textboxColor,
fontSizePct: textboxFontSizePct,
bold: textboxBold,
opacity: textboxOpacityPct / 100,
align: textboxAlign,
widthPct: TEXTBOX_DEFAULT_WIDTH_PCT,
};
// ความกว้างหน้าแรกที่ scale 1 (≈ pt ของ PDF) — ใช้แปลงหน่วย fontSize ระหว่าง % (ที่เก็บใน DB)
// กับ px ที่แสดงใน UI (fallback 595 = ความกว้าง A4 ก่อนเอกสารโหลด)
const basePageWidth = pageSizes[0]?.width ?? 595;
return (
// 2026-09-22: app shell สูงจอเดียว (dvh กันปัญหา URL bar ของจอสัมผัส) —
// ตัวเลื่อนเดียวของหน้าคือ <main> ของเอกสาร sidebar/header อยู่กับที่
// ไม่หนีไปกับ scroll/zoom ตามคำขอผู้ใช้ (เดิมเลื่อนที่ window ทั้งหน้า)
<div className="flex h-dvh overflow-hidden">
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
สะอาดกว่า native) */}
{TEXT_SELECTION_TOOLS.has(tool) && (
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
)}
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < lg)
z-[45] อยู่เหนือ header sticky z-40 แต่ใต้ aside z-50 / composer z-50 */}
{sidebarOpen && (
<div
className="fixed inset-0 z-[45] bg-ink/30 lg:hidden"
aria-hidden
onClick={() => setSidebarOpen(false)}
/>
)}
{/* lg+ = แยกคอลัมน์เต็มความสูง shell (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ; top-0 จำเป็น
ตอน max-lg:fixed ยึดขอบบนจอ) */}
<aside
data-sidebar=""
className={`top-0 flex h-full w-72 flex-col shrink-0 border-r border-line 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"
}`}
>
{/* 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-ink-soft hover:bg-ink/[0.05] ${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="flex h-full min-w-0 flex-1 flex-col">
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
<header className="sticky top-0 z-40 border-b border-line bg-white">
{/* Responsive audit 2026-09-24: มือถือแท้ (< ~450px) ของตายตัวในแถวนี้รวม ~454px
เกินจอ — แถวเดียวเลื่อนแนวนอนเหมือนแถบ tool (M5) แทนการ wrap ที่ทำ header
สูงขึ้นอีกบรรทัด (iPad ทุกรุ่นไม่เจอ — มี title flex-1 กินส่วนเหลือ) */}
<div className="touch-44 flex flex-wrap items-center gap-2 px-4 py-2 max-lg:flex-nowrap max-lg:overflow-x-auto">
{/* UX review 2026-09-09: สลับ sidebar (ทุกขนาดจอ — desktop ซ่อนเพื่อดู PDF เต็มจอได้) */}
<button
type="button"
title="สลับแผงข้าง"
aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"}
aria-expanded={sidebarOpen}
className={`shrink-0 rounded-md border border-line px-2 py-2 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => setSidebarOpen((o) => !o)}
>
<PanelLeft className="h-4 w-4" />
</button>
{/* M6: ซ่อนชื่อไฟล์บนจอเล็ก (แถว title พับ 2 บรรทัดทำ header สูงเกินครึ่งจอ) */}
<h1 className="hidden min-w-0 flex-1 truncate text-sm font-semibold sm:block">
{file.title}
</h1>
{/* ตัวเลื่อนหน้า < หน้า 1/12 > + ซูม −/%/+ — อยู่ด้วยกันกึ่งกลางแถว title (ซ้าย-ขวา flex-1 เท่ากัน) */}
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center rounded-md border border-line">
<button
type="button"
aria-label="หน้าก่อนหน้า"
disabled={currentPage <= 0}
className={`px-2 py-2 text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => goToPage(currentPage - 1)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{/* L4: เลข 3 หลักล้น w-16 — ปล่อยกว้างตามเนื้อหา มีขั้นต่ำให้ตัวเลขไม่ขยับ */}
<span className="w-auto min-w-[4.5rem] px-1 text-center text-xs tabular-nums">
หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length}
</span>
<button
type="button"
aria-label="หน้าถัดไป"
disabled={currentPage >= pageSizes.length - 1}
className={`px-2 py-2 text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => goToPage(currentPage + 1)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="flex items-center rounded-md border border-line">
<button
type="button"
aria-label="ซูมออก"
className={`px-2 py-2 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
userZoomedRef.current = true; // ซูมเองแล้ว toggle sidebar ไม่ re-fit ทับ
setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3));
}}
>
<ZoomOut className="h-4 w-4" />
</button>
{/* UX audit 2026-09-22: ตัวเลข % เป็นปุ่มกลับไป fit-width — PDF viewer ที่
ผู้ใช้เคยใช้ทุกตัวมี affordance รีเซ็ตซูม คิดจากความกว้าง main ปัจจุบัน */}
<button
type="button"
aria-label="ซูมพอดีความกว้างหน้า"
title="ซูมพอดีความกว้างหน้า"
className={`w-12 py-2 text-center text-xs tabular-nums text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
userZoomedRef.current = false; // กลับสู่ fit-width ให้ re-fit ทำงานอีก
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0]?.width);
if (s !== null) setScale(s);
}}
>
{Math.round(scale * 100)}%
</button>
<button
type="button"
aria-label="ซูมเข้า"
className={`px-2 py-2 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
userZoomedRef.current = true; // ซูมเองแล้ว toggle sidebar ไม่ re-fit ทับ
setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3));
}}
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex flex-1 items-center justify-end gap-2">
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ — label แสดงตั้งแต่ sm ขึ้นไปเท่านั้น
(จอแคบกว่านั้นแถว title ใส่ไม่พอ ให้เหลือ icon เดี่ยวเหมือนชื่อไฟล์ที่ hidden sm:block) */}
<Link
href={`/annotate/${file.id}/summary`}
title="หน้าสรุปหมายเหตุ"
aria-label="หน้าสรุปหมายเหตุ"
className={`flex items-center gap-1 whitespace-nowrap rounded-md border border-line px-2 py-2 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
>
<ClipboardList className="h-4 w-4" />
<span className="hidden text-xs font-medium sm:inline">หน้าสรุป</span>
</Link>
</div>
</div>
{/* 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-line/60 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: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14:
จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
<div className="flex max-lg:shrink-0 flex-wrap gap-0.5 rounded-md border border-line max-lg:flex-nowrap">
{visibleTools.map(({ key, label, text, Icon }, i) => {
// มุมปุ่มปลายแถวต้องน้อยกว่า/เท่ากับ container (rounded-md 6px) — ใส่ lg
// 8px แล้ว active bg โป๊ะชนเส้นโค้งของ container (UX review m4 2026-09-23)
const corner = i === 0 ? "rounded-l-md" : i === visibleTools.length - 1 ? "rounded-r-md" : "";
// tool นี้กำลัง active ไหม — ปุ่ม dropdown ที่มีค่าย่อย (มาร์กข้อความ/
// วาดเส้น/รูปทรง/ตรายาง) ใช้ตัดสินว่าจะแสดงชื่อ tool (ไม่ active) หรือค่า
// ที่เลือกไว้ (active = ค่าที่จะใช้จริงเมื่อวาด) — คำขอผู้ใช้ 2026-09-29
const active = tool === key;
const activeCls = `${
active ? "bg-primary text-white" : "text-ink-soft hover:bg-ink/[0.05]"
} ${FOCUS_RING_CLS}`;
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
if (key === "highlight") {
const face = toolButtonFace(
active,
{ label, text },
HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.label,
);
const CurrentIcon = active
? (HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon)
: Icon;
return (
<div key={key} ref={highlightMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={highlightMenuOpen}
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
// เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด
// (editor/แผง selection/popover) ไม่ให้ซ้อนกับเมนู; จำ intent เปิด/พับ
// ไว้ก่อนเพราะ switchTool ปิดเมนูทุกตัวก่อน
const reopen = menuIntentAfterClick(tool, "highlight", highlightMenuOpen);
switchTool("highlight");
if (reopen) setHighlightMenuOpen(true);
}}
>
<CurrentIcon className="h-4 w-4" />
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{highlightMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่าการมาร์กข้อความ"
className="w-60 rounded-lg border border-line 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
key={sKey}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
highlightStyle === sKey
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setHighlightStyle(sKey);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แตะหน้าครั้งถัดไปต้องใช้ได้ทันที)
setHighlightMenuOpen(false);
}}
>
<SIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{sLabel}</span>
{highlightStyle === sKey && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={highlightColor} onChange={setHighlightColor} />
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={highlightOpacityPct}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={setHighlightOpacityPct}
/>
</PanelRow>
</div>,
document.body
)}
</div>
);
}
// DRAW: ปุ่มวาดเส้น = dropdown เลือกชนิดปากกา (ปากกา/ไฮไลต์)
if (key === "draw") {
const face = toolButtonFace(active, { label, text }, DRAW_PENS[drawPen].label);
const CurrentPenIcon = active ? DRAW_PEN_ICONS[drawPen] : Icon;
return (
<div key={key} ref={drawMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={drawMenuOpen}
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = menuIntentAfterClick(tool, "draw", drawMenuOpen);
switchTool("draw");
if (reopen) setDrawMenuOpen(true);
}}
>
<CurrentPenIcon className="h-4 w-4" />
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{drawMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่าปากกา"
className="w-60 rounded-lg border border-line 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];
return (
<button
key={p}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
drawPen === p
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setDrawPen(p);
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น
setDrawColor(DRAW_PENS[p].color);
setDrawWidthPct(DRAW_PENS[p].strokeWidthPct);
setDrawOpacityPct(DRAW_PENS[p].opacity * 100);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ความหนา/ความทึบยังเปิดค้างให้ปรับต่อ)
setDrawMenuOpen(false);
}}
>
<PenIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{DRAW_PENS[p].label}</span>
{drawPen === p && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
);
})}
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={drawColor} onChange={setDrawColor} />
</PanelRow>
<PanelRow label="ความหนา">
{/* แสดงเป็นหน่วยย่อ ×10 ของ % ความกว้างหน้า (4 = ปากกาเดิม 0.4%, 12 = ไฮไลต์เดิม 1.2%)
— toFixed กัน float artifact ของการแปลง ÷10/×10 รอบกลับ */}
<NumberCombo
value={Number((drawWidthPct * 10).toFixed(2))}
options={[1, 2, 4, 8, 12]}
min={1}
step={1}
onChange={(v) => setDrawWidthPct(v / 10)}
/>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round(drawOpacityPct)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={setDrawOpacityPct}
/>
</PanelRow>
{/* M8: hint ไทยต้อง ≥11px + leading ไม่บีบ (สระซ้อนอ่านไม่ออกที่ 10px) */}
<p className="px-1 pt-1.5 text-[11px] leading-snug text-ink-soft">
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
</p>
</div>,
document.body
)}
</div>
);
}
// F5/B1: ปุ่มรูปทรง = dropdown เลือกชนิดรูปทรง
if (key === "shape") {
const face = toolButtonFace(
active,
{ label, text },
SHAPE_KIND_BTNS.find((k) => k.key === shapeKind)?.label,
);
return (
<div key={key} ref={shapeMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={shapeMenuOpen}
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = menuIntentAfterClick(tool, "shape", shapeMenuOpen);
switchTool("shape");
if (reopen) setShapeMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{shapeMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่ารูปทรง"
className="w-60 rounded-lg border border-line 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
key={kind}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
shapeKind === kind
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setShapeKind(kind);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ความหนายังเปิดค้างให้ปรับต่อ)
setShapeMenuOpen(false);
}}
>
<KindIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{kindLabel}</span>
{shapeKind === kind && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={shapeColor} onChange={setShapeColor} />
</PanelRow>
<PanelRow label="ความหนา">
{/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับความหนาของปากกา */}
<NumberCombo
value={Number((shapeWidthPct * 10).toFixed(2))}
options={[1, 2, 4, 8, 12]}
min={1}
step={1}
onChange={(v) => setShapeWidthPct(v / 10)}
/>
</PanelRow>
</div>,
document.body
)}
</div>
);
}
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — ตอน active เลือกคำกำหนด
// เองแล้วปุ่มแสดง "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือก)
if (key === "stamp") {
const face = toolButtonFace(
active,
{ label, text },
STAMP_PRESETS.includes(stampPreset) ? stampPreset : "กำหนดเอง",
);
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
const finishCustom = () => {
if (!stampPreset.trim()) setStampPreset(STAMP_PRESETS[0]);
setStampMenuOpen(false);
};
return (
<div key={key} ref={stampMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={face.title}
aria-label={face.title}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={stampMenuOpen}
className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${corner} ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = menuIntentAfterClick(tool, "stamp", stampMenuOpen);
switchTool("stamp");
if (reopen) {
setStampMenuOpen(true);
setStampCustomMode(false); // เปิดเมนูใหม่ = กลับหน้ารายการ preset
}
}}
>
<Icon className="h-4 w-4" />
<span className="text-xs font-medium">{face.text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{stampMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่าตรายาง"
className="w-60 rounded-lg border border-line 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",
}}
>
{stampCustomMode ? (
<>
{/* ─── หน้ากำหนดเอง: ข้อความ + สี + ขนาด + preview ตรายางตามสัดส่วนจริง ─── */}
<div className="flex items-center gap-1 px-1 pb-1">
{/* ย้อนกลับหน้ารายการ — คืน focus ไปปุ่ม "กำหนดเอง…" หลัง render
หน้า preset (rAF รอหลัง commit) เพราะ input ที่กำลัง focus อยู่
ถูก unmount ไปพร้อมกัน ไม่คืนแล้ว focus ตกไป body (UX audit 2026-09-28) */}
<button
type="button"
title="กลับรายการตรายาง"
aria-label="กลับรายการตรายาง"
className={`rounded-md p-1 text-ink-soft transition hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
setStampCustomMode(false);
requestAnimationFrame(() =>
stampCustomRowRef.current?.focus(),
);
}}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="text-xs font-semibold text-ink">ตรายางกำหนดเอง</span>
</div>
{/* ข้อความ — พิมพ์ live ไปที่ค่าที่เลือก (stampPreset) และ preview ทันที */}
<input
type="text"
aria-label="คำตรายางกำหนดเอง"
placeholder="พิมพ์คำสำหรับตรายาง…"
value={stampPreset}
maxLength={STAMP_MAX_LABEL_CHARS}
spellCheck={false}
autoFocus
onChange={(e) => setStampPreset(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัดของ viewer
if (e.key === "Enter") {
e.preventDefault();
finishCustom();
}
if (e.key === "Escape") {
e.preventDefault();
// ย้อนกลับหน้ารายการ (คำที่พิมพ์คงไว้) + คืน focus ไปปุ่มแถว
setStampCustomMode(false);
requestAnimationFrame(() =>
stampCustomRowRef.current?.focus(),
);
}
}}
// focus: แบบไม่มีเงื่อนไข (ไม่ใช่ focus-visible:) — autoFocus หลัง
// แตะปุ่ม "กำหนดเอง…" ด้วยเมาส์/นิ้ว ไม่ match :focus-visible ใน
// Chromium จนช่องโฟกัสอยู่แต่มองไม่เห็น — รูปแบบเดียวกับ popup
// bookmark + rounded-md 6px ตามสเปก Inputs ของ Genesis (UX audit)
className="mb-2 w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
{/* preview — กรอบ/ฟอนต์/การจัดวาง (ย่อฟอนต์บรรทัดเดียวก่อน คำยาว
ค่อย wrap หลายบรรทัด) สูตรเดียวกับตอน render บนหน้าเป๊ะ (measure
ตัวเดียวกัน) ขนาดตามสัดส่วนจริงบนจอ: ความกว้างหน้าแรก × scale% —
ใหญ่เกินเมนู (w-60) ย่อทั้งชุดเชิงเส้น */}
<div className="mb-2 flex justify-center overflow-hidden rounded border border-line bg-canvas px-2 py-3">
{(() => {
const pageW = pageSizes[0]
? Math.ceil(pageSizes[0].width * scale)
: 595;
const wReal = (pageW * stampScalePct) / 100;
const k = Math.min(1, 192 / wReal);
const w = wReal * k;
const label = stampPreset.trim() || "ตัวอย่าง";
const lay = stampLayout(label, w, measureStampText);
return (
<div
className="flex flex-col items-center justify-center overflow-hidden text-center font-bold"
style={{
width: w,
height: stampBoxHeight(lay, w),
border: `${Math.max(w * 0.02, 1)}px solid ${stampColor}`,
borderRadius: Math.max(w * 0.04, 2),
color: stampColor,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
}}
>
{lay.lines.map((ln, i) => (
<div key={i}>{ln || " "}</div>
))}
</div>
);
})()}
</div>
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
{/* จบเหมือนเลือก preset: ปิดเมนูด้วย setState ตรง (ไม่ผ่าน
markMenuClosed) ให้แตะหน้าวางได้ทันที ไม่โดน guard กลืน tap 300ms */}
<button
type="button"
className={`mb-1 w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-hover ${FOCUS_RING_CLS}`}
onClick={finishCustom}
>
ใช้ตรายางนี้
</button>
</>
) : (
<>
{STAMP_PRESETS.map((preset) => (
<button
key={preset}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
// L1: รายการที่เลือก = state "active" ใช้ primary tint เหมือน
// เมนูอื่น (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
stampPreset === preset
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
>
<span className="flex-1">{preset}</span>
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
{/* เข้าหน้ากำหนดเอง (ข้อความ/สี/ขนาด + preview) — สลับหน้าใน popup เดียวกัน
ไม่ปิดเมนู (tap อยู่บนเมนูเอง) · active เมื่อค่าปัจจุบันเป็นคำกำหนดเอง
· ref ไว้คืน focus ตอนย้อนกลับจากหน้ากำหนดเอง (UX audit 2026-09-28) */}
<button
ref={stampCustomRowRef}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
stampPreset.trim() !== "" &&
!STAMP_PRESETS.includes(stampPreset)
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => setStampCustomMode(true)}
>
<Pencil className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">กำหนดเอง…</span>
{stampPreset.trim() !== "" && !STAMP_PRESETS.includes(stampPreset) && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
</button>
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
</>
)}
</div>,
document.body
)}
</div>
);
}
// F17/B2: ปุ่มกล่องข้อความ = dropdown ตั้งค่าสี/ขนาดฟอนต์
if (key === "textbox") {
return (
<div key={key} ref={textboxMenuRef} className="relative">
<button
type="button"
data-tool={key}
title={label}
aria-label={label}
aria-pressed={active}
aria-haspopup="menu"
aria-expanded={textboxMenuOpen}
className={`${corner} flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = menuIntentAfterClick(tool, "textbox", textboxMenuOpen);
switchTool("textbox");
if (reopen) setTextboxMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
<span className="text-xs font-medium">{text}</span>
<ChevronDown className="h-3 w-3" />
</button>
{textboxMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
aria-label="ตั้งค่ากล่องข้อความ"
className="w-60 rounded-lg border border-line 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>
<ColorSwatches value={textboxColor} onChange={setTextboxColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง (scale 1 ≈ pt) — เก็บใน DB เป็น %
ของความกว้างหน้าเหมือนเดิม (zoom ไม่ทำให้ตัวเลขเปลี่ยน) */}
<NumberCombo
value={Number(((textboxFontSizePct / 100) * basePageWidth).toFixed(1))}
options={[10, 12, 14, 18, 24]}
min={6}
max={48}
step={1}
suffix="px"
onChange={(v) => setTextboxFontSizePct((v / basePageWidth) * 100)}
/>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round(textboxOpacityPct)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={setTextboxOpacityPct}
/>
</PanelRow>
<PanelRow label="ตัวหนา">
<button
type="button"
aria-label="สลับตัวหนา"
aria-pressed={textboxBold}
className={`flex items-center justify-center rounded-md border px-3 py-1 transition ${FOCUS_RING_CLS} ${
textboxBold
? "border-primary bg-primary text-white"
: "border-line bg-white text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => setTextboxBold((o) => !o)}
>
{/* B ทึบ — แทน icon Bold แบบเส้นขอบ (lucide ไม่มีรูปทึบ) */}
<span aria-hidden className="text-[13px] font-black leading-none">
B
</span>
</button>
</PanelRow>
<PanelRow label="จัดแนว">
<div className="flex overflow-hidden rounded-md border border-line">
{(
[
["left", AlignLeft, "ชิดซ้าย"],
["center", AlignCenter, "กึ่งกลาง"],
["right", AlignRight, "ชิดขวา"],
] as const
).map(([a, AlignIcon, alignLabel]) => (
<button
key={a}
type="button"
aria-label={`จัดแนว${alignLabel}`}
aria-pressed={textboxAlign === a}
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
textboxAlign === a
? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => setTextboxAlign(a)}
>
<AlignIcon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
</div>,
document.body
)}
</div>
);
}
return (
<button
key={key}
type="button"
data-tool={key}
title={label}
aria-label={label}
aria-pressed={tool === key}
className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${corner} ${activeCls}`}
onClick={() => switchTool(key)}
>
<Icon className="h-4 w-4" />
<span className="text-xs font-medium">{text}</span>
</button>
);
})}
</div>
{/* (เดิมมี span แสดงชื่อ tool ที่ active เฉพาะจอสัมผัส — H3 UX audit 2026-09-16
ถอดออก 2026-09-21 เพราะปุ่มทุก tool แสดงชื่อไทยหลังไอคอนแล้ว ส่วนคำเตือน
redaction อยู่ที่แถบเตือนใต้ header แสดงทุกขนาดจอตามเดิม (H2)) */}
{/* 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-md border border-line">
<button
type="button"
title="Undo (Ctrl+Z)"
aria-label="ย้อนการกระทำล่าสุด"
disabled={!canUndo}
className={`px-2 py-2 text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => void undoHistory()}
>
<Undo2 className="h-4 w-4" />
</button>
<button
type="button"
title="Redo (Ctrl+Shift+Z)"
aria-label="ทำซ้ำการกระทำ"
disabled={!canRedo}
className={`px-2 py-2 text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => void redoHistory()}
>
<Redo2 className="h-4 w-4" />
</button>
</div>
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร)
M2: จอ < lg ให้อยู่หัวแถวเคียง undo (bookmark เป็นส่วนของ flow หลัก
highlight → comment → bookmark แต่เดิมถูกดันไปท้ายสุดของแถบเลื่อน) */}
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:order-first max-lg:ml-0 max-lg:shrink-0">
<button
ref={bookmarkBtnRef}
type="button"
title={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
aria-label={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
disabled={!user}
onClick={openBookmarkComposer}
className={`flex items-center gap-1 whitespace-nowrap rounded-md border border-line px-2 py-2 text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
{/* M6: ✓ เขียว 1.5 วิหลังบันทึกสำเร็จ — sidebar ปิดอยู่ก็รู้ว่าเซฟแล้ว */}
{bookmarkSaved ? (
<Check className="h-4 w-4 text-success-ink" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
<span className="text-xs font-medium">Bookmark</span>
</button>
{bookmarkComposer &&
bookmarkPos &&
typeof document !== "undefined" &&
createPortal(
<div
data-tool-menu=""
className="w-72 rounded-lg border border-line 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-ink">
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
</p>
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
<p className="mt-0.5 text-[11px] text-ink-soft">
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
{bookmarkComposer.yOffset <= 25
? "ส่วนบน"
: bookmarkComposer.yOffset <= 75
? "ราวกลาง"
: "ส่วนล่าง"}{" "}
ของหน้า
</p>
<input
autoFocus
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
maxLength={MAX_BOOKMARK_TITLE_CHARS}
value={bookmarkComposer.title}
onChange={(e) =>
setBookmarkComposer({ ...bookmarkComposer, title: e.target.value })
}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัดของ viewer
if (e.key === "Enter") void handleSaveBookmark();
if (e.key === "Escape") setBookmarkComposer(null);
}}
placeholder={`ชื่อหัวข้อ (เว้นว่าง = "หน้า ${bookmarkComposer.pageIndex + 1}")`}
className="mt-2 w-full rounded-md border border-line px-2 py-1.5 focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<textarea
rows={2}
maxLength={MAX_BOOKMARK_NOTE_CHARS}
value={bookmarkComposer.note}
onChange={(e) =>
setBookmarkComposer({ ...bookmarkComposer, note: e.target.value })
}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") setBookmarkComposer(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
void handleSaveBookmark();
}
}}
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1.5 w-full resize-none rounded-md border border-line px-2 py-1.5 focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<p className="mt-1.5 text-[11px] text-ink-soft">วางที่</p>
<Select
value={bookmarkComposer.parentId ?? ""}
onChange={(e) =>
setBookmarkComposer({
...bookmarkComposer,
parentId: e.target.value || null,
})
}
onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded-md border border-line bg-white px-2 py-1.5 focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
>
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{optimisticBookmarks
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
.filter((b) => bookmarkDepthOf(b) < 3)
// M7: เรียงตามลำดับหน้าเหมือน sidebar (N5) + บอกเลขหน้าใน option
// ช่วยหาหัวข้อแม่บน select เนทีฟของ iPad ที่ไม่มีอย่างอื่นให้สแกน
.sort(byPageOrder)
.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot; (หน้า {h.pageIndex + 1})
</option>
))}
</Select>
{bookmarkError && (
<p className="mt-1.5 rounded bg-error/10 px-2 py-1 text-[11px] text-error-ink">
{bookmarkError}
</p>
)}
<div className="mt-2.5 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className={`rounded-md px-2.5 py-1 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => setBookmarkComposer(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={bookmarkSaving}
className={`rounded-md bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary-hover disabled:opacity-60 ${FOCUS_RING_CLS}`}
onClick={() => void handleSaveBookmark()}
>
{bookmarkSaving ? "กำลังบันทึก…" : "บันทึก"}
</button>
</div>
</div>,
document.body
)}
</div>
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4)
Responsive audit 2026-09-24: จอ < lg สั่ง order-first เหมือน undo/bookmark —
แถบเตือน redaction บอกผู้ใช้ให้กดปุ่มนี้ ต้องไม่อยู่ท้ายสุดของแถบเลื่อน
จนมองไม่เห็นบน iPad (อุปกรณ์หลัก) */}
<button
type="button"
title="Export PDF พร้อม annotation"
aria-label="Export PDF พร้อม annotation"
disabled={exporting}
className={`flex items-center gap-1 whitespace-nowrap max-lg:order-first max-lg:shrink-0 rounded-md border border-line px-2 py-2 text-ink-soft hover:bg-ink/[0.05] disabled:opacity-50 ${FOCUS_RING_CLS}`}
onClick={() => void handleExportPdf()}
>
<Download className={`h-4 w-4 ${exporting ? "animate-pulse" : ""}`} />
<span className="text-xs font-medium">Export PDF</span>
</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
ref={mainRef}
className="pdf-scroll-main min-h-0 flex-1 space-y-4 overflow-auto overscroll-contain px-4 py-4"
style={{
cursor:
tool === "pan"
? grabbing
? "grabbing"
: "grab"
: tool === "erase"
? ERASER_CURSOR
: tool === "comment" || tool === "textbox" || tool === "stamp"
? "crosshair" // tool วางของ — บอกว่าคลิกวางได้ (desktop อย่างเดียว จอสัมผัสไม่มี cursor)
: undefined,
}}
onPointerDown={(e) => {
if (tool !== "pan" || e.button !== 0) return;
panRef.current = { x: e.clientX, y: e.clientY };
e.currentTarget.setPointerCapture(e.pointerId);
setGrabbing(true);
}}
onPointerMove={(e) => {
const p = panRef.current;
if (!p) return;
mainRef.current?.scrollBy(p.x - e.clientX, p.y - e.clientY);
panRef.current = { x: e.clientX, y: e.clientY };
}}
onPointerUp={() => {
panRef.current = null;
setGrabbing(false);
}}
onPointerCancel={() => {
panRef.current = null;
setGrabbing(false);
}}
>
{/* M6: hint ไฟล์สแกน — เดิมโชว์ตอน tool highlight บนหน้าที่ไม่มีข้อความ ตอนนี้
ไฟล์สแกนล้วน (scannedOnly) ซ่อนปุ่ม+reset tool ไปแล้ว แถบจึงเหลือเป็น
defensive ช่วง transition สั้น ๆ เท่านั้น (คดีศาลมี PDF สแกนเยอะ) */}
{tool === "highlight" && scannedOnly && (
<p className="mb-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning-ink">
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
</p>
)}
{/* H2 (UX audit 2026-09-16): คำเตือน redaction ย้ายมาเป็นแถบใต้ header แสดงทุก
ขนาดจอ — เดิมเป็น hidden lg:block ผู้ใช้ iPad portrait (อุปกรณ์หลัก) ไม่เห็น
เลย ทั้งเป็นความเสี่ยงข้อมูลส่วนบุคคลรั่วถ้าเผยแพร่ไฟล์ต้นฉบับโดยไม่ export */}
{tool === "redact" && (
<p className="mb-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs leading-relaxed text-warning-ink">
{REDACT_WARN_TEXT}
</p>
)}
{/* M3: จอสัมผัส + tool highlight — อธิบายวิธีเลื่อนหน้า กันอาการ "เลื่อนไม่ได้
= แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */}
{tool === "highlight" && coarseTouch && coarseHlHintVisible && (
<p className="mb-3 rounded-lg border border-line bg-ink/[0.03] px-3 py-2 text-xs leading-relaxed text-ink-soft">
โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบจอเพื่อเลื่อนหน้า
หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ
</p>
)}
{PLACE_TOOL_HINTS[tool] && (
<p className="mb-3 rounded-lg border border-line bg-ink/[0.03] px-3 py-2 text-xs leading-relaxed text-ink-soft">
{PLACE_TOOL_HINTS[tool]}
</p>
)}
{/* UX audit 2026-09-22: ยางลบลบได้เฉพาะเส้น/รูปทรง/ตรายางของตัวเอง (สเปก F6 เดิม
ไม่ขยายตรรกะ) — เดิมลากผ่านไฮไลต์/รายการของผู้อื่นแล้วเงียบเปล่า ผู้ใช้สรุป
ว่าแอปไม่ทำงาน จึงบอกขอบเขตตรง ๆ ตอนใช้ tool */}
{tool === "erase" && (
<p className="mb-3 rounded-lg border border-line bg-ink/[0.03] px-3 py-2 text-xs leading-relaxed text-ink-soft">
{ERASE_HINT_TEXT}
</p>
)}
{pageSizes.length === 0 ? (
loadError ? (
<div className="flex h-[70vh] items-center justify-center px-6">
<p className="rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error-ink">
{loadError}
</p>
</div>
) : (
<div className="flex h-[70vh] animate-pulse items-center justify-center text-sm text-ink-soft">
กำลังโหลดเอกสาร…
</div>
)
) : (
pageSizes.map((size, i) => {
const w = Math.ceil(size.width * scale);
const h = Math.ceil(size.height * scale);
const mounted = renderPages.has(i);
// N3: เนื้อหาหน้า = pdf (pdfjs) หรือรูปภาพ — เลือกจาก state ที่โหลดแล้ว
const content: PageContent | null =
pdfjs && doc
? { kind: "pdf", pdfjs, doc }
: imageSrcs
? { kind: "image", src: imageSrcs[i] ?? null }
: null;
return (
<div
key={i}
data-page={i}
ref={(el) => {
pageWrapRefs.current[i] = el;
}}
className="relative mx-auto bg-white ring-1 ring-line"
style={{ width: w, height: h }}
onMouseUp={() => handleMouseUpOnPage(i)}
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
onMouseDown={(e) => {
// จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects)
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
selDragStartRef.current = { x: e.clientX, y: e.clientY };
selDragEndRef.current = { x: e.clientX, y: e.clientY };
}}
onMouseMove={(e) => {
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1)
if (TEXT_SELECTION_TOOLS.has(tool) && e.buttons === 1) {
selDragEndRef.current = { x: e.clientX, y: e.clientY };
}
handlePageHoverMove(e, i);
}}
onMouseLeave={() => setHoveredId(null)}
>
{mounted && content ? (
<PdfPage
content={content}
pageNumber={i + 1}
width={w}
height={h}
tool={tool}
shapeKind={shapeKind}
drawStyle={drawStyle}
shapeStyle={shapeStyle}
textboxStyle={textboxStyle}
pageBaseWidth={basePageWidth}
liveMark={customSelPreview ?? liveMark}
liveMarkStyle={{
color: highlightColor,
style: highlightStyle,
...(highlightOpacityPct < 100
? { opacity: highlightOpacityPct / 100 }
: {}),
}}
selectPreview={selectPreview}
annotations={visibleAnnotations
.filter((a) => a.pageIndex === i)
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
.map((a) =>
a.id === selectedId && previewData
? { ...a, data: previewData }
: a,
)}
replies={visibleReplies}
openPinId={openPinId}
ownUserId={user?.userId ?? ""}
selectedId={selectedId}
editingCommentId={editingCommentId}
commentEditDraft={commentEditDraft}
onCommentEditDraft={setCommentEditDraft}
onStartCommentEdit={startCommentEdit}
textbox={{
composer: textboxComposer,
commitComposer: commitTextboxComposer,
cancelComposer: () => setTextboxComposer(null),
editingId: editingTextboxId,
startEdit: startTextboxEdit,
commitEdit: commitTextboxEdit,
}}
onSelectAnnotation={handleSelectAnnotation}
onPinToggle={handlePinToggle}
onAnnotationPreview={setPreviewData}
onAnnotationUpdate={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onAnnotationPatch={handlePatchAnnotation}
onSetResolved={(id, resolved) => {
void handleSetResolved(id, resolved);
}}
onAnnotationBookmark={handleAnnotationBookmark}
onCreateReply={(annotationId, message) =>
handleCreateReply(annotationId, message)
}
onDeleteReply={(id) => {
void handleDeleteReply(id);
}}
onStrokeEnd={(points, widths) => {
if (!user) return;
void handleCreateAnnotation(i, "DRAW", {
strokeColor: drawStyle.color,
strokeWidth: drawStyle.strokeWidthPct,
paths: points,
opacity: drawStyle.opacity,
...(widths ? { widths } : {}),
...(drawPen === "highlighter" ? { pen: drawPen } : {}),
});
}}
onShapeEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "SHAPE", data);
}}
onRedactionEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "REDACTION", data);
}}
onTextLayerRendered={handleTextLayerRendered}
onErase={(id) => void handleDeleteAnnotation(id)}
/>
) : null}
{/* N1: tooltip เมื่อ mouse ชี้ annotation — ใครทำ + โน้ตที่ฝากไว้
(z-5 เหนือ pins, pointer-events none ไม่บังการคลิกใด ๆ) */}
{(() => {
const hovered = visibleAnnotations.find((a) => a.id === hoveredId);
// รายการที่ชี้ถูกเลือกอยู่ (แผง selection F1 เปิดแถวนั้นแล้ว) = ไม่โชว์
// tooltip ซ้อนอีกกล่อง — เจอตอนลากคลุมจบ auto-select โผล่ทันทีที่ตำแหน่งเคอร์เซอร์
if (!hovered || hovered.pageIndex !== i || hovered.id === selectedId) return null;
const box = annotationBBox(hovered, { width: w, height: h }, measureStampText);
const note = annotationNote(hovered);
// เหลือพื้นที่ขวาของ bbox ไม่พอ (~264px) → วางทางซ้ายแทน
const flipLeft = box.x + box.width + 264 > w;
return (
<div
className="pointer-events-none absolute z-[5] w-max max-w-[256px] rounded-lg border border-line bg-white p-2 text-xs shadow-xl"
style={{
...(flipLeft
? { right: w - box.x + 8 }
: { left: box.x + box.width + 8 }),
top: Math.max(box.y - 4, 2),
}}
>
<p className="font-medium text-ink">
{annotationTypeLabel(hovered)} <span className="text-ink-faint">·</span>{" "}
{hovered.userName}
</p>
{note && (
<p className="mt-0.5 whitespace-pre-wrap break-words leading-relaxed text-ink-soft">
{note}
</p>
)}
</div>
);
})()}
</div>
);
})
)}
</main>
</div>
{composer && (
<div
className="comment-composer fixed z-50 w-72 rounded-lg border border-line 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)),
}}
>
<textarea
autoFocus
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="พิมพ์ข้อความ comment"
className="w-full resize-none rounded-md border border-line p-2 text-xs leading-relaxed focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
onKeyDown={(e) => {
if (e.key === "Escape") setComposer(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
void handleComposerSave();
}
}}
/>
<div className="mt-2 flex justify-end gap-2 text-xs">
<button
type="button"
className={`coarse-36 rounded-md px-2 py-1 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => setComposer(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={!draft.trim()}
className={`coarse-36 rounded-md bg-primary px-2 py-1 font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
onClick={() => void handleComposerSave()}
>
บันทึก
</button>
</div>
</div>
)}
{/* F8: export พัง + H1: action พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI
ของ rules/pdf-viewer.md) วางซ้อนเป็นคอลัมน์เดียว ไม่ทับกันเมื่อเกิดพร้อมกัน */}
{(exportError || actionError || toolHintToast) &&
typeof document !== "undefined" &&
createPortal(
<div className="fixed bottom-4 right-4 z-[60] flex max-w-sm flex-col gap-2">
{toolHintToast && (
<div
role="status"
className="rounded-lg border border-line bg-white px-4 py-3 text-xs leading-relaxed text-ink-soft shadow-xl"
>
{toolHintToast}
</div>
)}
{actionError && (
<div
role="alert"
className="rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error-ink shadow-xl"
>
{actionError}
</div>
)}
{exportError && (
<div
role="alert"
className="rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error-ink shadow-xl"
>
{exportError}
</div>
)}
</div>,
document.body,
)}
</div>
);
}