"use client"; import { useRef, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { ArrowRight, Bookmark, Check, ChevronDown, ChevronRight, Circle, Clock, Copy, EyeOff, Highlighter, MessageSquare, Minus, Pencil, Plus, Search, Square, Stamp, StickyNote, Trash2, Type, Underline, Strikethrough, X, } from "lucide-react"; import Select from "../Select"; import { listVersions, restoreVersion, searchAnnotations, } from "@/app/actions/annotations"; import type { AnnotationVersionDTO } from "@/db/schema"; import type { AnnotationData, AnnotationDTO, BookmarkDTO, CommentData, DrawData, HighlightData, RedactionData, ShapeData, } from "@/db/schema"; import { annotationNote, annotationPreviewText } from "@/utils/annotation-display"; function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) { const cls = "h-4 w-4 shrink-0 text-slate-400"; if (type === "HIGHLIGHT") { const style = (data as HighlightData).style ?? "highlight"; if (style === "underline") return ; if (style === "strikethrough") return ; return ; // highlight / squiggle } if (type === "DRAW") return (data as DrawData).pen === "highlighter" ? ( ) : ( ); if (type === "REDACTION") return ; if (type === "TEXTBOX") return ; if (type === "STAMP") return ; if (type === "SHAPE") { const shape = (data as ShapeData).shape; if (shape === "rect") return ; if (shape === "ellipse") return ; if (shape === "line") return ; return ; } return ; } /** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */ function withNote(data: Exclude, note: string): AnnotationData { return note ? { ...data, note } : { ...data, note: undefined }; } // ─── N2: helper โครงสร้างต้นไม้ bookmark (3 ระดับ) — คำนวณบน state ที่โหลดครบ ──── // (ปกติลึกสุด 3 — cap กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) /** ความลึกของ bookmark (root = 1) — เดินขึ้นตาม parentId (parent หาย = ถือเป็น root) */ function bookmarkDepth(b: BookmarkDTO, byId: Map): number { let depth = 1; let cur: BookmarkDTO | undefined = b; while (cur?.parentId && depth <= 100) { const parent = byId.get(cur.parentId); if (!parent) break; depth += 1; cur = parent; } return depth; } /** ความสูงของ subtree รวมตัวเอง (leaf = 1) */ function subtreeHeight(b: BookmarkDTO, kidsOf: (id: string) => BookmarkDTO[], level = 1): number { if (level > 100) return 1; // กันข้อมูลวนซ้ำ const kids = kidsOf(b.id); return kids.length === 0 ? 1 : 1 + Math.max(...kids.map((k) => subtreeHeight(k, kidsOf, level + 1))); } /** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) */ function isDescendantOf(id: string, ancestorId: string, byId: Map): boolean { let cur = byId.get(id); for (let steps = 0; cur?.parentId && steps <= 100; steps += 1) { if (cur.parentId === ancestorId) return true; cur = byId.get(cur.parentId); } return false; } /** แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ปุ่มลบเฉพาะรายการของตัวเอง */ export default function AnnotationSidebar({ annotations, bookmarks, fileId, userId, userFilter, users, onUserFilterChange, replyCounts, onDeleteAnnotation, onUpdateAnnotation, onDeleteBookmark, onAddSubBookmark, onUpdateBookmark, onGoToBookmark, onGoToAnnotation, }: { annotations: AnnotationDTO[]; bookmarks: BookmarkDTO[]; /** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */ fileId: string; userId: string; /** F4: "all" | "mine" | — กรองฝั่ง client ล้วน */ userFilter: string; users: { userId: string; userName: string }[]; onUserFilterChange: (value: string) => void; /** F9: จำนวน reply ต่อ annotation (แสดงเฉพาะ COMMENT) */ replyCounts: Record; onDeleteAnnotation: (id: string) => void; /** แก้ไข data ของรายการของตัวเอง (โน้ตคำอธิบาย) — parent ทำ optimistic + updateAnnotation */ onUpdateAnnotation: (id: string, data: AnnotationData) => void; onDeleteBookmark: (id: string) => void; /** N2: ปุ่ม + ที่หัวข้อ — เพิ่ม bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนั้นทันที */ onAddSubBookmark: (parentId: string) => void; /** N2: แก้ bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (patch เฉพาะ field ที่เปลี่ยน) */ onUpdateBookmark: ( id: string, patch: { title?: string; note?: string | null; parentId?: string | null }, ) => void; onGoToBookmark: (b: BookmarkDTO) => void; onGoToAnnotation: (a: AnnotationDTO) => void; }) { const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks"); // ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ──── const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [searching, setSearching] = useState(false); async function runSearch(e: FormEvent) { e.preventDefault(); const q = searchQuery.trim(); if (!q || !fileId) return; setSearching(true); try { setSearchResults(await searchAnnotations(fileId, q)); } catch (err) { console.error("ค้นหาไม่สำเร็จ", err); setSearchResults([]); } finally { setSearching(false); } } // ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ───── const [historyFor, setHistoryFor] = useState(null); const [versions, setVersions] = useState(null); const [restoringId, setRestoringId] = useState(null); const [historyError, setHistoryError] = useState(null); async function openHistory(a: AnnotationDTO) { setHistoryFor(a); setVersions(null); setHistoryError(null); try { setVersions(await listVersions(a.id)); } catch (err) { setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ"); } } /** กู้คืน = เขียนกลับ row เดิม (ต้นทางถูกลบไปแล้ว = สร้างใหม่ id ใหม่) * sync ผ่าน SSE update/create event (รวมแท็บนี้) */ async function handleRestore(versionId: string) { setRestoringId(versionId); try { await restoreVersion(versionId, userId); setHistoryFor(null); } catch (err) { setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ"); } finally { setRestoringId(null); } } // F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น) // กับ "จัดการแล้ว" (comment ที่ถูก resolve) const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved); const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved); // F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=) const [copiedId, setCopiedId] = useState(null); // FIX-7: clipboard ถูก block / ไม่มี API → แสดง feedback fail // 1.5s แทนกลืน error เงียบ — timer เก็บใน ref กันคลิกรัว ๆ แล้ว feedback หายก่อนเวลา const [copyFailedId, setCopyFailedId] = useState(null); const copyTimerRef = useRef(null); function copyAnnotationLink(a: AnnotationDTO) { const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`; if (copyTimerRef.current !== null) { window.clearTimeout(copyTimerRef.current); copyTimerRef.current = null; } const show = (ok: boolean) => { setCopiedId(ok ? a.id : null); setCopyFailedId(ok ? null : a.id); copyTimerRef.current = window.setTimeout(() => { copyTimerRef.current = null; setCopiedId(null); setCopyFailedId(null); }, 1500); }; // บริบทที่ไม่มี clipboard API (HTTP ที่ไม่ใช่ localhost) — optional chaining // จะตัดทั้ง chain ทิ้ง ต้องตรวจก่อนถึงจะมี feedback fail จริง if (!navigator.clipboard?.writeText) { show(false); return; } navigator.clipboard.writeText(url).then( () => show(true), () => show(false), ); } // ─── โน้ตคำอธิบายของรายการ (N1) — แก้ไขเฉพาะรายการของตัวเอง แสดงให้ทุกคน ───── const [noteFor, setNoteFor] = useState(null); const [noteDraft, setNoteDraft] = useState(""); function startNoteEdit(a: AnnotationDTO) { setNoteFor(a.id); setNoteDraft(annotationNote(a)); } function saveNote(a: AnnotationDTO) { const msg = noteDraft.trim(); setNoteFor(null); if (a.type === "COMMENT" || msg === annotationNote(a)) return; // ไม่มีอะไรเปลี่ยน onUpdateAnnotation(a.id, withNote(a.data as Exclude, msg)); } // ─── N2: bookmark ต้นไม้ 3 ระดับ (หมุดหัวข้อใหญ่ + sub + sub-sub) + edit inline ── const [collapsedIds, setCollapsedIds] = useState>(new Set()); const bookmarkById = new Map(bookmarks.map((b) => [b.id, b] as const)); const [editBookmark, setEditBookmark] = useState<{ id: string; title: string; note: string; parentId: string | null; } | null>(null); function toggleCollapsed(id: string) { setCollapsedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function startBookmarkEdit(b: BookmarkDTO) { setEditBookmark({ id: b.id, title: b.title, note: b.note ?? "", parentId: b.parentId, }); } function saveBookmarkEdit(b: BookmarkDTO) { if (!editBookmark || editBookmark.id !== b.id) return; const patch: { title?: string; note?: string | null; parentId?: string | null } = {}; const title = editBookmark.title.trim(); if (title && title !== b.title) patch.title = title; const note = editBookmark.note.trim(); if (note !== (b.note ?? "")) patch.note = note || null; if (editBookmark.parentId !== (b.parentId ?? null)) { patch.parentId = editBookmark.parentId; } setEditBookmark(null); if (Object.keys(patch).length > 0) onUpdateBookmark(b.id, patch); } const renderBookmarkNode = (b: BookmarkDTO, depth: number) => { const children = bookmarks.filter((x) => x.parentId === b.id); const hasChildren = children.length > 0; const collapsed = collapsedIds.has(b.id); const editing = editBookmark?.id === b.id; // FIX-9: guard null ครั้งเดียวต้น branch แทน editBookmark! ซ้ำ 6 จุด const eb = editing ? editBookmark : null; // เป้าหมายที่ย้ายเข้าได้ = ไม่ใช่ตัวเอง/ลูกหลาน และความลึกแม่ + ความสูง subtree ตัวเอง ≤ 3 ระดับ // (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน) // FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า const movableTo = editing ? bookmarks.filter( (t) => t.id !== b.id && !isDescendantOf(t.id, b.id, bookmarkById) && bookmarkDepth(t, bookmarkById) + subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <= 3, ) : []; return (
  • {eb ? (
    setEditBookmark({ ...eb, title: e.target.value })} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") saveBookmarkEdit(b); if (e.key === "Escape") setEditBookmark(null); }} placeholder="ชื่อหัวข้อ" className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400" />