From 1a8629d84cd2735dd9f3474bfb980773e2f00522 Mon Sep 17 00:00:00 2001 From: ZLY Date: Tue, 14 Oct 2025 10:58:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(flowEditor):=20=E6=B7=BB=E5=8A=A0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=E5=92=8C=E6=92=A4?= =?UTF-8?q?=E9=94=80=E9=87=8D=E5=81=9A=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 actionBar 中添加撤销和重做按钮 - 实现 HistoryProvider 和 useHistory hook 来管理历史记录 - 为节点和边的变化添加快照记录机制 - 支持通过快捷键 Ctrl+Z 撤销和 Ctrl+Y/Ctrl+Shift+Z 重做- 在节点拖动、连接创建、节点删除等操作后自动记录历史- 添加防抖机制避免频繁的位置变化记录 - 实现历史记录长度限制防止内存泄漏 - 更新 FlowEditor 组件结构以支持历史记录功能 --- src/pages/flowEditor/components/actionBar.tsx | 38 ++- .../flowEditor/components/historyContext.tsx | 172 ++++++++++ src/pages/flowEditor/index.tsx | 313 ++++++++++++++++-- 3 files changed, 501 insertions(+), 22 deletions(-) create mode 100644 src/pages/flowEditor/components/historyContext.tsx diff --git a/src/pages/flowEditor/components/actionBar.tsx b/src/pages/flowEditor/components/actionBar.tsx index f43b4b0..6088f08 100644 --- a/src/pages/flowEditor/components/actionBar.tsx +++ b/src/pages/flowEditor/components/actionBar.tsx @@ -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 = ({ onSave }) => { +const ActionBar: React.FC = ({ + onSave, + onUndo, + onRedo, + canUndo = false, + canRedo = false + }) => { const { logBarStatus } = useSelector((state) => state.ideContainer); const dispatch = useDispatch(); @@ -40,6 +50,28 @@ const ActionBar: React.FC = ({ onSave }) => { 日志 + + + + ); }; diff --git a/src/pages/flowEditor/components/historyContext.tsx b/src/pages/flowEditor/components/historyContext.tsx new file mode 100644 index 0000000..259b12a --- /dev/null +++ b/src/pages/flowEditor/components/historyContext.tsx @@ -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(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 = ({ + 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 ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/pages/flowEditor/index.tsx b/src/pages/flowEditor/index.tsx index 9ab6dad..ae114a6 100644 --- a/src/pages/flowEditor/index.tsx +++ b/src/pages/flowEditor/index.tsx @@ -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(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
Loading...
; + } + + return ( + { + setNodes(newNodes); + setEdges(newEdges); + }} + > + + + ); +}; + +// 创建一个新的组件来包含 ReactFlow 和其他 UI 元素 +const FlowEditorContent: React.FC = (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 (
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 {/**/} - + @@ -810,10 +1087,8 @@ const FlowEditor: React.FC<{ initialData?: any, useDefault?: boolean }> = ({ ini />
)} - ); - }; export default FlowEditorWithProvider; \ No newline at end of file