Files
simstudioai--sim/apps/sim/lib/workflows/autolayout/change-set.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

226 lines
6.5 KiB
TypeScript

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',
])
}