feat: autosave
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user