feat(build): Add undo/redo functionality and integrate custom nodes

Introduce a `useUndoRedo` hook to manage state history with undo/redo, persistence using localStorage, and state validation. Updated the build page to display a flow with initial custom nodes and edges, replacing the previous flow editor implementation.
This commit is contained in:
Andy Hooker
2025-02-22 17:17:35 -06:00
parent 4afe724628
commit 84759053a7
2 changed files with 144 additions and 5 deletions

View File

@@ -3,15 +3,43 @@
import { useSearchParams } from "next/navigation";
import { GraphID } from "@/lib/autogpt-server-api/types";
import FlowEditor from "@/components/Flow";
import React from "react";
import BuildFlow from "@/components/build/BuildFlow";
import { CustomNode } from "@/components/CustomNode";
export default function Home() {
const query = useSearchParams();
const nodeTypes = { custom: CustomNode };
// Define initial nodes and edges
const initialNodes = [
{
id: "1",
type: "custom", // Type must match the key in the `nodeTypes` object
position: { x: 100, y: 100 },
data: { label: "I am a custom node" }, // Must match `CustomNodeData`
},
{
id: "2",
type: "custom",
position: { x: 400, y: 200 },
data: { label: "Another custom node" },
},
];
const initialEdges = [
{ id: "e1-2", source: "1", target: "2", type: "default" },
];
return (
<FlowEditor
className="flow-container"
flowID={query.get("flowID") as GraphID | null ?? undefined}
flowVersion={query.get("flowVersion") ?? undefined}
/>
<div className="flow-container">
<BuildFlow
id="example-canvas"
initialNodes={initialNodes}
initialEdges={initialEdges}
readOnly={false}
/>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { Edge, Node } from "@xyflow/react";
import isEqual from "lodash/isEqual";
const LOCAL_STORAGE_KEY = "build-state";
const MAX_HISTORY_LENGTH = 50;
interface CanvasState {
nodes: Node<any>[];
edges: Edge<any>[];
}
function useUndoRedo(initialState: CanvasState) {
const [history, setHistory] = useState<CanvasState[]>([initialState]);
const [pointer, setPointer] = useState(0);
const isFirstLoad = useRef(true);
const lastSavedState = useRef<string | null>(null);
// Load state from localStorage only once on mount
useEffect(() => {
const cachedState = localStorage.getItem(LOCAL_STORAGE_KEY);
if (cachedState && isFirstLoad.current) {
try {
const parsedHistory: CanvasState[] = JSON.parse(cachedState);
if (parsedHistory.every(validateCanvasState)) {
setHistory(parsedHistory);
setPointer(parsedHistory.length - 1);
lastSavedState.current = cachedState;
}
} catch (error) {
console.error("Failed to parse cached state:", error);
}
}
isFirstLoad.current = false;
}, []);
// Save state to localStorage
useEffect(() => {
if (isFirstLoad.current) return;
const newState = JSON.stringify(history.slice(0, pointer + 1));
if (newState !== lastSavedState.current) {
lastSavedState.current = newState;
localStorage.setItem(LOCAL_STORAGE_KEY, newState);
}
}, [history, pointer]);
const addState = useCallback(
(newState: CanvasState) => {
if (isFirstLoad.current) return;
setHistory((prevHistory) => {
// If the new state is the same as the current state, don't add it
const currentState = prevHistory[pointer];
if (isEqual(currentState, newState)) return prevHistory;
// Truncate the history to the current pointer and add the new state
const newHistory = [...prevHistory.slice(0, pointer + 1), newState];
// Maintain the maximum history length
return newHistory.slice(-MAX_HISTORY_LENGTH);
});
// Move the pointer to the new end of history
setPointer((prev) => {
const newPointer = prev + 1;
return Math.min(newPointer, MAX_HISTORY_LENGTH - 1);
});
},
[pointer],
);
const undo = useCallback(() => {
if (pointer > 0) {
setPointer((prev) => prev - 1);
}
}, [pointer]);
const redo = useCallback(() => {
if (pointer < history.length - 1) {
setPointer((prev) => prev + 1);
}
}, [pointer, history.length]);
const reset = useCallback(() => {
setHistory([initialState]);
setPointer(0);
}, [initialState]);
return {
history,
current: history[pointer],
undo,
redo,
canUndo: pointer > 0,
canRedo: pointer < history.length - 1,
addState,
reset,
};
}
const validateCanvasState = (state: CanvasState): boolean => {
return (
Array.isArray(state.nodes) &&
Array.isArray(state.edges) &&
state.nodes.every((node) => node && node.id && node.position) &&
state.edges.every((edge) => edge && edge.id && edge.source && edge.target)
);
};
export default useUndoRedo;