add redo and undo
This commit is contained in:
122
src/App.tsx
122
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<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,21 +341,43 @@ export default function App() {
|
||||
}
|
||||
}, [getMenuPosition, setNodes])
|
||||
|
||||
const deleteNode = React.useCallback((id: string | undefined) => {
|
||||
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))
|
||||
applyGraph(({ nodes: nds, edges: eds }) => ({
|
||||
nodes: nds.filter((n) => n.id !== id),
|
||||
edges: eds.filter((e) => e.source !== id && e.target !== id),
|
||||
}))
|
||||
setContextTarget(null)
|
||||
}, [setNodes, setEdges])
|
||||
},
|
||||
[applyGraph]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="reactflow-wrapper"
|
||||
className="reactflow-wrapper flex flex-col h-full"
|
||||
ref={wrapperRef}
|
||||
onContextMenuCapture={onContextMenuCapture}
|
||||
onContextMenu={onCanvasContextMenu}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<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"
|
||||
@@ -295,10 +388,11 @@ export default function App() {
|
||||
>
|
||||
{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 >
|
||||
)
|
||||
}
|
||||
|
||||
97
src/components/AppMenubar.tsx
Normal file
97
src/components/AppMenubar.tsx
Normal 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
28
src/components/ui/kbd.tsx
Normal 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 }
|
||||
117
src/hooks/useGraphStateWithHistory.ts
Normal file
117
src/hooks/useGraphStateWithHistory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user