Skip to content

Commit dddd0c8

Browse files
authored
fix(workflow): use panel-aware viewport center for paste and block placement (#3024)
1 parent be7f3db commit dddd0c8

File tree

7 files changed

+419
-264
lines changed

7 files changed

+419
-264
lines changed

apps/sim/app/api/chat/utils.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ vi.mock('@/serializer', () => ({
2626
Serializer: vi.fn(),
2727
}))
2828

29-
vi.mock('@/stores/workflows/server-utils', () => ({
30-
mergeSubblockState: vi.fn().mockReturnValue({}),
29+
vi.mock('@/lib/workflows/subblocks', () => ({
30+
mergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
31+
mergeSubBlockValues: vi.fn().mockReturnValue({}),
3132
}))
3233

3334
const mockDecryptSecret = vi.fn()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 150 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
6666
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
6767
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
6868
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
69-
import { usePermissionConfig } from '@/hooks/use-permission-config'
7069
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
7170
import { useCanvasModeStore } from '@/stores/canvas-mode'
7271
import { useChatStore } from '@/stores/chat/store'
@@ -99,34 +98,14 @@ const logger = createLogger('Workflow')
9998

10099
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
101100

102-
/**
103-
* Gets the center of the current viewport in flow coordinates
104-
*/
105-
function getViewportCenter(
106-
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
107-
): { x: number; y: number } {
108-
const flowContainer = document.querySelector('.react-flow')
109-
if (!flowContainer) {
110-
return screenToFlowPosition({
111-
x: window.innerWidth / 2,
112-
y: window.innerHeight / 2,
113-
})
114-
}
115-
const rect = flowContainer.getBoundingClientRect()
116-
return screenToFlowPosition({
117-
x: rect.width / 2,
118-
y: rect.height / 2,
119-
})
120-
}
121-
122101
/**
123102
* Calculates the offset to paste blocks at viewport center
124103
*/
125104
function calculatePasteOffset(
126105
clipboard: {
127106
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }>
128107
} | null,
129-
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
108+
viewportCenter: { x: number; y: number }
130109
): { x: number; y: number } {
131110
if (!clipboard) return DEFAULT_PASTE_OFFSET
132111

@@ -155,8 +134,6 @@ function calculatePasteOffset(
155134
)
156135
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
157136

158-
const viewportCenter = getViewportCenter(screenToFlowPosition)
159-
160137
return {
161138
x: viewportCenter.x - clipboardCenter.x,
162139
y: viewportCenter.y - clipboardCenter.y,
@@ -266,7 +243,7 @@ const WorkflowContent = React.memo(() => {
266243
const router = useRouter()
267244
const reactFlowInstance = useReactFlow()
268245
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
269-
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
246+
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
270247
const { emitCursorUpdate } = useSocket()
271248

272249
const workspaceId = params.workspaceId as string
@@ -338,8 +315,6 @@ const WorkflowContent = React.memo(() => {
338315
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
339316
const isChatOpen = useChatStore((state) => state.isChatOpen)
340317

341-
// Permission config for invitation control
342-
const { isInvitationsDisabled } = usePermissionConfig()
343318
const snapGrid: [number, number] = useMemo(
344319
() => [snapToGridSize, snapToGridSize],
345320
[snapToGridSize]
@@ -901,11 +876,125 @@ const WorkflowContent = React.memo(() => {
901876
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
902877
*/
903878
const executePasteOperation = useCallback(
904-
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
905-
const pasteData = preparePasteData(pasteOffset)
879+
(
880+
operation: 'paste' | 'duplicate',
881+
pasteOffset: { x: number; y: number },
882+
targetContainer?: {
883+
loopId: string
884+
loopPosition: { x: number; y: number }
885+
dimensions: { width: number; height: number }
886+
} | null,
887+
pasteTargetPosition?: { x: number; y: number }
888+
) => {
889+
// For context menu paste into a subflow, calculate offset to center blocks at click position
890+
// Skip click-position centering if blocks came from inside a subflow (relative coordinates)
891+
let effectiveOffset = pasteOffset
892+
if (targetContainer && pasteTargetPosition && clipboard) {
893+
const clipboardBlocks = Object.values(clipboard.blocks)
894+
// Only use click-position centering for top-level blocks (absolute coordinates)
895+
// Blocks with parentId have relative positions that can't be mixed with absolute click position
896+
const hasNestedBlocks = clipboardBlocks.some((b) => b.data?.parentId)
897+
if (clipboardBlocks.length > 0 && !hasNestedBlocks) {
898+
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
899+
const maxX = Math.max(
900+
...clipboardBlocks.map((b) => b.position.x + BLOCK_DIMENSIONS.FIXED_WIDTH)
901+
)
902+
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
903+
const maxY = Math.max(
904+
...clipboardBlocks.map((b) => b.position.y + BLOCK_DIMENSIONS.MIN_HEIGHT)
905+
)
906+
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
907+
effectiveOffset = {
908+
x: pasteTargetPosition.x - clipboardCenter.x,
909+
y: pasteTargetPosition.y - clipboardCenter.y,
910+
}
911+
}
912+
}
913+
914+
const pasteData = preparePasteData(effectiveOffset)
906915
if (!pasteData) return
907916

908-
const pastedBlocksArray = Object.values(pasteData.blocks)
917+
let pastedBlocksArray = Object.values(pasteData.blocks)
918+
919+
// If pasting into a subflow, adjust blocks to be children of that subflow
920+
if (targetContainer) {
921+
// Check if any pasted block is a trigger - triggers cannot be in subflows
922+
const hasTrigger = pastedBlocksArray.some((b) => TriggerUtils.isTriggerBlock(b))
923+
if (hasTrigger) {
924+
addNotification({
925+
level: 'error',
926+
message: 'Triggers cannot be placed inside loop or parallel subflows.',
927+
workflowId: activeWorkflowId || undefined,
928+
})
929+
return
930+
}
931+
932+
// Check if any pasted block is a subflow - subflows cannot be nested
933+
const hasSubflow = pastedBlocksArray.some((b) => b.type === 'loop' || b.type === 'parallel')
934+
if (hasSubflow) {
935+
addNotification({
936+
level: 'error',
937+
message: 'Subflows cannot be nested inside other subflows.',
938+
workflowId: activeWorkflowId || undefined,
939+
})
940+
return
941+
}
942+
943+
// Adjust each block's position to be relative to the container and set parentId
944+
pastedBlocksArray = pastedBlocksArray.map((block) => {
945+
// For blocks already nested (have parentId), positions are already relative - use as-is
946+
// For top-level blocks, convert absolute position to relative by subtracting container position
947+
const wasNested = Boolean(block.data?.parentId)
948+
const relativePosition = wasNested
949+
? { x: block.position.x, y: block.position.y }
950+
: {
951+
x: block.position.x - targetContainer.loopPosition.x,
952+
y: block.position.y - targetContainer.loopPosition.y,
953+
}
954+
955+
// Clamp position to keep block inside container (below header)
956+
const clampedPosition = {
957+
x: Math.max(
958+
CONTAINER_DIMENSIONS.LEFT_PADDING,
959+
Math.min(
960+
relativePosition.x,
961+
targetContainer.dimensions.width -
962+
BLOCK_DIMENSIONS.FIXED_WIDTH -
963+
CONTAINER_DIMENSIONS.RIGHT_PADDING
964+
)
965+
),
966+
y: Math.max(
967+
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
968+
Math.min(
969+
relativePosition.y,
970+
targetContainer.dimensions.height -
971+
BLOCK_DIMENSIONS.MIN_HEIGHT -
972+
CONTAINER_DIMENSIONS.BOTTOM_PADDING
973+
)
974+
),
975+
}
976+
977+
return {
978+
...block,
979+
position: clampedPosition,
980+
data: {
981+
...block.data,
982+
parentId: targetContainer.loopId,
983+
extent: 'parent',
984+
},
985+
}
986+
})
987+
988+
// Update pasteData.blocks with the modified blocks
989+
pasteData.blocks = pastedBlocksArray.reduce(
990+
(acc, block) => {
991+
acc[block.id] = block
992+
return acc
993+
},
994+
{} as Record<string, (typeof pastedBlocksArray)[0]>
995+
)
996+
}
997+
909998
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
910999
if (!validation.isValid) {
9111000
addNotification({
@@ -926,21 +1015,46 @@ const WorkflowContent = React.memo(() => {
9261015
pasteData.parallels,
9271016
pasteData.subBlockValues
9281017
)
1018+
1019+
// Resize container if we pasted into a subflow
1020+
if (targetContainer) {
1021+
resizeLoopNodesWrapper()
1022+
}
9291023
},
9301024
[
9311025
preparePasteData,
9321026
blocks,
1027+
clipboard,
9331028
addNotification,
9341029
activeWorkflowId,
9351030
collaborativeBatchAddBlocks,
9361031
setPendingSelection,
1032+
resizeLoopNodesWrapper,
9371033
]
9381034
)
9391035

9401036
const handleContextPaste = useCallback(() => {
9411037
if (!hasClipboard()) return
942-
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
943-
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
1038+
1039+
// Convert context menu position to flow coordinates and check if inside a subflow
1040+
const flowPosition = screenToFlowPosition(contextMenuPosition)
1041+
const targetContainer = isPointInLoopNode(flowPosition)
1042+
1043+
executePasteOperation(
1044+
'paste',
1045+
calculatePasteOffset(clipboard, getViewportCenter()),
1046+
targetContainer,
1047+
flowPosition // Pass the click position so blocks are centered at where user right-clicked
1048+
)
1049+
}, [
1050+
hasClipboard,
1051+
executePasteOperation,
1052+
clipboard,
1053+
getViewportCenter,
1054+
screenToFlowPosition,
1055+
contextMenuPosition,
1056+
isPointInLoopNode,
1057+
])
9441058

9451059
const handleContextDuplicate = useCallback(() => {
9461060
copyBlocks(contextMenuBlocks.map((b) => b.id))
@@ -1006,10 +1120,6 @@ const WorkflowContent = React.memo(() => {
10061120
setIsChatOpen(!isChatOpen)
10071121
}, [])
10081122

1009-
const handleContextInvite = useCallback(() => {
1010-
window.dispatchEvent(new CustomEvent('open-invite-modal'))
1011-
}, [])
1012-
10131123
useEffect(() => {
10141124
let cleanup: (() => void) | null = null
10151125

@@ -1054,7 +1164,7 @@ const WorkflowContent = React.memo(() => {
10541164
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
10551165
if (effectivePermissions.canEdit && hasClipboard()) {
10561166
event.preventDefault()
1057-
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
1167+
executePasteOperation('paste', calculatePasteOffset(clipboard, getViewportCenter()))
10581168
}
10591169
}
10601170
}
@@ -1074,7 +1184,7 @@ const WorkflowContent = React.memo(() => {
10741184
hasClipboard,
10751185
effectivePermissions.canEdit,
10761186
clipboard,
1077-
screenToFlowPosition,
1187+
getViewportCenter,
10781188
executePasteOperation,
10791189
])
10801190

@@ -1507,7 +1617,7 @@ const WorkflowContent = React.memo(() => {
15071617
if (!type) return
15081618
if (type === 'connectionBlock') return
15091619

1510-
const basePosition = getViewportCenter(screenToFlowPosition)
1620+
const basePosition = getViewportCenter()
15111621

15121622
if (type === 'loop' || type === 'parallel') {
15131623
const id = crypto.randomUUID()
@@ -1576,7 +1686,7 @@ const WorkflowContent = React.memo(() => {
15761686
)
15771687
}
15781688
}, [
1579-
screenToFlowPosition,
1689+
getViewportCenter,
15801690
blocks,
15811691
addBlock,
15821692
effectivePermissions.canEdit,

apps/sim/hooks/use-canvas-viewport.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,31 +57,16 @@ function getVisibleCanvasBounds(): VisibleBounds {
5757
* Gets the center of the visible canvas in screen coordinates.
5858
*/
5959
function getVisibleCanvasCenter(): { x: number; y: number } {
60-
const style = getComputedStyle(document.documentElement)
61-
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
62-
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
63-
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
60+
const bounds = getVisibleCanvasBounds()
6461

6562
const flowContainer = document.querySelector('.react-flow')
66-
if (!flowContainer) {
67-
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
68-
const visibleHeight = window.innerHeight - terminalHeight
69-
return {
70-
x: sidebarWidth + visibleWidth / 2,
71-
y: visibleHeight / 2,
72-
}
73-
}
74-
75-
const rect = flowContainer.getBoundingClientRect()
76-
77-
// Calculate actual visible area in screen coordinates
78-
const visibleLeft = Math.max(rect.left, sidebarWidth)
79-
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
80-
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
63+
const rect = flowContainer?.getBoundingClientRect()
64+
const containerLeft = rect?.left ?? 0
65+
const containerTop = rect?.top ?? 0
8166

8267
return {
83-
x: (visibleLeft + visibleRight) / 2,
84-
y: (rect.top + visibleBottom) / 2,
68+
x: containerLeft + bounds.offsetLeft + bounds.width / 2,
69+
y: containerTop + bounds.height / 2,
8570
}
8671
}
8772

apps/sim/lib/workflows/executor/execution-core.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
loadDeployedWorkflowState,
1515
loadWorkflowFromNormalizedTables,
1616
} from '@/lib/workflows/persistence/utils'
17+
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
1718
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
1819
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
1920
import { Executor } from '@/executor'
@@ -26,7 +27,6 @@ import type {
2627
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
2728
import { hasExecutionResult } from '@/executor/utils/errors'
2829
import { Serializer } from '@/serializer'
29-
import { mergeSubblockState } from '@/stores/workflows/server-utils'
3030

3131
const logger = createLogger('ExecutionCore')
3232

@@ -172,8 +172,7 @@ export async function executeWorkflowCore(
172172
logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`)
173173
}
174174

175-
// Merge block states
176-
const mergedStates = mergeSubblockState(blocks)
175+
const mergedStates = mergeSubblockStateWithValues(blocks)
177176

178177
const personalEnvUserId =
179178
metadata.isClientSession && metadata.sessionUserId

0 commit comments

Comments
 (0)