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
226 lines
6.5 KiB
TypeScript
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',
|
|
])
|
|
}
|