From dddd0c82776fbcc068684081e00c42f06e5da446 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 27 Jan 2026 12:36:38 -0800 Subject: [PATCH] fix(workflow): use panel-aware viewport center for paste and block placement (#3024) --- apps/sim/app/api/chat/utils.test.ts | 5 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 190 ++++++++++++---- apps/sim/hooks/use-canvas-viewport.ts | 27 +-- .../lib/workflows/executor/execution-core.ts | 5 +- apps/sim/stores/workflows/server-utils.ts | 52 ----- apps/sim/stores/workflows/utils.test.ts | 212 +++++++++++++++++- apps/sim/stores/workflows/utils.ts | 192 ++++------------ 7 files changed, 419 insertions(+), 264 deletions(-) delete mode 100644 apps/sim/stores/workflows/server-utils.ts diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index b6678fb53..f76ff1cd9 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 030781c4e..22fe3c8ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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 } | 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 + ) + } + 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, diff --git a/apps/sim/hooks/use-canvas-viewport.ts b/apps/sim/hooks/use-canvas-viewport.ts index 1e1cfba51..a3ff474bf 100644 --- a/apps/sim/hooks/use-canvas-viewport.ts +++ b/apps/sim/hooks/use-canvas-viewport.ts @@ -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, } } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index c2b300f08..6dd91bde7 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -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 diff --git a/apps/sim/stores/workflows/server-utils.ts b/apps/sim/stores/workflows/server-utils.ts deleted file mode 100644 index 5e8fa19f0..000000000 --- a/apps/sim/stores/workflows/server-utils.ts +++ /dev/null @@ -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, - subBlockValues: Record> = {}, - blockId?: string -): Record { - 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, - subBlockValues: Record> = {}, - blockId?: string -): Promise> { - // 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) -} diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index a8ea48cef..c82d1a712 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -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) + }) +}) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 60e69f9e7..22531fd4b 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -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 -} - -/** - * 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 -} { - const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options - - const filteredSubBlockValues = Object.fromEntries( - Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key)) - ) - - const baseSubBlocks: Record = 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, - workflowId?: string, - blockId?: string -): Promise> { - 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 - - // Return the full block state with updated subBlocks (including orphaned values) - return [ - id, - { - ...block, - subBlocks: mergedSubBlocks, - }, - ] as const - }) - ) - - return Object.fromEntries(processedBlockEntries) as Record -} - function updateValueReferences(value: unknown, nameMap: Map): unknown { if (typeof value === 'string') { let updatedValue = value @@ -444,14 +316,10 @@ function updateValueReferences(value: unknown, nameMap: Map): un function updateBlockReferences( blocks: Record, - idMap: Map, nameMap: Map, 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) => {