Browse Source

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

feature/composer
TianYun 1 month 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), }) => (isVisible ? <div data-testid="node-toolbar">{children}</div> : null),
NodeResizer: ({ NodeResizer: ({
isVisible, isVisible,
minWidth,
minHeight,
}: { }: {
isVisible: boolean; isVisible: boolean;
minWidth: number;
minHeight: number;
}) => ( }) => (
<div <div
data-testid="node-resizer" data-testid="node-resizer"
data-visible={isVisible} data-visible={isVisible}
data-min-width={minWidth}
data-min-height={minHeight}
/> />
), ),
NodeResizeControl: ({ NodeResizeControl: ({
@ -142,6 +148,39 @@ describe("GroupNode", () => {
expect(screen.getByTestId("group-resize-corner")).toHaveAttribute("title", "缩放分组"); 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", () => { it("returns null when group metadata is missing", () => {
mockUseWorkflowStore.mockReturnValue({ mockUseWorkflowStore.mockReturnValue({
groups: {}, groups: {},

64
src/components/nodes/GroupNode.tsx

@ -36,6 +36,9 @@ const LAYOUT_OPTIONS: { layout: GroupLayout; labelKey: TranslationKey }[] = [
]; ];
const STACK_GAP = 20; const STACK_GAP = 20;
const GROUP_MIN_WIDTH = 200;
const GROUP_MIN_HEIGHT = 120;
const GROUP_CHILD_PADDING = 20;
function getNodeSize(node: FlowNode) { function getNodeSize(node: FlowNode) {
return { return {
@ -177,6 +180,57 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
[edges, groupId, nodes] [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( const handleBatchDownload = useCallback(
async (event: React.MouseEvent) => { async (event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
@ -373,15 +427,17 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
<NodeResizer <NodeResizer
isVisible={showResizeControls} isVisible={showResizeControls}
minWidth={200} minWidth={resizeBounds.minWidth}
minHeight={120} minHeight={resizeBounds.minHeight}
shouldResize={shouldResizeGroup}
lineClassName="!border-neutral-400/40" lineClassName="!border-neutral-400/40"
handleClassName="!h-5 !w-5 !border-none !bg-transparent !p-0" handleClassName="!h-5 !w-5 !border-none !bg-transparent !p-0"
/> />
<NodeResizeControl <NodeResizeControl
position="bottom-right" position="bottom-right"
minWidth={200} minWidth={resizeBounds.minWidth}
minHeight={120} minHeight={resizeBounds.minHeight}
shouldResize={shouldResizeGroup}
className="group-resize-corner-control nodrag nopan nowheel" className="group-resize-corner-control nodrag nopan nowheel"
> >
<div <div

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

@ -2997,6 +2997,8 @@ describe("workflowStore integration tests", () => {
regroupedNodes.forEach((node) => { regroupedNodes.forEach((node) => {
expect(node.groupId).toBe(secondGroupId); expect(node.groupId).toBe(secondGroupId);
expect(node.parentId).toBe(`group-node-${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"); store.addNodesToGroup(["ease-1", "vs-1", "audio-1"], "group-1");
const nodes = useWorkflowStore.getState().nodes; const nodes = useWorkflowStore.getState().nodes;
expect(nodes.find((n) => n.id === "ease-1")?.groupId).toBe("group-1"); ["ease-1", "vs-1", "audio-1"].forEach((nodeId) => {
expect(nodes.find((n) => n.id === "vs-1")?.groupId).toBe("group-1"); const node = nodes.find((n) => n.id === nodeId);
expect(nodes.find((n) => n.id === "audio-1")?.groupId).toBe("group-1"); 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 ( if (
!isAlreadyNativeChild || !isAlreadyNativeChild ||
node.extent !== "parent" || node.extent !== undefined ||
node.expandParent !== true node.expandParent !== undefined
) { ) {
changed = true; changed = true;
} }
const { extent: _extent, expandParent: _expandParent, ...nodeWithoutDragBounds } = node;
return { return {
...node, ...nodeWithoutDragBounds,
parentId: groupNodeId, parentId: groupNodeId,
extent: "parent",
expandParent: true,
groupId, groupId,
position: nextPosition, position: nextPosition,
selected: false, selected: false,
@ -1682,11 +1681,10 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
...state.nodes.filter((node) => !selectedGroupNodeIds.has(node.id)).map((node) => { ...state.nodes.filter((node) => !selectedGroupNodeIds.has(node.id)).map((node) => {
if (!selectedIds.has(node.id)) return node; if (!selectedIds.has(node.id)) return node;
const absolutePosition = absolutePositionByNodeId.get(node.id) ?? getAbsoluteNodePosition(node, nodeById); const absolutePosition = absolutePositionByNodeId.get(node.id) ?? getAbsoluteNodePosition(node, nodeById);
const { extent: _extent, expandParent: _expandParent, ...nodeWithoutDragBounds } = node;
return { return {
...node, ...nodeWithoutDragBounds,
parentId: groupNode.id, parentId: groupNode.id,
extent: "parent",
expandParent: true,
groupId: id, groupId: id,
position: { position: {
x: absolutePosition.x - newGroup.position.x, 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; if (!group || node.type === "group" || !nodeIds.includes(node.id)) return node;
const nodeById = new Map(state.nodes.map((n) => [n.id, n])); const nodeById = new Map(state.nodes.map((n) => [n.id, n]));
const absolutePosition = getAbsoluteNodePosition(node, nodeById); const absolutePosition = getAbsoluteNodePosition(node, nodeById);
const { extent: _extent, expandParent: _expandParent, ...nodeWithoutDragBounds } = node;
return { return {
...node, ...nodeWithoutDragBounds,
parentId: groupNodeId, parentId: groupNodeId,
extent: "parent",
expandParent: true,
groupId, groupId,
position: { position: {
x: absolutePosition.x - group.position.x, x: absolutePosition.x - group.position.x,

Loading…
Cancel
Save