Skip to content

Commit e0cced0

Browse files
committed
fix(workflow): use panel-aware viewport center for paste and block placement
1 parent 46ba315 commit e0cced0

File tree

7 files changed

+409
-264
lines changed

7 files changed

+409
-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: 142 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,117 @@ 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+
let effectiveOffset = pasteOffset
891+
if (targetContainer && pasteTargetPosition && clipboard) {
892+
const clipboardBlocks = Object.values(clipboard.blocks)
893+
if (clipboardBlocks.length > 0) {
894+
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
895+
const maxX = Math.max(
896+
...clipboardBlocks.map((b) => b.position.x + BLOCK_DIMENSIONS.FIXED_WIDTH)
897+
)
898+
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
899+
const maxY = Math.max(
900+
...clipboardBlocks.map((b) => b.position.y + BLOCK_DIMENSIONS.MIN_HEIGHT)
901+
)
902+
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
903+
effectiveOffset = {
904+
x: pasteTargetPosition.x - clipboardCenter.x,
905+
y: pasteTargetPosition.y - clipboardCenter.y,
906+
}
907+
}
908+
}
909+
910+
const pasteData = preparePasteData(effectiveOffset)
906911
if (!pasteData) return
907912

908-
const pastedBlocksArray = Object.values(pasteData.blocks)
913+
let pastedBlocksArray = Object.values(pasteData.blocks)
914+
915+
// If pasting into a subflow, adjust blocks to be children of that subflow
916+
if (targetContainer) {
917+
// Check if any pasted block is a trigger - triggers cannot be in subflows
918+
const hasTrigger = pastedBlocksArray.some((b) => TriggerUtils.isTriggerBlock(b))
919+
if (hasTrigger) {
920+
addNotification({
921+
level: 'error',
922+
message: 'Triggers cannot be placed inside loop or parallel subflows.',
923+
workflowId: activeWorkflowId || undefined,
924+
})
925+
return
926+
}
927+
928+
// Check if any pasted block is a subflow - subflows cannot be nested
929+
const hasSubflow = pastedBlocksArray.some((b) => b.type === 'loop' || b.type === 'parallel')
930+
if (hasSubflow) {
931+
addNotification({
932+
level: 'error',
933+
message: 'Subflows cannot be nested inside other subflows.',
934+
workflowId: activeWorkflowId || undefined,
935+
})
936+
return
937+
}
938+
939+
// Adjust each block's position to be relative to the container and set parentId
940+
pastedBlocksArray = pastedBlocksArray.map((block) => {
941+
// Convert absolute position to relative position within the container
942+
const relativePosition = {
943+
x: block.position.x - targetContainer.loopPosition.x,
944+
y: block.position.y - targetContainer.loopPosition.y,
945+
}
946+
947+
// Clamp position to keep block inside container
948+
const clampedPosition = {
949+
x: Math.max(
950+
CONTAINER_DIMENSIONS.LEFT_PADDING,
951+
Math.min(
952+
relativePosition.x,
953+
targetContainer.dimensions.width -
954+
BLOCK_DIMENSIONS.FIXED_WIDTH -
955+
CONTAINER_DIMENSIONS.RIGHT_PADDING
956+
)
957+
),
958+
y: Math.max(
959+
CONTAINER_DIMENSIONS.TOP_PADDING,
960+
Math.min(
961+
relativePosition.y,
962+
targetContainer.dimensions.height -
963+
BLOCK_DIMENSIONS.MIN_HEIGHT -
964+
CONTAINER_DIMENSIONS.BOTTOM_PADDING
965+
)
966+
),
967+
}
968+
969+
return {
970+
...block,
971+
position: clampedPosition,
972+
data: {
973+
...block.data,
974+
parentId: targetContainer.loopId,
975+
extent: 'parent',
976+
},
977+
}
978+
})
979+
980+
// Update pasteData.blocks with the modified blocks
981+
pasteData.blocks = pastedBlocksArray.reduce(
982+
(acc, block) => {
983+
acc[block.id] = block
984+
return acc
985+
},
986+
{} as Record<string, (typeof pastedBlocksArray)[0]>
987+
)
988+
}
989+
909990
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
910991
if (!validation.isValid) {
911992
addNotification({
@@ -926,21 +1007,46 @@ const WorkflowContent = React.memo(() => {
9261007
pasteData.parallels,
9271008
pasteData.subBlockValues
9281009
)
1010+
1011+
// Resize container if we pasted into a subflow
1012+
if (targetContainer) {
1013+
resizeLoopNodesWrapper()
1014+
}
9291015
},
9301016
[
9311017
preparePasteData,
9321018
blocks,
1019+
clipboard,
9331020
addNotification,
9341021
activeWorkflowId,
9351022
collaborativeBatchAddBlocks,
9361023
setPendingSelection,
1024+
resizeLoopNodesWrapper,
9371025
]
9381026
)
9391027

9401028
const handleContextPaste = useCallback(() => {
9411029
if (!hasClipboard()) return
942-
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
943-
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
1030+
1031+
// Convert context menu position to flow coordinates and check if inside a subflow
1032+
const flowPosition = screenToFlowPosition(contextMenuPosition)
1033+
const targetContainer = isPointInLoopNode(flowPosition)
1034+
1035+
executePasteOperation(
1036+
'paste',
1037+
calculatePasteOffset(clipboard, getViewportCenter()),
1038+
targetContainer,
1039+
flowPosition // Pass the click position so blocks are centered at where user right-clicked
1040+
)
1041+
}, [
1042+
hasClipboard,
1043+
executePasteOperation,
1044+
clipboard,
1045+
getViewportCenter,
1046+
screenToFlowPosition,
1047+
contextMenuPosition,
1048+
isPointInLoopNode,
1049+
])
9441050

9451051
const handleContextDuplicate = useCallback(() => {
9461052
copyBlocks(contextMenuBlocks.map((b) => b.id))
@@ -1006,10 +1112,6 @@ const WorkflowContent = React.memo(() => {
10061112
setIsChatOpen(!isChatOpen)
10071113
}, [])
10081114

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

@@ -1054,7 +1156,7 @@ const WorkflowContent = React.memo(() => {
10541156
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
10551157
if (effectivePermissions.canEdit && hasClipboard()) {
10561158
event.preventDefault()
1057-
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
1159+
executePasteOperation('paste', calculatePasteOffset(clipboard, getViewportCenter()))
10581160
}
10591161
}
10601162
}
@@ -1074,7 +1176,7 @@ const WorkflowContent = React.memo(() => {
10741176
hasClipboard,
10751177
effectivePermissions.canEdit,
10761178
clipboard,
1077-
screenToFlowPosition,
1179+
getViewportCenter,
10781180
executePasteOperation,
10791181
])
10801182

@@ -1507,7 +1609,7 @@ const WorkflowContent = React.memo(() => {
15071609
if (!type) return
15081610
if (type === 'connectionBlock') return
15091611

1510-
const basePosition = getViewportCenter(screenToFlowPosition)
1612+
const basePosition = getViewportCenter()
15111613

15121614
if (type === 'loop' || type === 'parallel') {
15131615
const id = crypto.randomUUID()
@@ -1576,7 +1678,7 @@ const WorkflowContent = React.memo(() => {
15761678
)
15771679
}
15781680
}, [
1579-
screenToFlowPosition,
1681+
getViewportCenter,
15801682
blocks,
15811683
addBlock,
15821684
effectivePermissions.canEdit,

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

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,31 +57,14 @@ 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 containerTop = flowContainer?.getBoundingClientRect().top ?? 0
8164

8265
return {
83-
x: (visibleLeft + visibleRight) / 2,
84-
y: (rect.top + visibleBottom) / 2,
66+
x: bounds.offsetLeft + bounds.width / 2,
67+
y: containerTop + bounds.height / 2,
8568
}
8669
}
8770

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)