diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index 303b833265..28970fd484 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -3,6 +3,7 @@ import { RepeatIcon, SplitIcon } from 'lucide-react' import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow' import { Button, Trash } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' @@ -119,7 +120,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps { - return { top: '20px', transform: 'translateY(-50%)' } + return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' } } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index bdd29fc9de..7e6e2f4d97 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -28,6 +28,7 @@ import { import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { BLOCK_DIMENSIONS, + HANDLE_POSITIONS, useBlockDimensions, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' @@ -716,7 +717,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const getHandleStyle = (position: 'horizontal' | 'vertical') => { if (position === 'horizontal') { - return { top: '20px', transform: 'translateY(-50%)' } + return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' } } return { left: '50%', transform: 'translateX(-50%)' } } @@ -1030,7 +1031,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({ {type === 'condition' && ( <> {conditionRows.map((cond, condIndex) => { - const topOffset = 60 + condIndex * 29 + const topOffset = + HANDLE_POSITIONS.CONDITION_START_Y + + condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT return ( className={horizontalHandles ? horizontalHandleClass : verticalHandleClass} style={ horizontalHandles - ? { left: '-7px', top: '24px' } + ? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` } : { top: '-7px', left: '50%', transform: 'translateX(-50%)' } } /> @@ -122,7 +123,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps className={horizontalHandles ? horizontalHandleClass : verticalHandleClass} style={ horizontalHandles - ? { right: '-7px', top: '24px' } + ? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` } : { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' } } /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx index d58d04b5df..56059ff80a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx @@ -3,6 +3,7 @@ import { memo } from 'react' import { RepeatIcon, SplitIcon } from 'lucide-react' import { Handle, type NodeProps, Position } from 'reactflow' +import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' interface WorkflowPreviewSubflowData { name: string @@ -47,7 +48,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps {/* Header - matches actual subflow header */} @@ -81,7 +86,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps ) diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts index c932ede0a0..7616fb0944 100644 --- a/apps/sim/lib/workflows/autolayout/constants.ts +++ b/apps/sim/lib/workflows/autolayout/constants.ts @@ -75,7 +75,6 @@ export const DEFAULT_LAYOUT_OPTIONS = { horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, verticalSpacing: DEFAULT_VERTICAL_SPACING, padding: DEFAULT_LAYOUT_PADDING, - alignment: 'center' as const, } /** @@ -90,5 +89,4 @@ export const CONTAINER_LAYOUT_OPTIONS = { horizontalSpacing: DEFAULT_CONTAINER_HORIZONTAL_SPACING, verticalSpacing: DEFAULT_VERTICAL_SPACING, padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, - alignment: 'center' as const, } diff --git a/apps/sim/lib/workflows/autolayout/containers.ts b/apps/sim/lib/workflows/autolayout/containers.ts index 53cfd0c0bc..cdd79fcadc 100644 --- a/apps/sim/lib/workflows/autolayout/containers.ts +++ b/apps/sim/lib/workflows/autolayout/containers.ts @@ -28,16 +28,12 @@ export function layoutContainers( ): void { const { children } = getBlocksByParent(blocks) - // Build container-specific layout options - // If horizontalSpacing provided, reduce by 15% for tighter container layout - // Otherwise use the default container spacing (400) const containerOptions: LayoutOptions = { horizontalSpacing: options.horizontalSpacing ? options.horizontalSpacing * 0.85 : DEFAULT_CONTAINER_HORIZONTAL_SPACING, verticalSpacing: options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING, padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y }, - alignment: options.alignment, } for (const [parentId, childIds] of children.entries()) { diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 0131984756..745b4865ef 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -10,12 +10,55 @@ import { normalizePositions, prepareBlockMetrics, } from '@/lib/workflows/autolayout/utils' +import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('AutoLayout:Core') -/** Handle names that indicate edges from subflow end */ const SUBFLOW_END_HANDLES = new Set(['loop-end-source', 'parallel-end-source']) +const SUBFLOW_START_HANDLES = new Set(['loop-start-source', 'parallel-start-source']) + +/** + * Calculates the Y offset for a source handle based on block type and handle ID. + */ +function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null): number { + if (sourceHandle === 'error') { + const blockHeight = block.height || BLOCK_DIMENSIONS.MIN_HEIGHT + return blockHeight - HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET + } + + if (sourceHandle && SUBFLOW_START_HANDLES.has(sourceHandle)) { + return HANDLE_POSITIONS.SUBFLOW_START_Y_OFFSET + } + + if (block.type === 'condition' && sourceHandle?.startsWith('condition-')) { + const conditionId = sourceHandle.replace('condition-', '') + try { + const conditionsValue = block.subBlocks?.conditions?.value + if (typeof conditionsValue === 'string' && conditionsValue) { + const conditions = JSON.parse(conditionsValue) as Array<{ id?: string }> + const conditionIndex = conditions.findIndex((c) => c.id === conditionId) + if (conditionIndex >= 0) { + return ( + HANDLE_POSITIONS.CONDITION_START_Y + + conditionIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT + ) + } + } + } catch { + // Fall back to default offset + } + } + + return HANDLE_POSITIONS.DEFAULT_Y_OFFSET +} + +/** + * Calculates the Y offset for a target handle based on block type and handle ID. + */ +function getTargetHandleYOffset(_block: BlockState, _targetHandle?: string | null): number { + return HANDLE_POSITIONS.DEFAULT_Y_OFFSET +} /** * Checks if an edge comes from a subflow end handle @@ -225,18 +268,36 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v } } +/** + * Checks if a block is a container type (loop or parallel) + */ +function isContainerBlock(node: GraphNode): boolean { + return node.block.type === 'loop' || node.block.type === 'parallel' +} + +/** + * Extra vertical spacing after containers to prevent edge crossings with sibling blocks. + * This creates clearance for edges from container ends to route cleanly. + */ +const CONTAINER_VERTICAL_CLEARANCE = 120 + /** * Calculates positions for nodes organized by layer. * Uses cumulative width-based X positioning to properly handle containers of varying widths. + * Aligns blocks based on their connected predecessors to achieve handle-to-handle alignment. + * + * Handle alignment: Calculates actual source handle Y positions based on block type + * (condition blocks have handles at different heights for each branch). + * Target handles are also calculated per-block to ensure precise alignment. */ export function calculatePositions( layers: Map, + edges: Edge[], options: LayoutOptions = {} ): void { const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_LAYOUT_OPTIONS.horizontalSpacing const verticalSpacing = options.verticalSpacing ?? DEFAULT_LAYOUT_OPTIONS.verticalSpacing const padding = options.padding ?? DEFAULT_LAYOUT_OPTIONS.padding - const alignment = options.alignment ?? DEFAULT_LAYOUT_OPTIONS.alignment const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b) @@ -257,41 +318,89 @@ export function calculatePositions( cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing } - // Position nodes using cumulative X + // Build a flat map of all nodes for quick lookups + const allNodes = new Map() + for (const nodesInLayer of layers.values()) { + for (const node of nodesInLayer) { + allNodes.set(node.id, node) + } + } + + // Build incoming edges map for handle lookups + const incomingEdgesMap = new Map() + for (const edge of edges) { + if (!incomingEdgesMap.has(edge.target)) { + incomingEdgesMap.set(edge.target, []) + } + incomingEdgesMap.get(edge.target)!.push(edge) + } + + // Position nodes layer by layer, aligning with connected predecessors for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! const xPosition = layerXPositions.get(layerNum)! - // Calculate total height for this layer - const totalHeight = nodesInLayer.reduce( - (sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0), - 0 - ) - - // Start Y based on alignment - let yOffset: number - switch (alignment) { - case 'start': - yOffset = padding.y - break - case 'center': - yOffset = Math.max(padding.y, 300 - totalHeight / 2) - break - case 'end': - yOffset = 600 - totalHeight - padding.y - break - default: - yOffset = padding.y - break + // Separate containers and non-containers + const containersInLayer = nodesInLayer.filter(isContainerBlock) + const nonContainersInLayer = nodesInLayer.filter((n) => !isContainerBlock(n)) + + // For the first layer (layer 0), position sequentially from padding.y + if (layerNum === 0) { + let yOffset = padding.y + + // Sort containers by height for visual balance + containersInLayer.sort((a, b) => b.metrics.height - a.metrics.height) + + for (const node of containersInLayer) { + node.position = { x: xPosition, y: yOffset } + yOffset += node.metrics.height + verticalSpacing + } + + if (containersInLayer.length > 0 && nonContainersInLayer.length > 0) { + yOffset += CONTAINER_VERTICAL_CLEARANCE + } + + // Sort non-containers by outgoing connections + nonContainersInLayer.sort((a, b) => b.outgoing.size - a.outgoing.size) + + for (const node of nonContainersInLayer) { + node.position = { x: xPosition, y: yOffset } + yOffset += node.metrics.height + verticalSpacing + } + continue } - // Position each node - for (const node of nodesInLayer) { - node.position = { - x: xPosition, - y: yOffset, + // For subsequent layers, align with connected predecessors (handle-to-handle) + for (const node of [...containersInLayer, ...nonContainersInLayer]) { + // Find the bottommost predecessor handle Y (highest value) and align to it + let bestSourceHandleY = -1 + let bestEdge: Edge | null = null + const incomingEdges = incomingEdgesMap.get(node.id) || [] + + for (const edge of incomingEdges) { + const predecessor = allNodes.get(edge.source) + if (predecessor) { + // Calculate actual source handle Y position based on block type and handle + const sourceHandleOffset = getSourceHandleYOffset(predecessor.block, edge.sourceHandle) + const sourceHandleY = predecessor.position.y + sourceHandleOffset + + if (sourceHandleY > bestSourceHandleY) { + bestSourceHandleY = sourceHandleY + bestEdge = edge + } + } + } + + // If no predecessors found (shouldn't happen for layer > 0), use padding + if (bestSourceHandleY < 0) { + bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET } - yOffset += node.metrics.height + verticalSpacing + + // Calculate the target handle Y offset for this node + const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle) + + // Position node so its target handle aligns with the source handle Y + node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset } } } @@ -338,8 +447,8 @@ export function layoutBlocksCore( // 3. Group by layer const layers = groupByLayer(nodes) - // 4. Calculate positions - calculatePositions(layers, layoutOptions) + // 4. Calculate positions (pass edges for handle offset calculations) + calculatePositions(layers, edges, layoutOptions) // 5. Normalize positions const dimensions = normalizePositions(nodes, { isContainer: options.isContainer }) diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index 97cb9e0715..f4b741bd8f 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -228,7 +228,6 @@ function computeLayoutPositions( layoutOptions: { horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing, verticalSpacing, - alignment: 'center', }, subflowDepths, }) diff --git a/apps/sim/lib/workflows/autolayout/types.ts b/apps/sim/lib/workflows/autolayout/types.ts index ed763ae571..a20c35715a 100644 --- a/apps/sim/lib/workflows/autolayout/types.ts +++ b/apps/sim/lib/workflows/autolayout/types.ts @@ -4,7 +4,6 @@ export interface LayoutOptions { horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } - alignment?: 'start' | 'center' | 'end' } export interface LayoutResult { diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 7c63edc81a..45ddc614a4 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -329,7 +329,6 @@ export type LayoutFunction = ( horizontalSpacing?: number verticalSpacing?: number padding?: { x: number; y: number } - alignment?: 'start' | 'center' | 'end' } subflowDepths?: Map } @@ -418,7 +417,6 @@ export function prepareContainerDimensions( layoutOptions: { horizontalSpacing: horizontalSpacing * 0.85, verticalSpacing, - alignment: 'center', }, }) diff --git a/apps/sim/lib/workflows/blocks/block-dimensions.ts b/apps/sim/lib/workflows/blocks/block-dimensions.ts index cff175dc30..d311f0dc0f 100644 --- a/apps/sim/lib/workflows/blocks/block-dimensions.ts +++ b/apps/sim/lib/workflows/blocks/block-dimensions.ts @@ -2,56 +2,42 @@ * Shared Block Dimension Constants * * Single source of truth for block dimensions used by: - * - UI components (workflow-block, note-block) + * - UI components (workflow-block, note-block, subflow-node) * - Autolayout system * - Node utilities - * - * IMPORTANT: These values must match the actual CSS dimensions in the UI. - * Changing these values will affect both rendering and layout calculations. */ -/** - * Block dimension constants for workflow blocks - */ export const BLOCK_DIMENSIONS = { - /** Fixed width for all workflow blocks (matches w-[250px] in workflow-block.tsx) */ FIXED_WIDTH: 250, - - /** Header height for blocks */ HEADER_HEIGHT: 40, - - /** Minimum height for blocks */ MIN_HEIGHT: 100, - - /** Padding around workflow block content (p-[8px] top + bottom = 16px) */ WORKFLOW_CONTENT_PADDING: 16, - - /** Height of each subblock row (14px text + 8px gap + padding) */ WORKFLOW_ROW_HEIGHT: 29, - - /** Padding around note block content */ NOTE_CONTENT_PADDING: 14, - - /** Minimum content height for note blocks */ NOTE_MIN_CONTENT_HEIGHT: 20, - - /** Base content height for note blocks */ NOTE_BASE_CONTENT_HEIGHT: 60, } as const -/** - * Container block dimension constants (loop, parallel, subflow) - */ export const CONTAINER_DIMENSIONS = { - /** Default width for container blocks */ DEFAULT_WIDTH: 500, - - /** Default height for container blocks */ DEFAULT_HEIGHT: 300, - - /** Minimum width for container blocks */ MIN_WIDTH: 400, - - /** Minimum height for container blocks */ MIN_HEIGHT: 200, + HEADER_HEIGHT: 50, +} as const + +/** + * Handle position constants - must match CSS in workflow-block.tsx and subflow-node.tsx + */ +export const HANDLE_POSITIONS = { + /** Default Y offset from block top for source/target handles */ + DEFAULT_Y_OFFSET: 20, + /** Error handle offset from block bottom */ + ERROR_BOTTOM_OFFSET: 17, + /** Condition handle starting Y offset */ + CONDITION_START_Y: 60, + /** Height per condition row */ + CONDITION_ROW_HEIGHT: 29, + /** Subflow start handle Y offset (header 50px + pill offset 16px + pill center 14px) */ + SUBFLOW_START_Y_OFFSET: 80, } as const