Skip to content

Commit cacfb18

Browse files
Add auto layout controls to node editor (#8239)
* Add auto layout controls using elkjs to node editor Introduces auto layout functionality for the node editor using elkjs, including a new UI popover for layout options (placement strategy, layering, spacing, direction). Adds related state and actions to workflowSettingsSlice, updates translations, and ensures elkjs is included in optimized dependencies. * feat(nodes): Improve workflow auto-layout controls and accuracy - The auto-layout settings panel is updated to use `Select` dropdowns and `NumberInput` - The layout algorithm now uses the actual rendered dimensions of nodes from the DOM, falling back to estimates only when necessary. This results in a much more accurate and predictable layout. - The ELKjs library integration is refactored to fix some warnings * Update useAutoLayout.ts prettier * feat(nodes): Improve workflow auto-layout controls and accuracy - The auto-layout settings panel is updated to use `Select` dropdowns and `NumberInput` - The layout algorithm now uses the actual rendered dimensions of nodes from the DOM, falling back to estimates only when necessary. This results in a much more accurate and predictable layout. - The ELKjs library integration is refactored to fix some warnings * Update useAutoLayout.ts prettier * build(ui): import elkjs directly * updated to use dagrejs for autolayout updated to use dagrejs - it has less layout options but is already included but this is still WIP as some nodes don't report the height correctly. I am still investigating this... * Update useAutoLayout.ts update to fix layout issues * minor updates - pretty useAutoLayout.ts - add missing type import in ViewportControls.tsx - update pnpm-lock.yaml with elkjs removed * Update ViewportControls.tsx pnpm fix * Fix Frontend check + single node selection fix Fix Frontend check - remove unused export from workflowSettingsSlice.ts Update so that if you have a single node selected, it will auto layout all nodes, as this is a common thing to have a single node selected and means that you don't have to unselect it. * feat(ui): misc improvements for autolayout - Split popover into own component - Add util functions to get node w/h - Use magic wand icon for button - Fix sizing of input components - Use CompositeNumberInput instead of base chakra number input - Add zod schemas for string values and use them in the component to ensure state integrity * chore(ui): lint --------- Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
1 parent 564f4f7 commit cacfb18

File tree

5 files changed

+396
-15
lines changed

5 files changed

+396
-15
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,23 @@
11331133
"addItem": "Add Item",
11341134
"generateValues": "Generate Values",
11351135
"floatRangeGenerator": "Float Range Generator",
1136-
"integerRangeGenerator": "Integer Range Generator"
1136+
"integerRangeGenerator": "Integer Range Generator",
1137+
"layout": {
1138+
"autoLayout": "Auto Layout",
1139+
"layeringStrategy": "Layering Strategy",
1140+
"networkSimplex": "Network Simplex",
1141+
"longestPath": "Longest Path",
1142+
"nodeSpacing": "Node Spacing",
1143+
"layerSpacing": "Layer Spacing",
1144+
"layoutDirection": "Layout Direction",
1145+
"layoutDirectionRight": "Right",
1146+
"layoutDirectionDown": "Down",
1147+
"alignment": "Node Alignment",
1148+
"alignmentUL": "Top Left",
1149+
"alignmentDL": "Bottom Left",
1150+
"alignmentUR": "Top Right",
1151+
"alignmentDR": "Bottom Right"
1152+
}
11371153
},
11381154
"parameters": {
11391155
"aspect": "Aspect",
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
Button,
3+
CompositeNumberInput,
4+
CompositeSlider,
5+
Divider,
6+
Flex,
7+
FormControl,
8+
FormLabel,
9+
Grid,
10+
IconButton,
11+
Popover,
12+
PopoverArrow,
13+
PopoverBody,
14+
PopoverContent,
15+
PopoverTrigger,
16+
Select,
17+
} from '@invoke-ai/ui-library';
18+
import { useReactFlow } from '@xyflow/react';
19+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
20+
import { buildUseBoolean } from 'common/hooks/useBoolean';
21+
import { useAutoLayout } from 'features/nodes/hooks/useAutoLayout';
22+
import {
23+
layeringStrategyChanged,
24+
layerSpacingChanged,
25+
layoutDirectionChanged,
26+
nodeAlignmentChanged,
27+
nodeSpacingChanged,
28+
selectLayeringStrategy,
29+
selectLayerSpacing,
30+
selectLayoutDirection,
31+
selectNodeAlignment,
32+
selectNodeSpacing,
33+
zLayeringStrategy,
34+
zLayoutDirection,
35+
zNodeAlignment,
36+
} from 'features/nodes/store/workflowSettingsSlice';
37+
import { type ChangeEvent, memo, useCallback } from 'react';
38+
import { useTranslation } from 'react-i18next';
39+
import { PiMagicWandBold } from 'react-icons/pi';
40+
41+
const [useLayoutSettingsPopover] = buildUseBoolean(false);
42+
43+
export const AutoLayoutPopover = memo(() => {
44+
const { t } = useTranslation();
45+
const { fitView } = useReactFlow();
46+
const autoLayout = useAutoLayout();
47+
const dispatch = useAppDispatch();
48+
const popover = useLayoutSettingsPopover();
49+
const layeringStrategy = useAppSelector(selectLayeringStrategy);
50+
const nodeSpacing = useAppSelector(selectNodeSpacing);
51+
const layerSpacing = useAppSelector(selectLayerSpacing);
52+
const layoutDirection = useAppSelector(selectLayoutDirection);
53+
const nodeAlignment = useAppSelector(selectNodeAlignment);
54+
55+
const handleLayeringStrategyChanged = useCallback(
56+
(e: ChangeEvent<HTMLSelectElement>) => {
57+
const val = zLayeringStrategy.parse(e.target.value);
58+
dispatch(layeringStrategyChanged(val));
59+
},
60+
[dispatch]
61+
);
62+
63+
const handleNodeSpacingSliderChange = useCallback(
64+
(v: number) => {
65+
dispatch(nodeSpacingChanged(v));
66+
},
67+
[dispatch]
68+
);
69+
70+
const handleNodeSpacingInputChange = useCallback(
71+
(v: number) => {
72+
dispatch(nodeSpacingChanged(v));
73+
},
74+
[dispatch]
75+
);
76+
77+
const handleLayerSpacingSliderChange = useCallback(
78+
(v: number) => {
79+
dispatch(layerSpacingChanged(v));
80+
},
81+
[dispatch]
82+
);
83+
84+
const handleLayerSpacingInputChange = useCallback(
85+
(v: number) => {
86+
dispatch(layerSpacingChanged(v));
87+
},
88+
[dispatch]
89+
);
90+
91+
const handleLayoutDirectionChanged = useCallback(
92+
(e: ChangeEvent<HTMLSelectElement>) => {
93+
const val = zLayoutDirection.parse(e.target.value);
94+
dispatch(layoutDirectionChanged(val));
95+
},
96+
[dispatch]
97+
);
98+
99+
const handleNodeAlignmentChanged = useCallback(
100+
(e: ChangeEvent<HTMLSelectElement>) => {
101+
const val = zNodeAlignment.parse(e.target.value);
102+
dispatch(nodeAlignmentChanged(val));
103+
},
104+
[dispatch]
105+
);
106+
107+
const handleApplyAutoLayout = useCallback(() => {
108+
autoLayout();
109+
fitView({ duration: 300 });
110+
popover.setFalse();
111+
}, [autoLayout, fitView, popover]);
112+
113+
return (
114+
<Popover isOpen={popover.isTrue} onClose={popover.setFalse} placement="top">
115+
<PopoverTrigger>
116+
<IconButton
117+
tooltip={t('nodes.layout.autoLayout')}
118+
aria-label={t('nodes.layout.autoLayout')}
119+
icon={<PiMagicWandBold />}
120+
onClick={popover.toggle}
121+
/>
122+
</PopoverTrigger>
123+
<PopoverContent>
124+
<PopoverArrow />
125+
126+
<PopoverBody>
127+
<Flex direction="column" gap={2}>
128+
<FormControl>
129+
<FormLabel>{t('nodes.layout.layoutDirection')}</FormLabel>
130+
<Select size="sm" value={layoutDirection} onChange={handleLayoutDirectionChanged}>
131+
<option value="LR">{t('nodes.layout.layoutDirectionRight')}</option>
132+
<option value="TB">{t('nodes.layout.layoutDirectionDown')}</option>
133+
</Select>
134+
</FormControl>
135+
<FormControl>
136+
<FormLabel>{t('nodes.layout.layeringStrategy')}</FormLabel>
137+
<Select size="sm" value={layeringStrategy} onChange={handleLayeringStrategyChanged}>
138+
<option value="network-simplex">{t('nodes.layout.networkSimplex')}</option>
139+
<option value="longest-path">{t('nodes.layout.longestPath')}</option>
140+
</Select>
141+
</FormControl>
142+
<FormControl>
143+
<FormLabel>{t('nodes.layout.alignment')}</FormLabel>
144+
<Select size="sm" value={nodeAlignment} onChange={handleNodeAlignmentChanged}>
145+
<option value="UL">{t('nodes.layout.alignmentUL')}</option>
146+
<option value="DL">{t('nodes.layout.alignmentDL')}</option>
147+
<option value="UR">{t('nodes.layout.alignmentUR')}</option>
148+
<option value="DR">{t('nodes.layout.alignmentDR')}</option>
149+
</Select>
150+
</FormControl>
151+
<Divider />
152+
<FormControl>
153+
<FormLabel>{t('nodes.layout.nodeSpacing')}</FormLabel>
154+
<Grid w="full" gap={2} templateColumns="1fr auto">
155+
<CompositeSlider min={0} max={200} value={nodeSpacing} onChange={handleNodeSpacingSliderChange} marks />
156+
<CompositeNumberInput
157+
value={nodeSpacing}
158+
min={0}
159+
max={200}
160+
onChange={handleNodeSpacingInputChange}
161+
w={24}
162+
/>
163+
</Grid>
164+
</FormControl>
165+
<FormControl>
166+
<FormLabel>{t('nodes.layout.layerSpacing')}</FormLabel>
167+
<Grid w="full" gap={2} templateColumns="1fr auto">
168+
<CompositeSlider
169+
min={0}
170+
max={200}
171+
value={layerSpacing}
172+
onChange={handleLayerSpacingSliderChange}
173+
marks
174+
/>
175+
<CompositeNumberInput
176+
value={layerSpacing}
177+
min={0}
178+
max={200}
179+
onChange={handleLayerSpacingInputChange}
180+
w={24}
181+
/>
182+
</Grid>
183+
</FormControl>
184+
<Divider />
185+
<Button w="full" onClick={handleApplyAutoLayout}>
186+
{t('common.apply')}
187+
</Button>
188+
</Flex>
189+
</PopoverBody>
190+
</PopoverContent>
191+
</Popover>
192+
);
193+
});
194+
AutoLayoutPopover.displayName = 'AutoLayoutPopover';

invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
PiMapPinBold,
1515
} from 'react-icons/pi';
1616

17+
import { AutoLayoutPopover } from './AutoLayoutPopover';
18+
1719
const ViewportControls = () => {
1820
const { t } = useTranslation();
1921
const { zoomIn, zoomOut, fitView } = useReactFlow();
@@ -56,20 +58,7 @@ const ViewportControls = () => {
5658
onClick={handleClickedFitView}
5759
icon={<PiFrameCornersBold />}
5860
/>
59-
{/* <Tooltip
60-
label={
61-
shouldShowFieldTypeLegend
62-
? t('nodes.hideLegendNodes')
63-
: t('nodes.showLegendNodes')
64-
}
65-
>
66-
<IconButton
67-
aria-label="Toggle field type legend"
68-
isChecked={shouldShowFieldTypeLegend}
69-
onClick={handleClickedToggleFieldTypeLegend}
70-
icon={<FaInfo />}
71-
/>
72-
</Tooltip> */}
61+
<AutoLayoutPopover />
7362
<IconButton
7463
tooltip={shouldShowMinimapPanel ? t('nodes.hideMinimapnodes') : t('nodes.showMinimapnodes')}
7564
aria-label={shouldShowMinimapPanel ? t('nodes.hideMinimapnodes') : t('nodes.showMinimapnodes')}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { graphlib, layout } from '@dagrejs/dagre';
2+
import type { Edge, NodePositionChange } from '@xyflow/react';
3+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4+
import { nodesChanged } from 'features/nodes/store/nodesSlice';
5+
import { selectEdges, selectNodes } from 'features/nodes/store/selectors';
6+
import {
7+
selectLayeringStrategy,
8+
selectLayerSpacing,
9+
selectLayoutDirection,
10+
selectNodeAlignment,
11+
selectNodeSpacing,
12+
} from 'features/nodes/store/workflowSettingsSlice';
13+
import { NODE_WIDTH } from 'features/nodes/types/constants';
14+
import type { AnyNode } from 'features/nodes/types/invocation';
15+
import { isNotesNode } from 'features/nodes/types/invocation';
16+
import { useCallback } from 'react';
17+
18+
const ESTIMATED_NOTES_NODE_HEIGHT = 200;
19+
const DEFAULT_NODE_HEIGHT = NODE_WIDTH;
20+
21+
const getNodeHeight = (node: AnyNode): number => {
22+
if (node.measured?.height) {
23+
return node.measured.height;
24+
}
25+
if (isNotesNode(node)) {
26+
return ESTIMATED_NOTES_NODE_HEIGHT;
27+
}
28+
return DEFAULT_NODE_HEIGHT;
29+
};
30+
31+
const getNodeWidth = (node: AnyNode): number => {
32+
if (node.measured?.width) {
33+
return node.measured.width;
34+
}
35+
return NODE_WIDTH;
36+
};
37+
38+
export const useAutoLayout = (): (() => void) => {
39+
const dispatch = useAppDispatch();
40+
const nodes = useAppSelector(selectNodes);
41+
const edges = useAppSelector(selectEdges);
42+
const nodeSpacing = useAppSelector(selectNodeSpacing);
43+
const layerSpacing = useAppSelector(selectLayerSpacing);
44+
const layeringStrategy = useAppSelector(selectLayeringStrategy);
45+
const layoutDirection = useAppSelector(selectLayoutDirection);
46+
const nodeAlignment = useAppSelector(selectNodeAlignment);
47+
48+
const autoLayout = useCallback(() => {
49+
// We'll do graph layout using dagre, then convert the results to reactflow position changes
50+
const g = new graphlib.Graph();
51+
52+
g.setGraph({
53+
rankdir: layoutDirection,
54+
nodesep: nodeSpacing,
55+
ranksep: layerSpacing,
56+
ranker: layeringStrategy,
57+
align: nodeAlignment,
58+
});
59+
60+
g.setDefaultEdgeLabel(() => ({}));
61+
62+
const selectedNodes = nodes.filter((n) => n.selected);
63+
const isLayoutSelection = selectedNodes.length > 1 && nodes.length > selectedNodes.length;
64+
const nodesToLayout = isLayoutSelection ? selectedNodes : nodes;
65+
66+
//Anchor of the selected nodes
67+
const selectionAnchor = {
68+
minX: Infinity,
69+
minY: Infinity,
70+
};
71+
72+
nodesToLayout.forEach((node) => {
73+
// If we're laying out a selection, adjust the anchor to the top-left of the selection
74+
if (isLayoutSelection) {
75+
selectionAnchor.minX = Math.min(selectionAnchor.minX, node.position.x);
76+
selectionAnchor.minY = Math.min(selectionAnchor.minY, node.position.y);
77+
}
78+
79+
g.setNode(node.id, {
80+
width: getNodeWidth(node),
81+
height: getNodeHeight(node),
82+
});
83+
});
84+
85+
let edgesToLayout: Edge[] = edges;
86+
if (isLayoutSelection) {
87+
const nodesToLayoutIds = new Set(nodesToLayout.map((n) => n.id));
88+
edgesToLayout = edges.filter((edge) => nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target));
89+
}
90+
91+
edgesToLayout.forEach((edge) => {
92+
g.setEdge(edge.source, edge.target);
93+
});
94+
95+
layout(g);
96+
97+
// anchor for the new layout
98+
const layoutAnchor = {
99+
minX: Infinity,
100+
minY: Infinity,
101+
};
102+
let offsetX = 0;
103+
let offsetY = 0;
104+
105+
if (isLayoutSelection) {
106+
// Get the top-left position of the new layout
107+
nodesToLayout.forEach((node) => {
108+
const nodeInfo = g.node(node.id);
109+
// Convert from center to top-left
110+
const topLeftX = nodeInfo.x - nodeInfo.width / 2;
111+
const topLeftY = nodeInfo.y - nodeInfo.height / 2;
112+
// Use the top-left coordinates to find the bounding box
113+
layoutAnchor.minX = Math.min(layoutAnchor.minX, topLeftX);
114+
layoutAnchor.minY = Math.min(layoutAnchor.minY, topLeftY);
115+
});
116+
// Calculate the offset needed to move the new layout to the original position
117+
offsetX = selectionAnchor.minX - layoutAnchor.minX;
118+
offsetY = selectionAnchor.minY - layoutAnchor.minY;
119+
}
120+
121+
// Create reactflow position changes for each node based on the new layout
122+
const positionChanges: NodePositionChange[] = nodesToLayout.map((node) => {
123+
const nodeInfo = g.node(node.id);
124+
// Convert from center-based position to top-left-based position
125+
const x = nodeInfo.x - nodeInfo.width / 2;
126+
const y = nodeInfo.y - nodeInfo.height / 2;
127+
const newPosition = {
128+
x: isLayoutSelection ? x + offsetX : x,
129+
y: isLayoutSelection ? y + offsetY : y,
130+
};
131+
return { id: node.id, type: 'position', position: newPosition };
132+
});
133+
134+
dispatch(nodesChanged(positionChanges));
135+
}, [dispatch, edges, nodes, nodeSpacing, layerSpacing, layeringStrategy, layoutDirection, nodeAlignment]);
136+
137+
return autoLayout;
138+
};

0 commit comments

Comments
 (0)