eCourt-note/components/pdf/PDFViewer.tsx
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 eddcec28c7 Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

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

2918 lines
134 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,
Bold,
BookmarkPlus,
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,
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 { annotationBBox, hitTestPath, hitTestShape } 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 {
ColorSwatches,
HIGHLIGHT_STYLE_BTNS,
NumberCombo,
PanelRow,
} from "./ToolControls";
type Tool =
| "select"
| "pan"
| "highlight"
| "draw"
| "comment"
| "shape"
| "erase"
| "redact"
| "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 ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ยุบ whitespace และตัดยาวไม่เกิน 120 ตัวอักษร */
function normalizeBookmarkTitle(s: string): string {
const t = 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;
}
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 */
const DRAW_PEN_ICONS: Record<DrawPen, LucideIcon> = {
pencil: Pencil,
highlighter: Highlighter,
};
/** ชนิดรูปทรงย่อยของ 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,
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";
};
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
liveMark: { pageIndex: number; rects: Rect[] } | null;
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
/** ความกว้างหน้าแรกที่ 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,
),
)}
</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}
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,
}
: 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);
const [composer, setComposer] = useState<Composer | null>(null);
const [draft, setDraft] = useState("");
const [openPinId, setOpenPinId] = useState<string | null>(null);
// F1 แก้ไข/ย้าย/ปรับขนาด — รายการที่เลือก + data ชั่วคราวระหว่างลาก (render ที่ layer เดิม)
const [selectedId, setSelectedId] = useState<string | null>(null);
// N1: annotation ที่ mouse ชี้อยู่บนหน้า — แสดง tooltip ใครทำ + โน้ต (เปลี่ยนค่าเฉพาะเมื่อรายการเปลี่ยน)
const [hoveredId, setHoveredId] = useState<string | null>(null);
// N2: popup บันทึก bookmark (ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ) — null = ปิด
const [bookmarkComposer, setBookmarkComposer] = useState<{
pageIndex: number;
yOffset: number;
title: string;
note: string;
parentId: string | null;
} | null>(null);
/** error ล่าสุดของการบันทึก bookmark — แสดงใน popup ให้ผู้ใช้แก้ (เช่น เปลี่ยน "วางที่") แทนเงียบ */
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
const bookmarkPendingRef = useRef(false);
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [commentEditDraft, setCommentEditDraft] = useState("");
// F17/B2: กล่องข้อความ — composer ตอนสร้าง + โหมดแก้ไขข้อความของตัวเอง (double-click)
const [textboxComposer, setTextboxComposer] = useState<TextboxComposer | null>(null);
const [editingTextboxId, setEditingTextboxId] = useState<string | null>(null);
// F4 ซ่อน–แสดงรายคน — "all" | "mine" | <userId> กรองฝั่ง client ล้วน (A3)
const [userFilter, setUserFilter] = useState<string>("all");
// UX review 2026-09-09: สลับ sidebar — จอ < xl เป็น overlay, desktop กดซ่อนเพื่อดู PDF เต็มจอ
// (เริ่ม true เพื่อให้ SSR เรนเดอร์แบบเปิดเหมือน desktop เดิม กัน hydration mismatch)
const [sidebarOpen, setSidebarOpen] = useState(true);
const {
annotations,
bookmarks,
replies,
setAnnotations,
setBookmarks,
setReplies,
} = useAnnotationRealtime(file.id, initialAnnotations, initialBookmarks, initialReplies);
// F2: undo/redo ต่อ session ต่อผู้ใช้ฝั่ง client (D10 — ไม่เก็บใน DB)
const {
push: pushHistory,
undo: undoHistory,
redo: redoHistory,
canUndo,
canRedo,
} = useHistory();
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
const [optimisticAnnotations, applyAnnotation] = useOptimistic(
annotations,
(state, action: AnnotationAction) => {
if (action.kind === "add") {
return state.some((a) => a.id === action.row.id)
? state
: [...state, action.row];
}
if (action.kind === "update") {
// replace ตาม id — idempotent เหมือน SSE update event (PLAN-FEAT.md §2)
return state.map((a) => (a.id === action.row.id ? action.row : a));
}
return state.filter((a) => a.id !== action.id);
},
);
// N2: การสร้าง bookmark รอผลจาก action ตรง ๆ (ไม่ optimistic) — เหลือแค่ remove
const [optimisticBookmarks, applyBookmark] = useOptimistic(
bookmarks,
(state, action: BookmarkAction) =>
action.kind === "remove" ? state.filter((b) => b.id !== action.id) : state,
);
// F4: รายชื่อผู้เขียน (unique ตาม userId จาก annotations — ตัวเองอยู่ใน option "ของฉัน" แล้ว)
const users = useMemo(() => {
const seen = new Map<string, string>();
for (const a of optimisticAnnotations) {
if (!seen.has(a.userId) && a.userId !== user?.userId) seen.set(a.userId, a.userName);
}
return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
}, [optimisticAnnotations, user]);
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3)
const visibleAnnotations = useMemo(() => {
if (userFilter === "all") return optimisticAnnotations;
const target = userFilter === "mine" ? user?.userId : userFilter;
return optimisticAnnotations.filter((a) => a.userId === target);
}, [optimisticAnnotations, userFilter, user]);
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
// (O(points) ของเส้น DRAW) ครั้งเดียวต่อ list แทนคำนวณใหม่ทุก mousemove
const hoverBBoxPct = useMemo(() => {
const m = new Map<AnnotationDTO["id"], Rect>();
for (const a of visibleAnnotations) {
if (a.type === "COMMENT") continue; // pin มี popover ของตัวเอง ไม่เข้า hit-test
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }));
}
return m;
}, [visibleAnnotations]);
// F9: จำนวน reply ต่อ comment (สำหรับ sidebar)
const replyCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const r of replies) {
counts[r.annotationId] = (counts[r.annotationId] ?? 0) + 1;
}
return counts;
}, [replies]);
// F9: replies ของแม่ที่ยังแสดงอยู่ (ตาม filter ของ F4) — กรองครั้งเดียว ไม่ใช่ทุกหน้า
const visibleReplies = useMemo(
() =>
replies.filter((r) => visibleAnnotations.some((a) => a.id === r.annotationId)),
[replies, visibleAnnotations],
);
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
useEffect(() => {
setUser(getLocalUser());
}, []);
// UX review 2026-09-09: จอแคบกว่า xl (< 1280px) sidebar เป็น overlay — ปิดอัตโนมัติตอน mount
useEffect(() => {
if (!window.matchMedia("(min-width: 1280px)").matches) setSidebarOpen(false);
}, []);
// โหลดเอกสาร: 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";
const doc = await pdfjs.getDocument({ url: file.pdfUrl }).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.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]);
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 });
useEffect(() => {
// เก็บเวอร์ชันล่าสุดของ handler ตลอด (currentPage เปลี่ยนตอน scroll — กัน stale closure)
shortcutHandlersRef.current = { switchTool, openBookmarkComposer, undoHistory, redoHistory };
});
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;
switch (e.key.toLowerCase()) {
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("");
/** ความลึกของ 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);
setBookmarkComposer({ ...pos, title: lastSelectionTextRef.current, 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";
setBookmarkError(null);
setBookmarkComposer({
pageIndex: a.pageIndex,
yOffset: clamp(yOffsetPct, 0, 100),
title: meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "",
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 ระหว่างลาก */
function readTextSelection(pageIndex: number) {
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;
const pageRect = pageEl.getBoundingClientRect();
const rectsPct = clientRects.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 ต่อบรรทัดก่อนบันทึก
// ตัดไม่เกิน 5,000 ตัวอักษรตรงค่า limit ของ validator (FIX-4) — คลุมทั้งหน้า
// ของเอกสารหนาแน่นแล้ว action ปฏิเสธ ทำให้ไฮไลต์หายเงียบ (text ใช้แค่ค้นหา/preview)
text: selection.toString().slice(0, 5000),
first: clientRects[0],
};
}
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
useEffect(() => {
if (tool !== "highlight") {
setLiveMark(null);
return;
}
const onSelectionChange = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setLiveMark(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);
return;
}
const pi = Number(pageEl.dataset.page);
const sel = readTextSelection(pi);
setLiveMark((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);
return () => document.removeEventListener("selectionchange", onSelectionChange);
}, [tool]);
function handleMouseUpOnPage(pageIndex: number) {
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
lastSelectionTextRef.current = normalizeBookmarkTitle(
readTextSelection(pageIndex)?.text ?? "",
);
// มาร์กข้อความสร้างจาก tool highlight อย่างเดียว (มี live preview ระหว่างลาก)
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
if (tool !== "highlight") return;
const sel = readTextSelection(pageIndex);
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,
) {
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
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;
}
}
}
// คลิกพื้นที่ว่างของหน้า (tool select/draw) → เลิกเลือก annotation
if (selectedId !== null) {
setSelectedId(null);
setPreviewData(null);
}
}
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,
...(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,
};
// ความกว้างหน้าแรกที่ 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 highlight — ซ่อนสี selection น้ำเงินของเบราว์เซอร์ ให้เห็นแต่ preview มาร์กของเรา */}
{tool === "highlight" && (
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
)}
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
z-[45] อยู่เหนือ header sticky z-40 แต่ใต้ aside z-50 / composer z-50 */}
{sidebarOpen && (
<div
className="fixed inset-0 z-[45] bg-slate-900/30 xl:hidden"
aria-hidden
onClick={() => setSidebarOpen(false)}
/>
)}
{/* xl+ = sticky แยกคอลัมน์เหมือนเดิม / < xl = overlay เลื่อนเข้า-ออก
(ปิดบน desktop ใช้ xl:hidden ออกจาก flow → main เต็มจอ) */}
<aside
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-xl:fixed max-xl:left-0 max-xl:z-50 max-xl:transition-transform max-xl:duration-200 ${
sidebarOpen
? "max-xl:translate-x-0"
: "max-xl:pointer-events-none max-xl:-translate-x-full xl: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}
max={30}
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}
max={30}
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)}
>
<Bold className="h-3.5 w-3.5" />
</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
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
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)}
onMouseMove={(e) => 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 }
: {}),
}}
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>
);
}