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
This commit is contained in:
Vikhyath Mondreti
2025-09-30 13:24:19 -07:00
committed by GitHub
parent 87c00cec6d
commit c35c8d1f31
13 changed files with 227 additions and 95 deletions

View File

@@ -8,7 +8,10 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'
import { applyAutoLayout } from '@/lib/workflows/autolayout'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import {
loadWorkflowFromNormalizedTables,
type NormalizedWorkflowData,
} from '@/lib/workflows/db-helpers'
export const dynamic = 'force-dynamic'
@@ -36,10 +39,14 @@ const AutoLayoutRequestSchema = z.object({
})
.optional()
.default({}),
// Optional: if provided, use these blocks instead of loading from DB
// This allows using blocks with live measurements from the UI
blocks: z.record(z.any()).optional(),
edges: z.array(z.any()).optional(),
loops: z.record(z.any()).optional(),
parallels: z.record(z.any()).optional(),
})
type AutoLayoutRequest = z.infer<typeof AutoLayoutRequestSchema>
/**
* POST /api/workflows/[id]/autolayout
* Apply autolayout to an existing workflow
@@ -108,8 +115,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Load current workflow state
const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
// Use provided blocks/edges if available (with live measurements from UI),
// otherwise load from database
let currentWorkflowData: NormalizedWorkflowData | null
if (layoutOptions.blocks && layoutOptions.edges) {
logger.info(`[${requestId}] Using provided blocks with live measurements`)
currentWorkflowData = {
blocks: layoutOptions.blocks,
edges: layoutOptions.edges,
loops: layoutOptions.loops || {},
parallels: layoutOptions.parallels || {},
isFromNormalizedTables: false,
}
} else {
logger.info(`[${requestId}] Loading blocks from database`)
currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
}
if (!currentWorkflowData) {
logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)

View File

@@ -148,6 +148,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
)
const storeIsWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
const storeBlockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0)
const storeBlockLayout = useWorkflowStore((state) => state.blocks[id]?.layout)
const storeBlockAdvancedMode = useWorkflowStore(
(state) => state.blocks[id]?.advancedMode ?? false
)
@@ -168,6 +169,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
? (currentWorkflow.blocks[id]?.height ?? 0)
: storeBlockHeight
const blockWidth = currentWorkflow.isDiffMode
? (currentWorkflow.blocks[id]?.layout?.measuredWidth ?? 0)
: (storeBlockLayout?.measuredWidth ?? 0)
// Get per-block webhook status by checking if webhook is configured
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -240,7 +245,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}, [id, collaborativeSetSubblockValue])
// Workflow store actions
const updateBlockHeight = useWorkflowStore((state) => state.updateBlockHeight)
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)
// Execution store
const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(id))
@@ -419,9 +424,9 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
if (!contentRef.current) return
let rafId: number
const debouncedUpdate = debounce((height: number) => {
if (height !== blockHeight) {
updateBlockHeight(id, height)
const debouncedUpdate = debounce((dimensions: { width: number; height: number }) => {
if (dimensions.height !== blockHeight || dimensions.width !== blockWidth) {
updateBlockLayoutMetrics(id, dimensions)
updateNodeInternals(id)
}
}, 100)
@@ -435,9 +440,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
// Schedule the update on the next animation frame
rafId = requestAnimationFrame(() => {
for (const entry of entries) {
const height =
entry.borderBoxSize[0]?.blockSize ?? entry.target.getBoundingClientRect().height
debouncedUpdate(height)
const rect = entry.target.getBoundingClientRect()
const height = entry.borderBoxSize[0]?.blockSize ?? rect.height
const width = entry.borderBoxSize[0]?.inlineSize ?? rect.width
debouncedUpdate({ width, height })
}
})
})
@@ -450,7 +456,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
cancelAnimationFrame(rafId)
}
}
}, [id, blockHeight, updateBlockHeight, updateNodeInternals, lastUpdate])
}, [id, blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, lastUpdate])
// SubBlock layout management
function groupSubBlocks(subBlocks: SubBlockConfig[], blockId: string) {

View File

@@ -98,18 +98,12 @@ const getBlockDimensions = (
}
}
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),
width: block.layout?.measuredWidth || (block.isWide ? 450 : block.data?.width || 350),
height: Math.max(
block.layout?.measuredHeight || block.height || block.data?.height || 150,
100
),
}
}

View File

@@ -78,13 +78,19 @@ export async function applyAutoLayoutToWorkflow(
},
}
// Call the autolayout API route which has access to the server-side API key
// Call the autolayout API route, sending blocks with live measurements
const response = await fetch(`/api/workflows/${workflowId}/autolayout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(layoutOptions),
body: JSON.stringify({
...layoutOptions,
blocks,
edges,
loops,
parallels,
}),
})
if (!response.ok) {

View File

@@ -3,7 +3,12 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { Edge, LayoutOptions } from './types'
import { DEFAULT_CONTAINER_HEIGHT, DEFAULT_CONTAINER_WIDTH, getBlocksByParent } from './utils'
import {
DEFAULT_CONTAINER_HEIGHT,
DEFAULT_CONTAINER_WIDTH,
getBlocksByParent,
prepareBlockMetrics,
} from './utils'
const logger = createLogger('AutoLayout:Containers')
@@ -45,6 +50,7 @@ export function layoutContainers(
}
const childNodes = assignLayers(childBlocks, childEdges)
prepareBlockMetrics(childNodes)
const childLayers = groupByLayer(childNodes)
calculatePositions(childLayers, containerOptions)
@@ -57,8 +63,8 @@ export function layoutContainers(
for (const node of childNodes.values()) {
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.dimensions.width)
maxY = Math.max(maxY, node.position.y + node.dimensions.height)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}
// Adjust all child positions to start at proper padding from container edges

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { AdjustmentOptions, Edge } from './types'
import { boxesOverlap, createBoundingBox, getBlockDimensions } from './utils'
import { boxesOverlap, createBoundingBox, getBlockMetrics } from './utils'
const logger = createLogger('AutoLayout:Incremental')
@@ -70,8 +70,8 @@ export function adjustForNewBlock(
})
}
const newBlockDims = getBlockDimensions(newBlock)
const newBlockBox = createBoundingBox(newBlock.position, newBlockDims)
const newBlockMetrics = getBlockMetrics(newBlock)
const newBlockBox = createBoundingBox(newBlock.position, newBlockMetrics)
const blocksToShift: Array<{ block: BlockState; shiftAmount: number }> = []
@@ -80,11 +80,11 @@ export function adjustForNewBlock(
if (block.data?.parentId) continue
if (block.position.x >= newBlock.position.x) {
const blockDims = getBlockDimensions(block)
const blockBox = createBoundingBox(block.position, blockDims)
const blockMetrics = getBlockMetrics(block)
const blockBox = createBoundingBox(block.position, blockMetrics)
if (boxesOverlap(newBlockBox, blockBox, 50)) {
const requiredShift = newBlock.position.x + newBlockDims.width + 50 - block.position.x
const requiredShift = newBlock.position.x + newBlockMetrics.width + 50 - block.position.x
if (requiredShift > 0) {
blocksToShift.push({ block, shiftAmount: requiredShift })
}
@@ -115,8 +115,8 @@ export function compactHorizontally(blocks: Record<string, BlockState>, edges: E
const prevBlock = blockArray[i - 1]
const currentBlock = blockArray[i]
const prevDims = getBlockDimensions(prevBlock)
const expectedX = prevBlock.position.x + prevDims.width + MIN_SPACING
const prevMetrics = getBlockMetrics(prevBlock)
const expectedX = prevBlock.position.x + prevMetrics.width + MIN_SPACING
if (currentBlock.position.x > expectedX + 150) {
const shift = currentBlock.position.x - expectedX

View File

@@ -5,7 +5,7 @@ import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } f
import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types'
import { getBlocksByParent } from './utils'
import { getBlocksByParent, prepareBlockMetrics } from './utils'
const logger = createLogger('AutoLayout')
@@ -39,6 +39,7 @@ export function applyAutoLayout(
if (Object.keys(rootBlocks).length > 0) {
const nodes = assignLayers(rootBlocks, rootEdges)
prepareBlockMetrics(nodes)
const layers = groupByLayer(nodes)
calculatePositions(layers, options)
@@ -99,4 +100,4 @@ export function adjustForNewBlock(
}
export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel }
export { getBlockDimensions, isContainerType } from './utils'
export { getBlockMetrics, isContainerType } from './utils'

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { Edge, GraphNode } from './types'
import { getBlockDimensions, isStarterBlock } from './utils'
import { getBlockMetrics } from './utils'
const logger = createLogger('AutoLayout:Layering')
@@ -15,7 +15,7 @@ export function assignLayers(
nodes.set(id, {
id,
block,
dimensions: getBlockDimensions(block),
metrics: getBlockMetrics(block),
incoming: new Set(),
outgoing: new Set(),
layer: 0,
@@ -33,9 +33,9 @@ export function assignLayers(
}
}
const starterNodes = Array.from(nodes.values()).filter(
(node) => node.incoming.size === 0 || isStarterBlock(node.block)
)
// Only treat blocks as starters if they have no incoming edges
// This prevents triggers that are mid-flow from being forced to layer 0
const starterNodes = Array.from(nodes.values()).filter((node) => node.incoming.size === 0)
if (starterNodes.length === 0 && nodes.size > 0) {
const firstNode = Array.from(nodes.values())[0]
@@ -43,35 +43,50 @@ export function assignLayers(
logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id })
}
const visited = new Set<string>()
const queue: Array<{ nodeId: string; layer: number }> = []
// Use topological sort to ensure proper layering based on dependencies
// Each node's layer = max(all incoming nodes' layers) + 1
const inDegreeCount = new Map<string, number>()
for (const starter of starterNodes) {
starter.layer = 0
queue.push({ nodeId: starter.id, layer: 0 })
for (const node of nodes.values()) {
inDegreeCount.set(node.id, node.incoming.size)
if (starterNodes.includes(node)) {
node.layer = 0
}
}
while (queue.length > 0) {
const { nodeId, layer } = queue.shift()!
const queue: string[] = starterNodes.map((n) => n.id)
const processed = new Set<string>()
if (visited.has(nodeId)) {
continue
while (queue.length > 0) {
const nodeId = queue.shift()!
const node = nodes.get(nodeId)!
processed.add(nodeId)
// Calculate this node's layer based on all incoming edges
if (node.incoming.size > 0) {
let maxIncomingLayer = -1
for (const incomingId of node.incoming) {
const incomingNode = nodes.get(incomingId)
if (incomingNode) {
maxIncomingLayer = Math.max(maxIncomingLayer, incomingNode.layer)
}
}
node.layer = maxIncomingLayer + 1
}
visited.add(nodeId)
const node = nodes.get(nodeId)!
node.layer = Math.max(node.layer, layer)
// Add outgoing nodes to queue when all their dependencies are processed
for (const targetId of node.outgoing) {
const targetNode = nodes.get(targetId)
if (targetNode) {
queue.push({ nodeId: targetId, layer: layer + 1 })
const currentCount = inDegreeCount.get(targetId) || 0
inDegreeCount.set(targetId, currentCount - 1)
if (inDegreeCount.get(targetId) === 0 && !processed.has(targetId)) {
queue.push(targetId)
}
}
}
for (const node of nodes.values()) {
if (!visited.has(node.id)) {
if (!processed.has(node.id)) {
logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id })
node.layer = 0
}

View File

@@ -26,7 +26,7 @@ export function calculatePositions(
// Calculate total height needed for this layer
const totalHeight = nodesInLayer.reduce(
(sum, node, idx) => sum + node.dimensions.height + (idx > 0 ? verticalSpacing : 0),
(sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0),
0
)
@@ -55,7 +55,7 @@ export function calculatePositions(
y: yOffset,
}
yOffset += node.dimensions.height + verticalSpacing
yOffset += node.metrics.height + verticalSpacing
}
}
@@ -83,8 +83,8 @@ function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
const node1 = sortedNodes[i]
const node2 = sortedNodes[j]
const box1 = createBoundingBox(node1.position, node1.dimensions)
const box2 = createBoundingBox(node2.position, node2.dimensions)
const box1 = createBoundingBox(node1.position, node1.metrics)
const box2 = createBoundingBox(node2.position, node2.metrics)
// Check for overlap with margin
if (boxesOverlap(box1, box2, 30)) {
@@ -92,11 +92,11 @@ function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
// If in same layer, shift vertically
if (node1.layer === node2.layer) {
const totalHeight = node1.dimensions.height + node2.dimensions.height + verticalSpacing
const totalHeight = node1.metrics.height + node2.metrics.height + verticalSpacing
const midpoint = (node1.position.y + node2.position.y) / 2
node1.position.y = midpoint - node1.dimensions.height / 2 - verticalSpacing / 2
node2.position.y = midpoint + node2.dimensions.height / 2 + verticalSpacing / 2
node1.position.y = midpoint - node1.metrics.height / 2 - verticalSpacing / 2
node2.position.y = midpoint + node2.metrics.height / 2 + verticalSpacing / 2
} else {
// Different layers - shift the later one down
const requiredSpace = box1.y + box1.height + verticalSpacing

View File

@@ -35,9 +35,15 @@ export interface Parallel {
parallelType?: 'count' | 'collection'
}
export interface BlockDimensions {
export interface BlockMetrics {
width: number
height: number
minWidth: number
minHeight: number
paddingTop: number
paddingBottom: number
paddingLeft: number
paddingRight: number
}
export interface BoundingBox {
@@ -55,7 +61,7 @@ export interface LayerInfo {
export interface GraphNode {
id: string
block: BlockState
dimensions: BlockDimensions
metrics: BlockMetrics
incoming: Set<string>
outgoing: Set<string>
layer: number

View File

@@ -1,34 +1,85 @@
import { TriggerUtils } from '@/lib/workflows/triggers'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { BlockDimensions, BoundingBox } from './types'
import type { BlockMetrics, BoundingBox, GraphNode } from './types'
export const DEFAULT_BLOCK_WIDTH = 350
export const DEFAULT_BLOCK_WIDTH_WIDE = 480
export const DEFAULT_BLOCK_HEIGHT = 100
export const DEFAULT_CONTAINER_WIDTH = 500
export const DEFAULT_CONTAINER_HEIGHT = 300
const DEFAULT_PADDING = 40
function resolveNumeric(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
export function isContainerType(blockType: string): boolean {
return blockType === 'loop' || blockType === 'parallel'
}
export function getBlockDimensions(block: BlockState): BlockDimensions {
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,
}
}
function getContainerMetrics(block: BlockState): BlockMetrics {
const measuredWidth = block.layout?.measuredWidth
const measuredHeight = block.layout?.measuredHeight
const containerWidth = Math.max(
measuredWidth ?? 0,
resolveNumeric(block.data?.width, DEFAULT_CONTAINER_WIDTH)
)
const containerHeight = Math.max(
measuredHeight ?? 0,
resolveNumeric(block.data?.height, DEFAULT_CONTAINER_HEIGHT)
)
return {
width: block.isWide ? DEFAULT_BLOCK_WIDTH_WIDE : DEFAULT_BLOCK_WIDTH,
height: Math.max(block.height || DEFAULT_BLOCK_HEIGHT, DEFAULT_BLOCK_HEIGHT),
width: containerWidth,
height: containerHeight,
minWidth: DEFAULT_CONTAINER_WIDTH,
minHeight: DEFAULT_CONTAINER_HEIGHT,
paddingTop: DEFAULT_PADDING,
paddingBottom: DEFAULT_PADDING,
paddingLeft: DEFAULT_PADDING,
paddingRight: DEFAULT_PADDING,
}
}
function getRegularBlockMetrics(block: BlockState): BlockMetrics {
const minWidth = block.isWide ? DEFAULT_BLOCK_WIDTH_WIDE : DEFAULT_BLOCK_WIDTH
const minHeight = DEFAULT_BLOCK_HEIGHT
const measuredH = block.layout?.measuredHeight ?? block.height
const measuredW = block.layout?.measuredWidth
const width = Math.max(measuredW ?? minWidth, minWidth)
const height = Math.max(measuredH ?? minHeight, minHeight)
return {
width,
height,
minWidth,
minHeight,
paddingTop: DEFAULT_PADDING,
paddingBottom: DEFAULT_PADDING,
paddingLeft: DEFAULT_PADDING,
paddingRight: DEFAULT_PADDING,
}
}
export function getBlockMetrics(block: BlockState): BlockMetrics {
if (isContainerType(block.type)) {
return getContainerMetrics(block)
}
return getRegularBlockMetrics(block)
}
export function prepareBlockMetrics(nodes: Map<string, GraphNode>): void {
for (const node of nodes.values()) {
node.metrics = getBlockMetrics(node.block)
}
}
export function createBoundingBox(
position: { x: number; y: number },
dimensions: BlockDimensions
dimensions: Pick<BlockMetrics, 'width' | 'height'>
): BoundingBox {
return {
x: position.x,
@@ -75,5 +126,5 @@ export function isStarterBlock(block: BlockState): boolean {
return true
}
return block.triggerMode === true
return false
}

View File

@@ -185,6 +185,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
advancedMode: blockProperties?.advancedMode ?? false,
triggerMode: blockProperties?.triggerMode ?? false,
height: blockProperties?.height ?? 0,
layout: {},
data: nodeData,
},
},
@@ -233,6 +234,11 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
width: dimensions.width,
height: dimensions.height,
},
layout: {
...block.layout,
measuredWidth: dimensions.width,
measuredHeight: dimensions.height,
},
},
},
edges: [...state.edges],
@@ -786,20 +792,33 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
// Note: Socket.IO handles real-time sync automatically
},
updateBlockHeight: (id: string, height: number) => {
set((state) => ({
blocks: {
...state.blocks,
[id]: {
...state.blocks[id],
height,
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => {
set((state) => {
const block = state.blocks[id]
if (!block) {
logger.warn(`Cannot update layout metrics: Block ${id} not found in workflow store`)
return state
}
return {
blocks: {
...state.blocks,
[id]: {
...block,
height: dimensions.height,
layout: {
...block.layout,
measuredWidth: dimensions.width,
measuredHeight: dimensions.height,
},
},
},
},
edges: [...state.edges],
loops: { ...state.loops },
}))
edges: [...state.edges],
loops: { ...state.loops },
}
})
get().updateLastSaved()
// No sync needed for height changes, just visual
// No sync needed for layout changes, just visual
},
updateLoopCount: (loopId: string, count: number) =>

View File

@@ -62,6 +62,11 @@ export interface BlockData {
type?: string
}
export interface BlockLayoutState {
measuredWidth?: number
measuredHeight?: number
}
export interface BlockState {
id: string
type: string
@@ -76,6 +81,7 @@ export interface BlockState {
advancedMode?: boolean
triggerMode?: boolean
data?: BlockData
layout?: BlockLayoutState
}
export interface SubBlockState {
@@ -197,7 +203,7 @@ export interface WorkflowActions {
setBlockWide: (id: string, isWide: boolean) => void
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockHeight: (id: string, height: number) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => void