keep edges on subflow actions intact

This commit is contained in:
waleed
2026-01-08 21:15:16 -08:00
parent 20d66b9ab1
commit 6e7f3dadbf
4 changed files with 139 additions and 35 deletions

View File

@@ -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;
}
/**

View File

@@ -1,6 +1,7 @@
export {
clearDragHighlights,
computeClampedPositionUpdates,
computeParentUpdateEntries,
getClampedPositionForNode,
isInEditableElement,
selectNodesDeferred,

View File

@@ -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,
}
})
}

View File

@@ -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) => {