@@ -447,6 +447,7 @@ const WorkflowContent = React.memo(() => {
447447 collaborativeBatchRemoveEdges,
448448 collaborativeBatchUpdatePositions,
449449 collaborativeUpdateParentId : updateParentId ,
450+ collaborativeBatchUpdateParent,
450451 collaborativeBatchAddBlocks,
451452 collaborativeBatchRemoveBlocks,
452453 collaborativeBatchToggleBlockEnabled,
@@ -2437,6 +2438,43 @@ const WorkflowContent = React.memo(() => {
24372438 previousPositions : multiNodeDragStartRef . current ,
24382439 } )
24392440
2441+ // Process parent updates for all selected nodes if dropping into a subflow
2442+ if ( potentialParentId && potentialParentId !== dragStartParentId ) {
2443+ // Filter out nodes that cannot be moved into subflows
2444+ const validNodes = selectedNodes . filter ( ( n ) => {
2445+ const block = blocks [ n . id ]
2446+ if ( ! block ) return false
2447+ // Starter blocks cannot be in containers
2448+ if ( n . data ?. type === 'starter' ) return false
2449+ // Trigger blocks cannot be in containers
2450+ if ( TriggerUtils . isTriggerBlock ( block ) ) return false
2451+ // Subflow nodes (loop/parallel) cannot be nested
2452+ if ( n . type === 'subflowNode' ) return false
2453+ return true
2454+ } )
2455+
2456+ if ( validNodes . length > 0 ) {
2457+ // Build updates for all valid nodes
2458+ const updates = validNodes . map ( ( n ) => {
2459+ const edgesToRemove = edgesForDisplay . filter (
2460+ ( e ) => e . source === n . id || e . target === n . id
2461+ )
2462+ return {
2463+ blockId : n . id ,
2464+ newParentId : potentialParentId ,
2465+ affectedEdges : edgesToRemove ,
2466+ }
2467+ } )
2468+
2469+ collaborativeBatchUpdateParent ( updates )
2470+
2471+ logger . info ( 'Batch moved nodes into subflow' , {
2472+ targetParentId : potentialParentId ,
2473+ nodeCount : validNodes . length ,
2474+ } )
2475+ }
2476+ }
2477+
24402478 // Clear drag start state
24412479 setDragStartPosition ( null )
24422480 setPotentialParentId ( null )
@@ -2580,6 +2618,7 @@ const WorkflowContent = React.memo(() => {
25802618 addNotification ,
25812619 activeWorkflowId ,
25822620 collaborativeBatchUpdatePositions ,
2621+ collaborativeBatchUpdateParent ,
25832622 ]
25842623 )
25852624
@@ -2594,19 +2633,213 @@ const WorkflowContent = React.memo(() => {
25942633 requestAnimationFrame ( ( ) => setIsSelectionDragActive ( false ) )
25952634 } , [ ] )
25962635
2636+ /** Captures initial positions when selection drag starts (for marquee-selected nodes). */
2637+ const onSelectionDragStart = useCallback (
2638+ ( _event : React . MouseEvent , nodes : Node [ ] ) => {
2639+ // Capture the parent ID of the first node as reference (they should all be in the same context)
2640+ if ( nodes . length > 0 ) {
2641+ const firstNodeParentId = blocks [ nodes [ 0 ] . id ] ?. data ?. parentId || null
2642+ setDragStartParentId ( firstNodeParentId )
2643+ }
2644+
2645+ // Capture all selected nodes' positions for undo/redo
2646+ multiNodeDragStartRef . current . clear ( )
2647+ nodes . forEach ( ( n ) => {
2648+ const block = blocks [ n . id ]
2649+ if ( block ) {
2650+ multiNodeDragStartRef . current . set ( n . id , {
2651+ x : n . position . x ,
2652+ y : n . position . y ,
2653+ parentId : block . data ?. parentId ,
2654+ } )
2655+ }
2656+ } )
2657+ } ,
2658+ [ blocks ]
2659+ )
2660+
2661+ /** Handles selection drag to detect potential parent containers for batch drops. */
2662+ const onSelectionDrag = useCallback (
2663+ ( _event : React . MouseEvent , nodes : Node [ ] ) => {
2664+ if ( nodes . length === 0 ) return
2665+
2666+ // Filter out nodes that can't be placed in containers
2667+ const eligibleNodes = nodes . filter ( ( n ) => {
2668+ if ( n . data ?. type === 'starter' ) return false
2669+ if ( n . type === 'subflowNode' ) return false
2670+ const block = blocks [ n . id ]
2671+ if ( block && TriggerUtils . isTriggerBlock ( block ) ) return false
2672+ return true
2673+ } )
2674+
2675+ // If no eligible nodes, clear any potential parent
2676+ if ( eligibleNodes . length === 0 ) {
2677+ if ( potentialParentId ) {
2678+ clearDragHighlights ( )
2679+ setPotentialParentId ( null )
2680+ }
2681+ return
2682+ }
2683+
2684+ // Calculate bounding box of all dragged nodes using absolute positions
2685+ let minX = Number . POSITIVE_INFINITY
2686+ let minY = Number . POSITIVE_INFINITY
2687+ let maxX = Number . NEGATIVE_INFINITY
2688+ let maxY = Number . NEGATIVE_INFINITY
2689+
2690+ eligibleNodes . forEach ( ( node ) => {
2691+ const absolutePos = getNodeAbsolutePosition ( node . id )
2692+ const block = blocks [ node . id ]
2693+ const width = BLOCK_DIMENSIONS . FIXED_WIDTH
2694+ const height = Math . max (
2695+ node . height || BLOCK_DIMENSIONS . MIN_HEIGHT ,
2696+ BLOCK_DIMENSIONS . MIN_HEIGHT
2697+ )
2698+
2699+ minX = Math . min ( minX , absolutePos . x )
2700+ minY = Math . min ( minY , absolutePos . y )
2701+ maxX = Math . max ( maxX , absolutePos . x + width )
2702+ maxY = Math . max ( maxY , absolutePos . y + height )
2703+ } )
2704+
2705+ // Use bounding box for intersection detection
2706+ const selectionRect = { left : minX , right : maxX , top : minY , bottom : maxY }
2707+
2708+ // Find containers that intersect with the selection bounding box
2709+ const allNodes = getNodes ( )
2710+ const intersectingContainers = allNodes
2711+ . filter ( ( containerNode ) => {
2712+ if ( containerNode . type !== 'subflowNode' ) return false
2713+ // Skip if any dragged node is this container
2714+ if ( nodes . some ( ( n ) => n . id === containerNode . id ) ) return false
2715+
2716+ const containerAbsolutePos = getNodeAbsolutePosition ( containerNode . id )
2717+ const containerRect = {
2718+ left : containerAbsolutePos . x ,
2719+ right :
2720+ containerAbsolutePos . x +
2721+ ( containerNode . data ?. width || CONTAINER_DIMENSIONS . DEFAULT_WIDTH ) ,
2722+ top : containerAbsolutePos . y ,
2723+ bottom :
2724+ containerAbsolutePos . y +
2725+ ( containerNode . data ?. height || CONTAINER_DIMENSIONS . DEFAULT_HEIGHT ) ,
2726+ }
2727+
2728+ // Check intersection
2729+ return (
2730+ selectionRect . left < containerRect . right &&
2731+ selectionRect . right > containerRect . left &&
2732+ selectionRect . top < containerRect . bottom &&
2733+ selectionRect . bottom > containerRect . top
2734+ )
2735+ } )
2736+ . map ( ( n ) => ( {
2737+ container : n ,
2738+ depth : getNodeDepth ( n . id ) ,
2739+ size :
2740+ ( n . data ?. width || CONTAINER_DIMENSIONS . DEFAULT_WIDTH ) *
2741+ ( n . data ?. height || CONTAINER_DIMENSIONS . DEFAULT_HEIGHT ) ,
2742+ } ) )
2743+
2744+ if ( intersectingContainers . length > 0 ) {
2745+ // Sort by depth first (deepest first), then by size
2746+ const sortedContainers = intersectingContainers . sort ( ( a , b ) => {
2747+ if ( a . depth !== b . depth ) return b . depth - a . depth
2748+ return a . size - b . size
2749+ } )
2750+
2751+ const bestMatch = sortedContainers [ 0 ]
2752+
2753+ if ( bestMatch . container . id !== potentialParentId ) {
2754+ clearDragHighlights ( )
2755+ setPotentialParentId ( bestMatch . container . id )
2756+
2757+ // Add highlight
2758+ const containerElement = document . querySelector ( `[data-id="${ bestMatch . container . id } "]` )
2759+ if ( containerElement ) {
2760+ if ( ( bestMatch . container . data as SubflowNodeData ) ?. kind === 'loop' ) {
2761+ containerElement . classList . add ( 'loop-node-drag-over' )
2762+ } else if ( ( bestMatch . container . data as SubflowNodeData ) ?. kind === 'parallel' ) {
2763+ containerElement . classList . add ( 'parallel-node-drag-over' )
2764+ }
2765+ document . body . style . cursor = 'copy'
2766+ }
2767+ }
2768+ } else if ( potentialParentId ) {
2769+ clearDragHighlights ( )
2770+ setPotentialParentId ( null )
2771+ }
2772+ } ,
2773+ [
2774+ blocks ,
2775+ getNodes ,
2776+ potentialParentId ,
2777+ getNodeAbsolutePosition ,
2778+ getNodeDepth ,
2779+ clearDragHighlights ,
2780+ ]
2781+ )
2782+
25972783 const onSelectionDragStop = useCallback (
25982784 ( _event : React . MouseEvent , nodes : any [ ] ) => {
25992785 requestAnimationFrame ( ( ) => setIsSelectionDragActive ( false ) )
2786+ clearDragHighlights ( )
26002787 if ( nodes . length === 0 ) return
26012788
26022789 const allNodes = getNodes ( )
26032790 const positionUpdates = computeClampedPositionUpdates ( nodes , blocks , allNodes )
26042791 collaborativeBatchUpdatePositions ( positionUpdates , {
26052792 previousPositions : multiNodeDragStartRef . current ,
26062793 } )
2794+
2795+ // Process parent updates if dropping into a subflow
2796+ if ( potentialParentId && potentialParentId !== dragStartParentId ) {
2797+ // Filter out nodes that cannot be moved into subflows
2798+ const validNodes = nodes . filter ( ( n : Node ) => {
2799+ const block = blocks [ n . id ]
2800+ if ( ! block ) return false
2801+ if ( n . data ?. type === 'starter' ) return false
2802+ if ( TriggerUtils . isTriggerBlock ( block ) ) return false
2803+ if ( n . type === 'subflowNode' ) return false
2804+ return true
2805+ } )
2806+
2807+ if ( validNodes . length > 0 ) {
2808+ const updates = validNodes . map ( ( n : Node ) => {
2809+ const edgesToRemove = edgesForDisplay . filter (
2810+ ( e ) => e . source === n . id || e . target === n . id
2811+ )
2812+ return {
2813+ blockId : n . id ,
2814+ newParentId : potentialParentId ,
2815+ affectedEdges : edgesToRemove ,
2816+ }
2817+ } )
2818+
2819+ collaborativeBatchUpdateParent ( updates )
2820+
2821+ logger . info ( 'Batch moved selection into subflow' , {
2822+ targetParentId : potentialParentId ,
2823+ nodeCount : validNodes . length ,
2824+ } )
2825+ }
2826+ }
2827+
2828+ // Clear drag state
2829+ setDragStartPosition ( null )
2830+ setPotentialParentId ( null )
26072831 multiNodeDragStartRef . current . clear ( )
26082832 } ,
2609- [ blocks , getNodes , collaborativeBatchUpdatePositions ]
2833+ [
2834+ blocks ,
2835+ getNodes ,
2836+ collaborativeBatchUpdatePositions ,
2837+ collaborativeBatchUpdateParent ,
2838+ potentialParentId ,
2839+ dragStartParentId ,
2840+ edgesForDisplay ,
2841+ clearDragHighlights ,
2842+ ]
26102843 )
26112844
26122845 const onPaneClick = useCallback ( ( ) => {
@@ -2830,6 +3063,8 @@ const WorkflowContent = React.memo(() => {
28303063 className = { `workflow-container h-full transition-opacity duration-150 ${ reactFlowStyles } ${ isCanvasReady ? 'opacity-100' : 'opacity-0' } ` }
28313064 onNodeDrag = { effectivePermissions . canEdit ? onNodeDrag : undefined }
28323065 onNodeDragStop = { effectivePermissions . canEdit ? onNodeDragStop : undefined }
3066+ onSelectionDragStart = { effectivePermissions . canEdit ? onSelectionDragStart : undefined }
3067+ onSelectionDrag = { effectivePermissions . canEdit ? onSelectionDrag : undefined }
28333068 onSelectionDragStop = { effectivePermissions . canEdit ? onSelectionDragStop : undefined }
28343069 onNodeDragStart = { effectivePermissions . canEdit ? onNodeDragStart : undefined }
28353070 snapToGrid = { snapToGrid }
0 commit comments