Browse Source

组内的节点 无法拖拽到组外

feature/composer
TianYun 4 weeks ago
parent
commit
982cd7d370
  1. 39
      src/components/__tests__/GroupNode.test.tsx
  2. 64
      src/components/nodes/GroupNode.tsx
  3. 12
      src/store/__tests__/workflowStore.integration.test.ts
  4. 19
      src/store/workflowStore.ts

39
src/components/__tests__/GroupNode.test.tsx

@ -38,12 +38,18 @@ vi.mock("@xyflow/react", async () => {
}) => (isVisible ? <div data-testid="node-toolbar">{children}</div> : null),
NodeResizer: ({
isVisible,
minWidth,
minHeight,
}: {
isVisible: boolean;
minWidth: number;
minHeight: number;
}) => (
<div
data-testid="node-resizer"
data-visible={isVisible}
data-min-width={minWidth}
data-min-height={minHeight}
/>
),
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<string>(),
});
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
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: {},

64
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<GroupNodeType>) {
[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<GroupNodeType>) {
<NodeResizer
isVisible={showResizeControls}
minWidth={200}
minHeight={120}
minWidth={resizeBounds.minWidth}
minHeight={resizeBounds.minHeight}
shouldResize={shouldResizeGroup}
lineClassName="!border-neutral-400/40"
handleClassName="!h-5 !w-5 !border-none !bg-transparent !p-0"
/>
<NodeResizeControl
position="bottom-right"
minWidth={200}
minHeight={120}
minWidth={resizeBounds.minWidth}
minHeight={resizeBounds.minHeight}
shouldResize={shouldResizeGroup}
className="group-resize-corner-control nodrag nopan nowheel"
>
<div

12
src/store/__tests__/workflowStore.integration.test.ts

@ -2997,6 +2997,8 @@ describe("workflowStore integration tests", () => {
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();
});
});
});

19
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<WorkflowStore> = (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<WorkflowStore> = (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,

Loading…
Cancel
Save