"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 Select from "../Select"; 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( '', )}") 2 17, auto`; /** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS */ const DRAW_PEN_ICONS: Record = { 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 (
e.stopPropagation()}> {replies.length > 0 && (
{replies.map((r) => (

{r.userName} {new Date(r.createdAt).toLocaleString("th-TH", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short", })}

{r.message}

{r.userId === ownUserId && ( )}
))}
)}
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" />
); } // ─── หน้าเดียว: 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(null); const textLayerRef = useRef(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 (
); } if (hStyle === "strikethrough") { return (
); } if (hStyle === "squiggle") { const w = Math.max(px.width, 4); return ( ); } return (
); } return ( <> {/* z-1: canvas — pointer-events: none เสมอ */} {/* z-2: highlight-layer — pointer-events: none เสมอ, อยู่หน้า textLayer ใน DOM order แสดงผลตาม style: ทึบ / เส้นใต้ (ขอบล่าง) / ขีดฆ่า (กลางบรรทัด) / ขยุกขยิก (svg) */}
{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, ), )}
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
{/* z-3: InkLayer (SVG freehand + รูปทรง F5) */} {/* z-4: TEXTBOX overlay (F17/B2) — ก่อน comment pins ใน DOM */} {/* z-4: comment pins — คอนเทนเนอร์โปร่งใส ปุ่ม pin รับ event เอง */}
{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 (
{open && (
e.stopPropagation()} >

{a.userName}

{/* F13/A6: ปุ่มแก้ไขข้อความ — เจ้าของเท่านั้น */} {a.userId === ownUserId && editingCommentId !== a.id && ( )}
{editingCommentId === a.id ? (