feat: save button
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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, {
|
||||||
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,
|
version: PROJECT_VERSION,
|
||||||
nodes: pending.nodes,
|
nodes: nodesRef.current,
|
||||||
edges: pending.edges,
|
edges: edgesRef.current,
|
||||||
})
|
})
|
||||||
}
|
}, [projectId])
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
return { ...result, save }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user