fix(workflow): use panel-aware viewport center for paste and block placement (#3024)

This commit is contained in:
Waleed
2026-01-27 12:36:38 -08:00
committed by GitHub
parent be7f3db059
commit dddd0c8277
7 changed files with 419 additions and 264 deletions

View File

@@ -26,8 +26,9 @@ vi.mock('@/serializer', () => ({
Serializer: vi.fn(),
}))
vi.mock('@/stores/workflows/server-utils', () => ({
mergeSubblockState: vi.fn().mockReturnValue({}),
vi.mock('@/lib/workflows/subblocks', () => ({
mergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mergeSubBlockValues: vi.fn().mockReturnValue({}),
}))
const mockDecryptSecret = vi.fn()

View File

@@ -66,7 +66,6 @@ import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
@@ -99,26 +98,6 @@ const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
/**
* Gets the center of the current viewport in flow coordinates
*/
function getViewportCenter(
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
): { x: number; y: number } {
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
return screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
}
const rect = flowContainer.getBoundingClientRect()
return screenToFlowPosition({
x: rect.width / 2,
y: rect.height / 2,
})
}
/**
* Calculates the offset to paste blocks at viewport center
*/
@@ -126,7 +105,7 @@ function calculatePasteOffset(
clipboard: {
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }>
} | null,
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
viewportCenter: { x: number; y: number }
): { x: number; y: number } {
if (!clipboard) return DEFAULT_PASTE_OFFSET
@@ -155,8 +134,6 @@ function calculatePasteOffset(
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
const viewportCenter = getViewportCenter(screenToFlowPosition)
return {
x: viewportCenter.x - clipboardCenter.x,
y: viewportCenter.y - clipboardCenter.y,
@@ -266,7 +243,7 @@ const WorkflowContent = React.memo(() => {
const router = useRouter()
const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -338,8 +315,6 @@ const WorkflowContent = React.memo(() => {
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
// Permission config for invitation control
const { isInvitationsDisabled } = usePermissionConfig()
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
@@ -901,11 +876,125 @@ const WorkflowContent = React.memo(() => {
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
*/
const executePasteOperation = useCallback(
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
const pasteData = preparePasteData(pasteOffset)
(
operation: 'paste' | 'duplicate',
pasteOffset: { x: number; y: number },
targetContainer?: {
loopId: string
loopPosition: { x: number; y: number }
dimensions: { width: number; height: number }
} | null,
pasteTargetPosition?: { x: number; y: number }
) => {
// For context menu paste into a subflow, calculate offset to center blocks at click position
// Skip click-position centering if blocks came from inside a subflow (relative coordinates)
let effectiveOffset = pasteOffset
if (targetContainer && pasteTargetPosition && clipboard) {
const clipboardBlocks = Object.values(clipboard.blocks)
// Only use click-position centering for top-level blocks (absolute coordinates)
// Blocks with parentId have relative positions that can't be mixed with absolute click position
const hasNestedBlocks = clipboardBlocks.some((b) => b.data?.parentId)
if (clipboardBlocks.length > 0 && !hasNestedBlocks) {
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max(
...clipboardBlocks.map((b) => b.position.x + BLOCK_DIMENSIONS.FIXED_WIDTH)
)
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
const maxY = Math.max(
...clipboardBlocks.map((b) => b.position.y + BLOCK_DIMENSIONS.MIN_HEIGHT)
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
effectiveOffset = {
x: pasteTargetPosition.x - clipboardCenter.x,
y: pasteTargetPosition.y - clipboardCenter.y,
}
}
}
const pasteData = preparePasteData(effectiveOffset)
if (!pasteData) return
const pastedBlocksArray = Object.values(pasteData.blocks)
let pastedBlocksArray = Object.values(pasteData.blocks)
// If pasting into a subflow, adjust blocks to be children of that subflow
if (targetContainer) {
// Check if any pasted block is a trigger - triggers cannot be in subflows
const hasTrigger = pastedBlocksArray.some((b) => TriggerUtils.isTriggerBlock(b))
if (hasTrigger) {
addNotification({
level: 'error',
message: 'Triggers cannot be placed inside loop or parallel subflows.',
workflowId: activeWorkflowId || undefined,
})
return
}
// Check if any pasted block is a subflow - subflows cannot be nested
const hasSubflow = pastedBlocksArray.some((b) => b.type === 'loop' || b.type === 'parallel')
if (hasSubflow) {
addNotification({
level: 'error',
message: 'Subflows cannot be nested inside other subflows.',
workflowId: activeWorkflowId || undefined,
})
return
}
// Adjust each block's position to be relative to the container and set parentId
pastedBlocksArray = pastedBlocksArray.map((block) => {
// For blocks already nested (have parentId), positions are already relative - use as-is
// For top-level blocks, convert absolute position to relative by subtracting container position
const wasNested = Boolean(block.data?.parentId)
const relativePosition = wasNested
? { x: block.position.x, y: block.position.y }
: {
x: block.position.x - targetContainer.loopPosition.x,
y: block.position.y - targetContainer.loopPosition.y,
}
// Clamp position to keep block inside container (below header)
const clampedPosition = {
x: Math.max(
CONTAINER_DIMENSIONS.LEFT_PADDING,
Math.min(
relativePosition.x,
targetContainer.dimensions.width -
BLOCK_DIMENSIONS.FIXED_WIDTH -
CONTAINER_DIMENSIONS.RIGHT_PADDING
)
),
y: Math.max(
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
Math.min(
relativePosition.y,
targetContainer.dimensions.height -
BLOCK_DIMENSIONS.MIN_HEIGHT -
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
),
}
return {
...block,
position: clampedPosition,
data: {
...block.data,
parentId: targetContainer.loopId,
extent: 'parent',
},
}
})
// Update pasteData.blocks with the modified blocks
pasteData.blocks = pastedBlocksArray.reduce(
(acc, block) => {
acc[block.id] = block
return acc
},
{} as Record<string, (typeof pastedBlocksArray)[0]>
)
}
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
if (!validation.isValid) {
addNotification({
@@ -926,21 +1015,46 @@ const WorkflowContent = React.memo(() => {
pasteData.parallels,
pasteData.subBlockValues
)
// Resize container if we pasted into a subflow
if (targetContainer) {
resizeLoopNodesWrapper()
}
},
[
preparePasteData,
blocks,
clipboard,
addNotification,
activeWorkflowId,
collaborativeBatchAddBlocks,
setPendingSelection,
resizeLoopNodesWrapper,
]
)
const handleContextPaste = useCallback(() => {
if (!hasClipboard()) return
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
// Convert context menu position to flow coordinates and check if inside a subflow
const flowPosition = screenToFlowPosition(contextMenuPosition)
const targetContainer = isPointInLoopNode(flowPosition)
executePasteOperation(
'paste',
calculatePasteOffset(clipboard, getViewportCenter()),
targetContainer,
flowPosition // Pass the click position so blocks are centered at where user right-clicked
)
}, [
hasClipboard,
executePasteOperation,
clipboard,
getViewportCenter,
screenToFlowPosition,
contextMenuPosition,
isPointInLoopNode,
])
const handleContextDuplicate = useCallback(() => {
copyBlocks(contextMenuBlocks.map((b) => b.id))
@@ -1006,10 +1120,6 @@ const WorkflowContent = React.memo(() => {
setIsChatOpen(!isChatOpen)
}, [])
const handleContextInvite = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-invite-modal'))
}, [])
useEffect(() => {
let cleanup: (() => void) | null = null
@@ -1054,7 +1164,7 @@ const WorkflowContent = React.memo(() => {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault()
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
executePasteOperation('paste', calculatePasteOffset(clipboard, getViewportCenter()))
}
}
}
@@ -1074,7 +1184,7 @@ const WorkflowContent = React.memo(() => {
hasClipboard,
effectivePermissions.canEdit,
clipboard,
screenToFlowPosition,
getViewportCenter,
executePasteOperation,
])
@@ -1507,7 +1617,7 @@ const WorkflowContent = React.memo(() => {
if (!type) return
if (type === 'connectionBlock') return
const basePosition = getViewportCenter(screenToFlowPosition)
const basePosition = getViewportCenter()
if (type === 'loop' || type === 'parallel') {
const id = crypto.randomUUID()
@@ -1576,7 +1686,7 @@ const WorkflowContent = React.memo(() => {
)
}
}, [
screenToFlowPosition,
getViewportCenter,
blocks,
addBlock,
effectivePermissions.canEdit,

View File

@@ -57,31 +57,16 @@ function getVisibleCanvasBounds(): VisibleBounds {
* Gets the center of the visible canvas in screen coordinates.
*/
function getVisibleCanvasCenter(): { x: number; y: number } {
const style = getComputedStyle(document.documentElement)
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
const bounds = getVisibleCanvasBounds()
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
const visibleHeight = window.innerHeight - terminalHeight
return {
x: sidebarWidth + visibleWidth / 2,
y: visibleHeight / 2,
}
}
const rect = flowContainer.getBoundingClientRect()
// Calculate actual visible area in screen coordinates
const visibleLeft = Math.max(rect.left, sidebarWidth)
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
const rect = flowContainer?.getBoundingClientRect()
const containerLeft = rect?.left ?? 0
const containerTop = rect?.top ?? 0
return {
x: (visibleLeft + visibleRight) / 2,
y: (rect.top + visibleBottom) / 2,
x: containerLeft + bounds.offsetLeft + bounds.width / 2,
y: containerTop + bounds.height / 2,
}
}

View File

@@ -14,6 +14,7 @@ import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { Executor } from '@/executor'
@@ -26,7 +27,6 @@ import type {
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('ExecutionCore')
@@ -172,8 +172,7 @@ export async function executeWorkflowCore(
logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`)
}
// Merge block states
const mergedStates = mergeSubblockState(blocks)
const mergedStates = mergeSubblockStateWithValues(blocks)
const personalEnvUserId =
metadata.isClientSession && metadata.sessionUserId

View File

@@ -1,52 +0,0 @@
/**
* Server-Safe Workflow Utilities
*
* This file contains workflow utility functions that can be safely imported
* by server-side API routes without causing client/server boundary violations.
*
* Unlike the main utils.ts file, this does NOT import any client-side stores
* or React hooks, making it safe for use in Next.js API routes.
*/
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Server-safe version of mergeSubblockState for API routes
*
* Merges workflow block states with provided subblock values while maintaining block structure.
* This version takes explicit subblock values instead of reading from client stores.
*
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated values
*/
export function mergeSubblockState(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Record<string, BlockState> {
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
}
/**
* Server-safe async version of mergeSubblockState for API routes
*
* Asynchronously merges workflow block states with provided subblock values.
* This version takes explicit subblock values instead of reading from client stores.
*
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Promise resolving to merged block states with updated values
*/
export async function mergeSubblockStateAsync(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Promise<Record<string, BlockState>> {
// Since we're not reading from client stores, we can just return the sync version
// The async nature was only needed for the client-side store operations
return mergeSubblockState(blocks, subBlockValues, blockId)
}

View File

@@ -7,7 +7,7 @@ import {
} from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants'
import { getUniqueBlockName } from './utils'
import { getUniqueBlockName, regenerateBlockIds } from './utils'
describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => {
@@ -223,3 +223,213 @@ describe('getUniqueBlockName', () => {
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
})
})
describe('regenerateBlockIds', () => {
const positionOffset = { x: 50, y: 50 }
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBe(loopId)
expect(duplicatedBlock.data?.extent).toBe('parent')
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
})
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
const nonExistentParentId = 'non-existent-loop'
const childId = 'child-1'
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: nonExistentParentId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBeUndefined()
expect(duplicatedBlock.data?.extent).toBeUndefined()
})
it('should remap parentId when copying both parent and child together', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const blocksToCopy = {
[loopId]: createLoopBlock({
id: loopId,
name: 'Loop 1',
position: { x: 200, y: 200 },
}),
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
const newLoop = newBlocks.find((b) => b.type === 'loop')
const newChild = newBlocks.find((b) => b.type === 'agent')
expect(newLoop).toBeDefined()
expect(newChild).toBeDefined()
expect(newChild!.data?.parentId).toBe(newLoop!.id)
expect(newChild!.data?.extent).toBe('parent')
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
expect(newChild!.position).toEqual({ x: 100, y: 50 })
})
it('should apply offset to top-level blocks', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
})
it('should generate unique names for duplicated blocks', () => {
const blockId = 'block-1'
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 2')
})
it('should ignore large viewport offset for blocks inside existing subflows', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const largeViewportOffset = { x: 2000, y: 1500 }
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
largeViewportOffset,
existingBlocks,
getUniqueBlockName
)
const duplicatedBlock = Object.values(result.blocks)[0]
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId)
})
})

View File

@@ -1,7 +1,8 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
@@ -16,7 +17,8 @@ import type {
} from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
/** Threshold to detect viewport-based offsets vs small duplicate offsets */
const LARGE_OFFSET_THRESHOLD = 300
/**
* Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger)
@@ -204,64 +206,6 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
}
}
export interface PrepareDuplicateBlockStateOptions {
sourceBlock: BlockState
newId: string
newName: string
positionOffset: { x: number; y: number }
subBlockValues: Record<string, unknown>
}
/**
* Prepares a BlockState for duplicating an existing block.
* Copies block structure and subblock values, excluding webhook fields.
*/
export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOptions): {
block: BlockState
subBlockValues: Record<string, unknown>
} {
const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options
const filteredSubBlockValues = Object.fromEntries(
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in baseSubBlocks) {
delete baseSubBlocks[field]
}
})
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
string,
SubBlockState
>
const block: BlockState = {
id: newId,
type: sourceBlock.type,
name: newName,
position: {
x: sourceBlock.position.x + positionOffset.x,
y: sourceBlock.position.y + positionOffset.y,
},
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
subBlocks: mergedSubBlocks,
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
enabled: sourceBlock.enabled ?? true,
horizontalHandles: sourceBlock.horizontalHandles ?? true,
advancedMode: sourceBlock.advancedMode ?? false,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height || 0,
}
return { block, subBlockValues: filteredSubBlockValues }
}
/**
* Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store
@@ -348,78 +292,6 @@ export function mergeSubblockState(
)
}
/**
* Asynchronously merges workflow block states with subblock values
* Ensures all values are properly resolved before returning
*
* @param blocks - Block configurations from workflow store
* @param workflowId - ID of the workflow to merge values for
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Promise resolving to merged block states with updated values
*/
export async function mergeSubblockStateAsync(
blocks: Record<string, BlockState>,
workflowId?: string,
blockId?: string
): Promise<Record<string, BlockState>> {
const subBlockStore = useSubBlockStore.getState()
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
// Process blocks in parallel for better performance
const processedBlockEntries = await Promise.all(
Object.entries(blocksToProcess).map(async ([id, block]) => {
// Skip if block is undefined or doesn't have subBlocks
if (!block || !block.subBlocks) {
return [id, block] as const
}
// Process all subblocks in parallel
const subBlockEntries = await Promise.all(
Object.entries(block.subBlocks).map(async ([subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
return null
}
const storedValue = subBlockStore.getValue(id, subBlockId)
return [
subBlockId,
{
...subBlock,
value: (storedValue !== undefined && storedValue !== null
? storedValue
: subBlock.value) as SubBlockState['value'],
},
] as const
})
)
// Convert entries back to an object
const mergedSubBlocks = Object.fromEntries(
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
) as Record<string, SubBlockState>
// Return the full block state with updated subBlocks (including orphaned values)
return [
id,
{
...block,
subBlocks: mergedSubBlocks,
},
] as const
})
)
return Object.fromEntries(processedBlockEntries) as Record<string, BlockState>
}
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
if (typeof value === 'string') {
let updatedValue = value
@@ -444,14 +316,10 @@ function updateValueReferences(value: unknown, nameMap: Map<string, string>): un
function updateBlockReferences(
blocks: Record<string, BlockState>,
idMap: Map<string, string>,
nameMap: Map<string, string>,
clearTriggerRuntimeValues = false
): void {
Object.entries(blocks).forEach(([_, block]) => {
// NOTE: parentId remapping is handled in regenerateBlockIds' second pass.
// Do NOT remap parentId here as it would incorrectly clear already-mapped IDs.
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
@@ -533,7 +401,7 @@ export function regenerateWorkflowIds(
})
}
updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues)
updateBlockReferences(newBlocks, nameMap, clearTriggerRuntimeValues)
return {
blocks: newBlocks,
@@ -574,13 +442,36 @@ export function regenerateBlockIds(
const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName)
// Check if this block has a parent that's also being copied
// If so, it's a nested block and should keep its relative position (no offset)
// Only top-level blocks (no parent in the paste set) get the position offset
// Determine position offset based on parent relationship:
// 1. Parent also being copied: keep exact relative position (parent itself will be offset)
// 2. Parent exists in existing workflow: use provided offset, but cap large viewport-based
// offsets since they don't make sense for relative positions
// 3. Top-level block (no parent): apply full paste offset
const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId]
const newPosition = hasParentInPasteSet
? { x: block.position.x, y: block.position.y } // Keep relative position
: { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y }
const hasParentInExistingWorkflow =
block.data?.parentId && existingBlockNames[block.data.parentId]
let newPosition: Position
if (hasParentInPasteSet) {
// Parent also being copied - keep exact relative position
newPosition = { x: block.position.x, y: block.position.y }
} else if (hasParentInExistingWorkflow) {
// Block stays in existing subflow - use provided offset unless it's viewport-based (large)
const isLargeOffset =
Math.abs(positionOffset.x) > LARGE_OFFSET_THRESHOLD ||
Math.abs(positionOffset.y) > LARGE_OFFSET_THRESHOLD
const effectiveOffset = isLargeOffset ? DEFAULT_DUPLICATE_OFFSET : positionOffset
newPosition = {
x: block.position.x + effectiveOffset.x,
y: block.position.y + effectiveOffset.y,
}
} else {
// Top-level block - apply full paste offset
newPosition = {
x: block.position.x + positionOffset.x,
y: block.position.y + positionOffset.y,
}
}
// Placeholder block - we'll update parentId in second pass
const newBlock: BlockState = {
@@ -602,19 +493,30 @@ export function regenerateBlockIds(
})
// Second pass: update parentId references for nested blocks
// If a block's parent is also being pasted, map to new parentId; otherwise clear it
// If a block's parent is also being pasted, map to new parentId
// If parent exists in existing workflow, keep the original parentId (block stays in same subflow)
// Otherwise clear the parentId
Object.entries(newBlocks).forEach(([, block]) => {
if (block.data?.parentId) {
const oldParentId = block.data.parentId
const newParentId = blockIdMap.get(oldParentId)
if (newParentId) {
// Parent is being pasted - map to new parent ID
block.data = {
...block.data,
parentId: newParentId,
extent: 'parent',
}
} else if (existingBlockNames[oldParentId]) {
// Parent exists in existing workflow - keep original parentId (block stays in same subflow)
block.data = {
...block.data,
parentId: oldParentId,
extent: 'parent',
}
} else {
// Parent doesn't exist anywhere - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
@@ -647,7 +549,7 @@ export function regenerateBlockIds(
}
})
updateBlockReferences(newBlocks, blockIdMap, nameMap, false)
updateBlockReferences(newBlocks, nameMap, false)
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
Object.keys(blockValues).forEach((subBlockId) => {