From 0578b2624115ae84d1c7685a59761fd8e9e4b51f Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 21:06:47 +0100 Subject: [PATCH] feat: save button --- frontend/src/app/canvas/CanvasMenubar.tsx | 31 ++++++++- frontend/src/app/canvas/CanvasPage.tsx | 10 +++ frontend/src/app/canvas/useCanvasGraph.ts | 76 ++++++----------------- 3 files changed, 57 insertions(+), 60 deletions(-) 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 } }