All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
4627 lines
270 KiB
TypeScript
4627 lines
270 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useOptimistic,
|
||
useReducer,
|
||
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) {
|
||
// สองเงื่อนไขในการนับตัวอักษรเข้ากลุ่ม rect ของบรรทัด: (1) จุดกึ่งกลาง
|
||
// แนวตั้งของตัวอักษรต้องอยู่ในแถบของ rect (2) เลือก rect ที่ซ้อนทับแนวตั้ง
|
||
// มากที่สุด ไม่ใช่ตัวแรกที่เจอ — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง
|
||
// ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรของบรรทัดถัดไป (โดยเฉพาะตัวที่มีสระ
|
||
// บนดันกรอบขึ้น) เหลื่อมกับ rect ของบรรทัดก่อนหน้าจริง ๆ 1-3px การเช็คแค่
|
||
// การเหลื่อมจึงไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย
|
||
// (ลากจบท้ายบรรทัดแล้ว caret ปลายทางหลุดเข้า span ถัดไป) เดินถึงตัวอักษร
|
||
// ของบรรทัดนั้นแล้วดูดเข้ากลุ่มเดียวกัน โดนเรียงรวมด้วย x จนสองบรรทัดสอด
|
||
// แทรกตัวต่อตัวเป็น mojibake — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปยังอยู่นอก
|
||
// แถบเสมอ (เหลื่อมแค่ขอบ) เป็นเกณฑ์แยกบรรทัดที่เด็ดกว่า (bug report 2026-10-05)
|
||
const cy = r.top + r.height / 2;
|
||
let best = 0;
|
||
for (let ki = 0; ki < kept.length; ki++) {
|
||
const k = kept[ki];
|
||
if (r.left >= k.right + eps || r.right <= k.left - eps) continue;
|
||
if (cy < k.top - eps || cy > k.bottom + eps) continue;
|
||
const score =
|
||
Math.min(r.bottom, k.bottom + eps) - Math.max(r.top, k.top - eps);
|
||
if (score > best) {
|
||
best = score;
|
||
hit = ki;
|
||
}
|
||
}
|
||
}
|
||
// 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];
|
||
// ใช้เกณฑ์เดียวกับ sameLine ด้านล่าง: rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด
|
||
// เรียงบน→ล่าง, ซ้อนกัน = บรรทัดเดียวกันเรียงซ้าย→ขวา — เดิมเทียบ |midY| กับ
|
||
// ความสูงซึ่งตัดสินผิดกับบรรทัดไทยคุมชิด (ห่าง 16px สูง 16px = "บรรทัดเดียวกัน")
|
||
// จนย่อหน้าที่บรรทัดแรกเยื้องเข้า (left มากกว่าบรรทัดถัดไป) โดนเรียงตาม left
|
||
// กลางเป็นข้อความสลับลำดับบรรทัด (code review 2026-10-05)
|
||
const vOv = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
||
if (vOv <= eps) 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];
|
||
// คนละบรรทัด = rect ไม่ซ้อนทับแนวตั้งกัน (แค่ชิด/แตะกัน) — เดิมใช้เทียบ midY
|
||
// กับความสูง ซึ่งตัดสินผิดกับบรรทัดไทยคุมชิด (ห่าง 16px, สูง 16px: 16 ≤ 16
|
||
// = "บรรทัดเดียวกัน") จนไม่มี \n คั่น (และช่วยให้โดนจัดกลุ่มรวมกันในตอน hit)
|
||
const vOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||
const sameLine = vOverlap > eps;
|
||
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: annotationsProp,
|
||
replies,
|
||
openPinId,
|
||
ownUserId,
|
||
selectedId,
|
||
/** root สั่งเคลียร์ preview ของการลาก (bump counter — ปิด popup/เปลี่ยน selection/ก่อน
|
||
* คิว update): ทุกครั้งที่ค่าเปลี่ยน หน้าเคลียร์ previewData ท้องถิ่นของตัวเอง */
|
||
previewClear,
|
||
editingCommentId,
|
||
commentEditDraft,
|
||
onCommentEditDraft,
|
||
onStartCommentEdit,
|
||
textbox,
|
||
onSelectAnnotation,
|
||
onPinToggle,
|
||
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;
|
||
/** root สั่งเคลียร์ preview ของการลาก (bump counter — ดูตัวแปร previewClear ของ viewer) */
|
||
previewClear: number;
|
||
/** 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;
|
||
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);
|
||
|
||
// ระหว่างลากย้าย/ปรับขนาดรายการที่เลือก: data ชั่วคราวให้ layer เดิมของชนิดนั้น render
|
||
// ตาม (null = เลิก preview) — state อยู่ที่หน้านี้ ไม่ใช่ root ของ viewer: ต่อ pointermove
|
||
// ตอนลากต้อง re-render เฉพาะหน้าที่ลากเท่านั้น (เดิม setPreviewData อยู่ root แล้วแทนค่า
|
||
// กลับลงมาให้ทุกหน้า = จ่าย render ทั้งต้นไม้ต่อเฟรม จนลากเป็น step — แก้ 2026-09-29)
|
||
// เก็บพร้อม id เจ้าของไม่อิง selectedId ตอน substitute: การเคลียร์จาก root มาเป็น
|
||
// passive effect หลัง paint — อิง selectedId แล้วจอสัมผัสเปลี่ยน selection กลาง drag
|
||
// (นิ้วหนึ่งลากค้าง อีกนิ้วเลือกอื่น) จะเอา preview ของรายการเก่าไปทดแทนรายการใหม่
|
||
// หนึ่งเฟรม (code review 2026-09-29)
|
||
const [preview, setPreview] = useState<{ id: string; data: AnnotationData } | null>(null);
|
||
|
||
// root สั่งเคลียร์ preview ผ่าน counter (ปิด popup/เปลี่ยน selection/ก่อนคิว update) —
|
||
// ครั้งแรกตอน mount preview ยัง null อยู่แล้ว จึงเป็น no-op
|
||
useEffect(() => {
|
||
setPreview(null);
|
||
}, [previewClear]);
|
||
|
||
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
|
||
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]);
|
||
|
||
// ระหว่างลาก: แทน data ของรายการที่เลือกด้วย preview ก่อนแยกไป layer ต่าง ๆ +
|
||
// กรอบ selection (เดิมแทนตอน root ส่ง prop — ย้ายลงมาพร้อม state ของหน้า, 2026-09-29)
|
||
// เทียบ id กับเจ้าของ preview โดยตรง (ไม่ใช่ selectedId ปัจจุบัน) — ดู comment ของ state
|
||
const annotations = preview
|
||
? annotationsProp.map((a) =>
|
||
a.id === preview.id ? { ...a, data: preview.data } : a,
|
||
)
|
||
: annotationsProp;
|
||
|
||
const highlights = annotations.filter(isHighlight);
|
||
const 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}
|
||
// ผูก id เจ้าของ preview ตอนรับค่า (ไม่ใช่ตอน substitute) — ดู comment ของ state preview
|
||
onPreview={(d) => setPreview(d && selected ? { id: selected.id, data: d } : null)}
|
||
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);
|
||
// "เคลียร์ preview ของการลาก" จาก root — preview เป็น state ท้องถิ่นของหน้า (PdfPage)
|
||
// ตั้งแต่ 2026-09-29 root จึงสั่งเคลียร์ผ่าน counter นี้แทน setter ตรง ๆ: ทุกจุดที่เดิม
|
||
// setPreviewData(null) (ปิด popup/เปลี่ยน selection/ก่อนคิว update — กัน preview เก่าค้าง
|
||
// ทับรายการที่เลือกใหม่) bump ค่านี้ แล้วทุกหน้า effect เคลียร์ previewData ตัวเอง
|
||
const [previewClear, bumpPreviewClear] = useReducer((c: number) => c + 1, 0);
|
||
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
||
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/note/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/note/pdf ของเราแทนทางตรง — กัน CORS
|
||
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
|
||
const fileUrl = isHttpUrl(file.pdfUrl)
|
||
? withBasePath(`/api/note/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/note/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
||
let data: ArrayBuffer;
|
||
try {
|
||
const res = await fetch(withBasePath(`/api/note/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);
|
||
bumpPreviewClear();
|
||
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);
|
||
bumpPreviewClear();
|
||
// แผง 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);
|
||
bumpPreviewClear();
|
||
return;
|
||
}
|
||
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
|
||
setSelectedId(null);
|
||
bumpPreviewClear();
|
||
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)
|
||
bumpPreviewClear();
|
||
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);
|
||
bumpPreviewClear();
|
||
}
|
||
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);
|
||
bumpPreviewClear();
|
||
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);
|
||
bumpPreviewClear();
|
||
// เลือกรายการอื่น = ปิด 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={`/note/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}>
|
||
ใต้ "{h.title}" (หน้า {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)}
|
||
replies={visibleReplies}
|
||
openPinId={openPinId}
|
||
ownUserId={user?.userId ?? ""}
|
||
selectedId={selectedId}
|
||
previewClear={previewClear}
|
||
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}
|
||
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>
|
||
);
|
||
}
|