From fe4ac78c72b39f556725ef4eadd6e24c6eca9d5d Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 8 Mar 2026 21:15:40 +0100 Subject: [PATCH] add redo and undo --- src/App.tsx | 146 +++++++++++++++++++++----- src/components/AppMenubar.tsx | 97 +++++++++++++++++ src/components/ui/kbd.tsx | 28 +++++ src/hooks/useGraphStateWithHistory.ts | 117 +++++++++++++++++++++ 4 files changed, 364 insertions(+), 24 deletions(-) create mode 100644 src/components/AppMenubar.tsx create mode 100644 src/components/ui/kbd.tsx create mode 100644 src/hooks/useGraphStateWithHistory.ts diff --git a/src/App.tsx b/src/App.tsx index d22ad2d..826d05e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null) const [rfInstance, setRfInstance] = React.useState(null) const [renamingNodeId, setRenamingNodeId] = React.useState(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[]) => { + 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) => { + 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 (
- + + +
+ +
-
+
+
+
) } diff --git a/src/components/AppMenubar.tsx b/src/components/AppMenubar.tsx new file mode 100644 index 0000000..a83c3ad --- /dev/null +++ b/src/components/AppMenubar.tsx @@ -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 ( + + + Project + + + + Import… + + + + Export… + + + + + Edit + + + + Undo + + + ⌘ + Z + + + + + + Redo + + + ⌘ + ⇧ + Z + + + + + + + ) +} diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx new file mode 100644 index 0000000..44d8ba8 --- /dev/null +++ b/src/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils" + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ) +} + +function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Kbd, KbdGroup } diff --git a/src/hooks/useGraphStateWithHistory.ts b/src/hooks/useGraphStateWithHistory.ts new file mode 100644 index 0000000..8276f79 --- /dev/null +++ b/src/hooks/useGraphStateWithHistory.ts @@ -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(initialNodes) + const [edges, setEdgesState] = useState(initialEdges) + const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 }) + + const pastRef = useRef([]) + const futureRef = useRef([]) + const preDragRef = useRef(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, + } +}