Skip to content

Commit 87d5074

Browse files
committed
feat(copy-paste): allow cross workflow selection, paste, move for blocks
1 parent 2cfd75a commit 87d5074

File tree

10 files changed

+823
-186
lines changed

10 files changed

+823
-186
lines changed

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

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,13 @@ const edgeTypes: EdgeTypes = {
9393
/** ReactFlow configuration constants. */
9494
const defaultEdgeOptions = { type: 'custom' }
9595

96-
/** Tailwind classes for ReactFlow internal element styling */
9796
const reactFlowStyles = [
98-
// Z-index layering
9997
'[&_.react-flow__edges]:!z-0',
10098
'[&_.react-flow__node]:!z-[21]',
10199
'[&_.react-flow__handle]:!z-[30]',
102100
'[&_.react-flow__edge-labels]:!z-[60]',
103-
// Light mode: transparent pane to show dots
104101
'[&_.react-flow__pane]:!bg-transparent',
105102
'[&_.react-flow__renderer]:!bg-transparent',
106-
// Dark mode: solid background, hide dots
107103
'dark:[&_.react-flow__pane]:!bg-[var(--bg)]',
108104
'dark:[&_.react-flow__renderer]:!bg-[var(--bg)]',
109105
'dark:[&_.react-flow__background]:hidden',
@@ -151,12 +147,23 @@ const WorkflowContent = React.memo(() => {
151147

152148
const addNotification = useNotificationStore((state) => state.addNotification)
153149

154-
const { workflows, activeWorkflowId, hydration, setActiveWorkflow } = useWorkflowRegistry(
150+
const {
151+
workflows,
152+
activeWorkflowId,
153+
hydration,
154+
setActiveWorkflow,
155+
copyBlocks,
156+
preparePasteData,
157+
hasClipboard,
158+
} = useWorkflowRegistry(
155159
useShallow((state) => ({
156160
workflows: state.workflows,
157161
activeWorkflowId: state.activeWorkflowId,
158162
hydration: state.hydration,
159163
setActiveWorkflow: state.setActiveWorkflow,
164+
copyBlocks: state.copyBlocks,
165+
preparePasteData: state.preparePasteData,
166+
hasClipboard: state.hasClipboard,
160167
}))
161168
)
162169

@@ -340,12 +347,20 @@ const WorkflowContent = React.memo(() => {
340347
collaborativeAddEdge: addEdge,
341348
collaborativeRemoveBlock: removeBlock,
342349
collaborativeRemoveEdge: removeEdge,
343-
collaborativeUpdateBlockPosition,
350+
collaborativeBatchUpdatePositions,
344351
collaborativeUpdateParentId: updateParentId,
352+
collaborativePasteBlocks,
345353
undo,
346354
redo,
347355
} = useCollaborativeWorkflow()
348356

357+
const updateBlockPosition = useCallback(
358+
(id: string, position: { x: number; y: number }) => {
359+
collaborativeBatchUpdatePositions([{ id, position }])
360+
},
361+
[collaborativeBatchUpdatePositions]
362+
)
363+
349364
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
350365
useShallow((state) => ({
351366
activeBlockIds: state.activeBlockIds,
@@ -419,7 +434,7 @@ const WorkflowContent = React.memo(() => {
419434
const result = updateNodeParentUtil(
420435
nodeId,
421436
newParentId,
422-
collaborativeUpdateBlockPosition,
437+
updateBlockPosition,
423438
updateParentId,
424439
() => resizeLoopNodesWrapper()
425440
)
@@ -443,7 +458,7 @@ const WorkflowContent = React.memo(() => {
443458
},
444459
[
445460
getNodes,
446-
collaborativeUpdateBlockPosition,
461+
updateBlockPosition,
447462
updateParentId,
448463
blocks,
449464
edgesForDisplay,
@@ -495,6 +510,20 @@ const WorkflowContent = React.memo(() => {
495510
) {
496511
event.preventDefault()
497512
redo()
513+
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
514+
const selectedNodes = getNodes().filter((node) => node.selected)
515+
if (selectedNodes.length > 0) {
516+
event.preventDefault()
517+
copyBlocks(selectedNodes.map((node) => node.id))
518+
}
519+
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
520+
if (effectivePermissions.canEdit && hasClipboard()) {
521+
event.preventDefault()
522+
const pasteData = preparePasteData()
523+
if (pasteData) {
524+
collaborativePasteBlocks(pasteData)
525+
}
526+
}
498527
}
499528
}
500529

@@ -504,7 +533,17 @@ const WorkflowContent = React.memo(() => {
504533
window.removeEventListener('keydown', handleKeyDown)
505534
if (cleanup) cleanup()
506535
}
507-
}, [debouncedAutoLayout, undo, redo])
536+
}, [
537+
debouncedAutoLayout,
538+
undo,
539+
redo,
540+
getNodes,
541+
copyBlocks,
542+
preparePasteData,
543+
collaborativePasteBlocks,
544+
hasClipboard,
545+
effectivePermissions.canEdit,
546+
])
508547

509548
/**
510549
* Removes all edges connected to a block, skipping individual edge recording for undo/redo.
@@ -1673,21 +1712,12 @@ const WorkflowContent = React.memo(() => {
16731712
missingParentId: parentId,
16741713
})
16751714

1676-
// Fix the node by removing its parent reference and calculating absolute position
16771715
const absolutePosition = getNodeAbsolutePosition(id)
1678-
1679-
// Update the node to remove parent reference and use absolute position
1680-
collaborativeUpdateBlockPosition(id, absolutePosition)
1716+
updateBlockPosition(id, absolutePosition)
16811717
updateParentId(id, '', 'parent')
16821718
}
16831719
})
1684-
}, [
1685-
blocks,
1686-
collaborativeUpdateBlockPosition,
1687-
updateParentId,
1688-
getNodeAbsolutePosition,
1689-
isWorkflowReady,
1690-
])
1720+
}, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition, isWorkflowReady])
16911721

16921722
/** Handles edge removal changes. */
16931723
const onEdgesChange = useCallback(
@@ -2095,9 +2125,7 @@ const WorkflowContent = React.memo(() => {
20952125
}
20962126
}
20972127

2098-
// Emit collaborative position update for the final position
2099-
// This ensures other users see the smooth final position
2100-
collaborativeUpdateBlockPosition(node.id, finalPosition, true)
2128+
updateBlockPosition(node.id, finalPosition)
21012129

21022130
// Record single move entry on drag end to avoid micro-moves
21032131
const start = getDragStartPosition()
@@ -2218,7 +2246,7 @@ const WorkflowContent = React.memo(() => {
22182246
dragStartParentId,
22192247
potentialParentId,
22202248
updateNodeParent,
2221-
collaborativeUpdateBlockPosition,
2249+
updateBlockPosition,
22222250
addEdge,
22232251
tryCreateAutoConnectEdge,
22242252
blocks,
@@ -2232,7 +2260,45 @@ const WorkflowContent = React.memo(() => {
22322260
]
22332261
)
22342262

2235-
/** Clears edge selection and panel state when clicking empty canvas. */
2263+
const onSelectionDragStop = useCallback(
2264+
(_event: React.MouseEvent, nodes: any[]) => {
2265+
if (nodes.length === 0) return
2266+
2267+
const positionUpdates = nodes.map((node) => {
2268+
const currentBlock = blocks[node.id]
2269+
const currentParentId = currentBlock?.data?.parentId
2270+
let finalPosition = node.position
2271+
2272+
if (currentParentId) {
2273+
const parentNode = getNodes().find((n) => n.id === currentParentId)
2274+
if (parentNode) {
2275+
const containerDimensions = {
2276+
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
2277+
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
2278+
}
2279+
const blockDimensions = {
2280+
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
2281+
height: Math.max(
2282+
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
2283+
BLOCK_DIMENSIONS.MIN_HEIGHT
2284+
),
2285+
}
2286+
finalPosition = clampPositionToContainer(
2287+
node.position,
2288+
containerDimensions,
2289+
blockDimensions
2290+
)
2291+
}
2292+
}
2293+
2294+
return { id: node.id, position: finalPosition }
2295+
})
2296+
2297+
collaborativeBatchUpdatePositions(positionUpdates)
2298+
},
2299+
[blocks, getNodes, collaborativeBatchUpdatePositions]
2300+
)
2301+
22362302
const onPaneClick = useCallback(() => {
22372303
setSelectedEdgeInfo(null)
22382304
usePanelEditorStore.getState().clearCurrentBlock()
@@ -2390,15 +2456,14 @@ const WorkflowContent = React.memo(() => {
23902456
proOptions={reactFlowProOptions}
23912457
connectionLineStyle={connectionLineStyle}
23922458
connectionLineType={ConnectionLineType.SmoothStep}
2393-
onNodeClick={(e, _node) => {
2394-
e.stopPropagation()
2395-
}}
23962459
onPaneClick={onPaneClick}
23972460
onEdgeClick={onEdgeClick}
23982461
onPointerMove={handleCanvasPointerMove}
23992462
onPointerLeave={handleCanvasPointerLeave}
24002463
elementsSelectable={true}
2401-
selectNodesOnDrag={false}
2464+
selectionOnDrag={true}
2465+
panOnDrag={[1, 2]}
2466+
multiSelectionKeyCode={['Meta', 'Control']}
24022467
nodesConnectable={effectivePermissions.canEdit}
24032468
nodesDraggable={effectivePermissions.canEdit}
24042469
draggable={false}
@@ -2408,6 +2473,7 @@ const WorkflowContent = React.memo(() => {
24082473
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
24092474
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
24102475
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
2476+
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
24112477
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
24122478
snapToGrid={snapToGrid}
24132479
snapGrid={snapGrid}

0 commit comments

Comments
 (0)