Skip to content

Commit c35c8d1

Browse files
improvement(autolayout): use live block heights / widths for autolayout to prevent overlaps (#1505)
* improvement(autolayout): use live block heights / widths for autolayout to prevent overlaps * improve layering algo for multiple trigger setting * remove console logs * add type annotation
1 parent 87c00ce commit c35c8d1

File tree

13 files changed

+226
-94
lines changed

13 files changed

+226
-94
lines changed

apps/sim/app/api/workflows/[id]/autolayout/route.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import { createLogger } from '@/lib/logs/console/logger'
88
import { getUserEntityPermissions } from '@/lib/permissions/utils'
99
import { generateRequestId } from '@/lib/utils'
1010
import { applyAutoLayout } from '@/lib/workflows/autolayout'
11-
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
11+
import {
12+
loadWorkflowFromNormalizedTables,
13+
type NormalizedWorkflowData,
14+
} from '@/lib/workflows/db-helpers'
1215

1316
export const dynamic = 'force-dynamic'
1417

@@ -36,10 +39,14 @@ const AutoLayoutRequestSchema = z.object({
3639
})
3740
.optional()
3841
.default({}),
42+
// Optional: if provided, use these blocks instead of loading from DB
43+
// This allows using blocks with live measurements from the UI
44+
blocks: z.record(z.any()).optional(),
45+
edges: z.array(z.any()).optional(),
46+
loops: z.record(z.any()).optional(),
47+
parallels: z.record(z.any()).optional(),
3948
})
4049

41-
type AutoLayoutRequest = z.infer<typeof AutoLayoutRequestSchema>
42-
4350
/**
4451
* POST /api/workflows/[id]/autolayout
4552
* Apply autolayout to an existing workflow
@@ -108,8 +115,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
108115
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
109116
}
110117

111-
// Load current workflow state
112-
const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
118+
// Use provided blocks/edges if available (with live measurements from UI),
119+
// otherwise load from database
120+
let currentWorkflowData: NormalizedWorkflowData | null
121+
122+
if (layoutOptions.blocks && layoutOptions.edges) {
123+
logger.info(`[${requestId}] Using provided blocks with live measurements`)
124+
currentWorkflowData = {
125+
blocks: layoutOptions.blocks,
126+
edges: layoutOptions.edges,
127+
loops: layoutOptions.loops || {},
128+
parallels: layoutOptions.parallels || {},
129+
isFromNormalizedTables: false,
130+
}
131+
} else {
132+
logger.info(`[${requestId}] Loading blocks from database`)
133+
currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
134+
}
113135

114136
if (!currentWorkflowData) {
115137
logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
148148
)
149149
const storeIsWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
150150
const storeBlockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0)
151+
const storeBlockLayout = useWorkflowStore((state) => state.blocks[id]?.layout)
151152
const storeBlockAdvancedMode = useWorkflowStore(
152153
(state) => state.blocks[id]?.advancedMode ?? false
153154
)
@@ -168,6 +169,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
168169
? (currentWorkflow.blocks[id]?.height ?? 0)
169170
: storeBlockHeight
170171

172+
const blockWidth = currentWorkflow.isDiffMode
173+
? (currentWorkflow.blocks[id]?.layout?.measuredWidth ?? 0)
174+
: (storeBlockLayout?.measuredWidth ?? 0)
175+
171176
// Get per-block webhook status by checking if webhook is configured
172177
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
173178

@@ -240,7 +245,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
240245
}, [id, collaborativeSetSubblockValue])
241246

242247
// Workflow store actions
243-
const updateBlockHeight = useWorkflowStore((state) => state.updateBlockHeight)
248+
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)
244249

245250
// Execution store
246251
const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(id))
@@ -419,9 +424,9 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
419424
if (!contentRef.current) return
420425

421426
let rafId: number
422-
const debouncedUpdate = debounce((height: number) => {
423-
if (height !== blockHeight) {
424-
updateBlockHeight(id, height)
427+
const debouncedUpdate = debounce((dimensions: { width: number; height: number }) => {
428+
if (dimensions.height !== blockHeight || dimensions.width !== blockWidth) {
429+
updateBlockLayoutMetrics(id, dimensions)
425430
updateNodeInternals(id)
426431
}
427432
}, 100)
@@ -435,9 +440,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
435440
// Schedule the update on the next animation frame
436441
rafId = requestAnimationFrame(() => {
437442
for (const entry of entries) {
438-
const height =
439-
entry.borderBoxSize[0]?.blockSize ?? entry.target.getBoundingClientRect().height
440-
debouncedUpdate(height)
443+
const rect = entry.target.getBoundingClientRect()
444+
const height = entry.borderBoxSize[0]?.blockSize ?? rect.height
445+
const width = entry.borderBoxSize[0]?.inlineSize ?? rect.width
446+
debouncedUpdate({ width, height })
441447
}
442448
})
443449
})
@@ -450,7 +456,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
450456
cancelAnimationFrame(rafId)
451457
}
452458
}
453-
}, [id, blockHeight, updateBlockHeight, updateNodeInternals, lastUpdate])
459+
}, [id, blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, lastUpdate])
454460

455461
// SubBlock layout management
456462
function groupSubBlocks(subBlocks: SubBlockConfig[], blockId: string) {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,12 @@ const getBlockDimensions = (
9898
}
9999
}
100100

101-
if (block.type === 'workflowBlock') {
102-
const nodeWidth = block.data?.width || block.width
103-
const nodeHeight = block.data?.height || block.height
104-
105-
if (nodeWidth && nodeHeight) {
106-
return { width: nodeWidth, height: nodeHeight }
107-
}
108-
}
109-
110101
return {
111-
width: block.isWide ? 450 : block.data?.width || block.width || 350,
112-
height: Math.max(block.height || block.data?.height || 150, 100),
102+
width: block.layout?.measuredWidth || (block.isWide ? 450 : block.data?.width || 350),
103+
height: Math.max(
104+
block.layout?.measuredHeight || block.height || block.data?.height || 150,
105+
100
106+
),
113107
}
114108
}
115109

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,19 @@ export async function applyAutoLayoutToWorkflow(
7878
},
7979
}
8080

81-
// Call the autolayout API route which has access to the server-side API key
81+
// Call the autolayout API route, sending blocks with live measurements
8282
const response = await fetch(`/api/workflows/${workflowId}/autolayout`, {
8383
method: 'POST',
8484
headers: {
8585
'Content-Type': 'application/json',
8686
},
87-
body: JSON.stringify(layoutOptions),
87+
body: JSON.stringify({
88+
...layoutOptions,
89+
blocks,
90+
edges,
91+
loops,
92+
parallels,
93+
}),
8894
})
8995

9096
if (!response.ok) {

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
33
import { assignLayers, groupByLayer } from './layering'
44
import { calculatePositions } from './positioning'
55
import type { Edge, LayoutOptions } from './types'
6-
import { DEFAULT_CONTAINER_HEIGHT, DEFAULT_CONTAINER_WIDTH, getBlocksByParent } from './utils'
6+
import {
7+
DEFAULT_CONTAINER_HEIGHT,
8+
DEFAULT_CONTAINER_WIDTH,
9+
getBlocksByParent,
10+
prepareBlockMetrics,
11+
} from './utils'
712

813
const logger = createLogger('AutoLayout:Containers')
914

@@ -45,6 +50,7 @@ export function layoutContainers(
4550
}
4651

4752
const childNodes = assignLayers(childBlocks, childEdges)
53+
prepareBlockMetrics(childNodes)
4854
const childLayers = groupByLayer(childNodes)
4955
calculatePositions(childLayers, containerOptions)
5056

@@ -57,8 +63,8 @@ export function layoutContainers(
5763
for (const node of childNodes.values()) {
5864
minX = Math.min(minX, node.position.x)
5965
minY = Math.min(minY, node.position.y)
60-
maxX = Math.max(maxX, node.position.x + node.dimensions.width)
61-
maxY = Math.max(maxY, node.position.y + node.dimensions.height)
66+
maxX = Math.max(maxX, node.position.x + node.metrics.width)
67+
maxY = Math.max(maxY, node.position.y + node.metrics.height)
6268
}
6369

6470
// Adjust all child positions to start at proper padding from container edges

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@/lib/logs/console/logger'
22
import type { BlockState } from '@/stores/workflows/workflow/types'
33
import type { AdjustmentOptions, Edge } from './types'
4-
import { boxesOverlap, createBoundingBox, getBlockDimensions } from './utils'
4+
import { boxesOverlap, createBoundingBox, getBlockMetrics } from './utils'
55

66
const logger = createLogger('AutoLayout:Incremental')
77

@@ -70,8 +70,8 @@ export function adjustForNewBlock(
7070
})
7171
}
7272

73-
const newBlockDims = getBlockDimensions(newBlock)
74-
const newBlockBox = createBoundingBox(newBlock.position, newBlockDims)
73+
const newBlockMetrics = getBlockMetrics(newBlock)
74+
const newBlockBox = createBoundingBox(newBlock.position, newBlockMetrics)
7575

7676
const blocksToShift: Array<{ block: BlockState; shiftAmount: number }> = []
7777

@@ -80,11 +80,11 @@ export function adjustForNewBlock(
8080
if (block.data?.parentId) continue
8181

8282
if (block.position.x >= newBlock.position.x) {
83-
const blockDims = getBlockDimensions(block)
84-
const blockBox = createBoundingBox(block.position, blockDims)
83+
const blockMetrics = getBlockMetrics(block)
84+
const blockBox = createBoundingBox(block.position, blockMetrics)
8585

8686
if (boxesOverlap(newBlockBox, blockBox, 50)) {
87-
const requiredShift = newBlock.position.x + newBlockDims.width + 50 - block.position.x
87+
const requiredShift = newBlock.position.x + newBlockMetrics.width + 50 - block.position.x
8888
if (requiredShift > 0) {
8989
blocksToShift.push({ block, shiftAmount: requiredShift })
9090
}
@@ -115,8 +115,8 @@ export function compactHorizontally(blocks: Record<string, BlockState>, edges: E
115115
const prevBlock = blockArray[i - 1]
116116
const currentBlock = blockArray[i]
117117

118-
const prevDims = getBlockDimensions(prevBlock)
119-
const expectedX = prevBlock.position.x + prevDims.width + MIN_SPACING
118+
const prevMetrics = getBlockMetrics(prevBlock)
119+
const expectedX = prevBlock.position.x + prevMetrics.width + MIN_SPACING
120120

121121
if (currentBlock.position.x > expectedX + 150) {
122122
const shift = currentBlock.position.x - expectedX

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } f
55
import { assignLayers, groupByLayer } from './layering'
66
import { calculatePositions } from './positioning'
77
import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types'
8-
import { getBlocksByParent } from './utils'
8+
import { getBlocksByParent, prepareBlockMetrics } from './utils'
99

1010
const logger = createLogger('AutoLayout')
1111

@@ -39,6 +39,7 @@ export function applyAutoLayout(
3939

4040
if (Object.keys(rootBlocks).length > 0) {
4141
const nodes = assignLayers(rootBlocks, rootEdges)
42+
prepareBlockMetrics(nodes)
4243
const layers = groupByLayer(nodes)
4344
calculatePositions(layers, options)
4445

@@ -99,4 +100,4 @@ export function adjustForNewBlock(
99100
}
100101

101102
export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel }
102-
export { getBlockDimensions, isContainerType } from './utils'
103+
export { getBlockMetrics, isContainerType } from './utils'

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

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@/lib/logs/console/logger'
22
import type { BlockState } from '@/stores/workflows/workflow/types'
33
import type { Edge, GraphNode } from './types'
4-
import { getBlockDimensions, isStarterBlock } from './utils'
4+
import { getBlockMetrics } from './utils'
55

66
const logger = createLogger('AutoLayout:Layering')
77

@@ -15,7 +15,7 @@ export function assignLayers(
1515
nodes.set(id, {
1616
id,
1717
block,
18-
dimensions: getBlockDimensions(block),
18+
metrics: getBlockMetrics(block),
1919
incoming: new Set(),
2020
outgoing: new Set(),
2121
layer: 0,
@@ -33,45 +33,60 @@ export function assignLayers(
3333
}
3434
}
3535

36-
const starterNodes = Array.from(nodes.values()).filter(
37-
(node) => node.incoming.size === 0 || isStarterBlock(node.block)
38-
)
36+
// Only treat blocks as starters if they have no incoming edges
37+
// This prevents triggers that are mid-flow from being forced to layer 0
38+
const starterNodes = Array.from(nodes.values()).filter((node) => node.incoming.size === 0)
3939

4040
if (starterNodes.length === 0 && nodes.size > 0) {
4141
const firstNode = Array.from(nodes.values())[0]
4242
starterNodes.push(firstNode)
4343
logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id })
4444
}
4545

46-
const visited = new Set<string>()
47-
const queue: Array<{ nodeId: string; layer: number }> = []
46+
// Use topological sort to ensure proper layering based on dependencies
47+
// Each node's layer = max(all incoming nodes' layers) + 1
48+
const inDegreeCount = new Map<string, number>()
4849

49-
for (const starter of starterNodes) {
50-
starter.layer = 0
51-
queue.push({ nodeId: starter.id, layer: 0 })
50+
for (const node of nodes.values()) {
51+
inDegreeCount.set(node.id, node.incoming.size)
52+
if (starterNodes.includes(node)) {
53+
node.layer = 0
54+
}
5255
}
5356

54-
while (queue.length > 0) {
55-
const { nodeId, layer } = queue.shift()!
56-
57-
if (visited.has(nodeId)) {
58-
continue
59-
}
57+
const queue: string[] = starterNodes.map((n) => n.id)
58+
const processed = new Set<string>()
6059

61-
visited.add(nodeId)
60+
while (queue.length > 0) {
61+
const nodeId = queue.shift()!
6262
const node = nodes.get(nodeId)!
63-
node.layer = Math.max(node.layer, layer)
63+
processed.add(nodeId)
64+
65+
// Calculate this node's layer based on all incoming edges
66+
if (node.incoming.size > 0) {
67+
let maxIncomingLayer = -1
68+
for (const incomingId of node.incoming) {
69+
const incomingNode = nodes.get(incomingId)
70+
if (incomingNode) {
71+
maxIncomingLayer = Math.max(maxIncomingLayer, incomingNode.layer)
72+
}
73+
}
74+
node.layer = maxIncomingLayer + 1
75+
}
6476

77+
// Add outgoing nodes to queue when all their dependencies are processed
6578
for (const targetId of node.outgoing) {
66-
const targetNode = nodes.get(targetId)
67-
if (targetNode) {
68-
queue.push({ nodeId: targetId, layer: layer + 1 })
79+
const currentCount = inDegreeCount.get(targetId) || 0
80+
inDegreeCount.set(targetId, currentCount - 1)
81+
82+
if (inDegreeCount.get(targetId) === 0 && !processed.has(targetId)) {
83+
queue.push(targetId)
6984
}
7085
}
7186
}
7287

7388
for (const node of nodes.values()) {
74-
if (!visited.has(node.id)) {
89+
if (!processed.has(node.id)) {
7590
logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id })
7691
node.layer = 0
7792
}

0 commit comments

Comments
 (0)