Skip to content

Commit e590c00

Browse files
committed
feat(workflow): open command palette on empty edge drop and auto-connect created node
1 parent d824ce5 commit e590c00

File tree

1 file changed

+161
-16
lines changed
  • apps/sim/app/workspace/[workspaceId]/w/[workflowId]

1 file changed

+161
-16
lines changed

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

Lines changed: 161 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@ interface BlockData {
222222
position: { x: number; y: number }
223223
}
224224

225+
interface PendingConnectionContext {
226+
sourceNodeId: string
227+
sourceHandleId?: string
228+
dropPosition: { x: number; y: number }
229+
}
230+
225231
/**
226232
* Main workflow canvas content component.
227233
* Renders the ReactFlow canvas with blocks, edges, and all interactive features.
@@ -254,6 +260,7 @@ const WorkflowContent = React.memo(() => {
254260
const workflowIdParam = params.workflowId as string
255261

256262
const addNotification = useNotificationStore((state) => state.addNotification)
263+
const isSearchModalOpen = useSearchModalStore((state) => state.isOpen)
257264

258265
useEffect(() => {
259266
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
@@ -506,11 +513,36 @@ const WorkflowContent = React.memo(() => {
506513
)
507514

508515
/** Stores source node/handle info when a connection drag starts for drop-on-block detection. */
509-
const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null)
516+
const connectionSourceRef = useRef<{ nodeId: string; handleId?: string } | null>(null)
510517

511518
/** Tracks whether onConnect successfully handled the connection (ReactFlow pattern). */
512519
const connectionCompletedRef = useRef(false)
513520

521+
/** Stores pending context for edge-drag-to-create flow until a block is selected. */
522+
const pendingConnectionContextRef = useRef<PendingConnectionContext | null>(null)
523+
524+
const setPendingConnectionContext = useCallback((context: PendingConnectionContext) => {
525+
pendingConnectionContextRef.current = context
526+
}, [])
527+
528+
const clearPendingConnectionContext = useCallback(() => {
529+
pendingConnectionContextRef.current = null
530+
}, [])
531+
532+
const consumePendingConnectionContext = useCallback(() => {
533+
const context = pendingConnectionContextRef.current
534+
pendingConnectionContextRef.current = null
535+
return context
536+
}, [])
537+
538+
const wasSearchModalOpenRef = useRef(isSearchModalOpen)
539+
useEffect(() => {
540+
if (wasSearchModalOpenRef.current && !isSearchModalOpen) {
541+
clearPendingConnectionContext()
542+
}
543+
wasSearchModalOpenRef.current = isSearchModalOpen
544+
}, [isSearchModalOpen, clearPendingConnectionContext])
545+
514546
/** Stores start positions for multi-node drag undo/redo recording. */
515547
const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>(
516548
new Map()
@@ -1444,6 +1476,69 @@ const WorkflowContent = React.memo(() => {
14441476
[]
14451477
)
14461478

1479+
/**
1480+
* Creates an explicit edge from a pending edge-drag context to a newly created block.
1481+
* Returns undefined if the connection would violate subflow boundary rules.
1482+
*/
1483+
const createPendingConnectionEdge = useCallback(
1484+
(
1485+
pendingConnection: PendingConnectionContext,
1486+
targetBlockId: string,
1487+
targetParentId: string | null
1488+
): Edge | undefined => {
1489+
const sourceBlock = blocks[pendingConnection.sourceNodeId]
1490+
if (!sourceBlock) return undefined
1491+
1492+
const sourceHandle =
1493+
pendingConnection.sourceHandleId ||
1494+
determineSourceHandle({
1495+
id: pendingConnection.sourceNodeId,
1496+
type: sourceBlock.type,
1497+
})
1498+
1499+
const sourceParentId =
1500+
sourceBlock.data?.parentId ||
1501+
(sourceHandle === 'loop-start-source' || sourceHandle === 'parallel-start-source'
1502+
? pendingConnection.sourceNodeId
1503+
: undefined)
1504+
1505+
const normalizedTargetParentId = targetParentId || undefined
1506+
1507+
// Mirror onConnect container-boundary rules for edge-drag-to-create flow.
1508+
if (
1509+
(sourceParentId && !normalizedTargetParentId) ||
1510+
(!sourceParentId && normalizedTargetParentId) ||
1511+
(sourceParentId && normalizedTargetParentId && sourceParentId !== normalizedTargetParentId)
1512+
) {
1513+
addNotification({
1514+
level: 'info',
1515+
message: 'Block added, but connection was skipped due to subflow boundary rules.',
1516+
workflowId: activeWorkflowId || undefined,
1517+
})
1518+
return undefined
1519+
}
1520+
1521+
const isInsideContainer = Boolean(sourceParentId) || Boolean(normalizedTargetParentId)
1522+
const parentId = sourceParentId || normalizedTargetParentId
1523+
1524+
return {
1525+
id: crypto.randomUUID(),
1526+
source: pendingConnection.sourceNodeId,
1527+
sourceHandle,
1528+
target: targetBlockId,
1529+
targetHandle: 'target',
1530+
type: 'workflowEdge',
1531+
data: isInsideContainer
1532+
? {
1533+
parentId,
1534+
isInsideContainer,
1535+
}
1536+
: undefined,
1537+
}
1538+
},
1539+
[blocks, determineSourceHandle, addNotification, activeWorkflowId]
1540+
)
1541+
14471542
/** Gets the appropriate start handle for a container node (loop or parallel). */
14481543
const getContainerStartHandle = useCallback(
14491544
(containerId: string): string => {
@@ -1600,7 +1695,15 @@ const WorkflowContent = React.memo(() => {
16001695
* @param position - Drop position in ReactFlow coordinates.
16011696
*/
16021697
const handleToolbarDrop = useCallback(
1603-
(data: { type: string; enableTriggerMode?: boolean }, position: { x: number; y: number }) => {
1698+
(
1699+
data: {
1700+
type: string
1701+
enableTriggerMode?: boolean
1702+
presetOperation?: string
1703+
pendingConnectionContext?: PendingConnectionContext
1704+
},
1705+
position: { x: number; y: number }
1706+
) => {
16041707
if (!data.type || data.type === 'connectionBlock') return
16051708

16061709
try {
@@ -1614,9 +1717,11 @@ const WorkflowContent = React.memo(() => {
16141717
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
16151718
const name = getUniqueBlockName(baseName, blocks)
16161719

1617-
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
1618-
targetParentId: null,
1619-
})
1720+
const autoConnectEdge = data.pendingConnectionContext
1721+
? createPendingConnectionEdge(data.pendingConnectionContext, id, null)
1722+
: tryCreateAutoConnectEdge(position, id, {
1723+
targetParentId: null,
1724+
})
16201725

16211726
addBlock(
16221727
id,
@@ -1684,11 +1789,13 @@ const WorkflowContent = React.memo(() => {
16841789
.filter((b) => b.data?.parentId === containerInfo.loopId)
16851790
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
16861791

1687-
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
1688-
targetParentId: containerInfo.loopId,
1689-
existingChildBlocks,
1690-
containerId: containerInfo.loopId,
1691-
})
1792+
const autoConnectEdge = data.pendingConnectionContext
1793+
? createPendingConnectionEdge(data.pendingConnectionContext, id, containerInfo.loopId)
1794+
: tryCreateAutoConnectEdge(relativePosition, id, {
1795+
targetParentId: containerInfo.loopId,
1796+
existingChildBlocks,
1797+
containerId: containerInfo.loopId,
1798+
})
16921799

16931800
// Add block with parent info AND autoConnectEdge (atomic operation)
16941801
addBlock(
@@ -1702,7 +1809,9 @@ const WorkflowContent = React.memo(() => {
17021809
},
17031810
containerInfo.loopId,
17041811
'parent',
1705-
autoConnectEdge
1812+
autoConnectEdge,
1813+
undefined,
1814+
data.presetOperation ? { operation: data.presetOperation } : undefined
17061815
)
17071816

17081817
// Resize the container node to fit the new block
@@ -1712,9 +1821,11 @@ const WorkflowContent = React.memo(() => {
17121821
// Centralized trigger constraints
17131822
if (checkTriggerConstraints(data.type)) return
17141823

1715-
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
1716-
targetParentId: null,
1717-
})
1824+
const autoConnectEdge = data.pendingConnectionContext
1825+
? createPendingConnectionEdge(data.pendingConnectionContext, id, null)
1826+
: tryCreateAutoConnectEdge(position, id, {
1827+
targetParentId: null,
1828+
})
17181829

17191830
// Regular canvas drop with auto-connect edge
17201831
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
@@ -1728,7 +1839,8 @@ const WorkflowContent = React.memo(() => {
17281839
undefined,
17291840
undefined,
17301841
autoConnectEdge,
1731-
enableTriggerMode
1842+
enableTriggerMode,
1843+
data.presetOperation ? { operation: data.presetOperation } : undefined
17321844
)
17331845
}
17341846
} catch (err) {
@@ -1743,6 +1855,7 @@ const WorkflowContent = React.memo(() => {
17431855
addNotification,
17441856
activeWorkflowId,
17451857
tryCreateAutoConnectEdge,
1858+
createPendingConnectionEdge,
17461859
checkTriggerConstraints,
17471860
]
17481861
)
@@ -1760,6 +1873,20 @@ const WorkflowContent = React.memo(() => {
17601873
if (!type) return
17611874
if (type === 'connectionBlock') return
17621875

1876+
const pendingConnectionContext = consumePendingConnectionContext()
1877+
if (pendingConnectionContext) {
1878+
handleToolbarDrop(
1879+
{
1880+
type,
1881+
enableTriggerMode,
1882+
presetOperation,
1883+
pendingConnectionContext,
1884+
},
1885+
pendingConnectionContext.dropPosition
1886+
)
1887+
return
1888+
}
1889+
17631890
const basePosition = getViewportCenter()
17641891

17651892
if (type === 'loop' || type === 'parallel') {
@@ -1829,6 +1956,8 @@ const WorkflowContent = React.memo(() => {
18291956
)
18301957
}
18311958
}, [
1959+
consumePendingConnectionContext,
1960+
handleToolbarDrop,
18321961
getViewportCenter,
18331962
blocks,
18341963
addBlock,
@@ -2762,6 +2891,14 @@ const WorkflowContent = React.memo(() => {
27622891
x: clientPos.clientX,
27632892
y: clientPos.clientY,
27642893
})
2894+
const canvasElement = document.querySelector('.workflow-container') as HTMLElement | null
2895+
const canvasBounds = canvasElement?.getBoundingClientRect()
2896+
const isDropInsideCanvas = canvasBounds
2897+
? clientPos.clientX >= canvasBounds.left &&
2898+
clientPos.clientX <= canvasBounds.right &&
2899+
clientPos.clientY >= canvasBounds.top &&
2900+
clientPos.clientY <= canvasBounds.bottom
2901+
: false
27652902

27662903
// Find node under cursor
27672904
const targetNode = findNodeAtPosition(flowPosition)
@@ -2774,11 +2911,19 @@ const WorkflowContent = React.memo(() => {
27742911
target: targetNode.id,
27752912
targetHandle: 'target',
27762913
})
2914+
} else if (isDropInsideCanvas) {
2915+
// Edge dropped on empty canvas: open block search and remember context.
2916+
setPendingConnectionContext({
2917+
sourceNodeId: source.nodeId,
2918+
sourceHandleId: source.handleId,
2919+
dropPosition: flowPosition,
2920+
})
2921+
useSearchModalStore.getState().open()
27772922
}
27782923

27792924
connectionSourceRef.current = null
27802925
},
2781-
[screenToFlowPosition, findNodeAtPosition, onConnect]
2926+
[screenToFlowPosition, findNodeAtPosition, onConnect, setPendingConnectionContext]
27822927
)
27832928

27842929
/** Handles node drag to detect container intersections and update highlighting. */

0 commit comments

Comments
 (0)