diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 4123df565c..eaac62a570 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -51,9 +51,11 @@ border: 1px solid var(--brand-secondary) !important; } -.react-flow__nodesselection-rect { +.react-flow__nodesselection-rect, +.react-flow__nodesselection { background: transparent !important; border: none !important; + pointer-events: none !important; } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index 65bd3d4e49..3af268aa37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,6 +1,7 @@ export { clearDragHighlights, computeClampedPositionUpdates, + computeParentUpdateEntries, getClampedPositionForNode, isInEditableElement, selectNodesDeferred, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 634aedb326..a0f2a57722 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -1,4 +1,4 @@ -import type { Node } from 'reactflow' +import type { Edge, Node } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' @@ -139,3 +139,43 @@ export function computeClampedPositionUpdates( position: getClampedPositionForNode(node.id, node.position, blocks, allNodes), })) } + +interface ParentUpdateEntry { + blockId: string + newParentId: string + affectedEdges: Edge[] +} + +/** + * Computes parent update entries for nodes being moved into a subflow. + * Only includes "boundary edges" - edges that cross the selection boundary + * (one end inside selection, one end outside). Edges between nodes in the + * selection are preserved. + */ +export function computeParentUpdateEntries( + validNodes: Node[], + allEdges: Edge[], + targetParentId: string +): ParentUpdateEntry[] { + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + + // Find edges that cross the boundary (one end inside selection, one end outside) + // Edges between nodes in the selection should stay intact + const boundaryEdges = allEdges.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + // Only remove if exactly one end is in the selection (crosses boundary) + return sourceInSelection !== targetInSelection + }) + + // Build updates for all valid nodes + return validNodes.map((n) => { + // Only include boundary edges connected to this specific node + const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id) + return { + blockId: n.id, + newParentId: targetParentId, + affectedEdges: edgesForThisNode, + } + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index db0462a0ca..40bbd614c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2273,9 +2273,6 @@ const WorkflowContent = React.memo(() => { // Only consider container nodes that aren't the dragged node if (n.type !== 'subflowNode' || n.id === node.id) return false - // Skip if this container is already the parent of the node being dragged - if (n.id === currentParentId) return false - // Get the container's absolute position const containerAbsolutePos = getNodeAbsolutePosition(n.id) @@ -2426,37 +2423,56 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // Process parent updates for all selected nodes if dropping into a subflow - if (potentialParentId && potentialParentId !== dragStartParentId) { - // Filter out nodes that cannot be moved into subflows - const validNodes = selectedNodes.filter((n) => { - const block = blocks[n.id] - if (!block) return false - // Starter blocks cannot be in containers - if (n.data?.type === 'starter') return false - // Trigger blocks cannot be in containers - if (TriggerUtils.isTriggerBlock(block)) return false - // Subflow nodes (loop/parallel) cannot be nested - if (n.type === 'subflowNode') return false + // Process parent updates for nodes whose parent is changing + // Check each node individually - don't rely on dragStartParentId since + // multi-node selections can contain nodes from different parents + const selectedNodeIds = new Set(selectedNodes.map((n) => n.id)) + const nodesNeedingParentUpdate = selectedNodes.filter((n) => { + const block = blocks[n.id] + if (!block) return false + const currentParent = block.data?.parentId || null + // Skip if the node's parent is also being moved (keep children with their parent) + if (currentParent && selectedNodeIds.has(currentParent)) return false + // Node needs update if current parent !== target parent + return currentParent !== potentialParentId + }) + + if (nodesNeedingParentUpdate.length > 0) { + // Filter out nodes that cannot be moved into subflows (when target is a subflow) + const validNodes = nodesNeedingParentUpdate.filter((n) => { + // These restrictions only apply when moving INTO a subflow + if (potentialParentId) { + if (n.data?.type === 'starter') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + } return true }) if (validNodes.length > 0) { - // Build updates for all valid nodes + // Use boundary edge logic - only remove edges crossing the boundary + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + const updates = validNodes.map((n) => { - const edgesToRemove = edgesForDisplay.filter( + const edgesForThisNode = boundaryEdges.filter( (e) => e.source === n.id || e.target === n.id ) return { blockId: n.id, newParentId: potentialParentId, - affectedEdges: edgesToRemove, + affectedEdges: edgesForThisNode, } }) collaborativeBatchUpdateParent(updates) - logger.info('Batch moved nodes into subflow', { + logger.info('Batch moved nodes to new parent', { targetParentId: potentialParentId, nodeCount: validNodes.length, }) @@ -2584,6 +2600,30 @@ const WorkflowContent = React.memo(() => { edgesToAdd.forEach((edge) => addEdge(edge)) window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) + } else if (!potentialParentId && dragStartParentId) { + // Moving OUT of a subflow to canvas + // Remove edges connected to this node since it's leaving its parent + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === node.id || e.target === node.id + ) + + if (edgesToRemove.length > 0) { + removeEdgesForNode(node.id, edgesToRemove) + + logger.info('Removed edges when moving node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + edgeCount: edgesToRemove.length, + }) + } + + // Clear the parent relationship + updateNodeParent(node.id, null, edgesToRemove) + + logger.info('Moved node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + }) } // Reset state @@ -2780,33 +2820,56 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // Process parent updates if dropping into a subflow - if (potentialParentId && potentialParentId !== dragStartParentId) { - // Filter out nodes that cannot be moved into subflows - const validNodes = nodes.filter((n: Node) => { - const block = blocks[n.id] - if (!block) return false - if (n.data?.type === 'starter') return false - if (TriggerUtils.isTriggerBlock(block)) return false - if (n.type === 'subflowNode') return false + // Process parent updates for nodes whose parent is changing + // Check each node individually - don't rely on dragStartParentId since + // multi-node selections can contain nodes from different parents + const selectedNodeIds = new Set(nodes.map((n: Node) => n.id)) + const nodesNeedingParentUpdate = nodes.filter((n: Node) => { + const block = blocks[n.id] + if (!block) return false + const currentParent = block.data?.parentId || null + // Skip if the node's parent is also being moved (keep children with their parent) + if (currentParent && selectedNodeIds.has(currentParent)) return false + // Node needs update if current parent !== target parent + return currentParent !== potentialParentId + }) + + if (nodesNeedingParentUpdate.length > 0) { + // Filter out nodes that cannot be moved into subflows (when target is a subflow) + const validNodes = nodesNeedingParentUpdate.filter((n: Node) => { + // These restrictions only apply when moving INTO a subflow + if (potentialParentId) { + if (n.data?.type === 'starter') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + } return true }) if (validNodes.length > 0) { + // Use boundary edge logic - only remove edges crossing the boundary + const movingNodeIds = new Set(validNodes.map((n: Node) => n.id)) + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + const updates = validNodes.map((n: Node) => { - const edgesToRemove = edgesForDisplay.filter( + const edgesForThisNode = boundaryEdges.filter( (e) => e.source === n.id || e.target === n.id ) return { blockId: n.id, newParentId: potentialParentId, - affectedEdges: edgesToRemove, + affectedEdges: edgesForThisNode, } }) collaborativeBatchUpdateParent(updates) - logger.info('Batch moved selection into subflow', { + logger.info('Batch moved selection to new parent', { targetParentId: potentialParentId, nodeCount: validNodes.length, }) @@ -2824,7 +2887,6 @@ const WorkflowContent = React.memo(() => { collaborativeBatchUpdatePositions, collaborativeBatchUpdateParent, potentialParentId, - dragStartParentId, edgesForDisplay, clearDragHighlights, ] @@ -2909,7 +2971,6 @@ const WorkflowContent = React.memo(() => { /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ const edgesWithSelection = useMemo(() => { - // Build node lookup map once - O(n) instead of O(n) per edge const nodeMap = new Map(displayNodes.map((n) => [n.id, n])) return edgesForDisplay.map((edge) => {