mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -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 type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import type { OAuthProvider } from '@/lib/oauth'
|
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 { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
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 { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
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 { 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 { 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 { 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'
|
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||||
@@ -523,7 +525,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRemoveFromSubflow = (event: Event) => {
|
const handleRemoveFromSubflow = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<{ blockId: string }>
|
const customEvent = event as CustomEvent<{ blockId: string }>
|
||||||
const { blockId } = customEvent.detail || ({} as any)
|
const blockId = customEvent.detail?.blockId
|
||||||
if (!blockId) return
|
if (!blockId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -602,6 +604,152 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return 'source'
|
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.
|
* 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 baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||||
let autoConnectEdge
|
blockType: data.type,
|
||||||
if (isAutoConnectEnabled) {
|
targetParentId: null,
|
||||||
const closestBlock = findClosestOutput(position)
|
})
|
||||||
if (closestBlock) {
|
|
||||||
autoConnectEdge = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
source: closestBlock.id,
|
|
||||||
target: id,
|
|
||||||
sourceHandle: determineSourceHandle(closestBlock),
|
|
||||||
targetHandle: 'target',
|
|
||||||
type: 'workflowEdge',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
id,
|
id,
|
||||||
@@ -652,8 +789,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
{
|
{
|
||||||
width: 500,
|
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||||
height: 300,
|
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||||
type: 'subflowNode',
|
type: 'subflowNode',
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -675,12 +812,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||||
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
||||||
const baseName =
|
const baseName = defaultTriggerNameDrop || blockConfig.name
|
||||||
data.type === 'loop'
|
|
||||||
? 'Loop'
|
|
||||||
: data.type === 'parallel'
|
|
||||||
? 'Parallel'
|
|
||||||
: defaultTriggerNameDrop || blockConfig!.name
|
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
if (containerInfo) {
|
if (containerInfo) {
|
||||||
@@ -712,72 +844,18 @@ const WorkflowContent = React.memo(() => {
|
|||||||
estimateBlockDimensions(data.type)
|
estimateBlockDimensions(data.type)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Capture existing child blocks before adding the new one
|
// Capture existing child blocks for auto-connect
|
||||||
const existingChildBlocks = Object.values(blocks).filter(
|
const existingChildBlocks = Object.values(blocks)
|
||||||
(b) => b.data?.parentId === containerInfo.loopId
|
.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 autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
blockType: data.type,
|
||||||
let autoConnectEdge
|
enableTriggerMode: data.enableTriggerMode,
|
||||||
if (
|
targetParentId: containerInfo.loopId,
|
||||||
isAutoConnectEnabled &&
|
existingChildBlocks,
|
||||||
data.type !== 'starter' &&
|
containerId: containerInfo.loopId,
|
||||||
!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',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||||
addBlock(
|
addBlock(
|
||||||
@@ -799,49 +877,13 @@ const WorkflowContent = React.memo(() => {
|
|||||||
resizeLoopNodesWrapper()
|
resizeLoopNodesWrapper()
|
||||||
} else {
|
} else {
|
||||||
// Centralized trigger constraints
|
// Centralized trigger constraints
|
||||||
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type)
|
if (checkTriggerConstraints(data.type)) return
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular auto-connect logic
|
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
blockType: data.type,
|
||||||
let autoConnectEdge
|
enableTriggerMode: data.enableTriggerMode,
|
||||||
if (
|
targetParentId: null,
|
||||||
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',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular canvas drop with auto-connect edge
|
// Regular canvas drop with auto-connect edge
|
||||||
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
||||||
@@ -864,14 +906,13 @@ const WorkflowContent = React.memo(() => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
blocks,
|
blocks,
|
||||||
getNodes,
|
|
||||||
findClosestOutput,
|
|
||||||
determineSourceHandle,
|
|
||||||
isPointInLoopNode,
|
isPointInLoopNode,
|
||||||
resizeLoopNodesWrapper,
|
resizeLoopNodesWrapper,
|
||||||
addBlock,
|
addBlock,
|
||||||
addNotification,
|
addNotification,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
|
tryCreateAutoConnectEdge,
|
||||||
|
checkTriggerConstraints,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -888,44 +929,73 @@ const WorkflowContent = React.memo(() => {
|
|||||||
if (!type) return
|
if (!type) return
|
||||||
if (type === 'connectionBlock') 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)
|
// Special handling for container nodes (loop or parallel)
|
||||||
if (type === 'loop' || type === 'parallel') {
|
if (type === 'loop' || type === 'parallel') {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
const centerPosition = screenToFlowPosition({
|
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||||
x: window.innerWidth / 2,
|
blockType: type,
|
||||||
y: window.innerHeight / 2,
|
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
|
// Add the container node with default dimensions and auto-connect edge
|
||||||
addBlock(
|
addBlock(
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
centerPosition,
|
basePosition,
|
||||||
{
|
{
|
||||||
width: 500,
|
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||||
height: 300,
|
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||||
type: 'subflowNode',
|
type: 'subflowNode',
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -942,11 +1012,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the center position of the viewport
|
// Check trigger constraints first
|
||||||
const centerPosition = screenToFlowPosition({
|
if (checkTriggerConstraints(type)) return
|
||||||
x: window.innerWidth / 2,
|
|
||||||
y: window.innerHeight / 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a new block with a unique ID
|
// Create a new block with a unique ID
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
@@ -955,51 +1022,11 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const baseName = defaultTriggerName || blockConfig.name
|
const baseName = defaultTriggerName || blockConfig.name
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
// Auto-connect logic
|
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
blockType: type,
|
||||||
let autoConnectEdge
|
enableTriggerMode,
|
||||||
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
|
targetParentId: null,
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the block to the workflow with auto-connect edge
|
// Add the block to the workflow with auto-connect edge
|
||||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||||
@@ -1007,7 +1034,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
centerPosition,
|
basePosition,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1028,11 +1055,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
blocks,
|
blocks,
|
||||||
addBlock,
|
addBlock,
|
||||||
findClosestOutput,
|
tryCreateAutoConnectEdge,
|
||||||
determineSourceHandle,
|
isPointInLoopNode,
|
||||||
effectivePermissions.canEdit,
|
effectivePermissions.canEdit,
|
||||||
addNotification,
|
addNotification,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
|
checkTriggerConstraints,
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1223,12 +1251,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||||
if (
|
if (
|
||||||
containerNode?.type === 'subflowNode' &&
|
containerNode?.type === 'subflowNode' &&
|
||||||
(containerNode.data as any)?.kind === 'loop'
|
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||||
) {
|
) {
|
||||||
containerElement.classList.add('loop-node-drag-over')
|
containerElement.classList.add('loop-node-drag-over')
|
||||||
} else if (
|
} else if (
|
||||||
containerNode?.type === 'subflowNode' &&
|
containerNode?.type === 'subflowNode' &&
|
||||||
(containerNode.data as any)?.kind === 'parallel'
|
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||||
) {
|
) {
|
||||||
containerElement.classList.add('parallel-node-drag-over')
|
containerElement.classList.add('parallel-node-drag-over')
|
||||||
}
|
}
|
||||||
@@ -1427,8 +1455,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
data: {
|
data: {
|
||||||
...block.data,
|
...block.data,
|
||||||
name: block.name,
|
name: block.name,
|
||||||
width: block.data?.width || 500,
|
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||||
height: block.data?.height || 300,
|
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
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)
|
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||||
width: 250, // Standard width for both block types
|
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||||
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
|
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).
|
* Effect to resize loops when nodes change (add/remove/position change).
|
||||||
* Runs on structural changes only - not during drag (position-only changes).
|
* Runs on structural changes only - not during drag (position-only changes).
|
||||||
* Skips during loading to avoid unnecessary work.
|
* Skips during loading.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip during initial render when nodes aren't loaded yet or workflow not ready
|
// 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)
|
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||||
|
|
||||||
// Get dimensions based on node type (must match actual rendered dimensions)
|
// 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 =
|
const nodeHeight =
|
||||||
node.type === 'subflowNode'
|
node.type === 'subflowNode'
|
||||||
? node.data?.height || 300
|
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||||
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
|
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||||
|
|
||||||
// Check intersection using absolute coordinates
|
// Check intersection using absolute coordinates
|
||||||
const nodeRect = {
|
const nodeRect = {
|
||||||
@@ -1814,9 +1845,10 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
const containerRect = {
|
const containerRect = {
|
||||||
left: containerAbsolutePos.x,
|
left: containerAbsolutePos.x,
|
||||||
right: containerAbsolutePos.x + (n.data?.width || 500),
|
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
|
||||||
top: containerAbsolutePos.y,
|
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
|
// Check intersection with absolute coordinates for accurate detection
|
||||||
@@ -1832,7 +1864,9 @@ const WorkflowContent = React.memo(() => {
|
|||||||
container: n,
|
container: n,
|
||||||
depth: getNodeDepth(n.id),
|
depth: getNodeDepth(n.id),
|
||||||
// Calculate size for secondary sorting
|
// 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
|
// 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
|
// Apply appropriate class based on container type
|
||||||
if (
|
if (
|
||||||
bestContainerMatch.container.type === 'subflowNode' &&
|
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')
|
containerElement.classList.add('loop-node-drag-over')
|
||||||
} else if (
|
} else if (
|
||||||
bestContainerMatch.container.type === 'subflowNode' &&
|
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')
|
containerElement.classList.add('parallel-node-drag-over')
|
||||||
}
|
}
|
||||||
@@ -2037,64 +2071,19 @@ const WorkflowContent = React.memo(() => {
|
|||||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
|
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
|
// Auto-connect when moving an existing block into a container
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
const existingChildBlocks = Object.values(blocks)
|
||||||
// Don't auto-connect annotation blocks (like note blocks)
|
.filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
|
||||||
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
|
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||||
// 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
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existingChildBlocks.length > 0) {
|
const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
|
||||||
// Connect from nearest existing child inside the container
|
blockType: node.data?.type || '',
|
||||||
// Filter out response blocks since they have no outgoing handles
|
targetParentId: potentialParentId,
|
||||||
const closestBlock = existingChildBlocks
|
existingChildBlocks,
|
||||||
.filter((b) => b.type !== 'response')
|
containerId: potentialParentId,
|
||||||
.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
|
|
||||||
|
|
||||||
if (closestBlock) {
|
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip recording these edges separately since they're part of the parent update
|
// Skip recording these edges separately since they're part of the parent update
|
||||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||||
@@ -2119,7 +2108,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
updateNodeParent,
|
updateNodeParent,
|
||||||
collaborativeUpdateBlockPosition,
|
collaborativeUpdateBlockPosition,
|
||||||
addEdge,
|
addEdge,
|
||||||
determineSourceHandle,
|
tryCreateAutoConnectEdge,
|
||||||
blocks,
|
blocks,
|
||||||
edgesForDisplay,
|
edgesForDisplay,
|
||||||
removeEdgesForNode,
|
removeEdgesForNode,
|
||||||
|
|||||||
Reference in New Issue
Block a user