feat: autosave

This commit is contained in:
2026-03-14 10:33:03 +01:00
parent 78f5a51aa8
commit a1ad4d52cf
3 changed files with 128 additions and 10 deletions

View File

@@ -14,14 +14,18 @@ import {
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd' import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { usePlatform } from '@/app/kosmos/KosmosContext' 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 { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
export type CanvasMenubarProps = { export type CanvasMenubarProps = {
onImport: () => void onImport: () => void
onExport: () => void onExport: () => void
onSave?: () => void onSave?: () => void
canSave?: boolean canSave?: boolean
/** Shown next to project title: unsaved | saving | saved */
saveStatus?: SaveStatus
undo: () => void undo: () => void
redo: () => void redo: () => void
canUndo: boolean canUndo: boolean
@@ -48,6 +52,7 @@ export function CanvasMenubar({
onExport, onExport,
onSave, onSave,
canSave = true, canSave = true,
saveStatus = 'saved',
undo, undo,
redo, redo,
canUndo, canUndo,
@@ -68,8 +73,32 @@ export function CanvasMenubar({
const [isRenamingProject, setIsRenamingProject] = useState(false) const [isRenamingProject, setIsRenamingProject] = useState(false)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const renameInputRef = useRef<HTMLInputElement>(null) const renameInputRef = useRef<HTMLInputElement>(null)
const ignoreNextBlurRef = useRef(false) const ignoreNextBlurRef = useRef(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<SaveStatus>(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(() => { useEffect(() => {
if (isRenamingProject) { if (isRenamingProject) {
@@ -256,7 +285,7 @@ export function CanvasMenubar({
</MenubarMenu> </MenubarMenu>
</Menubar> </Menubar>
{projectId && ( {projectId && (
<div className="absolute left-1/2 -translate-x-1/2 flex justify-center max-w-[40%] min-w-[120px]"> <div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
{isRenamingProject ? ( {isRenamingProject ? (
<Input <Input
ref={renameInputRef} ref={renameInputRef}
@@ -277,9 +306,45 @@ export function CanvasMenubar({
aria-label="Project name" aria-label="Project name"
/> />
) : ( ) : (
<>
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif"> <span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
{projectName ?? 'Untitled'} {projectName ?? 'Untitled'}
</span> </span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 flex items-center text-muted-foreground cursor-default"
aria-live="polite"
aria-label={
saveStatus === 'saving'
? 'Saving'
: saveStatus === 'unsaved'
? 'Unsaved changes'
: 'All changes saved'
}
>
{saveStatus === 'saving' && (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
)}
{saveStatus === 'unsaved' && (
<CircleDot className="size-3.5" aria-hidden />
)}
{(saveStatus === 'saved' || showSavedBriefly) && (
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{saveStatus === 'saving'
? 'Saving…'
: saveStatus === 'unsaved'
? 'Unsaved changes'
: 'All changes saved'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)} )}
</div> </div>
)} )}

View File

@@ -171,6 +171,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
canRedo, canRedo,
setStateImmediate, setStateImmediate,
save, save,
saveStatus,
} = useCanvasGraph(projectId) } = useCanvasGraph(projectId)
const importInputRef = useRef<HTMLInputElement | null>(null) const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -628,6 +629,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
: undefined : undefined
} }
canSave={Boolean(projectId)} canSave={Boolean(projectId)}
saveStatus={saveStatus}
undo={undo} undo={undo}
redo={redo} redo={redo}
canUndo={canUndo} canUndo={canUndo}

View File

@@ -1,38 +1,89 @@
/** /**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory * Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from project storage (or example). Save is explicit via save(). * with initial graph from project storage (or example). Save is explicit via save();
* Keeps CanvasPage focused on composition and layout. * 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 { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils' import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { useCanvasStore } from '@/app/canvas/canvasStore'
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage' import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & { export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
/** Persist current nodes/edges to storage. No-op when projectId is missing. */ /** Persist current nodes/edges to storage. No-op when projectId is missing. */
save: () => void 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 { export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId]) const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges) const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result const { nodes, edges } = result
const storeNodes = useCanvasStore((s) => s.graph.nodes)
const storeEdges = useCanvasStore((s) => s.graph.edges)
const nodesRef = useRef(nodes) const nodesRef = useRef(nodes)
const edgesRef = useRef(edges) const edgesRef = useRef(edges)
nodesRef.current = nodes nodesRef.current = nodes
edgesRef.current = edges 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 if (!projectId) return
const current = JSON.stringify({
nodes: nodesRef.current,
edges: edgesRef.current,
})
if (current === lastSavedSnapshotRef.current) return
setIsSaving(true)
saveGraphToStorage(projectId, { saveGraphToStorage(projectId, {
version: PROJECT_VERSION, version: PROJECT_VERSION,
nodes: nodesRef.current, nodes: nodesRef.current,
edges: edgesRef.current, edges: edgesRef.current,
}) })
lastSavedSnapshotRef.current = current
setIsSaving(false)
}, [projectId]) }, [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 }
} }