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>
This commit is contained in:
skunkworxdark
2025-07-21 05:44:29 +01:00
committed by GitHub
parent 564f4f7a60
commit cacfb183a6
5 changed files with 396 additions and 15 deletions

View File

@@ -0,0 +1,194 @@
import {
Button,
CompositeNumberInput,
CompositeSlider,
Divider,
Flex,
FormControl,
FormLabel,
Grid,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Select,
} from '@invoke-ai/ui-library';
import { useReactFlow } from '@xyflow/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { useAutoLayout } from 'features/nodes/hooks/useAutoLayout';
import {
layeringStrategyChanged,
layerSpacingChanged,
layoutDirectionChanged,
nodeAlignmentChanged,
nodeSpacingChanged,
selectLayeringStrategy,
selectLayerSpacing,
selectLayoutDirection,
selectNodeAlignment,
selectNodeSpacing,
zLayeringStrategy,
zLayoutDirection,
zNodeAlignment,
} from 'features/nodes/store/workflowSettingsSlice';
import { type ChangeEvent, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiMagicWandBold } from 'react-icons/pi';
const [useLayoutSettingsPopover] = buildUseBoolean(false);
export const AutoLayoutPopover = memo(() => {
const { t } = useTranslation();
const { fitView } = useReactFlow();
const autoLayout = useAutoLayout();
const dispatch = useAppDispatch();
const popover = useLayoutSettingsPopover();
const layeringStrategy = useAppSelector(selectLayeringStrategy);
const nodeSpacing = useAppSelector(selectNodeSpacing);
const layerSpacing = useAppSelector(selectLayerSpacing);
const layoutDirection = useAppSelector(selectLayoutDirection);
const nodeAlignment = useAppSelector(selectNodeAlignment);
const handleLayeringStrategyChanged = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const val = zLayeringStrategy.parse(e.target.value);
dispatch(layeringStrategyChanged(val));
},
[dispatch]
);
const handleNodeSpacingSliderChange = useCallback(
(v: number) => {
dispatch(nodeSpacingChanged(v));
},
[dispatch]
);
const handleNodeSpacingInputChange = useCallback(
(v: number) => {
dispatch(nodeSpacingChanged(v));
},
[dispatch]
);
const handleLayerSpacingSliderChange = useCallback(
(v: number) => {
dispatch(layerSpacingChanged(v));
},
[dispatch]
);
const handleLayerSpacingInputChange = useCallback(
(v: number) => {
dispatch(layerSpacingChanged(v));
},
[dispatch]
);
const handleLayoutDirectionChanged = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const val = zLayoutDirection.parse(e.target.value);
dispatch(layoutDirectionChanged(val));
},
[dispatch]
);
const handleNodeAlignmentChanged = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const val = zNodeAlignment.parse(e.target.value);
dispatch(nodeAlignmentChanged(val));
},
[dispatch]
);
const handleApplyAutoLayout = useCallback(() => {
autoLayout();
fitView({ duration: 300 });
popover.setFalse();
}, [autoLayout, fitView, popover]);
return (
<Popover isOpen={popover.isTrue} onClose={popover.setFalse} placement="top">
<PopoverTrigger>
<IconButton
tooltip={t('nodes.layout.autoLayout')}
aria-label={t('nodes.layout.autoLayout')}
icon={<PiMagicWandBold />}
onClick={popover.toggle}
/>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<Flex direction="column" gap={2}>
<FormControl>
<FormLabel>{t('nodes.layout.layoutDirection')}</FormLabel>
<Select size="sm" value={layoutDirection} onChange={handleLayoutDirectionChanged}>
<option value="LR">{t('nodes.layout.layoutDirectionRight')}</option>
<option value="TB">{t('nodes.layout.layoutDirectionDown')}</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>{t('nodes.layout.layeringStrategy')}</FormLabel>
<Select size="sm" value={layeringStrategy} onChange={handleLayeringStrategyChanged}>
<option value="network-simplex">{t('nodes.layout.networkSimplex')}</option>
<option value="longest-path">{t('nodes.layout.longestPath')}</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>{t('nodes.layout.alignment')}</FormLabel>
<Select size="sm" value={nodeAlignment} onChange={handleNodeAlignmentChanged}>
<option value="UL">{t('nodes.layout.alignmentUL')}</option>
<option value="DL">{t('nodes.layout.alignmentDL')}</option>
<option value="UR">{t('nodes.layout.alignmentUR')}</option>
<option value="DR">{t('nodes.layout.alignmentDR')}</option>
</Select>
</FormControl>
<Divider />
<FormControl>
<FormLabel>{t('nodes.layout.nodeSpacing')}</FormLabel>
<Grid w="full" gap={2} templateColumns="1fr auto">
<CompositeSlider min={0} max={200} value={nodeSpacing} onChange={handleNodeSpacingSliderChange} marks />
<CompositeNumberInput
value={nodeSpacing}
min={0}
max={200}
onChange={handleNodeSpacingInputChange}
w={24}
/>
</Grid>
</FormControl>
<FormControl>
<FormLabel>{t('nodes.layout.layerSpacing')}</FormLabel>
<Grid w="full" gap={2} templateColumns="1fr auto">
<CompositeSlider
min={0}
max={200}
value={layerSpacing}
onChange={handleLayerSpacingSliderChange}
marks
/>
<CompositeNumberInput
value={layerSpacing}
min={0}
max={200}
onChange={handleLayerSpacingInputChange}
w={24}
/>
</Grid>
</FormControl>
<Divider />
<Button w="full" onClick={handleApplyAutoLayout}>
{t('common.apply')}
</Button>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
});
AutoLayoutPopover.displayName = 'AutoLayoutPopover';

View File

@@ -14,6 +14,8 @@ import {
PiMapPinBold,
} from 'react-icons/pi';
import { AutoLayoutPopover } from './AutoLayoutPopover';
const ViewportControls = () => {
const { t } = useTranslation();
const { zoomIn, zoomOut, fitView } = useReactFlow();
@@ -56,20 +58,7 @@ const ViewportControls = () => {
onClick={handleClickedFitView}
icon={<PiFrameCornersBold />}
/>
{/* <Tooltip
label={
shouldShowFieldTypeLegend
? t('nodes.hideLegendNodes')
: t('nodes.showLegendNodes')
}
>
<IconButton
aria-label="Toggle field type legend"
isChecked={shouldShowFieldTypeLegend}
onClick={handleClickedToggleFieldTypeLegend}
icon={<FaInfo />}
/>
</Tooltip> */}
<AutoLayoutPopover />
<IconButton
tooltip={shouldShowMinimapPanel ? t('nodes.hideMinimapnodes') : t('nodes.showMinimapnodes')}
aria-label={shouldShowMinimapPanel ? t('nodes.hideMinimapnodes') : t('nodes.showMinimapnodes')}

View File

@@ -0,0 +1,138 @@
import { graphlib, layout } from '@dagrejs/dagre';
import type { Edge, NodePositionChange } from '@xyflow/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectEdges, selectNodes } from 'features/nodes/store/selectors';
import {
selectLayeringStrategy,
selectLayerSpacing,
selectLayoutDirection,
selectNodeAlignment,
selectNodeSpacing,
} from 'features/nodes/store/workflowSettingsSlice';
import { NODE_WIDTH } from 'features/nodes/types/constants';
import type { AnyNode } from 'features/nodes/types/invocation';
import { isNotesNode } from 'features/nodes/types/invocation';
import { useCallback } from 'react';
const ESTIMATED_NOTES_NODE_HEIGHT = 200;
const DEFAULT_NODE_HEIGHT = NODE_WIDTH;
const getNodeHeight = (node: AnyNode): number => {
if (node.measured?.height) {
return node.measured.height;
}
if (isNotesNode(node)) {
return ESTIMATED_NOTES_NODE_HEIGHT;
}
return DEFAULT_NODE_HEIGHT;
};
const getNodeWidth = (node: AnyNode): number => {
if (node.measured?.width) {
return node.measured.width;
}
return NODE_WIDTH;
};
export const useAutoLayout = (): (() => void) => {
const dispatch = useAppDispatch();
const nodes = useAppSelector(selectNodes);
const edges = useAppSelector(selectEdges);
const nodeSpacing = useAppSelector(selectNodeSpacing);
const layerSpacing = useAppSelector(selectLayerSpacing);
const layeringStrategy = useAppSelector(selectLayeringStrategy);
const layoutDirection = useAppSelector(selectLayoutDirection);
const nodeAlignment = useAppSelector(selectNodeAlignment);
const autoLayout = useCallback(() => {
// We'll do graph layout using dagre, then convert the results to reactflow position changes
const g = new graphlib.Graph();
g.setGraph({
rankdir: layoutDirection,
nodesep: nodeSpacing,
ranksep: layerSpacing,
ranker: layeringStrategy,
align: nodeAlignment,
});
g.setDefaultEdgeLabel(() => ({}));
const selectedNodes = nodes.filter((n) => n.selected);
const isLayoutSelection = selectedNodes.length > 1 && nodes.length > selectedNodes.length;
const nodesToLayout = isLayoutSelection ? selectedNodes : nodes;
//Anchor of the selected nodes
const selectionAnchor = {
minX: Infinity,
minY: Infinity,
};
nodesToLayout.forEach((node) => {
// If we're laying out a selection, adjust the anchor to the top-left of the selection
if (isLayoutSelection) {
selectionAnchor.minX = Math.min(selectionAnchor.minX, node.position.x);
selectionAnchor.minY = Math.min(selectionAnchor.minY, node.position.y);
}
g.setNode(node.id, {
width: getNodeWidth(node),
height: getNodeHeight(node),
});
});
let edgesToLayout: Edge[] = edges;
if (isLayoutSelection) {
const nodesToLayoutIds = new Set(nodesToLayout.map((n) => n.id));
edgesToLayout = edges.filter((edge) => nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target));
}
edgesToLayout.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
layout(g);
// anchor for the new layout
const layoutAnchor = {
minX: Infinity,
minY: Infinity,
};
let offsetX = 0;
let offsetY = 0;
if (isLayoutSelection) {
// Get the top-left position of the new layout
nodesToLayout.forEach((node) => {
const nodeInfo = g.node(node.id);
// Convert from center to top-left
const topLeftX = nodeInfo.x - nodeInfo.width / 2;
const topLeftY = nodeInfo.y - nodeInfo.height / 2;
// Use the top-left coordinates to find the bounding box
layoutAnchor.minX = Math.min(layoutAnchor.minX, topLeftX);
layoutAnchor.minY = Math.min(layoutAnchor.minY, topLeftY);
});
// Calculate the offset needed to move the new layout to the original position
offsetX = selectionAnchor.minX - layoutAnchor.minX;
offsetY = selectionAnchor.minY - layoutAnchor.minY;
}
// Create reactflow position changes for each node based on the new layout
const positionChanges: NodePositionChange[] = nodesToLayout.map((node) => {
const nodeInfo = g.node(node.id);
// Convert from center-based position to top-left-based position
const x = nodeInfo.x - nodeInfo.width / 2;
const y = nodeInfo.y - nodeInfo.height / 2;
const newPosition = {
x: isLayoutSelection ? x + offsetX : x,
y: isLayoutSelection ? y + offsetY : y,
};
return { id: node.id, type: 'position', position: newPosition };
});
dispatch(nodesChanged(positionChanges));
}, [dispatch, edges, nodes, nodeSpacing, layerSpacing, layeringStrategy, layoutDirection, nodeAlignment]);
return autoLayout;
};

View File

@@ -3,12 +3,25 @@ import { createSelector, createSlice } from '@reduxjs/toolkit';
import { SelectionMode } from '@xyflow/react';
import type { PersistConfig, RootState } from 'app/store/store';
import type { Selector } from 'react-redux';
import z from 'zod';
export const zLayeringStrategy = z.enum(['network-simplex', 'longest-path']);
type LayeringStrategy = z.infer<typeof zLayeringStrategy>;
export const zLayoutDirection = z.enum(['TB', 'LR']);
type LayoutDirection = z.infer<typeof zLayoutDirection>;
export const zNodeAlignment = z.enum(['UL', 'UR', 'DL', 'DR']);
type NodeAlignment = z.infer<typeof zNodeAlignment>;
export type WorkflowSettingsState = {
_version: 1;
shouldShowMinimapPanel: boolean;
layeringStrategy: LayeringStrategy;
nodeSpacing: number;
layerSpacing: number;
layoutDirection: LayoutDirection;
shouldValidateGraph: boolean;
shouldAnimateEdges: boolean;
nodeAlignment: NodeAlignment;
nodeOpacity: number;
shouldSnapToGrid: boolean;
shouldColorEdges: boolean;
@@ -19,6 +32,11 @@ export type WorkflowSettingsState = {
const initialState: WorkflowSettingsState = {
_version: 1,
shouldShowMinimapPanel: true,
layeringStrategy: 'network-simplex',
nodeSpacing: 32,
layerSpacing: 32,
layoutDirection: 'LR',
nodeAlignment: 'UL',
shouldValidateGraph: true,
shouldAnimateEdges: true,
shouldSnapToGrid: false,
@@ -35,6 +53,18 @@ export const workflowSettingsSlice = createSlice({
shouldShowMinimapPanelChanged: (state, action: PayloadAction<boolean>) => {
state.shouldShowMinimapPanel = action.payload;
},
layeringStrategyChanged: (state, action: PayloadAction<LayeringStrategy>) => {
state.layeringStrategy = action.payload;
},
nodeSpacingChanged: (state, action: PayloadAction<number>) => {
state.nodeSpacing = action.payload;
},
layerSpacingChanged: (state, action: PayloadAction<number>) => {
state.layerSpacing = action.payload;
},
layoutDirectionChanged: (state, action: PayloadAction<LayoutDirection>) => {
state.layoutDirection = action.payload;
},
shouldValidateGraphChanged: (state, action: PayloadAction<boolean>) => {
state.shouldValidateGraph = action.payload;
},
@@ -53,6 +83,9 @@ export const workflowSettingsSlice = createSlice({
nodeOpacityChanged: (state, action: PayloadAction<number>) => {
state.nodeOpacity = action.payload;
},
nodeAlignmentChanged: (state, action: PayloadAction<NodeAlignment>) => {
state.nodeAlignment = action.payload;
},
selectionModeChanged: (state, action: PayloadAction<boolean>) => {
state.selectionMode = action.payload ? SelectionMode.Full : SelectionMode.Partial;
},
@@ -63,8 +96,13 @@ export const {
shouldAnimateEdgesChanged,
shouldColorEdgesChanged,
shouldShowMinimapPanelChanged,
layeringStrategyChanged,
nodeSpacingChanged,
layerSpacingChanged,
layoutDirectionChanged,
shouldShowEdgeLabelsChanged,
shouldSnapToGridChanged,
nodeAlignmentChanged,
shouldValidateGraphChanged,
nodeOpacityChanged,
selectionModeChanged,
@@ -96,3 +134,9 @@ export const selectShouldShowEdgeLabels = createWorkflowSettingsSelector((s) =>
export const selectNodeOpacity = createWorkflowSettingsSelector((s) => s.nodeOpacity);
export const selectShouldShowMinimapPanel = createWorkflowSettingsSelector((s) => s.shouldShowMinimapPanel);
export const selectShouldShouldValidateGraph = createWorkflowSettingsSelector((s) => s.shouldValidateGraph);
export const selectLayeringStrategy = createWorkflowSettingsSelector((s) => s.layeringStrategy);
export const selectNodeSpacing = createWorkflowSettingsSelector((s) => s.nodeSpacing);
export const selectLayerSpacing = createWorkflowSettingsSelector((s) => s.layerSpacing);
export const selectLayoutDirection = createWorkflowSettingsSelector((s) => s.layoutDirection);
export const selectNodeAlignment = createWorkflowSettingsSelector((s) => s.nodeAlignment);