mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter (#2489)
* fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter * stronger typing
This commit is contained in:
@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
@@ -523,7 +525,7 @@ const WorkflowContent = React.memo(() => {
|
||||
useEffect(() => {
|
||||
const handleRemoveFromSubflow = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ blockId: string }>
|
||||
const { blockId } = customEvent.detail || ({} as any)
|
||||
const blockId = customEvent.detail?.blockId
|
||||
if (!blockId) return
|
||||
|
||||
try {
|
||||
@@ -602,6 +604,152 @@ const WorkflowContent = React.memo(() => {
|
||||
return 'source'
|
||||
}, [])
|
||||
|
||||
/** Creates a standardized edge object for workflow connections. */
|
||||
const createEdgeObject = useCallback(
|
||||
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
/** Gets the appropriate start handle for a container node (loop or parallel). */
|
||||
const getContainerStartHandle = useCallback(
|
||||
(containerId: string): string => {
|
||||
const containerNode = getNodes().find((n) => n.id === containerId)
|
||||
return (containerNode?.data as SubflowNodeData)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
},
|
||||
[getNodes]
|
||||
)
|
||||
|
||||
/** Finds the closest non-response block to a position within a set of blocks. */
|
||||
const findClosestBlockInSet = useCallback(
|
||||
(
|
||||
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
|
||||
targetPosition: { x: number; y: number }
|
||||
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
|
||||
return candidateBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Attempts to create an auto-connect edge for a new block being added.
|
||||
* Returns the edge object if auto-connect should occur, or undefined otherwise.
|
||||
*
|
||||
* @param position - The position where the new block will be placed
|
||||
* @param targetBlockId - The ID of the new block being added
|
||||
* @param options - Configuration for auto-connect behavior
|
||||
*/
|
||||
const tryCreateAutoConnectEdge = useCallback(
|
||||
(
|
||||
position: { x: number; y: number },
|
||||
targetBlockId: string,
|
||||
options: {
|
||||
blockType: string
|
||||
enableTriggerMode?: boolean
|
||||
targetParentId?: string | null
|
||||
existingChildBlocks?: { id: string; type: string; position: { x: number; y: number } }[]
|
||||
containerId?: string
|
||||
}
|
||||
): Edge | undefined => {
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
if (!isAutoConnectEnabled) return undefined
|
||||
|
||||
// Don't auto-connect starter or annotation-only blocks
|
||||
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Check if target is a trigger block
|
||||
const targetBlockConfig = getBlock(options.blockType)
|
||||
const isTargetTrigger =
|
||||
options.enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
if (isTargetTrigger) return undefined
|
||||
|
||||
// Case 1: Adding block inside a container with existing children
|
||||
if (options.existingChildBlocks && options.existingChildBlocks.length > 0) {
|
||||
const closestBlock = findClosestBlockInSet(options.existingChildBlocks, position)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Case 2: Adding block inside an empty container - connect from container start
|
||||
if (
|
||||
options.containerId &&
|
||||
(!options.existingChildBlocks || options.existingChildBlocks.length === 0)
|
||||
) {
|
||||
const startHandle = getContainerStartHandle(options.containerId)
|
||||
return createEdgeObject(options.containerId, targetBlockId, startHandle)
|
||||
}
|
||||
|
||||
// Case 3: Adding block at root level - use findClosestOutput
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (!closestBlock) return undefined
|
||||
|
||||
// Don't create cross-container edges
|
||||
const closestBlockParentId = blocks[closestBlock.id]?.data?.parentId
|
||||
if (closestBlockParentId && !options.targetParentId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
createEdgeObject,
|
||||
getContainerStartHandle,
|
||||
findClosestBlockInSet,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if adding a trigger block would violate constraints and shows notification if so.
|
||||
* @returns true if validation failed (caller should return early), false if ok to proceed
|
||||
*/
|
||||
const checkTriggerConstraints = useCallback(
|
||||
(blockType: string): boolean => {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared handler for drops of toolbar items onto the workflow canvas.
|
||||
*
|
||||
@@ -630,21 +778,10 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle: determineSourceHandle(closestBlock),
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
blockType: data.type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
@@ -652,8 +789,8 @@ const WorkflowContent = React.memo(() => {
|
||||
name,
|
||||
position,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
@@ -675,12 +812,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const id = crypto.randomUUID()
|
||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
||||
const baseName =
|
||||
data.type === 'loop'
|
||||
? 'Loop'
|
||||
: data.type === 'parallel'
|
||||
? 'Parallel'
|
||||
: defaultTriggerNameDrop || blockConfig!.name
|
||||
const baseName = defaultTriggerNameDrop || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
if (containerInfo) {
|
||||
@@ -712,72 +844,18 @@ const WorkflowContent = React.memo(() => {
|
||||
estimateBlockDimensions(data.type)
|
||||
)
|
||||
|
||||
// Capture existing child blocks before adding the new one
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === containerInfo.loopId
|
||||
)
|
||||
// Capture existing child blocks for auto-connect
|
||||
const existingChildBlocks = Object.values(blocks)
|
||||
.filter((b) => b.data?.parentId === containerInfo.loopId)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
// Auto-connect logic for blocks inside containers
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect to the nearest existing child block within the container
|
||||
// Filter out response blocks since they have no outgoing handles
|
||||
const closestBlock = existingChildBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - relativePosition.x) ** 2 +
|
||||
(b.position.y - relativePosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing children: connect from the container's start handle to the moved node
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
const startSourceHandle =
|
||||
(containerNode?.data as any)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: containerInfo.loopId,
|
||||
target: id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
blockType: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode,
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
|
||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||
addBlock(
|
||||
@@ -799,49 +877,13 @@ const WorkflowContent = React.memo(() => {
|
||||
resizeLoopNodesWrapper()
|
||||
} else {
|
||||
// Centralized trigger constraints
|
||||
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type)
|
||||
if (dropIssue) {
|
||||
const message =
|
||||
dropIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (checkTriggerConstraints(data.type)) return
|
||||
|
||||
// Regular auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
blockType: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Regular canvas drop with auto-connect edge
|
||||
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
||||
@@ -864,14 +906,13 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
getNodes,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
isPointInLoopNode,
|
||||
resizeLoopNodesWrapper,
|
||||
addBlock,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
tryCreateAutoConnectEdge,
|
||||
checkTriggerConstraints,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -888,44 +929,73 @@ const WorkflowContent = React.memo(() => {
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
// Calculate smart position - to the right of existing root-level blocks
|
||||
const calculateSmartPosition = (): { x: number; y: number } => {
|
||||
// Get all root-level blocks (no parentId)
|
||||
const rootBlocks = Object.values(blocks).filter((b) => !b.data?.parentId)
|
||||
|
||||
if (rootBlocks.length === 0) {
|
||||
// No blocks yet, use viewport center
|
||||
return screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
}
|
||||
|
||||
// Find the rightmost block
|
||||
let maxRight = Number.NEGATIVE_INFINITY
|
||||
let rightmostBlockY = 0
|
||||
for (const block of rootBlocks) {
|
||||
const blockWidth =
|
||||
block.type === 'loop' || block.type === 'parallel'
|
||||
? block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
const blockRight = block.position.x + blockWidth
|
||||
if (blockRight > maxRight) {
|
||||
maxRight = blockRight
|
||||
rightmostBlockY = block.position.y
|
||||
}
|
||||
}
|
||||
|
||||
// Position to the right with autolayout spacing
|
||||
const position = {
|
||||
x: maxRight + DEFAULT_HORIZONTAL_SPACING,
|
||||
y: rightmostBlockY,
|
||||
}
|
||||
|
||||
// Ensure position doesn't overlap any container
|
||||
let container = isPointInLoopNode(position)
|
||||
while (container) {
|
||||
position.x =
|
||||
container.loopPosition.x + container.dimensions.width + DEFAULT_HORIZONTAL_SPACING
|
||||
container = isPointInLoopNode(position)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
const basePosition = calculateSmartPosition()
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
const id = crypto.randomUUID()
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Auto-connect logic for container nodes
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
basePosition,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
@@ -942,11 +1012,8 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
// Check trigger constraints first
|
||||
if (checkTriggerConstraints(type)) return
|
||||
|
||||
// Create a new block with a unique ID
|
||||
const id = crypto.randomUUID()
|
||||
@@ -955,51 +1022,11 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = defaultTriggerName || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
logger.info('Closest block found:', closestBlock)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = blockConfig
|
||||
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
logger.info('Auto-connect edge created:', autoConnectEdge)
|
||||
} else {
|
||||
logger.info('Skipping auto-connect into trigger block', {
|
||||
target: type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized trigger constraints
|
||||
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
|
||||
if (additionIssue) {
|
||||
const message =
|
||||
additionIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Add the block to the workflow with auto-connect edge
|
||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||
@@ -1007,7 +1034,7 @@ const WorkflowContent = React.memo(() => {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
basePosition,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1028,11 +1055,12 @@ const WorkflowContent = React.memo(() => {
|
||||
screenToFlowPosition,
|
||||
blocks,
|
||||
addBlock,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
tryCreateAutoConnectEdge,
|
||||
isPointInLoopNode,
|
||||
effectivePermissions.canEdit,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
checkTriggerConstraints,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -1223,12 +1251,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as any)?.kind === 'loop'
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as any)?.kind === 'parallel'
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
@@ -1427,8 +1455,8 @@ const WorkflowContent = React.memo(() => {
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
||||
},
|
||||
})
|
||||
@@ -1487,8 +1515,8 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
width: 250, // Standard width for both block types
|
||||
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1575,7 +1603,7 @@ const WorkflowContent = React.memo(() => {
|
||||
/**
|
||||
* Effect to resize loops when nodes change (add/remove/position change).
|
||||
* Runs on structural changes only - not during drag (position-only changes).
|
||||
* Skips during loading to avoid unnecessary work.
|
||||
* Skips during loading.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Skip during initial render when nodes aren't loaded yet or workflow not ready
|
||||
@@ -1797,12 +1825,15 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||
|
||||
// Get dimensions based on node type (must match actual rendered dimensions)
|
||||
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
|
||||
const nodeWidth =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
|
||||
const nodeHeight =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.height || 300
|
||||
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
|
||||
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||
|
||||
// Check intersection using absolute coordinates
|
||||
const nodeRect = {
|
||||
@@ -1814,9 +1845,10 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const containerRect = {
|
||||
left: containerAbsolutePos.x,
|
||||
right: containerAbsolutePos.x + (n.data?.width || 500),
|
||||
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
|
||||
top: containerAbsolutePos.y,
|
||||
bottom: containerAbsolutePos.y + (n.data?.height || 300),
|
||||
bottom:
|
||||
containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
|
||||
}
|
||||
|
||||
// Check intersection with absolute coordinates for accurate detection
|
||||
@@ -1832,7 +1864,9 @@ const WorkflowContent = React.memo(() => {
|
||||
container: n,
|
||||
depth: getNodeDepth(n.id),
|
||||
// Calculate size for secondary sorting
|
||||
size: (n.data?.width || 500) * (n.data?.height || 300),
|
||||
size:
|
||||
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
|
||||
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
|
||||
}))
|
||||
|
||||
// Update potential parent if there's at least one intersecting container node
|
||||
@@ -1860,12 +1894,12 @@ const WorkflowContent = React.memo(() => {
|
||||
// Apply appropriate class based on container type
|
||||
if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as any)?.kind === 'loop'
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as any)?.kind === 'parallel'
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
@@ -2037,64 +2071,19 @@ const WorkflowContent = React.memo(() => {
|
||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
|
||||
}
|
||||
|
||||
// Prepare edges that will be added when moving into the container
|
||||
const edgesToAdd: any[] = []
|
||||
|
||||
// Auto-connect when moving an existing block into a container
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
// Don't auto-connect annotation blocks (like note blocks)
|
||||
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
|
||||
// Existing children in the target container (excluding the moved node)
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
|
||||
)
|
||||
const existingChildBlocks = Object.values(blocks)
|
||||
.filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect from nearest existing child inside the container
|
||||
// Filter out response blocks since they have no outgoing handles
|
||||
const closestBlock = existingChildBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - relativePositionBefore.x) ** 2 +
|
||||
(b.position.y - relativePositionBefore.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
|
||||
blockType: node.data?.type || '',
|
||||
targetParentId: potentialParentId,
|
||||
existingChildBlocks,
|
||||
containerId: potentialParentId,
|
||||
})
|
||||
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: node.id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No children: connect from the container's start handle to the moved node
|
||||
const containerNode = getNodes().find((n) => n.id === potentialParentId)
|
||||
const startSourceHandle =
|
||||
(containerNode?.data as any)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: potentialParentId,
|
||||
target: node.id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
}
|
||||
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
|
||||
|
||||
// Skip recording these edges separately since they're part of the parent update
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||
@@ -2119,7 +2108,7 @@ const WorkflowContent = React.memo(() => {
|
||||
updateNodeParent,
|
||||
collaborativeUpdateBlockPosition,
|
||||
addEdge,
|
||||
determineSourceHandle,
|
||||
tryCreateAutoConnectEdge,
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
removeEdgesForNode,
|
||||
|
||||
Reference in New Issue
Block a user