Browse Source

feat(workflow): 为群组调整大小添加撤销重做支持

1. 移除GroupNode组件内置的resize事件处理,改为通过React Flow的维度变更统一处理
2. 在工作流存储中新增群组缩放状态追踪,仅在群组缩放开始/结束时创建撤销检查点
3. 修复原本维度变更不会标记未保存更改的问题,将群组维度变更纳入未保存更改判断
4. 完善moveGroupNodes函数,添加前置校验并补充撤销检查点
5. 新增多组撤销重做测试用例,覆盖群组创建、删除、节点增删、元数据修改、移动和缩放场景
feature/group
huangmin 1 month ago
parent
commit
7424fb15b5
  1. 7
      src/components/nodes/GroupNode.tsx
  2. 310
      src/store/__tests__/undoRedo.test.ts
  3. 46
      src/store/workflowStore.ts

7
src/components/nodes/GroupNode.tsx

@ -80,11 +80,6 @@ export function GroupNode({ data, selected }: NodeProps<GroupNodeType>) {
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<GroupNodeType>) {
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}
/>
<div
className="h-full w-full overflow-visible rounded-xl"

310
src/store/__tests__/undoRedo.test.ts

@ -595,6 +595,39 @@ describe("Undo/Redo integration", () => {
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", () => {

46
src/store/workflowStore.ts

@ -445,6 +445,7 @@ let autoSaveIntervalId: ReturnType<typeof setInterval> | 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<typeof setTimeout> | null = null;
// When true, a remove-checkpoint was already pushed in the current React Flow
@ -1239,6 +1240,9 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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<WorkflowStore> = (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<WorkflowStore> = (set, get) => ({
return {
nodes: nextNodes,
groups: nextGroups,
...(hasMeaningfulChange ? { hasUnsavedChanges: true } : {}),
...(hasMeaningfulChange || hasGroupDimensionChange ? { hasUnsavedChanges: true } : {}),
};
});
@ -1865,9 +1892,14 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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<WorkflowStore> = (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,
}));
},

Loading…
Cancel
Save