diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 0dce48b8929..12c7b3c4e00 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 916ee8fb2d3..f4224fd22e0 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 5f8e2070c37..0d1e45f0c18 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..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 @@ -1,24 +1,71 @@ -import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; +import { + Button, + ButtonGroup, + CompositeSlider, + Divider, + Flex, + FormControl, + FormLabel, + Grid, + IconButton, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverFooter, + PopoverTrigger, + Select, +} from '@invoke-ai/ui-library'; import { useReactFlow } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { buildUseBoolean } from 'common/hooks/useBoolean'; +import { useAutoLayout } from 'features/nodes/hooks/useAutoLayout'; import { + type LayeringStrategy, + layeringStrategyChanged, + layerSpacingChanged, + type LayoutDirection, + layoutDirectionChanged, + type NodePlacementStrategy, + nodePlacementStrategyChanged, + nodeSpacingChanged, + selectLayeringStrategy, + selectLayerSpacing, + selectLayoutDirection, + selectNodePlacementStrategy, + selectNodeSpacing, 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, + 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 +83,61 @@ 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)); + }, + [dispatch] + ); + + const handleNodeSpacingSliderChange = useCallback( + (v: number) => { + dispatch(nodeSpacingChanged(v)); + }, + [dispatch] + ); + + 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( + (e: ChangeEvent) => { + dispatch(layoutDirectionChanged(e.target.value as LayoutDirection)); + }, + [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.layoutDirection')} + + + + {t('nodes.layout.layeringStrategy')} + + + + {t('nodes.layout.nodePlacementStrategy')} + + + + + {t('nodes.layout.nodeSpacing')} + + + + + + + + + + + + + {t('nodes.layout.layerSpacing')} + + + + + + + + + + + + + + + + + + {/* +) => 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; + +export const useAutoLayout = (): (() => Promise) => { + 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)); + + // 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) => { + // 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 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)) { + 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; + } + } + + 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, + }; + + // 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: 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', + layoutOptions, + 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..8c155139d5a 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -2,11 +2,21 @@ 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 WorkflowSettingsState = { _version: 1; shouldShowMinimapPanel: boolean; + nodePlacementStrategy: NodePlacementStrategy; + layeringStrategy: LayeringStrategy; + nodeSpacing: number; + layerSpacing: number; + layoutDirection: LayoutDirection; shouldValidateGraph: boolean; shouldAnimateEdges: boolean; nodeOpacity: number; @@ -19,6 +29,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 +50,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 +93,11 @@ export const { shouldAnimateEdgesChanged, shouldColorEdgesChanged, shouldShowMinimapPanelChanged, + nodePlacementStrategyChanged, + layeringStrategyChanged, + nodeSpacingChanged, + layerSpacingChanged, + layoutDirectionChanged, shouldShowEdgeLabelsChanged, shouldSnapToGridChanged, shouldValidateGraphChanged, @@ -86,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); @@ -96,3 +131,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, },