Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Failing after 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Has been skipped
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
(1) hooks/useCustomSelection.ts (ใหม่): tool highlight บนอุปกรณ์ pointer หลัก coarse ลากเลือกข้อความเองผ่าน Pointer Events ชุดเดียวคุม mouse+touch — pointerdown เก็บ anchor caret (caretRangeFromPoint → caretPositionFromPoint fallback) ยังไม่ทำอะไร → เลย threshold 8px จึง setPointerCapture + เริ่ม select จริง (จับตอนเริ่ม select ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/pin ใช้ไม่ได้) → pointerup = commit ทันทีผ่าน commitHighlight เดิม (ห้ามแก้ signature — ไปทาง readTextSelection(pi, { range }) ที่เพิ่ม opts.range ให้อ่าน Range จากภายนอกแทน native selection) — ชดเชย UX ที่หายจาก native: word snap ขอบคำทั้ง anchor/focus (Intl.Segmenter แยกคำไทย dictionary → fallback regex \S+, กลางช่องว่างไม่ขยาย), finger offset อ่าน caret ที่ y−12 เพราะนิ้วบังข้อความ (เฉพาะ pointerType touch), auto-scroll rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px, live preview rAF throttle (getClientRects → rectsInSweptPath → % + mergeRectsByLine รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า customSelPreview ?? liveMark)
(2) กัน "ลากแล้วบางทีไม่ commit" ตาม bug report 3 จุด + code review: การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (resolveTextCaretDeep ใช้เฉพาะ pointerdown กับทั้งจุด y−12 และจุดแตะจริง): caret API + descend หา text node (childNodes[startOffset] + findFirstTextNode DFS) → retry 8 ทิศ ±2/3px → elementsFromPoint เจาะ overlay ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ใต้จุด → span ของหน้านั้นที่ใกล้สุดแนวตั้ง ≤40px (เกินถือว่าไม่มีข้อความจริง; cache span list ต่อ 1 gesture) — ระหว่างลากทุก rAF frame ใช้เฉพาะ 2 ชั้นถูก หาไม่เจอ (ผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้คง focus เดิมจาก frame ก่อน (lastFocus) ไม่ re-scan / lastRef เคลียร์เฉพาะ pointerdown, finish และเมื่อหน้า anchor โดน virtualization unmount กลางลาก (anchorPageEl.isConnected — กัน commit ของเก่าเงียบ ๆ) — frame ที่ rectsInSweptPath กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview / ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า) ห้าม bail — clampToAnchorPage clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) boundary cache ต่อ gesture (clampBounds ไม่เดิน TreeWalker ทุก frame) / จุดเส้นทางกรอง rectsInSweptPath ใช้จุด offset เดียวกับ caret (anchorPathPt = จุด probe ที่ resolve anchor + pathEnd = จุด focus หัก 12px) — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกินบรรทัด (ลากลง) เมื่อฟอนต์เล็ก
(3) ถอด path เดิมทั้งหมด: touchConfirm state, touchSelRef/touchSelTimerRef/touchHideTimerRef, debounce 400ms ใน effect selectionchange, handleTouchConfirmHighlight, ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — desktop (pointer หลัก fine) ใช้ native mouse selection + mouseup commit ตามเดิมไม่เปลี่ยน (enabled = tool highlight + matchMedia coarse) / ระหว่าง enabled ให้ <main> ได้ class .pdf-custom-select (user-select/-webkit-user-select/-webkit-touch-callout: none + touch-action: none กัน browser ยึด gesture เป็น scroll → pointercancel) ถอดทันทีเมื่อเปลี่ยน tool (tool อื่น native selection + long-press copy ของ OS ตามปกติ) + restore user-select:text และ touch-action:auto ให้ input/textarea/contenteditable ใต้ main (reply ของ comment pin ยังเลือก/เลื่อนข้อความในช่องได้)
(4) ย้าย ClientPt + rectsInSweptPath จาก PDFViewer ไป utils/coordinates.ts (hook import ได้โดยไม่ circular) + unit tests rectsInSweptPath 4 เคส (ลากบรรทัดเดียว/ลากขึ้นหลายบรรทัด/บรรทัดนอกเส้นทาง/ไม่ขยับ) / gap-analysis.md แถว 5.3.18 อัปเดตเป็นกลไกใหม่ (รวมงานฉบับแก้ไขข้อความข้อ 5.3.18–5.3.25 ของเจ้าของเอกสารที่ค้างใน working tree) / sync rules/pdf-viewer.md ครบทั้งกลไก 4 ชั้น + path points + กรณีหน้า unmount
verify: tsc ผ่าน + vitest 104/104 + Playwright touch emulation 3 ชุด (รอยต่อ span คำติดกัน gap=0/ขอบซ้าย-ขวาสุดบรรทัด/ช่องว่าง line-height, ลากช้า-เร็ว flick/ล้ำหน้าถัดไปทั้งลงและขึ้น/ขยับต่ำกว่า threshold ไม่สร้าง/tap annotation ได้, จุดว่างใกล้ 20px → commit vs ไกล 46px → ไม่สร้าง) — console.warn ชุด TEMP-DEBUG คงไว้ชั่วคราวตาม protocol รอยืนยันบนมือถือจริง (Safari iOS + Chrome Android) ก่อนถอด
Co-Authored-By: Claude Code <noreply@anthropic.com>
3630 lines
182 KiB
TypeScript
3630 lines
182 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useOptimistic,
|
||
useRef,
|
||
useState,
|
||
startTransition,
|
||
type MouseEvent as ReactMouseEvent,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { createPortal } from "react-dom";
|
||
import {
|
||
AlignCenter,
|
||
AlignLeft,
|
||
AlignRight,
|
||
ArrowRight,
|
||
BookmarkPlus,
|
||
Brush,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Circle,
|
||
ClipboardList,
|
||
Download,
|
||
Eraser,
|
||
EyeOff,
|
||
Hand,
|
||
Highlighter,
|
||
MessageSquare,
|
||
MessageSquarePlus,
|
||
Minus,
|
||
MousePointer2,
|
||
PanelLeft,
|
||
Pencil,
|
||
Redo2,
|
||
Shapes,
|
||
Square,
|
||
Trash2,
|
||
Type,
|
||
Stamp,
|
||
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,
|
||
sanitizePdfText,
|
||
STAMP_COLOR,
|
||
STAMP_DEFAULT_SCALE_PCT,
|
||
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 { getLocalUser, type LocalUser } from "@/utils/user";
|
||
import {
|
||
TEXTBOX_DEFAULT_WIDTH_PCT,
|
||
annotationBBox,
|
||
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 from "./AnnotationSelectionLayer";
|
||
import AnnotationSidebar from "../sidebar/AnnotationSidebar";
|
||
import Select from "../Select";
|
||
import {
|
||
ColorSwatches,
|
||
HIGHLIGHT_STYLE_BTNS,
|
||
NumberCombo,
|
||
PanelRow,
|
||
} from "./ToolControls";
|
||
|
||
type Tool =
|
||
| "select"
|
||
| "pan"
|
||
| "highlight"
|
||
| "draw"
|
||
| "comment"
|
||
| "shape"
|
||
| "erase"
|
||
| "redact"
|
||
| "textbox"
|
||
| "stamp";
|
||
|
||
/** 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 กลางทาง */
|
||
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 };
|
||
|
||
const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"];
|
||
|
||
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
|
||
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
|
||
* และตัดยาวไม่เกิน 120 ตัวอักษร */
|
||
function normalizeBookmarkTitle(s: string): string {
|
||
const t = sanitizePdfText(s).replace(/\s+/g, " ").trim();
|
||
return t.length > 120 ? `${t.slice(0, 120)}…` : t;
|
||
}
|
||
|
||
/** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */
|
||
function viewerHeaderHeight(): number {
|
||
return document.querySelector("header")?.offsetHeight ?? 0;
|
||
}
|
||
|
||
/** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) —
|
||
* ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */
|
||
const TOOL_MENU_W = 240;
|
||
const BOOKMARK_MENU_W = 288;
|
||
|
||
/** ตำแหน่ง fixed ของเมนูที่เปิดจากปุ่มนี้ — ตรงใต้ปุ่ม + clamp ไม่ให้ล้นขอบจอ
|
||
* (เมนู render ผ่าน createPortal ที่ document.body ออกจาก scroll container ของแถบ tool) */
|
||
function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number } {
|
||
const vw = window.innerWidth;
|
||
return {
|
||
left: clamp(rect.left, 8, Math.max(8, vw - menuW - 8)),
|
||
top: rect.bottom + 4,
|
||
};
|
||
}
|
||
|
||
/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
|
||
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
|
||
const SCROLL_BELOW_HEADER_GAP = 40;
|
||
|
||
function scrollToBelowHeader(absoluteTop: number): void {
|
||
window.scrollTo({
|
||
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
|
||
/** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */
|
||
function squigglePath(w: number): string {
|
||
let d = "M0 2.5";
|
||
let up = true;
|
||
for (let x = 0; x + 4 <= Math.max(w, 4); x += 4) {
|
||
d += up ? " q2 -2.5 4 0" : " q2 2.5 4 0";
|
||
up = !up;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/** วรรณยุกต์/สระบน-ล่างไทย (combining mark) — ตามหลังพยัญชนะฐานเสมอ แต่เดี่ยว ๆ
|
||
* rect อาจกว้าง 0 หรือลอยเหนือบรรทัด จึงต้องให้ตามสถานะของตัวฐานก่อนหน้า */
|
||
function isThaiCombiningMark(c: string): boolean {
|
||
const v = c.charCodeAt(0);
|
||
return v === 0x0e31 || (v >= 0x0e34 && v <= 0x0e3a) || (v >= 0x0e47 && v <= 0x0e4e);
|
||
}
|
||
|
||
/** ข้อความเฉพาะส่วนที่อยู่ใน rects ที่กรองแล้ว — DOM range ของ textLayer ไทยครอบ
|
||
* ข้อความเกินบริเวณที่ลากจริง selection.toString() ดิบเลยได้คำเกินมา (rects ถูกกรอง
|
||
* ด้วย rectsInSweptPath แต่ข้อความยังไม่) จึงเดินทีละตัวอักษรใน text nodes ของ range
|
||
* เช็คว่า rect ของตัวอักษรนั้นตกใน rect ที่ render บนจอไหม — ข้อความตรงกับที่เห็น
|
||
* จากนั้นเรียงตามตำแหน่ง rect (บน→ล่าง, ซ้าย→ขวา) เพราะ DOM order ของ span ใน PDF
|
||
* ไทยบางไฟล์ไม่ตรงลำดับบนจอ แล้วปิดท้ายด้วย sanitizePdfText ตัด control char ขยะ
|
||
* แพงกว่า toString() มาก ใช้เฉพาะตอน mouseup/copy เท่านั้น (live preview เรียกถี่เกิน) */
|
||
const TEXT_PROBE_MAX_CHARS = 3000;
|
||
function selectionTextWithin(kept: DOMRect[], range: Range): string {
|
||
const raw = range.toString();
|
||
// เลือกยาวเกินขีดนี้ = คุ้มกว่าใช้ข้อความดิบ (กันหน่วงตอนปล่อยเมาส์)
|
||
if (raw.length > TEXT_PROBE_MAX_CHARS) return sanitizePdfText(raw.slice(0, 5000));
|
||
const ancestor = range.commonAncestorContainer;
|
||
const root = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode : ancestor;
|
||
if (!root) return sanitizePdfText(raw.slice(0, 5000));
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||
const probe = document.createRange();
|
||
const eps = 1; // ทนพลาดจากการปัดทศนิยมของ client rects
|
||
// ตัวอักษรที่เลือก: rect ที่ hit (ใช้เป็นกลุ่มบรรทัด) + ตำแหน่ง x + ลำดับ DOM
|
||
// (สำรองตอนจัดเรียง — combining mark ที่ไม่มี rect เอา x ของตัวฐานก่อนหน้า)
|
||
type Entry = { ch: string; rect: number; left: number; dom: number };
|
||
const entries: Entry[] = [];
|
||
let dom = 0;
|
||
let prevRect = -1;
|
||
let prevLeft = 0;
|
||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||
const node = n as Text;
|
||
if (!range.intersectsNode(node)) continue;
|
||
const s = node === range.startContainer ? range.startOffset : 0;
|
||
const e = node === range.endContainer ? range.endOffset : node.data.length;
|
||
for (let i = s; i < e && entries.length < 6000; i++) {
|
||
const ch = node.data[i];
|
||
probe.setStart(node, i);
|
||
probe.setEnd(node, i + 1);
|
||
const r = probe.getClientRects()[0];
|
||
let hit = -1;
|
||
if (r !== undefined) {
|
||
hit = kept.findIndex(
|
||
(k) =>
|
||
r.left < k.right + eps &&
|
||
r.right > k.left - eps &&
|
||
r.top < k.bottom + eps &&
|
||
r.bottom > k.top - eps,
|
||
);
|
||
}
|
||
// combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น
|
||
const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0);
|
||
if (hit < 0 && sticky) hit = prevRect;
|
||
if (hit < 0) {
|
||
prevRect = -1;
|
||
continue;
|
||
}
|
||
entries.push({
|
||
ch,
|
||
rect: hit,
|
||
left: hit === prevRect && sticky ? prevLeft : r ? r.left : 0,
|
||
dom: dom++,
|
||
});
|
||
prevRect = hit;
|
||
prevLeft = entries[entries.length - 1].left;
|
||
}
|
||
}
|
||
if (entries.length === 0) return sanitizePdfText(raw.slice(0, 5000));
|
||
// เรียงกลุ่ม rect ตามตำแหน่งบนจอ: คนละบรรทัด (กลางแกน y ห่างเกินความสูงบรรทัด)
|
||
// เรียงบน→ล่าง, บรรทัดเดียวกันเรียงซ้าย→ขวา — DOM order ใช้เป็น tie-break เท่านั้น
|
||
const midY = (r: DOMRect) => r.top + r.height / 2;
|
||
const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => {
|
||
const ra = kept[a];
|
||
const rb = kept[b];
|
||
if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) {
|
||
return midY(ra) - midY(rb);
|
||
}
|
||
return ra.left - rb.left;
|
||
});
|
||
let out = "";
|
||
let prev = -1;
|
||
for (const ri of rectOrder) {
|
||
if (prev >= 0) {
|
||
const a = kept[prev];
|
||
const b = kept[ri];
|
||
const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height);
|
||
if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString()
|
||
}
|
||
out += entries
|
||
.filter((en) => en.rect === ri)
|
||
.sort((a, b) => a.left - b.left || a.dom - b.dom)
|
||
.map((en) => en.ch)
|
||
.join("");
|
||
prev = ri;
|
||
}
|
||
// กรองแล้วว่างเปล่า (เรขาคณิตผิดปกติ) = ใช้ข้อความดิบดีกว่าไม่มีเลย
|
||
return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000));
|
||
}
|
||
|
||
const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
|
||
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
|
||
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
|
||
{ key: "highlight", label: "มาร์กข้อความ (H)", Icon: Highlighter },
|
||
{ key: "draw", label: "วาดเส้น (D)", Icon: Pencil },
|
||
{ key: "comment", label: "วาง comment (C)", Icon: MessageSquarePlus },
|
||
{ key: "shape", label: "รูปทรง", Icon: Shapes },
|
||
{ key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser },
|
||
{ key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff },
|
||
{ key: "textbox", label: "กล่องข้อความ", Icon: Type },
|
||
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
|
||
];
|
||
|
||
/** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible
|
||
* ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน
|
||
* ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง
|
||
* (z-10 ให้ ring ลอยเหนือปุ่มข้าง ๆ — flex item ใช้ z-index ได้) */
|
||
const FOCUS_RING_CLS =
|
||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
|
||
|
||
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
|
||
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
|
||
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
|
||
"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="#334155" 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 });
|
||
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) => void;
|
||
onDeleteReply: (id: string) => void;
|
||
}) {
|
||
const [draft, setDraft] = useState("");
|
||
function submit() {
|
||
const msg = draft.trim();
|
||
if (!msg) return;
|
||
setDraft("");
|
||
onCreateReply(annotationId, msg);
|
||
}
|
||
return (
|
||
<div className="mt-2 border-t border-slate-100 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-slate-600">
|
||
{r.userName}
|
||
<span className="ml-1 font-normal text-slate-400" suppressHydrationWarning>
|
||
{new Date(r.createdAt).toLocaleString("th-TH", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
day: "2-digit",
|
||
month: "short",
|
||
})}
|
||
</span>
|
||
</p>
|
||
<p className="whitespace-pre-wrap break-words text-[11px]">{r.message}</p>
|
||
</div>
|
||
{r.userId === ownUserId && (
|
||
<button
|
||
type="button"
|
||
aria-label="ลบคำตอบ"
|
||
className="shrink-0 text-slate-300 hover:text-red-500"
|
||
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") submit();
|
||
}}
|
||
placeholder="ตอบ comment…"
|
||
className="min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={!draft.trim()}
|
||
onClick={submit}
|
||
className="shrink-0 rounded bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40"
|
||
>
|
||
ตอบ
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── หน้าเดียว: canvas + highlight-layer + textLayer + InkLayer + pins ──────
|
||
|
||
function PdfPage({
|
||
pdfjs,
|
||
doc,
|
||
pageNumber,
|
||
width,
|
||
height,
|
||
tool,
|
||
shapeKind,
|
||
drawStyle,
|
||
shapeStyle,
|
||
textboxStyle,
|
||
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
|
||
pageBaseWidth,
|
||
liveMark,
|
||
liveMarkStyle,
|
||
selectPreview,
|
||
annotations,
|
||
replies,
|
||
openPinId,
|
||
ownUserId,
|
||
selectedId,
|
||
editingCommentId,
|
||
commentEditDraft,
|
||
onCommentEditDraft,
|
||
onStartCommentEdit,
|
||
textbox,
|
||
onSelectAnnotation,
|
||
onPinToggle,
|
||
onAnnotationPreview,
|
||
onAnnotationUpdate,
|
||
onSetResolved,
|
||
onAnnotationBookmark,
|
||
onCreateReply,
|
||
onDeleteReply,
|
||
onStrokeEnd,
|
||
onShapeEnd,
|
||
onRedactionEnd,
|
||
onErase,
|
||
onTextLayerRendered,
|
||
}: {
|
||
pdfjs: typeof import("pdfjs-dist");
|
||
doc: import("pdfjs-dist").PDFDocumentProxy;
|
||
/** 1-based */
|
||
pageNumber: number;
|
||
width: number;
|
||
height: number;
|
||
tool: Tool;
|
||
shapeKind: ShapeKind;
|
||
/** ค่าปากกาปัจจุบันของ tool draw — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่ */
|
||
drawStyle: { color: string; strokeWidthPct: number; opacity: number };
|
||
/** ค่าสี/ความหนาของ tool shape — ใช้ตอนสร้างรูปทรงใหม่ */
|
||
shapeStyle: { color: string; strokeWidthPct: number };
|
||
/** ค่าสี/ขนาดฟอนต์/หนา-ทึบ-จัดแนวของ tool textbox — ใช้ทั้ง composer และตอนบันทึก */
|
||
textboxStyle: {
|
||
color: string;
|
||
fontSizePct: number;
|
||
bold: boolean;
|
||
opacity: number;
|
||
align: "left" | "center" | "right";
|
||
/** ความกว้างกล่อง default (สไตล์ Smallpdf — คลิกเดียวได้กล่อง ลากขอบขยายทีหลัง) */
|
||
widthPct: number;
|
||
};
|
||
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
|
||
liveMark: { pageIndex: number; rects: Rect[] } | null;
|
||
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
|
||
/** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark
|
||
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
|
||
selectPreview: { pageIndex: number; rects: Rect[] } | null;
|
||
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
|
||
pageBaseWidth: number;
|
||
annotations: AnnotationDTO[];
|
||
replies: ReplyDTO[];
|
||
openPinId: string | null;
|
||
ownUserId: string;
|
||
selectedId: string | null;
|
||
/** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
|
||
editingCommentId: string | null;
|
||
commentEditDraft: string;
|
||
onCommentEditDraft: (text: string) => void;
|
||
onStartCommentEdit: (id: string | null) => void;
|
||
/** F17/B2: state/handler ของกล่องข้อความ (composer จะ render เฉพาะหน้าที่ตรง pageIndex) */
|
||
textbox: TextboxUI;
|
||
/** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */
|
||
onSelectAnnotation: (id: string) => void;
|
||
onPinToggle: (id: string | null) => void;
|
||
onAnnotationPreview: (data: AnnotationData | null) => void;
|
||
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
|
||
onSetResolved: (id: string, resolved: boolean) => void;
|
||
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
|
||
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
|
||
onCreateReply: (annotationId: string, message: string) => void;
|
||
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);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let renderTask: import("pdfjs-dist").RenderTask | null = null;
|
||
|
||
(async () => {
|
||
const page = await doc.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 pdfjs.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 จะเก่า
|
||
}, [pdfjs, doc, pageNumber, width, height]);
|
||
|
||
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 — pointer-events: none เสมอ */}
|
||
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
|
||
|
||
{/* 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 }),
|
||
"#3b82f6",
|
||
"highlight",
|
||
0.3,
|
||
),
|
||
)}
|
||
</div>
|
||
|
||
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
|
||
<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;
|
||
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 ${
|
||
a.resolved
|
||
? "border-emerald-300 bg-emerald-50 text-emerald-600 opacity-60 hover:opacity-100"
|
||
: "border-amber-300 bg-amber-100 text-amber-600 hover:bg-amber-200"
|
||
}`}
|
||
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 left-3 top-0 z-10 w-56 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
|
||
style={{ 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="shrink-0 text-slate-400 hover:text-slate-600"
|
||
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 border border-slate-300 p-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||
/>
|
||
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
||
<button
|
||
type="button"
|
||
className="coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
|
||
onClick={() => onStartCommentEdit(null)}
|
||
>
|
||
ยกเลิก
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!commentEditDraft.trim()}
|
||
className="coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
|
||
onClick={() => {
|
||
const msg = commentEditDraft.trim();
|
||
if (!msg) return;
|
||
onAnnotationUpdate(a.id, {
|
||
position: a.data.position,
|
||
message: msg,
|
||
});
|
||
onStartCommentEdit(null);
|
||
}}
|
||
>
|
||
บันทึก
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="mt-1 whitespace-pre-wrap break-words">{a.data.message}</p>
|
||
)}
|
||
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
|
||
{new Date(a.createdAt).toLocaleString("th-TH")}
|
||
</p>
|
||
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
|
||
<button
|
||
type="button"
|
||
className={`mt-1.5 w-full rounded px-2 py-1 text-[11px] font-medium ${
|
||
a.resolved
|
||
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
|
||
: "border border-slate-300 text-slate-500 hover:bg-slate-100"
|
||
}`}
|
||
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);
|
||
if (!selected) return null;
|
||
return (
|
||
<AnnotationSelectionLayer
|
||
annotation={selected}
|
||
size={{ width, height }}
|
||
editable={ownUserId !== "" && selected.userId === ownUserId}
|
||
pageBaseWidth={pageBaseWidth}
|
||
disabled={overlayFrozen}
|
||
onPreview={onAnnotationPreview}
|
||
onCommit={onAnnotationUpdate}
|
||
onDelete={onErase}
|
||
onBookmark={(y) => onAnnotationBookmark(selected, y)}
|
||
onStartEdit={textbox.startEdit}
|
||
/>
|
||
);
|
||
})()}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── Client root ─────────────────────────────────────────────────────────────
|
||
|
||
export default function PDFViewer({
|
||
file,
|
||
initialAnnotations,
|
||
initialBookmarks,
|
||
initialReplies,
|
||
}: {
|
||
file: { id: string; title: string; pdfUrl: string };
|
||
initialAnnotations: AnnotationDTO[];
|
||
initialBookmarks: BookmarkDTO[];
|
||
initialReplies: ReplyDTO[];
|
||
}) {
|
||
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
|
||
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | 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("#ef4444");
|
||
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 ตรายาง
|
||
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_COLORS[0]);
|
||
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 [user, setUser] = useState<LocalUser | null>(null);
|
||
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);
|
||
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
|
||
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
|
||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||
const [commentEditDraft, setCommentEditDraft] = useState("");
|
||
// F17/B2: กล่องข้อความ — composer ตอนสร้าง + โหมดแก้ไขข้อความของตัวเอง (double-click)
|
||
const [textboxComposer, setTextboxComposer] = useState<TextboxComposer | null>(null);
|
||
const [editingTextboxId, setEditingTextboxId] = useState<string | null>(null);
|
||
// F4 ซ่อน–แสดงรายคน — "all" | "mine" | <userId> กรองฝั่ง client ล้วน (A3)
|
||
const [userFilter, setUserFilter] = useState<string>("all");
|
||
// UX review 2026-09-09: สลับ sidebar — จอ < xl เป็น overlay, desktop กดซ่อนเพื่อดู PDF เต็มจอ
|
||
// (เริ่ม true เพื่อให้ SSR เรนเดอร์แบบเปิดเหมือน desktop เดิม กัน hydration mismatch)
|
||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||
|
||
const {
|
||
annotations,
|
||
bookmarks,
|
||
replies,
|
||
setAnnotations,
|
||
setBookmarks,
|
||
setReplies,
|
||
} = useAnnotationRealtime(file.id, initialAnnotations, initialBookmarks, initialReplies);
|
||
|
||
// F2: undo/redo ต่อ session ต่อผู้ใช้ฝั่ง client (D10 — ไม่เก็บใน DB)
|
||
const {
|
||
push: pushHistory,
|
||
undo: undoHistory,
|
||
redo: redoHistory,
|
||
canUndo,
|
||
canRedo,
|
||
} = useHistory();
|
||
|
||
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
|
||
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
|
||
const [optimisticAnnotations, applyAnnotation] = useOptimistic(
|
||
annotations,
|
||
(state, action: AnnotationAction) => {
|
||
if (action.kind === "add") {
|
||
return state.some((a) => a.id === action.row.id)
|
||
? state
|
||
: [...state, action.row];
|
||
}
|
||
if (action.kind === "update") {
|
||
// replace ตาม id — idempotent เหมือน SSE update event (PLAN-FEAT.md §2)
|
||
return state.map((a) => (a.id === action.row.id ? action.row : a));
|
||
}
|
||
return state.filter((a) => a.id !== action.id);
|
||
},
|
||
);
|
||
// N2: การสร้าง bookmark รอผลจาก action ตรง ๆ (ไม่ optimistic) — เหลือแค่ remove
|
||
const [optimisticBookmarks, applyBookmark] = useOptimistic(
|
||
bookmarks,
|
||
(state, action: BookmarkAction) =>
|
||
action.kind === "remove" ? state.filter((b) => b.id !== action.id) : state,
|
||
);
|
||
|
||
// F4: รายชื่อผู้เขียน (unique ตาม userId จาก annotations — ตัวเองอยู่ใน option "ของฉัน" แล้ว)
|
||
const users = useMemo(() => {
|
||
const seen = new Map<string, string>();
|
||
for (const a of optimisticAnnotations) {
|
||
if (!seen.has(a.userId) && a.userId !== user?.userId) seen.set(a.userId, a.userName);
|
||
}
|
||
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
|
||
}, [optimisticAnnotations, user]);
|
||
|
||
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3)
|
||
const visibleAnnotations = useMemo(() => {
|
||
if (userFilter === "all") return optimisticAnnotations;
|
||
const target = userFilter === "mine" ? user?.userId : userFilter;
|
||
return optimisticAnnotations.filter((a) => a.userId === target);
|
||
}, [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 }));
|
||
}
|
||
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],
|
||
);
|
||
|
||
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
|
||
useEffect(() => {
|
||
setUser(getLocalUser());
|
||
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
|
||
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
|
||
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
||
}, []);
|
||
|
||
// UX review 2026-09-14 M5: จอ < lg แถบ tool เป็นแถวเดียวเลื่อนแนวนอน — เฝ้าว่าเลื่อน
|
||
// ถึงท้ายแถวหรือยัง เพื่อซ่อนแถบเงาขวา (บอกว่ายังมีปุ่มถัดไป) เมื่อเลื่อนสุดแล้ว
|
||
const toolbarRowRef = useRef<HTMLDivElement | null>(null);
|
||
const [toolbarAtEnd, setToolbarAtEnd] = useState(false);
|
||
const syncToolbarEnd = useCallback(() => {
|
||
const el = toolbarRowRef.current;
|
||
if (el) setToolbarAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 4);
|
||
}, []);
|
||
useEffect(() => {
|
||
syncToolbarEnd();
|
||
window.addEventListener("resize", syncToolbarEnd);
|
||
return () => window.removeEventListener("resize", syncToolbarEnd);
|
||
}, [syncToolbarEnd]);
|
||
|
||
// เมนู tool ใด ๆ เปิดอยู่ — C1: เมนูเป็น position:fixed ผ่าน portal แล้ว แถบ tool คง
|
||
// overflow-x-auto เสมอไม่ต้องสลับ overflow — เหลือใช้เป็นเงื่อนไข effect ปิดเมนู
|
||
// ตอนเลื่อนแถบ/resize จอ (ตำแหน่ง fixed คำนวณตอนเปิด ไม่ตามปุ่มที่เลื่อนไปทีหลัง)
|
||
const anyMenuOpen =
|
||
highlightMenuOpen ||
|
||
drawMenuOpen ||
|
||
shapeMenuOpen ||
|
||
stampMenuOpen ||
|
||
textboxMenuOpen ||
|
||
!!bookmarkComposer;
|
||
|
||
// C1: ปิดเมนูทั้งหมดเมื่อเลื่อนแถบ tool ต่อ หรือ resize/หมุนจอ — ตำแหน่ง fixed จะเพี้ยน
|
||
// จากปุ่มทันทีที่แถบเลื่อน (header sticky → เลื่อนหน้าเอกสารไม่กระทบ ปุ่มไม่ขยับเทียบ viewport)
|
||
// Major 1 (code review): resize ของมือถือจาก URL bar/คีย์บอร์ดเปลี่ยนแค่ความสูงและเมนูยัง
|
||
// ยึด sticky header อยู่กับที่ — จึงปิดจาก resize เฉพาะเมื่อความกว้างจอเปลี่ยนจากตอนเปิดเท่านั้น
|
||
useEffect(() => {
|
||
if (!anyMenuOpen) return;
|
||
const close = () => {
|
||
markMenuClosed(); // M4: เลื่อนแถบ/resize จนเมนูปิด — tap ถัดไปใกล้ ๆ ไม่สร้างรายการ
|
||
setStampMenuOpen(false);
|
||
setShapeMenuOpen(false);
|
||
setDrawMenuOpen(false);
|
||
setTextboxMenuOpen(false);
|
||
setHighlightMenuOpen(false);
|
||
setBookmarkComposer(null);
|
||
};
|
||
const onResize = () => {
|
||
if (window.innerWidth !== menuOpenWRef.current) close();
|
||
};
|
||
toolbarRowRef.current?.addEventListener("scroll", close, { passive: true });
|
||
window.addEventListener("resize", onResize);
|
||
return () => {
|
||
toolbarRowRef.current?.removeEventListener("scroll", close);
|
||
window.removeEventListener("resize", onResize);
|
||
};
|
||
}, [anyMenuOpen]);
|
||
|
||
// M6: จำนวน span ของ textLayer ต่อหน้า (0 = หน้าสแกนไม่มีข้อความ) — ใช้แสดง hint ตอน
|
||
// tool highlight ว่าไฟล์นี้คลุมข้อความไม่ได้ / เขียนค่าเดิมคืน object เดิมให้ callback
|
||
// identity คงที่ (useCallback []) กัน effect render ของทุกหน้ารันซ้ำเพราะ prop เปลี่ยน
|
||
const [pageSpanCount, setPageSpanCount] = useState<Record<number, number>>({});
|
||
const handleTextLayerRendered = useCallback((pageIndex: number, spanCount: number) => {
|
||
setPageSpanCount((prev) =>
|
||
prev[pageIndex] === spanCount ? prev : { ...prev, [pageIndex]: spanCount },
|
||
);
|
||
}, []);
|
||
|
||
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let loadedDoc: import("pdfjs-dist").PDFDocumentProxy | null = null;
|
||
|
||
(async () => {
|
||
const pdfjs = await import("pdfjs-dist");
|
||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
||
let data: ArrayBuffer;
|
||
try {
|
||
const res = await fetch(`/api/pdf?fileId=${file.id}`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
data = await res.arrayBuffer();
|
||
} catch {
|
||
data = await (await fetch(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);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (loadedDoc) void loadedDoc.destroy();
|
||
};
|
||
}, [file.id, file.pdfUrl]);
|
||
|
||
const pageWrapRefs = useRef<(HTMLDivElement | 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);
|
||
},
|
||
{ 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 center = window.scrollY + window.innerHeight / 2;
|
||
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 = r.top + window.scrollY + r.height / 2;
|
||
const d = Math.abs(mid - center);
|
||
if (d < bestDist) {
|
||
bestDist = d;
|
||
best = i;
|
||
}
|
||
}
|
||
setCurrentPage(best);
|
||
};
|
||
onScroll();
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => window.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];
|
||
if (el) {
|
||
scrollToBelowHeader(el.getBoundingClientRect().top + window.scrollY);
|
||
}
|
||
}
|
||
}, [pageSizes, annotations]);
|
||
|
||
// จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง
|
||
// ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) —
|
||
// อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก
|
||
const mainRef = useRef<HTMLElement>(null);
|
||
const fitWidthDoneRef = useRef(false);
|
||
useEffect(() => {
|
||
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
|
||
fitWidthDoneRef.current = true;
|
||
const avail = (mainRef.current?.clientWidth ?? 0) - 32;
|
||
const pageW = pageSizes[0].width;
|
||
if (avail > 0 && pageW > avail) {
|
||
setScale(clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1));
|
||
}
|
||
}, [pageSizes]);
|
||
|
||
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();
|
||
}
|
||
// 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 = [
|
||
{ open: stampMenuOpen, ref: stampMenuRef, close: () => setStampMenuOpen(false) },
|
||
{ open: shapeMenuOpen, ref: shapeMenuRef, close: () => setShapeMenuOpen(false) },
|
||
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
|
||
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
|
||
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
|
||
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
|
||
];
|
||
if (!menus.some((m) => m.open)) return;
|
||
const onMouseDown = (e: MouseEvent) => {
|
||
// เมนู render ผ่าน portal ที่ document.body — ref ของ wrapper มองไม่เห็นโหนดใน portal
|
||
// ต้องข้ามก่อน ไม่งั้นคลิกในเมนูถูกนับเป็น "ข้างนอก" แล้วเมนูปิดตัวเอง (C1)
|
||
if (e.target instanceof Element && e.target.closest("[data-tool-menu]")) return;
|
||
markMenuClosed(); // M4: tap นี้ใช้ปิดเมนู — click ที่ตามมาต้องไม่สร้างรายการ
|
||
for (const m of menus) {
|
||
if (m.open && !m.ref.current?.contains(e.target as Node)) m.close();
|
||
}
|
||
};
|
||
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);
|
||
};
|
||
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen, bookmarkComposer]);
|
||
|
||
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
|
||
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
|
||
// 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 เอง
|
||
setComposer(null);
|
||
setOpenPinId(null);
|
||
setSelectedId(null);
|
||
setPreviewData(null);
|
||
setEditingCommentId(null);
|
||
setTextboxComposer(null);
|
||
setEditingTextboxId(null);
|
||
setBookmarkComposer(null);
|
||
return;
|
||
}
|
||
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (e.key.toLowerCase() === "z" && !inForm) {
|
||
e.preventDefault();
|
||
if (e.shiftKey) void h.redoHistory();
|
||
else void h.undoHistory();
|
||
}
|
||
return; // Ctrl/Cmd + อื่น (เช่น Ctrl+C) ไม่ใช่คีย์ลัดของเรา
|
||
}
|
||
|
||
if (e.altKey || inForm) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k === "delete" || k === "backspace") {
|
||
e.preventDefault(); // Backspace ในบางบราวเซอร์ = ย้อนหน้า — กันไว้
|
||
h.deleteSelected();
|
||
return;
|
||
}
|
||
// กันอักขระหลุดเข้า input ที่เพิ่ง autoFocus — กด B เปิด popup bookmark แล้ว
|
||
// React focus input ทันทีใน keydown เดียวกัน browser จึงพิมพ์ "b" ตามลงไป
|
||
if (k !== "h" && k !== "d" && k !== "c" && k !== "b") return;
|
||
e.preventDefault();
|
||
switch (k) {
|
||
case "h": // H = ไฮไลต์ — สลับไป tool highlight (คลุมข้อความ = สร้างทันที)
|
||
h.switchTool("highlight");
|
||
break;
|
||
case "d":
|
||
h.switchTool("draw");
|
||
break;
|
||
case "c":
|
||
h.switchTool("comment");
|
||
break;
|
||
case "b":
|
||
h.openBookmarkComposer(); // N2: เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อ (คีย์ลัด B เดิม)
|
||
break;
|
||
}
|
||
};
|
||
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 }>());
|
||
|
||
async function handleCreateAnnotation(
|
||
pageIndex: number,
|
||
type: AnnotationType,
|
||
data: AnnotationData,
|
||
) {
|
||
if (!user) return;
|
||
const now = new Date().toISOString();
|
||
const temp: AnnotationDTO = {
|
||
id: crypto.randomUUID(),
|
||
fileId: file.id,
|
||
pageIndex,
|
||
userId: user.userId,
|
||
userName: user.userName,
|
||
type,
|
||
data,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
resolved: false,
|
||
resolvedAt: null,
|
||
};
|
||
const pending = { cancelled: false };
|
||
pendingCreatesRef.current.set(temp.id, pending);
|
||
await startTransition(async () => {
|
||
applyAnnotation({ kind: "add", row: temp });
|
||
try {
|
||
const saved = await createAnnotation({
|
||
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
|
||
fileId: file.id,
|
||
pageIndex,
|
||
userId: user.userId,
|
||
userName: user.userName,
|
||
type,
|
||
data,
|
||
});
|
||
if (pending.cancelled) {
|
||
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
|
||
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
|
||
await deleteAnnotation(saved.id, user.userId);
|
||
return;
|
||
}
|
||
setAnnotations((prev) =>
|
||
prev.some((a) => a.id === saved.id) ? prev : [...prev, saved],
|
||
);
|
||
// สร้าง "วัตถุ" เสร็จ = เลือกตัวใหม่ทันที (ใช้ id จาก server — id ชั่วคราวของ
|
||
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
|
||
if (AUTO_SELECT_TYPES.has(type)) {
|
||
setSelectedId(saved.id);
|
||
setPreviewData(null);
|
||
}
|
||
// 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 ให้เอง
|
||
} finally {
|
||
pendingCreatesRef.current.delete(temp.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleDeleteAnnotation(id: string) {
|
||
if (!user) return;
|
||
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
|
||
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
|
||
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
|
||
// optimistic row จะหายเองเมื่อ transition ของ create จบลง
|
||
const pending = pendingCreatesRef.current.get(id);
|
||
if (pending) {
|
||
pending.cancelled = true;
|
||
setSelectedId(null);
|
||
setPreviewData(null);
|
||
return;
|
||
}
|
||
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
|
||
setSelectedId(null);
|
||
setPreviewData(null);
|
||
await startTransition(async () => {
|
||
applyAnnotation({ kind: "remove", id });
|
||
try {
|
||
await deleteAnnotation(id, user.userId);
|
||
setAnnotations((prev) => prev.filter((a) => a.id !== id));
|
||
if (target) {
|
||
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
|
||
let currentId: string | null = null;
|
||
pushHistory({
|
||
undo: async () => {
|
||
const re = await createAnnotation({
|
||
fileId: file.id,
|
||
pageIndex: target.pageIndex,
|
||
userId: target.userId,
|
||
userName: target.userName,
|
||
type: target.type,
|
||
data: target.data,
|
||
});
|
||
currentId = re.id;
|
||
setAnnotations((prev) =>
|
||
prev.some((a) => a.id === re.id) ? prev : [...prev, re],
|
||
);
|
||
},
|
||
redo: async () => {
|
||
if (!currentId) return;
|
||
await deleteAnnotation(currentId, user.userId);
|
||
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
|
||
},
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error("ลบ annotation ไม่สำเร็จ", err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
|
||
async function handleUpdateAnnotation(id: string, data: AnnotationData) {
|
||
if (!user) return;
|
||
const current = annotations.find((a) => a.id === id);
|
||
if (!current || current.userId !== user.userId) return; // ของคนอื่นแก้ไม่ได้ (D9)
|
||
const before = current.data; // ไว้ย้อนกลับตอน undo (F2)
|
||
setPreviewData(null);
|
||
await startTransition(async () => {
|
||
applyAnnotation({
|
||
kind: "update",
|
||
row: { ...current, data, updatedAt: new Date().toISOString() },
|
||
});
|
||
try {
|
||
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 ให้เอง
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── 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 ให้เอง
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
|
||
|
||
async function handleCreateReply(annotationId: string, message: string) {
|
||
if (!user) return;
|
||
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],
|
||
);
|
||
} catch (err) {
|
||
console.error("ตอบ comment ไม่สำเร็จ", err);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ─── 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() {
|
||
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) {
|
||
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];
|
||
if (!el) return null;
|
||
const r = el.getBoundingClientRect();
|
||
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
|
||
// แถบมองเห็นใต้ header (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) ตอนกด
|
||
// bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
|
||
const viewTop =
|
||
window.scrollY + viewerHeaderHeight() + SCROLL_BELOW_HEADER_GAP;
|
||
const pageTop = r.top + window.scrollY;
|
||
return {
|
||
pageIndex: currentPage,
|
||
yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
|
||
};
|
||
}
|
||
|
||
async function handleSaveBookmark() {
|
||
const c = bookmarkComposer;
|
||
if (!c || !user || bookmarkPendingRef.current) return;
|
||
bookmarkPendingRef.current = true;
|
||
try {
|
||
const saved = await createBookmark({
|
||
fileId: file.id,
|
||
pageIndex: c.pageIndex,
|
||
userId: user.userId,
|
||
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],
|
||
);
|
||
} catch (err) {
|
||
console.error("บันทึก bookmark ไม่สำเร็จ", err);
|
||
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
|
||
} finally {
|
||
bookmarkPendingRef.current = 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,
|
||
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,
|
||
);
|
||
|
||
// จอสัมผัส + 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)
|
||
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
|
||
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
|
||
// logic เลิกเลือกท้ายฟังก์ชัน แผงจึงค้างเปิด) — คลิกบนตัว annotation เองถูก
|
||
// stopPropagation ที่ layer ของมันแล้ว ส่วน tool select/highlight ตั้ง selectedId
|
||
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
|
||
if (selectedId !== null) {
|
||
setSelectedId(null);
|
||
setPreviewData(null);
|
||
}
|
||
if (tool === "comment" && user) {
|
||
const position = getPointerPercent(e, pageEl);
|
||
setComposer({ pageIndex, position, x: e.clientX, y: e.clientY });
|
||
return;
|
||
}
|
||
// F17/B2: คลิกบนหน้า = เปิด textarea สร้างกล่องข้อความ ณ ตำแหน่งนั้น
|
||
if (tool === "textbox" && user) {
|
||
const position = getPointerPercent(e, pageEl);
|
||
setTextboxComposer({ pageIndex, position });
|
||
return;
|
||
}
|
||
// F18/B5: คลิกบนหน้า = วางตรายาง preset ที่เลือกไว้ทันที (สี/ขนาดจากเมนูตั้งค่าของ tool)
|
||
if (tool === "stamp" && user) {
|
||
const position = getPointerPercent(e, pageEl);
|
||
void handleCreateAnnotation(pageIndex, "STAMP", {
|
||
label: stampPreset,
|
||
position,
|
||
scale: stampScalePct,
|
||
color: stampColor,
|
||
});
|
||
return;
|
||
}
|
||
// tool select/highlight — คลิกบน annotation ตัวไหน = เลือกตัวนั้น (กรอบ F1 + ปุ่มลบ)
|
||
// เช็คจากรายการหลังสุดก่อนเพื่อให้ตัวที่วาดทีหลัง (อยู่บน) ถูกเลือกก่อน
|
||
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 && 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) {
|
||
const el = pageWrapRefs.current[b.pageIndex];
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const pageTop = r.top + window.scrollY;
|
||
// ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
|
||
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
|
||
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
|
||
scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
|
||
}
|
||
|
||
function goToAnnotation(a: AnnotationDTO) {
|
||
// คลิกใน sidebar = กระโดดไปหน้า + เลือกรายการนั้น (F1) — เลื่อนให้ขอบบน bbox
|
||
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
|
||
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
|
||
setSelectedId(a.id);
|
||
setPreviewData(null);
|
||
const el = pageWrapRefs.current[a.pageIndex];
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const boxPct = annotationBBox(a, { width: 100, height: 100 });
|
||
scrollToBelowHeader(r.top + window.scrollY + (r.height * boxPct.y) / 100);
|
||
}
|
||
|
||
/** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */
|
||
function handlePinToggle(id: string | null) {
|
||
setOpenPinId(id);
|
||
if (id) setSelectedId(id);
|
||
}
|
||
|
||
/** คลิกรายการ overlay (TEXTBOX ฯลฯ) = เลือกรายการนั้น (F1) */
|
||
function handleSelectAnnotation(id: string) {
|
||
setSelectedId(id);
|
||
setPreviewData(null);
|
||
}
|
||
|
||
/** 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) ─────────────────────────────────────────────────────
|
||
|
||
/** เข้าสู่โหมดแก้ไขข้อความ TEXTBOX ของตัวเอง (double-click — null = ยกเลิก) */
|
||
function startTextboxEdit(id: string | null) {
|
||
setEditingTextboxId(id);
|
||
}
|
||
|
||
/** บันทึกข้อความที่แก้ — reuse updateAnnotation ของ F1 (เจ้าของเท่านั้น การกันอยู่ที่ handler)
|
||
* segments (ตัวหนาเฉพาะส่วน) เก็บเฉพาะเมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
|
||
function commitTextboxEdit(id: string, text: string, segments?: TextboxSegment[]) {
|
||
const current = annotations.find((a) => a.id === id);
|
||
if (!current || current.type !== "TEXTBOX") return;
|
||
setEditingTextboxId(null);
|
||
const hasBold = !!segments?.some((s) => s.bold);
|
||
void handleUpdateAnnotation(id, {
|
||
...current.data,
|
||
text,
|
||
segments: hasBold ? segments : undefined,
|
||
});
|
||
}
|
||
|
||
/** บันทึกกล่องข้อความใหม่จาก 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) {
|
||
setTool(t);
|
||
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
|
||
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
|
||
if (anyMenuOpen) markMenuClosed();
|
||
setStampMenuOpen(false);
|
||
setShapeMenuOpen(false);
|
||
setDrawMenuOpen(false);
|
||
setTextboxMenuOpen(false);
|
||
setHighlightMenuOpen(false);
|
||
setComposer(null);
|
||
setOpenPinId(null);
|
||
setSelectedId(null);
|
||
setPreviewData(null);
|
||
setEditingCommentId(null);
|
||
setTextboxComposer(null);
|
||
setEditingTextboxId(null);
|
||
window.getSelection()?.removeAllRanges();
|
||
}
|
||
|
||
/** ตัวเลื่อนหน้า — scroll ไปต้นหน้าที่ระบุ (กลไกเดียวกับ bookmark/deep link:
|
||
* placeholder ทุกหน้ามีขนาดจริงจึงคำนวณ absolute scroll ได้เสมอ) */
|
||
function goToPage(pageIndex: number) {
|
||
const el = pageWrapRefs.current[pageIndex];
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
scrollToBelowHeader(r.top + window.scrollY);
|
||
}
|
||
|
||
// ─── Render ────────────────────────────────────────────────────────────────
|
||
|
||
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง 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 (
|
||
<div className="flex min-h-screen">
|
||
{/* 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-slate-900/30 lg:hidden"
|
||
aria-hidden
|
||
onClick={() => setSidebarOpen(false)}
|
||
/>
|
||
)}
|
||
{/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
|
||
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
|
||
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
|
||
<aside
|
||
data-sidebar=""
|
||
className={`sticky top-0 flex h-screen w-72 flex-col shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
|
||
sidebarOpen
|
||
? "max-lg:translate-x-0"
|
||
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
|
||
}`}
|
||
>
|
||
{/* M6: ปิด sidebar บนมือถือได้อีกทาง — overlay (z-50) กลบปุ่ม toggle ใน header
|
||
(z-40) ไปด้วย เหลือทางออกเดิมคือจิ้ม backdrop 25% ขวา ซึ่งไม่ชัดเท่าปุ่มปิดตรง ๆ */}
|
||
<div className="flex items-center justify-end px-2 pt-2 lg:hidden">
|
||
<button
|
||
type="button"
|
||
aria-label="ปิดแผงข้าง"
|
||
className={`flex h-11 w-11 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||
onClick={() => setSidebarOpen(false)}
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
{/* min-h-0 flex-1 ให้ root h-full ของ sidebar อยู่ในคอลัมน์ flex โดยไม่ล้น h-screen */}
|
||
<div className="min-h-0 flex-1">
|
||
<AnnotationSidebar
|
||
annotations={visibleAnnotations}
|
||
bookmarks={optimisticBookmarks}
|
||
fileId={file.id}
|
||
userId={user?.userId ?? ""}
|
||
userFilter={userFilter}
|
||
users={users}
|
||
onUserFilterChange={setUserFilter}
|
||
replyCounts={replyCounts}
|
||
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
|
||
onUpdateAnnotation={(id, data) => {
|
||
void handleUpdateAnnotation(id, data);
|
||
}}
|
||
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
|
||
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
|
||
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
|
||
onGoToBookmark={goToBookmark}
|
||
onGoToAnnotation={goToAnnotation}
|
||
selectedId={selectedId}
|
||
/>
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
|
||
ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
|
||
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white">
|
||
<div className="touch-44 flex flex-wrap items-center gap-2 px-4 py-2">
|
||
{/* UX review 2026-09-09: สลับ sidebar (ทุกขนาดจอ — desktop ซ่อนเพื่อดู PDF เต็มจอได้) */}
|
||
<button
|
||
type="button"
|
||
title="สลับแผงข้าง"
|
||
aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"}
|
||
aria-expanded={sidebarOpen}
|
||
className={`shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||
onClick={() => setSidebarOpen((o) => !o)}
|
||
>
|
||
<PanelLeft className="h-4 w-4" />
|
||
</button>
|
||
|
||
{/* 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-lg border border-slate-300">
|
||
<button
|
||
type="button"
|
||
aria-label="หน้าก่อนหน้า"
|
||
disabled={currentPage <= 0}
|
||
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
|
||
onClick={() => goToPage(currentPage - 1)}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<span className="w-16 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-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
|
||
onClick={() => goToPage(currentPage + 1)}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center rounded-lg border border-slate-300">
|
||
<button
|
||
type="button"
|
||
aria-label="ซูมออก"
|
||
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||
onClick={() => setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3))}
|
||
>
|
||
<ZoomOut className="h-4 w-4" />
|
||
</button>
|
||
<span className="w-12 text-center text-xs tabular-nums">
|
||
{Math.round(scale * 100)}%
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label="ซูมเข้า"
|
||
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||
onClick={() => setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3))}
|
||
>
|
||
<ZoomIn className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-1 items-center justify-end gap-2">
|
||
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ */}
|
||
<Link
|
||
href={`/annotate/${file.id}/summary`}
|
||
title="หน้าสรุปหมายเหตุ"
|
||
className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||
>
|
||
<ClipboardList className="h-4 w-4" />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{/* UX review 2026-09-14 M5: จอ < lg ทั้งแถวเป็นแถวเดียวเลื่อนแนวนอนได้ (wrap เดิม
|
||
ทำ header สูง ~32% ของจอมือถือ) — C1: เมนู dropdown/popup bookmark ย้ายไป
|
||
createPortal (position:fixed ที่ document.body) แล้ว แถบจึงเป็น overflow-x-auto
|
||
เสมอ ไม่ต้องสลับ overflow ตอนเปิดเมนู (สลับแล้ว scroll position รีเซ็ต เมนูเปิด
|
||
นอกจอ และทั้ง app เอียงเลื่อนแนวนอนได้) */}
|
||
<div className="relative">
|
||
<div
|
||
ref={toolbarRowRef}
|
||
onScroll={syncToolbarEnd}
|
||
className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2 max-lg:flex-nowrap max-lg:overflow-x-auto"
|
||
>
|
||
|
||
{/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง
|
||
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
|
||
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
|
||
UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14:
|
||
จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
|
||
<div className="flex max-lg:shrink-0 flex-wrap gap-0.5 rounded-lg border border-slate-300 max-lg:flex-nowrap">
|
||
{TOOLS.map(({ key, label, Icon }, i) => {
|
||
const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
|
||
const activeCls = `${
|
||
tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100"
|
||
} ${FOCUS_RING_CLS}`;
|
||
|
||
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
|
||
if (key === "highlight") {
|
||
const CurrentIcon =
|
||
HIGHLIGHT_STYLE_BTNS.find((s) => s.key === highlightStyle)?.Icon ?? Icon;
|
||
return (
|
||
<div key={key} ref={highlightMenuRef} className="relative">
|
||
<button
|
||
type="button"
|
||
title={label}
|
||
aria-label={label}
|
||
aria-haspopup="menu"
|
||
aria-expanded={highlightMenuOpen}
|
||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||
onClick={(e) => {
|
||
openToolMenu(e.currentTarget);
|
||
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
|
||
else {
|
||
switchTool("highlight");
|
||
setHighlightMenuOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<CurrentIcon className="h-4 w-4" />
|
||
<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-slate-200 bg-white p-1.5 shadow-lg"
|
||
style={{
|
||
position: "fixed",
|
||
left: menuPos.left,
|
||
top: menuPos.top,
|
||
// เหนือ aside sidebar/composer (z-50) และ backdrop (z-[45])
|
||
zIndex: 60,
|
||
// จอเตี้ย (มือถือแนวนอน) เมนูยาวเกินพื้นที่ใต้แถบ → เลื่อนภายในได้
|
||
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
{HIGHLIGHT_STYLE_BTNS.map(({ key: sKey, label: sLabel, Icon: SIcon }) => (
|
||
<button
|
||
key={sKey}
|
||
type="button"
|
||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
||
highlightStyle === sKey
|
||
? "bg-slate-100 text-slate-900"
|
||
: "text-slate-600 hover:bg-slate-100"
|
||
}`}
|
||
onClick={() => setHighlightStyle(sKey)}
|
||
>
|
||
<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-slate-100" />
|
||
<PanelRow label="สี">
|
||
<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 CurrentPenIcon = DRAW_PEN_ICONS[drawPen];
|
||
return (
|
||
<div key={key} ref={drawMenuRef} className="relative">
|
||
<button
|
||
type="button"
|
||
title={label}
|
||
aria-label={label}
|
||
aria-haspopup="menu"
|
||
aria-expanded={drawMenuOpen}
|
||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||
onClick={(e) => {
|
||
openToolMenu(e.currentTarget);
|
||
if (tool === "draw") setDrawMenuOpen((o) => !o);
|
||
else {
|
||
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||
setDrawMenuOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<CurrentPenIcon className="h-4 w-4" />
|
||
<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-slate-200 bg-white p-1.5 shadow-lg"
|
||
style={{
|
||
position: "fixed",
|
||
left: menuPos.left,
|
||
top: menuPos.top,
|
||
zIndex: 60,
|
||
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
{(Object.keys(DRAW_PENS) as DrawPen[]).map((p) => {
|
||
const PenIcon = DRAW_PEN_ICONS[p];
|
||
return (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
||
drawPen === p
|
||
? "bg-slate-100 text-slate-900"
|
||
: "text-slate-600 hover:bg-slate-100"
|
||
}`}
|
||
onClick={() => {
|
||
setDrawPen(p);
|
||
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น (เมนูเปิดค้างให้ปรับต่อ)
|
||
setDrawColor(DRAW_PENS[p].color);
|
||
setDrawWidthPct(DRAW_PENS[p].strokeWidthPct);
|
||
setDrawOpacityPct(DRAW_PENS[p].opacity * 100);
|
||
}}
|
||
>
|
||
<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-slate-100" />
|
||
<PanelRow label="สี">
|
||
<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>
|
||
<p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400">
|
||
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
|
||
</p>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// F5/B1: ปุ่มรูปทรง = dropdown เลือกชนิดรูปทรง
|
||
if (key === "shape") {
|
||
return (
|
||
<div key={key} ref={shapeMenuRef} className="relative">
|
||
<button
|
||
type="button"
|
||
title={label}
|
||
aria-label={label}
|
||
aria-haspopup="menu"
|
||
aria-expanded={shapeMenuOpen}
|
||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||
onClick={(e) => {
|
||
openToolMenu(e.currentTarget);
|
||
if (tool === "shape") setShapeMenuOpen((o) => !o);
|
||
else {
|
||
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||
setShapeMenuOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<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-slate-200 bg-white p-1.5 shadow-lg"
|
||
style={{
|
||
position: "fixed",
|
||
left: menuPos.left,
|
||
top: menuPos.top,
|
||
zIndex: 60,
|
||
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
{SHAPE_KIND_BTNS.map(({ key: kind, label: kindLabel, Icon: KindIcon }) => (
|
||
<button
|
||
key={kind}
|
||
type="button"
|
||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
||
shapeKind === kind
|
||
? "bg-slate-100 text-slate-900"
|
||
: "text-slate-600 hover:bg-slate-100"
|
||
}`}
|
||
onClick={() => setShapeKind(kind)}
|
||
>
|
||
<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-slate-100" />
|
||
<PanelRow label="สี">
|
||
<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
|
||
if (key === "stamp") {
|
||
return (
|
||
<div key={key} ref={stampMenuRef} className="relative">
|
||
<button
|
||
type="button"
|
||
title={label}
|
||
aria-label={label}
|
||
aria-haspopup="menu"
|
||
aria-expanded={stampMenuOpen}
|
||
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
|
||
onClick={(e) => {
|
||
openToolMenu(e.currentTarget);
|
||
if (tool === "stamp") setStampMenuOpen((o) => !o);
|
||
else {
|
||
switchTool("stamp");
|
||
setStampMenuOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<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-slate-200 bg-white p-1.5 shadow-lg"
|
||
style={{
|
||
position: "fixed",
|
||
left: menuPos.left,
|
||
top: menuPos.top,
|
||
zIndex: 60,
|
||
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
{STAMP_PRESETS.map((preset) => (
|
||
<button
|
||
key={preset}
|
||
type="button"
|
||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
|
||
stampPreset === preset
|
||
? "bg-red-50 text-red-700"
|
||
: "text-slate-600 hover:bg-slate-100"
|
||
}`}
|
||
onClick={() => setStampPreset(preset)}
|
||
>
|
||
<span className="flex-1">{preset}</span>
|
||
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||
</button>
|
||
))}
|
||
<div className="my-1.5 border-t border-slate-100" />
|
||
<PanelRow label="สี">
|
||
<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"
|
||
title={label}
|
||
aria-label={label}
|
||
aria-haspopup="menu"
|
||
aria-expanded={textboxMenuOpen}
|
||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||
onClick={(e) => {
|
||
openToolMenu(e.currentTarget);
|
||
if (tool === "textbox") setTextboxMenuOpen((o) => !o);
|
||
else {
|
||
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||
setTextboxMenuOpen(true);
|
||
}
|
||
}}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<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-slate-200 bg-white p-1.5 shadow-lg"
|
||
style={{
|
||
position: "fixed",
|
||
left: menuPos.left,
|
||
top: menuPos.top,
|
||
zIndex: 60,
|
||
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
<PanelRow label="สี">
|
||
<ColorSwatches value={textboxColor} onChange={setTextboxColor} />
|
||
</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 ${
|
||
textboxBold
|
||
? "border-primary bg-primary text-white"
|
||
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
||
}`}
|
||
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-slate-300">
|
||
{(
|
||
[
|
||
["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 ${
|
||
textboxAlign === a
|
||
? "bg-primary text-white"
|
||
: "text-slate-500 hover:bg-slate-100"
|
||
}`}
|
||
onClick={() => setTextboxAlign(a)}
|
||
>
|
||
<AlignIcon className="h-3.5 w-3.5" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</PanelRow>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
title={label}
|
||
aria-label={label}
|
||
className={`px-2 py-2 transition ${corner} ${activeCls}`}
|
||
onClick={() => switchTool(key)}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* F8: hint ความปลอดภัยของ redaction (PLAN-FEAT.md §5 — บนจอบังเท่านั้น) */}
|
||
{tool === "redact" && (
|
||
<p className="hidden max-w-xs text-[11px] leading-tight text-amber-600 lg:block">
|
||
เป็นการปกปิดข้อมูลบนหน้าจอชั่วคราวเท่านั้น (ข้อความดั้งเดิมยังสามารถคัดลอกได้)
|
||
กรุณากด 'Export PDF' เพื่อปกปิดข้อมูลอย่างถาวรก่อนนำไปเผยแพร่
|
||
</p>
|
||
)}
|
||
|
||
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
|
||
M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง
|
||
เลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order
|
||
ลำดับ/หน้าตาเดิมทุกประการ */}
|
||
<div className="flex max-lg:order-first max-lg:shrink-0 items-center rounded-lg border border-slate-300">
|
||
<button
|
||
type="button"
|
||
title="Undo (Ctrl+Z)"
|
||
aria-label="ย้อนการกระทำล่าสุด"
|
||
disabled={!canUndo}
|
||
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
|
||
onClick={() => void undoHistory()}
|
||
>
|
||
<Undo2 className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="Redo (Ctrl+Shift+Z)"
|
||
aria-label="ทำซ้ำการกระทำ"
|
||
disabled={!canRedo}
|
||
className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
|
||
onClick={() => void redoHistory()}
|
||
>
|
||
<Redo2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
|
||
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
|
||
<div ref={bookmarkWrapRef} className="relative ml-auto 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={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||
>
|
||
<BookmarkPlus className="h-4 w-4" />
|
||
</button>
|
||
{bookmarkComposer &&
|
||
bookmarkPos &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<div
|
||
data-tool-menu=""
|
||
className="w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
|
||
style={{
|
||
position: "fixed",
|
||
left: bookmarkPos.left,
|
||
top: bookmarkPos.top,
|
||
zIndex: 60,
|
||
maxHeight: Math.max(160, window.innerHeight - bookmarkPos.top - 8),
|
||
overflowY: "auto",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<p className="text-[11px] font-semibold text-slate-700">
|
||
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
|
||
</p>
|
||
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
|
||
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
|
||
<p className="mt-0.5 text-[11px] text-slate-400">
|
||
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
|
||
{bookmarkComposer.yOffset <= 25
|
||
? "ส่วนบน"
|
||
: bookmarkComposer.yOffset <= 75
|
||
? "ราวกลาง"
|
||
: "ส่วนล่าง"}{" "}
|
||
ของหน้า
|
||
</p>
|
||
<input
|
||
autoFocus
|
||
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
|
||
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-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||
/>
|
||
<textarea
|
||
rows={2}
|
||
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-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||
/>
|
||
<p className="mt-1.5 text-[11px] text-slate-400">วางที่</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-slate-300 bg-white px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||
>
|
||
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
||
{optimisticBookmarks
|
||
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
|
||
.filter((b) => bookmarkDepthOf(b) < 3)
|
||
.map((h) => (
|
||
<option key={h.id} value={h.id}>
|
||
ใต้ "{h.title}"
|
||
</option>
|
||
))}
|
||
</Select>
|
||
{bookmarkError && (
|
||
<p className="mt-1.5 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
|
||
{bookmarkError}
|
||
</p>
|
||
)}
|
||
<div className="mt-2.5 flex justify-end gap-1.5 text-[11px]">
|
||
<button
|
||
type="button"
|
||
className="rounded px-2.5 py-1 text-slate-500 hover:bg-slate-100"
|
||
onClick={() => setBookmarkComposer(null)}
|
||
>
|
||
ยกเลิก
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary/85"
|
||
onClick={() => void handleSaveBookmark()}
|
||
>
|
||
บันทึก
|
||
</button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
|
||
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
|
||
<button
|
||
type="button"
|
||
title="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
|
||
aria-label="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
|
||
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||
onClick={() =>
|
||
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
|
||
}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}
|
||
{!toolbarAtEnd && (
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-r from-transparent to-white lg:hidden" />
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<main
|
||
ref={mainRef}
|
||
className="space-y-4 px-4 py-4"
|
||
style={{
|
||
cursor:
|
||
tool === "pan"
|
||
? grabbing
|
||
? "grabbing"
|
||
: "grab"
|
||
: tool === "erase"
|
||
? ERASER_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;
|
||
window.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 ไฟล์สแกน — ทุกหน้าที่ render แล้วไม่มี span เลย (virtualization เห็นเฉพาะ
|
||
หน้าใกล้ viewport — ไฟล์ผสมหน้าสแกน/หน้า text จึงพูดแบบระดับ "หน้าที่เปิดดู" เท่านั้น)
|
||
โชว์เฉพาะตอน tool highlight เพื่อไม่รบกวน tool อื่น — คดีศาลมี PDF สแกนเยอะ */}
|
||
{tool === "highlight" &&
|
||
Object.keys(pageSpanCount).length > 0 &&
|
||
Object.values(pageSpanCount).every((n) => n === 0) && (
|
||
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
|
||
</p>
|
||
)}
|
||
{pageSizes.length === 0 ? (
|
||
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
|
||
กำลังโหลดเอกสาร…
|
||
</div>
|
||
) : (
|
||
pageSizes.map((size, i) => {
|
||
const w = Math.ceil(size.width * scale);
|
||
const h = Math.ceil(size.height * scale);
|
||
const mounted = renderPages.has(i);
|
||
return (
|
||
<div
|
||
key={i}
|
||
data-page={i}
|
||
ref={(el) => {
|
||
pageWrapRefs.current[i] = el;
|
||
}}
|
||
className="relative mx-auto bg-white shadow-md"
|
||
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 && pdfjs && doc ? (
|
||
<PdfPage
|
||
pdfjs={pdfjs}
|
||
doc={doc}
|
||
pageNumber={i + 1}
|
||
width={w}
|
||
height={h}
|
||
tool={tool}
|
||
shapeKind={shapeKind}
|
||
drawStyle={drawStyle}
|
||
shapeStyle={shapeStyle}
|
||
textboxStyle={textboxStyle}
|
||
pageBaseWidth={basePageWidth}
|
||
liveMark={customSelPreview ?? liveMark}
|
||
liveMarkStyle={{
|
||
color: highlightColor,
|
||
style: highlightStyle,
|
||
...(highlightOpacityPct < 100
|
||
? { opacity: highlightOpacityPct / 100 }
|
||
: {}),
|
||
}}
|
||
selectPreview={selectPreview}
|
||
annotations={visibleAnnotations
|
||
.filter((a) => a.pageIndex === i)
|
||
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
|
||
.map((a) =>
|
||
a.id === selectedId && previewData
|
||
? { ...a, data: previewData }
|
||
: a,
|
||
)}
|
||
replies={visibleReplies}
|
||
openPinId={openPinId}
|
||
ownUserId={user?.userId ?? ""}
|
||
selectedId={selectedId}
|
||
editingCommentId={editingCommentId}
|
||
commentEditDraft={commentEditDraft}
|
||
onCommentEditDraft={setCommentEditDraft}
|
||
onStartCommentEdit={startCommentEdit}
|
||
textbox={{
|
||
composer: textboxComposer,
|
||
commitComposer: commitTextboxComposer,
|
||
cancelComposer: () => setTextboxComposer(null),
|
||
editingId: editingTextboxId,
|
||
startEdit: startTextboxEdit,
|
||
commitEdit: commitTextboxEdit,
|
||
}}
|
||
onSelectAnnotation={handleSelectAnnotation}
|
||
onPinToggle={handlePinToggle}
|
||
onAnnotationPreview={setPreviewData}
|
||
onAnnotationUpdate={(id, data) => {
|
||
void handleUpdateAnnotation(id, data);
|
||
}}
|
||
onSetResolved={(id, resolved) => {
|
||
void handleSetResolved(id, resolved);
|
||
}}
|
||
onAnnotationBookmark={handleAnnotationBookmark}
|
||
onCreateReply={(annotationId, message) => {
|
||
void 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);
|
||
if (!hovered || hovered.pageIndex !== i) return null;
|
||
const box = annotationBBox(hovered, { width: w, height: h });
|
||
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-slate-200 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-slate-700">
|
||
{annotationTypeLabel(hovered)} <span className="text-slate-300">·</span>{" "}
|
||
{hovered.userName}
|
||
</p>
|
||
{note && (
|
||
<p className="mt-0.5 whitespace-pre-wrap break-words text-slate-500">
|
||
{note}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
{composer && (
|
||
<div
|
||
className="comment-composer fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl"
|
||
style={{
|
||
left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)),
|
||
top: clamp(composer.y, 8, Math.max(8, window.innerHeight - 180)),
|
||
}}
|
||
>
|
||
<textarea
|
||
autoFocus
|
||
rows={3}
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
placeholder="พิมพ์ข้อความ comment"
|
||
className="w-full resize-none rounded border border-slate-300 p-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||
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 px-2 py-1 text-slate-500 hover:bg-slate-100"
|
||
onClick={() => setComposer(null)}
|
||
>
|
||
ยกเลิก
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!draft.trim()}
|
||
className="coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
|
||
onClick={() => void handleComposerSave()}
|
||
>
|
||
บันทึก
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|