From ddd17046e229e47835dd78cd78e6a72ed94053cc Mon Sep 17 00:00:00 2001 From: skunkworxdark Date: Tue, 8 Jul 2025 21:36:24 +0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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.