import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowUpRight, BookOpenText, ChevronLeft, ChevronRight, Crosshair, FolderKanban, GripVertical, Images, LocateFixed, LoaderCircle, Palette, Plus, Search, Settings2, X, } from "lucide-react"; import { Link } from "react-router"; import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; import { getProject, getProjectUnit, type ProjectDetail, type ProjectUnit } from "@/services/api/projects"; export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-infinite-canvas-project-chapter"; export type CanvasProjectChapterPayload = Pick & { projectId: string; sourceText?: string }; const CHAPTER_ROW_HEIGHT = 36; type CanvasProjectSidebarProps = { projectId: string; detail?: ProjectDetail; onAddChapter: (chapter: CanvasProjectChapterPayload) => void | Promise; onLocateStyle: () => void; onOpenAssets: () => void; }; export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocateStyle, onOpenAssets }: CanvasProjectSidebarProps) { const [collapsed, setCollapsed] = useState(false); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(""); const [addingId, setAddingId] = useState(""); const [pendingScrollId, setPendingScrollId] = useState(""); const scrollRef = useRef(null); const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase("zh-CN")); const projectQuery = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId) && !detail }); const projectDetail = detail || projectQuery.data; const style = resolveCanvasStylePreset(projectDetail?.project.stylePresetId); const mediaAssetCount = projectDetail?.assets.filter((asset) => asset.mediaType === "image" || asset.mediaType === "video").length || 0; const orderedUnits = useMemo(() => projectDetail?.units.slice().sort((left, right) => left.position - right.position) || [], [projectDetail?.units]); const visibleUnits = useMemo(() => { if (!deferredQuery) return orderedUnits.map((unit, index) => ({ unit, index })); const numericQuery = /^\d+$/.test(deferredQuery) ? deferredQuery.replace(/^0+/, "") || "0" : ""; return orderedUnits.reduce>((result, unit, index) => { const chapterNumber = String(index + 1); const matchesNumber = numericQuery && chapterNumber.startsWith(numericQuery); const matchesTitle = unit.title.toLocaleLowerCase("zh-CN").includes(deferredQuery); if (matchesNumber || matchesTitle) result.push({ unit, index }); return result; }, []); }, [deferredQuery, orderedUnits]); const selectedUnit = orderedUnits.find((unit) => unit.id === selectedId); const selectedUnitQuery = useQuery({ queryKey: ["project-unit", projectId, selectedId], queryFn: () => getProjectUnit(projectId, selectedId), enabled: Boolean(projectId && selectedId), }); const chapterVirtualizer = useVirtualizer({ count: visibleUnits.length, getScrollElement: () => scrollRef.current, estimateSize: () => CHAPTER_ROW_HEIGHT, getItemKey: (index) => visibleUnits[index]?.unit.id || index, initialOffset: () => readStoredScroll(`canvas-project-chapters:${projectId}`), onChange: (instance, scrolling) => { if (!scrolling && instance.scrollOffset !== null) sessionStorage.setItem(`canvas-project-chapters:${projectId}`, String(instance.scrollOffset)); }, overscan: 12, }); useEffect(() => { if (!pendingScrollId) return; const index = visibleUnits.findIndex(({ unit }) => unit.id === pendingScrollId); if (index < 0) return; chapterVirtualizer.scrollToIndex(index, { align: "center" }); setPendingScrollId(""); }, [chapterVirtualizer, pendingScrollId, visibleUnits]); useEffect(() => { if (!selectedId) return; const closePreview = (event: KeyboardEvent) => { if (event.key === "Escape") setSelectedId(""); }; document.addEventListener("keydown", closePreview); return () => document.removeEventListener("keydown", closePreview); }, [selectedId]); const revealSelectedChapter = () => { if (!selectedId) return; setQuery(""); setPendingScrollId(selectedId); }; const selectFirstSearchResult = () => { const first = visibleUnits[0]; if (!first) return; setSelectedId(first.unit.id); chapterVirtualizer.scrollToIndex(0, { align: "center" }); }; const insertChapter = async (unit: ProjectUnit, sourceText?: string) => { if (addingId) return; setAddingId(unit.id); try { await onAddChapter({ id: unit.id, projectId, title: unit.title, position: unit.position, sourceText }); } finally { setAddingId(""); } }; if (collapsed) { return ( ); } return ( ); } function ChapterPreview({ projectId, unit, chapterNumber, loading, loadError, adding, onAdd, onClose }: { projectId: string; unit: ProjectUnit; chapterNumber: number; loading: boolean; loadError: boolean; adding: boolean; onAdd: () => void | Promise; onClose: () => void }) { const text = htmlToPlainText(unit.sourceText); return (
第 {chapterNumber.toLocaleString("zh-CN")} 章

{unit.title}

{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}

{!loading && !loadError ?
{text.length.toLocaleString("zh-CN")} 字
: null}
); } function htmlToPlainText(value: string) { if (!value) return ""; const documentNode = new DOMParser().parseFromString(value, "text/html"); return (documentNode.body.textContent || "").replace(/\u00a0/g, " ").trim(); } function readStoredScroll(key: string) { const value = Number(sessionStorage.getItem(key) || 0); return Number.isFinite(value) && value > 0 ? value : 0; }