From 7424fb15b5fd04f02e4a13ae44847a7d69c9d766 Mon Sep 17 00:00:00 2001 From: huangmin <2927933426@qq.com> Date: Mon, 8 Jun 2026 14:58:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E4=B8=BA=E7=BE=A4=E7=BB=84?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=A4=A7=E5=B0=8F=E6=B7=BB=E5=8A=A0=E6=92=A4?= =?UTF-8?q?=E9=94=80=E9=87=8D=E5=81=9A=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 移除GroupNode组件内置的resize事件处理,改为通过React Flow的维度变更统一处理 2. 在工作流存储中新增群组缩放状态追踪,仅在群组缩放开始/结束时创建撤销检查点 3. 修复原本维度变更不会标记未保存更改的问题,将群组维度变更纳入未保存更改判断 4. 完善moveGroupNodes函数,添加前置校验并补充撤销检查点 5. 新增多组撤销重做测试用例,覆盖群组创建、删除、节点增删、元数据修改、移动和缩放场景 --- src/components/nodes/GroupNode.tsx | 7 - src/store/__tests__/undoRedo.test.ts | 310 +++++++++++++++++++++++++++ src/store/workflowStore.ts | 46 +++- 3 files changed, 354 insertions(+), 9 deletions(-) diff --git a/src/components/nodes/GroupNode.tsx b/src/components/nodes/GroupNode.tsx index 27cceca4..b56e475b 100644 --- a/src/components/nodes/GroupNode.tsx +++ b/src/components/nodes/GroupNode.tsx @@ -80,11 +80,6 @@ export function GroupNode({ data, selected }: NodeProps) { const bgColor = GROUP_COLORS[group.color]; const showResizeControls = selected || isHovered; - const handleResize = (_: unknown, params: { width: number; height: number }) => { - updateGroup(groupId, { - size: { width: params.width, height: params.height }, - }); - }; return ( <> @@ -94,8 +89,6 @@ export function GroupNode({ data, selected }: NodeProps) { minHeight={120} lineClassName="!border-neutral-400/40" handleClassName="!h-4 !w-4 !border-neutral-300 !bg-neutral-700 !shadow-sm !p-0" - onResize={handleResize} - onResizeEnd={handleResize} />
{ expect(Object.keys(store.groups).length).toBe(0); }); + it("redoes creating a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + store.addNode("imageInput", { x: 100, y: 0 }); + }); + + store = useWorkflowStore.getState(); + const nodeIds = store.nodes.map((n) => n.id); + let groupId = ""; + + act(() => { + groupId = store.createGroup(nodeIds); + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeUndefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeDefined(); + expect(store.nodes.some((node) => node.id === `group-node-${groupId}`)).toBe(true); + expect(store.nodes.filter((node) => node.groupId === groupId)).toHaveLength(2); + }); + it("undoes deleting a group", () => { let store = useWorkflowStore.getState(); @@ -626,6 +659,283 @@ describe("Undo/Redo integration", () => { store = useWorkflowStore.getState(); expect(Object.keys(store.groups).length).toBe(1); }); + + it("redoes deleting a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + }); + store = useWorkflowStore.getState(); + const nodeIds = store.nodes.map((n) => n.id); + + let groupId = ""; + act(() => { + groupId = store.createGroup(nodeIds); + }); + + act(() => { + store.deleteGroup(groupId); + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeDefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeUndefined(); + expect(store.nodes.some((node) => node.id === `group-node-${groupId}`)).toBe(false); + expect(store.nodes.every((node) => node.groupId !== groupId && node.parentId !== `group-node-${groupId}`)).toBe(true); + }); + + it("undoes and redoes removing nodes from a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + store.addNode("imageInput", { x: 100, y: 0 }); + }); + + store = useWorkflowStore.getState(); + const nodeIds = store.nodes.map((n) => n.id); + const nodeIdToRemove = nodeIds[0]; + let groupId = ""; + + act(() => { + groupId = store.createGroup(nodeIds); + }); + + act(() => { + store.removeNodesFromGroup([nodeIdToRemove]); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === nodeIdToRemove)?.groupId).toBeUndefined(); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === nodeIdToRemove)?.groupId).toBe(groupId); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === nodeIdToRemove)?.groupId).toBeUndefined(); + }); + + it("undoes and redoes adding nodes to a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + store.addNode("imageInput", { x: 300, y: 0 }); + }); + + store = useWorkflowStore.getState(); + const [firstNodeId, secondNodeId] = store.nodes.map((n) => n.id); + let groupId = ""; + + act(() => { + groupId = store.createGroup([firstNodeId]); + }); + + act(() => { + store.addNodesToGroup([secondNodeId], groupId); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === secondNodeId)?.groupId).toBe(groupId); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === secondNodeId)?.groupId).toBeUndefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === secondNodeId)?.groupId).toBe(groupId); + }); + + it("undoes and redoes group metadata changes", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + }); + + store = useWorkflowStore.getState(); + let groupId = ""; + act(() => { + groupId = store.createGroup(store.nodes.map((n) => n.id)); + }); + + act(() => { + store.updateGroup(groupId, { name: "Updated group", color: "purple", locked: true }); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].name).toBe("Updated group"); + expect(store.groups[groupId].color).toBe("purple"); + expect(store.groups[groupId].locked).toBe(true); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].name).not.toBe("Updated group"); + expect(store.groups[groupId].color).not.toBe("purple"); + expect(store.groups[groupId].locked).toBeUndefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].name).toBe("Updated group"); + expect(store.groups[groupId].color).toBe("purple"); + expect(store.groups[groupId].locked).toBe(true); + }); + + it("undoes and redoes moving a group through the store API", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 50, y: 80 }); + }); + + store = useWorkflowStore.getState(); + let groupId = ""; + act(() => { + groupId = store.createGroup(store.nodes.map((n) => n.id)); + }); + + store = useWorkflowStore.getState(); + const originalPosition = store.groups[groupId].position; + + act(() => { + store.moveGroupNodes(groupId, { x: 40, y: 25 }); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].position).toEqual({ + x: originalPosition.x + 40, + y: originalPosition.y + 25, + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].position).toEqual(originalPosition); + expect(store.nodes.find((node) => node.id === `group-node-${groupId}`)?.position).toEqual(originalPosition); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].position).toEqual({ + x: originalPosition.x + 40, + y: originalPosition.y + 25, + }); + expect(store.nodes.find((node) => node.id === `group-node-${groupId}`)?.position).toEqual({ + x: originalPosition.x + 40, + y: originalPosition.y + 25, + }); + }); + + it("undoes and redoes resizing a group from React Flow dimensions changes", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 50, y: 80 }); + }); + + store = useWorkflowStore.getState(); + let groupId = ""; + act(() => { + groupId = store.createGroup(store.nodes.map((n) => n.id)); + }); + + store = useWorkflowStore.getState(); + const groupNodeId = `group-node-${groupId}`; + const originalSize = store.groups[groupId].size; + + act(() => { + store.onNodesChange([ + { + id: groupNodeId, + type: "dimensions", + dimensions: { + width: originalSize.width + 80, + height: originalSize.height + 40, + }, + resizing: true, + setAttributes: true, + }, + { + id: groupNodeId, + type: "dimensions", + dimensions: { + width: originalSize.width + 80, + height: originalSize.height + 40, + }, + resizing: false, + setAttributes: true, + }, + ]); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].size).toEqual({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + expect(store.nodes.find((node) => node.id === groupNodeId)?.style).toMatchObject({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].size).toEqual(originalSize); + expect(store.nodes.find((node) => node.id === groupNodeId)?.style).toMatchObject(originalSize); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].size).toEqual({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + expect(store.nodes.find((node) => node.id === groupNodeId)?.style).toMatchObject({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + }); }); describe("hasUnsavedChanges is set on undo/redo", () => { diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 92a56be9..434644b5 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -445,6 +445,7 @@ let autoSaveIntervalId: ReturnType | null = null; // Undo/redo state (module-level, not in Zustand to avoid serialization) const undoManager = new UndoManager(); let isDragging = false; +let isResizing = false; let pendingDataSnapshot: UndoSnapshot | null = null; let dataChangeTimer: ReturnType | null = null; // When true, a remove-checkpoint was already pushed in the current React Flow @@ -1239,6 +1240,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const effectiveChanges = changes.filter((change) => isNodeChangeEffective(change, nodeById)); if (effectiveChanges.length === 0) return; + const hasGroupDimensionChange = effectiveChanges.some( + (c) => c.type === "dimensions" && nodeById.get(c.id)?.type === "group" + ); // Only mark as unsaved for meaningful changes (not selection changes) const hasMeaningfulChange = effectiveChanges.some( (c) => c.type !== "select" && c.type !== "dimensions" @@ -1261,6 +1265,29 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ isDragging = false; } + // Undo: capture snapshot when resizing a group starts. Most dimensions + // changes are measurement-only React Flow updates, so only group resize is + // treated as an undoable canvas edit here. + const hasResizeStart = effectiveChanges.some( + (c) => + c.type === "dimensions" && + nodeById.get(c.id)?.type === "group" && + (c as { resizing?: boolean }).resizing === true + ); + const hasResizeEnd = effectiveChanges.some( + (c) => + c.type === "dimensions" && + nodeById.get(c.id)?.type === "group" && + (c as { resizing?: boolean }).resizing === false + ); + if (hasResizeStart && !isResizing) { + isResizing = true; + pushUndoCheckpoint(get, set); + } + if (hasResizeEnd && isResizing) { + isResizing = false; + } + // Undo: capture snapshot before node removal — but skip if onEdgesChange // already pushed a checkpoint in this same deleteElements cycle // (React Flow v12 fires edge removals BEFORE node removals). @@ -1350,7 +1377,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ return { nodes: nextNodes, groups: nextGroups, - ...(hasMeaningfulChange ? { hasUnsavedChanges: true } : {}), + ...(hasMeaningfulChange || hasGroupDimensionChange ? { hasUnsavedChanges: true } : {}), }; }); @@ -1865,9 +1892,14 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }, moveGroupNodes: (groupId: string, delta: { x: number; y: number }) => { + if (delta.x === 0 && delta.y === 0) return; + const group = get().groups[groupId]; + if (!group) return; + pushUndoCheckpoint(get, set); + const groupNodeId = getGroupNodeId(groupId); set((state) => ({ nodes: state.nodes.map((node) => - node.id === getGroupNodeId(groupId) + node.id === groupNodeId ? { ...node, position: { @@ -1877,6 +1909,16 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } : node ) as WorkflowNode[], + groups: { + ...state.groups, + [groupId]: { + ...state.groups[groupId], + position: { + x: state.groups[groupId].position.x + delta.x, + y: state.groups[groupId].position.y + delta.y, + }, + }, + }, hasUnsavedChanges: true, })); },