diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx index 862347d0b8..1d2ccfd868 100644 --- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -8,6 +8,7 @@ import { ChevronDown, Copy, History, + Layers, Loader2, Play, SkipForward, @@ -698,6 +699,37 @@ export function ControlBar() { ) + /** + * Render auto-layout button + */ + const renderAutoLayoutButton = () => { + const handleAutoLayoutClick = () => { + if (isExecuting || isMultiRunning || isDebugging) { + return + } + + window.dispatchEvent(new CustomEvent('trigger-auto-layout')) + } + + return ( + + + + + Auto Layout + + ) + } + /** * Render debug mode controls */ @@ -975,6 +1007,7 @@ export function ControlBar() { {renderHistoryDropdown()} {renderNotificationsDropdown()} {renderDuplicateButton()} + {renderAutoLayoutButton()} {renderDebugModeToggle()} {/* {renderPublishButton()} */} {renderDeployButton()} diff --git a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx index 0b87808d06..d044dfba7b 100644 --- a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx @@ -12,8 +12,6 @@ export const WorkflowEdge = ({ data, style, }: EdgeProps) => { - const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' - const [edgePath, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, @@ -22,7 +20,7 @@ export const WorkflowEdge = ({ targetY, targetPosition, borderRadius: 8, - offset: isHorizontal ? 30 : 20, + offset: 10, }) // Use the directly provided isSelected flag instead of computing it diff --git a/apps/sim/app/w/[id]/utils.ts b/apps/sim/app/w/[id]/utils.ts index d16f092581..3d269af5a1 100644 --- a/apps/sim/app/w/[id]/utils.ts +++ b/apps/sim/app/w/[id]/utils.ts @@ -2,13 +2,198 @@ import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('WorkflowUtils') -// Default dimensions for loop and parallel container nodes const DEFAULT_CONTAINER_WIDTH = 500 const DEFAULT_CONTAINER_HEIGHT = 300 /** - * Utility functions for handling node hierarchies and loop operations in the workflow + * Check if a block is a container type */ +const isContainerType = (blockType: string): boolean => { + return ( + blockType === 'loop' || + blockType === 'parallel' || + blockType === 'loopNode' || + blockType === 'parallelNode' + ) +} + +/** + * Check if a block is a container block + */ +const isContainerBlock = (blocks: Record, blockId: string): boolean => { + const block = blocks[blockId] + return block && isContainerType(block.type) +} + +/** + * Get the priority score of a block + */ +const getBlockPriorityScore = ( + blockId: string, + orphanedBlocks: Set, + disabledBlocks: Set, + terminalBlocks: Set +): number => { + if (orphanedBlocks.has(blockId)) return 3 + if (disabledBlocks.has(blockId)) return 2 + if (terminalBlocks.has(blockId)) return 1 + return 0 +} + +/** + * Get the type of a block + */ +const getBlockType = ( + blockId: string, + orphanedBlocks: Set, + disabledBlocks: Set, + terminalBlocks: Set +): 'orphaned' | 'disabled' | 'terminal' | 'regular' => { + if (orphanedBlocks.has(blockId)) return 'orphaned' + if (disabledBlocks.has(blockId)) return 'disabled' + if (terminalBlocks.has(blockId)) return 'terminal' + return 'regular' +} + +/** + * Calculate extra spacing between blocks of different types + */ +const calculateExtraSpacing = ( + currentBlockType: string, + nextBlockType: string, + baseSpacing: number, + multiplier = 0.3 +): number => { + return currentBlockType !== nextBlockType ? baseSpacing * multiplier : 0 +} + +/** + * Calculate the dimensions of a group of blocks + */ +const calculateGroupDimensions = ( + group: string[], + orphanedBlocks: Set, + disabledBlocks: Set, + terminalBlocks: Set, + blocks: Record, + spacing: number, + getDimension: (blocks: Record, blockId: string) => number +): number => { + const sortedGroup = sortBlocksByPriority(group, orphanedBlocks, disabledBlocks, terminalBlocks) + + let totalDimension = 0 + sortedGroup.forEach((nodeId, index) => { + const blockDimension = getDimension(blocks, nodeId) + totalDimension += blockDimension + + if (index < sortedGroup.length - 1) { + const currentBlockType = getBlockType(nodeId, orphanedBlocks, disabledBlocks, terminalBlocks) + const nextBlockType = getBlockType( + sortedGroup[index + 1], + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + const extraSpacing = calculateExtraSpacing(currentBlockType, nextBlockType, spacing) + totalDimension += spacing * 0.5 + extraSpacing + } + }) + + return totalDimension +} + +/** + * Group nodes by their parent relationships + */ +const groupNodesByParents = ( + nodes: string[], + edges: any[], + blocks: Record, + keyPrefix = '' +): Map => { + const parentGroups = new Map() + + nodes.forEach((nodeId) => { + const parents: string[] = [] + edges.forEach((edge) => { + if (edge.target === nodeId && blocks[edge.source]) { + parents.push(edge.source) + } + }) + + const parentKey = parents.sort().join(',') || `${keyPrefix}no-parent` + + if (!parentGroups.has(parentKey)) { + parentGroups.set(parentKey, []) + } + parentGroups.get(parentKey)!.push(nodeId) + }) + + return parentGroups +} + +/** + * Sort blocks by priority + */ +const sortBlocksByPriority = ( + blocks: string[], + orphanedBlocks: Set, + disabledBlocks: Set, + terminalBlocks: Set +): string[] => { + return [...blocks].sort((a, b) => { + const aScore = getBlockPriorityScore(a, orphanedBlocks, disabledBlocks, terminalBlocks) + const bScore = getBlockPriorityScore(b, orphanedBlocks, disabledBlocks, terminalBlocks) + if (aScore !== bScore) return aScore - bScore + return a.localeCompare(b) + }) +} + +/** + * Get the dimensions of a block + */ +const getBlockDimensions = ( + blocks: Record, + blockId: string +): { width: number; height: number } => { + const block = blocks[blockId] + if (!block) return { width: 350, height: 150 } + + if (isContainerType(block.type)) { + return { + width: block.data?.width ? Math.max(block.data.width, 400) : DEFAULT_CONTAINER_WIDTH, + height: block.data?.height ? Math.max(block.data.height, 200) : DEFAULT_CONTAINER_HEIGHT, + } + } + + if (block.type === 'workflowBlock') { + const nodeWidth = block.data?.width || block.width + const nodeHeight = block.data?.height || block.height + + if (nodeWidth && nodeHeight) { + return { width: nodeWidth, height: nodeHeight } + } + } + + return { + width: block.isWide ? 450 : block.data?.width || block.width || 350, + height: Math.max(block.height || block.data?.height || 150, 100), + } +} + +/** + * Get the height of a block + */ +const getBlockHeight = (blocks: Record, blockId: string): number => { + return getBlockDimensions(blocks, blockId).height +} + +/** + * Get the width of a block + */ +const getBlockWidth = (blocks: Record, blockId: string): number => { + return getBlockDimensions(blocks, blockId).width +} /** * Calculates the depth of a node in the hierarchy tree @@ -19,8 +204,7 @@ const DEFAULT_CONTAINER_HEIGHT = 300 */ export const getNodeDepth = (nodeId: string, getNodes: () => any[], maxDepth = 100): number => { const node = getNodes().find((n) => n.id === nodeId) - if (!node || !node.parentId) return 0 - if (maxDepth <= 0) return 0 + if (!node || !node.parentId || maxDepth <= 0) return 0 return 1 + getNodeDepth(node.parentId, getNodes, maxDepth - 1) } @@ -48,8 +232,6 @@ export const getNodeAbsolutePosition = ( ): { x: number; y: number } => { const node = getNodes().find((n) => n.id === nodeId) if (!node) { - // Handle case where node doesn't exist anymore by returning origin position - // This helps prevent errors during cleanup operations logger.warn('Attempted to get position of non-existent node', { nodeId }) return { x: 0, y: 0 } } @@ -58,10 +240,8 @@ export const getNodeAbsolutePosition = ( return node.position } - // Check if parent exists const parentNode = getNodes().find((n) => n.id === node.parentId) if (!parentNode) { - // Parent reference is invalid, return node's current position logger.warn('Node references non-existent parent', { nodeId, invalidParentId: node.parentId, @@ -69,12 +249,10 @@ export const getNodeAbsolutePosition = ( return node.position } - // Check for circular reference to prevent infinite recursion const visited = new Set() let current: any = node while (current?.parentId) { if (visited.has(current.parentId)) { - // Circular reference detected logger.error('Circular parent reference detected', { nodeId, parentChain: Array.from(visited), @@ -85,10 +263,8 @@ export const getNodeAbsolutePosition = ( current = getNodes().find((n) => n.id === current.parentId) } - // Get parent's absolute position const parentPos = getNodeAbsolutePosition(node.parentId, getNodes) - // Calculate this node's absolute position return { x: parentPos.x + node.position.x, y: parentPos.y + node.position.y, @@ -107,13 +283,10 @@ export const calculateRelativePosition = ( newParentId: string, getNodes: () => any[] ): { x: number; y: number } => { - // Get absolute position of the node const nodeAbsPos = getNodeAbsolutePosition(nodeId, getNodes) - // Get absolute position of the new parent const parentAbsPos = getNodeAbsolutePosition(newParentId, getNodes) - // Calculate relative position return { x: nodeAbsPos.x - parentAbsPos.x, y: nodeAbsPos.y - parentAbsPos.y, @@ -137,7 +310,6 @@ export const updateNodeParent = ( updateParentId: (id: string, parentId: string, extent: 'parent') => void, resizeLoopNodes: () => void ) => { - // Skip if no change const node = getNodes().find((n) => n.id === nodeId) if (!node) return @@ -145,22 +317,16 @@ export const updateNodeParent = ( if (newParentId === currentParentId) return if (newParentId) { - // Moving to a new parent - calculate relative position const relativePosition = calculateRelativePosition(nodeId, newParentId, getNodes) - // Update both position and parent updateBlockPosition(nodeId, relativePosition) updateParentId(nodeId, newParentId, 'parent') } else if (currentParentId) { - // Removing parent - convert to absolute position const absolutePosition = getNodeAbsolutePosition(nodeId, getNodes) - // Update position to absolute coordinates and remove parent updateBlockPosition(nodeId, absolutePosition) - // Note: updateParentId function signature needs to handle null case } - // Resize affected loops resizeLoopNodes() } @@ -178,9 +344,8 @@ export const isPointInLoopNode = ( loopPosition: { x: number; y: number } dimensions: { width: number; height: number } } | null => { - // Find loops and parallel nodes that contain this position point const containingNodes = getNodes() - .filter((n) => n.type === 'loopNode' || n.type === 'parallelNode') + .filter((n) => isContainerType(n.type)) .filter((n) => { const rect = { left: n.position.x, @@ -205,7 +370,6 @@ export const isPointInLoopNode = ( }, })) - // Sort by area (smallest first) in case of nested containers if (containingNodes.length > 0) { return containingNodes.sort((a, b) => { const aArea = a.dimensions.width * a.dimensions.height @@ -221,75 +385,29 @@ export const isPointInLoopNode = ( * Calculates appropriate dimensions for a loop or parallel node based on its children * @param nodeId ID of the container node * @param getNodes Function to retrieve all nodes from ReactFlow + * @param blocks Block states from workflow store * @returns Calculated width and height for the container */ export const calculateLoopDimensions = ( nodeId: string, - getNodes: () => any[] + getNodes: () => any[], + blocks: Record ): { width: number; height: number } => { - // Default minimum dimensions const minWidth = DEFAULT_CONTAINER_WIDTH const minHeight = DEFAULT_CONTAINER_HEIGHT - // Get all child nodes of this container const childNodes = getNodes().filter((node) => node.parentId === nodeId) - if (childNodes.length === 0) { return { width: minWidth, height: minHeight } } - // Calculate the bounding box that contains all children let minX = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY let maxY = Number.NEGATIVE_INFINITY childNodes.forEach((node) => { - // Get accurate node dimensions based on node type - let nodeWidth - let nodeHeight - - if (node.type === 'loopNode' || node.type === 'parallelNode') { - // For nested containers, use their actual dimensions - nodeWidth = node.data?.width || DEFAULT_CONTAINER_WIDTH - nodeHeight = node.data?.height || DEFAULT_CONTAINER_HEIGHT - } else if (node.type === 'workflowBlock') { - // Use actual dynamic dimensions from the node data if available - // Fall back to block type defaults only if no dynamic dimensions exist - nodeWidth = node.data?.width || node.width - nodeHeight = node.data?.height || node.height - - // If still no dimensions, use block type defaults as last resort - if (!nodeWidth || !nodeHeight) { - const blockType = node.data?.type - - switch (blockType) { - case 'agent': - case 'api': - // Tall blocks - nodeWidth = nodeWidth || 350 - nodeHeight = nodeHeight || 650 - break - case 'condition': - case 'function': - nodeWidth = nodeWidth || 250 - nodeHeight = nodeHeight || 200 - break - case 'router': - nodeWidth = nodeWidth || 250 - nodeHeight = nodeHeight || 350 - break - default: - // Default dimensions for other block types - nodeWidth = nodeWidth || 200 - nodeHeight = nodeHeight || 200 - } - } - } else { - // For any other node types, try to get actual dimensions first - nodeWidth = node.data?.width || node.width || 200 - nodeHeight = node.data?.height || node.height || 200 - } + const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(blocks, node.id) minX = Math.min(minX, node.position.x + nodeWidth) minY = Math.min(minY, node.position.y + nodeHeight) @@ -297,21 +415,12 @@ export const calculateLoopDimensions = ( maxY = Math.max(maxY, node.position.y + nodeHeight + 50) }) - // Add buffer padding to all sides (20px buffer before edges) - // Add extra padding for nested containers to prevent tight boundaries - const hasNestedContainers = childNodes.some( - (node) => node.type === 'loopNode' || node.type === 'parallelNode' - ) + const hasNestedContainers = childNodes.some((node) => isContainerType(node.type)) - // More reasonable padding values, especially for nested containers - // Reduce the excessive padding that was causing parent containers to be too large - const sidePadding = hasNestedContainers ? 150 : 120 // Reduced padding for containers containing other containers + const sidePadding = hasNestedContainers ? 150 : 120 - // Add extra padding to the right side to prevent sidebar from covering the right handle const extraPadding = 50 - // Ensure the width and height are never less than the minimums - // Apply padding to all sides (left/right and top/bottom) with extra right padding const width = Math.max(minWidth, maxX + sidePadding + extraPadding) const height = Math.max(minHeight, maxY + sidePadding) @@ -322,27 +431,703 @@ export const calculateLoopDimensions = ( * Resizes all loop and parallel nodes based on their children * @param getNodes Function to retrieve all nodes from ReactFlow * @param updateNodeDimensions Function to update the dimensions of a node + * @param blocks Block states from workflow store */ export const resizeLoopNodes = ( getNodes: () => any[], - updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void, + blocks: Record ) => { - // Find all container nodes and sort by hierarchy depth (parents first) const containerNodes = getNodes() - .filter((node) => node.type === 'loopNode' || node.type === 'parallelNode') + .filter((node) => isContainerType(node.type)) .map((node) => ({ ...node, depth: getNodeDepth(node.id, getNodes), })) .sort((a, b) => a.depth - b.depth) - // Resize each container node based on its children containerNodes.forEach((node) => { - const dimensions = calculateLoopDimensions(node.id, getNodes) + const dimensions = calculateLoopDimensions(node.id, getNodes, blocks) - // Only update if dimensions have changed (to avoid unnecessary updates) if (dimensions.width !== node.data?.width || dimensions.height !== node.data?.height) { updateNodeDimensions(node.id, dimensions) } }) } + +export interface LayoutOptions { + horizontalSpacing?: number + verticalSpacing?: number + startX?: number + startY?: number + alignByLayer?: boolean + handleOrientation?: 'auto' | 'horizontal' | 'vertical' +} + +/** + * Detects the predominant handle orientation in the workflow + * @param blocks Block states from workflow store + * @returns 'horizontal' if most blocks use horizontal handles, 'vertical' otherwise + */ +export const detectHandleOrientation = (blocks: Record): 'horizontal' | 'vertical' => { + const topLevelBlocks = Object.values(blocks).filter((block) => !block.data?.parentId) + + if (topLevelBlocks.length === 0) { + return 'horizontal' + } + + let horizontalCount = 0 + let verticalCount = 0 + + topLevelBlocks.forEach((block) => { + if (block.horizontalHandles === true) { + horizontalCount++ + } else if (block.horizontalHandles === false) { + verticalCount++ + } else { + horizontalCount++ + } + }) + + return horizontalCount >= verticalCount ? 'horizontal' : 'vertical' +} + +/** + * Analyzes the workflow graph using topological sort to determine execution layers + * and properly handle parallel execution paths, disabled blocks, and terminal blocks + * @param blocks Block states from workflow store + * @param edges Edge connections from workflow store + * @returns Map of block IDs to their layer numbers and additional graph data + */ +export const analyzeWorkflowGraph = ( + blocks: Record, + edges: any[] +): { + layers: Map + parallelGroups: Map + maxLayer: number + disabledBlocks: Set + terminalBlocks: Set + orphanedBlocks: Set +} => { + const blockLayers = new Map() + const inDegree = new Map() + const adjacencyList = new Map() + const disabledBlocks = new Set() + const terminalBlocks = new Set() + const orphanedBlocks = new Set() + + Object.entries(blocks).forEach(([blockId, block]) => { + inDegree.set(blockId, 0) + adjacencyList.set(blockId, []) + + if (block.enabled === false) { + disabledBlocks.add(blockId) + } + }) + + edges.forEach((edge) => { + if (edge.source && edge.target && blocks[edge.source] && blocks[edge.target]) { + const neighbors = adjacencyList.get(edge.source) || [] + neighbors.push(edge.target) + adjacencyList.set(edge.source, neighbors) + inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1) + } + }) + + Object.keys(blocks).forEach((blockId) => { + const neighbors = adjacencyList.get(blockId) || [] + const block = blocks[blockId] + + if (neighbors.length === 0 && !isContainerType(block.type)) { + terminalBlocks.add(blockId) + } + }) + + Object.keys(blocks).forEach((blockId) => { + const inDegreeValue = inDegree.get(blockId) || 0 + const outDegreeValue = (adjacencyList.get(blockId) || []).length + const block = blocks[blockId] + + if (inDegreeValue === 0 && outDegreeValue === 0 && block.type !== 'starter') { + orphanedBlocks.add(blockId) + } + }) + + const queue: string[] = [] + inDegree.forEach((degree, blockId) => { + if (degree === 0 || blocks[blockId].type === 'starter') { + queue.push(blockId) + blockLayers.set(blockId, 0) + } + }) + + const visited = new Set() + let maxLayer = 0 + + while (queue.length > 0) { + const currentId = queue.shift()! + visited.add(currentId) + + const currentLayer = blockLayers.get(currentId) || 0 + maxLayer = Math.max(maxLayer, currentLayer) + + const neighbors = adjacencyList.get(currentId) || [] + neighbors.forEach((neighborId) => { + const existingLayer = blockLayers.get(neighborId) || 0 + blockLayers.set(neighborId, Math.max(existingLayer, currentLayer + 1)) + + const newInDegree = (inDegree.get(neighborId) || 1) - 1 + inDegree.set(neighborId, newInDegree) + + if (newInDegree === 0 && !visited.has(neighborId)) { + queue.push(neighborId) + } + }) + } + + Object.keys(blocks).forEach((blockId) => { + if (!blockLayers.has(blockId)) { + if (orphanedBlocks.has(blockId)) { + blockLayers.set(blockId, maxLayer + 2) + } else { + blockLayers.set(blockId, 0) + } + } + }) + + blockLayers.forEach((layer) => { + maxLayer = Math.max(maxLayer, layer) + }) + + const parallelGroups = new Map() + const layerNodes = new Map() + + blockLayers.forEach((layer, blockId) => { + if (!layerNodes.has(layer)) { + layerNodes.set(layer, []) + } + layerNodes.get(layer)!.push(blockId) + }) + + layerNodes.forEach((nodes, layer) => { + if (nodes.length === 1) { + parallelGroups.set(layer, [[nodes[0]]]) + } else { + const disabledNodesInLayer = nodes.filter((nodeId) => disabledBlocks.has(nodeId)) + const terminalNodesInLayer = nodes.filter((nodeId) => terminalBlocks.has(nodeId)) + const orphanedNodesInLayer = nodes.filter((nodeId) => orphanedBlocks.has(nodeId)) + const regularNodes = nodes.filter( + (nodeId) => + !disabledBlocks.has(nodeId) && !terminalBlocks.has(nodeId) && !orphanedBlocks.has(nodeId) + ) + + const regularParentGroups = groupNodesByParents(regularNodes, edges, blocks) + const groups = Array.from(regularParentGroups.values()) + + if (disabledNodesInLayer.length > 0) { + const disabledParentGroups = groupNodesByParents( + disabledNodesInLayer, + edges, + blocks, + 'disabled-' + ) + groups.push(...Array.from(disabledParentGroups.values())) + } + + if (terminalNodesInLayer.length > 0) { + groups.push(terminalNodesInLayer) + } + + if (orphanedNodesInLayer.length > 0) { + groups.push(orphanedNodesInLayer) + } + + parallelGroups.set(layer, groups) + } + }) + + return { + layers: blockLayers, + parallelGroups, + maxLayer, + disabledBlocks, + terminalBlocks, + orphanedBlocks, + } +} + +/** + * Calculates auto-layout positions for workflow blocks with improved spacing and alignment + * Enhanced to handle both horizontal and vertical handle orientations + * @param blocks Block states from workflow store + * @param edges Edge connections from workflow store + * @param options Layout configuration options + * @returns Map of block IDs to their new positions + */ +export const calculateAutoLayout = ( + blocks: Record, + edges: any[], + options: LayoutOptions = {} +): Map => { + const { + horizontalSpacing = 600, + verticalSpacing = 200, + startX = 300, + startY = 300, + alignByLayer = true, + handleOrientation = 'auto', + } = options + + const newPositions = new Map() + + const topLevelBlocks = Object.fromEntries( + Object.entries(blocks).filter(([_, block]) => !block.data?.parentId) + ) + + if (Object.keys(topLevelBlocks).length === 0) { + return newPositions + } + + let actualOrientation: 'horizontal' | 'vertical' + if (handleOrientation === 'auto') { + actualOrientation = detectHandleOrientation(blocks) + } else { + actualOrientation = handleOrientation + } + + if (alignByLayer) { + const { parallelGroups, maxLayer, disabledBlocks, terminalBlocks, orphanedBlocks } = + analyzeWorkflowGraph(topLevelBlocks, edges) + + if (actualOrientation === 'horizontal') { + const calculateLayerSpacing = (currentLayer: number, nextLayer: number): number => { + const currentLayerGroups = parallelGroups.get(currentLayer) || [] + const nextLayerGroups = parallelGroups.get(nextLayer) || [] + + let maxCurrentWidth = 0 + currentLayerGroups.forEach((group: string[]) => { + group.forEach((nodeId: string) => { + maxCurrentWidth = Math.max(maxCurrentWidth, getBlockWidth(blocks, nodeId)) + }) + }) + + let maxNextWidth = 0 + nextLayerGroups.forEach((group: string[]) => { + group.forEach((nodeId: string) => { + maxNextWidth = Math.max(maxNextWidth, getBlockWidth(blocks, nodeId)) + }) + }) + + const baseSpacing = horizontalSpacing + const widthAdjustment = Math.max(maxCurrentWidth, maxNextWidth) - 350 // 350 is standard width + const connectionTagSpace = 50 // Reduced from 100 - Extra space for connection tags + + const isOrphanedLayer = + currentLayer > maxLayer - 2 && + (currentLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId))) || + nextLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId)))) + const orphanedSpacing = isOrphanedLayer ? 100 : 0 + + return baseSpacing + widthAdjustment + connectionTagSpace + orphanedSpacing + } + + let currentLayerX = startX + + for (let layer = 0; layer <= maxLayer; layer++) { + const groups = parallelGroups.get(layer) || [] + + let totalHeight = 0 + const groupHeights: number[] = [] + + groups.forEach((group) => { + const groupHeight = calculateGroupDimensions( + group, + orphanedBlocks, + disabledBlocks, + terminalBlocks, + blocks, + verticalSpacing, + getBlockHeight + ) + groupHeights.push(groupHeight) + totalHeight += groupHeight + }) + + if (groups.length > 1) { + totalHeight += (groups.length - 1) * verticalSpacing + } + + let currentY = startY - totalHeight / 2 + + groups.forEach((group, groupIndex) => { + const sortedGroup = sortBlocksByPriority( + group, + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + + sortedGroup.forEach((nodeId, nodeIndex) => { + const blockHeight = getBlockHeight(blocks, nodeId) + + let positionY = currentY + if (isContainerBlock(blocks, nodeId)) { + positionY = currentY + } + + newPositions.set(nodeId, { + x: currentLayerX, + y: positionY, + }) + + currentY += blockHeight + + if (nodeIndex < sortedGroup.length - 1) { + const currentBlockType = getBlockType( + nodeId, + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + const nextBlockType = getBlockType( + sortedGroup[nodeIndex + 1], + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + + const extraSpacing = calculateExtraSpacing( + currentBlockType, + nextBlockType, + verticalSpacing + ) + currentY += verticalSpacing * 0.5 + extraSpacing + } + }) + + if (groupIndex < groups.length - 1) { + currentY += verticalSpacing + } + }) + + if (layer < maxLayer) { + const dynamicSpacing = calculateLayerSpacing(layer, layer + 1) + currentLayerX += dynamicSpacing + } + } + } else { + const calculateLayerSpacing = (currentLayer: number, nextLayer: number): number => { + const currentLayerGroups = parallelGroups.get(currentLayer) || [] + const nextLayerGroups = parallelGroups.get(nextLayer) || [] + + let maxCurrentHeight = 0 + currentLayerGroups.forEach((group: string[]) => { + group.forEach((nodeId: string) => { + maxCurrentHeight = Math.max(maxCurrentHeight, getBlockHeight(blocks, nodeId)) + }) + }) + + let maxNextHeight = 0 + nextLayerGroups.forEach((group: string[]) => { + group.forEach((nodeId: string) => { + maxNextHeight = Math.max(maxNextHeight, getBlockHeight(blocks, nodeId)) + }) + }) + + const baseSpacing = verticalSpacing + const heightAdjustment = Math.max(maxCurrentHeight, maxNextHeight) - 150 // 150 is standard height + const connectionTagSpace = 25 + + const isOrphanedLayer = + currentLayer > maxLayer - 2 && + (currentLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId))) || + nextLayerGroups.some((group) => group.some((nodeId) => orphanedBlocks.has(nodeId)))) + const orphanedSpacing = isOrphanedLayer ? 75 : 0 + + return baseSpacing + heightAdjustment + connectionTagSpace + orphanedSpacing + } + + let currentLayerY = startY + + for (let layer = 0; layer <= maxLayer; layer++) { + const groups = parallelGroups.get(layer) || [] + + let totalWidth = 0 + const groupWidths: number[] = [] + + groups.forEach((group) => { + const groupWidth = calculateGroupDimensions( + group, + orphanedBlocks, + disabledBlocks, + terminalBlocks, + blocks, + horizontalSpacing, + getBlockWidth + ) + groupWidths.push(groupWidth) + totalWidth += groupWidth + }) + + if (groups.length > 1) { + totalWidth += (groups.length - 1) * horizontalSpacing + } + + let currentX = startX - totalWidth / 2 + + groups.forEach((group, groupIndex) => { + const sortedGroup = sortBlocksByPriority( + group, + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + + sortedGroup.forEach((nodeId, nodeIndex) => { + const blockWidth = getBlockWidth(blocks, nodeId) + + let positionX = currentX + if (isContainerBlock(blocks, nodeId)) { + positionX = currentX + } + + newPositions.set(nodeId, { + x: positionX, + y: currentLayerY, + }) + + currentX += blockWidth + + if (nodeIndex < sortedGroup.length - 1) { + const currentBlockType = getBlockType( + nodeId, + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + const nextBlockType = getBlockType( + sortedGroup[nodeIndex + 1], + orphanedBlocks, + disabledBlocks, + terminalBlocks + ) + + const extraSpacing = calculateExtraSpacing( + currentBlockType, + nextBlockType, + horizontalSpacing + ) + currentX += horizontalSpacing * 0.5 + extraSpacing + } + }) + + if (groupIndex < groups.length - 1) { + currentX += horizontalSpacing + } + }) + + if (layer < maxLayer) { + const dynamicSpacing = calculateLayerSpacing(layer, layer + 1) + currentLayerY += dynamicSpacing + } + } + } + } else { + const blockIds = Object.keys(topLevelBlocks) + + if (actualOrientation === 'horizontal') { + let currentX = startX + + blockIds.forEach((blockId, index) => { + newPositions.set(blockId, { x: currentX, y: startY }) + + if (index < blockIds.length - 1) { + const blockWidth = getBlockWidth(blocks, blockId) + const nextBlockWidth = getBlockWidth(blocks, blockIds[index + 1]) + const spacing = horizontalSpacing + Math.max(blockWidth, nextBlockWidth) - 350 + currentX += spacing + } + }) + } else { + let currentY = startY + + blockIds.forEach((blockId, index) => { + newPositions.set(blockId, { x: startX, y: currentY }) + + if (index < blockIds.length - 1) { + const blockHeight = getBlockHeight(blocks, blockId) + const nextBlockHeight = getBlockHeight(blocks, blockIds[index + 1]) + const spacing = verticalSpacing + Math.max(blockHeight, nextBlockHeight) - 150 + currentY += spacing + } + }) + } + } + + return newPositions +} + +/** + * Enhanced auto-layout function with smooth animations + * @param blocks Block states from workflow store + * @param edges Edge connections from workflow store + * @param updateBlockPosition Function to update block positions + * @param fitView Function to fit the view + * @param resizeLoopNodes Function to resize loop nodes + * @param options Layout configuration options + */ +export const applyAutoLayoutSmooth = ( + blocks: Record, + edges: any[], + updateBlockPosition: (id: string, position: { x: number; y: number }) => void, + fitView: (options?: { padding?: number; duration?: number }) => void, + resizeLoopNodes: () => void, + options: LayoutOptions & { + animationDuration?: number + isSidebarCollapsed?: boolean + } = {} +): void => { + const { animationDuration = 500, isSidebarCollapsed = false, ...layoutOptions } = options + + if (!layoutOptions.handleOrientation || layoutOptions.handleOrientation === 'auto') { + layoutOptions.handleOrientation = detectHandleOrientation(blocks) + } + + const topLevelPositions = calculateAutoLayout(blocks, edges, layoutOptions) + + const childPositions = new Map() + + const containerBlocks = Object.entries(blocks).filter( + ([_, block]) => isContainerType(block.type) && !block.data?.parentId + ) + + containerBlocks.forEach(([containerId]) => { + const childBlocks = Object.fromEntries( + Object.entries(blocks).filter(([_, block]) => block.data?.parentId === containerId) + ) + + if (Object.keys(childBlocks).length === 0) return + + const childEdges = edges.filter((edge) => childBlocks[edge.source] && childBlocks[edge.target]) + + const childLayoutOptions: LayoutOptions = { + horizontalSpacing: Math.min(300, layoutOptions.horizontalSpacing || 300), + verticalSpacing: Math.min(150, layoutOptions.verticalSpacing || 150), + startX: 50, + startY: 80, + alignByLayer: true, + handleOrientation: layoutOptions.handleOrientation, + } + + const childPositionsForContainer = calculateAutoLayout( + childBlocks, + childEdges, + childLayoutOptions + ) + + childPositionsForContainer.forEach((position, blockId) => { + childPositions.set(blockId, position) + }) + }) + + const allPositions = new Map([...topLevelPositions, ...childPositions]) + + if (allPositions.size === 0) return + + const currentPositions = new Map() + allPositions.forEach((_, blockId) => { + const block = blocks[blockId] + if (block) { + currentPositions.set(blockId, { x: block.position.x, y: block.position.y }) + } + }) + + const startTime = Date.now() + const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3 + + const animate = async () => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / animationDuration, 1) + const easedProgress = easeOutCubic(progress) + + allPositions.forEach((targetPosition, blockId) => { + const currentPosition = currentPositions.get(blockId) + if (!currentPosition) return + + const newPosition = { + x: currentPosition.x + (targetPosition.x - currentPosition.x) * easedProgress, + y: currentPosition.y + (targetPosition.y - currentPosition.y) * easedProgress, + } + + updateBlockPosition(blockId, newPosition) + }) + + if (progress < 1) { + requestAnimationFrame(animate) + } else { + await resizeLoopNodes() + + const padding = isSidebarCollapsed ? 0.35 : 0.55 + await fitView({ + padding, + duration: 400, + }) + } + } + + animate() +} + +/** + * Original auto-layout function (for backward compatibility) + * @param blocks Block states from workflow store + * @param edges Edge connections from workflow store + * @param updateBlockPosition Function to update block positions + * @param options Layout configuration options + */ +export const applyAutoLayout = ( + blocks: Record, + edges: any[], + updateBlockPosition: (id: string, position: { x: number; y: number }) => void, + options: LayoutOptions = {} +): void => { + if (!options.handleOrientation || options.handleOrientation === 'auto') { + options.handleOrientation = detectHandleOrientation(blocks) + } + + const topLevelPositions = calculateAutoLayout(blocks, edges, options) + + topLevelPositions.forEach((position, blockId) => { + updateBlockPosition(blockId, position) + }) + + const containerBlocks = Object.entries(blocks).filter( + ([_, block]) => isContainerType(block.type) && !block.data?.parentId + ) + + containerBlocks.forEach(([containerId]) => { + const childBlocks = Object.fromEntries( + Object.entries(blocks).filter(([_, block]) => block.data?.parentId === containerId) + ) + + if (Object.keys(childBlocks).length === 0) return + + const childEdges = edges.filter((edge) => childBlocks[edge.source] && childBlocks[edge.target]) + + const childLayoutOptions: LayoutOptions = { + horizontalSpacing: Math.min(300, options.horizontalSpacing || 300), + verticalSpacing: Math.min(150, options.verticalSpacing || 150), + startX: 50, + startY: 80, + alignByLayer: true, + handleOrientation: options.handleOrientation, + } + + const childPositions = calculateAutoLayout(childBlocks, childEdges, childLayoutOptions) + + childPositions.forEach((position, blockId) => { + updateBlockPosition(blockId, position) + }) + }) +} diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index 223c234e48..a0601613de 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -34,8 +34,8 @@ import { Toolbar } from './components/toolbar/toolbar' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' import { - calculateLoopDimensions, - calculateRelativePosition, + applyAutoLayoutSmooth, + detectHandleOrientation, getNodeAbsolutePosition, getNodeDepth, getNodeHierarchy, @@ -73,11 +73,10 @@ function WorkflowContent() { // Hooks const params = useParams() const router = useRouter() - const { project, getNodes } = useReactFlow() + const { project, getNodes, fitView } = useReactFlow() // Store access const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - //Removed loops from the store const { blocks, edges, @@ -98,6 +97,26 @@ function WorkflowContent() { const { isDebugModeEnabled } = useGeneralStore() const [dragStartParentId, setDragStartParentId] = useState(null) + // Helper function to update a node's parent with proper position calculation + const updateNodeParent = useCallback( + (nodeId: string, newParentId: string | null) => { + return updateNodeParentUtil( + nodeId, + newParentId, + getNodes, + updateBlockPosition, + updateParentId, + () => resizeLoopNodes(getNodes, updateNodeDimensions, blocks) + ) + }, + [getNodes, updateBlockPosition, updateParentId, updateNodeDimensions, blocks] + ) + + // Function to resize all loop nodes with improved hierarchy handling + const resizeLoopNodesWrapper = useCallback(() => { + return resizeLoopNodes(getNodes, updateNodeDimensions, blocks) + }, [getNodes, updateNodeDimensions, blocks]) + // Wrapper functions that use the utilities but provide the getNodes function const getNodeDepthWrapper = useCallback( (nodeId: string): number => { @@ -120,28 +139,6 @@ function WorkflowContent() { [getNodes] ) - const calculateRelativePositionWrapper = useCallback( - (nodeId: string, newParentId: string): { x: number; y: number } => { - return calculateRelativePosition(nodeId, newParentId, getNodes) - }, - [getNodes] - ) - - // Helper function to update a node's parent with proper position calculation - const updateNodeParent = useCallback( - (nodeId: string, newParentId: string | null) => { - return updateNodeParentUtil( - nodeId, - newParentId, - getNodes, - updateBlockPosition, - updateParentId, - resizeLoopNodesWrapper - ) - }, - [getNodes, updateBlockPosition, updateParentId] - ) - const isPointInLoopNodeWrapper = useCallback( (position: { x: number; y: number }) => { return isPointInLoopNode(position, getNodes) @@ -149,20 +146,95 @@ function WorkflowContent() { [getNodes] ) - const calculateLoopDimensionsWrapper = useCallback( - (loopId: string): { width: number; height: number } => { - return calculateLoopDimensions(loopId, getNodes) - }, - [getNodes] - ) + // Auto-layout handler + const handleAutoLayout = useCallback(() => { + if (Object.keys(blocks).length === 0) return - // Function to resize all loop nodes with improved hierarchy handling - const resizeLoopNodesWrapper = useCallback(() => { - return resizeLoopNodes(getNodes, updateNodeDimensions) - }, [getNodes, updateNodeDimensions]) + // Detect the predominant handle orientation in the workflow + const detectedOrientation = detectHandleOrientation(blocks) - // Use direct resizing function instead of debounced version for immediate updates - const debouncedResizeLoopNodes = resizeLoopNodesWrapper + // Optimize spacing based on handle orientation + const orientationConfig = + detectedOrientation === 'vertical' + ? { + // Vertical handles: optimize for top-to-bottom flow + horizontalSpacing: 400, + verticalSpacing: 150, + startX: 200, + startY: 200, + } + : { + // Horizontal handles: optimize for left-to-right flow + horizontalSpacing: 300, + verticalSpacing: 200, + startX: 150, + startY: 300, + } + + applyAutoLayoutSmooth(blocks, edges, updateBlockPosition, fitView, resizeLoopNodesWrapper, { + ...orientationConfig, + alignByLayer: true, + animationDuration: 500, // Smooth 500ms animation + isSidebarCollapsed, + handleOrientation: detectedOrientation, // Explicitly set the detected orientation + }) + + const orientationMessage = + detectedOrientation === 'vertical' + ? 'Auto-layout applied with vertical flow (top-to-bottom)' + : 'Auto-layout applied with horizontal flow (left-to-right)' + + logger.info(orientationMessage, { + orientation: detectedOrientation, + blockCount: Object.keys(blocks).length, + }) + }, [blocks, edges, updateBlockPosition, fitView, isSidebarCollapsed, resizeLoopNodesWrapper]) + + const debouncedAutoLayout = useCallback(() => { + const debounceTimer = setTimeout(() => { + handleAutoLayout() + }, 250) + + return () => clearTimeout(debounceTimer) + }, [handleAutoLayout]) + + useEffect(() => { + let cleanup: (() => void) | null = null + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.shiftKey && event.key === 'L' && !event.ctrlKey && !event.metaKey) { + event.preventDefault() + + if (cleanup) cleanup() + + cleanup = debouncedAutoLayout() + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + if (cleanup) cleanup() + } + }, [debouncedAutoLayout]) + + useEffect(() => { + let cleanup: (() => void) | null = null + + const handleAutoLayoutEvent = () => { + if (cleanup) cleanup() + + cleanup = debouncedAutoLayout() + } + + window.addEventListener('trigger-auto-layout', handleAutoLayoutEvent) + + return () => { + window.removeEventListener('trigger-auto-layout', handleAutoLayoutEvent) + if (cleanup) cleanup() + } + }, [debouncedAutoLayout]) // Initialize workflow useEffect(() => { @@ -434,7 +506,7 @@ function WorkflowContent() { } // Resize the parent container to fit the new child container - debouncedResizeLoopNodes() + resizeLoopNodesWrapper() } else { // Add the container node directly to canvas with default dimensions addBlock(id, data.type, name, position, { @@ -495,7 +567,7 @@ function WorkflowContent() { // Resize the container node to fit the new block // Immediate resize without delay - debouncedResizeLoopNodes() + resizeLoopNodesWrapper() // Auto-connect logic for blocks inside containers const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled @@ -802,11 +874,11 @@ function WorkflowContent() { if (nodes.length === 0) return // Resize all loops to fit their children - debouncedResizeLoopNodes() + resizeLoopNodesWrapper() // No need for cleanup with direct function return () => {} - }, [nodes, debouncedResizeLoopNodes]) + }, [nodes, resizeLoopNodesWrapper]) // Special effect to handle cleanup after node deletion useEffect(() => {