Skip to content

Commit dd7db6e

Browse files
fix(autolayout): align by handle (#2277)
* fix(autolayout): align by handle * use shared constants everywhere * cleanup
1 parent 306043e commit dd7db6e

File tree

12 files changed

+193
-84
lines changed

12 files changed

+193
-84
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { RepeatIcon, SplitIcon } from 'lucide-react'
33
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
44
import { Button, Trash } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
6+
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
67
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
78
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
89
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -119,7 +120,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
119120
}
120121

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

125126
/**

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
2929
import {
3030
BLOCK_DIMENSIONS,
31+
HANDLE_POSITIONS,
3132
useBlockDimensions,
3233
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
3334
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
@@ -716,7 +717,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
716717

717718
const getHandleStyle = (position: 'horizontal' | 'vertical') => {
718719
if (position === 'horizontal') {
719-
return { top: '20px', transform: 'translateY(-50%)' }
720+
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
720721
}
721722
return { left: '50%', transform: 'translateX(-50%)' }
722723
}
@@ -1030,7 +1031,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10301031
{type === 'condition' && (
10311032
<>
10321033
{conditionRows.map((cond, condIndex) => {
1033-
const topOffset = 60 + condIndex * 29
1034+
const topOffset =
1035+
HANDLE_POSITIONS.CONDITION_START_Y +
1036+
condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
10341037
return (
10351038
<Handle
10361039
key={`handle-${cond.id}`}
@@ -1052,7 +1055,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10521055
position={Position.Right}
10531056
id='error'
10541057
className={getHandleClasses('right', true)}
1055-
style={{ right: '-7px', top: 'auto', bottom: '17px', transform: 'translateY(50%)' }}
1058+
style={{
1059+
right: '-7px',
1060+
top: 'auto',
1061+
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
1062+
transform: 'translateY(50%)',
1063+
}}
10561064
data-nodeid={id}
10571065
data-handleid='error'
10581066
isConnectableStart={true}
@@ -1083,7 +1091,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10831091
position={Position.Right}
10841092
id='error'
10851093
className={getHandleClasses('right', true)}
1086-
style={{ right: '-7px', top: 'auto', bottom: '17px', transform: 'translateY(50%)' }}
1094+
style={{
1095+
right: '-7px',
1096+
top: 'auto',
1097+
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
1098+
transform: 'translateY(50%)',
1099+
}}
10871100
data-nodeid={id}
10881101
data-handleid='error'
10891102
isConnectableStart={true}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useUpdateNodeInternals } from 'reactflow'
33
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
44

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

88
interface BlockDimensions {
99
width: number

apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

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

78
interface WorkflowPreviewBlockData {
@@ -62,7 +63,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
6263
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
6364
style={
6465
horizontalHandles
65-
? { left: '-7px', top: '24px' }
66+
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
6667
: { top: '-7px', left: '50%', transform: 'translateX(-50%)' }
6768
}
6869
/>
@@ -122,7 +123,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
122123
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
123124
style={
124125
horizontalHandles
125-
? { right: '-7px', top: '24px' }
126+
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
126127
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
127128
}
128129
/>

apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { memo } from 'react'
44
import { RepeatIcon, SplitIcon } from 'lucide-react'
55
import { Handle, type NodeProps, Position } from 'reactflow'
6+
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
67

78
interface WorkflowPreviewSubflowData {
89
name: string
@@ -47,7 +48,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
4748
position={Position.Left}
4849
id='target'
4950
className={handleClass}
50-
style={{ left: '-7px', top: '20px', transform: 'translateY(-50%)' }}
51+
style={{
52+
left: '-7px',
53+
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
54+
transform: 'translateY(-50%)',
55+
}}
5156
/>
5257

5358
{/* Header - matches actual subflow header */}
@@ -81,7 +86,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
8186
position={Position.Right}
8287
id={endHandleId}
8388
className={handleClass}
84-
style={{ right: '-7px', top: '20px', transform: 'translateY(-50%)' }}
89+
style={{
90+
right: '-7px',
91+
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
92+
transform: 'translateY(-50%)',
93+
}}
8594
/>
8695
</div>
8796
)

apps/sim/lib/workflows/autolayout/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ export const DEFAULT_LAYOUT_OPTIONS = {
7575
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
7676
verticalSpacing: DEFAULT_VERTICAL_SPACING,
7777
padding: DEFAULT_LAYOUT_PADDING,
78-
alignment: 'center' as const,
7978
}
8079

8180
/**
@@ -90,5 +89,4 @@ export const CONTAINER_LAYOUT_OPTIONS = {
9089
horizontalSpacing: DEFAULT_CONTAINER_HORIZONTAL_SPACING,
9190
verticalSpacing: DEFAULT_VERTICAL_SPACING,
9291
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
93-
alignment: 'center' as const,
9492
}

apps/sim/lib/workflows/autolayout/containers.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,12 @@ export function layoutContainers(
2828
): void {
2929
const { children } = getBlocksByParent(blocks)
3030

31-
// Build container-specific layout options
32-
// If horizontalSpacing provided, reduce by 15% for tighter container layout
33-
// Otherwise use the default container spacing (400)
3431
const containerOptions: LayoutOptions = {
3532
horizontalSpacing: options.horizontalSpacing
3633
? options.horizontalSpacing * 0.85
3734
: DEFAULT_CONTAINER_HORIZONTAL_SPACING,
3835
verticalSpacing: options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING,
3936
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
40-
alignment: options.alignment,
4137
}
4238

4339
for (const [parentId, childIds] of children.entries()) {

apps/sim/lib/workflows/autolayout/core.ts

Lines changed: 141 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,55 @@ import {
1010
normalizePositions,
1111
prepareBlockMetrics,
1212
} from '@/lib/workflows/autolayout/utils'
13+
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
1314
import type { BlockState } from '@/stores/workflows/workflow/types'
1415

1516
const logger = createLogger('AutoLayout:Core')
1617

17-
/** Handle names that indicate edges from subflow end */
1818
const SUBFLOW_END_HANDLES = new Set(['loop-end-source', 'parallel-end-source'])
19+
const SUBFLOW_START_HANDLES = new Set(['loop-start-source', 'parallel-start-source'])
20+
21+
/**
22+
* Calculates the Y offset for a source handle based on block type and handle ID.
23+
*/
24+
function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null): number {
25+
if (sourceHandle === 'error') {
26+
const blockHeight = block.height || BLOCK_DIMENSIONS.MIN_HEIGHT
27+
return blockHeight - HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET
28+
}
29+
30+
if (sourceHandle && SUBFLOW_START_HANDLES.has(sourceHandle)) {
31+
return HANDLE_POSITIONS.SUBFLOW_START_Y_OFFSET
32+
}
33+
34+
if (block.type === 'condition' && sourceHandle?.startsWith('condition-')) {
35+
const conditionId = sourceHandle.replace('condition-', '')
36+
try {
37+
const conditionsValue = block.subBlocks?.conditions?.value
38+
if (typeof conditionsValue === 'string' && conditionsValue) {
39+
const conditions = JSON.parse(conditionsValue) as Array<{ id?: string }>
40+
const conditionIndex = conditions.findIndex((c) => c.id === conditionId)
41+
if (conditionIndex >= 0) {
42+
return (
43+
HANDLE_POSITIONS.CONDITION_START_Y +
44+
conditionIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
45+
)
46+
}
47+
}
48+
} catch {
49+
// Fall back to default offset
50+
}
51+
}
52+
53+
return HANDLE_POSITIONS.DEFAULT_Y_OFFSET
54+
}
55+
56+
/**
57+
* Calculates the Y offset for a target handle based on block type and handle ID.
58+
*/
59+
function getTargetHandleYOffset(_block: BlockState, _targetHandle?: string | null): number {
60+
return HANDLE_POSITIONS.DEFAULT_Y_OFFSET
61+
}
1962

2063
/**
2164
* Checks if an edge comes from a subflow end handle
@@ -225,18 +268,36 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v
225268
}
226269
}
227270

271+
/**
272+
* Checks if a block is a container type (loop or parallel)
273+
*/
274+
function isContainerBlock(node: GraphNode): boolean {
275+
return node.block.type === 'loop' || node.block.type === 'parallel'
276+
}
277+
278+
/**
279+
* Extra vertical spacing after containers to prevent edge crossings with sibling blocks.
280+
* This creates clearance for edges from container ends to route cleanly.
281+
*/
282+
const CONTAINER_VERTICAL_CLEARANCE = 120
283+
228284
/**
229285
* Calculates positions for nodes organized by layer.
230286
* Uses cumulative width-based X positioning to properly handle containers of varying widths.
287+
* Aligns blocks based on their connected predecessors to achieve handle-to-handle alignment.
288+
*
289+
* Handle alignment: Calculates actual source handle Y positions based on block type
290+
* (condition blocks have handles at different heights for each branch).
291+
* Target handles are also calculated per-block to ensure precise alignment.
231292
*/
232293
export function calculatePositions(
233294
layers: Map<number, GraphNode[]>,
295+
edges: Edge[],
234296
options: LayoutOptions = {}
235297
): void {
236298
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_LAYOUT_OPTIONS.horizontalSpacing
237299
const verticalSpacing = options.verticalSpacing ?? DEFAULT_LAYOUT_OPTIONS.verticalSpacing
238300
const padding = options.padding ?? DEFAULT_LAYOUT_OPTIONS.padding
239-
const alignment = options.alignment ?? DEFAULT_LAYOUT_OPTIONS.alignment
240301

241302
const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b)
242303

@@ -257,41 +318,89 @@ export function calculatePositions(
257318
cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing
258319
}
259320

260-
// Position nodes using cumulative X
321+
// Build a flat map of all nodes for quick lookups
322+
const allNodes = new Map<string, GraphNode>()
323+
for (const nodesInLayer of layers.values()) {
324+
for (const node of nodesInLayer) {
325+
allNodes.set(node.id, node)
326+
}
327+
}
328+
329+
// Build incoming edges map for handle lookups
330+
const incomingEdgesMap = new Map<string, Edge[]>()
331+
for (const edge of edges) {
332+
if (!incomingEdgesMap.has(edge.target)) {
333+
incomingEdgesMap.set(edge.target, [])
334+
}
335+
incomingEdgesMap.get(edge.target)!.push(edge)
336+
}
337+
338+
// Position nodes layer by layer, aligning with connected predecessors
261339
for (const layerNum of layerNumbers) {
262340
const nodesInLayer = layers.get(layerNum)!
263341
const xPosition = layerXPositions.get(layerNum)!
264342

265-
// Calculate total height for this layer
266-
const totalHeight = nodesInLayer.reduce(
267-
(sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0),
268-
0
269-
)
270-
271-
// Start Y based on alignment
272-
let yOffset: number
273-
switch (alignment) {
274-
case 'start':
275-
yOffset = padding.y
276-
break
277-
case 'center':
278-
yOffset = Math.max(padding.y, 300 - totalHeight / 2)
279-
break
280-
case 'end':
281-
yOffset = 600 - totalHeight - padding.y
282-
break
283-
default:
284-
yOffset = padding.y
285-
break
343+
// Separate containers and non-containers
344+
const containersInLayer = nodesInLayer.filter(isContainerBlock)
345+
const nonContainersInLayer = nodesInLayer.filter((n) => !isContainerBlock(n))
346+
347+
// For the first layer (layer 0), position sequentially from padding.y
348+
if (layerNum === 0) {
349+
let yOffset = padding.y
350+
351+
// Sort containers by height for visual balance
352+
containersInLayer.sort((a, b) => b.metrics.height - a.metrics.height)
353+
354+
for (const node of containersInLayer) {
355+
node.position = { x: xPosition, y: yOffset }
356+
yOffset += node.metrics.height + verticalSpacing
357+
}
358+
359+
if (containersInLayer.length > 0 && nonContainersInLayer.length > 0) {
360+
yOffset += CONTAINER_VERTICAL_CLEARANCE
361+
}
362+
363+
// Sort non-containers by outgoing connections
364+
nonContainersInLayer.sort((a, b) => b.outgoing.size - a.outgoing.size)
365+
366+
for (const node of nonContainersInLayer) {
367+
node.position = { x: xPosition, y: yOffset }
368+
yOffset += node.metrics.height + verticalSpacing
369+
}
370+
continue
286371
}
287372

288-
// Position each node
289-
for (const node of nodesInLayer) {
290-
node.position = {
291-
x: xPosition,
292-
y: yOffset,
373+
// For subsequent layers, align with connected predecessors (handle-to-handle)
374+
for (const node of [...containersInLayer, ...nonContainersInLayer]) {
375+
// Find the bottommost predecessor handle Y (highest value) and align to it
376+
let bestSourceHandleY = -1
377+
let bestEdge: Edge | null = null
378+
const incomingEdges = incomingEdgesMap.get(node.id) || []
379+
380+
for (const edge of incomingEdges) {
381+
const predecessor = allNodes.get(edge.source)
382+
if (predecessor) {
383+
// Calculate actual source handle Y position based on block type and handle
384+
const sourceHandleOffset = getSourceHandleYOffset(predecessor.block, edge.sourceHandle)
385+
const sourceHandleY = predecessor.position.y + sourceHandleOffset
386+
387+
if (sourceHandleY > bestSourceHandleY) {
388+
bestSourceHandleY = sourceHandleY
389+
bestEdge = edge
390+
}
391+
}
392+
}
393+
394+
// If no predecessors found (shouldn't happen for layer > 0), use padding
395+
if (bestSourceHandleY < 0) {
396+
bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET
293397
}
294-
yOffset += node.metrics.height + verticalSpacing
398+
399+
// Calculate the target handle Y offset for this node
400+
const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle)
401+
402+
// Position node so its target handle aligns with the source handle Y
403+
node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset }
295404
}
296405
}
297406

@@ -338,8 +447,8 @@ export function layoutBlocksCore(
338447
// 3. Group by layer
339448
const layers = groupByLayer(nodes)
340449

341-
// 4. Calculate positions
342-
calculatePositions(layers, layoutOptions)
450+
// 4. Calculate positions (pass edges for handle offset calculations)
451+
calculatePositions(layers, edges, layoutOptions)
343452

344453
// 5. Normalize positions
345454
const dimensions = normalizePositions(nodes, { isContainer: options.isContainer })

0 commit comments

Comments
 (0)