"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { ArrowRight, Bookmark, Braces, Check, ChevronDown, ChevronRight, Circle, Clock, Copy, EyeOff, Highlighter, MessageSquare, Minus, MoreHorizontal, Pencil, Plus, Search, SlidersHorizontal, Square, Stamp, StickyNote, Trash2, Type, Underline, Strikethrough, X, type LucideIcon, } from "lucide-react"; import Select from "../Select"; import { listVersions, restoreVersion, searchAnnotations, searchBookmarks, } 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"; /** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS * ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น * circular import — แก้ต้องแก้ทั้งสองที่พร้อมกัน) */ 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"; function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) { const cls = "h-4 w-4 shrink-0 text-slate-500"; 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; } /** N5: เรียงตามลำดับหน้า — หน้า → ตำแหน่งบนหน้า (บน→ล่าง) → เวลาสร้าง (tiebreak ให้ deterministic) * sort ตอน render ฝั่ง client เท่านั้น — realtime แทรก row ต่อท้าย state เชื่อลำดับจาก server อย่างเดียวไม่ได้ * (export ให้ PDFViewer ใช้กับ dropdown "วางที่" ของ popup bookmark ด้วย — M7 UX audit 2026-09-16) */ export function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number { return ( a.pageIndex - b.pageIndex || a.yOffset - b.yOffset || a.createdAt.localeCompare(b.createdAt) ); } // ─── เมนู ⋯ ของแถวรายการ — ปุ่ม action เดียวแทนไอคอนชุดติดกัน ────────────────── /** ตำแหน่งเมนู ⋯ — คัดลอก pattern fixedMenuPos ของ PDFViewer.tsx (import ข้ามกัน * ไม่ได้เพราะ circular): ชิดขอบขวาปุ่ม ⋯ (ปุ่มอยู่ขวาสุดแถว) แล้ว clamp ไม่ให้ล้นจอ */ const ROW_MENU_W = 240; // w-60 เท่า dropdown ของ tool function rowMenuPos(rect: DOMRect): { left: number; top: number } { const vw = window.innerWidth; return { left: Math.min(Math.max(rect.right - ROW_MENU_W, 8), Math.max(8, vw - ROW_MENU_W - 8)), top: rect.bottom + 4, }; } /** เป้าหมายของเมนู ⋯ ที่เปิดอยู่ — เก็บแค่ kind+id ไม่เก็บ object เพราะ props * annotations/bookmarks เปลี่ยนตลอดจาก SSE (realtime) — lookup ตอน render/คลิก * ให้ได้ข้อมูลสดเสมอ ไม่มี ghost object */ type RowMenuTarget = { kind: "annotation" | "bookmark"; id: string }; // ─── มุมมอง "ตามหน้า" — สลับมุมมองรองในแท็บ + หัวกลุ่มหน้าที่หุบได้ ──────────── /** ปุ่มสลับมุมมองภายในแท็บ (ตามหัวข้อ↔ตามหน้า / ตามสถานะ↔ตามหน้า) — * segmented control ระดับรองใต้ tab หลัก (tab ใช้เส้นใต้ อันนี้ใช้ pill) */ function ViewSwitch({ value, options, onChange, }: { value: T; options: { key: T; label: string }[]; onChange: (v: T) => void; }) { return (
{options.map((o) => ( ))}
); } /** รายการในเมนู ⋯ — ไอคอนเล็ก + label ไทย (จอสัมผัสไม่มี hover อ่าน title ไม่ได้ * label จึงต้องอยู่ในตัวเมนู) / danger = โซนทำลาย (แดง) / items-start รองรับ * label ยาวอย่าง "เพิ่มหน้าที่ดูอยู่เข้าหัวข้อนี้" ที่ wrap 2 บรรทัดใน w-60 */ function MenuItem({ Icon, label, danger, onClick, }: { Icon: LucideIcon; label: string; danger?: boolean; onClick: () => void; }) { return ( ); } /** หัวกลุ่ม "หน้า N (จำนวน)" — หุบ/กางรายการของหน้านั้น (chevron เหมือนต้นไม้ bookmark) */ function PageGroupHeader({ pageIndex, count, collapsed, onToggle, }: { pageIndex: number; count: number; collapsed: boolean; onToggle: () => void; }) { return ( ); } /** จัดกลุ่มตามหน้าแล้วเรียง pageIndex น้อย→มาก — ลำดับในกลุ่มตาม input ต่อเนื่อง */ function groupByPage(items: T[]): [number, T[]][] { const byPage = new Map(); for (const it of items) { const list = byPage.get(it.pageIndex); if (list) list.push(it); else byPage.set(it.pageIndex, [it]); } return Array.from(byPage).sort(([a], [b]) => a - b); } // ─── N6: เงื่อนไขการค้นหา bookmark (คำค้นอยู่แยกใน state ของฟอร์ม) ──────────── /** ค่าทุกช่องเก็บเป็น string ตรง ๆ จาก input (ว่าง = ไม่ตั้งเงื่อนไข) — * แปลงเป็นตัวเลข/ส่งต่อ server ตอนกดค้นเท่านั้น */ type BmConditions = { pageFrom: string; pageTo: string; userId: string; dateFrom: string; dateTo: string; }; const EMPTY_BM_CONDITIONS: BmConditions = { pageFrom: "", pageTo: "", userId: "", dateFrom: "", dateTo: "", }; /** วันที่ ISO (yyyy-mm-dd) → d/m/พ.ศ. — เจ้าหน้าที่ศาลคิดวันที่เป็น พ.ศ. */ function fmtThaiDate(iso: string): string { const [y, m, d] = iso.split("-").map(Number); return `${d}/${m}/${y + 543}`; } /** สรุปเงื่อนไขค้นหา bookmark เป็นข้อความสั้น — แถวสรุปใต้ช่องค้นหาใช้แบบไม่มีคำค้น * (คำค้นแสดงอยู่ใน input ข้างบนแล้ว), หัวผลการค้นหาใช้แบบมีคำค้น (snapshot ตอนกดหา) * ช่วงที่กรองฝั่งเดียวแสดงปลายเปิด เช่น "หน้า 5–…" */ function bmConditionSummary(c: BmConditions, q?: string, userName?: string): string { const parts: string[] = []; if (q?.trim()) parts.push(`"${q.trim()}"`); if (c.pageFrom || c.pageTo) parts.push(`หน้า ${c.pageFrom || "…"}–${c.pageTo || "…"}`); if (c.userId) parts.push(userName ?? "ผู้บันทึกที่เลือก"); if (c.dateFrom || c.dateTo) parts.push( `${c.dateFrom ? fmtThaiDate(c.dateFrom) : "…"}–${c.dateTo ? fmtThaiDate(c.dateTo) : "…"}`, ); return parts.join(" · "); } /** ค่าจาก input number → เงื่อนไขช่วงหน้า (1-based ตรงที่ผู้ใช้เห็น) — * ว่าง/ไม่ใช่จำนวนเต็ม ≥ 1 = ไม่ส่งเงื่อนไขนั้น */ function toPageCondition(v: string): number | undefined { if (!v) return undefined; const n = Number(v); return Number.isInteger(n) && n >= 1 ? n : undefined; } /** แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ปุ่มลบเฉพาะรายการของตัวเอง */ export default function AnnotationSidebar({ annotations, bookmarks, fileId, userId, userFilter, users, onUserFilterChange, replyCounts, onDeleteAnnotation, onUpdateAnnotation, onDeleteBookmark, onAddSubBookmark, onUpdateBookmark, onGoToBookmark, onGoToAnnotation, selectedId, }: { 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; /** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */ selectedId: string | null; }) { const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks"); // คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks) // แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป // (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน) const listRef = useRef(null); // id ล่าสุดที่ประกาศไปแล้ว — บังคับสลับแท็บเฉพาะตอน selectedId "เพิ่งเปลี่ยน" เท่านั้น // ไม่ใช่ทุกครั้งที่ effect รัน เพราะ dep มี tab ด้วย ไม่งั้นผู้ใช้กดกลับมาแท็บ bookmarks // ไม่ได้เลยตอนมีรายการถูกเลือกอยู่ (ถูกดึงกลับ annotations ทันที); เคลียร์เป็น null // ตอนไม่มีรายการถูกเลือก เพื่อให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้เหมือนเดิม const announcedSelRef = useRef(null); useEffect(() => { if (!selectedId) { announcedSelRef.current = null; return; } const firstAnnounce = announcedSelRef.current !== selectedId; announcedSelRef.current = selectedId; if (firstAnnounce && tab !== "annotations") setTab("annotations"); const raf = requestAnimationFrame(() => { listRef.current ?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`) ?.scrollIntoView({ block: "nearest" }); }); return () => cancelAnimationFrame(raf); }, [selectedId, tab]); // ─── 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); } } /** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations * ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกับ visibleAnnotations ของ * PDFViewer — เปลี่ยนตัวกรองหลังค้นแล้ว ผลกรองทันทีไม่ต้องค้นใหม่ */ const visibleSearchResults = useMemo(() => { if (searchResults === null) return null; if (userFilter === "all") return searchResults; const target = userFilter === "mine" ? userId : userFilter; return searchResults.filter((a) => a.userId === target); }, [searchResults, userFilter, userId]); // ─── 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); } } // Escape ปิด dialog ประวัติ (UX audit: เดิมปิดได้ด้วยปุ่ม "ปิด" เท่านั้น) useEffect(() => { if (!historyFor) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setHistoryFor(null); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [historyFor]); // 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); // มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root // (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง const bmRootPages = groupByPage(bookmarks.filter((b) => !b.parentId).sort(byPageOrder)); const annPages = groupByPage(annotations); // 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)); } // ─── เมนู ⋯ ของแถวรายการ (annotation/bookmark) — UX audit 2026-09-16 ─────────── const [menuFor, setMenuFor] = useState(null); const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); // สถานะ "แถวยืนยันลบ" ในเมนู — การลบเป็น action ที่กลับไม่ได้บนจอสัมผัส (ไม่มี Ctrl+Z) const [menuConfirmDelete, setMenuConfirmDelete] = useState(false); // ปุ่ม ⋯ ที่เปิดเมนูอยู่ — outside-mousedown เว้นปุ่มนี้ให้ onClick toggle เอง const menuAnchorRef = useRef(null); const closeRowMenu = useCallback(() => { setMenuFor(null); setMenuPos(null); setMenuConfirmDelete(false); menuAnchorRef.current = null; }, []); function openRowMenu(btn: HTMLButtonElement, target: RowMenuTarget) { menuAnchorRef.current = btn; setMenuPos(rowMenuPos(btn.getBoundingClientRect())); setMenuFor(target); setMenuConfirmDelete(false); } // ปิดเมนู 3 ทาง: คลิกนอก (เว้นโหนดใน portal + ปุ่ม anchor กัน toggle bug) / // Escape / scroll ใด ๆ (scroll ของ element ไม่ bubble ถึง document — ต้องใช้ // capture ถึงจับ scroll ของ listRef ของแผงนี้เองได้) useEffect(() => { if (!menuFor) return; const onMouseDown = (e: MouseEvent) => { const t = e.target; if (!(t instanceof Element)) return; if (t.closest("[data-tool-menu]")) return; // คลิกในเมนู — ให้ handler ของรายการทำงาน if (menuAnchorRef.current?.contains(t)) return; // anchor — click ถัดไป toggle เอง closeRowMenu(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeRowMenu(); }; const onScroll = (e: Event) => { // เว้น scroll ภายในเมนูเอง (เมนูมี overflowY ตอนจอเตี้ย) if (e.target instanceof Element && e.target.closest("[data-tool-menu]")) return; closeRowMenu(); }; document.addEventListener("mousedown", onMouseDown); document.addEventListener("keydown", onKey); document.addEventListener("scroll", onScroll, true); return () => { document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("keydown", onKey); document.removeEventListener("scroll", onScroll, true); }; }, [menuFor, closeRowMenu]); // realtime (SSE): แถวที่เปิดเมนูถูกลบไป = id หาไม่เจอ → ปิดเมนูเอง // (นับ snapshot ผลค้นหา F14 ด้วย — แถวนั้นไม่ได้อยู่ใน prop annotations ที่กรอง F4 แล้ว) useEffect(() => { if (!menuFor) return; const alive = menuFor.kind === "annotation" ? annotations.some((a) => a.id === menuFor.id) || (visibleSearchResults ?? []).some((a) => a.id === menuFor.id) : bookmarks.some((b) => b.id === menuFor.id); if (!alive) closeRowMenu(); }, [menuFor, annotations, bookmarks, visibleSearchResults, closeRowMenu]); // ─── N2: bookmark ต้นไม้ 3 ระดับ (หมุดหัวข้อใหญ่ + sub + sub-sub) + edit inline ── const [collapsedIds, setCollapsedIds] = useState>(new Set()); // มุมมองรองในแต่ละแท็บ: ที่คั่นหน้า (topic=ต้นไม้หัวข้อ ↔ page) / หมายเหตุ (status ↔ page) const [bmView, setBmView] = useState<"topic" | "page">("topic"); const [annView, setAnnView] = useState<"status" | "page">("status"); // หน้าที่กางไว้ขอมุมมอง "ตามหน้า" (ใช้ร่วม 2 แท็บ — เก็บ pageIndex; ว่าง = หุบทั้งหมด) const [expandedPages, setExpandedPages] = useState>(new Set()); const bookmarkById = new Map(bookmarks.map((b) => [b.id, b] as const)); // เป้าหมายเมนู ⋯ ที่ lookup จาก state สดทุก render (SSE อัปเดตตลอด) // — null = แถวหายไปแล้ว (ผลค้นหา F14 เป็น snapshot จึง lookup เสริมจาก searchResults) const menuAnn = menuFor?.kind === "annotation" ? (annotations.find((a) => a.id === menuFor.id) ?? searchResults?.find((a) => a.id === menuFor.id)) ?? null : null; const menuBm = menuFor?.kind === "bookmark" ? (bookmarks.find((b) => b.id === menuFor.id) ?? null) : null; 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 togglePageExpanded(pageIndex: number) { setExpandedPages((prev) => { const next = new Set(prev); if (next.has(pageIndex)) next.delete(pageIndex); else next.add(pageIndex); 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); } // ─── N6: ค้นหา bookmark พร้อมเงื่อนไข — คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ ── const [bmQuery, setBmQuery] = useState(""); const [bmConditions, setBmConditions] = useState(EMPTY_BM_CONDITIONS); const [bmShowConditions, setBmShowConditions] = useState(false); /** null = ยังไม่ค้น (แสดงต้นไม้ปกติ) — มีค่า = แสดงผลลัพธ์แทนต้นไม้จนกว่าจะกดปิด */ const [bmResults, setBmResults] = useState(null); const [bmSearching, setBmSearching] = useState(false); /** error ของฟอร์มค้นหา (input ผิด/format ผิด/action fail) — แสดงแทนการเห็น "ไม่พบ" * ที่หลอกลวง เช่น พิมพ์ 0/1.5 ในช่องหน้า แล้วเงื่อนไขหายเงียบจน server throw */ const [bmSearchError, setBmSearchError] = useState(null); /** ลายเซ็น (คำค้น+เงื่อนไข) ของการค้นล่าสุดที่สำเร็จ — ต่างจากค่าในฟอร์มปัจจุบัน = * ผลที่กำลังแสดงเป็นของเงื่อนไขเก่า แสดง hint เตือนให้กด "หา" ใหม่ (ตั้งค่าเฉพาะ * path สำเร็จหลัง await — ถ้าตั้งก่อนแล้ว request พัง hint จะเตือนทั้งที่ผลไม่เคยอัปเดต) */ const [bmSearchedSig, setBmSearchedSig] = useState(null); /** สรุปเงื่อนไข (รวมคำค้น) ของการค้นล่าสุดที่สำเร็จ — แสดงใต้หัวผลให้ผลอ่านรู้เรื่อง * โดยไม่ต้องจำว่าค้นด้วยอะไร (snapshot คู่กับ bmSearchedSig — เฉพาะ path สำเร็จ) */ const [bmSearchedSummary, setBmSearchedSummary] = useState(null); /** เงื่อนไขที่ตั้งอยู่ทั้งหมด (คำค้นนับ 1) — ใช้ที่ badge ของปุ่มเงื่อนไข + disable ปุ่มหา */ const activeConditionCount = (bmQuery.trim() !== "" ? 1 : 0) + Object.values(bmConditions).filter((v) => v !== "").length; /** รายชื่อผู้บันทึก bookmark — unique จาก bookmarks prop * (ห้ามปนกับ dropdown กรอง F4 ที่ derive จาก annotations เท่านั้น) */ const bookmarkAuthors = useMemo(() => { const seen = new Map(); for (const b of bookmarks) { if (!seen.has(b.userId)) seen.set(b.userId, b.userName || "ไม่ทราบชื่อ"); } return Array.from(seen, ([userId, userName]) => ({ userId, userName })).sort((x, y) => x.userName.localeCompare(y.userName, "th"), ); }, [bookmarks]); // ─── แถวสรุปเงื่อนไข (UX design 2026-09-16) — เงื่อนไข active ต้องมองเห็นเป็น // ข้อความตลอดแม้แผงหุบ/ปิดผลแล้ว (เดิม badge ตัวเลขเห็นแค่จำนวน เงื่อนไขเก่าจึง // กรองการค้นถัดไปเงียบ ๆ ได้) — สรุปสดจากฟอร์ม ไม่รวมคำค้น (อยู่ใน input ข้างบน) const hasBmConditionValues = Object.values(bmConditions).some((v) => v !== ""); const bmConditionUserName = bmConditions.userId ? bookmarkAuthors.find((u) => u.userId === bmConditions.userId)?.userName : undefined; const bmLiveSummary = bmConditionSummary(bmConditions, undefined, bmConditionUserName); async function runBookmarkSearch(e: FormEvent) { e.preventDefault(); if (!fileId || activeConditionCount === 0 || bmSearching) return; // input type=number ล็อกแค่ spinner — พิมพ์มือ 0/1.5 ได้: ตรวจก่อน submit // ไม่งั้นเงื่อนไขหายเงียบ (toPageCondition → undefined) จนเห็น "ไม่พบ" ผิด ๆ const pageFrom = toPageCondition(bmConditions.pageFrom); const pageTo = toPageCondition(bmConditions.pageTo); if ( (bmConditions.pageFrom !== "" && pageFrom === undefined) || (bmConditions.pageTo !== "" && pageTo === undefined) ) { setBmSearchError("ช่วงหน้าต้องเป็นจำนวนเต็ม ≥ 1"); return; } setBmSearchError(null); setBmSearching(true); try { const q = bmQuery.trim() || undefined; setBmResults( await searchBookmarks({ fileId, q, pageFrom, pageTo, userId: bmConditions.userId || undefined, dateFrom: bmConditions.dateFrom || undefined, dateTo: bmConditions.dateTo || undefined, }), ); // ลายเซ็นของค่าที่ค้นจริง (สร้างจากตัวแปรเดียวกับที่ส่งให้ server เพื่อบวกเทียบ // กับ JSON.stringify([bmQuery.trim(), bmConditions]) ตอน render ได้ถูกต้อง) setBmSearchedSig(JSON.stringify([q ?? "", bmConditions])); // snapshot สรุป "ค้นด้วยอะไร" ของผลที่กำลังจะแสดง — หาผู้บันทึกจากรายชื่อปัจจุบัน setBmSearchedSummary( bmConditionSummary( bmConditions, q, bmConditions.userId ? bookmarkAuthors.find((u) => u.userId === bmConditions.userId)?.userName : undefined, ), ); } catch (err) { // แสดง error จริง ไม่กลืนเป็น "ไม่พบ" — "ไม่พบ" สงวนไว้เฉพาะผลลัพธ์ว่างจริง console.error("ค้นหา bookmark ไม่สำเร็จ", err); setBmSearchError(err instanceof Error ? err.message : "ค้นหาไม่สำเร็จ ลองอีกครั้ง"); } finally { setBmSearching(false); } } const renderBookmarkNode = (b: BookmarkDTO, depth: number) => { const children = bookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder); 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, ) .sort(byPageOrder) // M7: เรียงตามลำดับหน้าเหมือนต้นไม้ — หาหัวข้อแม่ใน select ง่ายขึ้น : []; // พื้นหลัง hover อยู่ที่ div แถวรายการ ไม่ใช่ li — li ครอบ
    ลูกทั้งก้อน // ทำให้ :hover ของแม่ทำงานด้วยเมื่อชี้รายการลูก (ไฮไลต์ทั้ง subtree เป็นก้อนเดียว) // UX audit: indent + เส้น guide ย้ายไปอยู่ที่
      ลูก แถวจึงใช้ pad เดียวกันทุกระดับ const pad = "px-1"; // แสดงปุ่ม ⋯ เมื่อมี action อย่างน้อยหนึ่ง: เพิ่ม sub (depth < 3) หรือเจ้าของแก้/ลบ const hasRowMenu = (depth < 3 && userId !== "") || b.userId === userId; 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" />