add redo and undo

This commit is contained in:
2026-03-08 21:15:40 +01:00
parent 53106cd051
commit fe4ac78c72
4 changed files with 364 additions and 24 deletions

View File

@@ -6,14 +6,14 @@ import {
Background,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
applyNodeChanges,
applyEdgeChanges,
type Node,
type Edge,
type Connection,
type ColorMode,
type NodeChange,
type EdgeChange,
} from '@xyflow/react'
import ConfigNode from './components/graph/ConfigNode'
import FunctionNode from './components/graph/FunctionNode'
@@ -22,6 +22,7 @@ import VariableNode from './components/graph/VariableNode'
import { AnimatedEdge } from './components/graph/AnimatedEdge'
import FlowContext from './lib/flowContext'
import { useTheme } from './lib/themeContext'
import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory'
import {
ContextMenu,
ContextMenuContent,
@@ -33,6 +34,7 @@ import {
ContextMenuTrigger,
ContextMenuGroup
} from "@/components/ui/context-menu"
import { AppMenubar } from '@/components/AppMenubar'
import { Button } from '@/components/ui/button'
import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
@@ -64,10 +66,27 @@ const initialEdges: Edge[] = [
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
]
const PROJECT_FILE_EXT = '.zui.json'
export default function App() {
const { theme, toggleTheme } = useTheme()
const [nodes, setNodes, onNodesChangeBase] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const {
nodes,
edges,
setNodes,
setEdges,
setNodesSilent,
applyGraph,
saveForDragEnd,
commitDragEnd,
undo,
redo,
canUndo,
canRedo,
setStateImmediate,
} = useGraphStateWithHistory(initialNodes, initialEdges)
const importInputRef = useRef<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<any | null>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
@@ -98,12 +117,12 @@ export default function App() {
rafRef.current = null
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
if (toApply.length > 0) {
setNodes((nds) => applyNodeChanges(toApply, nds))
setNodesSilent((nds) => applyNodeChanges(toApply, nds))
}
})
}
},
[setNodes]
[setNodesSilent]
)
const nodeTypes = React.useMemo(
@@ -115,6 +134,14 @@ export default function App() {
const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), [])
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (changes.length === 0) return
setEdges((eds) => applyEdgeChanges(changes, eds))
},
[setEdges]
)
const onConnect = React.useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
@@ -155,6 +182,50 @@ export default function App() {
setRfInstance(instance)
}, [])
const onNodeDragStart = useCallback(() => {
saveForDragEnd()
}, [saveForDragEnd])
const onNodeDragStop = useCallback(() => {
commitDragEnd()
}, [commitDragEnd])
const handleExportProject = useCallback(() => {
const state = { nodes, edges }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `project${PROJECT_FILE_EXT}`
a.click()
URL.revokeObjectURL(url)
}, [nodes, edges])
const handleImportProject = useCallback(() => {
importInputRef.current?.click()
}, [])
const onImportFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = reader.result as string
const state = JSON.parse(text) as { nodes?: Node[]; edges?: Edge[] }
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) return
setStateImmediate({ nodes: state.nodes, edges: state.edges })
} catch {
// Invalid JSON
}
}
reader.readAsText(file)
},
[setStateImmediate]
)
const flowContextValue = useMemo(
() => ({
nodes,
@@ -270,35 +341,58 @@ export default function App() {
}
}, [getMenuPosition, setNodes])
const deleteNode = React.useCallback((id: string | undefined) => {
if (!id) return
setNodes((nds) => nds.filter((n) => n.id !== id))
setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id))
setContextTarget(null)
}, [setNodes, setEdges])
const deleteNode = React.useCallback(
(id: string | undefined) => {
if (!id) return
applyGraph(({ nodes: nds, edges: eds }) => ({
nodes: nds.filter((n) => n.id !== id),
edges: eds.filter((e) => e.source !== id && e.target !== id),
}))
setContextTarget(null)
},
[applyGraph]
)
return (
<div
className="reactflow-wrapper"
className="reactflow-wrapper flex flex-col h-full"
ref={wrapperRef}
onContextMenuCapture={onContextMenuCapture}
onContextMenu={onCanvasContextMenu}
style={{ position: 'relative' }}
>
<Button
variant="outline"
size="icon"
className="absolute top-3 right-3 z-10 nodrag nopan"
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,.zui.json,application/json"
className="hidden"
onChange={onImportFileChange}
aria-hidden
/>
<AppMenubar
onImport={handleImportProject}
onExport={handleExportProject}
undo={undo}
redo={redo}
canUndo={canUndo}
canRedo={canRedo}
/>
<div className="flex-1 min-h-0 relative flex flex-col">
<Button
variant="outline"
size="icon"
className="absolute top-3 right-3 z-10 nodrag nopan"
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<div className="flex-1 min-h-0 flex flex-col">
<FlowContext.Provider value={flowContextValue}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div style={{ width: '100%', height: '100%' }}>
<div className="flex-1 min-h-0 w-full">
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<ReactFlow
nodes={nodes}
@@ -308,6 +402,8 @@ export default function App() {
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
@@ -361,6 +457,8 @@ export default function App() {
</ContextMenuContent>
</ContextMenu>
</FlowContext.Provider>
</div>
</div>
</div >
)
}