add redo and undo
This commit is contained in:
146
src/App.tsx
146
src/App.tsx
@@ -6,14 +6,14 @@ import {
|
|||||||
Background,
|
Background,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
addEdge,
|
addEdge,
|
||||||
useNodesState,
|
|
||||||
useEdgesState,
|
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Connection,
|
type Connection,
|
||||||
type ColorMode,
|
type ColorMode,
|
||||||
type NodeChange,
|
type NodeChange,
|
||||||
|
type EdgeChange,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import ConfigNode from './components/graph/ConfigNode'
|
import ConfigNode from './components/graph/ConfigNode'
|
||||||
import FunctionNode from './components/graph/FunctionNode'
|
import FunctionNode from './components/graph/FunctionNode'
|
||||||
@@ -22,6 +22,7 @@ import VariableNode from './components/graph/VariableNode'
|
|||||||
import { AnimatedEdge } from './components/graph/AnimatedEdge'
|
import { AnimatedEdge } from './components/graph/AnimatedEdge'
|
||||||
import FlowContext from './lib/flowContext'
|
import FlowContext from './lib/flowContext'
|
||||||
import { useTheme } from './lib/themeContext'
|
import { useTheme } from './lib/themeContext'
|
||||||
|
import { useGraphStateWithHistory } from './hooks/useGraphStateWithHistory'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
ContextMenuGroup
|
ContextMenuGroup
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
|
import { AppMenubar } from '@/components/AppMenubar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
|
import { ClipboardPaste, Code2, Moon, ScrollText, Sparkles, Sun, Variable } from 'lucide-react'
|
||||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from './lib/flowUtils'
|
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' },
|
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PROJECT_FILE_EXT = '.zui.json'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(initialNodes)
|
const {
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
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 [rfInstance, setRfInstance] = React.useState<any | null>(null)
|
||||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||||
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: 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
|
rafRef.current = null
|
||||||
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
|
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
|
||||||
if (toApply.length > 0) {
|
if (toApply.length > 0) {
|
||||||
setNodes((nds) => applyNodeChanges(toApply, nds))
|
setNodesSilent((nds) => applyNodeChanges(toApply, nds))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setNodes]
|
[setNodesSilent]
|
||||||
)
|
)
|
||||||
|
|
||||||
const nodeTypes = React.useMemo(
|
const nodeTypes = React.useMemo(
|
||||||
@@ -115,6 +134,14 @@ export default function App() {
|
|||||||
|
|
||||||
const defaultEdgeOptions = React.useMemo(() => ({ type: 'animated' as const }), [])
|
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(
|
const onConnect = React.useCallback(
|
||||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||||
[setEdges]
|
[setEdges]
|
||||||
@@ -155,6 +182,50 @@ export default function App() {
|
|||||||
setRfInstance(instance)
|
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(
|
const flowContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
nodes,
|
nodes,
|
||||||
@@ -270,35 +341,58 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [getMenuPosition, setNodes])
|
}, [getMenuPosition, setNodes])
|
||||||
|
|
||||||
const deleteNode = React.useCallback((id: string | undefined) => {
|
const deleteNode = React.useCallback(
|
||||||
if (!id) return
|
(id: string | undefined) => {
|
||||||
setNodes((nds) => nds.filter((n) => n.id !== id))
|
if (!id) return
|
||||||
setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id))
|
applyGraph(({ nodes: nds, edges: eds }) => ({
|
||||||
setContextTarget(null)
|
nodes: nds.filter((n) => n.id !== id),
|
||||||
}, [setNodes, setEdges])
|
edges: eds.filter((e) => e.source !== id && e.target !== id),
|
||||||
|
}))
|
||||||
|
setContextTarget(null)
|
||||||
|
},
|
||||||
|
[applyGraph]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="reactflow-wrapper"
|
className="reactflow-wrapper flex flex-col h-full"
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
onContextMenuCapture={onContextMenuCapture}
|
onContextMenuCapture={onContextMenuCapture}
|
||||||
onContextMenu={onCanvasContextMenu}
|
onContextMenu={onCanvasContextMenu}
|
||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
>
|
>
|
||||||
<Button
|
<input
|
||||||
variant="outline"
|
ref={importInputRef}
|
||||||
size="icon"
|
type="file"
|
||||||
className="absolute top-3 right-3 z-10 nodrag nopan"
|
accept=".json,.zui.json,application/json"
|
||||||
onClick={toggleTheme}
|
className="hidden"
|
||||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
onChange={onImportFileChange}
|
||||||
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
aria-hidden
|
||||||
>
|
/>
|
||||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
<AppMenubar
|
||||||
</Button>
|
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}>
|
<FlowContext.Provider value={flowContextValue}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div style={{ width: '100%', height: '100%' }}>
|
<div className="flex-1 min-h-0 w-full">
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -308,6 +402,8 @@ export default function App() {
|
|||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onConnectStart={onConnectStart}
|
onConnectStart={onConnectStart}
|
||||||
onConnectEnd={onConnectEnd}
|
onConnectEnd={onConnectEnd}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
@@ -361,6 +457,8 @@ export default function App() {
|
|||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</FlowContext.Provider>
|
</FlowContext.Provider>
|
||||||
|
</div>
|
||||||
|
</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