diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8a3f8e1d1b7..931816bea6b 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", + "layeringStrategy": "Layering Strategy", + "networkSimplex": "Network Simplex", + "longestPath": "Longest Path", + "nodeSpacing": "Node Spacing", + "layerSpacing": "Layer Spacing", + "layoutDirection": "Layout Direction", + "layoutDirectionRight": "Right", + "layoutDirectionDown": "Down", + "alignment": "Node Alignment", + "alignmentUL": "Top Left", + "alignmentDL": "Bottom Left", + "alignmentUR": "Top Right", + "alignmentDR": "Bottom Right" + } }, "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..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 @@ -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 NodeAlignment, + nodeAlignmentChanged, + nodeSpacingChanged, + selectLayeringStrategy, + selectLayerSpacing, + selectLayoutDirection, + selectNodeAlignment, + 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 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 }); @@ -36,6 +83,62 @@ const ViewportControls = () => { dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel)); }, [shouldShowMinimapPanel, 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 handleNodeAlignmentChanged = useCallback( + (e: ChangeEvent) => { + const value = e.target.value as NodeAlignment; + dispatch(nodeAlignmentChanged(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.layoutDirection')} + + + + {t('nodes.layout.layeringStrategy')} + + + + {t('nodes.layout.alignment')} + + + + + {t('nodes.layout.nodeSpacing')} + + + + + + + + + + + + + {t('nodes.layout.layerSpacing')} + + + + + + + + + + + + + + + + + + {/* void) => { + const dispatch = useAppDispatch(); + const nodes = useAppSelector(selectNodes); + const edges = useAppSelector(selectEdges); + const nodeSpacing = useAppSelector(selectNodeSpacing); + const layerSpacing = useAppSelector(selectLayerSpacing); + const layeringStrategy = useAppSelector(selectLayeringStrategy); + const layoutDirection = useAppSelector(selectLayoutDirection); + const nodeAlignment = useAppSelector(selectNodeAlignment); + + const autoLayout = useCallback(() => { + const g = new graphlib.Graph(); + + g.setGraph({ + rankdir: layoutDirection, + nodesep: nodeSpacing, + ranksep: layerSpacing, + ranker: layeringStrategy, + align: nodeAlignment, + }); + + g.setDefaultEdgeLabel(() => ({})); + + const selectedNodes = nodes.filter((n) => n.selected); + const isLayoutSelection = selectedNodes.length > 1 && nodes.length > selectedNodes.length; + const nodesToLayout = isLayoutSelection ? selectedNodes : nodes; + + //Anchor of the selected nodes + const selectionAnchor = { + minX: Infinity, + minY: Infinity, + }; + + nodesToLayout.forEach((node) => { + // If we're laying out a selection, adjust the anchor to the top-left of the selection + if (isLayoutSelection) { + selectionAnchor.minX = Math.min(selectionAnchor.minX, node.position.x); + selectionAnchor.minY = Math.min(selectionAnchor.minY, node.position.y); + } + // 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, + height: height, + }); + }); + + let edgesToLayout: Edge[] = edges; + if (isLayoutSelection) { + const nodesToLayoutIds = new Set(nodesToLayout.map((n) => n.id)); + edgesToLayout = edges.filter((edge) => nodesToLayoutIds.has(edge.source) && nodesToLayoutIds.has(edge.target)); + } + + edgesToLayout.forEach((edge) => { + g.setEdge(edge.source, edge.target); + }); + + layout(g); + + // anchor for the new layout + const layoutAnchor = { + minX: Infinity, + minY: Infinity, + }; + let offsetX = 0; + let offsetY = 0; + + if (isLayoutSelection) { + // Get the top-left position of the new layout + nodesToLayout.forEach((node) => { + const nodeInfo = g.node(node.id); + // Convert from center to top-left + const topLeftX = nodeInfo.x - nodeInfo.width / 2; + const topLeftY = nodeInfo.y - nodeInfo.height / 2; + // Use the top-left coordinates to find the bounding box + layoutAnchor.minX = Math.min(layoutAnchor.minX, topLeftX); + layoutAnchor.minY = Math.min(layoutAnchor.minY, topLeftY); + }); + // Calculate the offset needed to move the new layout to the original position + offsetX = selectionAnchor.minX - layoutAnchor.minX; + offsetY = selectionAnchor.minY - layoutAnchor.minY; + } + + // Create position changes for each node based on the new layout + const positionChanges: NodeChange[] = nodesToLayout.map((node) => { + const nodeInfo = g.node(node.id); + // Convert from center-based position to top-left-based position + const x = nodeInfo.x - nodeInfo.width / 2; + const y = nodeInfo.y - nodeInfo.height / 2; + const newPosition = { + x: isLayoutSelection ? x + offsetX : x, + y: isLayoutSelection ? y + offsetY : y, + }; + return { id: node.id, type: 'position', position: newPosition }; + }); + + dispatch(nodesChanged(positionChanges)); + }, [dispatch, edges, nodes, nodeSpacing, layerSpacing, layeringStrategy, layoutDirection, nodeAlignment]); + + return autoLayout; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts index 8d0ef927cd9..a0a409364fe 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSettingsSlice.ts @@ -4,11 +4,20 @@ import { SelectionMode } from '@xyflow/react'; import type { PersistConfig, RootState } from 'app/store/store'; import type { Selector } from 'react-redux'; +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; + layeringStrategy: LayeringStrategy; + nodeSpacing: number; + layerSpacing: number; + layoutDirection: LayoutDirection; shouldValidateGraph: boolean; shouldAnimateEdges: boolean; + nodeAlignment: NodeAlignment; nodeOpacity: number; shouldSnapToGrid: boolean; shouldColorEdges: boolean; @@ -19,6 +28,11 @@ export type WorkflowSettingsState = { const initialState: WorkflowSettingsState = { _version: 1, shouldShowMinimapPanel: true, + layeringStrategy: 'network-simplex', + nodeSpacing: 32, + layerSpacing: 32, + layoutDirection: 'LR', + nodeAlignment: 'UL', shouldValidateGraph: true, shouldAnimateEdges: true, shouldSnapToGrid: false, @@ -35,6 +49,18 @@ export const workflowSettingsSlice = createSlice({ shouldShowMinimapPanelChanged: (state, action: PayloadAction) => { state.shouldShowMinimapPanel = 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; }, @@ -53,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; }, @@ -63,8 +92,13 @@ export const { shouldAnimateEdgesChanged, shouldColorEdgesChanged, shouldShowMinimapPanelChanged, + layeringStrategyChanged, + nodeSpacingChanged, + layerSpacingChanged, + layoutDirectionChanged, shouldShowEdgeLabelsChanged, shouldSnapToGridChanged, + nodeAlignmentChanged, shouldValidateGraphChanged, nodeOpacityChanged, selectionModeChanged, @@ -96,3 +130,9 @@ export const selectShouldShowEdgeLabels = createWorkflowSettingsSelector((s) => export const selectNodeOpacity = createWorkflowSettingsSelector((s) => s.nodeOpacity); export const selectShouldShowMinimapPanel = createWorkflowSettingsSelector((s) => s.shouldShowMinimapPanel); export const selectShouldShouldValidateGraph = createWorkflowSettingsSelector((s) => s.shouldValidateGraph); + +export const selectLayeringStrategy = createWorkflowSettingsSelector((s) => s.layeringStrategy); +export const selectNodeSpacing = createWorkflowSettingsSelector((s) => s.nodeSpacing); +export const selectLayerSpacing = createWorkflowSettingsSelector((s) => s.layerSpacing); +export const selectLayoutDirection = createWorkflowSettingsSelector((s) => s.layoutDirection); +export const selectNodeAlignment = createWorkflowSettingsSelector((s) => s.nodeAlignment);