eCourt-note/components/pdf/PDFViewer.tsx

3289 lines
159 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import Link from "next/link";
import {
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
startTransition,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
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,
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 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,
toPx,
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;
}
/** เลื่อนหน้าต่างให้ตำแหน่ง 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;
}
/** กรอง clientRects ของ selection ให้เหลือเฉพาะแนวเส้นทางที่ผู้ใช้ลากเมาส์จริง —
* span ของ textLayer ในบาง PDF (เอกสารไทย) เรียงใน DOM ไม่ตรงลำดับบรรทัด และ
* anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node)
* จึงใช้จุด mousedown + ตำแหน่งเมาส์ล่าสุด (client coords) เป็น ground truth แทน
* กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ,
* บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง */
type ClientPt = { x: number; y: number };
function rectsInSweptPath(rects: DOMRect[], start: ClientPt, end: ClientPt): DOMRect[] {
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
let top: ClientPt;
let bottom: ClientPt;
if (Math.abs(end.y - start.y) < lineTol) {
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
} else {
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
}
return rects.filter((r) => {
const cy = r.y + r.height / 2;
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (!moved) return true;
const inTop = Math.abs(cy - top.y) <= lineTol;
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
if (inBottom) return crosses(-Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ
return true; // บรรทัดกลาง: ครอบทั้งบรรทัด
});
}
/** วรรณยุกต์/สระบน-ล่างไทย (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: "มาร์กข้อความ (highlight)", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น", Icon: Pencil },
{ key: "comment", label: "วาง comment", 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 },
];
/** 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,
}: {
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;
}) {
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();
})().catch((err) => {
if (!cancelled) {
console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err);
}
});
return () => {
cancelled = true;
renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md)
};
}, [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 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="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
onClick={() => onStartCommentEdit(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={!commentEditDraft.trim()}
className="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)}
/>
);
})()}
</>
);
}
// ─── 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);
// UX review 2026-09-09 ข้อ 2: เมนู w-60 ของปุ่ม tool ที่อยู่ครึ่งขวาของจอ (หรือ wrap ลงแถวล่าง)
// อาจล้นขอบขวา — วัดตำแหน่งปุ่มตอนคลิก แล้วเลือก anchor left-0/right-0 ให้เมนูอยู่ในจอ
// (anchor ตายตัวใช้ไม่ได้: ปุ่มเดียวกันอยู่ชิดขวาบนจอกว้าง แต่ชิดซ้ายบนจอแคบที่ wrap)
// ค่าเขียนทับใหม่ทุกครั้งที่เปิดเมนู จึงไม่ต้องเคลียร์ตอนปิด
const [menuFlip, setMenuFlip] = useState<Record<string, boolean>>({});
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());
}, []);
// โหลดเอกสาร: 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);
// 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) },
];
if (!menus.some((m) => m.open)) return;
const onMouseDown = (e: MouseEvent) => {
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();
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]);
// 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({
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],
);
// 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;
}
function openBookmarkComposer() {
const pos = currentViewPosition();
if (!pos) return;
setBookmarkError(null);
// ชื่อ 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);
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();
const centerY = window.scrollY + window.innerHeight / 2;
const pageTop = r.top + window.scrollY;
return {
pageIndex: currentPage,
yOffset: clamp(((centerY - 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 */
function readTextSelection(pageIndex: number, opts?: { withText?: boolean }) {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return null;
}
const 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: selection.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],
};
}
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
// tool ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
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]);
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;
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 } : {}),
});
}
// ─── 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;
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 (ซึ่งเก็บจากกึ่งกลางจอตอนสร้าง) กลับไปอยู่
// กึ่งกลางพื้นที่มองเห็นใต้ header — เดิมวางจุดนี้ไว้ที่ขอบบน viewport เลยเลื่อน
// เกินลงล่างครึ่งจอ (bookmark หน้าแรกจึงดูเหมือน "ไม่ขึ้นบนสุด")
const center = (viewerHeaderHeight() + window.innerHeight) / 2;
window.scrollTo({
top: Math.max(0, pageTop + (r.height * b.yOffset) / 100 - center),
behavior: "smooth",
});
}
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);
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
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
sidebarOpen
? "max-lg:translate-x-0"
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
}`}
>
<AnnotationSidebar
annotations={visibleAnnotations}
bookmarks={optimisticBookmarks}
fileId={file.id}
userId={user?.userId ?? ""}
userFilter={userFilter}
users={users}
onUserFilterChange={setUserFilter}
replyCounts={replyCounts}
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
onUpdateAnnotation={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
onGoToBookmark={goToBookmark}
onGoToAnnotation={goToAnnotation}
/>
</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-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => setSidebarOpen((o) => !o)}
>
<PanelLeft className="h-4 w-4" />
</button>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold">
{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-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
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-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
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-1.5 text-slate-500 hover:bg-slate-100"
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-1.5 text-slate-500 hover:bg-slate-100"
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-1.5 text-slate-500 hover:bg-slate-100"
>
<ClipboardList className="h-4 w-4" />
</Link>
</div>
</div>
<div className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2">
{/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
UX review 2026-09-09: ปุ่ม wrap ภายในกรอบได้บนจอแคบ */}
<div className="flex flex-wrap rounded-lg border border-slate-300">
{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";
// 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-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, highlight: r.left + 248 > window.innerWidth }));
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 && (
<div
aria-label="ตั้งค่าการมาร์กข้อความ"
className={`absolute ${menuFlip.highlight ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{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>
)}
</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-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, draw: r.left + 248 > window.innerWidth }));
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 && (
<div
aria-label="ตั้งค่าปากกา"
className={`absolute ${menuFlip.draw ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{(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>
)}
</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-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, shape: r.left + 248 > window.innerWidth }));
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 && (
<div
aria-label="ตั้งค่ารูปทรง"
className={`absolute ${menuFlip.shape ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{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>
)}
</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-1.5 transition ${corner} ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, stamp: r.left + 248 > window.innerWidth }));
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 && (
<div
aria-label="ตั้งค่าตรายาง"
className={`absolute ${menuFlip.stamp ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
{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>
)}
</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-1.5 transition ${activeCls}`}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuFlip((m) => ({ ...m, textbox: r.left + 248 > window.innerWidth }));
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 && (
<div
aria-label="ตั้งค่ากล่องข้อความ"
className={`absolute ${menuFlip.textbox ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
>
<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>
)}
</div>
);
}
return (
<button
key={key}
type="button"
title={label}
aria-label={label}
className={`px-2 py-1.5 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 */}
<div className="flex items-center rounded-lg border border-slate-300">
<button
type="button"
title="Undo (Ctrl+Z)"
aria-label="ย้อนการกระทำล่าสุด"
disabled={!canUndo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
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-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
onClick={() => void redoHistory()}
>
<Redo2 className="h-4 w-4" />
</button>
</div>
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
<div className="relative ml-auto">
<button
type="button"
title={`เพิ่ม bookmark (หน้า ${currentPage + 1})`}
disabled={!user}
onClick={openBookmarkComposer}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40"
>
<BookmarkPlus className="h-4 w-4" />
</button>
{bookmarkComposer && (
<div
className="absolute right-0 top-full z-50 mt-1.5 w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<p className="text-[11px] font-semibold text-slate-700">
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
</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}>
ใต้ &quot;{h.title}&quot;
</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>
)}
</div>
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
<button
type="button"
title="Export PDF พร้อม annotation"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() =>
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
}
>
<Download className="h-4 w-4" />
</button>
</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);
}}
>
{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={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);
}}
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="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="rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
onClick={() => setComposer(null)}
>
ยกเลิก
</button>
<button
type="button"
disabled={!draft.trim()}
className="rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
onClick={() => void handleComposerSave()}
>
บันทึก
</button>
</div>
</div>
)}
</div>
);
}