eCourt-note/components/sidebar/AnnotationSidebar.tsx
Your Name f4d46c7720 external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 12:02:42 +07:00

1729 lines
91 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 { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { createPortal } from "react-dom";
import {
Bookmark,
Check,
ChevronDown,
ChevronRight,
Clock,
Copy,
MoreHorizontal,
Pencil,
Plus,
Search,
SlidersHorizontal,
StickyNote,
Trash2,
X,
type LucideIcon,
} from "lucide-react";
import { AnnotationTypeIcon } from "../AnnotationTypeIcon";
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,
RedactionData,
} from "@/db/schema";
import {
annotationNote,
annotationPreviewText,
describeAnnotationChange,
} from "@/utils/annotation-display";
import { withBasePath } from "@/utils/base-path";
import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { formatThaiDate } from "@/utils/date-format";
/** 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 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary focus-visible:z-10";
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
function withNote(data: Exclude<AnnotationData, CommentData>, 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<string, BookmarkDTO>): 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<string, BookmarkDTO>): 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<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { key: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className="flex rounded-md bg-ink/[0.05] p-0.5">
{options.map((o) => (
<button
key={o.key}
type="button"
aria-pressed={value === o.key}
className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition ${FOCUS_RING_CLS} ${
value === o.key
? "bg-white text-ink ring-1 ring-line"
: "text-ink-soft hover:text-ink"
}`}
onClick={() => onChange(o.key)}
>
{o.label}
</button>
))}
</div>
);
}
/** รายการในเมนู ⋯ — ไอคอนเล็ก + 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 (
<button
type="button"
role="menuitem"
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition ${FOCUS_RING_CLS} ${
danger ? "text-error-ink hover:bg-error/10" : "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={onClick}
>
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{label}</span>
</button>
);
}
/** หัวกลุ่ม "หน้า N (จำนวน)" — หุบ/กางรายการของหน้านั้น (chevron เหมือนต้นไม้ bookmark) */
function PageGroupHeader({
pageIndex,
count,
collapsed,
onToggle,
}: {
pageIndex: number;
count: number;
collapsed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
aria-label={collapsed ? `กางหน้า ${pageIndex + 1}` : `หุบหน้า ${pageIndex + 1}`}
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={onToggle}
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-ink-soft" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-ink-soft" />
)}
<span className="text-xs font-medium text-ink">
หน้า {pageIndex + 1}
<span className="ml-1 font-normal text-ink-soft">({count})</span>
</span>
</button>
);
}
/** จัดกลุ่มตามหน้าแล้วเรียง pageIndex น้อย→มาก — ลำดับในกลุ่มตาม input ต่อเนื่อง */
function groupByPage<T extends { pageIndex: number }>(items: T[]): [number, T[]][] {
const byPage = new Map<number, T[]>();
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" | <userId> — กรองฝั่ง client ล้วน */
userFilter: string;
users: { userId: string; userName: string }[];
onUserFilterChange: (value: string) => void;
/** F9: จำนวน reply ต่อ annotation (แสดงเฉพาะ COMMENT) */
replyCounts: Record<string, number>;
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<HTMLDivElement>(null);
// id ล่าสุดที่ประกาศไปแล้ว — บังคับสลับแท็บเฉพาะตอน selectedId "เพิ่งเปลี่ยน" เท่านั้น
// ไม่ใช่ทุกครั้งที่ effect รัน เพราะ dep มี tab ด้วย ไม่งั้นผู้ใช้กดกลับมาแท็บ bookmarks
// ไม่ได้เลยตอนมีรายการถูกเลือกอยู่ (ถูกดึงกลับ annotations ทันที); เคลียร์เป็น null
// ตอนไม่มีรายการถูกเลือก เพื่อให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้เหมือนเดิม
const announcedSelRef = useRef<string | null>(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<AnnotationDTO[] | null>(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 (รวม REDACTION ของทุกคนไว้เสมอ — D4) — เปลี่ยนตัวกรองหลังค้นแล้ว
* ผลกรองทันทีไม่ต้องค้นใหม่ */
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 || a.type === "REDACTION");
}, [searchResults, userFilter, userId]);
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
const [historyFor, setHistoryFor] = useState<AnnotationDTO | null>(null);
const [versions, setVersions] = useState<AnnotationVersionDTO[] | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(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]);
// state ปัจจุบันของรายการที่เปิดประวัติอยู่ — lookup สดจาก realtime ก่อน
// (SSE update หลังเปิด dialog ก็ยังเทียบกับค่าล่าสุดได้) โดย fallback เป็น
// snapshot ตอนเปิด (รายการจากผลค้นหา F14 ไม่อยู่ใน prop annotations)
const historyCurrent =
historyFor && (annotations.find((a) => a.id === historyFor.id) ?? 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);
// F4: bookmarks ที่แสดง กรองตามเจ้าของหมายเหตุเหมือน annotation (คำขอผู้ใช้
// 2026-09-22 ให้ตรงกับ export ที่กรอง PDF outline ตามผู้บันทึก) — หัวข้อแม่โดนกรอง
// แต่ลูกยังอยู่ ลูกขึ้น root (พฤติกรรมเดียวกับ buildOutline ของ export) — ใช้เฉพาะจุด
// แสดงผล (ต้นไม้/กลุ่มหน้า/ตัวเลขแท็บ): popup ย้ายหัวข้อ (movableTo) กับ bookmarkById
// ยังเห็นต้นไม้เต็ม ย้ายไปใต้หัวข้อของคนอื่นได้ตามเดิม
const keptBookmarks =
userFilter === "all"
? bookmarks
: bookmarks.filter(
(b) => b.userId === (userFilter === "mine" ? userId : userFilter),
);
const keptIds = new Set(keptBookmarks.map((b) => b.id));
const visibleBookmarks = keptBookmarks.map((b) =>
b.parentId && !keptIds.has(b.parentId) ? { ...b, parentId: null } : b,
);
// มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root
// (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง
const bmRootPages = groupByPage(visibleBookmarks.filter((b) => !b.parentId).sort(byPageOrder));
const annPages = groupByPage(annotations);
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
const [copiedId, setCopiedId] = useState<string | null>(null);
// FIX-7: clipboard ถูก block / ไม่มี API → แสดง feedback fail
// 1.5s แทนกลืน error เงียบ — timer เก็บใน ref กันคลิกรัว ๆ แล้ว feedback หายก่อนเวลา
const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
const copyTimerRef = useRef<number | null>(null);
function copyAnnotationLink(a: AnnotationDTO) {
const url = `${window.location.origin}${withBasePath(`/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<AnnotationDTO["id"] | null>(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<AnnotationData, CommentData>, msg));
}
// ─── เมนู ⋯ ของแถวรายการ (annotation/bookmark) — UX audit 2026-09-16 ───────────
const [menuFor, setMenuFor] = useState<RowMenuTarget | null>(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<HTMLButtonElement | null>(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<Set<string>>(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<Set<number>>(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<BmConditions>(EMPTY_BM_CONDITIONS);
const [bmShowConditions, setBmShowConditions] = useState(false);
/** null = ยังไม่ค้น (แสดงต้นไม้ปกติ) — มีค่า = แสดงผลลัพธ์แทนต้นไม้จนกว่าจะกดปิด */
const [bmResults, setBmResults] = useState<BookmarkDTO[] | null>(null);
const [bmSearching, setBmSearching] = useState(false);
/** error ของฟอร์มค้นหา (input ผิด/format ผิด/action fail) — แสดงแทนการเห็น "ไม่พบ"
* ที่หลอกลวง เช่น พิมพ์ 0/1.5 ในช่องหน้า แล้วเงื่อนไขหายเงียบจน server throw */
const [bmSearchError, setBmSearchError] = useState<string | null>(null);
/** ลายเซ็น (คำค้น+เงื่อนไข) ของการค้นล่าสุดที่สำเร็จ — ต่างจากค่าในฟอร์มปัจจุบัน =
* ผลที่กำลังแสดงเป็นของเงื่อนไขเก่า แสดง hint เตือนให้กด "หา" ใหม่ (ตั้งค่าเฉพาะ
* path สำเร็จหลัง await — ถ้าตั้งก่อนแล้ว request พัง hint จะเตือนทั้งที่ผลไม่เคยอัปเดต) */
const [bmSearchedSig, setBmSearchedSig] = useState<string | null>(null);
/** สรุปเงื่อนไข (รวมคำค้น) ของการค้นล่าสุดที่สำเร็จ — แสดงใต้หัวผลให้ผลอ่านรู้เรื่อง
* โดยไม่ต้องจำว่าค้นด้วยอะไร (snapshot คู่กับ bmSearchedSig — เฉพาะ path สำเร็จ) */
const [bmSearchedSummary, setBmSearchedSummary] = useState<string | null>(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<string, string>();
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 = visibleBookmarks.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 ครอบ <ul> ลูกทั้งก้อน
// ทำให้ :hover ของแม่ทำงานด้วยเมื่อชี้รายการลูก (ไฮไลต์ทั้ง subtree เป็นก้อนเดียว)
// UX audit: indent + เส้น guide ย้ายไปอยู่ที่ <ul> ลูก แถวจึงใช้ pad เดียวกันทุกระดับ
const pad = "px-1";
// แสดงปุ่ม ⋯ เมื่อมี action อย่างน้อยหนึ่ง: เพิ่ม sub (depth < 3) หรือเจ้าของแก้/ลบ
const hasRowMenu = (depth < 3 && userId !== "") || b.userId === userId;
return (
<li key={b.id}>
{eb ? (
<div className={`rounded py-1 ${pad}`}>
<input
autoFocus
maxLength={MAX_BOOKMARK_TITLE_CHARS}
value={eb.title}
onChange={(e) => 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-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<textarea
rows={2}
maxLength={MAX_BOOKMARK_NOTE_CHARS}
value={eb.note}
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") setEditBookmark(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
}}
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1 w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<p className="mt-1 text-[11px] text-ink-soft">วางที่</p>
<Select
value={eb.parentId ?? ""}
onChange={(e) =>
setEditBookmark({ ...eb, parentId: e.target.value || null })
}
onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
>
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{movableTo.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot; (หน้า {h.pageIndex + 1})
</option>
))}
</Select>
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => setEditBookmark(null)}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
onClick={() => saveBookmarkEdit(b)}
>
บันทึก
</button>
</div>
</div>
) : (
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-ink/[0.05] ${pad}`}>
{hasChildren ? (
// bm-toggle: คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้าง 14px เท่า spacer
// ของแถวลูกเสมอ — บนจอสัมผัส rule coarse ที่ขยายปุ่มเป็น 32px จะดันไอคอน
// ของแม่ไปชนแนวของลูก (indent 16px หาย) จึงยกเว้นผ่าน class + hit area
// ขยายด้วย ::after แทน (ดู globals.css)
<button
type="button"
aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`}
className={`shrink-0 bm-toggle text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
onClick={() => toggleCollapsed(b.id)}
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<button
type="button"
aria-level={depth}
className={`flex min-w-0 flex-1 items-start gap-1.5 text-left ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)}
>
{/* ไอคอน bookmark สีเดียวทุกระดับ (warning-ink ผ่าน 5:1 บนขาว — เดิมแยก
root/ลูกด้วย amber-500/300 แต่ตัว 300 จาง 1.6:1 แทบมองไม่เห็น;
ลำดับชั้นของต้นไม้สื่อด้วย font-semibold + indent อยู่แล้ว) */}
<Bookmark className="h-3.5 w-3.5 shrink-0 text-warning-ink" />
<span className="min-w-0">
<span
className={`block truncate text-[13px] leading-snug ${
depth === 1 ? "font-semibold text-ink" : ""
}`}
>
{b.title}
</span>
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
<span className="block text-xs text-ink-soft">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
{b.note}
</span>
)}
</span>
</button>
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
{hasChildren && (
<span className="shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft">
{children.length}
</span>
)}
{/* action เดียวของแถว — เมนู ⋯ (เพิ่ม sub/แก้ไข/ลบ ตามสิทธิ์) ผ่าน portal */}
{hasRowMenu && (
<button
type="button"
aria-label="จัดการหัวข้อนี้"
aria-haspopup="menu"
aria-expanded={menuFor?.kind === "bookmark" && menuFor.id === b.id}
className={`shrink-0 text-ink-soft hover:text-ink group-hover:text-ink-soft ${FOCUS_RING_CLS}`}
onClick={(e) => {
if (menuFor?.kind === "bookmark" && menuFor.id === b.id) closeRowMenu();
else openRowMenu(e.currentTarget, { kind: "bookmark", id: b.id });
}}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{hasChildren && !collapsed && (
// เส้น guide แนวตั้งนำสายตากลับหา parent แบบ file tree (indent ~17px/ระดับ
// ≈ ของเดิม 16px — spacer/chevron w-3.5 คงเดิมจึงตรงแนวกันทุกระดับ)
<ul className="ml-3 space-y-0.5 border-l border-line pl-1">
{children.map((c) => renderBookmarkNode(c, depth + 1))}
</ul>
)}
</li>
);
};
const renderAnnotationRow = (a: AnnotationDTO) => {
const note = annotationNote(a);
const editingNote = noteFor === a.id;
return (
<li
key={a.id}
data-ann-id={a.id}
className={`group rounded px-2 py-1.5 ${
a.id === selectedId
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
: "hover:bg-ink/[0.05]"
} ${a.resolved ? "opacity-60" : ""}`}
>
<div className="flex items-center gap-2">
<button
type="button"
className={`flex min-w-0 flex-1 items-start gap-2 text-left ${FOCUS_RING_CLS}`}
onClick={() => {
// ระหว่างแก้โน้ต: คลิกข้อความไม่กระโดดหน้า (กัน scroll หนีตอน editor เปิดอยู่)
if (editingNote) return;
onGoToAnnotation(a);
}}
>
<AnnotationTypeIcon type={a.type} data={a.data} />
<span className="min-w-0">
{/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px
จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */}
<span className="block truncate text-[13px] leading-snug">{annotationPreviewText(a)}</span>
<span className="block text-xs text-ink-soft">
หน้า {a.pageIndex + 1} · {a.userName}
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
? ` · ตอบ ${replyCounts[a.id]}`
: ""}
</span>
</span>
</button>
{/* ปุ่ม action เดียวของแถว — เปิดเมนู (คัดลอกลิงก์ F12/โน้ต N1/ประวัติ F19/ลบ ตามสิทธิ์)
แทนไอคอนติดกัน 4 ตัวที่จิ้มพลาดง่ายบนจอสัมผัส; ตัวปุ่มยังแสดง feedback
คัดลอกลิงก์ (Check เขียว/X แดง 1.5s) หลังเลือกจากเมนูแล้วเมนูปิดตัว */}
<button
type="button"
aria-label="จัดการหมายเหตุนี้"
aria-haspopup="menu"
aria-expanded={menuFor?.kind === "annotation" && menuFor.id === a.id}
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "จัดการหมายเหตุนี้"}
className={`shrink-0 ${FOCUS_RING_CLS} ${
copiedId === a.id
? "text-success-ink"
: copyFailedId === a.id
? "text-error-ink"
: "text-ink-soft hover:text-ink group-hover:text-ink-soft"
}`}
onClick={(e) => {
if (menuFor?.kind === "annotation" && menuFor.id === a.id) closeRowMenu();
else openRowMenu(e.currentTarget, { kind: "annotation", id: a.id });
}}
>
{copiedId === a.id ? (
<Check className="h-4 w-4" />
) : copyFailedId === a.id ? (
<X className="h-4 w-4" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</button>
</div>
{editingNote ? (
<div className="mt-1 pl-6">
<textarea
autoFocus
rows={2}
value={noteDraft}
onChange={(e) => setNoteDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของ viewer
if (e.key === "Escape") setNoteFor(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
}}
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
className="w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => setNoteFor(null)}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
onClick={() => saveNote(a)}
>
บันทึก
</button>
</div>
</div>
) : (
note && (
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-ink-soft">
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
</p>
)
)}
</li>
);
};
return (
<div className="flex h-full flex-col">
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
<div className="shrink-0 border-b border-line p-2">
<Select
aria-label="เลือกเจ้าของหมายเหตุที่แสดง"
className="w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink-soft focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
value={userFilter}
onChange={(e) => onUserFilterChange(e.target.value)}
>
<option value="all">เจ้าของหมายเหตุ: ทุกคน</option>
<option value="mine">เฉพาะของฉัน</option>
{users.map((u) => (
<option key={u.userId} value={u.userId}>
เฉพาะ {u.userName}
</option>
))}
</Select>
</div>
{/* แท็บหลัก — tablist/tab semantics ให้ screen reader ประกาศว่าเป็นแท็บ */}
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-line">
{(
[
["bookmarks", `Bookmark (${visibleBookmarks.length})`],
["annotations", `Annotation (${annotations.length})`],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
tab === key
? "border-b-2 border-primary text-ink"
: "text-ink-soft hover:text-ink"
}`}
onClick={() => setTab(key)}
>
{label}
</button>
))}
</div>
{/* N6: ค้นหา bookmark พร้อมเงื่อนไข — เฉพาะแท็บที่คั่นหน้า
(แยกจากช่อง F14 ที่ค้นหมายเหตุ ซึ่งอยู่เหนือแท็บ) */}
{tab === "bookmarks" && (
<div className="shrink-0 space-y-1.5 border-b border-line p-2">
{/* แถวคำค้น — โครงเดียวกับ F14 ของแท็บหมายเหตุ: input เต็มกว้าง + ปุ่มเดียว
(เดิมปุ่ม "เงื่อนไข"+badge แย่งแถวเดียวกันจน input เหลือพื้นที่ข้อความ
~80px ≈ 6–7 ตัวอักษรไทย พิมพ์คำค้นจริงไม่พอ) */}
<form className="flex gap-1" onSubmit={(e) => void runBookmarkSearch(e)}>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" />
<input
value={bmQuery}
onChange={(e) => setBmQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ชื่อ หรือ โน้ต…"
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
</div>
<button
type="submit"
disabled={activeConditionCount === 0 || bmSearching}
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
{bmSearching ? "…" : "หา"}
</button>
</form>
{/* แถวสรุปเงื่อนไข — toggle แผงแบบเต็มความกว้าง (เป้าใหญ่จิ้มง่ายบนจอสัมผัส)
แสดงเงื่อนไขที่ตั้งอยู่ "เป็นข้อความ" สดตามฟอร์ม ตลอดแม้แผงหุบ/ปิดผลแล้ว —
แทน badge ตัวเลขที่เห็นแค่จำนวน / ไม่มีเงื่อนไข = บอกขอบเขตที่ตั้งได้เลย */}
<div className="flex items-center gap-1">
<button
type="button"
aria-expanded={bmShowConditions}
onClick={() => setBmShowConditions((v) => !v)}
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-ink/[0.05] ${FOCUS_RING_CLS} ${
hasBmConditionValues ? "text-primary" : "text-ink-soft"
}`}
>
<SlidersHorizontal className="h-3 w-3 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">
{hasBmConditionValues ? bmLiveSummary : "หน้า · ผู้บันทึก · วันที่"}
</span>
{bmShowConditions ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
</button>
{/* ปุ่มส่งออก JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้ — ระบบภายนอก
ดึงข้อมูลผ่าน N10 API แทน (ปุ่มเดียวที่เหลือคือ toggle เงื่อนไขค้นหา) */}
</div>
{bmShowConditions && (
// จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr
// (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input
// ไม่ตรงแนวกัน และคู่ date ในแถว inline ล้น 288px จนต้องหนีขึ้นบรรทัดบน)
<div className="space-y-1.5 text-[11px] text-ink-soft">
<div>
<span className="block pb-0.5">หน้า</span>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
<input
type="number"
min={1}
step={1}
aria-label="หน้าเริ่ม"
value={bmConditions.pageFrom}
onChange={(e) =>
setBmConditions({ ...bmConditions, pageFrom: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
placeholder="จาก"
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<span className="text-ink-faint">–</span>
<input
type="number"
min={1}
step={1}
aria-label="หน้าสุดท้าย"
value={bmConditions.pageTo}
onChange={(e) =>
setBmConditions({ ...bmConditions, pageTo: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
placeholder="ถึง"
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
</div>
</div>
<div>
<span className="block pb-0.5">ผู้บันทึก</span>
<Select
aria-label="กรองตามผู้บันทึก bookmark"
value={bmConditions.userId}
onChange={(e) =>
setBmConditions({ ...bmConditions, userId: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
className="w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
>
<option value="">ทุกคน</option>
{bookmarkAuthors.map((u) => (
<option key={u.userId} value={u.userId}>
{u.userName}
</option>
))}
</Select>
</div>
<div>
<span className="block pb-0.5">บันทึกวันที่</span>
{/* เรียง 2 แถวเต็มกว้าง (ไม่ใช่คู่แนวนอน + "–" คั่น): คอลัมน์คู่แคบ
~122px ของ sidebar ใส่ค่า "23 ก.ย. 2026" ที่ 16px (rule จอสัมผัส
กัน iOS zoom-on-focus) + icon ปฏิทินไม่พอ ปีโดนตัด (code review
2026-09-23) — aria-label กำกับช่วงเริ่ม/สุดท้ายอยู่แล้ว */}
<div className="grid gap-1">
<input
type="date"
aria-label="วันที่เริ่ม"
value={bmConditions.dateFrom}
onChange={(e) =>
setBmConditions({ ...bmConditions, dateFrom: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
<input
type="date"
aria-label="วันที่สุดท้าย"
value={bmConditions.dateTo}
onChange={(e) =>
setBmConditions({ ...bmConditions, dateTo: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className={`rounded-md px-1.5 py-1 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
// UX audit: ล้างให้สิ้นเชิง — คำค้น + เงื่อนไข + ผลที่แสดง พร้อมกัน
setBmConditions(EMPTY_BM_CONDITIONS);
setBmQuery("");
setBmResults(null);
}}
>
ล้างทั้งหมด
</button>
</div>
</div>
)}
{/* error กับ hint เป็นคำเตือนคนละเรื่อง (ฟอร์มผิด vs ผลเป็นของเงื่อนไขเก่า)
— มี error แสดงเดี่ยว ไม่ซ้อนสองข้อความพร้อมกัน */}
{bmSearchError ? (
<p className="px-1 text-[11px] text-error-ink">{bmSearchError}</p>
) : (
bmResults !== null &&
bmSearchedSig !== null &&
bmSearchedSig !== JSON.stringify([bmQuery.trim(), bmConditions]) && (
<p className="px-1 text-[11px] text-warning-ink">
เงื่อนไขเปลี่ยนแล้ว กด &quot;หา&quot; เพื่อค้นใหม่
</p>
)
)}
</div>
)}
{/* F14/D1: ค้นหาในหมายเหตุทั้งไฟล์ — อยู่ใต้แท็บเฉพาะแท็บหมายเหตุ (ค้นหาอะไร
อยู่ในแท็บนั้น สมมาตรกับช่องค้นหาของแท็บที่คั่นหน้า) ผลลัพธ์ render แทน
เนื้อหาใน list ด้านล่าง คลิกแถว = กระโดดไปตำแหน่ง + เลือก */}
{tab === "annotations" && (
<form
className="flex shrink-0 gap-1 border-b border-line p-2"
onSubmit={(e) => void runSearch(e)}
>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ค้นหาในหมายเหตุ…"
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
</div>
<button
type="submit"
disabled={!searchQuery.trim() || searching}
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
{searching ? "…" : "หา"}
</button>
</form>
)}
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
{tab === "bookmarks" ? (
bmResults !== null ? (
<div>
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold tabular-nums text-ink-soft">
ผลการค้นหา ({bmResults.length})
</p>
<button
type="button"
aria-label="ปิดผลการค้นหา"
title="กลับไปรายการที่คั่นหน้า"
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
onClick={() => setBmResults(null)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* snapshot "ค้นด้วยอะไร" ของผลที่กำลังแสดง — ผลอ่านรู้เรื่องแม้ผู้ใช้ลืม/
แชร์จอให้คนอื่นดู (แก้ฟอร์มหลังค้นบรรทัดนี้ไม่เปลี่ยน จนกด "หา" ใหม่) */}
{bmSearchedSummary && (
<p
className="truncate px-1 pb-1 text-[11px] text-ink-soft"
title={bmSearchedSummary}
>
ค้นด้วย: {bmSearchedSummary}
</p>
)}
{bmResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p>
) : (
<ul className="space-y-0.5">
{bmResults.map((b) => {
const parent = b.parentId ? bookmarkById.get(b.parentId) : undefined;
return (
<li key={b.id}>
<button
type="button"
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)}
>
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" />
<span className="min-w-0">
<span className="block truncate text-[13px] leading-snug">{b.title}</span>
<span className="block text-xs text-ink-soft">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span>
{parent && (
<span className="block truncate text-[11px] text-ink-soft">
ใน: {parent.title}
</span>
)}
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
{b.note}
</span>
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
) : bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
</p>
) : visibleBookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก
</p>
) : (
<>
{/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */}
<div className="px-1 pb-1.5">
<ViewSwitch
value={bmView}
options={[
{ key: "topic", label: "ตามหัวข้อ" },
{ key: "page", label: "ตามหน้า" },
]}
onChange={setBmView}
/>
</div>
{bmView === "page" ? (
bmRootPages.map(([pi, roots]) => {
const collapsed = !expandedPages.has(pi);
return (
<section key={pi}>
<PageGroupHeader
pageIndex={pi}
count={roots.length}
collapsed={collapsed}
onToggle={() => togglePageExpanded(pi)}
/>
{!collapsed && (
<ul className="space-y-0.5">
{roots.map((b) => {
const childCount = visibleBookmarks.filter(
(x) => x.parentId === b.id,
).length;
return (
<li key={b.id}>
<button
type="button"
className={`flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)}
>
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] leading-snug font-medium text-ink">
{b.title}
</span>
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
<span className="block text-xs text-ink-soft">
{b.userName || "ไม่ทราบชื่อ"}
</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
{b.note}
</span>
)}
</span>
{childCount > 0 && (
<span className="ml-auto shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft">
{childCount}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
</section>
);
})
) : (
<ul className="space-y-0.5">
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
{visibleBookmarks
.filter((b) => !b.parentId)
.sort(byPageOrder)
.map((b) => renderBookmarkNode(b, 1))}
</ul>
)}
</>
)
) : visibleSearchResults !== null ? (
<div>
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold tabular-nums text-ink-soft">
ผลการค้นหา ({visibleSearchResults.length})
</p>
<button
type="button"
aria-label="ปิดผลการค้นหา"
title="กลับไปรายการหมายเหตุ"
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
onClick={() => setSearchResults(null)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{visibleSearchResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p>
) : (
// reuse แถวเดิม — li มี data-ann-id ให้ effect scrollIntoView หาเจอ
<ul className="space-y-1">{visibleSearchResults.map(renderAnnotationRow)}</ul>
)}
</div>
) : annotations.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คอมเมนต์บนหน้าเอกสาร
<span className="mt-1 block text-[11px]">
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
</span>
</p>
) : (
<>
{/* สลับมุมมองแท็บนี้: ค้าง–จัดแล้ว ↔ แยกตามหน้า */}
<div className="px-1 pb-1.5">
<ViewSwitch
value={annView}
options={[
{ key: "status", label: "ตามสถานะ" },
{ key: "page", label: "ตามหน้า" },
]}
onChange={setAnnView}
/>
</div>
{annView === "page" ? (
annPages.map(([pi, items]) => {
const collapsed = !expandedPages.has(pi);
return (
<section key={pi}>
<PageGroupHeader
pageIndex={pi}
count={items.length}
collapsed={collapsed}
onToggle={() => togglePageExpanded(pi)}
/>
{!collapsed && (
<ul className="ml-4 mr-1 space-y-1">{items.map(renderAnnotationRow)}</ul>
)}
</section>
);
})
) : (
<>
{pendingItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-ink-soft">
ค้างดำเนินการ
</p>
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
</section>
)}
{resolvedItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-success-ink">
จัดการแล้ว
</p>
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
</section>
)}
</>
)}
</>
)}
</div>
{/* F19/A7: dialog render ผ่าน portal ไป document.body —
aside.sticky สร้าง stacking context ครอบ z-50 ของ dialog ไว้ ทำให้ textLayer
(z-[2] ระดับ root) ทับปุ่มจนคลิกไม่ได้ (เจอตอน verify ด้วยเบราว์เซอร์จริง) */}
{typeof document === "undefined"
? null // SSR ไม่มี document — client component ก็ถูก render ฝั่ง server สำหรับ HTML แรก
: createPortal(
<>
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
{historyFor && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
onClick={() => setHistoryFor(null)}
>
<div
role="dialog"
aria-modal="true"
aria-label="ประวัติการเปลี่ยนแปลง"
className="w-96 max-w-full rounded-lg border border-line bg-white p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-ink-soft">
{annotationPreviewText(historyFor)}
</p>
{historyError && (
<p className="mt-2 rounded bg-error/10 px-2 py-1 text-[11px] text-error-ink">
{historyError}
</p>
)}
{versions === null && !historyError && (
<p className="mt-3 text-xs text-ink-soft">กำลังโหลด…</p>
)}
{versions !== null && versions.length === 0 && (
<p className="mt-3 text-xs text-ink-soft">
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
</p>
)}
{versions !== null && versions.length > 0 && (
<ul className="mt-3 max-h-[60vh] divide-y divide-line/60 overflow-y-auto">
{versions.map((v, i) => {
// สิ่งที่เปลี่ยนของรอบนี้ = snapshot ก่อนแก้ (v.data) เทียบ state
// ถัดไป: snapshot ของรอบแก้ที่ใหม่กว่าถัดไป (i-1) หรือค่าปัจจุบัน
// ของรายการ (version ใหม่สุด) — แถว "ถูกลบ" เป็น snapshot สุดท้าย
// ก่อนลบ ไม่มี state ต่อจากนั้น จึงไม่ diff
const changes =
v.kind === "update" && historyCurrent
? describeAnnotationChange(
v.type,
v.data,
i === 0 ? historyCurrent.data : versions[i - 1].data,
)
: [];
return (
<li key={v.id} className="flex items-start gap-2 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
</span>
<span className="block text-[11px] text-ink-soft" suppressHydrationWarning>
{formatThaiDate(v.createdAt, true)} · โดย {v.userName}
</span>
{changes.map((c) => (
<span key={c} className="mt-0.5 block text-[11px] text-ink-soft">
{c}
</span>
))}
</span>
<button
type="button"
disabled={restoringId !== null}
className={`mt-0.5 shrink-0 rounded-md border border-line px-2 py-1 text-[11px] text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
onClick={() => void handleRestore(v.id)}
>
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
</button>
</li>
);
})}
</ul>
)}
<div className="mt-3 flex justify-end text-xs">
<button
type="button"
autoFocus
className={`rounded-md px-3 py-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => setHistoryFor(null)}
>
ปิด
</button>
</div>
</div>
</div>
)}
{/* เมนู ⋯ ของแถวรายการ — render ผ่าน portal เดียวกับ dialog ประวัติ (aside.sticky
สร้าง stacking context ครอบ z ไว้) / attribute data-tool-menu ให้ (1) outside-click
handler ของ PDFViewer ข้ามโหนดใน portal นี้ (2) CSS จอสัมผัส [data-tool-menu]
button ได้ min 36px อัตโนมัติ / lookup เป้าหมายจาก state สดทุก render — หาย
ไปจาก realtime แล้ว effect ด้านบนปิดเมนูให้เอง */}
{menuFor && menuPos && (menuAnn ?? menuBm) && (
<div
data-tool-menu=""
role="menu"
aria-label={menuFor.kind === "annotation" ? "จัดการหมายเหตุ" : "จัดการหัวข้อ"}
className="w-60 rounded-lg border border-line bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
// เหนือ aside overlay (z-50) และ backdrop (z-[45]) ตามเมนูของ tool
zIndex: 60,
// จอเตี้ย (มือถือแนวนอน) เมนูยาวเกินพื้นที่ใต้ปุ่ม → เลื่อนภายในได้
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
>
{menuConfirmDelete ? (
<div className="p-1">
<p className="px-1 text-xs text-ink">
ลบ{menuFor.kind === "annotation" ? "รายการ" : "หัวข้อ"}นี้?
</p>
<div className="mt-1.5 flex justify-end gap-1.5">
<button
type="button"
className={`rounded-md px-2 py-1 text-xs text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={closeRowMenu}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded-md border border-error px-2 py-1 text-xs font-medium text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`}
onClick={() => {
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
else onDeleteBookmark(menuFor.id);
closeRowMenu();
}}
>
ลบ
</button>
</div>
</div>
) : menuFor.kind === "annotation" && menuAnn ? (
<>
<MenuItem
Icon={Copy}
label="คัดลอกลิงก์"
onClick={() => {
closeRowMenu();
copyAnnotationLink(menuAnn);
}}
/>
{/* N1: โน้ตคำอธิบาย — ยกเว้น COMMENT ที่มี message ทำหน้าที่นี้แล้ว */}
{menuAnn.userId === userId && menuAnn.type !== "COMMENT" && (
<MenuItem
Icon={StickyNote}
label={annotationNote(menuAnn) ? "แก้ไขโน้ต" : "เพิ่มโน้ต"}
onClick={() => {
closeRowMenu();
startNoteEdit(menuAnn);
}}
/>
)}
{/* F19/A7: ประวัติ + กู้คืน — เฉพาะรายการของตัวเอง */}
{menuAnn.userId === userId && (
<MenuItem
Icon={Clock}
label="ดูประวัติการเปลี่ยนแปลง"
onClick={() => {
closeRowMenu();
void openHistory(menuAnn);
}}
/>
)}
{menuAnn.userId === userId && (
<>
<div className="my-1 border-t border-line/60" />
<MenuItem
danger
Icon={Trash2}
label="ลบรายการ"
onClick={() => setMenuConfirmDelete(true)}
/>
</>
)}
</>
) : menuBm ? (
<>
{/* N2: ดักหน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนี้ — ได้เฉพาะความลึกยังไม่เต็ม 3 ระดับ */}
{bookmarkDepth(menuBm, bookmarkById) < 3 && userId !== "" && (
<MenuItem
Icon={Plus}
label="เพิ่มหน้าที่ดูอยู่เข้าหัวข้อนี้"
onClick={() => {
closeRowMenu();
onAddSubBookmark(menuBm.id);
}}
/>
)}
{menuBm.userId === userId && (
<MenuItem
Icon={Pencil}
label="แก้ไขหัวข้อ"
onClick={() => {
closeRowMenu();
startBookmarkEdit(menuBm);
}}
/>
)}
{menuBm.userId === userId && (
<>
<div className="my-1 border-t border-line/60" />
<MenuItem
danger
Icon={Trash2}
label="ลบหัวข้อ"
onClick={() => setMenuConfirmDelete(true)}
/>
</>
)}
</>
) : null}
</div>
)}
</>
,
document.body
)}
</div>
);
}