From 5e22ed8806ff1aafc0e1a5b36f652b28f30d4cd8 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Sun, 7 Dec 2025 15:21:59 +0000 Subject: [PATCH] feat: added undo and redo functionality --- src/components/TextField.tsx | 6 +- src/pages/VisProgPage/VisProg.tsx | 31 ++- .../visualProgrammingUI/EditorUndoRedo.ts | 129 ++++++++++ .../visualProgrammingUI/VisProgStores.tsx | 95 ++++--- .../visualProgrammingUI/VisProgTypes.tsx | 12 + .../components/DragDropSidebar.tsx | 8 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 15 +- .../EditorUndoRedo.test.ts | 239 ++++++++++++++++++ test/setupFlowTests.ts | 6 + 9 files changed, 490 insertions(+), 51 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index f9527c8..6dbc47b 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; /** @@ -105,6 +105,10 @@ export function TextField({ }) { const [inputValue, setInputValue] = useState(value); + useEffect(() => { + setInputValue(value); + }, [value]); + const onCommit = () => setValue(inputValue); return ({ onConnect: state.onConnect, onReconnectStart: state.onReconnectStart, onReconnectEnd: state.onReconnectEnd, - onReconnect: state.onReconnect + onReconnect: state.onReconnect, + undo: state.undo, + redo: state.redo, + beginBatchAction: state.beginBatchAction, + endBatchAction: state.endBatchAction }); // --| define ReactFlow editor |-- @@ -60,9 +65,23 @@ const VisProgUI = () => { onConnect, onReconnect, onReconnectStart, - onReconnectEnd + onReconnectEnd, + undo, + redo, + beginBatchAction, + endBatchAction } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore + // adds ctrl+z and ctrl+y support to respectively undo and redo actions + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'z') undo(); + if (e.ctrlKey && e.key === 'y') redo(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); + return (
{ onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd} onConnect={onConnect} + onNodeDragStart={beginBatchAction} + onNodeDragStop={endBatchAction} snapToGrid fitView proOptions={{hideAttribution: true}} @@ -83,6 +104,10 @@ const VisProgUI = () => { {/* contains the drag and drop panel for nodes */} + + + + @@ -90,8 +115,6 @@ const VisProgUI = () => { ); }; - - /** * Places the VisProgUI component inside a ReactFlowProvider * diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts new file mode 100644 index 0000000..70c4c01 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -0,0 +1,129 @@ +import type {Edge, Node} from "@xyflow/react"; +import type {StateCreator, StoreApi } from 'zustand/vanilla'; +import type {FlowState} from "./VisProgTypes.tsx"; + +export type FlowSnapshot = { + nodes: Node[]; + edges: Edge[]; +} + +/** + * A reduced version of the flowState type, + * This removes the functions that are provided by UndoRedo from the expected input type + */ +type BaseFlowState = Omit; + + +/** + * UndoRedo is implemented as a middleware for the FlowState store, + * this allows us to keep the undo redo logic separate from the flowState, + * and thus from the internal editor logic + * + * Allows users to undo and redo actions in the visual programming editor + * + * @param {(set: StoreApi["setState"], get: () => FlowState, api: StoreApi) => BaseFlowState} config + * @returns {StateCreator} + * @constructor + */ +export const UndoRedo = ( + config: ( + set: StoreApi['setState'], + get: () => FlowState, + api: StoreApi + ) => BaseFlowState ) : StateCreator => (set, get, api) => { + let batchTimeout: number | null = null; + + /** + * Captures the current state for + * + * @param {BaseFlowState} state - the current state of the editor + * @returns {FlowSnapshot} - returns a snapshot of the current editor state + */ + const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({ + nodes: state.nodes, + edges: state.edges + }); + + const initialState = config(set, get, api); + + return { + ...initialState, + + /** + * Adds a snapshot of the current state to the undo history + */ + pushSnapshot: () => { + const state = get(); + // we don't add new snapshots during an ongoing batch action + if (!state.isBatchAction) { + set({ + past: [...state.past, getSnapshot(state)], + future: [] + }); + } + + }, + + /** + * Undoes the last action from the editor, + * The state before undoing is added to the future for potential redoing + */ + undo: () => { + const state = get(); + if (!state.past.length) return; + + const snapshot = state.past.pop()!; // pop last snapshot + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.future.push(currentSnapshot); // push current to redo + }, + + /** + * redoes the last undone action, + * The state before redoing is added to the past for potential undoing + */ + redo: () => { + const state = get(); + if (!state.future.length) return; + + const snapshot = state.future.pop()!; // pop last redo + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.past.push(currentSnapshot); // push current to undo + }, + + /** + * Begins a batched action + * + * An example of a batched action is dragging a node in the editor, + * where we want the entire action of moving a node to a different position + * to be covered by one undoable snapshot + */ + beginBatchAction: () => { + get().pushSnapshot(); + set({ isBatchAction: true }); + if (batchTimeout) clearTimeout(batchTimeout); + }, + + /** + * Ends a batched action, + * a very short timeout is used to prevent new snapshots from being added + * until we are certain that the batch event is finished + */ + endBatchAction: () => { + batchTimeout = window.setTimeout(() => { + set({ isBatchAction: false }); + }, 10); + } + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e79715f..5bcd855 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -10,6 +10,7 @@ import { } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; +import { UndoRedo } from "./EditorUndoRedo.ts"; /** @@ -34,7 +35,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record return {...defaultData, ...newData} } -//* Initial nodes to populate the flow at startup. +//* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), @@ -42,7 +43,7 @@ const initialNodes : Node[] = [ createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -//* Initial edges to connect the startup nodes. +// * Initial edges * / const initialEdges: Edge[] = [ { id: 'start-phase-1', source: 'start', target: 'phase-1' }, { id: 'phase-1-end', source: 'phase-1', target: 'end' }, @@ -50,17 +51,17 @@ const initialEdges: Edge[] = [ /** - * How we have defined the functions for our FlowState. - * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. - * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. - * + * useFlowStore contains the implementation for all editor functionality + * and stores the current state of the visual programming editor + * * * Provides: * - Node and edge state management * - Node creation, deletion, and updates * - Custom connection handling via NodeConnects * - Edge reconnection handling + * - Undo Redo functionality through custom middleware */ -const useFlowStore = create((set, get) => ({ +const useFlowStore = create(UndoRedo((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, @@ -68,8 +69,7 @@ const useFlowStore = create((set, get) => ({ /** * Handles changes to nodes triggered by ReactFlow. */ - onNodesChange: (changes) => - set({nodes: applyNodeChanges(changes, get().nodes)}), + onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), /** * Handles changes to edges triggered by ReactFlow. @@ -81,28 +81,34 @@ const useFlowStore = create((set, get) => ({ * Updates edges and calls the node-specific connection functions. */ onConnect: (connection) => { - const edges = addEdge(connection, get().edges); - const nodes = get().nodes; - // connection has: { source, sourceHandle, target, targetHandle } - // Let's find the source and target ID's. - const sourceNode = nodes.find((n) => n.id == connection.source); - const targetNode = nodes.find((n) => n.id == connection.target); - - // In case the nodes weren't found, return basic functionality. - if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) { - set({ nodes, edges }); - return; - } + get().pushSnapshot(); - // We should find out how their data changes by calling their respective functions. - const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] - const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] - - // We're going to have to update their data based on how they want to update it. - sourceConnectFunction(sourceNode, targetNode, true) - targetConnectFunction(targetNode, sourceNode, false) - set({ nodes, edges }); -}, + const edges = addEdge(connection, get().edges); + const nodes = get().nodes; + // connection has: { source, sourceHandle, target, targetHandle } + // Let's find the source and target ID's. + const sourceNode = nodes.find((n) => n.id == connection.source); + const targetNode = nodes.find((n) => n.id == connection.target); + + // In case the nodes weren't found, return basic functionality. + if ( sourceNode == undefined + || targetNode == undefined + || sourceNode.type == undefined + || targetNode.type == undefined + ){ + set({ nodes, edges }); + return; + } + + // We should find out how their data changes by calling their respective functions. + const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] + const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] + + // We're going to have to update their data based on how they want to update it. + sourceConnectFunction(sourceNode, targetNode, true) + targetConnectFunction(targetNode, sourceNode, false) + set({ nodes, edges }); + }, /** * Handles reconnecting an edge between nodes. @@ -112,19 +118,32 @@ const useFlowStore = create((set, get) => ({ set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); }, - onReconnectStart: () => set({ edgeReconnectSuccessful: false }), + onReconnectStart: () => { + get().pushSnapshot(); + set({ edgeReconnectSuccessful: false }) + }, + + /** + * handles potential dropping (deleting) of an edge + * if it is not reconnected to a node after detaching it + * + * @param _evt - the event + * @param {{id: string}} edge - the described edge + */ onReconnectEnd: (_evt, edge) => { if (!get().edgeReconnectSuccessful) { set({ edges: get().edges.filter((e) => e.id !== edge.id) }); } set({ edgeReconnectSuccessful: true }); }, - + /** * Deletes a node by ID, respecting NodeDeletes rules. * Also removes all edges connected to that node. */ deleteNode: (nodeId) => { + get().pushSnapshot(); + // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] @@ -135,7 +154,7 @@ const useFlowStore = create((set, get) => ({ nodes: get().nodes.filter((n) => n.id !== nodeId), edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), })} - }, + }, /** * Replaces the entire nodes array in the store. @@ -151,6 +170,7 @@ const useFlowStore = create((set, get) => ({ * Updates the data of a node by merging new data with existing data. */ updateNodeData: (nodeId, data) => { + get().pushSnapshot(); set({ nodes: get().nodes.map((node) => { if (node.id === nodeId) { @@ -165,8 +185,15 @@ const useFlowStore = create((set, get) => ({ * Adds a new node to the flow store. */ addNode: (node: Node) => { + get().pushSnapshot(); set({ nodes: [...get().nodes, node] }); }, -})); + + // undo redo default values + past: [], + future: [], + isBatchAction: false, + })) +); export default useFlowStore; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index e466bed..b35bbf2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,6 +1,8 @@ // VisProgTypes.ts import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; +import type {FlowSnapshot} from "./EditorUndoRedo.ts"; + /** * Type representing all registered node types. @@ -74,4 +76,14 @@ export type FlowState = { * @param node - the Node object to add */ addNode: (node: Node) => void; + + // UndoRedo Types + past: FlowSnapshot[]; + future: FlowSnapshot[]; + pushSnapshot: () => void; + isBatchAction: boolean; + beginBatchAction: () => void; + endBatchAction: () => void; + undo: () => void; + redo: () => void; }; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 92f211c..94ce1dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -64,8 +64,8 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * @param nodeType - The type of node to create (from `NodeTypes`). * @param position - The XY position in the flow canvas where the node will appear. */ -function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); +function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, addNode } = useFlowStore.getState(); // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} @@ -90,7 +90,7 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { position, data: {...defaultData} } - setNodes([...nodes, newNode]); + addNode(newNode); } /** @@ -125,7 +125,7 @@ export function DndToolbar() { if (isInFlow) { const position = screenToFlowPosition(screenPosition); - addNode(nodeType, position); + addNodeToFlow(nodeType, position); } }, [screenToFlowPosition], diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 5be666b..6168f32 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -32,23 +32,22 @@ export type GoalNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function GoalNode(props: NodeProps) { - const data = props.data +export default function GoalNode({id, data}: NodeProps) { const {updateNodeData} = useFlowStore(); - const text_input_id = `goal_${props.id}_text_input`; - const checkbox_id = `goal_${props.id}_checkbox`; + const text_input_id = `goal_${id}_text_input`; + const checkbox_id = `goal_${id}_checkbox`; const setDescription = (value: string) => { - updateNodeData(props.id, {...data, description: value}); + updateNodeData(id, {...data, description: value}); } const setAchieved = (value: boolean) => { - updateNodeData(props.id, {...data, achieved: value}); + updateNodeData(id, {...data, achieved: value}); } return <> - +
@@ -64,7 +63,7 @@ export default function GoalNode(props: NodeProps) { setAchieved(e.target.checked)} />
diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts new file mode 100644 index 0000000..76e7e96 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -0,0 +1,239 @@ +import {act} from '@testing-library/react'; +import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../../setupFlowTests.ts'; + + +beforeAll(() => { + mockReactFlow(); +}); + +describe("UndoRedo Middleware", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test("pushSnapshot adds a snapshot to past and clears future", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [], + past: [], + future: [{ + nodes: [ + { + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }, + ], + edges: [] + }], + }); + + act(() => { + store.getState().pushSnapshot(); + }) + + const state = store.getState(); + expect(state.past.length).toBe(1); + expect(state.past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + expect(state.future).toEqual([]); + }); + + test("pushSnapshot does nothing during batch action", () => { + const store = useFlowStore; + + act(() => { + store.setState({ isBatchAction: true }); + store.getState().pushSnapshot(); + }) + + expect(store.getState().past.length).toBe(0); + }); + + test("undo restores last snapshot and pushes current snapshot to future", () => { + const store = useFlowStore; + + // initial state + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + + // modified state + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + store.getState().undo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }]); + expect(store.getState().future.length).toBe(1); + expect(store.getState().future[0]).toEqual({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + }); + + test("undo does nothing when past is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + + act(() => { store.getState().undo(); }); + + expect(store.getState().nodes).toEqual([]); + expect(store.getState().future).toEqual([]); + }); + + test("redo restores last future snapshot and pushes current to past", () => { + const store = useFlowStore; + + // initial + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + + store.getState().undo(); + + // redo should restore node with id 'B' + store.getState().redo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }]); + expect(store.getState().past.length).toBe(1); // snapshot A stored + expect(store.getState().past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + }); + + test("redo does nothing when future is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + act(() => { store.getState().redo(); }); + + expect(store.getState().nodes).toEqual([]); + }); + + test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { store.getState().beginBatchAction(); }); + + expect(store.getState().isBatchAction).toBe(true); + expect(store.getState().past.length).toBe(1); + }); + + test("endBatchAction sets isBatchAction=false after timeout", () => { + const store = useFlowStore; + + store.setState({ isBatchAction: true }); + act(() => { store.getState().endBatchAction(); }); + + // isBatchAction should remain true before the timer has advanced + expect(store.getState().isBatchAction).toBe(true); + + jest.advanceTimersByTime(10); + + // it should now be set to false as the timer has advanced enough + expect(store.getState().isBatchAction).toBe(false); + }); + + test("multiple beginBatchAction calls clear the timeout", () => { + const store = useFlowStore; + + act(() => { + store.getState().beginBatchAction(); + store.getState().endBatchAction(); // starts timeout + store.getState().beginBatchAction(); // should clear previous timeout + }); + + + jest.advanceTimersByTime(10); + + // After advancing the timers, isBatchAction should still be true, + // as the timeout should have been cleared + expect(store.getState().isBatchAction).toBe(true); + }); +}); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 21a4945..3ce8c3a 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -69,6 +69,9 @@ beforeAll(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); @@ -78,6 +81,9 @@ afterEach(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); });