mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(workflow-editor): open block palette on edge drag-release with auto-connect (#5586)
* feat(workflow-editor): open block palette on edge drag-release with auto-connect * fix(workflow-editor): correct drag-release drop coords, scoping, and container placement * fix(workflow-editor): correlate drag-release palette selection with a token * fix(workflow-editor): preserve tool operation preset on in-container drag-release * fix(workflow-editor): wire drag-release edge from the actual source handle via handleToolbarDrop * refactor(workflow-editor): collapse drag-release correlation into one store field
This commit is contained in:
@@ -95,6 +95,7 @@ import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import type { PendingConnect } from '@/stores/modals/search/types'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useVariablesModalStore } from '@/stores/variables/modal'
|
||||
@@ -209,6 +210,7 @@ interface AddBlockFromToolbarDetail {
|
||||
type?: unknown
|
||||
enableTriggerMode?: unknown
|
||||
presetOperation?: unknown
|
||||
pendingConnect?: PendingConnect
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1779,9 +1781,55 @@ const WorkflowContent = React.memo(
|
||||
* @param position - Drop position in ReactFlow coordinates.
|
||||
*/
|
||||
const handleToolbarDrop = useCallback(
|
||||
(data: { type: string; enableTriggerMode?: boolean }, position: { x: number; y: number }) => {
|
||||
(
|
||||
data: {
|
||||
type: string
|
||||
enableTriggerMode?: boolean
|
||||
presetOperation?: string
|
||||
forcedSource?: { nodeId: string; handleId: string }
|
||||
},
|
||||
position: { x: number; y: number }
|
||||
) => {
|
||||
if (!data.type || data.type === 'connectionBlock') return
|
||||
|
||||
const operationConfig = data.presetOperation
|
||||
? { operation: data.presetOperation }
|
||||
: undefined
|
||||
|
||||
const { forcedSource } = data
|
||||
|
||||
/**
|
||||
* Edge for the new block. With a `forcedSource` (a drag-release from a
|
||||
* handle), wire from that exact handle — but only when it stays within the
|
||||
* resolved container context, matching onConnect's boundary rules; a
|
||||
* cross-boundary source yields no edge. Otherwise fall back to normal
|
||||
* proximity auto-connect.
|
||||
*/
|
||||
const resolveEdge = (
|
||||
targetId: string,
|
||||
targetParentId: string | null,
|
||||
fallback: () => Edge | undefined
|
||||
): Edge | undefined => {
|
||||
if (!forcedSource) return fallback()
|
||||
|
||||
const isContainerStartHandle =
|
||||
forcedSource.handleId === 'loop-start-source' ||
|
||||
forcedSource.handleId === 'parallel-start-source'
|
||||
if (isContainerStartHandle) {
|
||||
// A container-start handle may only wire to a child of that container.
|
||||
return forcedSource.nodeId === targetParentId
|
||||
? createEdgeObject(forcedSource.nodeId, targetId, forcedSource.handleId)
|
||||
: undefined
|
||||
}
|
||||
|
||||
const sourceBlock = blocks[forcedSource.nodeId]
|
||||
if (!sourceBlock) return undefined
|
||||
const sourceParentId = sourceBlock.data?.parentId ?? null
|
||||
return sourceParentId === targetParentId
|
||||
? createEdgeObject(forcedSource.nodeId, targetId, forcedSource.handleId)
|
||||
: undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const containerInfo = isPointInLoopNode(position)
|
||||
|
||||
@@ -1811,11 +1859,13 @@ const WorkflowContent = React.memo(
|
||||
.filter((b) => b.data?.parentId === containerInfo.loopId)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
const autoConnectEdge = resolveEdge(id, containerInfo.loopId, () =>
|
||||
tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
)
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
@@ -1836,9 +1886,11 @@ const WorkflowContent = React.memo(
|
||||
|
||||
resizeLoopNodesWrapper()
|
||||
} else {
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
targetParentId: null,
|
||||
})
|
||||
const autoConnectEdge = resolveEdge(id, null, () =>
|
||||
tryCreateAutoConnectEdge(position, id, {
|
||||
targetParentId: null,
|
||||
})
|
||||
)
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
@@ -1903,11 +1955,13 @@ const WorkflowContent = React.memo(
|
||||
.filter((b) => b.data?.parentId === containerInfo.loopId)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
const autoConnectEdge = resolveEdge(id, containerInfo.loopId, () =>
|
||||
tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
)
|
||||
|
||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||
addBlock(
|
||||
@@ -1921,7 +1975,9 @@ const WorkflowContent = React.memo(
|
||||
},
|
||||
containerInfo.loopId,
|
||||
'parent',
|
||||
autoConnectEdge
|
||||
autoConnectEdge,
|
||||
undefined,
|
||||
operationConfig
|
||||
)
|
||||
|
||||
// Resize the container node to fit the new block
|
||||
@@ -1931,9 +1987,11 @@ const WorkflowContent = React.memo(
|
||||
// Centralized trigger constraints
|
||||
if (checkTriggerConstraints(data.type)) return
|
||||
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
targetParentId: null,
|
||||
})
|
||||
const autoConnectEdge = resolveEdge(id, null, () =>
|
||||
tryCreateAutoConnectEdge(position, id, {
|
||||
targetParentId: null,
|
||||
})
|
||||
)
|
||||
|
||||
// Regular canvas drop with auto-connect edge
|
||||
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
||||
@@ -1947,7 +2005,8 @@ const WorkflowContent = React.memo(
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge,
|
||||
enableTriggerMode
|
||||
enableTriggerMode,
|
||||
operationConfig
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1961,6 +2020,7 @@ const WorkflowContent = React.memo(
|
||||
addBlock,
|
||||
tryCreateAutoConnectEdge,
|
||||
checkTriggerConstraints,
|
||||
createEdgeObject,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1972,11 +2032,30 @@ const WorkflowContent = React.memo(
|
||||
return
|
||||
}
|
||||
|
||||
const { type, enableTriggerMode, presetOperation } = event.detail
|
||||
const { type, enableTriggerMode, presetOperation, pendingConnect } = event.detail
|
||||
|
||||
if (typeof type !== 'string' || !type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
// Complete an edge drag-release: only a genuine palette selection carries
|
||||
// `pendingConnect` (other add-block dispatchers — toolbar, sidebar, command
|
||||
// list — don't), so its presence is the signal. Delegating to
|
||||
// handleToolbarDrop with the drag source gives container-aware placement AND
|
||||
// an edge from the released handle that respects container boundaries.
|
||||
if (pendingConnect) {
|
||||
// screenToFlowPosition subtracts the pane rect internally — pass raw client coords.
|
||||
handleToolbarDrop(
|
||||
{
|
||||
type,
|
||||
enableTriggerMode: enableTriggerMode === true,
|
||||
presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined,
|
||||
forcedSource: pendingConnect.source,
|
||||
},
|
||||
screenToFlowPosition({ x: pendingConnect.screenX, y: pendingConnect.screenY })
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const basePosition = getViewportCenter()
|
||||
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
@@ -2054,6 +2133,8 @@ const WorkflowContent = React.memo(
|
||||
effectivePermissions.canEdit,
|
||||
checkTriggerConstraints,
|
||||
tryCreateAutoConnectEdge,
|
||||
screenToFlowPosition,
|
||||
handleToolbarDrop,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -3132,6 +3213,17 @@ const WorkflowContent = React.memo(
|
||||
target: targetNode.id,
|
||||
targetHandle: 'target',
|
||||
})
|
||||
} else if (!targetNode) {
|
||||
// Released on empty canvas: open the command palette with the drag origin
|
||||
// + drop point, so the chosen block lands here wired from this handle.
|
||||
useSearchModalStore.getState().open({
|
||||
sections: ['blocks', 'tools', 'toolOperations'],
|
||||
pendingConnect: {
|
||||
source: { nodeId: source.nodeId, handleId: source.handleId },
|
||||
screenX: clientPos.clientX,
|
||||
screenY: clientPos.clientY,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
connectionSourceRef.current = null
|
||||
|
||||
+111
-72
@@ -40,6 +40,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import type {
|
||||
SearchBlockItem,
|
||||
SearchDocItem,
|
||||
SearchSection,
|
||||
SearchToolOperationItem,
|
||||
} from '@/stores/modals/search/types'
|
||||
import {
|
||||
@@ -118,6 +119,9 @@ export function SearchModal({
|
||||
(state) => state.data
|
||||
)
|
||||
|
||||
const sections = useSearchModalStore((state) => state.sections)
|
||||
const showSection = (key: SearchSection) => !sections || sections.includes(key)
|
||||
|
||||
const openHelpModal = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-help-modal'))
|
||||
}, [])
|
||||
@@ -347,6 +351,7 @@ export function SearchModal({
|
||||
detail: {
|
||||
type: block.type,
|
||||
enableTriggerMode,
|
||||
pendingConnect: useSearchModalStore.getState().pendingConnect,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -364,7 +369,11 @@ export function SearchModal({
|
||||
(op: SearchToolOperationItem) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: op.blockType, presetOperation: op.operationId },
|
||||
detail: {
|
||||
type: op.blockType,
|
||||
presetOperation: op.operationId,
|
||||
pendingConnect: useSearchModalStore.getState().pendingConnect,
|
||||
},
|
||||
})
|
||||
)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
@@ -690,77 +699,107 @@ export function SearchModal({
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
<ActionsGroup
|
||||
items={filteredActions}
|
||||
onSelect={handleActionSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<ConnectedAccountsGroup
|
||||
items={filteredConnectedAccounts}
|
||||
onSelect={handleConnectedAccountSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<IntegrationsGroup
|
||||
items={filteredIntegrations}
|
||||
onSelect={handleIntegrationSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<BlocksGroup
|
||||
items={filteredBlocks}
|
||||
onSelect={handleBlockSelectAsBlock}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<ToolsGroup
|
||||
items={filteredTools}
|
||||
onSelect={handleBlockSelectAsTool}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<TriggersGroup
|
||||
items={filteredTriggers}
|
||||
onSelect={handleBlockSelectAsTrigger}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<ChatsGroup
|
||||
items={filteredChats}
|
||||
onSelect={handleChatSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<WorkflowsGroup
|
||||
items={filteredWorkflows}
|
||||
onSelect={handleWorkflowSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<TablesGroup
|
||||
items={filteredTables}
|
||||
onSelect={handleTableSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<FilesGroup
|
||||
items={filteredFiles}
|
||||
onSelect={handleFileSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<KnowledgeBasesGroup
|
||||
items={filteredKnowledgeBases}
|
||||
onSelect={handleKbSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<ToolOpsGroup
|
||||
items={filteredToolOps}
|
||||
onSelect={handleToolOperationSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<WorkspacesGroup
|
||||
items={filteredWorkspaces}
|
||||
onSelect={handleWorkspaceSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} query={deferredSearch} />
|
||||
<PagesGroup
|
||||
items={filteredPages}
|
||||
onSelect={handlePageSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
{showSection('actions') && (
|
||||
<ActionsGroup
|
||||
items={filteredActions}
|
||||
onSelect={handleActionSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('connectedAccounts') && (
|
||||
<ConnectedAccountsGroup
|
||||
items={filteredConnectedAccounts}
|
||||
onSelect={handleConnectedAccountSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('integrations') && (
|
||||
<IntegrationsGroup
|
||||
items={filteredIntegrations}
|
||||
onSelect={handleIntegrationSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('blocks') && (
|
||||
<BlocksGroup
|
||||
items={filteredBlocks}
|
||||
onSelect={handleBlockSelectAsBlock}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('tools') && (
|
||||
<ToolsGroup
|
||||
items={filteredTools}
|
||||
onSelect={handleBlockSelectAsTool}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('triggers') && (
|
||||
<TriggersGroup
|
||||
items={filteredTriggers}
|
||||
onSelect={handleBlockSelectAsTrigger}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('chats') && (
|
||||
<ChatsGroup
|
||||
items={filteredChats}
|
||||
onSelect={handleChatSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('workflows') && (
|
||||
<WorkflowsGroup
|
||||
items={filteredWorkflows}
|
||||
onSelect={handleWorkflowSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('tables') && (
|
||||
<TablesGroup
|
||||
items={filteredTables}
|
||||
onSelect={handleTableSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('files') && (
|
||||
<FilesGroup
|
||||
items={filteredFiles}
|
||||
onSelect={handleFileSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('knowledgeBases') && (
|
||||
<KnowledgeBasesGroup
|
||||
items={filteredKnowledgeBases}
|
||||
onSelect={handleKbSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('toolOperations') && (
|
||||
<ToolOpsGroup
|
||||
items={filteredToolOps}
|
||||
onSelect={handleToolOperationSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('workspaces') && (
|
||||
<WorkspacesGroup
|
||||
items={filteredWorkspaces}
|
||||
onSelect={handleWorkspaceSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
{showSection('docs') && (
|
||||
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} query={deferredSearch} />
|
||||
)}
|
||||
{showSection('pages') && (
|
||||
<PagesGroup
|
||||
items={filteredPages}
|
||||
onSelect={handlePageSelect}
|
||||
query={deferredSearch}
|
||||
/>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
@@ -67,18 +67,24 @@ export const useSearchModalStore = create<SearchModalState>()(
|
||||
devtools(
|
||||
(set, _) => ({
|
||||
isOpen: false,
|
||||
sections: null,
|
||||
pendingConnect: null,
|
||||
data: initialData,
|
||||
|
||||
setOpen: (open: boolean) => {
|
||||
set({ isOpen: open })
|
||||
set({ isOpen: open, sections: null, pendingConnect: null })
|
||||
},
|
||||
|
||||
open: () => {
|
||||
set({ isOpen: true })
|
||||
open: (options) => {
|
||||
set({
|
||||
isOpen: true,
|
||||
sections: options?.sections ?? null,
|
||||
pendingConnect: options?.pendingConnect ?? null,
|
||||
})
|
||||
},
|
||||
|
||||
close: () => {
|
||||
set({ isOpen: false })
|
||||
set({ isOpen: false, sections: null, pendingConnect: null })
|
||||
},
|
||||
|
||||
initializeData: (filterBlocks) => {
|
||||
|
||||
@@ -49,6 +49,44 @@ export interface SearchData {
|
||||
isInitialized: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Every result group the search modal can render, in render order. Used to
|
||||
* restrict the palette to a subset of sections when opened for a specific
|
||||
* intent (e.g. a drag-release that should only offer canvas-insertable items).
|
||||
*/
|
||||
export const SEARCH_SECTIONS = [
|
||||
'actions',
|
||||
'connectedAccounts',
|
||||
'integrations',
|
||||
'blocks',
|
||||
'tools',
|
||||
'triggers',
|
||||
'chats',
|
||||
'workflows',
|
||||
'tables',
|
||||
'files',
|
||||
'knowledgeBases',
|
||||
'toolOperations',
|
||||
'workspaces',
|
||||
'docs',
|
||||
'pages',
|
||||
] as const
|
||||
|
||||
/** A single search-modal result group. */
|
||||
export type SearchSection = (typeof SEARCH_SECTIONS)[number]
|
||||
|
||||
/**
|
||||
* Context handed to the palette when it is opened to complete an edge
|
||||
* drag-release: the dragged source handle and the release point. A selection
|
||||
* stamps it onto its event so the canvas places the block at the drop point and
|
||||
* wires it from that handle.
|
||||
*/
|
||||
export interface PendingConnect {
|
||||
source: { nodeId: string; handleId: string }
|
||||
screenX: number
|
||||
screenY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Global state for the universal search modal.
|
||||
*
|
||||
@@ -60,18 +98,33 @@ export interface SearchModalState {
|
||||
/** Whether the search modal is currently open. */
|
||||
isOpen: boolean
|
||||
|
||||
/**
|
||||
* When set, the palette renders only these sections; `null` shows all of them.
|
||||
*/
|
||||
sections: SearchSection[] | null
|
||||
|
||||
/**
|
||||
* Pending edge drag-release the palette was opened to complete. A selection
|
||||
* stamps it onto its event; other add-block dispatchers carry none, so only a
|
||||
* genuine palette pick completes the connection. `null` for ordinary opens.
|
||||
*/
|
||||
pendingConnect: PendingConnect | null
|
||||
|
||||
/** Pre-computed search data. */
|
||||
data: SearchData
|
||||
|
||||
/**
|
||||
* Explicitly set the open state of the modal.
|
||||
* Explicitly set the open state of the modal. Always resets to the full
|
||||
* palette (no section restriction, no pending connect).
|
||||
*/
|
||||
setOpen: (open: boolean) => void
|
||||
|
||||
/**
|
||||
* Convenience method to open the modal.
|
||||
* Convenience method to open the modal. Pass `sections` to restrict the
|
||||
* palette to a subset of result groups, and `pendingConnect` to complete an
|
||||
* edge drag-release with the selection.
|
||||
*/
|
||||
open: () => void
|
||||
open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void
|
||||
|
||||
/**
|
||||
* Convenience method to close the modal.
|
||||
|
||||
Reference in New Issue
Block a user