From 982cd7d3707b4f0747ed30edcd73fd29fdbe8f63 Mon Sep 17 00:00:00 2001 From: TianYun Date: Mon, 15 Jun 2026 14:32:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=84=E5=86=85=E7=9A=84=E8=8A=82=E7=82=B9?= =?UTF-8?q?=20=E6=97=A0=E6=B3=95=E6=8B=96=E6=8B=BD=E5=88=B0=E7=BB=84?= =?UTF-8?q?=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/__tests__/GroupNode.test.tsx | 39 +++++++++++ src/components/nodes/GroupNode.tsx | 64 +++++++++++++++++-- .../workflowStore.integration.test.ts | 12 +++- src/store/workflowStore.ts | 19 +++--- 4 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/components/__tests__/GroupNode.test.tsx b/src/components/__tests__/GroupNode.test.tsx index e4ba178f..7ec7289f 100644 --- a/src/components/__tests__/GroupNode.test.tsx +++ b/src/components/__tests__/GroupNode.test.tsx @@ -38,12 +38,18 @@ vi.mock("@xyflow/react", async () => { }) => (isVisible ?
{children}
: null), NodeResizer: ({ isVisible, + minWidth, + minHeight, }: { isVisible: boolean; + minWidth: number; + minHeight: number; }) => (
), NodeResizeControl: ({ @@ -142,6 +148,39 @@ describe("GroupNode", () => { expect(screen.getByTestId("group-resize-corner")).toHaveAttribute("title", "缩放分组"); }); + it("keeps resize minimums large enough to contain grouped nodes", () => { + mockUseWorkflowStore.mockReturnValue({ + groups: { "group-1": defaultGroup }, + nodes: [ + { + id: "node-1", + type: "prompt", + position: { x: 260, y: 140 }, + style: { width: 180, height: 90 }, + groupId: "group-1", + }, + ], + edges: [], + onNodesChange: mockOnNodesChange, + updateGroup: mockUpdateGroup, + deleteGroup: mockDeleteGroup, + runGroup: mockRunGroup, + cancelGroupTask: mockCancelGroupTask, + runningGroupIds: new Set(), + }); + + render( + + + + ); + + expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-min-width", "460"); + expect(screen.getByTestId("node-resizer")).toHaveAttribute("data-min-height", "250"); + expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-min-width", "460"); + expect(screen.getByTestId("node-resize-control")).toHaveAttribute("data-min-height", "250"); + }); + it("returns null when group metadata is missing", () => { mockUseWorkflowStore.mockReturnValue({ groups: {}, diff --git a/src/components/nodes/GroupNode.tsx b/src/components/nodes/GroupNode.tsx index 561ecfcf..48c2f023 100644 --- a/src/components/nodes/GroupNode.tsx +++ b/src/components/nodes/GroupNode.tsx @@ -36,6 +36,9 @@ const LAYOUT_OPTIONS: { layout: GroupLayout; labelKey: TranslationKey }[] = [ ]; const STACK_GAP = 20; +const GROUP_MIN_WIDTH = 200; +const GROUP_MIN_HEIGHT = 120; +const GROUP_CHILD_PADDING = 20; function getNodeSize(node: FlowNode) { return { @@ -177,6 +180,57 @@ export function GroupNode({ id, data, selected }: NodeProps) { [edges, groupId, nodes] ); + const groupMemberNodes = useMemo( + () => nodes.filter((node) => node.type !== "group" && (node.groupId === groupId || node.parentId === id)), + [groupId, id, nodes] + ); + + const resizeBounds = useMemo(() => { + let minWidth = GROUP_MIN_WIDTH; + let minHeight = GROUP_MIN_HEIGHT; + let maxRight = 0; + let maxBottom = 0; + + groupMemberNodes.forEach((node) => { + const { width, height } = getNodeSize(node); + maxRight = Math.max(maxRight, node.position.x + width); + maxBottom = Math.max(maxBottom, node.position.y + height); + }); + + if (groupMemberNodes.length > 0) { + minWidth = Math.max(minWidth, Math.ceil(maxRight + GROUP_CHILD_PADDING)); + minHeight = Math.max(minHeight, Math.ceil(maxBottom + GROUP_CHILD_PADDING)); + } + + return { minWidth, minHeight }; + }, [groupMemberNodes]); + + const shouldResizeGroup = useCallback( + (_event: unknown, params: { x: number; y: number; width: number; height: number }) => { + if (!group) return true; + if (groupMemberNodes.length === 0) return true; + + const right = params.x + params.width; + const bottom = params.y + params.height; + + return groupMemberNodes.every((node) => { + const { width, height } = getNodeSize(node); + const nodeLeft = group.position.x + node.position.x; + const nodeTop = group.position.y + node.position.y; + const nodeRight = nodeLeft + width; + const nodeBottom = nodeTop + height; + + return ( + nodeLeft >= params.x && + nodeTop >= params.y && + nodeRight <= right && + nodeBottom <= bottom + ); + }); + }, + [group, groupMemberNodes] + ); + const handleBatchDownload = useCallback( async (event: React.MouseEvent) => { event.stopPropagation(); @@ -373,15 +427,17 @@ export function GroupNode({ id, data, selected }: NodeProps) {
{ regroupedNodes.forEach((node) => { expect(node.groupId).toBe(secondGroupId); expect(node.parentId).toBe(`group-node-${secondGroupId}`); + expect(node.extent).toBeUndefined(); + expect(node.expandParent).toBeUndefined(); }); }); }); @@ -3049,9 +3051,13 @@ describe("workflowStore integration tests", () => { store.addNodesToGroup(["ease-1", "vs-1", "audio-1"], "group-1"); const nodes = useWorkflowStore.getState().nodes; - expect(nodes.find((n) => n.id === "ease-1")?.groupId).toBe("group-1"); - expect(nodes.find((n) => n.id === "vs-1")?.groupId).toBe("group-1"); - expect(nodes.find((n) => n.id === "audio-1")?.groupId).toBe("group-1"); + ["ease-1", "vs-1", "audio-1"].forEach((nodeId) => { + const node = nodes.find((n) => n.id === nodeId); + expect(node?.groupId).toBe("group-1"); + expect(node?.parentId).toBe("group-node-group-1"); + expect(node?.extent).toBeUndefined(); + expect(node?.expandParent).toBeUndefined(); + }); }); }); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 3cb15ead..05d54035 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -630,17 +630,16 @@ function normalizeNativeGroupNodes( if ( !isAlreadyNativeChild || - node.extent !== "parent" || - node.expandParent !== true + node.extent !== undefined || + node.expandParent !== undefined ) { changed = true; } + const { extent: _extent, expandParent: _expandParent, ...nodeWithoutDragBounds } = node; return { - ...node, + ...nodeWithoutDragBounds, parentId: groupNodeId, - extent: "parent", - expandParent: true, groupId, position: nextPosition, selected: false, @@ -1682,11 +1681,10 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ...state.nodes.filter((node) => !selectedGroupNodeIds.has(node.id)).map((node) => { if (!selectedIds.has(node.id)) return node; const absolutePosition = absolutePositionByNodeId.get(node.id) ?? getAbsoluteNodePosition(node, nodeById); + const { extent: _extent, expandParent: _expandParent, ...nodeWithoutDragBounds } = node; return { - ...node, + ...nodeWithoutDragBounds, parentId: groupNode.id, - extent: "parent", - expandParent: true, groupId: id, position: { x: absolutePosition.x - newGroup.position.x, @@ -1741,11 +1739,10 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (!group || node.type === "group" || !nodeIds.includes(node.id)) return node; const nodeById = new Map(state.nodes.map((n) => [n.id, n])); const absolutePosition = getAbsoluteNodePosition(node, nodeById); + const { extent: _extent, expandParent: _expandParent, ...nodeWithoutDragBounds } = node; return { - ...node, + ...nodeWithoutDragBounds, parentId: groupNodeId, - extent: "parent", - expandParent: true, groupId, position: { x: absolutePosition.x - group.position.x,