diff --git a/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts b/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts new file mode 100644 index 000000000..72dc6699c --- /dev/null +++ b/src/frontend/src/pages/FlowPage/hooks/useUndoRedo.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Edge, Node, useReactFlow } from 'reactflow'; + +type UseUndoRedoOptions = { + maxHistorySize: number; + enableShortcuts: boolean; +}; + +type UseUndoRedo = (options?: UseUndoRedoOptions) => { + undo: () => void; + redo: () => void; + takeSnapshot: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +type HistoryItem = { + nodes: Node[]; + edges: Edge[]; +}; + +const defaultOptions: UseUndoRedoOptions = { + maxHistorySize: 100, + enableShortcuts: true, +}; + +// https://redux.js.org/usage/implementing-undo-history +export const useUndoRedo: UseUndoRedo = ({ + maxHistorySize = defaultOptions.maxHistorySize, + enableShortcuts = defaultOptions.enableShortcuts, +} = defaultOptions) => { + // the past and future arrays store the states that we can jump to + const [past, setPast] = useState([]); + const [future, setFuture] = useState([]); + + const { setNodes, setEdges, getNodes, getEdges } = useReactFlow(); + + const takeSnapshot = useCallback(() => { + // push the current graph to the past state + setPast((past) => [ + ...past.slice(past.length - maxHistorySize + 1, past.length), + { nodes: getNodes(), edges: getEdges() }, + ]); + + // whenever we take a new snapshot, the redo operations need to be cleared to avoid state mismatches + setFuture([]); + }, [getNodes, getEdges, maxHistorySize]); + + const undo = useCallback(() => { + // get the last state that we want to go back to + const pastState = past[past.length - 1]; + + if (pastState) { + // first we remove the state from the history + setPast((past) => past.slice(0, past.length - 1)); + // we store the current graph for the redo operation + setFuture((future) => [...future, { nodes: getNodes(), edges: getEdges() }]); + // now we can set the graph to the past state + setNodes(pastState.nodes); + setEdges(pastState.edges); + } + }, [setNodes, setEdges, getNodes, getEdges, past]); + + const redo = useCallback(() => { + const futureState = future[future.length - 1]; + + if (futureState) { + setFuture((future) => future.slice(0, future.length - 1)); + setPast((past) => [...past, { nodes: getNodes(), edges: getEdges() }]); + setNodes(futureState.nodes); + setEdges(futureState.edges); + } + }, [setNodes, setEdges, getNodes, getEdges, future]); + + useEffect(() => { + // this effect is used to attach the global event handlers + if (!enableShortcuts) { + return; + } + + const keyDownHandler = (event: KeyboardEvent) => { + if (event.key === 'z' && (event.ctrlKey || event.metaKey) && event.shiftKey) { + redo(); + } + else if (event.key === 'y' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); // prevent the default action + redo(); + } else if (event.key === 'z' && (event.ctrlKey || event.metaKey)) { + undo(); + } + }; + + document.addEventListener('keydown', keyDownHandler); + + return () => { + document.removeEventListener('keydown', keyDownHandler); + }; + }, [undo, redo, enableShortcuts]); + + return { + undo, + redo, + takeSnapshot, + canUndo: !!past.length, + canRedo: !!future.length, + }; +}; + +export default useUndoRedo; diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx index 0c7e46d06..16092e990 100644 --- a/src/frontend/src/pages/FlowPage/index.tsx +++ b/src/frontend/src/pages/FlowPage/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useRef } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import ReactFlow, { Background, Controls, @@ -22,6 +22,7 @@ import ConnectionLineComponent from "./components/ConnectionLineComponent"; import { FlowType, NodeType } from "../../types/flow"; import { APIClassType } from "../../types/api"; import { isValidConnection } from "../../utils"; +import useUndoRedo from "./hooks/useUndoRedo"; const nodeTypes = { genericNode: GenericNode, @@ -185,7 +186,7 @@ export default function FlowPage({ flow }: { flow: FlowType }) { }, []); return ( -
+
{Object.keys(templates).length > 0 && Object.keys(types).length > 0 ? ( <> onKeyDown(e)} onConnect={onConnect} onLoad={setReactFlowInstance} onInit={setReactFlowInstance} @@ -203,6 +205,9 @@ export default function FlowPage({ flow }: { flow: FlowType }) { onEdgeUpdate={onEdgeUpdate} onEdgeUpdateStart={onEdgeUpdateStart} onEdgeUpdateEnd={onEdgeUpdateEnd} + onNodeDragStart={onNodeDragStart} + onSelectionDragStart={onSelectionDragStart} + onEdgesDelete={onEdgesDelete} connectionLineComponent={ConnectionLineComponent} onDragOver={onDragOver} onDrop={onDrop}