import { useEffect, useRef, useState } from "react"; import { COVER_ID, useBook } from "../store/useBook"; import { type Chapter, type ChapterKind, bodyNumber, chapterKind, partNumber, partRoman } from "../model/book"; import { AddPageMenu } from "./AddPageMenu"; import { ConfirmDialog } from "./ConfirmDialog"; import { Icon } from "./Icon"; import { RowMenu } from "./RowMenu"; interface Row { chapter: Chapter; index: number; num: number | null; part: number | null; indent: boolean; } interface DropTarget { kind: ChapterKind; index: number; } function formatEdited(ts: number, now: number): string { const diff = Math.max(0, now - ts); const min = 60_000; const hour = 60 * min; const day = 24 * hour; if (diff < min) return "just now"; if (diff < hour) { const m = Math.floor(diff / min); return `${m} minute${m === 1 ? "" : "s"} ago`; } if (diff < day) { const h = Math.floor(diff / hour); return `${h} hour${h === 1 ? "" : "s"} ago`; } if (diff < 2 * day) return "yesterday"; const d = Math.floor(diff / day); if (d < 7) return `${d} days ago`; return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); } export function Sidebar({ onNavigate }: { onNavigate?: () => void }) { const chapters = useBook((s) => s.book?.chapters ?? []); const activeChapterId = useBook((s) => s.activeChapterId); const setActiveChapter = useBook((s) => s.setActiveChapter); const addChapter = useBook((s) => s.addChapter); const addPage = useBook((s) => s.addPage); const addPart = useBook((s) => s.addPart); const duplicateChapter = useBook((s) => s.duplicateChapter); const setChapterNoTitle = useBook((s) => s.setChapterNoTitle); const setChapterNoMargin = useBook((s) => s.setChapterNoMargin); const deleteChapter = useBook((s) => s.deleteChapter); const moveChapter = useBook((s) => s.moveChapter); const closeBook = useBook((s) => s.closeBook); const [dragIndex, setDragIndex] = useState(null); const [dropTarget, setDropTarget] = useState(null); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null); const [menuOpenId, setMenuOpenId] = useState(null); const [now, setNow] = useState(() => Date.now()); const dropRef = useRef(null); const gesture = useRef<{ from: number; x: number; y: number; active: boolean } | null>(null); const suppressClick = useRef(false); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(timer); }, []); const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index), part: partNumber(chapters, index), indent: chapterKind(chapter) === "body" && chapters.slice(0, index).some((c) => chapterKind(c) === "part"), })); const inBody = (r: Row) => chapterKind(r.chapter) === "body" || chapterKind(r.chapter) === "part"; const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [ { kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") }, { kind: "body", label: "Chapters", rows: rows.filter(inBody) }, { kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") }, ]; const groupEnd: Record = { front: groups[0].rows.length, body: groups[0].rows.length + groups[1].rows.length, part: groups[0].rows.length + groups[1].rows.length, back: rows.length, }; const dragging = dragIndex !== null; const setDrop = (t: DropTarget | null) => { dropRef.current = t; setDropTarget(t); }; const computeDrop = (x: number, y: number): DropTarget | null => { const el = document.elementFromPoint(x, y) as HTMLElement | null; const row = el?.closest(".chapter"); if (row?.dataset.idx) { const rect = row.getBoundingClientRect(); const idx = Number(row.dataset.idx); const after = y > rect.top + rect.height / 2; return { kind: row.dataset.kind as ChapterKind, index: after ? idx + 1 : idx }; } const section = el?.closest(".nav-section"); if (section?.dataset.kind) { const kind = section.dataset.kind as ChapterKind; return { kind, index: groupEnd[kind] }; } return null; }; const onPointerMove = (e: PointerEvent) => { const g = gesture.current; if (!g) return; if (!g.active) { if (Math.abs(e.clientX - g.x) < 4 && Math.abs(e.clientY - g.y) < 4) return; g.active = true; setDragIndex(g.from); } const t = computeDrop(e.clientX, e.clientY); if (t) setDrop(t); }; const onPointerUp = () => { window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); const g = gesture.current; gesture.current = null; if (g?.active) { suppressClick.current = true; const t = dropRef.current; if (t) moveChapter(g.from, t.index, t.kind); } setDragIndex(null); setDrop(null); }; const onRowPointerDown = (e: React.PointerEvent, index: number) => { if (e.button !== 0 || chapters[index]?.id === menuOpenId || (e.target as HTMLElement).closest(".row-menu-btn")) return; suppressClick.current = false; gesture.current = { from: index, x: e.clientX, y: e.clientY, active: false }; window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); }; const onRowClick = (id: string) => { if (suppressClick.current) { suppressClick.current = false; return; } setActiveChapter(id); onNavigate?.(); }; const focusRow = (id: string) => requestAnimationFrame(() => { document.querySelector(`.chapter[data-id="${CSS.escape(id)}"]`)?.focus(); }); const onRowKeyDown = (e: React.KeyboardEvent, index: number, id: string) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setActiveChapter(id); onNavigate?.(); } else if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") { e.preventDefault(); const target = e.key === "Home" ? chapters[0] : e.key === "End" ? chapters[chapters.length - 1] : chapters[index + (e.key === "ArrowDown" ? 1 : -1)]; if (target) { setActiveChapter(target.id); focusRow(target.id); } } }; const tabStopId = chapters.some((c) => c.id === activeChapterId) ? activeChapterId : chapters[0]?.id; return ( ); }