"use client"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, startTransition, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { AlignCenter, AlignLeft, AlignRight, ArrowRight, BookmarkPlus, Brush, Check, ChevronDown, ChevronLeft, ChevronRight, Circle, ClipboardList, Download, Eraser, EyeOff, Hand, Highlighter, MessageSquare, MessageSquarePlus, Minus, MousePointer2, PanelLeft, Pencil, Redo2, Shapes, Square, Trash2, Type, Stamp, Undo2, X, ZoomIn, ZoomOut, type LucideIcon, } from "lucide-react"; import { createAnnotation, createBookmark, createReply, deleteAnnotation, deleteBookmark, deleteReply, setAnnotationResolved, updateAnnotation, updateBookmark, } from "@/app/actions/annotations"; import { useAnnotationRealtime, } from "@/hooks/useAnnotationRealtime"; import { useHistory } from "@/hooks/useHistory"; import { useCustomSelection, type CustomSelectionCommit, } from "@/hooks/useCustomSelection"; import type { AnnotationData, AnnotationDTO, AnnotationType, BookmarkDTO, CommentData, DrawData, DrawPen, HighlightData, HighlightStyle, TextboxSegment, RedactionData, ReplyDTO, ShapeData, ShapeKind, StampData, TextboxData, } from "@/db/schema"; import { annotationNote, annotationPreviewText, annotationTypeLabel, DRAW_PENS, effectiveDrawOpacity, sanitizePdfText, STAMP_COLOR, STAMP_DEFAULT_SCALE_PCT, STAMP_PRESETS, TEXTBOX_DEFAULT_COLOR, TEXTBOX_DEFAULT_FONT_SIZE_PCT, } from "@/utils/annotation-display"; import { clamp, getPointerPercent, mergeRectsByLine, rectToPercent, rectToPx, rectsInSweptPath, toPx, type ClientPt, type Point, type Rect, type Size, } from "@/utils/coordinates"; import type { OacUser } from "@/utils/user"; import { MAX_BOOKMARK_NOTE_CHARS, MAX_BOOKMARK_TITLE_CHARS, } from "@/utils/bookmark-limits"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; import { withBasePath } from "@/utils/base-path"; import { formatThaiDate } from "@/utils/date-format"; import { decodeTiffPages } from "@/utils/tiff-client"; import { TEXTBOX_DEFAULT_WIDTH_PCT, annotationBBox, hitTestPath, hitTestShape, textboxMaxWidthPct, } from "@/utils/geometry"; import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer"; import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer"; import AnnotationSelectionLayer from "./AnnotationSelectionLayer"; import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar"; import Select from "../Select"; import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow, } from "./ToolControls"; type Tool = | "select" | "pan" | "highlight" | "draw" | "comment" | "shape" | "erase" | "redact" | "textbox" | "stamp"; /** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) — * ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด, * select/comment/textbox/stamp = selection สีฟ้าสะอาด (กรองแนวเส้นทางลาก) */ const TEXT_SELECTION_TOOLS = new Set([ "select", "highlight", "comment", "textbox", "stamp", ]); type Composer = { pageIndex: number; position: Point; // % ของหน้า x: number; // ตำแหน่งจอสำหรับวางกล่อง (fixed) y: number; }; /** F17/B2: กล่องข้อความที่กำลังสร้างใหม่ — textarea วางบนหน้า ณ ตำแหน่งที่คลิก */ type TextboxComposer = { pageIndex: number; position: Point; // % ของหน้า }; type AnnotationAction = | { kind: "add"; row: AnnotationDTO } | { kind: "update"; row: AnnotationDTO } | { kind: "remove"; id: string }; /** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2) * ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน) * ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง */ type TextboxUI = { composer: TextboxComposer | null; commitComposer: (text: string, segments?: TextboxSegment[]) => void; cancelComposer: () => void; editingId: string | null; startEdit: (id: string | null) => void; commitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void; }; /** N2: เหลือแค่ remove (create รอผลจาก action ตรง ๆ) */ type BookmarkAction = { kind: "remove"; id: string }; const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"]; /** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย * (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace * และตัดยาวไม่เกิน MAX_BOOKMARK_TITLE_CHARS ตัวอักษร (รวม … ที่ต่อท้าย — * เดิม slice 120 แล้วต่อ … กลายเป็น 121 ตัว โดน limit ของ server ตีกลับตอนบันทึก) */ function normalizeBookmarkTitle(s: string): string { const t = sanitizePdfText(s).replace(/\s+/g, " ").trim(); return t.length > MAX_BOOKMARK_TITLE_CHARS ? `${t.slice(0, MAX_BOOKMARK_TITLE_CHARS - 1)}…` : t; } /** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */ function viewerHeaderHeight(): number { return document.querySelector("header")?.offsetHeight ?? 0; } /** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) — * ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */ const TOOL_MENU_W = 240; const BOOKMARK_MENU_W = 288; /** ตำแหน่ง fixed ของเมนูที่เปิดจากปุ่มนี้ — ตรงใต้ปุ่ม + clamp ไม่ให้ล้นขอบจอ * (เมนู render ผ่าน createPortal ที่ document.body ออกจาก scroll container ของแถบ tool) */ function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number } { const vw = window.innerWidth; return { left: clamp(rect.left, 8, Math.max(8, vw - menuW - 8)), top: rect.bottom + 4, }; } /** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header * (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */ const SCROLL_BELOW_HEADER_GAP = 40; function scrollToBelowHeader(absoluteTop: number): void { window.scrollTo({ top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP), // L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", }); } /** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */ function squigglePath(w: number): string { let d = "M0 2.5"; let up = true; for (let x = 0; x + 4 <= Math.max(w, 4); x += 4) { d += up ? " q2 -2.5 4 0" : " q2 2.5 4 0"; up = !up; } return d; } /** วรรณยุกต์/สระบน-ล่างไทย (combining mark) — ตามหลังพยัญชนะฐานเสมอ แต่เดี่ยว ๆ * rect อาจกว้าง 0 หรือลอยเหนือบรรทัด จึงต้องให้ตามสถานะของตัวฐานก่อนหน้า */ function isThaiCombiningMark(c: string): boolean { const v = c.charCodeAt(0); return v === 0x0e31 || (v >= 0x0e34 && v <= 0x0e3a) || (v >= 0x0e47 && v <= 0x0e4e); } /** ข้อความเฉพาะส่วนที่อยู่ใน rects ที่กรองแล้ว — DOM range ของ textLayer ไทยครอบ * ข้อความเกินบริเวณที่ลากจริง selection.toString() ดิบเลยได้คำเกินมา (rects ถูกกรอง * ด้วย rectsInSweptPath แต่ข้อความยังไม่) จึงเดินทีละตัวอักษรใน text nodes ของ range * เช็คว่า rect ของตัวอักษรนั้นตกใน rect ที่ render บนจอไหม — ข้อความตรงกับที่เห็น * จากนั้นเรียงตามตำแหน่ง rect (บน→ล่าง, ซ้าย→ขวา) เพราะ DOM order ของ span ใน PDF * ไทยบางไฟล์ไม่ตรงลำดับบนจอ แล้วปิดท้ายด้วย sanitizePdfText ตัด control char ขยะ * แพงกว่า toString() มาก ใช้เฉพาะตอน mouseup/copy เท่านั้น (live preview เรียกถี่เกิน) */ const TEXT_PROBE_MAX_CHARS = 3000; function selectionTextWithin(kept: DOMRect[], range: Range): string { const raw = range.toString(); // เลือกยาวเกินขีดนี้ = คุ้มกว่าใช้ข้อความดิบ (กันหน่วงตอนปล่อยเมาส์) if (raw.length > TEXT_PROBE_MAX_CHARS) return sanitizePdfText(raw.slice(0, 5000)); const ancestor = range.commonAncestorContainer; const root = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode : ancestor; if (!root) return sanitizePdfText(raw.slice(0, 5000)); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const probe = document.createRange(); const eps = 1; // ทนพลาดจากการปัดทศนิยมของ client rects // ตัวอักษรที่เลือก: rect ที่ hit (ใช้เป็นกลุ่มบรรทัด) + ตำแหน่ง x + ลำดับ DOM // (สำรองตอนจัดเรียง — combining mark ที่ไม่มี rect เอา x ของตัวฐานก่อนหน้า) type Entry = { ch: string; rect: number; left: number; dom: number }; const entries: Entry[] = []; let dom = 0; let prevRect = -1; let prevLeft = 0; for (let n = walker.nextNode(); n; n = walker.nextNode()) { const node = n as Text; if (!range.intersectsNode(node)) continue; const s = node === range.startContainer ? range.startOffset : 0; const e = node === range.endContainer ? range.endOffset : node.data.length; for (let i = s; i < e && entries.length < 6000; i++) { const ch = node.data[i]; probe.setStart(node, i); probe.setEnd(node, i + 1); const r = probe.getClientRects()[0]; let hit = -1; if (r !== undefined) { hit = kept.findIndex( (k) => r.left < k.right + eps && r.right > k.left - eps && r.top < k.bottom + eps && r.bottom > k.top - eps, ); } // combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0); if (hit < 0 && sticky) hit = prevRect; if (hit < 0) { prevRect = -1; continue; } entries.push({ ch, rect: hit, left: hit === prevRect && sticky ? prevLeft : r ? r.left : 0, dom: dom++, }); prevRect = hit; prevLeft = entries[entries.length - 1].left; } } if (entries.length === 0) return sanitizePdfText(raw.slice(0, 5000)); // เรียงกลุ่ม rect ตามตำแหน่งบนจอ: คนละบรรทัด (กลางแกน y ห่างเกินความสูงบรรทัด) // เรียงบน→ล่าง, บรรทัดเดียวกันเรียงซ้าย→ขวา — DOM order ใช้เป็น tie-break เท่านั้น const midY = (r: DOMRect) => r.top + r.height / 2; const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => { const ra = kept[a]; const rb = kept[b]; if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) { return midY(ra) - midY(rb); } return ra.left - rb.left; }); let out = ""; let prev = -1; for (const ri of rectOrder) { if (prev >= 0) { const a = kept[prev]; const b = kept[ri]; const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height); if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString() } out += entries .filter((en) => en.rect === ri) .sort((a, b) => a.left - b.left || a.dom - b.dom) .map((en) => en.ch) .join(""); prev = ri; } // กรองแล้วว่างเปล่า (เรขาคณิตผิดปกติ) = ใช้ข้อความดิบดีกว่าไม่มีเลย return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000)); } const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [ { key: "select", label: "เลือกข้อความ", Icon: MousePointer2 }, { key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand }, { key: "highlight", label: "มาร์กข้อความ (H)", Icon: Highlighter }, { key: "draw", label: "วาดเส้น (D)", Icon: Pencil }, { key: "comment", label: "วาง comment (C)", Icon: MessageSquarePlus }, { key: "shape", label: "รูปทรง", Icon: Shapes }, { key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser }, { key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff }, { key: "textbox", label: "กล่องข้อความ", Icon: Type }, { key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp }, ]; /** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible * ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน * ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง * (z-10 ให้ ring ลอยเหนือปุ่มข้าง ๆ — flex item ใช้ z-index ได้) */ const FOCUS_RING_CLS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10"; /** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์) * ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */ const AUTO_SELECT_TYPES = new Set([ "DRAW", "SHAPE", "TEXTBOX", "STAMP", "HIGHLIGHT", "REDACTION", ]); /** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน * แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */ const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( '', )}") 2 17, auto`; /** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS * (ปากกาไฮไลต์ใช้ Brush — ไม่ใช้ Highlighter เพื่อไม่ให้ปุ่ม tool วาดเส้นหน้าตาเหมือนปุ่มมาร์กข้อความ) */ const DRAW_PEN_ICONS: Record = { pencil: Pencil, highlighter: Brush, }; /** ชนิดรูปทรงย่อยของ tool shape (F5/B1) — ปุ่มแสดงเมื่อ tool ใช้งาน */ const SHAPE_KIND_BTNS: { key: ShapeKind; label: string; Icon: LucideIcon }[] = [ { key: "rect", label: "สี่เหลี่ยม", Icon: Square }, { key: "ellipse", label: "วงรี", Icon: Circle }, { key: "line", label: "เส้นตรง", Icon: Minus }, { key: "arrow", label: "ลูกศร", Icon: ArrowRight }, ]; // AnnotationDTO เป็น flat type (type/data แยกกัน) → ใช้ type predicate แคบ scope function isHighlight(a: AnnotationDTO): a is AnnotationDTO & { type: "HIGHLIGHT"; data: HighlightData } { return a.type === "HIGHLIGHT"; } function isDraw(a: AnnotationDTO): a is AnnotationDTO & { type: "DRAW"; data: DrawData } { return a.type === "DRAW"; } function isComment(a: AnnotationDTO): a is AnnotationDTO & { type: "COMMENT"; data: CommentData } { return a.type === "COMMENT"; } function isShape(a: AnnotationDTO): a is AnnotationDTO & { type: "SHAPE"; data: ShapeData } { return a.type === "SHAPE"; } function isRedaction(a: AnnotationDTO): a is AnnotationDTO & { type: "REDACTION"; data: RedactionData } { return a.type === "REDACTION"; } function isTextbox(a: AnnotationDTO): a is AnnotationDTO & { type: "TEXTBOX"; data: TextboxData } { return a.type === "TEXTBOX"; } function isStamp(a: AnnotationDTO): a is AnnotationDTO & { type: "STAMP"; data: StampData } { return a.type === "STAMP"; } /** ตรวจว่าจุดคลิก (% ของหน้า) ชน annotation นี้หรือไม่ — hit-test แม่นตามชนิด * (DRAW/SHAPE ใช้ตัวตรวจเดียวกับ eraser, HIGHLIGHT ดู rect ต่อบรรทัด, * ที่เหลือดูจาก bbox + padding เผื่อรายการเล็ก) */ function hitTestAnnotation(a: AnnotationDTO, p: Point, size: Size): boolean { if (a.type === "HIGHLIGHT") { return (a.data as HighlightData).rects.some( (r) => p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height, ); } if (a.type === "DRAW") { const d = a.data as DrawData; const threshold = toPx(d.strokeWidth, size.width) / 2 + 6; return hitTestPath(d.paths, p, threshold, size); } if (a.type === "SHAPE") { const d = a.data as ShapeData; const threshold = toPx(d.strokeWidth, size.width) / 2 + 6; return hitTestShape(d, p, threshold, size); } // ที่เหลือ (REDACTION/TEXTBOX/STAMP) — bbox ในหน่วย % เทียบกับ p (%) หน่วยเดียวกัน // (เดิมเทียบ bbox หน่วย px ตาม size จริงกับ p ที่เป็น % หน่วยไม่ตรงกัน จนคลิกเลือก // และ tooltip จับได้เฉพาะกรอบที่ชิดมุมซ้ายบนของหน้า) — padding 4px เดิมแปลงเป็น % ตามแกน const box = annotationBBox(a, { width: 100, height: 100 }); const padX = (4 / size.width) * 100; const padY = (4 / size.height) * 100; return ( p.x >= box.x - padX && p.x <= box.x + box.width + padX && p.y >= box.y - padY && p.y <= box.y + box.height + padY ); } /** กล่องเธรดคำตอบใน popover ของ comment pin (F9/A4) — state draft ของตัวเอง */ function ReplyBox({ annotationId, replies, ownUserId, onCreateReply, onDeleteReply, }: { annotationId: string; replies: ReplyDTO[]; ownUserId: string; onCreateReply: (annotationId: string, message: string) => Promise; onDeleteReply: (id: string) => void; }) { const [draft, setDraft] = useState(""); async function submit() { const msg = draft.trim(); if (!msg) return; // H1 (UX audit 2026-09-16): เคลียร์ draft เฉพาะตอนสำเร็จ — fail แล้วข้อความ // ที่พิมพ์เองต้องอยู่ต่อให้กดส่งใหม่ได้ (เดิมเคลียร์ก่อนเรียก action = หายทั้งข้อ) if (await onCreateReply(annotationId, msg)) setDraft(""); } return (
e.stopPropagation()}> {replies.length > 0 && (
{replies.map((r) => (

{r.userName} {formatThaiDate(r.createdAt, true)}

{r.message}

{r.userId === ownUserId && ( )}
))}
)}
setDraft(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก if (e.key === "Enter") void submit(); }} placeholder="ตอบ comment…" className="min-w-0 flex-1 rounded 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 ────── /** N3: เนื้อหาหนึ่งหน้า — pdf (pdfjs) หรือรูปภาพ (src = URL รูป; null = เฟรม TIFF decode * ไม่ได้ → placeholder เทา แต่หน้ายังมีขนาดจาก IFD header ให้ scroll/bookmark เสถียร) */ type PageContent = | { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy } | { kind: "image"; src: string | null }; /** N3: หน้ารูปภาพ normalize กว้าง 595 (เท่าหน้า A4 pt) — scale contract เดียวกับ export * (utils/image-to-pdf.ts) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า PDF */ const IMAGE_PAGE_WIDTH = 595; function PdfPage({ content, pageNumber, width, height, tool, shapeKind, drawStyle, shapeStyle, textboxStyle, /** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */ pageBaseWidth, liveMark, liveMarkStyle, selectPreview, annotations, replies, openPinId, ownUserId, selectedId, editingCommentId, commentEditDraft, onCommentEditDraft, onStartCommentEdit, textbox, onSelectAnnotation, onPinToggle, onAnnotationPreview, onAnnotationUpdate, onSetResolved, onAnnotationBookmark, onCreateReply, onDeleteReply, onStrokeEnd, onShapeEnd, onRedactionEnd, onErase, onTextLayerRendered, }: { content: PageContent; /** 1-based */ pageNumber: number; width: number; height: number; tool: Tool; shapeKind: ShapeKind; /** ค่าปากกาปัจจุบันของ tool draw — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่ */ drawStyle: { color: string; strokeWidthPct: number; opacity: number }; /** ค่าสี/ความหนาของ tool shape — ใช้ตอนสร้างรูปทรงใหม่ */ shapeStyle: { color: string; strokeWidthPct: number }; /** ค่าสี/ขนาดฟอนต์/หนา-ทึบ-จัดแนวของ tool textbox — ใช้ทั้ง composer และตอนบันทึก */ textboxStyle: { color: string; fontSizePct: number; bold: boolean; opacity: number; align: "left" | "center" | "right"; /** ความกว้างกล่อง default (สไตล์ Smallpdf — คลิกเดียวได้กล่อง ลากขอบขยายทีหลัง) */ widthPct: number; }; /** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */ liveMark: { pageIndex: number; rects: Rect[] } | null; liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number }; /** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark * แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */ selectPreview: { pageIndex: number; rects: Rect[] } | null; /** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */ pageBaseWidth: number; annotations: AnnotationDTO[]; replies: ReplyDTO[]; openPinId: string | null; ownUserId: string; selectedId: string | null; /** 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) => Promise; onDeleteReply: (id: string) => void; onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void; onShapeEnd: (data: ShapeData) => void; onRedactionEnd: (data: RedactionData) => void; onErase: (id: string) => void; /** M6: รายงานจำนวน span ของ textLayer หลัง render หน้าเสร็จ (0 = หน้าสแกนไม่มีข้อความ * — ใช้แสดง hint ว่าไฮไลต์ไม่ได้) identity ต้องคงที่ กัน effect render รันซ้ำ */ onTextLayerRendered?: (pageIndex: number, spanCount: number) => void; }) { const canvasRef = useRef(null); const textLayerRef = useRef(null); // N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null; const pdfDoc = content.kind === "pdf" ? content.doc : null; useEffect(() => { let cancelled = false; let renderTask: import("pdfjs-dist").RenderTask | null = null; if (!pdfjsLib || !pdfDoc) return; (async () => { const page = await pdfDoc.getPage(pageNumber); if (cancelled) return; const scale = width / page.getViewport({ scale: 1 }).width; const viewport = page.getViewport({ scale }); // Canvas Retina: backing store ×dpr + transform แต่ style คง css px const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const ctx = canvas.getContext("2d"); if (!ctx) return; renderTask = page.render({ canvasContext: ctx, viewport, transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined, }); await renderTask.promise.catch(() => undefined); // cancel ระหว่าง unmount ≠ error if (cancelled) return; // TextLayer — span ใช้ calc(var(--scale-factor)*…) → ต้องตั้งบน container const container = textLayerRef.current; if (!container) return; const textContent = await page.getTextContent(); if (cancelled) return; container.replaceChildren(); container.style.setProperty("--scale-factor", String(scale)); const textLayer = new pdfjsLib.TextLayer({ textContentSource: textContent, container, viewport, }); await textLayer.render(); // M6: รายงาน span count ของหน้านี้ (0 = ไฟล์สแกนไม่มี text layer — hint ไฮไลต์ไม่ได้) if (!cancelled) onTextLayerRendered?.(pageNumber - 1, container.querySelectorAll("span").length); })().catch((err) => { if (!cancelled) { console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err); } }); return () => { cancelled = true; renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md) }; // onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร // เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย // ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า }, [pdfjsLib, pdfDoc, pageNumber, width, height]); // N3: หน้ารูปไม่มี textLayer — รายงาน span count = 0 ให้ hint "ภาพสแกน" ของ tool // highlight ทำงาน (พฤติกรรมเดียวกับ PDF สแกน) — onTextLayerRendered ไม่อยู่ใน deps // เพราะเป็น useCallback [] เสถียร (เหมือน effect ด้านบน) useEffect(() => { if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0); }, [content.kind, pageNumber]); 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 (pdf) / (รูปภาพ N3) — pointer-events: none เสมอ (invariant #3) รูป: browser render ที่ devicePixelRatio เต็มเอง เทียบเท่า canvas×dpr ของ pdfjs */} {content.kind === "pdf" ? ( ) : content.src ? ( ) : (
ถอดรหัสหน้านี้ไม่ได้
)} {/* 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, ), )} {/* selection สีฟ้าของ tool เลือกข้อความ — กรองแนวเส้นทางลากแล้ว วาดเองแทน สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */} {selectPreview && selectPreview.pageIndex === pageNumber - 1 && selectPreview.rects.map((r, i) => renderMark( `selprev-${i}`, rectToPx(r, { width, height }), "#3b82f6", "highlight", 0.3, ), )}
{/* z-2: textLayer (pdfjs — pdf เท่านั้น, N3: หน้ารูปไม่มี) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 (conditional render ไม่ย้ายโหนด — invariant #3) */} {content.kind === "pdf" && (
)} {/* 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; // L3 (UX audit 2026-09-16): pin ชิดขวาหน้า — popover เปิดขวาของ pin จะยื่น // เกินกระดาษ 200px+ พลิกไปซ้ายของ pin แทน (pattern เดียวกับ tooltip N1) const flipLeft = x + 12 + 224 > width; return (
{open && (
e.stopPropagation()} >

{a.userName}

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