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
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user