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 >
)
}

View File

@@ -0,0 +1,97 @@
import React, { useEffect } from 'react'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarTrigger,
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { Download, FolderOpen, Redo2, Undo2 } from 'lucide-react'
type AppMenubarProps = {
onImport: () => void
onExport: () => void
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
}
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return (
ev.key.toLowerCase() === want.key &&
!!mod &&
!!ev.shiftKey === want.shiftKey
)
}
export function AppMenubar({ onImport, onExport, undo, redo, canUndo, canRedo }: AppMenubarProps) {
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS)) {
if (canUndo) {
ev.preventDefault()
ev.stopPropagation()
undo()
}
return
}
if (matchKey(ev, REDO_KEYS)) {
if (canRedo) {
ev.preventDefault()
ev.stopPropagation()
redo()
}
}
}
// Capture phase so we run before CodeMirror/inputs; then graph undo applies even when focus is in an editor
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [undo, redo, canUndo, canRedo])
return (
<Menubar className="shrink-0 rounded-none border-x-0 border-t-0 border-b-0">
<MenubarMenu>
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</MenubarItem>
<MenubarItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}

28
src/components/ui/kbd.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,117 @@
import { useCallback, useRef, useState } from 'react'
import type { Node, Edge } from '@xyflow/react'
export type GraphState = { nodes: Node[]; edges: Edge[] }
function cloneState(state: GraphState): GraphState {
return {
nodes: state.nodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...n.data } : n.data })),
edges: state.edges.map((e) => ({ ...e })),
}
}
const MAX_HISTORY = 100
export function useGraphStateWithHistory(initialNodes: Node[], initialEdges: Edge[]) {
const [nodes, setNodesState] = useState<Node[]>(initialNodes)
const [edges, setEdgesState] = useState<Edge[]>(initialEdges)
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
const pastRef = useRef<GraphState[]>([])
const futureRef = useRef<GraphState[]>([])
const preDragRef = useRef<GraphState | null>(null)
const nodesRef = useRef(nodes)
const edgesRef = useRef(edges)
nodesRef.current = nodes
edgesRef.current = edges
const pushToPast = useCallback((state: GraphState) => {
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
pastRef.current.push(cloneState(state))
futureRef.current = []
setHistorySizes({ past: pastRef.current.length, future: 0 })
}, [])
const setNodes = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
setNodesState(typeof updater === 'function' ? updater : () => updater)
}, [pushToPast])
const setEdges = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
setEdgesState(typeof updater === 'function' ? updater : () => updater)
}, [pushToPast])
const setNodesSilent = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => {
setNodesState(typeof updater === 'function' ? updater : () => updater)
}, [])
const setEdgesSilent = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => {
setEdgesState(typeof updater === 'function' ? updater : () => updater)
}, [])
const applyGraph = useCallback((updater: (state: GraphState) => GraphState) => {
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
const next = updater({ nodes: nodesRef.current, edges: edgesRef.current })
setNodesState(next.nodes)
setEdgesState(next.edges)
}, [pushToPast])
const saveForDragEnd = useCallback(() => {
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
}, [])
const commitDragEnd = useCallback(() => {
if (preDragRef.current) {
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
pastRef.current.push(preDragRef.current)
futureRef.current = []
preDragRef.current = null
setHistorySizes({ past: pastRef.current.length, future: 0 })
}
}, [])
const undo = useCallback(() => {
if (pastRef.current.length === 0) return
const prev = pastRef.current.pop()!
futureRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
setNodesState(prev.nodes)
setEdgesState(prev.edges)
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
}, [])
const redo = useCallback(() => {
if (futureRef.current.length === 0) return
const next = futureRef.current.pop()!
pastRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
setNodesState(next.nodes)
setEdgesState(next.edges)
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
}, [])
const setStateImmediate = useCallback((state: GraphState) => {
setNodesState(state.nodes)
setEdgesState(state.edges)
pastRef.current = []
futureRef.current = []
preDragRef.current = null
setHistorySizes({ past: 0, future: 0 })
}, [])
return {
nodes,
edges,
setNodes,
setEdges,
setNodesSilent,
setEdgesSilent,
applyGraph,
saveForDragEnd,
commitDragEnd,
undo,
redo,
canUndo: historySizes.past > 0,
canRedo: historySizes.future > 0,
setStateImmediate,
}
}