chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,469 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getTargetedLayoutChangeSet,
getTargetedLayoutImpact,
} from '@/lib/workflows/autolayout/change-set'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const { mockGetBlock } = vi.hoisted(() => ({
mockGetBlock: vi.fn(),
}))
vi.mock('@/blocks', () => ({
getBlock: mockGetBlock,
}))
const JIRA_TEST_BLOCK_CONFIG = {
category: 'tools',
subBlocks: [
{ id: 'operation', type: 'dropdown' },
{ id: 'domain', type: 'short-input' },
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
],
} as const
function createBlock(
id: string,
overrides: Partial<BlockState> = {},
parentId?: string
): BlockState {
return {
id,
type: 'agent',
name: id,
position: { x: 100, y: 100 },
subBlocks: {},
outputs: {},
enabled: true,
...(parentId ? { data: { parentId, extent: 'parent' as const } } : {}),
...overrides,
}
}
function createWorkflowState({
blocks,
edges = [],
}: {
blocks: Record<string, BlockState>
edges?: WorkflowState['edges']
}): Pick<WorkflowState, 'blocks' | 'edges'> {
return {
blocks,
edges,
}
}
function createJiraBlock(
id: string,
operation: 'read' | 'write',
overrides: Partial<BlockState> = {}
): BlockState {
return createBlock(id, {
type: 'jira',
position: { x: 100, y: 100 },
height: 100,
layout: { measuredWidth: 250, measuredHeight: 100 },
subBlocks: {
operation: {
id: 'operation',
type: 'dropdown',
value: operation,
},
domain: {
id: 'domain',
type: 'short-input',
value: 'company.atlassian.net',
},
credential: {
id: 'credential',
type: 'oauth-input',
value: 'credential-1',
},
},
...overrides,
})
}
describe('getTargetedLayoutChangeSet', () => {
beforeEach(() => {
mockGetBlock.mockImplementation((type: string) =>
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
)
})
it('does not relayout newly added blocks that already have valid positions', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
agent: createBlock('agent', { position: { x: 400, y: 100 } }),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('includes newly added blocks when they still have sentinel positions', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
agent: createBlock('agent', { position: { x: 0, y: 0 } }),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual(['agent'])
})
it('keeps subblock-only edits anchored', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start', {
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'old value',
},
},
}),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start', {
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'updated',
},
},
}),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('reopens edited blocks when an operation change increases their visible height', () => {
const before = createWorkflowState({
blocks: {
jira: createJiraBlock('jira', 'read'),
},
})
const after = createWorkflowState({
blocks: {
jira: createJiraBlock('jira', 'write'),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
resizedBlockIds: ['jira'],
shiftSourceBlockIds: [],
})
})
it('does not relayout a pre-existing block legitimately placed at the origin', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start', {
position: { x: 0, y: 0 },
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'old value',
},
},
}),
},
})
const after = createWorkflowState({
blocks: {
start: createBlock('start', {
position: { x: 0, y: 0 },
subBlocks: {
prompt: {
id: 'prompt',
type: 'long-input',
value: 'updated',
},
},
}),
},
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
it('reopens only the downstream path when an edge is added later', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
function1: createBlock('function1', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'function1',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
function1: createBlock('function1', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'function1',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'start',
target: 'function1',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: ['function1'],
resizedBlockIds: [],
shiftSourceBlockIds: [],
})
})
it('returns a pure shift source when a stable block gains an edge to an already-connected target', () => {
const before = createWorkflowState({
blocks: {
source: createBlock('source', { position: { x: 100, y: 100 } }),
upstream: createBlock('upstream', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'upstream',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'target',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
source: createBlock('source', { position: { x: 100, y: 100 } }),
upstream: createBlock('upstream', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
end: createBlock('end', { position: { x: 700, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'upstream',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'target',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-3',
source: 'source',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
resizedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
})
it('distinguishes added edges when ids and handles contain hyphens', () => {
const before = createWorkflowState({
blocks: {
a: createBlock('a', { position: { x: 100, y: 100 } }),
'a-b': createBlock('a-b', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'a',
sourceHandle: 'b-c',
target: 'target',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
a: createBlock('a', { position: { x: 100, y: 100 } }),
'a-b': createBlock('a-b', { position: { x: 100, y: 300 } }),
target: createBlock('target', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'a',
sourceHandle: 'b-c',
target: 'target',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'a-b',
sourceHandle: 'c',
target: 'target',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: [],
resizedBlockIds: [],
shiftSourceBlockIds: ['a-b'],
})
})
it('keeps the upstream source anchored when inserting between existing blocks', () => {
const before = createWorkflowState({
blocks: {
start: createBlock('start'),
end: createBlock('end', { position: { x: 700, y: 100 } }),
inserted: createBlock('inserted', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-1',
source: 'start',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
const after = createWorkflowState({
blocks: {
start: createBlock('start'),
end: createBlock('end', { position: { x: 700, y: 100 } }),
inserted: createBlock('inserted', { position: { x: 400, y: 100 } }),
},
edges: [
{
id: 'edge-2',
source: 'start',
target: 'inserted',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-3',
source: 'inserted',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutImpact({ before, after })).toEqual({
layoutBlockIds: ['inserted'],
resizedBlockIds: [],
shiftSourceBlockIds: ['inserted'],
})
})
it('ignores edge changes that cross layout scopes', () => {
const before = createWorkflowState({
blocks: {
loop: createBlock('loop'),
child: createBlock('child', { position: { x: 120, y: 160 } }, 'loop'),
},
})
const after = createWorkflowState({
blocks: {
loop: createBlock('loop'),
child: createBlock('child', { position: { x: 120, y: 160 } }, 'loop'),
},
edges: [
{
id: 'edge-1',
source: 'loop',
target: 'child',
sourceHandle: 'loop-start-source',
targetHandle: 'target',
},
],
})
expect(getTargetedLayoutChangeSet({ before, after })).toEqual([])
})
})
@@ -0,0 +1,225 @@
import type { Edge } from 'reactflow'
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface TargetedLayoutChangeSetOptions {
before: Pick<WorkflowState, 'blocks' | 'edges'>
after: Pick<WorkflowState, 'blocks' | 'edges'>
}
export interface TargetedLayoutImpact {
layoutBlockIds: string[]
resizedBlockIds: string[]
shiftSourceBlockIds: string[]
}
/**
* Computes the minimal change set that should be reopened for targeted layout
* after a workflow edit. `layoutBlockIds` are fully repositioned, while
* `resizedBlockIds` keep their existing position and only shift neighbors.
*/
export function getTargetedLayoutImpact({
before,
after,
}: TargetedLayoutChangeSetOptions): TargetedLayoutImpact {
const layoutBlockIds = new Set<string>()
const resizedBlockIds = new Set<string>()
const afterBlockIds = new Set(Object.keys(after.blocks || {}))
const beforeBlockIds = new Set(Object.keys(before.blocks || {}))
for (const blockId of afterBlockIds) {
if (!beforeBlockIds.has(blockId)) {
const position = after.blocks[blockId]?.position
if (
!position ||
!Number.isFinite(position.x) ||
!Number.isFinite(position.y) ||
(position.x === 0 && position.y === 0)
) {
layoutBlockIds.add(blockId)
}
continue
}
const previousParentId = before.blocks[blockId]?.data?.parentId ?? null
const currentParentId = after.blocks[blockId]?.data?.parentId ?? null
if (previousParentId === currentParentId) {
if (hasLayoutRelevantSizeChange(before.blocks[blockId], after.blocks[blockId])) {
resizedBlockIds.add(blockId)
}
continue
}
const position = after.blocks[blockId]?.position
if (position?.x === 0 && position?.y === 0) {
layoutBlockIds.add(blockId)
}
}
for (const blockId of getBlocksWithInvalidPositions(after, beforeBlockIds)) {
layoutBlockIds.add(blockId)
}
const addedEdges = getAddedLayoutScopedEdges(before.edges || [], after.edges || [], after.blocks)
if (addedEdges.length === 0) {
return {
layoutBlockIds: Array.from(layoutBlockIds),
resizedBlockIds: Array.from(resizedBlockIds),
shiftSourceBlockIds: [],
}
}
const beforeIncomingCounts = countIncomingLayoutScopedEdges(before.edges || [], before.blocks)
const afterIncomingCounts = countIncomingLayoutScopedEdges(after.edges || [], after.blocks)
for (const edge of addedEdges) {
const targetBlock = after.blocks[edge.target]
if (!targetBlock) {
continue
}
const beforeIncoming = beforeIncomingCounts.get(edge.target) ?? 0
const afterIncoming = afterIncomingCounts.get(edge.target) ?? 0
if (beforeIncoming === 0 && afterIncoming > 0) {
layoutBlockIds.add(edge.target)
}
}
const shiftSourceBlockIds = new Set<string>()
for (const edge of addedEdges) {
if (!after.blocks[edge.source] || !after.blocks[edge.target]) {
continue
}
if (layoutBlockIds.has(edge.target)) {
continue
}
shiftSourceBlockIds.add(edge.source)
}
return {
layoutBlockIds: Array.from(layoutBlockIds),
resizedBlockIds: Array.from(resizedBlockIds),
shiftSourceBlockIds: Array.from(shiftSourceBlockIds),
}
}
export function getTargetedLayoutChangeSet(options: TargetedLayoutChangeSetOptions): string[] {
return getTargetedLayoutImpact(options).layoutBlockIds
}
/**
* Returns block IDs that cannot be treated as stable layout anchors.
* Existing blocks are only considered invalid for missing or non-finite
* coordinates; `(0,0)` is reserved as a layout sentinel only for newly added
* blocks and parent-change handling above.
*/
function getBlocksWithInvalidPositions(
after: Pick<WorkflowState, 'blocks'>,
beforeBlockIds: Set<string>
): string[] {
return Object.keys(after.blocks || {}).filter((blockId) => {
const position = after.blocks[blockId]?.position
return (
!position ||
!Number.isFinite(position.x) ||
!Number.isFinite(position.y) ||
(!beforeBlockIds.has(blockId) && position.x === 0 && position.y === 0)
)
})
}
/**
* Returns true when a persisted block changed size enough that anchored layout
* should reopen its column and shift affected siblings.
*/
function hasLayoutRelevantSizeChange(
beforeBlock: WorkflowState['blocks'][string] | undefined,
afterBlock: WorkflowState['blocks'][string] | undefined
): boolean {
if (!beforeBlock || !afterBlock) {
return false
}
const beforeMetrics = getBlockMetrics(beforeBlock)
const afterMetrics = getBlockMetrics(afterBlock)
return beforeMetrics.height !== afterMetrics.height || beforeMetrics.width !== afterMetrics.width
}
/**
* Returns added edges that participate in layout within a shared parent scope.
*/
function getAddedLayoutScopedEdges(
beforeEdges: Edge[],
afterEdges: Edge[],
afterBlocks: WorkflowState['blocks']
): Edge[] {
const beforeSignatures = new Set(beforeEdges.map((edge) => getEdgeSignature(edge)))
const addedEdges: Edge[] = []
for (const edge of afterEdges) {
if (beforeSignatures.has(getEdgeSignature(edge))) {
continue
}
if (isLayoutScopedEdge(edge, afterBlocks)) {
addedEdges.push(edge)
}
}
return addedEdges
}
/**
* Counts incoming edges that participate in layout within each shared parent
* scope for the provided workflow snapshot.
*/
function countIncomingLayoutScopedEdges(
edges: Edge[],
blocks: WorkflowState['blocks']
): Map<string, number> {
const counts = new Map<string, number>()
for (const edge of edges) {
if (!isLayoutScopedEdge(edge, blocks)) {
continue
}
counts.set(edge.target, (counts.get(edge.target) ?? 0) + 1)
}
return counts
}
/**
* Layout groups are scoped by parent container, so only edges whose endpoints
* share the same current parent can affect the group's block positions.
*/
function isLayoutScopedEdge(edge: Edge, afterBlocks: WorkflowState['blocks']): boolean {
const sourceBlock = afterBlocks[edge.source]
const targetBlock = afterBlocks[edge.target]
if (!sourceBlock || !targetBlock) {
return false
}
const sourceParentId = sourceBlock.data?.parentId ?? null
const targetParentId = targetBlock.data?.parentId ?? null
return sourceParentId === targetParentId
}
/**
* Creates a stable signature for comparing workflow edges independent of edge
* record IDs.
*/
function getEdgeSignature(edge: Edge): string {
return JSON.stringify([
edge.source,
edge.sourceHandle || 'source',
edge.target,
edge.targetHandle || 'target',
])
}
@@ -0,0 +1,115 @@
/**
* Autolayout Constants
*
* Layout algorithm specific constants for spacing, padding, and overlap detection.
* Block dimensions are in @sim/workflow-renderer
*/
/**
* Horizontal spacing between layers (columns)
*/
export const DEFAULT_HORIZONTAL_SPACING = 180
/**
* Vertical spacing between blocks in the same layer
*/
export const DEFAULT_VERTICAL_SPACING = 80
/**
* Default offset when duplicating blocks
*/
export const DEFAULT_DUPLICATE_OFFSET = {
x: 180,
y: 20,
} as const
/**
* General container padding for layout calculations
*/
export const CONTAINER_PADDING = 150
/**
* Container horizontal padding (X offset for children in layout coordinates)
*/
export const CONTAINER_PADDING_X = 180
/**
* Container vertical padding (Y offset for children in layout coordinates)
*/
export const CONTAINER_PADDING_Y = 100
/**
* Root level horizontal padding
*/
export const ROOT_PADDING_X = 150
/**
* Root level vertical padding
*/
export const ROOT_PADDING_Y = 150
/**
* Default padding for layout positioning
*/
export const DEFAULT_LAYOUT_PADDING = { x: 150, y: 150 }
/**
* Margin for overlap detection
*/
export const OVERLAP_MARGIN = 30
/**
* Maximum iterations for overlap resolution
*/
export const MAX_OVERLAP_ITERATIONS = 20
/**
* Note block type identifier
*/
export const NOTE_BLOCK_TYPE = 'note'
/**
* Block types excluded from autolayout
*/
export const AUTO_LAYOUT_EXCLUDED_TYPES = new Set([NOTE_BLOCK_TYPE])
/**
* Container block types that can have children
*/
export const CONTAINER_BLOCK_TYPES = new Set(['loop', 'parallel'])
/**
* Estimated height per subblock when no measured height is available.
* Used as a heuristic for new blocks that haven't been rendered yet.
*/
export const ESTIMATED_SUBBLOCK_HEIGHT = 45
/**
* Bottom padding added to estimated block height
*/
export const ESTIMATED_BLOCK_BOTTOM_PADDING = 20
/**
* Maximum estimated block height when no measurement is available.
* Prevents wildly over-estimated heights for blocks with many conditional
* subblocks (e.g. agent blocks define ~20 subblocks but only ~5 are visible).
*/
export const MAX_ESTIMATED_BLOCK_HEIGHT = 350
/**
* Default layout options
*/
export const DEFAULT_LAYOUT_OPTIONS = {
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
padding: DEFAULT_LAYOUT_PADDING,
}
/**
* Container-specific layout options (same spacing as root level for consistency)
*/
export const CONTAINER_LAYOUT_OPTIONS = {
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
}
@@ -0,0 +1,89 @@
import { createLogger } from '@sim/logger'
import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
DEFAULT_VERTICAL_SPACING,
} from '@/lib/workflows/autolayout/constants'
import { layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types'
import { filterLayoutEligibleBlockIds, getBlocksByParent } from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout:Containers')
/**
* Default horizontal spacing for containers (tighter than root level)
*/
const DEFAULT_CONTAINER_HORIZONTAL_SPACING = 400
/**
* Lays out children within container blocks (loops and parallels).
* Updates both child positions and container dimensions.
*/
export function layoutContainers(
blocks: Record<string, BlockState>,
edges: Edge[],
options: LayoutOptions = {}
): void {
const { children } = getBlocksByParent(blocks)
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 },
gridSize: options.gridSize,
}
for (const [parentId, childIds] of children.entries()) {
const parentBlock = blocks[parentId]
if (!parentBlock) continue
logger.debug('Processing container', { parentId, childCount: childIds.length })
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
const childBlocks: Record<string, BlockState> = {}
for (const childId of layoutChildIds) {
childBlocks[childId] = blocks[childId]
}
const childEdges = edges.filter(
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
)
if (Object.keys(childBlocks).length === 0) {
continue
}
const { nodes, dimensions } = layoutBlocksCore(childBlocks, childEdges, {
isContainer: true,
layoutOptions: containerOptions,
})
for (const node of nodes.values()) {
blocks[node.id].position = node.position
}
const calculatedWidth = dimensions.width
const calculatedHeight = dimensions.height
const containerWidth = Math.max(calculatedWidth, CONTAINER_DIMENSIONS.DEFAULT_WIDTH)
const containerHeight = Math.max(calculatedHeight, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT)
if (!parentBlock.data) {
parentBlock.data = {}
}
parentBlock.data.width = containerWidth
parentBlock.data.height = containerHeight
logger.debug('Container dimensions calculated', {
parentId,
width: containerWidth,
height: containerHeight,
childCount: childIds.length,
})
}
}
+432
View File
@@ -0,0 +1,432 @@
import { createLogger } from '@sim/logger'
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@sim/workflow-renderer'
import {
CONTAINER_LAYOUT_OPTIONS,
DEFAULT_LAYOUT_OPTIONS,
MAX_OVERLAP_ITERATIONS,
} from '@/lib/workflows/autolayout/constants'
import type { Edge, GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types'
import {
getBlockMetrics,
normalizePositions,
prepareBlockMetrics,
snapNodesToGrid,
} from '@/lib/workflows/autolayout/utils'
import { EDGE } from '@/executor/constants'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout:Core')
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(EDGE.CONDITION_PREFIX)) {
const conditionId = sourceHandle.replace(EDGE.CONDITION_PREFIX, '')
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
*/
function isSubflowEndEdge(edge: Edge): boolean {
return edge.sourceHandle != null && SUBFLOW_END_HANDLES.has(edge.sourceHandle)
}
/**
* Assigns layers (columns) to blocks using topological sort.
* Blocks with no incoming edges are placed in layer 0.
* When edges come from subflow end handles, the subflow's internal depth is added.
*
* @param blocks - The blocks to assign layers to
* @param edges - The edges connecting blocks
* @param subflowDepths - Optional map of container block IDs to their internal depth (max layers inside)
*/
export function assignLayers(
blocks: Record<string, BlockState>,
edges: Edge[],
subflowDepths?: Map<string, number>
): Map<string, GraphNode> {
const nodes = new Map<string, GraphNode>()
for (const [id, block] of Object.entries(blocks)) {
nodes.set(id, {
id,
block,
metrics: getBlockMetrics(block),
incoming: new Set(),
outgoing: new Set(),
layer: 0,
position: { ...block.position },
})
}
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)
}
for (const edge of edges) {
const sourceNode = nodes.get(edge.source)
const targetNode = nodes.get(edge.target)
if (sourceNode && targetNode) {
sourceNode.outgoing.add(edge.target)
targetNode.incoming.add(edge.source)
}
}
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]
starterNodes.push(firstNode)
logger.warn('No starter blocks found, using first block as starter', { blockId: firstNode.id })
}
const inDegreeCount = new Map<string, number>()
for (const node of nodes.values()) {
inDegreeCount.set(node.id, node.incoming.size)
if (starterNodes.includes(node)) {
node.layer = 0
}
}
const queue: string[] = starterNodes.map((n) => n.id)
const processed = new Set<string>()
while (queue.length > 0) {
const nodeId = queue.shift()!
const node = nodes.get(nodeId)!
processed.add(nodeId)
if (node.incoming.size > 0) {
let maxEffectiveLayer = -1
const incomingEdges = incomingEdgesMap.get(nodeId) || []
for (const incomingId of node.incoming) {
const incomingNode = nodes.get(incomingId)
if (incomingNode) {
const edgesFromSource = incomingEdges.filter((e) => e.source === incomingId)
let additionalDepth = 0
const hasSubflowEndEdge = edgesFromSource.some(isSubflowEndEdge)
if (hasSubflowEndEdge && subflowDepths) {
const depth = subflowDepths.get(incomingId) ?? 1
additionalDepth = Math.max(0, depth - 1)
}
const effectiveLayer = incomingNode.layer + additionalDepth
maxEffectiveLayer = Math.max(maxEffectiveLayer, effectiveLayer)
}
}
node.layer = maxEffectiveLayer + 1
}
for (const targetId of node.outgoing) {
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 (!processed.has(node.id)) {
logger.debug('Isolated node detected, assigning to layer 0', { blockId: node.id })
node.layer = 0
}
}
return nodes
}
/**
* Groups nodes by their layer number
*/
export function groupByLayer(nodes: Map<string, GraphNode>): Map<number, GraphNode[]> {
const layers = new Map<number, GraphNode[]>()
for (const node of nodes.values()) {
if (!layers.has(node.layer)) {
layers.set(node.layer, [])
}
layers.get(node.layer)!.push(node)
}
return layers
}
/**
* Resolves vertical overlaps between nodes in the same layer.
* X overlaps are prevented by construction via cumulative width-based positioning.
*/
function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
let iteration = 0
let hasOverlap = true
while (hasOverlap && iteration < MAX_OVERLAP_ITERATIONS) {
hasOverlap = false
iteration++
const nodesByLayer = new Map<number, GraphNode[]>()
for (const node of nodes) {
if (!nodesByLayer.has(node.layer)) {
nodesByLayer.set(node.layer, [])
}
nodesByLayer.get(node.layer)!.push(node)
}
for (const [layer, layerNodes] of nodesByLayer) {
if (layerNodes.length < 2) continue
layerNodes.sort((a, b) => a.position.y - b.position.y)
for (let i = 0; i < layerNodes.length - 1; i++) {
const node1 = layerNodes[i]
const node2 = layerNodes[i + 1]
const node1Bottom = node1.position.y + node1.metrics.height
const requiredY = node1Bottom + verticalSpacing
if (node2.position.y < requiredY) {
hasOverlap = true
node2.position.y = requiredY
logger.debug('Resolved vertical overlap in layer', {
layer,
block1: node1.id,
block2: node2.id,
iteration,
})
}
}
}
}
if (hasOverlap) {
logger.warn('Could not fully resolve all vertical overlaps after max iterations', {
iterations: MAX_OVERLAP_ITERATIONS,
})
}
}
/**
* 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 layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b)
const layerWidths = new Map<number, number>()
for (const layerNum of layerNumbers) {
const nodesInLayer = layers.get(layerNum)!
const maxWidth = Math.max(...nodesInLayer.map((n) => n.metrics.width))
layerWidths.set(layerNum, maxWidth)
}
const layerXPositions = new Map<number, number>()
let cumulativeX = padding.x
for (const layerNum of layerNumbers) {
layerXPositions.set(layerNum, cumulativeX)
cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing
}
const allNodes = new Map<string, GraphNode>()
for (const nodesInLayer of layers.values()) {
for (const node of nodesInLayer) {
allNodes.set(node.id, node)
}
}
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)
}
for (const layerNum of layerNumbers) {
const nodesInLayer = layers.get(layerNum)!
const xPosition = layerXPositions.get(layerNum)!
const containersInLayer = nodesInLayer.filter(isContainerBlock)
const nonContainersInLayer = nodesInLayer.filter((n) => !isContainerBlock(n))
if (layerNum === 0) {
let yOffset = padding.y
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
}
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
}
for (const node of [...containersInLayer, ...nonContainersInLayer]) {
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) {
const sourceHandleOffset = getSourceHandleYOffset(predecessor.block, edge.sourceHandle)
const sourceHandleY = predecessor.position.y + sourceHandleOffset
if (sourceHandleY > bestSourceHandleY) {
bestSourceHandleY = sourceHandleY
bestEdge = edge
}
}
}
if (bestSourceHandleY < 0) {
bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET
}
const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle)
node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset }
}
}
resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing)
}
/**
* Core layout function that performs the complete layout pipeline:
* 1. Assign layers using topological sort
* 2. Prepare block metrics
* 3. Group nodes by layer
* 4. Calculate positions
* 5. Normalize positions to start from padding
*
* @param blocks - The blocks to lay out
* @param edges - The edges connecting blocks
* @param options - Layout options including container flag and subflow depths
* @returns The laid-out nodes with updated positions, and bounding dimensions
*/
export function layoutBlocksCore(
blocks: Record<string, BlockState>,
edges: Edge[],
options: {
isContainer: boolean
layoutOptions?: LayoutOptions
subflowDepths?: Map<string, number>
}
): { nodes: Map<string, GraphNode>; dimensions: { width: number; height: number } } {
if (Object.keys(blocks).length === 0) {
return { nodes: new Map(), dimensions: { width: 0, height: 0 } }
}
const layoutOptions: LayoutOptions =
options.layoutOptions ??
(options.isContainer ? CONTAINER_LAYOUT_OPTIONS : DEFAULT_LAYOUT_OPTIONS)
// 1. Assign layers (with subflow depth adjustment for subflow end edges)
const nodes = assignLayers(blocks, edges, options.subflowDepths)
// 2. Prepare metrics
prepareBlockMetrics(nodes)
// 3. Group by layer
const layers = groupByLayer(nodes)
// 4. Calculate positions (pass edges for handle offset calculations)
calculatePositions(layers, edges, layoutOptions)
// 5. Normalize positions
let dimensions = normalizePositions(nodes, { isContainer: options.isContainer })
// 6. Snap to grid if gridSize is specified (recalculates dimensions)
const snappedDimensions = snapNodesToGrid(nodes, layoutOptions.gridSize)
if (snappedDimensions) {
dimensions = snappedDimensions
}
return { nodes, dimensions }
}
+110
View File
@@ -0,0 +1,110 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
} from '@/lib/workflows/autolayout/constants'
import { layoutContainers } from '@/lib/workflows/autolayout/containers'
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
import {
calculateSubflowDepths,
filterLayoutEligibleBlockIds,
getBlocksByParent,
prepareContainerDimensions,
resolveNoteOverlaps,
} from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout')
/**
* Applies automatic layout to all blocks in a workflow.
* Positions blocks in layers based on their connections (edges).
*/
export function applyAutoLayout(
blocks: Record<string, BlockState>,
edges: Edge[],
options: LayoutOptions = {}
): LayoutResult {
try {
logger.info('Starting auto layout', {
blockCount: Object.keys(blocks).length,
edgeCount: edges.length,
})
const blocksCopy: Record<string, BlockState> = structuredClone(blocks)
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
prepareContainerDimensions(
blocksCopy,
edges,
layoutBlocksCore,
horizontalSpacing,
verticalSpacing,
options.gridSize
)
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
const rootBlocks: Record<string, BlockState> = {}
for (const id of layoutRootIds) {
rootBlocks[id] = blocksCopy[id]
}
const rootEdges = edges.filter(
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
)
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
if (Object.keys(rootBlocks).length > 0) {
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
isContainer: false,
layoutOptions: options,
subflowDepths,
})
for (const node of nodes.values()) {
blocksCopy[node.id].position = node.position
}
}
layoutContainers(blocksCopy, edges, options)
resolveNoteOverlaps(blocksCopy, verticalSpacing)
logger.info('Auto layout completed successfully', {
blockCount: Object.keys(blocksCopy).length,
})
return {
blocks: blocksCopy,
success: true,
}
} catch (error) {
logger.error('Auto layout failed', { error })
return {
blocks,
success: false,
error: getErrorMessage(error, 'Unknown error'),
}
}
}
export {
getTargetedLayoutChangeSet,
getTargetedLayoutImpact,
} from '@/lib/workflows/autolayout/change-set'
export { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted'
export type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
export {
getBlockMetrics,
isContainerType,
shouldSkipAutoLayout,
snapPositionToGrid,
transferBlockHeights,
} from '@/lib/workflows/autolayout/utils'
@@ -0,0 +1,348 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_VERTICAL_SPACING } from '@/lib/workflows/autolayout/constants'
import { applyTargetedLayout } from '@/lib/workflows/autolayout/targeted'
import type { Edge } from '@/lib/workflows/autolayout/types'
import { getBlockMetrics } from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const { mockGetBlock } = vi.hoisted(() => ({
mockGetBlock: vi.fn(),
}))
vi.mock('@/blocks', () => ({
getBlock: mockGetBlock,
}))
const JIRA_TEST_BLOCK_CONFIG = {
category: 'tools',
subBlocks: [
{ id: 'operation', type: 'dropdown' },
{ id: 'domain', type: 'short-input' },
{ id: 'credential', type: 'oauth-input', mode: 'basic' },
{ id: 'issueKey', type: 'short-input', condition: { field: 'operation', value: 'read' } },
{ id: 'projectId', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'summary', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'description', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'priority', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'labels', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'issueType', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'parentIssue', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'assignee', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'reporter', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'environment', type: 'long-input', condition: { field: 'operation', value: 'write' } },
{ id: 'components', type: 'short-input', condition: { field: 'operation', value: 'write' } },
{ id: 'fixVersions', type: 'short-input', condition: { field: 'operation', value: 'write' } },
],
} as const
function createBlock(id: string, overrides: Partial<BlockState> = {}): BlockState {
return {
id,
type: 'function',
name: id,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
}
}
function expectVerticalSeparation(upper: BlockState, lower: BlockState): void {
const upperMetrics = getBlockMetrics(upper)
expect(lower.position.y).toBeGreaterThanOrEqual(
upper.position.y + upperMetrics.height + DEFAULT_VERTICAL_SPACING
)
}
function createJiraBlock(
id: string,
operation: 'read' | 'write',
overrides: Partial<BlockState> = {}
): BlockState {
return createBlock(id, {
type: 'jira',
position: { x: 100, y: 100 },
height: 100,
layout: { measuredWidth: 250, measuredHeight: 100 },
subBlocks: {
operation: {
id: 'operation',
type: 'dropdown',
value: operation,
},
domain: {
id: 'domain',
type: 'short-input',
value: 'company.atlassian.net',
},
credential: {
id: 'credential',
type: 'oauth-input',
value: 'credential-1',
},
},
...overrides,
})
}
describe('applyTargetedLayout', () => {
beforeEach(() => {
mockGetBlock.mockImplementation((type: string) =>
type === 'jira' ? JIRA_TEST_BLOCK_CONFIG : undefined
)
})
it('shifts downstream frozen blocks when only shift sources are provided', () => {
const blocks = {
source: createBlock('source', {
position: { x: 100, y: 100 },
}),
target: createBlock('target', {
position: { x: 400, y: 100 },
}),
end: createBlock('end', {
position: { x: 760, y: 100 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
sourceHandle: 'source',
targetHandle: 'target',
},
{
id: 'edge-2',
source: 'target',
target: 'end',
sourceHandle: 'source',
targetHandle: 'target',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
expect(result.source.position).toEqual({ x: 100, y: 100 })
expect(result.target.position).toEqual({ x: 530, y: 100 })
expect(result.end.position).toEqual({ x: 960, y: 100 })
})
it('places new linear blocks without moving anchors', () => {
const blocks = {
anchor: createBlock('anchor', {
position: { x: 150, y: 150 },
}),
changed: createBlock('changed', {
position: { x: 0, y: 0 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'anchor',
target: 'changed',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['changed'],
})
expect(result.anchor.position).toEqual({ x: 150, y: 150 })
expect(result.changed.position.x).toBeGreaterThan(result.anchor.position.x)
expect(result.changed.position.y).toBe(result.anchor.position.y)
})
it('keeps root-level insertions closer to anchored blocks near the top of the canvas', () => {
const blocks = {
start: createBlock('start', {
position: { x: 0, y: 0 },
}),
changed: createBlock('changed', {
position: { x: 0, y: 0 },
}),
agent: createBlock('agent', {
position: { x: 410.94, y: 2.33 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'start',
target: 'changed',
},
{
id: 'edge-2',
source: 'changed',
target: 'agent',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['changed'],
})
expect(result.changed.position.y).toBeLessThan(150)
})
it('pushes frozen blocks below downstream nodes shifted into occupied columns', () => {
const blocks = {
start: createBlock('start', {
position: { x: 100, y: 100 },
}),
inserted: createBlock('inserted', {
position: { x: 0, y: 0 },
}),
end: createBlock('end', {
position: { x: 400, y: 100 },
}),
branch: createBlock('branch', {
position: { x: 990, y: 150 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'start',
target: 'inserted',
},
{
id: 'edge-2',
source: 'inserted',
target: 'end',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: ['inserted'],
})
expect(result.end.position).toEqual({ x: 960, y: 100 })
expectVerticalSeparation(result.end, result.branch)
})
it('repairs vertical overlaps during shift-only targeted layout passes', () => {
const blocks = {
source: createBlock('source', {
position: { x: 100, y: 100 },
}),
target: createBlock('target', {
position: { x: 400, y: 100 },
}),
sibling: createBlock('sibling', {
position: { x: 560, y: 150 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
expect(result.target.position).toEqual({ x: 530, y: 100 })
expectVerticalSeparation(result.target, result.sibling)
})
it('resolves same-column overlaps even when another column is interleaved in Y order', () => {
const blocks = {
source: createBlock('source', {
position: { x: 100, y: 100 },
}),
target: createBlock('target', {
position: { x: 400, y: 100 },
}),
blocker: createBlock('blocker', {
position: { x: 1500, y: 140 },
}),
sibling: createBlock('sibling', {
position: { x: 560, y: 160 },
}),
}
const edges: Edge[] = [
{
id: 'edge-1',
source: 'source',
target: 'target',
},
]
const result = applyTargetedLayout(blocks, edges, {
changedBlockIds: [],
shiftSourceBlockIds: ['source'],
})
expect(result.blocker.position).toEqual({ x: 1500, y: 140 })
expect(result.target.position).toEqual({ x: 530, y: 100 })
expectVerticalSeparation(result.target, result.sibling)
})
it('keeps resized integration blocks anchored while shifting frozen blocks below them', () => {
const blocks = {
above: createBlock('above', {
position: { x: 430, y: 460 },
}),
jira: createJiraBlock('jira', 'write', {
position: { x: 433, y: 690 },
}),
below: createBlock('below', {
position: { x: 460, y: 1120 },
}),
}
const result = applyTargetedLayout(blocks, [], {
changedBlockIds: [],
resizedBlockIds: ['jira'],
})
expect(result.above.position).toEqual({ x: 430, y: 460 })
expect(result.jira.position).toEqual({ x: 433, y: 690 })
expect(getBlockMetrics(result.jira).height).toBeGreaterThan(100)
expectVerticalSeparation(result.jira, result.below)
})
it('places new parallel children below tall anchored siblings', () => {
const blocks = {
parallel: createBlock('parallel', {
type: 'parallel',
position: { x: 200, y: 150 },
data: { width: 600, height: 500 },
layout: { measuredWidth: 600, measuredHeight: 500 },
}),
existing: createBlock('existing', {
position: { x: 180, y: 100 },
data: { parentId: 'parallel', extent: 'parent' },
layout: { measuredWidth: 250, measuredHeight: 220 },
height: 220,
}),
changed: createBlock('changed', {
position: { x: 0, y: 0 },
data: { parentId: 'parallel', extent: 'parent' },
}),
}
const result = applyTargetedLayout(blocks, [], {
changedBlockIds: ['changed'],
})
const existingMetrics = getBlockMetrics(result.existing)
expect(result.parallel.position).toEqual({ x: 200, y: 150 })
expect(result.changed.position.y).toBeGreaterThanOrEqual(
result.existing.position.y + existingMetrics.height
)
})
})
@@ -0,0 +1,544 @@
import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
CONTAINER_PADDING,
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
MAX_OVERLAP_ITERATIONS,
} from '@/lib/workflows/autolayout/constants'
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types'
import {
calculateSubflowDepths,
filterLayoutEligibleBlockIds,
getBlockMetrics,
getBlocksByParent,
hasFinitePosition,
prepareContainerDimensions,
resolveNoteOverlaps,
shouldSkipAutoLayout,
snapPositionToGrid,
} from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
type TargetedBlockInfo = {
id: string
block: BlockState
metrics: ReturnType<typeof getBlockMetrics>
}
export interface TargetedLayoutOptions extends LayoutOptions {
changedBlockIds: string[]
/** Existing blocks whose size changed but whose position should remain anchored. */
resizedBlockIds?: string[]
shiftSourceBlockIds?: string[]
verticalSpacing?: number
horizontalSpacing?: number
}
/**
* Applies targeted layout to only reposition changed blocks.
* Unchanged blocks act as anchors to preserve existing layout.
*/
export function applyTargetedLayout(
blocks: Record<string, BlockState>,
edges: Edge[],
options: TargetedLayoutOptions
): Record<string, BlockState> {
const {
changedBlockIds,
resizedBlockIds = [],
shiftSourceBlockIds = [],
verticalSpacing = DEFAULT_VERTICAL_SPACING,
horizontalSpacing = DEFAULT_HORIZONTAL_SPACING,
gridSize,
} = options
if (
(!changedBlockIds || changedBlockIds.length === 0) &&
resizedBlockIds.length === 0 &&
shiftSourceBlockIds.length === 0
) {
return blocks
}
const changedSet = new Set(changedBlockIds)
const resizedSet = new Set(resizedBlockIds)
const shiftSourceSet = new Set(shiftSourceBlockIds)
const blocksCopy: Record<string, BlockState> = structuredClone(blocks)
prepareContainerDimensions(
blocksCopy,
edges,
layoutBlocksCore,
horizontalSpacing,
verticalSpacing,
gridSize
)
const groups = getBlocksByParent(blocksCopy)
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
layoutGroup(
null,
groups.root,
blocksCopy,
edges,
changedSet,
resizedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
gridSize
)
for (const [parentId, childIds] of groups.children.entries()) {
layoutGroup(
parentId,
childIds,
blocksCopy,
edges,
changedSet,
resizedSet,
shiftSourceSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
gridSize
)
}
// Relocate notes only where this pass introduced an overlap, comparing against
// the original positions so pre-existing note arrangements are preserved.
resolveNoteOverlaps(blocksCopy, verticalSpacing, { previousBlocks: blocks })
return blocksCopy
}
/**
* Selects the best anchor block for offset computation.
* Prefers an upstream (predecessor) anchor over a downstream one because
* upstream blocks keep their layer assignment when new blocks are inserted
* after them, giving a stable offset. Downstream blocks shift to later
* layers in the ideal layout, producing a large incorrect offset.
*/
function selectBestAnchor(
eligibleIds: string[],
needsLayoutSet: Set<string>,
edges: Edge[],
layoutPositions: Map<string, { x: number; y: number }>
): string | undefined {
const candidates = eligibleIds.filter((id) => !needsLayoutSet.has(id) && layoutPositions.has(id))
if (candidates.length === 0) return undefined
if (candidates.length === 1) return candidates[0]
const candidateSet = new Set(candidates)
for (const edge of edges) {
if (needsLayoutSet.has(edge.target) && candidateSet.has(edge.source)) {
return edge.source
}
}
for (const edge of edges) {
if (needsLayoutSet.has(edge.source) && candidateSet.has(edge.target)) {
return edge.target
}
}
return candidates[0]
}
/**
* Layouts a group of blocks (either root level or within a container).
* Only repositions blocks in `changedSet` or those with invalid positions.
* Resized existing blocks remain anchored and instead drive shifts in nearby
* frozen blocks when their new dimensions create overlap.
*/
function layoutGroup(
parentId: string | null,
childIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
changedSet: Set<string>,
resizedSet: Set<string>,
shiftSourceSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
subflowDepths: Map<string, number>,
gridSize?: number
): void {
if (childIds.length === 0) return
const parentBlock = parentId ? blocks[parentId] : undefined
const layoutEligibleChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
if (layoutEligibleChildIds.length === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
const requestedLayout = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return changedSet.has(id)
})
const invalidPositions = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return !hasFinitePosition(block)
})
const needsLayoutSet = new Set([...requestedLayout, ...invalidPositions])
const needsLayout = Array.from(needsLayoutSet)
const resizedAnchorIds = layoutEligibleChildIds.filter((id) => resizedSet.has(id))
const groupShiftSourceIds = layoutEligibleChildIds.filter((id) => shiftSourceSet.has(id))
const activeShiftSourceSet = new Set([
...needsLayoutSet,
...resizedAnchorIds,
...groupShiftSourceIds,
])
if (needsLayout.length === 0 && activeShiftSourceSet.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
if (needsLayout.length > 0) {
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
if (!block) continue
oldPositions.set(id, { ...block.position })
}
const layoutPositions = computeLayoutPositions(
layoutEligibleChildIds,
blocks,
edges,
parentBlock,
horizontalSpacing,
verticalSpacing,
parentId === null ? subflowDepths : undefined,
gridSize
)
if (layoutPositions.size === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
let offsetX = 0
let offsetY = 0
const anchorId = selectBestAnchor(
layoutEligibleChildIds,
needsLayoutSet,
edges,
layoutPositions
)
if (anchorId) {
const oldPos = oldPositions.get(anchorId)
const newPos = layoutPositions.get(anchorId)
if (oldPos && newPos) {
offsetX = oldPos.x - newPos.x
offsetY = oldPos.y - newPos.y
}
}
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = snapPositionToGrid(
{ x: newPos.x + offsetX, y: newPos.y + offsetY },
gridSize
)
}
}
const shiftedFrozenIds = shiftDownstreamFrozenBlocks(
activeShiftSourceSet,
needsLayoutSet,
layoutEligibleChildIds,
blocks,
edges,
horizontalSpacing,
gridSize
)
const affectedBlockIds = new Set([...needsLayoutSet, ...resizedAnchorIds, ...shiftedFrozenIds])
if (affectedBlockIds.size > 0) {
resolveVerticalOverlapsWithFrozen(
affectedBlockIds,
layoutEligibleChildIds,
blocks,
verticalSpacing,
gridSize
)
}
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
}
/**
* Shifts frozen (unchanged) blocks rightward when a newly placed block
* overlaps with them in the X-axis. Traverses the DAG forward from changed
* blocks via BFS, cascading shifts through downstream frozen blocks so that
* insertions between existing layers push everything after them to the right.
*
* Only considers edges within the current layout group (scoped to subflow).
*/
function shiftDownstreamFrozenBlocks(
shiftSourceSet: Set<string>,
needsLayoutSet: Set<string>,
eligibleIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
horizontalSpacing: number,
gridSize?: number
): Set<string> {
const eligibleSet = new Set(eligibleIds)
const downstreamMap = new Map<string, string[]>()
for (const edge of edges) {
if (!eligibleSet.has(edge.source) || !eligibleSet.has(edge.target)) continue
if (!downstreamMap.has(edge.source)) downstreamMap.set(edge.source, [])
downstreamMap.get(edge.source)!.push(edge.target)
}
const shifted = new Set<string>()
const queue: string[] = Array.from(shiftSourceSet)
while (queue.length > 0) {
const sourceId = queue.shift()!
const sourceBlock = blocks[sourceId]
if (!sourceBlock) continue
const sourceMetrics = getBlockMetrics(sourceBlock)
const sourceRight = sourceBlock.position.x + sourceMetrics.width
const successors = downstreamMap.get(sourceId) || []
for (const targetId of successors) {
if (needsLayoutSet.has(targetId)) continue
if (shifted.has(targetId)) continue
const targetBlock = blocks[targetId]
if (!targetBlock) continue
if (targetBlock.position.x < sourceRight + horizontalSpacing) {
const shiftX = sourceRight + horizontalSpacing - targetBlock.position.x
targetBlock.position = snapPositionToGrid(
{ x: targetBlock.position.x + shiftX, y: targetBlock.position.y },
gridSize
)
shifted.add(targetId)
queue.push(targetId)
}
}
}
return shifted
}
/**
* Resolves Y-axis overlaps between changed/shifted blocks and frozen blocks
* that share the same column (overlapping X ranges). When a new block is
* inserted into the same layer as existing blocks (e.g. adding a parallel
* branch), this pushes frozen blocks downward to make room, cascading
* through any further blocks below.
*/
function resolveVerticalOverlapsWithFrozen(
affectedBlockIds: Set<string>,
eligibleIds: string[],
blocks: Record<string, BlockState>,
verticalSpacing: number,
gridSize?: number
): void {
const blockInfos = eligibleIds
.map((id) => {
const block = blocks[id]
if (!block) return null
return { id, block, metrics: getBlockMetrics(block) }
})
.filter((info): info is TargetedBlockInfo => info !== null)
if (blockInfos.length < 2 || affectedBlockIds.size === 0) return
const movedSet = new Set(affectedBlockIds)
let hasOverlap = true
let iteration = 0
while (hasOverlap && iteration < MAX_OVERLAP_ITERATIONS) {
hasOverlap = false
iteration++
blockInfos.sort((a, b) => a.block.position.y - b.block.position.y)
for (let i = 0; i < blockInfos.length - 1; i++) {
const upper = blockInfos[i]
for (let lowerIndex = i + 1; lowerIndex < blockInfos.length; lowerIndex++) {
const lower = blockInfos[lowerIndex]
if (!movedSet.has(upper.id) && !movedSet.has(lower.id)) continue
if (!blocksOverlapOnX(upper, lower)) continue
const requiredY = upper.block.position.y + upper.metrics.height + verticalSpacing
if (lower.block.position.y >= requiredY) continue
lower.block.position = snapPositionToGrid(
{ x: lower.block.position.x, y: requiredY },
gridSize
)
movedSet.add(lower.id)
reorderBlockInfoByY(blockInfos, lowerIndex)
hasOverlap = true
}
}
}
}
function blocksOverlapOnX(left: TargetedBlockInfo, right: TargetedBlockInfo): boolean {
const leftRight = left.block.position.x + left.metrics.width
const rightRight = right.block.position.x + right.metrics.width
return left.block.position.x < rightRight && right.block.position.x < leftRight
}
function reorderBlockInfoByY(blockInfos: TargetedBlockInfo[], fromIndex: number): void {
const [movedInfo] = blockInfos.splice(fromIndex, 1)
let insertIndex = fromIndex
while (
insertIndex < blockInfos.length &&
blockInfos[insertIndex].block.position.y < movedInfo.block.position.y
) {
insertIndex++
}
blockInfos.splice(insertIndex, 0, movedInfo)
}
/**
* Computes layout positions for a subset of blocks using the core layout function
*/
function computeLayoutPositions(
childIds: string[],
blocks: Record<string, BlockState>,
edges: Edge[],
parentBlock: BlockState | undefined,
horizontalSpacing: number,
verticalSpacing: number,
subflowDepths?: Map<string, number>,
gridSize?: number
): Map<string, { x: number; y: number }> {
const subsetBlocks: Record<string, BlockState> = {}
for (const id of childIds) {
subsetBlocks[id] = blocks[id]
}
const subsetEdges = edges.filter(
(edge) => childIds.includes(edge.source) && childIds.includes(edge.target)
)
if (Object.keys(subsetBlocks).length === 0) {
return new Map()
}
const isContainer = !!parentBlock
const { nodes, dimensions } = layoutBlocksCore(subsetBlocks, subsetEdges, {
isContainer,
layoutOptions: {
horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing,
verticalSpacing,
gridSize,
},
subflowDepths,
})
if (parentBlock) {
parentBlock.data = {
...parentBlock.data,
width: Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
height: Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
}
}
const positions = new Map<string, { x: number; y: number }>()
for (const node of nodes.values()) {
positions.set(node.id, { x: node.position.x, y: node.position.y })
}
return positions
}
/**
* Updates container dimensions based on children
*/
function updateContainerDimensions(
parentBlock: BlockState,
childIds: string[],
blocks: Record<string, BlockState>
): void {
if (childIds.length === 0) {
parentBlock.data = {
...parentBlock.data,
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
parentBlock.layout = {
...parentBlock.layout,
measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
return
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const id of childIds) {
const child = blocks[id]
if (!child) continue
if (shouldSkipAutoLayout(child)) {
continue
}
const metrics = getBlockMetrics(child)
minX = Math.min(minX, child.position.x)
minY = Math.min(minY, child.position.y)
maxX = Math.max(maxX, child.position.x + metrics.width)
maxY = Math.max(maxY, child.position.y + metrics.height)
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
return
}
const calculatedWidth = maxX - minX + CONTAINER_PADDING * 2
const calculatedHeight = maxY - minY + CONTAINER_PADDING * 2
parentBlock.data = {
...parentBlock.data,
width: Math.max(calculatedWidth, CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
height: Math.max(calculatedHeight, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
}
parentBlock.layout = {
...parentBlock.layout,
measuredWidth: parentBlock.data.width,
measuredHeight: parentBlock.data.height,
}
}
@@ -0,0 +1,54 @@
import type { BlockState, Position } from '@/stores/workflows/workflow/types'
export type { Edge } from 'reactflow'
export interface LayoutOptions {
horizontalSpacing?: number
verticalSpacing?: number
padding?: { x: number; y: number }
gridSize?: number
}
export interface LayoutResult {
blocks: Record<string, BlockState>
success: boolean
error?: string
}
export interface BlockMetrics {
width: number
height: number
minWidth: number
minHeight: number
paddingTop: number
paddingBottom: number
paddingLeft: number
paddingRight: number
}
export interface BoundingBox {
x: number
y: number
width: number
height: number
}
interface LayerInfo {
layer: number
order: number
}
export interface GraphNode {
id: string
block: BlockState
metrics: BlockMetrics
incoming: Set<string>
outgoing: Set<string>
layer: number
position: Position
}
interface AdjustmentOptions extends LayoutOptions {
preservePositions?: boolean
minimalShift?: boolean
}
@@ -0,0 +1,317 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_VERTICAL_SPACING } from '@/lib/workflows/autolayout/constants'
import { getBlockMetrics, resolveNoteOverlaps } from '@/lib/workflows/autolayout/utils'
import type { getBlock } from '@/blocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
const { mockGetBlock } = vi.hoisted(() => ({
mockGetBlock: vi.fn(),
}))
vi.mock('@/blocks', () => ({
getBlock: mockGetBlock,
}))
function createBlock(
id: string,
type: string,
position: { x: number; y: number },
overrides: Partial<BlockState> = {}
): BlockState {
return {
id,
type,
name: id,
position,
subBlocks: {},
outputs: {},
enabled: true,
layout: { measuredWidth: 250, measuredHeight: 120 },
...overrides,
} as BlockState
}
beforeEach(() => {
mockGetBlock.mockReturnValue(null)
})
describe('resolveNoteOverlaps', () => {
it('relocates a note that overlaps a laid-out block', () => {
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 160, y: 160 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING)
// Block is untouched; note is pushed below the block's bottom edge.
expect(blocks.a.position).toEqual({ x: 150, y: 150 })
expect(blocks.note.position.x).toBe(150)
expect(blocks.note.position.y).toBeGreaterThanOrEqual(150 + 120 + DEFAULT_VERTICAL_SPACING - 1)
})
it('leaves a note that does not overlap any block in place', () => {
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 2000, y: 2000 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING)
expect(blocks.note.position).toEqual({ x: 2000, y: 2000 })
})
it('stacks multiple overlapping notes without overlapping each other', () => {
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note1: createBlock(
'note1',
'note',
{ x: 150, y: 150 },
{
height: 100,
layout: { measuredHeight: 100 },
}
),
note2: createBlock(
'note2',
'note',
{ x: 200, y: 200 },
{
height: 100,
layout: { measuredHeight: 100 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING)
const n1 = blocks.note1.position
const n2 = blocks.note2.position
// Both relocated, stacked in reading order with no vertical overlap.
expect(n2.y).toBeGreaterThanOrEqual(n1.y + 100)
})
it('does nothing when there are no notes', () => {
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
b: createBlock('b', 'agent', { x: 500, y: 150 }),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING)
expect(blocks.a.position).toEqual({ x: 150, y: 150 })
expect(blocks.b.position).toEqual({ x: 500, y: 150 })
})
it('never produces non-finite coordinates when a block has a NaN position', () => {
const blocks: Record<string, BlockState> = {
bad: createBlock('bad', 'agent', { x: Number.NaN, y: Number.NaN }),
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 150, y: 150 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING)
// The corrupted block is ignored; the note still relocates off block "a"
// using only finite coordinates.
expect(Number.isFinite(blocks.note.position.x)).toBe(true)
expect(Number.isFinite(blocks.note.position.y)).toBe(true)
expect(blocks.note.position.x).toBe(150)
expect(blocks.note.position.y).toBeGreaterThan(150)
})
describe('targeted mode (previousBlocks)', () => {
it('relocates a note when a block was moved onto it', () => {
const previousBlocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 2000, y: 2000 }),
note: createBlock(
'note',
'note',
{ x: 150, y: 150 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
// Block "a" has been shifted onto the note by the layout pass.
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 150, y: 150 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING, { previousBlocks })
expect(blocks.note.position.x).toBe(150)
expect(blocks.note.position.y).toBeGreaterThan(150)
})
it('preserves a pre-existing overlap not introduced by this pass', () => {
// The note already overlapped block "a" before the pass; "a" did not move.
const previousBlocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 160, y: 160 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 160, y: 160 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING, { previousBlocks })
expect(blocks.note.position).toEqual({ x: 160, y: 160 })
})
it('relocates when a newly added block (no prior position) lands on a note', () => {
const previousBlocks: Record<string, BlockState> = {
note: createBlock(
'note',
'note',
{ x: 150, y: 150 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
const blocks: Record<string, BlockState> = {
a: createBlock('a', 'agent', { x: 150, y: 150 }),
note: createBlock(
'note',
'note',
{ x: 150, y: 150 },
{
height: 120,
layout: { measuredHeight: 120 },
}
),
}
resolveNoteOverlaps(blocks, DEFAULT_VERTICAL_SPACING, { previousBlocks })
expect(blocks.note.position.y).toBeGreaterThan(150)
})
})
})
describe('getBlockMetrics preview row estimation', () => {
/**
* Mirrors a block that spreads a trigger's subBlocks after its own,
* producing duplicate canonical pair entries with trigger/trigger-advanced
* modes (e.g. the Table block spreading the table_new_row trigger).
*/
const tableLikeConfig = {
category: 'blocks',
subBlocks: [
{ id: 'operation', title: 'Operation', type: 'dropdown' },
{
id: 'tableSelector',
title: 'Table',
type: 'table-selector',
mode: 'basic',
canonicalParamId: 'tableId',
},
{
id: 'manualTableId',
title: 'Table ID',
type: 'short-input',
mode: 'advanced',
canonicalParamId: 'tableId',
},
{ id: 'data', title: 'Row Data (JSON)', type: 'code' },
{
id: 'tableSelector',
title: 'Table',
type: 'table-selector',
mode: 'trigger',
canonicalParamId: 'tableId',
},
{
id: 'manualTableId',
title: 'Table ID',
type: 'short-input',
mode: 'trigger-advanced',
canonicalParamId: 'tableId',
},
{ id: 'eventType', title: 'Event', type: 'dropdown', mode: 'trigger' },
],
} as unknown as ReturnType<typeof getBlock>
function createTableBlock(canonicalMode: 'basic' | 'advanced'): BlockState {
return {
id: 'table-1',
type: 'table',
name: 'Table 1',
position: { x: 0, y: 0 },
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'insert_row' },
tableSelector: { id: 'tableSelector', type: 'table-selector', value: 'tbl_1' },
manualTableId: { id: 'manualTableId', type: 'short-input', value: 'tbl_1' },
},
outputs: {},
enabled: true,
data: { canonicalModes: { tableId: canonicalMode } },
} as unknown as BlockState
}
it('renders one row per canonical pair regardless of basic/advanced mode', () => {
mockGetBlock.mockReturnValue(tableLikeConfig)
const basic = getBlockMetrics(createTableBlock('basic'))
const advanced = getBlockMetrics(createTableBlock('advanced'))
expect(advanced.height).toBe(basic.height)
})
})
+748
View File
@@ -0,0 +1,748 @@
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
AUTO_LAYOUT_EXCLUDED_TYPES,
CONTAINER_BLOCK_TYPES,
CONTAINER_PADDING,
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
NOTE_BLOCK_TYPE,
ROOT_PADDING_X,
ROOT_PADDING_Y,
} from '@/lib/workflows/autolayout/constants'
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHidden,
isSubBlockVisibleForMode,
isTriggerModeSubBlock,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Resolves a potentially undefined numeric value to a fallback
*/
function resolveNumeric(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
/**
* Snaps a single coordinate value to the nearest grid position
*/
function snapToGrid(value: number, gridSize: number): number {
return Math.round(value / gridSize) * gridSize
}
/**
* Snaps a position to the nearest grid point.
* Returns the original position if gridSize is 0 or not provided.
*/
export function snapPositionToGrid(
position: { x: number; y: number },
gridSize: number | undefined
): { x: number; y: number } {
if (!gridSize || gridSize <= 0) {
return position
}
return {
x: snapToGrid(position.x, gridSize),
y: snapToGrid(position.y, gridSize),
}
}
/**
* Snaps all node positions in a graph to grid positions and returns updated dimensions.
* Returns null if gridSize is not set or no snapping was needed.
*/
export function snapNodesToGrid(
nodes: Map<string, GraphNode>,
gridSize: number | undefined
): { width: number; height: number } | null {
if (!gridSize || gridSize <= 0 || nodes.size === 0) {
return null
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const node of nodes.values()) {
node.position = snapPositionToGrid(node.position, gridSize)
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}
return {
width: maxX - minX + CONTAINER_PADDING * 2,
height: maxY - minY + CONTAINER_PADDING * 2,
}
}
/**
* Checks if a block type is a container (loop or parallel)
*/
export function isContainerType(blockType: string): boolean {
return CONTAINER_BLOCK_TYPES.has(blockType)
}
/**
* Checks if a block should be excluded from autolayout
*/
export function shouldSkipAutoLayout(block?: BlockState): boolean {
if (!block) return true
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
}
/**
* Filters block IDs to only include those eligible for layout
*/
export function filterLayoutEligibleBlockIds(
blockIds: string[],
blocks: Record<string, BlockState>
): string[] {
return blockIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return !shouldSkipAutoLayout(block)
})
}
/**
* Gets metrics for a container block
*/
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, CONTAINER_DIMENSIONS.DEFAULT_WIDTH)
)
const containerHeight = Math.max(
measuredHeight ?? 0,
resolveNumeric(block.data?.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT)
)
return {
width: containerWidth,
height: containerHeight,
minWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
minHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
paddingTop: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingBottom: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingLeft: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingRight: BLOCK_DIMENSIONS.HEADER_HEIGHT,
}
}
/**
* Counts visible preview subblocks using the same visibility rules as the
* workflow block preview when no DOM measurements are available.
*/
function getVisiblePreviewSubBlockCount(block: BlockState): number {
const blockConfig = getBlock(block.type)
if (!blockConfig?.subBlocks?.length) {
return Object.values(block.subBlocks || {}).filter((subBlock) => subBlock != null).length
}
const rawValues = buildSubBlockValues(block.subBlocks || {})
const canonicalModeOverrides =
typeof block.data?.canonicalModes === 'object' && block.data.canonicalModes !== null
? (block.data.canonicalModes as CanonicalModeOverrides)
: undefined
if (canonicalModeOverrides) {
rawValues.__canonicalModes = canonicalModeOverrides
}
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const effectiveAdvanced = Boolean(block.advancedMode)
const effectiveTrigger = Boolean(block.triggerMode)
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (isSubBlockHidden(subBlock)) return false
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? isTriggerModeSubBlock(subBlock) || !subBlock.mode
: isTriggerModeSubBlock(subBlock)
if (!isValidTriggerSubblock) {
return false
}
} else if (isTriggerModeSubBlock(subBlock)) {
return false
}
if (
!isSubBlockVisibleForMode(
subBlock,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
}).length
}
/**
* Estimates workflow block dimensions using the same deterministic row-based
* formula used by the canvas block renderer.
*/
function estimateWorkflowBlockDimensions(block: BlockState): { width: number; height: number } {
const blockConfig = getBlock(block.type)
const visibleCount = getVisiblePreviewSubBlockCount(block)
if (!blockConfig) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
visibleCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
}
return calculateWorkflowBlockDimensions({
blockType: block.type,
category: blockConfig.category,
displayTriggerMode: Boolean(block.triggerMode),
visibleSubBlockCount: visibleCount,
conditionRowCount:
block.type === 'condition'
? getConditionRows(block.id, block.subBlocks?.conditions?.value).length
: 0,
routerRowCount:
block.type === 'router_v2'
? getRouterRows(block.id, block.subBlocks?.routes?.value).length
: 0,
})
}
/**
* Gets metrics for a regular (non-container) block.
* Falls back to subblock-based height estimation when no measurement exists,
* which prevents overlaps for newly added blocks that haven't been rendered.
*/
function getRegularBlockMetrics(block: BlockState): BlockMetrics {
const minWidth = BLOCK_DIMENSIONS.FIXED_WIDTH
const minHeight = BLOCK_DIMENSIONS.MIN_HEIGHT
const measuredH = block.layout?.measuredHeight ?? block.height
const measuredW = block.layout?.measuredWidth
const estimatedDimensions = estimateWorkflowBlockDimensions(block)
const estimatedHeight = estimatedDimensions.height
const hasMeasurement = typeof measuredH === 'number' && measuredH > 0
const height = hasMeasurement ? Math.max(measuredH, estimatedHeight, minHeight) : estimatedHeight
const width = Math.max(measuredW ?? estimatedDimensions.width, minWidth)
return {
width,
height,
minWidth,
minHeight,
paddingTop: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingBottom: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingLeft: BLOCK_DIMENSIONS.HEADER_HEIGHT,
paddingRight: BLOCK_DIMENSIONS.HEADER_HEIGHT,
}
}
/**
* Gets the dimensions and metrics for a block
*/
export function getBlockMetrics(block: BlockState): BlockMetrics {
if (isContainerType(block.type)) {
return getContainerMetrics(block)
}
return getRegularBlockMetrics(block)
}
/**
* Prepares metrics for all nodes in a graph
*/
export function prepareBlockMetrics(nodes: Map<string, GraphNode>): void {
for (const node of nodes.values()) {
node.metrics = getBlockMetrics(node.block)
}
}
/**
* Creates a bounding box from position and dimensions
*/
export function createBoundingBox(
position: { x: number; y: number },
dimensions: Pick<BlockMetrics, 'width' | 'height'>
): BoundingBox {
return {
x: position.x,
y: position.y,
width: dimensions.width,
height: dimensions.height,
}
}
/**
* Checks if two bounding boxes overlap (with optional margin)
*/
export function boxesOverlap(box1: BoundingBox, box2: BoundingBox, margin = 0): boolean {
return !(
box1.x + box1.width + margin <= box2.x ||
box2.x + box2.width + margin <= box1.x ||
box1.y + box1.height + margin <= box2.y ||
box2.y + box2.height + margin <= box1.y
)
}
/**
* Resolves the on-canvas dimensions of a note block.
* Notes are fixed-width and use a deterministic height, but fall back to any
* stored measurement or data override when present.
*/
function getNoteDimensions(block: BlockState): { width: number; height: number } {
const width = Math.max(
resolveNumeric(block.data?.width, 0),
block.layout?.measuredWidth ?? 0,
BLOCK_DIMENSIONS.FIXED_WIDTH
)
const defaultHeight =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING +
BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT
const height = Math.max(
resolveNumeric(block.data?.height, 0),
block.layout?.measuredHeight ?? 0,
block.height ?? 0,
defaultHeight
)
return { width, height }
}
/**
* Checks if a block has a valid, finite position.
* Returns false for missing, undefined, NaN, or Infinity coordinates.
*/
export function hasFinitePosition(block: BlockState): boolean {
return Number.isFinite(block.position?.x) && Number.isFinite(block.position?.y)
}
/**
* Determines whether a note and a block overlapped at their pre-layout
* positions. Used by targeted layout to distinguish overlaps introduced by the
* current pass from arrangements that already existed.
*/
function noteOverlappedBlockBefore(
previousBlocks: Record<string, BlockState>,
noteId: string,
blockId: string
): boolean {
const previousNote = previousBlocks[noteId]
const previousBlock = previousBlocks[blockId]
if (!previousNote || !previousBlock) return false
// A block without a finite prior position was not yet placed on the canvas,
// so it could not have overlapped anything before this pass.
if (!hasFinitePosition(previousNote) || !hasFinitePosition(previousBlock)) return false
// Derive dimensions from the prior blocks so a resize between passes does not
// pair new dimensions with the old position.
const noteBox = createBoundingBox(previousNote.position, getNoteDimensions(previousNote))
const blockBox = createBoundingBox(previousBlock.position, getBlockMetrics(previousBlock))
return boxesOverlap(blockBox, noteBox)
}
export interface ResolveNoteOverlapsOptions {
/**
* Pre-layout block snapshot. When provided, only notes whose overlap with a
* block was *introduced* by the current pass are relocated (i.e. they overlap
* now but did not at these positions). Targeted layout passes this so that
* pre-existing note arrangements are preserved. When omitted (full
* auto-layout), any note overlapping a block is relocated.
*/
previousBlocks?: Record<string, BlockState>
}
/**
* Relocates note blocks that overlap the laid-out workflow blocks.
*
* Notes are excluded from the topological layout because they are free-form
* annotations, but repositioned workflow blocks can land on top of them. This
* pass keeps notes that already sit in clear space and stacks any relocated
* notes in a column beneath their parent group's blocks, preserving the notes'
* relative reading order. Notes are grouped by parent so notes inside a
* container are resolved against that container's children only.
*
* Placement is always computed against the full set of blocks and notes in the
* group, so a relocated note never collides with anything regardless of which
* overlaps triggered the relocation.
*/
export function resolveNoteOverlaps(
blocks: Record<string, BlockState>,
verticalSpacing: number,
options: ResolveNoteOverlapsOptions = {}
): void {
const { previousBlocks } = options
const { root, children } = getBlocksByParent(blocks)
const groups: string[][] = [root, ...Array.from(children.values())]
for (const groupIds of groups) {
const obstacles: Array<{ id: string; box: BoundingBox }> = []
const noteIds: string[] = []
let maxBottom = Number.NEGATIVE_INFINITY
let minX = Number.POSITIVE_INFINITY
for (const id of groupIds) {
const block = blocks[id]
// Skip non-finite positions so corrupted coordinates never propagate into
// minX / maxBottom (and therefore into relocated note positions).
if (!block || !hasFinitePosition(block)) continue
if (block.type === NOTE_BLOCK_TYPE) {
noteIds.push(id)
const { height } = getNoteDimensions(block)
maxBottom = Math.max(maxBottom, block.position.y + height)
continue
}
if (shouldSkipAutoLayout(block)) continue
const box = createBoundingBox(block.position, getBlockMetrics(block))
obstacles.push({ id, box })
maxBottom = Math.max(maxBottom, box.y + box.height)
minX = Math.min(minX, box.x)
}
if (noteIds.length === 0 || obstacles.length === 0) continue
noteIds.sort((a, b) => {
const posA = blocks[a].position
const posB = blocks[b].position
return posA.y - posB.y || posA.x - posB.x
})
let stackY = maxBottom + verticalSpacing
for (const id of noteIds) {
const note = blocks[id]
const dimensions = getNoteDimensions(note)
const noteBox = createBoundingBox(note.position, dimensions)
const needsRelocation = obstacles.some(({ id: blockId, box }) => {
if (!boxesOverlap(box, noteBox)) return false
if (previousBlocks) {
return !noteOverlappedBlockBefore(previousBlocks, id, blockId)
}
return true
})
if (!needsRelocation) continue
note.position = { x: minX, y: stackY }
stackY += dimensions.height + verticalSpacing
}
}
}
/**
* Groups blocks by their parent container
*/
export function getBlocksByParent(blocks: Record<string, BlockState>): {
root: string[]
children: Map<string, string[]>
} {
const root: string[] = []
const children = new Map<string, string[]>()
for (const [id, block] of Object.entries(blocks)) {
const parentId = block.data?.parentId
if (!parentId) {
root.push(id)
} else {
if (!children.has(parentId)) {
children.set(parentId, [])
}
children.get(parentId)!.push(id)
}
}
return { root, children }
}
/**
* Normalizes node positions to start from a given padding offset.
* Returns the bounding box dimensions of the normalized layout.
*/
export function normalizePositions(
nodes: Map<string, GraphNode>,
options: { isContainer: boolean }
): { width: number; height: number } {
if (nodes.size === 0) {
return { width: 0, height: 0 }
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const node of nodes.values()) {
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + node.metrics.width)
maxY = Math.max(maxY, node.position.y + node.metrics.height)
}
const paddingX = options.isContainer ? CONTAINER_PADDING_X : ROOT_PADDING_X
const paddingY = options.isContainer ? CONTAINER_PADDING_Y : ROOT_PADDING_Y
const xOffset = paddingX - minX
const yOffset = paddingY - minY
for (const node of nodes.values()) {
node.position = {
x: node.position.x + xOffset,
y: node.position.y + yOffset,
}
}
const width = maxX - minX + CONTAINER_PADDING * 2
const height = maxY - minY + CONTAINER_PADDING * 2
return { width, height }
}
/**
* Transfers block height measurements from source blocks to target blocks.
* Matches blocks by type:name key.
*/
export function transferBlockHeights(
sourceBlocks: Record<string, BlockState>,
targetBlocks: Record<string, BlockState>
): void {
// Build a map of block type+name to heights from source
const heightMap = new Map<string, { height: number; width: number }>()
for (const block of Object.values(sourceBlocks)) {
const key = `${block.type}:${block.name}`
heightMap.set(key, {
height: block.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
width: block.layout?.measuredWidth || BLOCK_DIMENSIONS.FIXED_WIDTH,
})
}
// Transfer heights to target blocks
for (const block of Object.values(targetBlocks)) {
const key = `${block.type}:${block.name}`
const measurements = heightMap.get(key)
if (measurements) {
block.height = measurements.height
if (!block.layout) {
block.layout = {}
}
block.layout.measuredHeight = measurements.height
block.layout.measuredWidth = measurements.width
}
}
}
/**
* Calculates the internal depth (max layer count) for each subflow container.
* Used to properly position blocks that connect after a subflow ends.
*
* @param blocks - All blocks in the workflow
* @param edges - All edges in the workflow
* @param assignLayersFn - Function to assign layers to blocks
* @returns Map of container block IDs to their internal layer depth
*/
export function calculateSubflowDepths(
blocks: Record<string, BlockState>,
edges: Edge[],
assignLayersFn: (blocks: Record<string, BlockState>, edges: Edge[]) => Map<string, GraphNode>
): Map<string, number> {
const depths = new Map<string, number>()
const { children } = getBlocksByParent(blocks)
for (const [containerId, childIds] of children.entries()) {
if (childIds.length === 0) {
depths.set(containerId, 1)
continue
}
const childBlocks: Record<string, BlockState> = {}
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
for (const childId of layoutChildIds) {
childBlocks[childId] = blocks[childId]
}
const childEdges = edges.filter(
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
)
if (Object.keys(childBlocks).length === 0) {
depths.set(containerId, 1)
continue
}
const childNodes = assignLayersFn(childBlocks, childEdges)
let maxLayer = 0
for (const node of childNodes.values()) {
maxLayer = Math.max(maxLayer, node.layer)
}
depths.set(containerId, Math.max(maxLayer + 1, 1))
}
return depths
}
/**
* Layout function type for preparing container dimensions.
* Returns laid out nodes and bounding dimensions.
*/
export type LayoutFunction = (
blocks: Record<string, BlockState>,
edges: Edge[],
options: {
isContainer: boolean
layoutOptions?: {
horizontalSpacing?: number
verticalSpacing?: number
padding?: { x: number; y: number }
gridSize?: number
}
subflowDepths?: Map<string, number>
}
) => { nodes: Map<string, GraphNode>; dimensions: { width: number; height: number } }
/**
* Pre-calculates container dimensions by laying out their children.
* Processes containers bottom-up to handle nested subflows correctly.
* This ensures accurate width/height values before root-level layout.
*
* @param blocks - All blocks in the workflow (will be mutated with updated dimensions)
* @param edges - All edges in the workflow
* @param layoutFn - The layout function to use for calculating dimensions
* @param horizontalSpacing - Horizontal spacing between blocks
* @param verticalSpacing - Vertical spacing between blocks
* @param gridSize - Optional grid size for snap-to-grid
*/
export function prepareContainerDimensions(
blocks: Record<string, BlockState>,
edges: Edge[],
layoutFn: LayoutFunction,
horizontalSpacing: number,
verticalSpacing: number,
gridSize?: number
): void {
const { children } = getBlocksByParent(blocks)
// Build dependency graph to process nested containers bottom-up
const containerIds = Array.from(children.keys())
const containerDepth = new Map<string, number>()
// Calculate nesting depth for each container
for (const containerId of containerIds) {
let depth = 0
let currentId: string | undefined = containerId
while (currentId) {
const block: BlockState | undefined = blocks[currentId]
const parentId: string | undefined = block?.data?.parentId
currentId = parentId
if (currentId) depth++
}
containerDepth.set(containerId, depth)
}
// Sort containers by depth (deepest first) for bottom-up processing
const sortedContainerIds = containerIds.sort((a, b) => {
const depthA = containerDepth.get(a) ?? 0
const depthB = containerDepth.get(b) ?? 0
return depthB - depthA
})
// Process each container, laying out its children to determine dimensions
for (const containerId of sortedContainerIds) {
const container = blocks[containerId]
if (!container) continue
const childIds = children.get(containerId) ?? []
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
if (layoutChildIds.length === 0) {
// Empty container - use default dimensions
container.data = {
...container.data,
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
container.layout = {
...container.layout,
measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
continue
}
// Build subset of blocks and edges for this container's children
const childBlocks: Record<string, BlockState> = {}
for (const childId of layoutChildIds) {
childBlocks[childId] = blocks[childId]
}
const childEdges = edges.filter(
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
)
// Layout children to get dimensions
const { dimensions } = layoutFn(childBlocks, childEdges, {
isContainer: true,
layoutOptions: {
horizontalSpacing: horizontalSpacing * 0.85,
verticalSpacing,
gridSize,
},
})
// Update container with calculated dimensions
const calculatedWidth = Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH)
const calculatedHeight = Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT)
container.data = {
...container.data,
width: calculatedWidth,
height: calculatedHeight,
}
container.layout = {
...container.layout,
measuredWidth: calculatedWidth,
measuredHeight: calculatedHeight,
}
}
}