diff --git a/frontend/src/app/canvas/CanvasMenubar.tsx b/frontend/src/app/canvas/CanvasMenubar.tsx
index 3e7e4dc..e27a4ae 100644
--- a/frontend/src/app/canvas/CanvasMenubar.tsx
+++ b/frontend/src/app/canvas/CanvasMenubar.tsx
@@ -14,12 +14,14 @@ 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, Undo2 } from 'lucide-react'
+import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Save, Undo2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
export type CanvasMenubarProps = {
onImport: () => void
onExport: () => void
+ onSave?: () => void
+ canSave?: boolean
undo: () => void
redo: () => void
canUndo: boolean
@@ -34,6 +36,7 @@ export type CanvasMenubarProps = {
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
+const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
@@ -43,6 +46,8 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
export function CanvasMenubar({
onImport,
onExport,
+ onSave,
+ canSave = true,
undo,
redo,
canUndo,
@@ -114,11 +119,19 @@ export function CanvasMenubar({
ev.stopPropagation()
redo()
}
+ return
+ }
+ if (matchKey(ev, SAVE_KEYS)) {
+ if (onSave && canSave) {
+ ev.preventDefault()
+ ev.stopPropagation()
+ onSave()
+ }
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
- }, [undo, redo, canUndo, canRedo])
+ }, [undo, redo, canUndo, canRedo, onSave, canSave])
return (
@@ -135,6 +148,20 @@ export function CanvasMenubar({
{projectId && (
<>
+ {onSave != null && (
+ <>
+
+
+ Save
+
+
+ ⌘S
+
+
+
+
+ >
+ )}
setIsRenamingProject(true)}
className="gap-2"
diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx
index dc3d071..532901f 100644
--- a/frontend/src/app/canvas/CanvasPage.tsx
+++ b/frontend/src/app/canvas/CanvasPage.tsx
@@ -169,6 +169,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
canUndo,
canRedo,
setStateImmediate,
+ save,
} = useCanvasGraph(projectId)
const importInputRef = useRef(null)
@@ -623,6 +624,15 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
{
+ save()
+ toast.success('Saved')
+ }
+ : undefined
+ }
+ canSave={Boolean(projectId)}
undo={undo}
redo={redo}
canUndo={canUndo}
diff --git a/frontend/src/app/canvas/useCanvasGraph.ts b/frontend/src/app/canvas/useCanvasGraph.ts
index 577a77c..dc6f593 100644
--- a/frontend/src/app/canvas/useCanvasGraph.ts
+++ b/frontend/src/app/canvas/useCanvasGraph.ts
@@ -1,78 +1,38 @@
/**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
- * with initial graph from project storage (or example) and debounced + idle-based save.
+ * with initial graph from project storage (or example). Save is explicit via save().
* Keeps CanvasPage focused on composition and layout.
*/
-import { useEffect, useMemo, useRef } from 'react'
+import { useCallback, useMemo, useRef } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
-export type UseCanvasGraphResult = ReturnType
-
-/** Debounce delay (ms) before we schedule a save. */
-const SAVE_DEBOUNCE_MS = 800
-/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
-const SAVE_IDLE_TIMEOUT_MS = 2000
+export type UseCanvasGraphResult = ReturnType & {
+ /** Persist current nodes/edges to storage. No-op when projectId is missing. */
+ save: () => void
+}
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 saveTimeoutRef = useRef | null>(null)
- const idleCallbackRef = useRef(null)
- const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
- null
- )
+ const nodesRef = useRef(nodes)
+ const edgesRef = useRef(edges)
+ nodesRef.current = nodes
+ edgesRef.current = edges
- useEffect(() => {
+ const save = useCallback(() => {
if (!projectId) return
+ saveGraphToStorage(projectId, {
+ version: PROJECT_VERSION,
+ nodes: nodesRef.current,
+ edges: edgesRef.current,
+ })
+ }, [projectId])
- const scheduleSave = () => {
- pendingSaveRef.current = { projectId, nodes, edges }
-
- const doSave = () => {
- const pending = pendingSaveRef.current
- pendingSaveRef.current = null
- if (pending && pending.projectId === projectId) {
- saveGraphToStorage(pending.projectId, {
- version: PROJECT_VERSION,
- nodes: pending.nodes,
- edges: pending.edges,
- })
- }
- }
-
- if (typeof requestIdleCallback !== 'undefined') {
- idleCallbackRef.current = requestIdleCallback(doSave, {
- timeout: SAVE_IDLE_TIMEOUT_MS,
- })
- } else {
- idleCallbackRef.current = window.setTimeout(doSave, 0) as unknown as number
- }
- }
-
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
- saveTimeoutRef.current = setTimeout(scheduleSave, SAVE_DEBOUNCE_MS)
-
- return () => {
- if (saveTimeoutRef.current) {
- clearTimeout(saveTimeoutRef.current)
- saveTimeoutRef.current = null
- }
- if (idleCallbackRef.current != null) {
- if (typeof cancelIdleCallback !== 'undefined') {
- cancelIdleCallback(idleCallbackRef.current)
- } else {
- clearTimeout(idleCallbackRef.current)
- }
- idleCallbackRef.current = null
- }
- }
- }, [projectId, nodes, edges])
-
- return result
+ return { ...result, save }
}