feat(flowEditor): 添加历史记录功能和撤销重做操作

- 在 actionBar 中添加撤销和重做按钮
- 实现 HistoryProvider 和 useHistory hook 来管理历史记录
- 为节点和边的变化添加快照记录机制
- 支持通过快捷键 Ctrl+Z 撤销和 Ctrl+Y/Ctrl+Shift+Z 重做- 在节点拖动、连接创建、节点删除等操作后自动记录历史- 添加防抖机制避免频繁的位置变化记录
- 实现历史记录长度限制防止内存泄漏
- 更新 FlowEditor 组件结构以支持历史记录功能
master
钟良源 4 months ago
parent 3c57f650fb
commit 1a8629d84c

@ -1,16 +1,26 @@
import React from 'react';
import { Button } from '@arco-design/web-react';
import { IconSave, IconPlayArrow, IconCodeSquare } from '@arco-design/web-react/icon';
import { IconSave, IconPlayArrow, IconCodeSquare, IconUndo, IconRedo } from '@arco-design/web-react/icon';
import { updateLogBarStatus } from '@/store/ideContainer';
import { useSelector, useDispatch } from 'react-redux';
const ButtonGroup = Button.Group;
interface ActionBarProps {
onSave: () => void;
onSave?: () => void;
onUndo?: () => void;
onRedo?: () => void;
canUndo?: boolean;
canRedo?: boolean;
}
const ActionBar: React.FC<ActionBarProps> = ({ onSave }) => {
const ActionBar: React.FC<ActionBarProps> = ({
onSave,
onUndo,
onRedo,
canUndo = false,
canRedo = false
}) => {
const { logBarStatus } = useSelector((state) => state.ideContainer);
const dispatch = useDispatch();
@ -40,6 +50,28 @@ const ActionBar: React.FC<ActionBarProps> = ({ onSave }) => {
</Button>
</ButtonGroup>
<ButtonGroup style={{ marginLeft: 15 }}>
<Button
type="outline"
shape="round"
icon={<IconUndo />}
onClick={onUndo}
disabled={!canUndo}
style={{ padding: '0 8px', backgroundColor: '#fff' }}
>
</Button>
<Button
type="outline"
shape="round"
icon={<IconRedo />}
onClick={onRedo}
disabled={!canRedo}
style={{ padding: '0 8px', backgroundColor: '#fff' }}
>
</Button>
</ButtonGroup>
</div>
);
};

@ -0,0 +1,172 @@
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import { Node, Edge } from '@xyflow/react';
interface HistoryContextType {
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
takeSnapshot: () => void;
}
const HistoryContext = createContext<HistoryContextType | undefined>(undefined);
export const useHistory = () => {
const context = useContext(HistoryContext);
if (!context) {
throw new Error('useHistory must be used within a HistoryProvider');
}
return context;
};
interface HistoryProviderProps {
children: React.ReactNode;
initialNodes: Node[];
initialEdges: Edge[];
onHistoryChange: (nodes: Node[], edges: Edge[]) => void;
}
export const HistoryProvider: React.FC<HistoryProviderProps> = ({
children,
initialNodes,
initialEdges,
onHistoryChange
}) => {
// 历史记录状态
const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([
{ nodes: initialNodes, edges: initialEdges }
]);
const [step, setStep] = useState(0);
// 当前状态的引用,避免重复添加相同状态
const currentState = useRef({
nodes: initialNodes,
edges: initialEdges
});
// 检查两个状态是否相等
const isSameState = useCallback((state1: { nodes: Node[]; edges: Edge[] }, state2: { nodes: Node[]; edges: Edge[] }) => {
// 只比较节点和边的关键属性,忽略拖动过程中的临时状态
if (state1.nodes.length !== state2.nodes.length || state1.edges.length !== state2.edges.length) {
return false;
}
// 比较节点
for (let i = 0; i < state1.nodes.length; i++) {
const node1 = state1.nodes[i];
const node2 = state2.nodes[i];
if (node1.id !== node2.id ||
node1.type !== node2.type ||
node1.position.x !== node2.position.x ||
node1.position.y !== node2.position.y ||
JSON.stringify(node1.data) !== JSON.stringify(node2.data)) {
return false;
}
}
// 比较边
for (let i = 0; i < state1.edges.length; i++) {
const edge1 = state1.edges[i];
const edge2 = state2.edges[i];
if (edge1.id !== edge2.id ||
edge1.source !== edge2.source ||
edge1.target !== edge2.target ||
edge1.sourceHandle !== edge2.sourceHandle ||
edge1.targetHandle !== edge2.targetHandle) {
return false;
}
}
return true;
}, []);
// 拍摄快照
const takeSnapshot = useCallback(() => {
// 获取当前状态
const { nodes, edges } = currentState.current;
// 如果当前状态与历史记录中的当前步骤相同,则不添加新快照
const currentHistoryState = history[step];
if (isSameState({ nodes, edges }, currentHistoryState)) {
return;
}
// 删除当前步骤之后的所有历史记录
const newHistory = history.slice(0, step + 1);
// 添加新快照
newHistory.push({
nodes: nodes.map(node => ({...node})),
edges: edges.map(edge => ({...edge}))
});
// 限制历史记录长度,防止内存泄漏
const maxLength = 100;
if (newHistory.length > maxLength) {
newHistory.shift();
setStep(prev => prev - 1);
}
setHistory(newHistory);
setStep(newHistory.length - 1);
}, [history, step, isSameState]);
// 撤销操作
const undo = useCallback(() => {
if (step <= 0) return;
const prevStep = step - 1;
const { nodes, edges } = history[prevStep];
currentState.current = { nodes, edges };
setStep(prevStep);
onHistoryChange([...nodes], [...edges]);
}, [step, history, onHistoryChange]);
// 重做操作
const redo = useCallback(() => {
if (step >= history.length - 1) return;
const nextStep = step + 1;
const { nodes, edges } = history[nextStep];
currentState.current = { nodes, edges };
setStep(nextStep);
onHistoryChange([...nodes], [...edges]);
}, [step, history, onHistoryChange]);
// 更新当前状态的引用
const updateCurrentState = useCallback((nodes: Node[], edges: Edge[]) => {
currentState.current = { nodes, edges };
}, []);
// 监听 takeSnapshot 事件
useEffect(() => {
const handleTakeSnapshot = ((event: CustomEvent) => {
const { nodes, edges } = event.detail;
updateCurrentState(nodes, edges);
takeSnapshot();
}) as EventListener;
document.addEventListener('takeSnapshot', handleTakeSnapshot);
return () => {
document.removeEventListener('takeSnapshot', handleTakeSnapshot);
};
}, [takeSnapshot, updateCurrentState]);
const value = {
undo,
redo,
canUndo: step > 0,
canRedo: step < history.length - 1,
takeSnapshot
};
return (
<HistoryContext.Provider value={value}>
{children}
</HistoryContext.Provider>
);
};

@ -40,6 +40,7 @@ import { useAlignmentGuidelines } from '@/hooks/useAlignmentGuidelines';
import { setMainFlow } from '@/api/appRes';
import { Message } from '@arco-design/web-react';
import BasicNode from '@/components/FlowEditor/node/basicNode/BasicNode';
import { HistoryProvider, useHistory } from './components/historyContext';
const edgeTypes: EdgeTypes = {
custom: CustomEdge
@ -162,24 +163,57 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
return sourceDataType === targetDataType;
};
// 在组件顶部添加历史记录相关状态
const [historyInitialized, setHistoryInitialized] = useState(false);
const historyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 修改 onNodesChange 函数,添加防抖机制
const onNodesChange = useCallback(
(changes: any) => {
const newNodes = applyNodeChanges(changes, nodes);
setNodes(newNodes);
// 如果需要在节点变化时执行某些操作,可以在这里添加
onPaneClick();
// 只有当变化是节点位置变化时才不立即记录历史
const isPositionChange = changes.some((change: any) =>
change.type === 'position' && change.dragging === false
);
// 如果是位置变化结束或者不是位置变化,则记录历史
if (isPositionChange || !changes.some((change: any) => change.type === 'position')) {
// 清除之前的定时器
if (historyTimeoutRef.current) {
clearTimeout(historyTimeoutRef.current);
}
// 设置新的定时器,延迟记录历史记录
historyTimeoutRef.current = setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...newNodes], edges: [...edges] }
});
document.dispatchEvent(event);
}, 100);
}
},
[nodes]
[nodes, edges]
);
// 修改 onEdgesChange 函数
const onEdgesChange = useCallback(
(changes: any) => {
const newEdges = applyEdgeChanges(changes, edges);
setEdges(newEdges);
// 如果需要在边变化时执行某些操作,可以在这里添加
onPaneClick();
// 边的变化立即记录历史
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...nodes], edges: [...newEdges] }
});
document.dispatchEvent(event);
},
[edges]
[edges, nodes]
);
const onNodesDelete = useCallback((deletedNodes) => {
@ -187,6 +221,7 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
closeEditModal();
}, []);
// 修改 onConnect 函数
const onConnect = useCallback(
(params: any) => {
// 获取源节点和目标节点
@ -228,7 +263,19 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
// };
// 如果验证通过,创建连接
setEdges((edgesSnapshot) => addEdge({ ...params, type: 'custom' }, edgesSnapshot));
setEdges((edgesSnapshot) => {
const newEdges = addEdge({ ...params, type: 'custom' }, edgesSnapshot);
// 连接建立后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...nodes], edges: [...newEdges] }
});
document.dispatchEvent(event);
}, 0);
return newEdges;
});
},
[nodes]
);
@ -277,6 +324,7 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
}, []);
// 侧边栏节点实例
// 修改 onDrop 函数
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
@ -306,9 +354,21 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
// 目前默认添加的都是系统组件/本地组件
if (!nodeMap.includes(nodeData.nodeType)) registerNodeType(nodeData.nodeType, LocalNode, nodeData.nodeName);
setNodes((nds) => nds.concat(newNode));
setNodes((nds) => {
const newNodes = nds.concat(newNode);
// 添加节点后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...newNodes], edges: [...edges] }
});
document.dispatchEvent(event);
}, 0);
return newNodes;
});
},
[reactFlowInstance]
[reactFlowInstance, edges]
);
const onNodeDrag = useCallback(
@ -350,6 +410,9 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
}));
}
}
// 标记历史记录已初始化
setHistoryInitialized(true);
}, [initialData]);
// 实时更新 canvasDataMap
@ -493,18 +556,40 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
// saveFlowDataToServer();
}, [nodes, editingNode, closeEditModal]);
// 删除节点
// 修改删除节点函数
const deleteNode = useCallback((node: Node) => {
setNodes((nds) => nds.filter((n) => n.id !== node.id));
setEdges((eds) => eds.filter((e) => e.source !== node.id && e.target !== node.id));
setMenu(null);
}, []);
// 删除边
// 删除节点后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: {
nodes: [...nodes.filter((n) => n.id !== node.id)],
edges: [...edges.filter((e) => e.source !== node.id && e.target !== node.id)]
}
});
document.dispatchEvent(event);
}, 0);
}, [nodes, edges]);
// 修改删除边函数
const deleteEdge = useCallback((edge: Edge) => {
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
setMenu(null);
}, []);
// 删除边后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: {
nodes: [...nodes],
edges: [...edges.filter((e) => e.id !== edge.id)]
}
});
document.dispatchEvent(event);
}, 0);
}, [nodes, edges]);
// 编辑节点
const editNode = useCallback((node: Node) => {
@ -528,6 +613,7 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
}, []);
// 在边上添加节点的具体实现
// 修改 addNodeOnEdge 函数
const addNodeOnEdge = useCallback((nodeType: string, node: any) => {
if (!edgeForNodeAdd || !reactFlowInstance) return;
@ -570,8 +656,8 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
setEdges((eds) => eds.filter(e => e.id !== edgeForNodeAdd.id));
// 创建新边: source -> new node, new node -> target
setEdges((eds) => [
...eds,
const newEdges = [
...edges.filter(e => e.id !== edgeForNodeAdd.id),
{
id: `e${edgeForNodeAdd.source}-${newNode.id}`,
source: edgeForNodeAdd.source,
@ -584,14 +670,28 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
target: edgeForNodeAdd.target,
type: 'custom'
}
]);
];
setEdges(newEdges);
// 关闭菜单
setEdgeForNodeAdd(null);
setPositionForNodeAdd(null);
}, [edgeForNodeAdd, nodes, reactFlowInstance]);
// 添加节点后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: {
nodes: [...nodes, newNode],
edges: [...newEdges]
}
});
document.dispatchEvent(event);
}, 0);
}, [edgeForNodeAdd, nodes, reactFlowInstance, edges]);
// 在画布上添加节点
// 修改 addNodeOnPane 函数
const addNodeOnPane = useCallback((nodeType: string, position: { x: number; y: number }, node?: any) => {
setMenu(null);
@ -618,8 +718,20 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
// 目前默认添加的都是系统组件/本地组件
if (!nodeMap.includes(nodeType)) registerNodeType(nodeType, nodeType === 'BASIC' ? BasicNode : LocalNode, nodeDefinition.nodeName);
setNodes((nds) => [...nds, newNode]);
}, [reactFlowInstance]);
setNodes((nds) => {
const newNodes = [...nds, newNode];
// 添加节点后记录历史
setTimeout(() => {
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...newNodes], edges: [...edges] }
});
document.dispatchEvent(event);
}, 0);
return newNodes;
});
}, [reactFlowInstance, edges]);
// 处理添加节点的统一方法
const handleAddNode = useCallback((nodeType: string, node) => {
@ -657,6 +769,163 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
}
}, [nodes, edges]);
if (!historyInitialized) {
return <div>Loading...</div>;
}
return (
<HistoryProvider
initialNodes={nodes}
initialEdges={edges}
onHistoryChange={(newNodes, newEdges) => {
setNodes(newNodes);
setEdges(newEdges);
}}
>
<FlowEditorContent
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
reactFlowInstance={reactFlowInstance}
reactFlowWrapper={reactFlowWrapper}
menu={menu}
setMenu={setMenu}
store={store}
dispatch={dispatch}
editingNode={editingNode}
setEditingNode={setEditingNode}
isEditModalOpen={isEditModalOpen}
setIsEditModalOpen={setIsEditModalOpen}
isDelete={isDelete}
setIsDelete={setIsDelete}
edgeForNodeAdd={edgeForNodeAdd}
setEdgeForNodeAdd={setEdgeForNodeAdd}
positionForNodeAdd={positionForNodeAdd}
setPositionForNodeAdd={setPositionForNodeAdd}
getGuidelines={getGuidelines}
clearGuidelines={clearGuidelines}
AlignmentGuides={AlignmentGuides}
updateCanvasDataMapDebounced={updateCanvasDataMapDebounced}
canvasDataMap={canvasDataMap}
initialData={initialData}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onNodeContextMenu={onNodeContextMenu}
onNodeDoubleClick={onNodeDoubleClick}
onEdgeContextMenu={onEdgeContextMenu}
onPaneContextMenu={onPaneContextMenu}
onPaneClick={onPaneClick}
closeEditModal={closeEditModal}
saveNodeEdit={saveNodeEdit}
deleteNode={deleteNode}
deleteEdge={deleteEdge}
editNode={editNode}
editEdge={editEdge}
copyNode={copyNode}
addNodeOnEdge={addNodeOnEdge}
addNodeOnPane={addNodeOnPane}
handleAddNode={handleAddNode}
saveFlowDataToServer={saveFlowDataToServer}
/>
</HistoryProvider>
);
};
// 创建一个新的组件来包含 ReactFlow 和其他 UI 元素
const FlowEditorContent: React.FC<any> = (props) => {
const {
nodes,
edges,
setNodes,
setEdges,
reactFlowInstance,
reactFlowWrapper,
menu,
setMenu,
editingNode,
setEditingNode,
isEditModalOpen,
setIsEditModalOpen,
isDelete,
setIsDelete,
edgeForNodeAdd,
setEdgeForNodeAdd,
positionForNodeAdd,
setPositionForNodeAdd,
getGuidelines,
clearGuidelines,
AlignmentGuides,
initialData,
onNodesChange,
onEdgesChange,
onConnect,
onReconnect,
onDragOver,
onDrop,
onNodeDrag,
onNodeDragStop,
onNodeContextMenu,
onNodeDoubleClick,
onEdgeContextMenu,
onPaneContextMenu,
onPaneClick,
closeEditModal,
saveNodeEdit,
deleteNode,
deleteEdge,
editNode,
editEdge,
copyNode,
addNodeOnEdge,
addNodeOnPane,
handleAddNode,
saveFlowDataToServer
} = props;
const { undo, redo, canUndo, canRedo } = useHistory();
// 监听键盘事件实现快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+Z 撤销
if (e.ctrlKey && e.key === 'z' && !e.shiftKey && canUndo) {
e.preventDefault();
undo();
}
// Ctrl+Shift+Z 重做
if (e.ctrlKey && e.shiftKey && e.key === 'Z' && canRedo) {
e.preventDefault();
redo();
}
// Ctrl+Y 重做
if (e.ctrlKey && e.key === 'y' && canRedo) {
e.preventDefault();
redo();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [undo, redo, canUndo, canRedo]);
// 监听节点和边的变化以拍摄快照
useEffect(() => {
// 获取 HistoryProvider 中的 takeSnapshot 方法
const event = new CustomEvent('takeSnapshot', {
detail: { nodes: [...nodes], edges: [...edges] }
});
document.dispatchEvent(event);
}, [nodes, edges]);
return (
<div ref={reactFlowWrapper} style={{ width: '100%', height: '100%', position: 'relative' }}
onContextMenu={(e) => e.preventDefault()}>
@ -668,7 +937,9 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
edgeTypes={edgeTypes}
snapToGrid={true}
snapGrid={[2, 2]}
onNodesDelete={onNodesDelete}
onNodesDelete={(deleted) => {
setNodes((nds) => nds.filter((n) => !deleted.find(d => d.id === n.id)));
}}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
@ -711,7 +982,13 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
<Background />
{/*<Controls />*/}
<Panel position="top-left">
<ActionBar onSave={saveFlowDataToServer}></ActionBar>
<ActionBar
onSave={saveFlowDataToServer}
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
></ActionBar>
</Panel>
<AlignmentGuides />
</ReactFlow>
@ -810,10 +1087,8 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini
/>
</div>
)}
</div>
);
};
export default FlowEditorWithProvider;
Loading…
Cancel
Save