From ddd17046e229e47835dd78cd78e6a72ed94053cc Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Tue, 8 Jul 2025 21:36:24 +0100 Subject: [PATCH 01/11] 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. --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 8 + invokeai/frontend/web/public/locales/en.json | 18 ++- .../BottomLeftPanel/ViewportControls.tsx | 143 ++++++++++++++++- .../src/features/nodes/hooks/useAutoLayout.ts | 146 ++++++++++++++++++ .../nodes/store/workflowSettingsSlice.ts | 42 +++++ invokeai/frontend/web/src/types/elkjs.d.ts | 20 +++ invokeai/frontend/web/vite.config.mts | 3 + 8 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts create mode 100644 invokeai/frontend/web/src/types/elkjs.d.ts diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 896f232f67e..45c6dc63d05 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -57,6 +57,7 @@ "cmdk": "^1.1.1", "compare-versions": "^6.1.1", "dockview": "^4.4.0", + "elkjs": "^0.10.0", "es-toolkit": "^1.39.5", "filesize": "^10.1.6", "fracturedjsonjs": "^4.1.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index a8ce2b263f4..ac3ef880876 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: dockview: specifier: ^4.4.0 version: 4.4.0(react@18.3.1) + elkjs: + specifier: ^0.10.0 + version: 0.10.0 es-toolkit: specifier: ^1.39.5 version: 1.39.6 @@ -2429,6 +2432,9 @@ packages: electron-to-chromium@1.5.179: resolution: {integrity: sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==} + elkjs@0.10.0: + resolution: {integrity: sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -7079,6 +7085,8 @@ snapshots: electron-to-chromium@1.5.179: {} + elkjs@0.10.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8f68ef1667f..50aa03a9657 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1125,7 +1125,23 @@ "addItem": "Add Item", "generateValues": "Generate Values", "floatRangeGenerator": "Float Range Generator", - "integerRangeGenerator": "Integer Range Generator" + "integerRangeGenerator": "Integer Range Generator", + "layout": { + "autoLayout": "Auto Layout", + "nodePlacementStrategy": "Node Placement Strategy", + "networkSimplex": "Network Simplex", + "brandesKoepf": "Brandes-Koepf", + "linearSegments": "Linear Segments", + "simplePlacement": "Simple Placement", + "layeringStrategy": "Layering Strategy", + "longestPath": "Longest Path", + "coffmanGraham": "Coffman-Graham", + "nodeSpacing": "Node Spacing", + "layerSpacing": "Layer Spacing", + "layoutDirection": "Layout Direction", + "layoutDirectionRight": "Right", + "layoutDirectionDown": "Down" + } }, "parameters": { "aspect": "Aspect", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index de0c62722e0..b061b97c560 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -1,7 +1,38 @@ -import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; +import { + Button, + ButtonGroup, + CompositeSlider, + Divider, + Flex, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverFooter, + PopoverTrigger, + Radio, + RadioGroup, + Text, +} 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 { + type LayeringStrategy, + layeringStrategyChanged, + layerSpacingChanged, + type LayoutDirection, + layoutDirectionChanged, + type NodePlacementStrategy, + nodePlacementStrategyChanged, + nodeSpacingChanged, + selectLayeringStrategy, + selectLayerSpacing, + selectLayoutDirection, + selectNodePlacementStrategy, + selectNodeSpacing, selectShouldShowMinimapPanel, shouldShowMinimapPanelChanged, } from 'features/nodes/store/workflowSettingsSlice'; @@ -9,16 +40,26 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFrameCornersBold, + PiGitDiffBold, PiMagnifyingGlassMinusBold, PiMagnifyingGlassPlusBold, PiMapPinBold, } from 'react-icons/pi'; +const [useLayoutSettingsPopover] = buildUseBoolean(false); + const ViewportControls = () => { const { t } = useTranslation(); const { zoomIn, zoomOut, fitView } = useReactFlow(); + const autoLayout = useAutoLayout(); const dispatch = useAppDispatch(); + const popover = useLayoutSettingsPopover(); const shouldShowMinimapPanel = useAppSelector(selectShouldShowMinimapPanel); + const nodePlacementStrategy = useAppSelector(selectNodePlacementStrategy); + const layeringStrategy = useAppSelector(selectLayeringStrategy); + const nodeSpacing = useAppSelector(selectNodeSpacing); + const layerSpacing = useAppSelector(selectLayerSpacing); + const layoutDirection = useAppSelector(selectLayoutDirection); const handleClickedZoomIn = useCallback(() => { zoomIn({ duration: 300 }); @@ -36,6 +77,47 @@ const ViewportControls = () => { dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel)); }, [shouldShowMinimapPanel, dispatch]); + const handleStrategyChanged = useCallback( + (value: NodePlacementStrategy) => { + dispatch(nodePlacementStrategyChanged(value)); + }, + [dispatch] + ); + + const handleLayeringStrategyChanged = useCallback( + (value: LayeringStrategy) => { + dispatch(layeringStrategyChanged(value)); + }, + [dispatch] + ); + + const handleNodeSpacingChanged = useCallback( + (v: number) => { + dispatch(nodeSpacingChanged(v)); + }, + [dispatch] + ); + + const handleLayerSpacingChanged = useCallback( + (v: number) => { + dispatch(layerSpacingChanged(v)); + }, + [dispatch] + ); + + const handleLayoutDirectionChanged = useCallback( + (value: LayoutDirection) => { + dispatch(layoutDirectionChanged(value)); + }, + [dispatch] + ); + + const handleApplyAutoLayout = useCallback(async () => { + await autoLayout(); + fitView({ duration: 300 }); + popover.setFalse(); + }, [autoLayout, fitView, popover]); + return ( { onClick={handleClickedFitView} icon={} /> + + + } + onClick={popover.toggle} + /> + + + + + + {t('nodes.layout.nodePlacementStrategy')} + + + {t('nodes.layout.networkSimplex')} + {t('nodes.layout.brandesKoepf')} + {t('nodes.layout.linearSegments')} + {t('nodes.layout.simplePlacement')} + + + + {t('nodes.layout.layeringStrategy')} + + + {t('nodes.layout.networkSimplex')} + {t('nodes.layout.longestPath')} + {t('nodes.layout.coffmanGraham')} + + + + {t('nodes.layout.layoutDirection')} + + + {t('nodes.layout.layoutDirectionRight')} + {t('nodes.layout.layoutDirectionDown')} + + + + + {t('nodes.layout.nodeSpacing')} + {nodeSpacing} + + + + {t('nodes.layout.layerSpacing')} + {layerSpacing} + + + + + + + + + {/* { + const dispatch = useAppDispatch(); + const nodes = useAppSelector(selectNodes); + const edges = useAppSelector(selectEdges); + const templates = useStore($templates); + const nodePlacementStrategy = useAppSelector(selectNodePlacementStrategy); + const layeringStrategy = useAppSelector(selectLayeringStrategy); + const nodeSpacing = useAppSelector(selectNodeSpacing); + const layerSpacing = useAppSelector(selectLayerSpacing); + const layoutDirection = useAppSelector(selectLayoutDirection); + + const autoLayout = useCallback(async () => { + const selectedNodes = nodes.filter((n) => n.selected); + const isLayoutingSelection = selectedNodes.length > 0; + + // We always include all nodes in the layout, so the layout engine can avoid overlaps. + const nodesToLayout = nodes; + + const nodeIdsToLayout = new Set(nodesToLayout.map((n) => n.id)); + const edgesToLayout = edges.filter((e) => nodeIdsToLayout.has(e.source) && nodeIdsToLayout.has(e.target)); + + const elkNodes: ElkNode[] = nodesToLayout.map((node) => { + let height = node.height; + + // If the node has no height, we need to estimate it. + if (!height) { + if (isInvocationNode(node)) { + // This is an invocation node. We can estimate its height based on the number of fields. + const template = templates[node.data.type]; + if (template) { + const numInputs = Object.keys(template.inputs).length; + const numOutputs = Object.keys(template.outputs).length; + height = + ESTIMATED_NODE_HEADER_HEIGHT + + (numInputs + numOutputs) * ESTIMATED_FIELD_HEIGHT + + ESTIMATED_NODE_FOOTER_HEIGHT; + } + } else if (node.type === 'notes') { + // This is a notes node. They have a fixed default size. + height = ESTIMATED_NOTES_NODE_HEIGHT; + } + } + + const elkNode: ElkNode = { + id: node.id, + width: node.width || NODE_WIDTH, + height: height || 200, // A final fallback just in case. + }; + + // If we are layouting a selection, we must provide the positions of all unselected nodes to + // the layout engine. This allows the engine to position the selected nodes relative to them. + if (isLayoutingSelection && !node.selected) { + elkNode.x = node.position.x; + elkNode.y = node.position.y; + } + + return elkNode; + }); + + const elkEdges: ElkEdge[] = edgesToLayout.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })); + + const graph: ElkNode = { + id: 'root', + width: 0, + height: 0, + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': layoutDirection, + // Spacing between nodes in the same layer (vertical) + 'elk.spacing.nodeNode': String(nodeSpacing), + // Spacing between nodes in adjacent layers (horizontal) + 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing), + // Spacing between an edge and a node + 'elk.spacing.edgeNode': '50', + // layout strategy for node placement + 'elk.layered.nodePlacement.strategy': nodePlacementStrategy, + // layering strategy + 'elk.layered.layering.strategy': layeringStrategy, + }, + children: elkNodes, + edges: elkEdges, + }; + + const layout = await elk.layout(graph); + + const positionChanges: NodeChange[] = + layout.children + ?.filter((elkNode) => { + // If we are layouting a selection, we only want to update the positions of the selected nodes. + if (isLayoutingSelection) { + return selectedNodes.some((n) => n.id === elkNode.id); + } + // Otherwise, update all nodes. + return true; + }) + .map((elkNode) => ({ + id: elkNode.id, + type: 'position', + position: { x: elkNode.x ?? 0, y: elkNode.y ?? 0 }, + })) ?? []; + + dispatch(nodesChanged(positionChanges)); + }, [ + nodes, + edges, + dispatch, + templates, + nodePlacementStrategy, + layeringStrategy, + nodeSpacing, + layerSpacing, + layoutDirection, + ]); + + return autoLayout; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts index 8d0ef927cd9..c1a75535943 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -4,9 +4,20 @@ import { SelectionMode } from '@xyflow/react'; import type { PersistConfig, RootState } from 'app/store/store'; import type { Selector } from 'react-redux'; +export type NodePlacementStrategy = 'NETWORK_SIMPLEX' | 'BRANDES_KOEPF' | 'LINEAR_SEGMENTS' | 'SIMPLE'; + +export type LayeringStrategy = 'NETWORK_SIMPLEX' | 'LONGEST_PATH' | 'COFFMAN_GRAHAM'; + +export type LayoutDirection = 'DOWN' | 'RIGHT'; + export type WorkflowSettingsState = { _version: 1; shouldShowMinimapPanel: boolean; + nodePlacementStrategy: NodePlacementStrategy; + layeringStrategy: LayeringStrategy; + nodeSpacing: number; + layerSpacing: number; + layoutDirection: LayoutDirection; shouldValidateGraph: boolean; shouldAnimateEdges: boolean; nodeOpacity: number; @@ -19,6 +30,11 @@ export type WorkflowSettingsState = { const initialState: WorkflowSettingsState = { _version: 1, shouldShowMinimapPanel: true, + nodePlacementStrategy: 'NETWORK_SIMPLEX', + layeringStrategy: 'NETWORK_SIMPLEX', + nodeSpacing: 50, + layerSpacing: 50, + layoutDirection: 'RIGHT', shouldValidateGraph: true, shouldAnimateEdges: true, shouldSnapToGrid: false, @@ -35,6 +51,21 @@ export const workflowSettingsSlice = createSlice({ shouldShowMinimapPanelChanged: (state, action: PayloadAction) => { state.shouldShowMinimapPanel = action.payload; }, + nodePlacementStrategyChanged: (state, action: PayloadAction) => { + state.nodePlacementStrategy = action.payload; + }, + layeringStrategyChanged: (state, action: PayloadAction) => { + state.layeringStrategy = action.payload; + }, + nodeSpacingChanged: (state, action: PayloadAction) => { + state.nodeSpacing = action.payload; + }, + layerSpacingChanged: (state, action: PayloadAction) => { + state.layerSpacing = action.payload; + }, + layoutDirectionChanged: (state, action: PayloadAction) => { + state.layoutDirection = action.payload; + }, shouldValidateGraphChanged: (state, action: PayloadAction) => { state.shouldValidateGraph = action.payload; }, @@ -63,6 +94,11 @@ export const { shouldAnimateEdgesChanged, shouldColorEdgesChanged, shouldShowMinimapPanelChanged, + nodePlacementStrategyChanged, + layeringStrategyChanged, + nodeSpacingChanged, + layerSpacingChanged, + layoutDirectionChanged, shouldShowEdgeLabelsChanged, shouldSnapToGridChanged, shouldValidateGraphChanged, @@ -96,3 +132,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 selectNodePlacementStrategy = createWorkflowSettingsSelector((s) => s.nodePlacementStrategy); +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); diff --git a/invokeai/frontend/web/src/types/elkjs.d.ts b/invokeai/frontend/web/src/types/elkjs.d.ts new file mode 100644 index 00000000000..b107c09ca14 --- /dev/null +++ b/invokeai/frontend/web/src/types/elkjs.d.ts @@ -0,0 +1,20 @@ +declare module 'elkjs/lib/elk.bundled.js' { + export class ElkLayout { + layout(graph: ElkNode): Promise; + } + export interface ElkNode { + id: string; + width: number; + height: number; + x?: number; + y?: number; + children?: ElkNode[]; + edges?: ElkEdge[]; + layoutOptions?: Record; + } + export interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; + } +} diff --git a/invokeai/frontend/web/vite.config.mts b/invokeai/frontend/web/vite.config.mts index b32fe0fc74e..ee72ea0e51f 100644 --- a/invokeai/frontend/web/vite.config.mts +++ b/invokeai/frontend/web/vite.config.mts @@ -72,6 +72,9 @@ export default defineConfig(({ mode }) => { tsconfigPaths(), visualizer() as unknown as PluginOption, ], + optimizeDeps: { + include: ['elkjs/lib/elk.bundled.js'], + }, build: { chunkSizeWarningLimit: 1500, }, From 8a836f072484ef2390dd150cebe8dadd0bd67155 Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 11 Jul 2025 14:51:29 +0100 Subject: [PATCH 02/11] 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 --- .../BottomLeftPanel/ViewportControls.tsx | 158 ++++++++++++------ .../src/features/nodes/hooks/useAutoLayout.ts | 72 +++++--- .../nodes/store/workflowSettingsSlice.ts | 3 +- 3 files changed, 155 insertions(+), 78 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index b061b97c560..c8b6c955bbd 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -4,16 +4,22 @@ import { CompositeSlider, Divider, Flex, + FormControl, + FormLabel, + Grid, IconButton, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, Popover, PopoverArrow, PopoverBody, PopoverContent, PopoverFooter, PopoverTrigger, - Radio, - RadioGroup, - Text, + Select, } from '@invoke-ai/ui-library'; import { useReactFlow } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -36,7 +42,7 @@ import { selectShouldShowMinimapPanel, shouldShowMinimapPanelChanged, } from 'features/nodes/store/workflowSettingsSlice'; -import { memo, useCallback } from 'react'; +import { type ChangeEvent, memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFrameCornersBold, @@ -78,36 +84,50 @@ const ViewportControls = () => { }, [shouldShowMinimapPanel, dispatch]); const handleStrategyChanged = useCallback( - (value: NodePlacementStrategy) => { - dispatch(nodePlacementStrategyChanged(value)); + (e: ChangeEvent) => { + dispatch(nodePlacementStrategyChanged(e.target.value as NodePlacementStrategy)); }, [dispatch] ); const handleLayeringStrategyChanged = useCallback( - (value: LayeringStrategy) => { - dispatch(layeringStrategyChanged(value)); + (e: ChangeEvent) => { + dispatch(layeringStrategyChanged(e.target.value as LayeringStrategy)); }, [dispatch] ); - const handleNodeSpacingChanged = useCallback( + const handleNodeSpacingSliderChange = useCallback( (v: number) => { dispatch(nodeSpacingChanged(v)); }, [dispatch] ); - const handleLayerSpacingChanged = useCallback( + const handleNodeSpacingInputChange = useCallback( + (_: string, v: number) => { + dispatch(nodeSpacingChanged(v)); + }, + [dispatch] + ); + + const handleLayerSpacingSliderChange = useCallback( (v: number) => { dispatch(layerSpacingChanged(v)); }, [dispatch] ); + const handleLayerSpacingInputChange = useCallback( + (_: string, v: number) => { + dispatch(layerSpacingChanged(v)); + }, + [dispatch] + ); + const handleLayoutDirectionChanged = useCallback( - (value: LayoutDirection) => { - dispatch(layoutDirectionChanged(value)); + (e: ChangeEvent) => { + dispatch(layoutDirectionChanged(e.target.value as LayoutDirection)); }, [dispatch] ); @@ -150,44 +170,84 @@ const ViewportControls = () => { - - {t('nodes.layout.nodePlacementStrategy')} - - - {t('nodes.layout.networkSimplex')} - {t('nodes.layout.brandesKoepf')} - {t('nodes.layout.linearSegments')} - {t('nodes.layout.simplePlacement')} - - - - {t('nodes.layout.layeringStrategy')} - - - {t('nodes.layout.networkSimplex')} - {t('nodes.layout.longestPath')} - {t('nodes.layout.coffmanGraham')} - - - - {t('nodes.layout.layoutDirection')} - - - {t('nodes.layout.layoutDirectionRight')} - {t('nodes.layout.layoutDirectionDown')} - - + + + {t('nodes.layout.layoutDirection')} + + + + {t('nodes.layout.layeringStrategy')} + + + + {t('nodes.layout.nodePlacementStrategy')} + + - - {t('nodes.layout.nodeSpacing')} - {nodeSpacing} - - - - {t('nodes.layout.layerSpacing')} - {layerSpacing} - - + + {t('nodes.layout.nodeSpacing')} + + + + + + + + + + + + + {t('nodes.layout.layerSpacing')} + + + + + + + + + + + diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index c2075ee633f..d9adcfd6e56 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,7 +1,8 @@ import { useStore } from '@nanostores/react'; import type { NodeChange } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import ELK, { type ElkEdge, type ElkNode } from 'elkjs/lib/elk.bundled.js'; +import type { ELK as ELKType,ElkExtendedEdge, ElkNode } from 'elkjs'; +import * as ElkModule from 'elkjs/lib/elk.bundled.js'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; import { @@ -15,8 +16,14 @@ import { NODE_WIDTH } from 'features/nodes/types/constants'; import type { AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useCallback } from 'react'; - -const elk = new ELK(); + +// This is a workaround for a common issue with how ELKjs is packaged. The bundled script doesn't have a +// clean ES module export, so we import the module namespace and then extract the constructor, which may +// be on the `default` property or be the module itself. +const ElkConstructor = ((ElkModule as unknown as { default: unknown }).default ?? ElkModule) as new ( + options?: Record +) => ELKType; +const elk: ELKType = new ElkConstructor(); // These are estimates for node dimensions, used as a fallback when the node has not yet been rendered. const ESTIMATED_NODE_HEADER_HEIGHT = 40; @@ -24,7 +31,7 @@ const ESTIMATED_NODE_FOOTER_HEIGHT = 20; const ESTIMATED_FIELD_HEIGHT = 36; const ESTIMATED_NOTES_NODE_HEIGHT = 200; -export const useAutoLayout = () => { +export const useAutoLayout = (): (() => Promise) => { const dispatch = useAppDispatch(); const nodes = useAppSelector(selectNodes); const edges = useAppSelector(selectEdges); @@ -45,13 +52,29 @@ export const useAutoLayout = () => { const nodeIdsToLayout = new Set(nodesToLayout.map((n) => n.id)); const edgesToLayout = edges.filter((e) => nodeIdsToLayout.has(e.source) && nodeIdsToLayout.has(e.target)); + // Get all node elements from the DOM at once for performance, then create a map for fast lookups. + const nodeElements = document.querySelectorAll('.react-flow__node'); + const nodeElementMap = new Map(); + nodeElements.forEach((el) => { + const id = el.dataset.id; + if (id) { + nodeElementMap.set(id, el); + } + }); + const elkNodes: ElkNode[] = nodesToLayout.map((node) => { - let height = node.height; + // First, try to get the live height from the DOM element. This is the most accurate. + let height = nodeElementMap.get(node.id)?.offsetHeight; - // If the node has no height, we need to estimate it. - if (!height) { + // If the DOM element isn't available or its height is too small (e.g. not fully rendered), + // fall back to the height from the node state. + if (!height || height < ESTIMATED_NODE_HEADER_HEIGHT) { + height = node.height; + } + + // If we still don't have a valid height, estimate it based on the node's template. + if (!height || height < ESTIMATED_NODE_HEADER_HEIGHT) { if (isInvocationNode(node)) { - // This is an invocation node. We can estimate its height based on the number of fields. const template = templates[node.data.type]; if (template) { const numInputs = Object.keys(template.inputs).length; @@ -62,7 +85,6 @@ export const useAutoLayout = () => { ESTIMATED_NODE_FOOTER_HEIGHT; } } else if (node.type === 'notes') { - // This is a notes node. They have a fixed default size. height = ESTIMATED_NOTES_NODE_HEIGHT; } } @@ -70,7 +92,8 @@ export const useAutoLayout = () => { const elkNode: ElkNode = { id: node.id, width: node.width || NODE_WIDTH, - height: height || 200, // A final fallback just in case. + // Final fallback to a default height if all else fails. + height: height && height >= ESTIMATED_NODE_HEADER_HEIGHT ? height : 200, }; // If we are layouting a selection, we must provide the positions of all unselected nodes to @@ -83,30 +106,25 @@ export const useAutoLayout = () => { return elkNode; }); - const elkEdges: ElkEdge[] = edgesToLayout.map((edge) => ({ + const elkEdges: ElkExtendedEdge[] = edgesToLayout.map((edge) => ({ id: edge.id, sources: [edge.source], targets: [edge.target], })); + const layoutOptions: ElkNode['layoutOptions'] = { + 'elk.algorithm': 'layered', + 'elk.spacing.nodeNode': String(nodeSpacing), + 'elk.direction': layoutDirection, + 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing), + 'elk.spacing.edgeNode': '50', + 'elk.layered.nodePlacement.strategy': nodePlacementStrategy, + 'elk.layered.layering.strategy': layeringStrategy, + }; + const graph: ElkNode = { id: 'root', - width: 0, - height: 0, - layoutOptions: { - 'elk.algorithm': 'layered', - 'elk.direction': layoutDirection, - // Spacing between nodes in the same layer (vertical) - 'elk.spacing.nodeNode': String(nodeSpacing), - // Spacing between nodes in adjacent layers (horizontal) - 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing), - // Spacing between an edge and a node - 'elk.spacing.edgeNode': '50', - // layout strategy for node placement - 'elk.layered.nodePlacement.strategy': nodePlacementStrategy, - // layering strategy - 'elk.layered.layering.strategy': layeringStrategy, - }, + layoutOptions, children: elkNodes, edges: elkEdges, }; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts index c1a75535943..8c155139d5a 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit'; 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'; export type NodePlacementStrategy = 'NETWORK_SIMPLEX' | 'BRANDES_KOEPF' | 'LINEAR_SEGMENTS' | 'SIMPLE'; @@ -122,7 +121,7 @@ export const workflowSettingsPersistConfig: PersistConfig }; export const selectWorkflowSettingsSlice = (state: RootState) => state.workflowSettings; -const createWorkflowSettingsSelector = (selector: Selector) => +const createWorkflowSettingsSelector = (selector: (state: WorkflowSettingsState) => T) => createSelector(selectWorkflowSettingsSlice, selector); export const selectShouldSnapToGrid = createWorkflowSettingsSelector((s) => s.shouldSnapToGrid); export const selectSelectionMode = createWorkflowSettingsSelector((s) => s.selectionMode); From f9a4a5711a8b208ad14f4494c62dcf82af29a3f5 Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 11 Jul 2025 15:26:47 +0100 Subject: [PATCH 03/11] Update useAutoLayout.ts prettier --- .../frontend/web/src/features/nodes/hooks/useAutoLayout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index d9adcfd6e56..ec548dd68b3 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react'; import type { NodeChange } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import type { ELK as ELKType,ElkExtendedEdge, ElkNode } from 'elkjs'; +import type { ELK as ELKType, ElkExtendedEdge, ElkNode } from 'elkjs'; import * as ElkModule from 'elkjs/lib/elk.bundled.js'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; @@ -16,7 +16,7 @@ import { NODE_WIDTH } from 'features/nodes/types/constants'; import type { AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useCallback } from 'react'; - + // This is a workaround for a common issue with how ELKjs is packaged. The bundled script doesn't have a // clean ES module export, so we import the module namespace and then extract the constructor, which may // be on the `default` property or be the module itself. From fdd9421c94471460388b02f752be27922b58add9 Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 11 Jul 2025 14:51:29 +0100 Subject: [PATCH 04/11] 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 --- .../BottomLeftPanel/ViewportControls.tsx | 158 ++++++++++++------ .../src/features/nodes/hooks/useAutoLayout.ts | 72 +++++--- .../nodes/store/workflowSettingsSlice.ts | 3 +- 3 files changed, 155 insertions(+), 78 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index b061b97c560..c8b6c955bbd 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -4,16 +4,22 @@ import { CompositeSlider, Divider, Flex, + FormControl, + FormLabel, + Grid, IconButton, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, Popover, PopoverArrow, PopoverBody, PopoverContent, PopoverFooter, PopoverTrigger, - Radio, - RadioGroup, - Text, + Select, } from '@invoke-ai/ui-library'; import { useReactFlow } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -36,7 +42,7 @@ import { selectShouldShowMinimapPanel, shouldShowMinimapPanelChanged, } from 'features/nodes/store/workflowSettingsSlice'; -import { memo, useCallback } from 'react'; +import { type ChangeEvent, memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFrameCornersBold, @@ -78,36 +84,50 @@ const ViewportControls = () => { }, [shouldShowMinimapPanel, dispatch]); const handleStrategyChanged = useCallback( - (value: NodePlacementStrategy) => { - dispatch(nodePlacementStrategyChanged(value)); + (e: ChangeEvent) => { + dispatch(nodePlacementStrategyChanged(e.target.value as NodePlacementStrategy)); }, [dispatch] ); const handleLayeringStrategyChanged = useCallback( - (value: LayeringStrategy) => { - dispatch(layeringStrategyChanged(value)); + (e: ChangeEvent) => { + dispatch(layeringStrategyChanged(e.target.value as LayeringStrategy)); }, [dispatch] ); - const handleNodeSpacingChanged = useCallback( + const handleNodeSpacingSliderChange = useCallback( (v: number) => { dispatch(nodeSpacingChanged(v)); }, [dispatch] ); - const handleLayerSpacingChanged = useCallback( + const handleNodeSpacingInputChange = useCallback( + (_: string, v: number) => { + dispatch(nodeSpacingChanged(v)); + }, + [dispatch] + ); + + const handleLayerSpacingSliderChange = useCallback( (v: number) => { dispatch(layerSpacingChanged(v)); }, [dispatch] ); + const handleLayerSpacingInputChange = useCallback( + (_: string, v: number) => { + dispatch(layerSpacingChanged(v)); + }, + [dispatch] + ); + const handleLayoutDirectionChanged = useCallback( - (value: LayoutDirection) => { - dispatch(layoutDirectionChanged(value)); + (e: ChangeEvent) => { + dispatch(layoutDirectionChanged(e.target.value as LayoutDirection)); }, [dispatch] ); @@ -150,44 +170,84 @@ const ViewportControls = () => { - - {t('nodes.layout.nodePlacementStrategy')} - - - {t('nodes.layout.networkSimplex')} - {t('nodes.layout.brandesKoepf')} - {t('nodes.layout.linearSegments')} - {t('nodes.layout.simplePlacement')} - - - - {t('nodes.layout.layeringStrategy')} - - - {t('nodes.layout.networkSimplex')} - {t('nodes.layout.longestPath')} - {t('nodes.layout.coffmanGraham')} - - - - {t('nodes.layout.layoutDirection')} - - - {t('nodes.layout.layoutDirectionRight')} - {t('nodes.layout.layoutDirectionDown')} - - + + + {t('nodes.layout.layoutDirection')} + + + + {t('nodes.layout.layeringStrategy')} + + + + {t('nodes.layout.nodePlacementStrategy')} + + - - {t('nodes.layout.nodeSpacing')} - {nodeSpacing} - - - - {t('nodes.layout.layerSpacing')} - {layerSpacing} - - + + {t('nodes.layout.nodeSpacing')} + + + + + + + + + + + + + {t('nodes.layout.layerSpacing')} + + + + + + + + + + + diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index c2075ee633f..d9adcfd6e56 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,7 +1,8 @@ import { useStore } from '@nanostores/react'; import type { NodeChange } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import ELK, { type ElkEdge, type ElkNode } from 'elkjs/lib/elk.bundled.js'; +import type { ELK as ELKType,ElkExtendedEdge, ElkNode } from 'elkjs'; +import * as ElkModule from 'elkjs/lib/elk.bundled.js'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; import { @@ -15,8 +16,14 @@ import { NODE_WIDTH } from 'features/nodes/types/constants'; import type { AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useCallback } from 'react'; - -const elk = new ELK(); + +// This is a workaround for a common issue with how ELKjs is packaged. The bundled script doesn't have a +// clean ES module export, so we import the module namespace and then extract the constructor, which may +// be on the `default` property or be the module itself. +const ElkConstructor = ((ElkModule as unknown as { default: unknown }).default ?? ElkModule) as new ( + options?: Record +) => ELKType; +const elk: ELKType = new ElkConstructor(); // These are estimates for node dimensions, used as a fallback when the node has not yet been rendered. const ESTIMATED_NODE_HEADER_HEIGHT = 40; @@ -24,7 +31,7 @@ const ESTIMATED_NODE_FOOTER_HEIGHT = 20; const ESTIMATED_FIELD_HEIGHT = 36; const ESTIMATED_NOTES_NODE_HEIGHT = 200; -export const useAutoLayout = () => { +export const useAutoLayout = (): (() => Promise) => { const dispatch = useAppDispatch(); const nodes = useAppSelector(selectNodes); const edges = useAppSelector(selectEdges); @@ -45,13 +52,29 @@ export const useAutoLayout = () => { const nodeIdsToLayout = new Set(nodesToLayout.map((n) => n.id)); const edgesToLayout = edges.filter((e) => nodeIdsToLayout.has(e.source) && nodeIdsToLayout.has(e.target)); + // Get all node elements from the DOM at once for performance, then create a map for fast lookups. + const nodeElements = document.querySelectorAll('.react-flow__node'); + const nodeElementMap = new Map(); + nodeElements.forEach((el) => { + const id = el.dataset.id; + if (id) { + nodeElementMap.set(id, el); + } + }); + const elkNodes: ElkNode[] = nodesToLayout.map((node) => { - let height = node.height; + // First, try to get the live height from the DOM element. This is the most accurate. + let height = nodeElementMap.get(node.id)?.offsetHeight; - // If the node has no height, we need to estimate it. - if (!height) { + // If the DOM element isn't available or its height is too small (e.g. not fully rendered), + // fall back to the height from the node state. + if (!height || height < ESTIMATED_NODE_HEADER_HEIGHT) { + height = node.height; + } + + // If we still don't have a valid height, estimate it based on the node's template. + if (!height || height < ESTIMATED_NODE_HEADER_HEIGHT) { if (isInvocationNode(node)) { - // This is an invocation node. We can estimate its height based on the number of fields. const template = templates[node.data.type]; if (template) { const numInputs = Object.keys(template.inputs).length; @@ -62,7 +85,6 @@ export const useAutoLayout = () => { ESTIMATED_NODE_FOOTER_HEIGHT; } } else if (node.type === 'notes') { - // This is a notes node. They have a fixed default size. height = ESTIMATED_NOTES_NODE_HEIGHT; } } @@ -70,7 +92,8 @@ export const useAutoLayout = () => { const elkNode: ElkNode = { id: node.id, width: node.width || NODE_WIDTH, - height: height || 200, // A final fallback just in case. + // Final fallback to a default height if all else fails. + height: height && height >= ESTIMATED_NODE_HEADER_HEIGHT ? height : 200, }; // If we are layouting a selection, we must provide the positions of all unselected nodes to @@ -83,30 +106,25 @@ export const useAutoLayout = () => { return elkNode; }); - const elkEdges: ElkEdge[] = edgesToLayout.map((edge) => ({ + const elkEdges: ElkExtendedEdge[] = edgesToLayout.map((edge) => ({ id: edge.id, sources: [edge.source], targets: [edge.target], })); + const layoutOptions: ElkNode['layoutOptions'] = { + 'elk.algorithm': 'layered', + 'elk.spacing.nodeNode': String(nodeSpacing), + 'elk.direction': layoutDirection, + 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing), + 'elk.spacing.edgeNode': '50', + 'elk.layered.nodePlacement.strategy': nodePlacementStrategy, + 'elk.layered.layering.strategy': layeringStrategy, + }; + const graph: ElkNode = { id: 'root', - width: 0, - height: 0, - layoutOptions: { - 'elk.algorithm': 'layered', - 'elk.direction': layoutDirection, - // Spacing between nodes in the same layer (vertical) - 'elk.spacing.nodeNode': String(nodeSpacing), - // Spacing between nodes in adjacent layers (horizontal) - 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing), - // Spacing between an edge and a node - 'elk.spacing.edgeNode': '50', - // layout strategy for node placement - 'elk.layered.nodePlacement.strategy': nodePlacementStrategy, - // layering strategy - 'elk.layered.layering.strategy': layeringStrategy, - }, + layoutOptions, children: elkNodes, edges: elkEdges, }; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts index c1a75535943..8c155139d5a 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit'; 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'; export type NodePlacementStrategy = 'NETWORK_SIMPLEX' | 'BRANDES_KOEPF' | 'LINEAR_SEGMENTS' | 'SIMPLE'; @@ -122,7 +121,7 @@ export const workflowSettingsPersistConfig: PersistConfig }; export const selectWorkflowSettingsSlice = (state: RootState) => state.workflowSettings; -const createWorkflowSettingsSelector = (selector: Selector) => +const createWorkflowSettingsSelector = (selector: (state: WorkflowSettingsState) => T) => createSelector(selectWorkflowSettingsSlice, selector); export const selectShouldSnapToGrid = createWorkflowSettingsSelector((s) => s.shouldSnapToGrid); export const selectSelectionMode = createWorkflowSettingsSelector((s) => s.selectionMode); From f7d021d25370886d4b2c20233d5e84ab61a24799 Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 11 Jul 2025 15:26:47 +0100 Subject: [PATCH 05/11] Update useAutoLayout.ts prettier --- .../frontend/web/src/features/nodes/hooks/useAutoLayout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index d9adcfd6e56..ec548dd68b3 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react'; import type { NodeChange } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import type { ELK as ELKType,ElkExtendedEdge, ElkNode } from 'elkjs'; +import type { ELK as ELKType, ElkExtendedEdge, ElkNode } from 'elkjs'; import * as ElkModule from 'elkjs/lib/elk.bundled.js'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; @@ -16,7 +16,7 @@ import { NODE_WIDTH } from 'features/nodes/types/constants'; import type { AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useCallback } from 'react'; - + // This is a workaround for a common issue with how ELKjs is packaged. The bundled script doesn't have a // clean ES module export, so we import the module namespace and then extract the constructor, which may // be on the `default` property or be the module itself. From 62cce283fc18e15df936f5ff88a21358598335f0 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:37:14 +1000 Subject: [PATCH 06/11] build(ui): import elkjs directly --- .../src/features/nodes/hooks/useAutoLayout.ts | 12 +++-------- invokeai/frontend/web/src/types/elkjs.d.ts | 20 ------------------- invokeai/frontend/web/vite.config.mts | 3 --- 3 files changed, 3 insertions(+), 32 deletions(-) delete mode 100644 invokeai/frontend/web/src/types/elkjs.d.ts diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index ec548dd68b3..133cf99b09d 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,8 +1,8 @@ import { useStore } from '@nanostores/react'; import type { NodeChange } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import type { ELK as ELKType, ElkExtendedEdge, ElkNode } from 'elkjs'; -import * as ElkModule from 'elkjs/lib/elk.bundled.js'; +import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk.bundled.js'; +import ELK from 'elkjs/lib/elk.bundled.js'; import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; import { @@ -17,13 +17,7 @@ import type { AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useCallback } from 'react'; -// This is a workaround for a common issue with how ELKjs is packaged. The bundled script doesn't have a -// clean ES module export, so we import the module namespace and then extract the constructor, which may -// be on the `default` property or be the module itself. -const ElkConstructor = ((ElkModule as unknown as { default: unknown }).default ?? ElkModule) as new ( - options?: Record -) => ELKType; -const elk: ELKType = new ElkConstructor(); +const elk = new ELK(); // These are estimates for node dimensions, used as a fallback when the node has not yet been rendered. const ESTIMATED_NODE_HEADER_HEIGHT = 40; diff --git a/invokeai/frontend/web/src/types/elkjs.d.ts b/invokeai/frontend/web/src/types/elkjs.d.ts deleted file mode 100644 index b107c09ca14..00000000000 --- a/invokeai/frontend/web/src/types/elkjs.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -declare module 'elkjs/lib/elk.bundled.js' { - export class ElkLayout { - layout(graph: ElkNode): Promise; - } - export interface ElkNode { - id: string; - width: number; - height: number; - x?: number; - y?: number; - children?: ElkNode[]; - edges?: ElkEdge[]; - layoutOptions?: Record; - } - export interface ElkEdge { - id: string; - sources: string[]; - targets: string[]; - } -} diff --git a/invokeai/frontend/web/vite.config.mts b/invokeai/frontend/web/vite.config.mts index ee72ea0e51f..b32fe0fc74e 100644 --- a/invokeai/frontend/web/vite.config.mts +++ b/invokeai/frontend/web/vite.config.mts @@ -72,9 +72,6 @@ export default defineConfig(({ mode }) => { tsconfigPaths(), visualizer() as unknown as PluginOption, ], - optimizeDeps: { - include: ['elkjs/lib/elk.bundled.js'], - }, build: { chunkSizeWarningLimit: 1500, }, From 8daf8449c4a8f0da5fc128c09417d7abec58fe0e Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Thu, 17 Jul 2025 21:50:18 +0100 Subject: [PATCH 07/11] 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... --- invokeai/frontend/web/package.json | 1 - invokeai/frontend/web/public/locales/en.json | 14 +- .../BottomLeftPanel/ViewportControls.tsx | 44 ++-- .../src/features/nodes/hooks/useAutoLayout.ts | 209 +++++++----------- .../nodes/store/workflowSettingsSlice.ts | 34 +-- 5 files changed, 132 insertions(+), 170 deletions(-) diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 12c7b3c4e00..0dce48b8929 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -57,7 +57,6 @@ "cmdk": "^1.1.1", "compare-versions": "^6.1.1", "dockview": "^4.4.0", - "elkjs": "^0.10.0", "es-toolkit": "^1.39.5", "filesize": "^10.1.6", "fracturedjsonjs": "^4.1.0", diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index fda9add4995..931816bea6b 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1128,19 +1128,19 @@ "integerRangeGenerator": "Integer Range Generator", "layout": { "autoLayout": "Auto Layout", - "nodePlacementStrategy": "Node Placement Strategy", - "networkSimplex": "Network Simplex", - "brandesKoepf": "Brandes-Koepf", - "linearSegments": "Linear Segments", - "simplePlacement": "Simple Placement", "layeringStrategy": "Layering Strategy", + "networkSimplex": "Network Simplex", "longestPath": "Longest Path", - "coffmanGraham": "Coffman-Graham", "nodeSpacing": "Node Spacing", "layerSpacing": "Layer Spacing", "layoutDirection": "Layout Direction", "layoutDirectionRight": "Right", - "layoutDirectionDown": "Down" + "layoutDirectionDown": "Down", + "alignment": "Node Alignment", + "alignmentUL": "Top Left", + "alignmentDL": "Bottom Left", + "alignmentUR": "Top Right", + "alignmentDR": "Bottom Right" } }, "parameters": { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index c8b6c955bbd..89d16134a0f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -31,13 +31,12 @@ import { layerSpacingChanged, type LayoutDirection, layoutDirectionChanged, - type NodePlacementStrategy, - nodePlacementStrategyChanged, + nodeAlignmentChanged, nodeSpacingChanged, selectLayeringStrategy, selectLayerSpacing, selectLayoutDirection, - selectNodePlacementStrategy, + selectNodeAlignment, selectNodeSpacing, selectShouldShowMinimapPanel, shouldShowMinimapPanelChanged, @@ -61,11 +60,11 @@ const ViewportControls = () => { const dispatch = useAppDispatch(); const popover = useLayoutSettingsPopover(); const shouldShowMinimapPanel = useAppSelector(selectShouldShowMinimapPanel); - const nodePlacementStrategy = useAppSelector(selectNodePlacementStrategy); const layeringStrategy = useAppSelector(selectLayeringStrategy); const nodeSpacing = useAppSelector(selectNodeSpacing); const layerSpacing = useAppSelector(selectLayerSpacing); const layoutDirection = useAppSelector(selectLayoutDirection); + const nodeAlignment = useAppSelector(selectNodeAlignment); const handleClickedZoomIn = useCallback(() => { zoomIn({ duration: 300 }); @@ -83,13 +82,6 @@ const ViewportControls = () => { dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel)); }, [shouldShowMinimapPanel, dispatch]); - const handleStrategyChanged = useCallback( - (e: ChangeEvent) => { - dispatch(nodePlacementStrategyChanged(e.target.value as NodePlacementStrategy)); - }, - [dispatch] - ); - const handleLayeringStrategyChanged = useCallback( (e: ChangeEvent) => { dispatch(layeringStrategyChanged(e.target.value as LayeringStrategy)); @@ -132,6 +124,14 @@ const ViewportControls = () => { [dispatch] ); + const handleNodeAlignmentChanged = useCallback( + (e: ChangeEvent) => { + const value = e.target.value as NodeAlignment; + dispatch(nodeAlignmentChanged(value)); + }, + [dispatch] + ); + const handleApplyAutoLayout = useCallback(async () => { await autoLayout(); fitView({ duration: 300 }); @@ -169,30 +169,30 @@ const ViewportControls = () => { + {t('nodes.layout.layoutDirection')} {t('nodes.layout.layeringStrategy')} - {t('nodes.layout.nodePlacementStrategy')} - + + + + diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index fc5fa19a619..933c1dd59b3 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,164 +1,127 @@ -import { useStore } from '@nanostores/react'; -import type { NodeChange } from '@xyflow/react'; +import { graphlib, layout } from '@dagrejs/dagre'; +import type { Edge, NodeChange} from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk.bundled.js'; -import ELK from 'elkjs/lib/elk.bundled.js'; -import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; +import { nodesChanged } from 'features/nodes/store/nodesSlice'; import { selectEdges, selectNodes } from 'features/nodes/store/selectors'; import { selectLayeringStrategy, selectLayerSpacing, selectLayoutDirection, - selectNodePlacementStrategy, + selectNodeAlignment, selectNodeSpacing, } from 'features/nodes/store/workflowSettingsSlice'; import { NODE_WIDTH } from 'features/nodes/types/constants'; import type { AnyNode } from 'features/nodes/types/invocation'; -import { isInvocationNode } from 'features/nodes/types/invocation'; +import { isNotesNode } from 'features/nodes/types/invocation'; import { useCallback } from 'react'; -// This is a workaround for a common issue with how ELKjs is packaged. The bundled script doesn't have a -// clean ES module export, so we import the module namespace and then extract the constructor, which may -// be on the `default` property or be the module itself. -const ElkConstructor = ((ElkModule as unknown as { default: unknown }).default ?? ElkModule) as new ( - options?: Record -) => ELKType; -const elk: ELKType = new ElkConstructor(); - -// These are estimates for node dimensions, used as a fallback when the node has not yet been rendered. -const ESTIMATED_NODE_HEADER_HEIGHT = 40; -const ESTIMATED_NODE_FOOTER_HEIGHT = 20; -const ESTIMATED_FIELD_HEIGHT = 36; const ESTIMATED_NOTES_NODE_HEIGHT = 200; +const DEFAULT_NODE_HEIGHT = NODE_WIDTH; +const NODE_PADDING = 0;//40; // Padding to add to the node height -export const useAutoLayout = (): (() => Promise) => { +export const useAutoLayout = (): (() => void) => { const dispatch = useAppDispatch(); const nodes = useAppSelector(selectNodes); const edges = useAppSelector(selectEdges); - const templates = useStore($templates); - const nodePlacementStrategy = useAppSelector(selectNodePlacementStrategy); - const layeringStrategy = useAppSelector(selectLayeringStrategy); const nodeSpacing = useAppSelector(selectNodeSpacing); const layerSpacing = useAppSelector(selectLayerSpacing); + const layeringStrategy = useAppSelector(selectLayeringStrategy); const layoutDirection = useAppSelector(selectLayoutDirection); + const nodeAlignment = useAppSelector(selectNodeAlignment); - const autoLayout = useCallback(async () => { - const selectedNodes = nodes.filter((n) => n.selected); - const isLayoutingSelection = selectedNodes.length > 0; + const autoLayout = useCallback(() => { + const g = new graphlib.Graph(); - // We always include all nodes in the layout, so the layout engine can avoid overlaps. - const nodesToLayout = nodes; + g.setGraph({ + rankdir: layoutDirection, + nodesep: nodeSpacing, + ranksep: layerSpacing, + ranker: layeringStrategy, + align: nodeAlignment, + }); - const nodeIdsToLayout = new Set(nodesToLayout.map((n) => n.id)); - const edgesToLayout = edges.filter((e) => nodeIdsToLayout.has(e.source) && nodeIdsToLayout.has(e.target)); + g.setDefaultEdgeLabel(() => ({})); - // Get all node elements from the DOM at once for performance, then create a map for fast lookups. - const nodeElements = document.querySelectorAll('.react-flow__node'); - const nodeElementMap = new Map(); - nodeElements.forEach((el) => { - const id = el.dataset.id; - if (id) { - nodeElementMap.set(id, el); - } - }); + const selectedNodes = nodes.filter((n) => n.selected); + const isLayoutSelection = selectedNodes.length > 0 && nodes.length > selectedNodes.length; - const elkNodes: ElkNode[] = nodesToLayout.map((node) => { - // First, try to get the live height from the DOM element. This is the most accurate. - let height = nodeElementMap.get(node.id)?.offsetHeight; + const nodesToLayout = isLayoutSelection ? selectedNodes : nodes; - // If the DOM element isn't available or its height is too small (e.g. not fully rendered), - // fall back to the height from the node state. - if (!height || height < ESTIMATED_NODE_HEADER_HEIGHT) { - height = node.height; - } + // Get the top-left position of the selection's bounding box before layout + const selectionBBox = { + minX: Infinity, + minY: Infinity, + }; - // If we still don't have a valid height, estimate it based on the node's template. - if (!height || height < ESTIMATED_NODE_HEADER_HEIGHT) { - if (isInvocationNode(node)) { - const template = templates[node.data.type]; - if (template) { - const numInputs = Object.keys(template.inputs).length; - const numOutputs = Object.keys(template.outputs).length; - height = - ESTIMATED_NODE_HEADER_HEIGHT + - (numInputs + numOutputs) * ESTIMATED_FIELD_HEIGHT + - ESTIMATED_NODE_FOOTER_HEIGHT; - } - } else if (node.type === 'notes') { - height = ESTIMATED_NOTES_NODE_HEIGHT; - } + if (isLayoutSelection) { + for (const node of selectedNodes) { + selectionBBox.minX = Math.min(selectionBBox.minX, node.position.x); + selectionBBox.minY = Math.min(selectionBBox.minY, node.position.y); } + } - const elkNode: ElkNode = { - id: node.id, - width: node.width || NODE_WIDTH, - // Final fallback to a default height if all else fails. - height: height && height >= ESTIMATED_NODE_HEADER_HEIGHT ? height : 200, - }; + nodesToLayout.forEach((node) => { + let height: number; - // If we are layouting a selection, we must provide the positions of all unselected nodes to - // the layout engine. This allows the engine to position the selected nodes relative to them. - if (isLayoutingSelection && !node.selected) { - elkNode.x = node.position.x; - elkNode.y = node.position.y; + // Check if a measured height is available and valid + if (node.measured?.height !== null && node.measured?.height !== undefined) { + height = node.measured.height + NODE_PADDING; // Add padding to the measured height + } else { + // If not available, determine the fallback height + height = isNotesNode(node) ? ESTIMATED_NOTES_NODE_HEIGHT : DEFAULT_NODE_HEIGHT; } - return elkNode; + g.setNode(node.id, { + width: node.width ?? NODE_WIDTH, + height: height, + }); }); - const elkEdges: ElkExtendedEdge[] = edgesToLayout.map((edge) => ({ - id: edge.id, - sources: [edge.source], - targets: [edge.target], - })); - - const layoutOptions: ElkNode['layoutOptions'] = { - 'elk.algorithm': 'layered', - 'elk.spacing.nodeNode': String(nodeSpacing), - 'elk.direction': layoutDirection, - 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing), - 'elk.spacing.edgeNode': '50', - 'elk.layered.nodePlacement.strategy': nodePlacementStrategy, - 'elk.layered.layering.strategy': layeringStrategy, - }; + const edgesToLayout: Edge[] = isLayoutSelection + ? edges.filter( + (edge) => + nodesToLayout.some((n) => n.id === edge.source) && nodesToLayout.some((n) => n.id === edge.target) + ) + : edges; - const graph: ElkNode = { - id: 'root', - layoutOptions, - children: elkNodes, - edges: elkEdges, - }; + edgesToLayout.forEach((edge) => { + g.setEdge(edge.source, edge.target); + }); - const layout = await elk.layout(graph); - - const positionChanges: NodeChange[] = - layout.children - ?.filter((elkNode) => { - // If we are layouting a selection, we only want to update the positions of the selected nodes. - if (isLayoutingSelection) { - return selectedNodes.some((n) => n.id === elkNode.id); - } - // Otherwise, update all nodes. - return true; - }) - .map((elkNode) => ({ - id: elkNode.id, - type: 'position', - position: { x: elkNode.x ?? 0, y: elkNode.y ?? 0 }, - })) ?? []; + layout(g); + + const layoutBBox = { + minX: Infinity, + minY: Infinity, + }; + let offsetX = 0; + let offsetY = 0; + + if (isLayoutSelection) { + // Get the top-left position of the new layout's bounding box + nodesToLayout.forEach((node) => { + const { x, y } = g.node(node.id); + layoutBBox.minX = Math.min(layoutBBox.minX, x); + layoutBBox.minY = Math.min(layoutBBox.minY, y); + }); + + // Calculate the offset needed to move the new layout to the original position + offsetX = selectionBBox.minX - layoutBBox.minX; + offsetY = selectionBBox.minY - layoutBBox.minY; + } + + const positionChanges: NodeChange[] = nodesToLayout.map((node) => { + const { x, y } = g.node(node.id); + // For selected layouts, apply the calculated offset. Otherwise, use the new position directly. + const newPosition = { + x: isLayoutSelection ? x + offsetX : x, + y: isLayoutSelection ? y + offsetY : y, + }; + return { id: node.id, type: 'position', position: newPosition }; + }); dispatch(nodesChanged(positionChanges)); - }, [ - nodes, - edges, - dispatch, - templates, - nodePlacementStrategy, - layeringStrategy, - nodeSpacing, - layerSpacing, - layoutDirection, - ]); + }, [dispatch, edges, nodes, nodeSpacing, layerSpacing, layeringStrategy, layoutDirection, nodeAlignment]); return autoLayout; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts index 8c155139d5a..50c49ef0afc 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -2,23 +2,22 @@ import type { PayloadAction } from '@reduxjs/toolkit'; 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'; -export type NodePlacementStrategy = 'NETWORK_SIMPLEX' | 'BRANDES_KOEPF' | 'LINEAR_SEGMENTS' | 'SIMPLE'; - -export type LayeringStrategy = 'NETWORK_SIMPLEX' | 'LONGEST_PATH' | 'COFFMAN_GRAHAM'; - -export type LayoutDirection = 'DOWN' | 'RIGHT'; +export type LayeringStrategy = 'network-simplex' | 'longest-path'; +export type LayoutDirection = 'TB' | 'LR'; +export type NodeAlignment = 'UL' | 'UR' | 'DL' | 'DR'; export type WorkflowSettingsState = { _version: 1; shouldShowMinimapPanel: boolean; - nodePlacementStrategy: NodePlacementStrategy; layeringStrategy: LayeringStrategy; nodeSpacing: number; layerSpacing: number; layoutDirection: LayoutDirection; shouldValidateGraph: boolean; shouldAnimateEdges: boolean; + nodeAlignment: NodeAlignment; nodeOpacity: number; shouldSnapToGrid: boolean; shouldColorEdges: boolean; @@ -29,11 +28,11 @@ export type WorkflowSettingsState = { const initialState: WorkflowSettingsState = { _version: 1, shouldShowMinimapPanel: true, - nodePlacementStrategy: 'NETWORK_SIMPLEX', - layeringStrategy: 'NETWORK_SIMPLEX', - nodeSpacing: 50, - layerSpacing: 50, - layoutDirection: 'RIGHT', + layeringStrategy: 'network-simplex', + nodeSpacing: 32, + layerSpacing: 32, + layoutDirection: 'LR', + nodeAlignment: 'UL', shouldValidateGraph: true, shouldAnimateEdges: true, shouldSnapToGrid: false, @@ -50,9 +49,6 @@ export const workflowSettingsSlice = createSlice({ shouldShowMinimapPanelChanged: (state, action: PayloadAction) => { state.shouldShowMinimapPanel = action.payload; }, - nodePlacementStrategyChanged: (state, action: PayloadAction) => { - state.nodePlacementStrategy = action.payload; - }, layeringStrategyChanged: (state, action: PayloadAction) => { state.layeringStrategy = action.payload; }, @@ -83,6 +79,9 @@ export const workflowSettingsSlice = createSlice({ nodeOpacityChanged: (state, action: PayloadAction) => { state.nodeOpacity = action.payload; }, + nodeAlignmentChanged: (state, action: PayloadAction) => { + state.nodeAlignment = action.payload; + }, selectionModeChanged: (state, action: PayloadAction) => { state.selectionMode = action.payload ? SelectionMode.Full : SelectionMode.Partial; }, @@ -93,17 +92,18 @@ export const { shouldAnimateEdgesChanged, shouldColorEdgesChanged, shouldShowMinimapPanelChanged, - nodePlacementStrategyChanged, layeringStrategyChanged, nodeSpacingChanged, layerSpacingChanged, layoutDirectionChanged, shouldShowEdgeLabelsChanged, shouldSnapToGridChanged, + nodeAlignmentChanged, shouldValidateGraphChanged, nodeOpacityChanged, selectionModeChanged, } = workflowSettingsSlice.actions; +export const { reducer } = workflowSettingsSlice; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const migrateWorkflowSettingsState = (state: any): any => { @@ -121,7 +121,7 @@ export const workflowSettingsPersistConfig: PersistConfig }; export const selectWorkflowSettingsSlice = (state: RootState) => state.workflowSettings; -const createWorkflowSettingsSelector = (selector: (state: WorkflowSettingsState) => T) => +const createWorkflowSettingsSelector = (selector: Selector) => createSelector(selectWorkflowSettingsSlice, selector); export const selectShouldSnapToGrid = createWorkflowSettingsSelector((s) => s.shouldSnapToGrid); export const selectSelectionMode = createWorkflowSettingsSelector((s) => s.selectionMode); @@ -132,8 +132,8 @@ export const selectNodeOpacity = createWorkflowSettingsSelector((s) => s.nodeOpa export const selectShouldShowMinimapPanel = createWorkflowSettingsSelector((s) => s.shouldShowMinimapPanel); export const selectShouldShouldValidateGraph = createWorkflowSettingsSelector((s) => s.shouldValidateGraph); -export const selectNodePlacementStrategy = createWorkflowSettingsSelector((s) => s.nodePlacementStrategy); 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); From 2d65d3e0cf8730521dfb0e9520fe8d98e74c3d30 Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 18 Jul 2025 15:28:44 +0100 Subject: [PATCH 08/11] Update useAutoLayout.ts update to fix layout issues --- .../src/features/nodes/hooks/useAutoLayout.ts | 71 ++++++++++--------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index 933c1dd59b3..653dcc80287 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -17,7 +17,6 @@ import { useCallback } from 'react'; const ESTIMATED_NOTES_NODE_HEIGHT = 200; const DEFAULT_NODE_HEIGHT = NODE_WIDTH; -const NODE_PADDING = 0;//40; // Padding to add to the node height export const useAutoLayout = (): (() => void) => { const dispatch = useAppDispatch(); @@ -44,32 +43,25 @@ export const useAutoLayout = (): (() => void) => { const selectedNodes = nodes.filter((n) => n.selected); const isLayoutSelection = selectedNodes.length > 0 && nodes.length > selectedNodes.length; - const nodesToLayout = isLayoutSelection ? selectedNodes : nodes; - // Get the top-left position of the selection's bounding box before layout - const selectionBBox = { + //Anchor of the selected nodes + const selectionAnchor = { minX: Infinity, minY: Infinity, }; - if (isLayoutSelection) { - for (const node of selectedNodes) { - selectionBBox.minX = Math.min(selectionBBox.minX, node.position.x); - selectionBBox.minY = Math.min(selectionBBox.minY, node.position.y); - } - } - nodesToLayout.forEach((node) => { - let height: number; - - // Check if a measured height is available and valid - if (node.measured?.height !== null && node.measured?.height !== undefined) { - height = node.measured.height + NODE_PADDING; // Add padding to the measured height - } else { - // If not available, determine the fallback height - height = isNotesNode(node) ? ESTIMATED_NOTES_NODE_HEIGHT : DEFAULT_NODE_HEIGHT; + // 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); } + // update the Height based on the node's measured height or use a default value + const measuredHeight = node.measured?.height; + const height = typeof measuredHeight === 'number' + ? measuredHeight + : isNotesNode(node) ? ESTIMATED_NOTES_NODE_HEIGHT : DEFAULT_NODE_HEIGHT; g.setNode(node.id, { width: node.width ?? NODE_WIDTH, @@ -77,12 +69,14 @@ export const useAutoLayout = (): (() => void) => { }); }); - const edgesToLayout: Edge[] = isLayoutSelection - ? edges.filter( - (edge) => - nodesToLayout.some((n) => n.id === edge.source) && nodesToLayout.some((n) => n.id === edge.target) - ) - : edges; + 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); @@ -90,7 +84,8 @@ export const useAutoLayout = (): (() => void) => { layout(g); - const layoutBBox = { + // anchor for the new layout + const layoutAnchor = { minX: Infinity, minY: Infinity, }; @@ -98,21 +93,27 @@ export const useAutoLayout = (): (() => void) => { let offsetY = 0; if (isLayoutSelection) { - // Get the top-left position of the new layout's bounding box + // Get the top-left position of the new layout nodesToLayout.forEach((node) => { - const { x, y } = g.node(node.id); - layoutBBox.minX = Math.min(layoutBBox.minX, x); - layoutBBox.minY = Math.min(layoutBBox.minY, y); + 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 = selectionBBox.minX - layoutBBox.minX; - offsetY = selectionBBox.minY - layoutBBox.minY; + offsetX = selectionAnchor.minX - layoutAnchor.minX; + offsetY = selectionAnchor.minY - layoutAnchor.minY; } + // Create position changes for each node based on the new layout const positionChanges: NodeChange[] = nodesToLayout.map((node) => { - const { x, y } = g.node(node.id); - // For selected layouts, apply the calculated offset. Otherwise, use the new position directly. + 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, From a39bf8abba691c08db20f961e3331c2db5d6cc5c Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 18 Jul 2025 17:38:50 +0100 Subject: [PATCH 09/11] minor updates - pretty useAutoLayout.ts - add missing type import in ViewportControls.tsx - update pnpm-lock.yaml with elkjs removed --- invokeai/frontend/web/pnpm-lock.yaml | 8 -------- .../panels/BottomLeftPanel/ViewportControls.tsx | 1 + .../src/features/nodes/hooks/useAutoLayout.ts | 16 ++++++++-------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index f4224fd22e0..916ee8fb2d3 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: dockview: specifier: ^4.4.0 version: 4.4.0(react@18.3.1) - elkjs: - specifier: ^0.10.0 - version: 0.10.0 es-toolkit: specifier: ^1.39.5 version: 1.39.6 @@ -2432,9 +2429,6 @@ packages: electron-to-chromium@1.5.179: resolution: {integrity: sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==} - elkjs@0.10.0: - resolution: {integrity: sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -7085,8 +7079,6 @@ snapshots: electron-to-chromium@1.5.179: {} - elkjs@0.10.0: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 89d16134a0f..506b0d73b1d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -32,6 +32,7 @@ import { type LayoutDirection, layoutDirectionChanged, nodeAlignmentChanged, + type NodeAlignment, nodeSpacingChanged, selectLayeringStrategy, selectLayerSpacing, diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index 653dcc80287..889256273fd 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -1,5 +1,5 @@ import { graphlib, layout } from '@dagrejs/dagre'; -import type { Edge, NodeChange} from '@xyflow/react'; +import type { Edge, NodeChange } 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'; @@ -59,9 +59,12 @@ export const useAutoLayout = (): (() => void) => { } // update the Height based on the node's measured height or use a default value const measuredHeight = node.measured?.height; - const height = typeof measuredHeight === 'number' - ? measuredHeight - : isNotesNode(node) ? ESTIMATED_NOTES_NODE_HEIGHT : DEFAULT_NODE_HEIGHT; + const height = + typeof measuredHeight === 'number' + ? measuredHeight + : isNotesNode(node) + ? ESTIMATED_NOTES_NODE_HEIGHT + : DEFAULT_NODE_HEIGHT; g.setNode(node.id, { width: node.width ?? NODE_WIDTH, @@ -72,10 +75,7 @@ export const useAutoLayout = (): (() => void) => { 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 = edges.filter((edge) => nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target)); } edgesToLayout.forEach((edge) => { From a58b48ba204ffcc367a0358e5de584b9a147d6fe Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 18 Jul 2025 17:45:19 +0100 Subject: [PATCH 10/11] Update ViewportControls.tsx pnpm fix --- .../components/flow/panels/BottomLeftPanel/ViewportControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 506b0d73b1d..7ff08be9263 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -31,8 +31,8 @@ import { layerSpacingChanged, type LayoutDirection, layoutDirectionChanged, - nodeAlignmentChanged, type NodeAlignment, + nodeAlignmentChanged, nodeSpacingChanged, selectLayeringStrategy, selectLayerSpacing, From 21a2a5f132b3e532952d7ada8c04cda733360823 Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Fri, 18 Jul 2025 18:18:22 +0100 Subject: [PATCH 11/11] 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. --- invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts | 2 +- .../web/src/features/nodes/store/workflowSettingsSlice.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts index 889256273fd..503785c07fc 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useAutoLayout.ts @@ -42,7 +42,7 @@ export const useAutoLayout = (): (() => void) => { g.setDefaultEdgeLabel(() => ({})); const selectedNodes = nodes.filter((n) => n.selected); - const isLayoutSelection = selectedNodes.length > 0 && nodes.length > selectedNodes.length; + const isLayoutSelection = selectedNodes.length > 1 && nodes.length > selectedNodes.length; const nodesToLayout = isLayoutSelection ? selectedNodes : nodes; //Anchor of the selected nodes diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts index 50c49ef0afc..a0a409364fe 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -103,7 +103,6 @@ export const { nodeOpacityChanged, selectionModeChanged, } = workflowSettingsSlice.actions; -export const { reducer } = workflowSettingsSlice; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const migrateWorkflowSettingsState = (state: any): any => {