diff --git a/frontend/src/app/canvas/CanvasMenubar.tsx b/frontend/src/app/canvas/CanvasMenubar.tsx index e27a4ae..81cca70 100644 --- a/frontend/src/app/canvas/CanvasMenubar.tsx +++ b/frontend/src/app/canvas/CanvasMenubar.tsx @@ -14,14 +14,18 @@ import { } from '@/components/ui/menubar' import { Kbd, KbdGroup } from '@/components/ui/kbd' import { usePlatform } from '@/app/kosmos/KosmosContext' -import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Save, Undo2 } from 'lucide-react' +import { ArrowLeft, CheckCircle2, CircleDot, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Loader2, Pencil, Redo2, Save, Undo2 } from 'lucide-react' import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import type { SaveStatus } from '@/app/canvas/useCanvasGraph' export type CanvasMenubarProps = { onImport: () => void onExport: () => void onSave?: () => void canSave?: boolean + /** Shown next to project title: unsaved | saving | saved */ + saveStatus?: SaveStatus undo: () => void redo: () => void canUndo: boolean @@ -48,6 +52,7 @@ export function CanvasMenubar({ onExport, onSave, canSave = true, + saveStatus = 'saved', undo, redo, canUndo, @@ -68,8 +73,32 @@ export function CanvasMenubar({ const [isRenamingProject, setIsRenamingProject] = useState(false) const [renameValue, setRenameValue] = useState('') + const [showSavedBriefly, setShowSavedBriefly] = useState(false) const renameInputRef = useRef(null) const ignoreNextBlurRef = useRef(false) + const savedBrieflyTimerRef = useRef | null>(null) + const prevSaveStatusRef = useRef(saveStatus) + + useEffect(() => { + if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') { + setShowSavedBriefly(true) + if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current) + savedBrieflyTimerRef.current = setTimeout(() => { + savedBrieflyTimerRef.current = null + setShowSavedBriefly(false) + }, 2500) + } + prevSaveStatusRef.current = saveStatus + }, [saveStatus]) + + useEffect(() => { + return () => { + if (savedBrieflyTimerRef.current) { + clearTimeout(savedBrieflyTimerRef.current) + savedBrieflyTimerRef.current = null + } + } + }, []) useEffect(() => { if (isRenamingProject) { @@ -256,7 +285,7 @@ export function CanvasMenubar({ {projectId && ( -
+
{isRenamingProject ? ( ) : ( - - {projectName ?? 'Untitled'} - + <> + + {projectName ?? 'Untitled'} + + + + + + {saveStatus === 'saving' && ( + + )} + {saveStatus === 'unsaved' && ( + + )} + {(saveStatus === 'saved' || showSavedBriefly) && ( + + )} + + + + {saveStatus === 'saving' + ? 'Saving…' + : saveStatus === 'unsaved' + ? 'Unsaved changes' + : 'All changes saved'} + + + + )}
)} diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index b6429c6..cadc500 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -171,6 +171,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { canRedo, setStateImmediate, save, + saveStatus, } = useCanvasGraph(projectId) const importInputRef = useRef(null) @@ -628,6 +629,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { : undefined } canSave={Boolean(projectId)} + saveStatus={saveStatus} undo={undo} redo={redo} canUndo={canUndo} diff --git a/frontend/src/app/canvas/useCanvasGraph.ts b/frontend/src/app/canvas/useCanvasGraph.ts index dc6f593..ef4a437 100644 --- a/frontend/src/app/canvas/useCanvasGraph.ts +++ b/frontend/src/app/canvas/useCanvasGraph.ts @@ -1,38 +1,89 @@ /** * Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory - * with initial graph from project storage (or example). Save is explicit via save(). - * Keeps CanvasPage focused on composition and layout. + * with initial graph from project storage (or example). Save is explicit via save(); + * autosave runs on an interval (~10s) and saves when the graph has changed. */ -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory' import { getInitialGraph } from '@/app/canvas/canvasGraphUtils' +import { useCanvasStore } from '@/app/canvas/canvasStore' import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' +export type SaveStatus = 'saved' | 'unsaved' | 'saving' + export type UseCanvasGraphResult = ReturnType & { /** Persist current nodes/edges to storage. No-op when projectId is missing. */ save: () => void + /** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */ + saveStatus: SaveStatus } +const AUTOSAVE_INTERVAL_MS = 10_000 + export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult { const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId]) const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges) const { nodes, edges } = result + const storeNodes = useCanvasStore((s) => s.graph.nodes) + const storeEdges = useCanvasStore((s) => s.graph.edges) + const nodesRef = useRef(nodes) const edgesRef = useRef(edges) nodesRef.current = nodes edgesRef.current = edges - const save = useCallback(() => { + const [isSaving, setIsSaving] = useState(false) + const lastSavedSnapshotRef = useRef( + JSON.stringify({ nodes: initialGraph.nodes, edges: initialGraph.edges }) + ) + + const serializedFromStore = useMemo( + () => JSON.stringify({ nodes: storeNodes, edges: storeEdges }), + [storeNodes, storeEdges] + ) + const isDirty = serializedFromStore !== lastSavedSnapshotRef.current + const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved' + + const performSave = useCallback(() => { if (!projectId) return + const current = JSON.stringify({ + nodes: nodesRef.current, + edges: edgesRef.current, + }) + if (current === lastSavedSnapshotRef.current) return + setIsSaving(true) saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes: nodesRef.current, edges: edgesRef.current, }) + lastSavedSnapshotRef.current = current + setIsSaving(false) }, [projectId]) - return { ...result, save } + const save = useCallback(() => { + if (!projectId) return + setIsSaving(true) + saveGraphToStorage(projectId, { + version: PROJECT_VERSION, + nodes: nodesRef.current, + edges: edgesRef.current, + }) + lastSavedSnapshotRef.current = JSON.stringify({ + nodes: nodesRef.current, + edges: edgesRef.current, + }) + setIsSaving(false) + }, [projectId]) + + useEffect(() => { + if (!projectId) return + const id = setInterval(performSave, AUTOSAVE_INTERVAL_MS) + return () => clearInterval(id) + }, [projectId, performSave]) + + return { ...result, save, saveStatus } }