From ac850e559f787a39261ecfa7a3c590ffece789f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E6=B5=93?= <844670992@qq.com> Date: Mon, 31 Mar 2025 15:17:17 +0800 Subject: [PATCH] feat: organize button adds organization of nodes inside iteration/loop nodes (#17068) --- web/app/components/workflow/constants.ts | 4 + .../hooks/use-workflow-interactions.ts | 118 ++++++++++++++- web/app/components/workflow/utils.ts | 134 +++++++++++++++++- 3 files changed, 250 insertions(+), 6 deletions(-) diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 19195b168f..fce79cb1df 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -416,6 +416,10 @@ export const LOOP_PADDING = { left: 16, } +export const NODE_LAYOUT_HORIZONTAL_PADDING = 60 +export const NODE_LAYOUT_VERTICAL_PADDING = 60 +export const NODE_LAYOUT_MIN_DISTANCE = 100 + let maxParallelLimit = 10 if (process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT && process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT !== '') diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index b39a3d8014..eeb4d658a4 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -8,12 +8,15 @@ import produce from 'immer' import { useStore, useWorkflowStore } from '../store' import { CUSTOM_NODE, DSL_EXPORT_CHECK, + NODE_LAYOUT_HORIZONTAL_PADDING, + NODE_LAYOUT_VERTICAL_PADDING, WORKFLOW_DATA_UPDATE, } from '../constants' import type { Node, WorkflowDataUpdater } from '../types' -import { ControlMode } from '../types' +import { BlockEnum, ControlMode } from '../types' import { getLayoutByDagre, + getLayoutForChildNodes, initialEdges, initialNodes, } from '../utils' @@ -98,10 +101,81 @@ export const useWorkflowOrganize = () => { } = store.getState() const { setViewport } = reactflow const nodes = getNodes() - const layout = getLayoutByDagre(nodes, edges) - const rankMap = {} as Record - nodes.forEach((node) => { + const loopAndIterationNodes = nodes.filter( + node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) + && !node.parentId + && node.type === CUSTOM_NODE, + ) + + const childLayoutsMap: Record = {} + loopAndIterationNodes.forEach((node) => { + childLayoutsMap[node.id] = getLayoutForChildNodes(node.id, nodes, edges) + }) + + const containerSizeChanges: Record = {} + + loopAndIterationNodes.forEach((parentNode) => { + const childLayout = childLayoutsMap[parentNode.id] + if (!childLayout) return + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + let hasChildren = false + + const childNodes = nodes.filter(node => node.parentId === parentNode.id) + + childNodes.forEach((node) => { + if (childLayout.node(node.id)) { + hasChildren = true + const childNodeWithPosition = childLayout.node(node.id) + + const nodeX = childNodeWithPosition.x - node.width! / 2 + const nodeY = childNodeWithPosition.y - node.height! / 2 + + minX = Math.min(minX, nodeX) + minY = Math.min(minY, nodeY) + maxX = Math.max(maxX, nodeX + node.width!) + maxY = Math.max(maxY, nodeY + node.height!) + } + }) + + if (hasChildren) { + const requiredWidth = maxX - minX + NODE_LAYOUT_HORIZONTAL_PADDING * 2 + const requiredHeight = maxY - minY + NODE_LAYOUT_VERTICAL_PADDING * 2 + + containerSizeChanges[parentNode.id] = { + width: Math.max(parentNode.width || 0, requiredWidth), + height: Math.max(parentNode.height || 0, requiredHeight), + } + } + }) + + const nodesWithUpdatedSizes = produce(nodes, (draft) => { + draft.forEach((node) => { + if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) + && containerSizeChanges[node.id]) { + node.width = containerSizeChanges[node.id].width + node.height = containerSizeChanges[node.id].height + + if (node.data.type === BlockEnum.Loop) { + node.data.width = containerSizeChanges[node.id].width + node.data.height = containerSizeChanges[node.id].height + } + else if (node.data.type === BlockEnum.Iteration) { + node.data.width = containerSizeChanges[node.id].width + node.data.height = containerSizeChanges[node.id].height + } + } + }) + }) + + const layout = getLayoutByDagre(nodesWithUpdatedSizes, edges) + + const rankMap = {} as Record + nodesWithUpdatedSizes.forEach((node) => { if (!node.parentId && node.type === CUSTOM_NODE) { const rank = layout.node(node.id).rank! @@ -115,7 +189,7 @@ export const useWorkflowOrganize = () => { } }) - const newNodes = produce(nodes, (draft) => { + const newNodes = produce(nodesWithUpdatedSizes, (draft) => { draft.forEach((node) => { if (!node.parentId && node.type === CUSTOM_NODE) { const nodeWithPosition = layout.node(node.id) @@ -126,7 +200,40 @@ export const useWorkflowOrganize = () => { } } }) + + loopAndIterationNodes.forEach((parentNode) => { + const childLayout = childLayoutsMap[parentNode.id] + if (!childLayout) return + + const childNodes = draft.filter(node => node.parentId === parentNode.id) + + let minX = Infinity + let minY = Infinity + + childNodes.forEach((node) => { + if (childLayout.node(node.id)) { + const childNodeWithPosition = childLayout.node(node.id) + const nodeX = childNodeWithPosition.x - node.width! / 2 + const nodeY = childNodeWithPosition.y - node.height! / 2 + + minX = Math.min(minX, nodeX) + minY = Math.min(minY, nodeY) + } + }) + + childNodes.forEach((node) => { + if (childLayout.node(node.id)) { + const childNodeWithPosition = childLayout.node(node.id) + + node.position = { + x: NODE_LAYOUT_HORIZONTAL_PADDING + (childNodeWithPosition.x - node.width! / 2 - minX), + y: NODE_LAYOUT_VERTICAL_PADDING + (childNodeWithPosition.y - node.height! / 2 - minY), + } + } + }) + }) }) + setNodes(newNodes) const zoom = 0.7 setViewport({ @@ -139,6 +246,7 @@ export const useWorkflowOrganize = () => { handleSyncWorkflowDraft() }) }, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) + return { handleLayout, } diff --git a/web/app/components/workflow/utils.ts b/web/app/components/workflow/utils.ts index dd0beece52..fda468aae6 100644 --- a/web/app/components/workflow/utils.ts +++ b/web/app/components/workflow/utils.ts @@ -32,6 +32,9 @@ import { ITERATION_NODE_Z_INDEX, LOOP_CHILDREN_Z_INDEX, LOOP_NODE_Z_INDEX, + NODE_LAYOUT_HORIZONTAL_PADDING, + NODE_LAYOUT_MIN_DISTANCE, + NODE_LAYOUT_VERTICAL_PADDING, NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION, } from './constants' @@ -461,13 +464,142 @@ export const getLayoutByDagre = (originNodes: Node[], originEdges: Edge[]) => { height: node.height!, }) }) - edges.forEach((edge) => { dagreGraph.setEdge(edge.source, edge.target) }) + dagre.layout(dagreGraph) + return dagreGraph +} + +export const getLayoutForChildNodes = (parentNodeId: string, originNodes: Node[], originEdges: Edge[]) => { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + + const nodes = cloneDeep(originNodes).filter(node => node.parentId === parentNodeId) + const edges = cloneDeep(originEdges).filter(edge => + (edge.data?.isInIteration && edge.data?.iteration_id === parentNodeId) + || (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId), + ) + + const startNode = nodes.find(node => + node.type === CUSTOM_ITERATION_START_NODE + || node.type === CUSTOM_LOOP_START_NODE + || node.data?.type === BlockEnum.LoopStart + || node.data?.type === BlockEnum.IterationStart, + ) + + if (!startNode) { + dagreGraph.setGraph({ + rankdir: 'LR', + align: 'UL', + nodesep: 40, + ranksep: 60, + marginx: NODE_LAYOUT_HORIZONTAL_PADDING, + marginy: NODE_LAYOUT_VERTICAL_PADDING, + }) + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: node.width || 244, + height: node.height || 100, + }) + }) + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) + + dagre.layout(dagreGraph) + return dagreGraph + } + + const startNodeOutEdges = edges.filter(edge => edge.source === startNode.id) + const firstConnectedNodes = startNodeOutEdges.map(edge => + nodes.find(node => node.id === edge.target), + ).filter(Boolean) as Node[] + + const nonStartNodes = nodes.filter(node => node.id !== startNode.id) + const nonStartEdges = edges.filter(edge => edge.source !== startNode.id && edge.target !== startNode.id) + + dagreGraph.setGraph({ + rankdir: 'LR', + align: 'UL', + nodesep: 40, + ranksep: 60, + marginx: NODE_LAYOUT_HORIZONTAL_PADDING / 2, + marginy: NODE_LAYOUT_VERTICAL_PADDING / 2, + }) + + nonStartNodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: node.width || 244, + height: node.height || 100, + }) + }) + + nonStartEdges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) dagre.layout(dagreGraph) + const startNodeSize = { + width: startNode.width || 44, + height: startNode.height || 48, + } + + const startNodeX = NODE_LAYOUT_HORIZONTAL_PADDING / 1.5 + let startNodeY = 100 + + let minFirstLayerX = Infinity + let avgFirstLayerY = 0 + let firstLayerCount = 0 + + if (firstConnectedNodes.length > 0) { + firstConnectedNodes.forEach((node) => { + if (dagreGraph.node(node.id)) { + const nodePos = dagreGraph.node(node.id) + avgFirstLayerY += nodePos.y + firstLayerCount++ + minFirstLayerX = Math.min(minFirstLayerX, nodePos.x - nodePos.width / 2) + } + }) + + if (firstLayerCount > 0) { + avgFirstLayerY /= firstLayerCount + startNodeY = avgFirstLayerY + } + + const minRequiredX = startNodeX + startNodeSize.width + NODE_LAYOUT_MIN_DISTANCE + + if (minFirstLayerX < minRequiredX) { + const shiftX = minRequiredX - minFirstLayerX + + nonStartNodes.forEach((node) => { + if (dagreGraph.node(node.id)) { + const nodePos = dagreGraph.node(node.id) + dagreGraph.setNode(node.id, { + x: nodePos.x + shiftX, + y: nodePos.y, + width: nodePos.width, + height: nodePos.height, + }) + } + }) + } + } + + dagreGraph.setNode(startNode.id, { + x: startNodeX + startNodeSize.width / 2, + y: startNodeY, + width: startNodeSize.width, + height: startNodeSize.height, + }) + + startNodeOutEdges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) + return dagreGraph }