feat: save button

This commit is contained in:
2026-03-12 21:06:47 +01:00
parent 029bab8917
commit 0578b26241
3 changed files with 57 additions and 60 deletions

View File

@@ -14,12 +14,14 @@ 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, Undo2 } from 'lucide-react' import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Save, Undo2 } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
export type CanvasMenubarProps = { export type CanvasMenubarProps = {
onImport: () => void onImport: () => void
onExport: () => void onExport: () => void
onSave?: () => void
canSave?: boolean
undo: () => void undo: () => void
redo: () => void redo: () => void
canUndo: boolean canUndo: boolean
@@ -34,6 +36,7 @@ export type CanvasMenubarProps = {
const UNDO_KEYS = { key: 'z', shiftKey: false } const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true } const REDO_KEYS = { key: 'z', shiftKey: true }
const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) { function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey const mod = ev.ctrlKey || ev.metaKey
@@ -43,6 +46,8 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
export function CanvasMenubar({ export function CanvasMenubar({
onImport, onImport,
onExport, onExport,
onSave,
canSave = true,
undo, undo,
redo, redo,
canUndo, canUndo,
@@ -114,11 +119,19 @@ export function CanvasMenubar({
ev.stopPropagation() ev.stopPropagation()
redo() redo()
} }
return
}
if (matchKey(ev, SAVE_KEYS)) {
if (onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
onSave()
}
} }
} }
window.addEventListener('keydown', onKeyDown, true) window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true)
}, [undo, redo, canUndo, canRedo]) }, [undo, redo, canUndo, canRedo, onSave, canSave])
return ( return (
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background"> <div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
@@ -135,6 +148,20 @@ export function CanvasMenubar({
<MenubarContent> <MenubarContent>
{projectId && ( {projectId && (
<> <>
{onSave != null && (
<>
<MenubarItem onClick={onSave} disabled={!canSave} className="gap-2">
<Save className="h-4 w-4" />
Save
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>S</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarSeparator />
</>
)}
<MenubarItem <MenubarItem
onClick={() => setIsRenamingProject(true)} onClick={() => setIsRenamingProject(true)}
className="gap-2" className="gap-2"

View File

@@ -169,6 +169,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
canUndo, canUndo,
canRedo, canRedo,
setStateImmediate, setStateImmediate,
save,
} = useCanvasGraph(projectId) } = useCanvasGraph(projectId)
const importInputRef = useRef<HTMLInputElement | null>(null) const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -623,6 +624,15 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<CanvasMenubar <CanvasMenubar
onImport={handleImportProject} onImport={handleImportProject}
onExport={handleExportProject} onExport={handleExportProject}
onSave={
projectId
? () => {
save()
toast.success('Saved')
}
: undefined
}
canSave={Boolean(projectId)}
undo={undo} undo={undo}
redo={redo} redo={redo}
canUndo={canUndo} canUndo={canUndo}

View File

@@ -1,78 +1,38 @@
/** /**
* 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) 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. * 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 { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils' import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
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 UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
/** Persist current nodes/edges to storage. No-op when projectId is missing. */
/** Debounce delay (ms) before we schedule a save. */ save: () => void
const SAVE_DEBOUNCE_MS = 800 }
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
const SAVE_IDLE_TIMEOUT_MS = 2000
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 saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const nodesRef = useRef(nodes)
const idleCallbackRef = useRef<number | null>(null) const edgesRef = useRef(edges)
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>( nodesRef.current = nodes
null edgesRef.current = edges
)
useEffect(() => { const save = useCallback(() => {
if (!projectId) return if (!projectId) return
saveGraphToStorage(projectId, {
version: PROJECT_VERSION,
nodes: nodesRef.current,
edges: edgesRef.current,
})
}, [projectId])
const scheduleSave = () => { return { ...result, save }
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
} }