diff --git a/electron/board-store.ts b/electron/board-store.ts index 94ecea0..2e3b503 100644 --- a/electron/board-store.ts +++ b/electron/board-store.ts @@ -161,6 +161,7 @@ export class BoardStore { result.push({ id: board.id, title: board.title, + sessionCount: board.sessionCount, kind: board.kind, updatedAt: board.updatedAt, ...(board.archivedAt ? { archivedAt: board.archivedAt } : {}), diff --git a/src/App.tsx b/src/App.tsx index 5bc8482..a7bf99e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1630,18 +1630,36 @@ export default function App() { boardId: string, sessionId: string, ): Promise { - if (locked()) return; + if (locked()) throw new Error("正在切换画布"); if (boardRef.current?.id !== boardId) await switchBoard(boardId); const current = boardRef.current; - if (current?.id !== boardId) return; + if (current?.id !== boardId) throw new Error("无法打开画布"); const target = current.notes.find((note) => note.id === sessionId); - if (!target) return; + if (!target) throw new Error("对话不存在"); openDrawer(sessionId); const at = target.attachedTo; - if (at) setSelection(at); - if (at?.kind === "node") { + const anchorExists = + at?.kind === "node" + ? current.nodes.some((n) => n.id === at.id) + : at?.kind === "relation" + ? current.relations.some((r) => r.id === at.id) + : false; + if (at && anchorExists) setSelection(at); + if (at && !anchorExists) { + setSelection(null); + setTimeout(() => flow.fitView({ padding: 0.16, duration: 400 }), 80); + showToast("锚点卡已不在画布上"); + } + if (at?.kind === "node" && anchorExists) { // Let React Flow render the selected board before locating its card. setTimeout(() => frameFamily(at.id, true), 80); + } else if (at?.kind === "relation" && anchorExists) { + const relation = current.relations.find((r) => r.id === at.id)!; + setTimeout(() => flow.fitView({ + nodes: [{ id: relation.source }, { id: relation.target }], + padding: 0.2, + duration: 320, + }), 80); } } async function createBoard(walkthrough = false) { @@ -3017,7 +3035,7 @@ export default function App() { sessions={listSessions(board)} currentSessionId={drawer?.id} onOpenSession={(boardId, sessionId) => - void openSession(boardId, sessionId) + openSession(boardId, sessionId) } onNewSession={() => addNote(undefined, { fresh: true })} /> diff --git a/src/api.ts b/src/api.ts index 9b2f7d7..78421c8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -39,6 +39,7 @@ const preview: AtlasAPI = { return read().map((b) => ({ id: b.id, title: b.title, + sessionCount: b.sessionCount, kind: b.kind, updatedAt: b.updatedAt, ...(b.archivedAt ? { archivedAt: b.archivedAt } : {}), diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index e8314c9..c4ff0f6 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -9,6 +9,7 @@ import { createPortal } from "react-dom"; import { Archive, ChevronRight, + ChevronDown, Compass, LoaderCircle, MessageSquare, @@ -22,12 +23,16 @@ import type { BoardSummary } from "../shared/types"; import { api } from "../api"; import { listSessions, + visibleSessions, + filterSessions, sessionClock, type SessionEntry, } from "../model/sessions"; import { MOD } from "../model/shortcuts"; import { tip } from "./Tooltip"; +const SESSION_VIEW_KEY = "studyboard.sidebar.sessions.v1"; + export const SIDEBAR_DEFAULT_WIDTH = 256; export const SIDEBAR_MIN_WIDTH = 200; export const SIDEBAR_MAX_WIDTH = 400; @@ -52,7 +57,7 @@ type WorkspaceSidebarProps = { onSettings: () => void; sessions: SessionEntry[]; currentSessionId?: string; - onOpenSession: (boardId: string, sessionId: string) => void; + onOpenSession: (boardId: string, sessionId: string) => Promise; onNewSession: () => void; }; @@ -65,7 +70,7 @@ type BoardGroupProps = { currentSessionId?: string; onPick: () => void; onContextMenu: (event: React.MouseEvent) => void; - onOpenSession: (sessionId: string) => void; + onOpenSession: (sessionId: string) => Promise; onNewSession: () => void; }; @@ -86,20 +91,79 @@ function BoardGroup({ onOpenSession, onNewSession, }: BoardGroupProps): ReactElement { - const [expanded, setExpanded] = useState(false); + const [view, setView] = useState(() => { + try { + return ( + JSON.parse(localStorage.getItem(SESSION_VIEW_KEY) ?? "{}")[ + board.id + ] ?? { open: false, all: false } + ); + } catch { + return { open: false, all: false }; + } + }); + const expanded = view.open === true; + function updateView(patch: { open?: boolean; all?: boolean }): void { + const next = { ...view, ...patch }; + setView(next); + try { + const saved = JSON.parse(localStorage.getItem(SESSION_VIEW_KEY) ?? "{}"); + localStorage.setItem( + SESSION_VIEW_KEY, + JSON.stringify({ ...saved, [board.id]: next }), + ); + } catch { + /* Storage can be unavailable. */ + } + } + const [query, setQuery] = useState(""); + const [opening, setOpening] = useState(null); + const [openError, setOpenError] = useState(null); + const activeRef = useRef(null); + const cache = useRef<{ + stamp: string; + retry: number; + entries: SessionEntry[]; + } | null>(null); + useEffect(() => { + if (expanded && currentSessionId && current) + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [expanded, currentSessionId, current]); + async function open(id: string): Promise { + if (opening || switchingId) return; + setOpening(id); + setOpenError(null); + try { + await onOpenSession(id); + updateView({ open: true }); + } catch { + setOpenError(id); + } finally { + setOpening(null); + } + } const [savedSessions, setSavedSessions] = useState({ status: "loading", }); const [retry, setRetry] = useState(0); useEffect(() => { if (current || !expanded || collapsed) return; + if ( + cache.current?.stamp === board.updatedAt && + cache.current.retry === retry + ) { + setSavedSessions({ status: "ready", entries: cache.current.entries }); + return; + } let cancelled = false; setSavedSessions({ status: "loading" }); void api.loadBoard(board.id).then( (value) => { if (cancelled) return; if (value) { - setSavedSessions({ status: "ready", entries: listSessions(value) }); + const entries = listSessions(value); + cache.current = { stamp: board.updatedAt, retry, entries }; + setSavedSessions({ status: "ready", entries }); } else { setSavedSessions({ status: "error", message: "画布不存在" }); } @@ -113,55 +177,128 @@ function BoardGroup({ cancelled = true; }; }, [board.id, board.updatedAt, current, expanded, collapsed, retry]); + useEffect(() => { + if (current) + cache.current = { stamp: board.updatedAt, retry, entries: sessions }; + }, [current, board.updatedAt, retry, sessions]); const sessionState: SessionLoadState = current ? { status: "ready", entries: sessions } : savedSessions; - const disabled = switchingId !== null; + const disabled = switchingId !== null || opening !== null; const activeSessionId = current ? currentSessionId : undefined; + const count = current + ? sessions.length + : sessionState.status === "ready" + ? sessionState.entries.length + : board.sessionCount; + const filtered = + sessionState.status === "ready" + ? filterSessions(sessionState.entries, query) + : []; + const { rows, hidden } = visibleSessions( + filtered, + activeSessionId, + view.all || !!query.trim(), + ); + const disclosureLabel = `${expanded ? "收起" : "展开"}对话(${count ?? "未知"})`; let sessionItems: ReactNode; if (sessionState.status === "error") { sessionItems = (
  • - {sessionState.message}。 + 无法读取对话 ·
  • ); } else if (sessionState.status === "loading") { sessionItems = ( -
  • - 正在读取对话… +
  • +
    正在读取对话…
    + {Array.from({ length: Math.min(count ?? 3, 3) }, (_, i) => ( + + ))}
  • ); } else if (!sessionState.entries.length) { sessionItems =
  • 还没有对话
  • ; } else { - sessionItems = sessionState.entries.map((session) => ( -
  • - -
  • - )); + sessionItems = rows.length ? ( + rows.map((session) => ( +
  • + + {openError === session.id && ( +
    + 无法打开 ·{" "} + +
    + )} +
  • + )) + ) : ( +
  • 没有匹配的对话
  • + ); } return ( -
  • -
    +
  • { + if ( + e.target instanceof HTMLInputElement || + (e.target as HTMLElement).closest(".workspace-sessions") + ) + return; + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + e.stopPropagation(); + updateView({ open: e.key === "ArrowRight" }); + } + }} + > +
    { + if (!(e.target as HTMLElement).closest("button")) onPick(); + }} + className={`workspace-board-row ${current ? "is-current" : ""} ${count ? "has-sessions" : ""}`} + > {!collapsed && ( )} + {!collapsed && !!count && ( + + )}
    {!collapsed && (