Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -119,7 +120,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
}

const getHandleStyle = () => {
return { top: '20px', transform: 'translateY(-50%)' }
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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%)' }
}
Expand Down Expand Up @@ -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 (
<Handle
key={`handle-${cond.id}`}
Expand All @@ -1052,7 +1055,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{ right: '-7px', top: 'auto', bottom: '17px', transform: 'translateY(50%)' }}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
Expand Down Expand Up @@ -1083,7 +1091,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{ right: '-7px', top: 'auto', bottom: '17px', transform: 'translateY(50%)' }}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useUpdateNodeInternals } from 'reactflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

// Re-export for backwards compatibility
export { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
export { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'

interface BlockDimensions {
width: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlock } from '@/blocks/registry'

interface WorkflowPreviewBlockData {
Expand Down Expand Up @@ -62,7 +63,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
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%)' }
}
/>
Expand Down Expand Up @@ -122,7 +123,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
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%)' }
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,7 +48,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
position={Position.Left}
id='target'
className={handleClass}
style={{ left: '-7px', top: '20px', transform: 'translateY(-50%)' }}
style={{
left: '-7px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>

{/* Header - matches actual subflow header */}
Expand Down Expand Up @@ -81,7 +86,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
position={Position.Right}
id={endHandleId}
className={handleClass}
style={{ right: '-7px', top: '20px', transform: 'translateY(-50%)' }}
style={{
right: '-7px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)
Expand Down
2 changes: 0 additions & 2 deletions apps/sim/lib/workflows/autolayout/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

/**
Expand All @@ -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,
}
4 changes: 0 additions & 4 deletions apps/sim/lib/workflows/autolayout/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
173 changes: 141 additions & 32 deletions apps/sim/lib/workflows/autolayout/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number, GraphNode[]>,
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)

Expand All @@ -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<string, GraphNode>()
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<string, Edge[]>()
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 }
}
}

Expand Down Expand Up @@ -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 })
Expand Down
Loading