@@ -447,6 +447,7 @@ const WorkflowContent = React.memo(() => {
447447 collaborativeBatchRemoveEdges,
448448 collaborativeBatchUpdatePositions,
449449 collaborativeUpdateParentId : updateParentId ,
450+ collaborativeBatchUpdateParent,
450451 collaborativeBatchAddBlocks,
451452 collaborativeBatchRemoveBlocks,
452453 collaborativeBatchToggleBlockEnabled,
@@ -2425,6 +2426,43 @@ const WorkflowContent = React.memo(() => {
24252426 previousPositions : multiNodeDragStartRef . current ,
24262427 } )
24272428
2429+ // Process parent updates for all selected nodes if dropping into a subflow
2430+ if ( potentialParentId && potentialParentId !== dragStartParentId ) {
2431+ // Filter out nodes that cannot be moved into subflows
2432+ const validNodes = selectedNodes . filter ( ( n ) => {
2433+ const block = blocks [ n . id ]
2434+ if ( ! block ) return false
2435+ // Starter blocks cannot be in containers
2436+ if ( n . data ?. type === 'starter' ) return false
2437+ // Trigger blocks cannot be in containers
2438+ if ( TriggerUtils . isTriggerBlock ( block ) ) return false
2439+ // Subflow nodes (loop/parallel) cannot be nested
2440+ if ( n . type === 'subflowNode' ) return false
2441+ return true
2442+ } )
2443+
2444+ if ( validNodes . length > 0 ) {
2445+ // Build updates for all valid nodes
2446+ const updates = validNodes . map ( ( n ) => {
2447+ const edgesToRemove = edgesForDisplay . filter (
2448+ ( e ) => e . source === n . id || e . target === n . id
2449+ )
2450+ return {
2451+ blockId : n . id ,
2452+ newParentId : potentialParentId ,
2453+ affectedEdges : edgesToRemove ,
2454+ }
2455+ } )
2456+
2457+ collaborativeBatchUpdateParent ( updates )
2458+
2459+ logger . info ( 'Batch moved nodes into subflow' , {
2460+ targetParentId : potentialParentId ,
2461+ nodeCount : validNodes . length ,
2462+ } )
2463+ }
2464+ }
2465+
24282466 // Clear drag start state
24292467 setDragStartPosition ( null )
24302468 setPotentialParentId ( null )
@@ -2568,6 +2606,7 @@ const WorkflowContent = React.memo(() => {
25682606 addNotification ,
25692607 activeWorkflowId ,
25702608 collaborativeBatchUpdatePositions ,
2609+ collaborativeBatchUpdateParent ,
25712610 ]
25722611 )
25732612
@@ -2582,19 +2621,213 @@ const WorkflowContent = React.memo(() => {
25822621 requestAnimationFrame ( ( ) => setIsSelectionDragActive ( false ) )
25832622 } , [ ] )
25842623
2624+ /** Captures initial positions when selection drag starts (for marquee-selected nodes). */
2625+ const onSelectionDragStart = useCallback (
2626+ ( _event : React . MouseEvent , nodes : Node [ ] ) => {
2627+ // Capture the parent ID of the first node as reference (they should all be in the same context)
2628+ if ( nodes . length > 0 ) {
2629+ const firstNodeParentId = blocks [ nodes [ 0 ] . id ] ?. data ?. parentId || null
2630+ setDragStartParentId ( firstNodeParentId )
2631+ }
2632+
2633+ // Capture all selected nodes' positions for undo/redo
2634+ multiNodeDragStartRef . current . clear ( )
2635+ nodes . forEach ( ( n ) => {
2636+ const block = blocks [ n . id ]
2637+ if ( block ) {
2638+ multiNodeDragStartRef . current . set ( n . id , {
2639+ x : n . position . x ,
2640+ y : n . position . y ,
2641+ parentId : block . data ?. parentId ,
2642+ } )
2643+ }
2644+ } )
2645+ } ,
2646+ [ blocks ]
2647+ )
2648+
2649+ /** Handles selection drag to detect potential parent containers for batch drops. */
2650+ const onSelectionDrag = useCallback (
2651+ ( _event : React . MouseEvent , nodes : Node [ ] ) => {
2652+ if ( nodes . length === 0 ) return
2653+
2654+ // Filter out nodes that can't be placed in containers
2655+ const eligibleNodes = nodes . filter ( ( n ) => {
2656+ if ( n . data ?. type === 'starter' ) return false
2657+ if ( n . type === 'subflowNode' ) return false
2658+ const block = blocks [ n . id ]
2659+ if ( block && TriggerUtils . isTriggerBlock ( block ) ) return false
2660+ return true
2661+ } )
2662+
2663+ // If no eligible nodes, clear any potential parent
2664+ if ( eligibleNodes . length === 0 ) {
2665+ if ( potentialParentId ) {
2666+ clearDragHighlights ( )
2667+ setPotentialParentId ( null )
2668+ }
2669+ return
2670+ }
2671+
2672+ // Calculate bounding box of all dragged nodes using absolute positions
2673+ let minX = Number . POSITIVE_INFINITY
2674+ let minY = Number . POSITIVE_INFINITY
2675+ let maxX = Number . NEGATIVE_INFINITY
2676+ let maxY = Number . NEGATIVE_INFINITY
2677+
2678+ eligibleNodes . forEach ( ( node ) => {
2679+ const absolutePos = getNodeAbsolutePosition ( node . id )
2680+ const block = blocks [ node . id ]
2681+ const width = BLOCK_DIMENSIONS . FIXED_WIDTH
2682+ const height = Math . max (
2683+ node . height || BLOCK_DIMENSIONS . MIN_HEIGHT ,
2684+ BLOCK_DIMENSIONS . MIN_HEIGHT
2685+ )
2686+
2687+ minX = Math . min ( minX , absolutePos . x )
2688+ minY = Math . min ( minY , absolutePos . y )
2689+ maxX = Math . max ( maxX , absolutePos . x + width )
2690+ maxY = Math . max ( maxY , absolutePos . y + height )
2691+ } )
2692+
2693+ // Use bounding box for intersection detection
2694+ const selectionRect = { left : minX , right : maxX , top : minY , bottom : maxY }
2695+
2696+ // Find containers that intersect with the selection bounding box
2697+ const allNodes = getNodes ( )
2698+ const intersectingContainers = allNodes
2699+ . filter ( ( containerNode ) => {
2700+ if ( containerNode . type !== 'subflowNode' ) return false
2701+ // Skip if any dragged node is this container
2702+ if ( nodes . some ( ( n ) => n . id === containerNode . id ) ) return false
2703+
2704+ const containerAbsolutePos = getNodeAbsolutePosition ( containerNode . id )
2705+ const containerRect = {
2706+ left : containerAbsolutePos . x ,
2707+ right :
2708+ containerAbsolutePos . x +
2709+ ( containerNode . data ?. width || CONTAINER_DIMENSIONS . DEFAULT_WIDTH ) ,
2710+ top : containerAbsolutePos . y ,
2711+ bottom :
2712+ containerAbsolutePos . y +
2713+ ( containerNode . data ?. height || CONTAINER_DIMENSIONS . DEFAULT_HEIGHT ) ,
2714+ }
2715+
2716+ // Check intersection
2717+ return (
2718+ selectionRect . left < containerRect . right &&
2719+ selectionRect . right > containerRect . left &&
2720+ selectionRect . top < containerRect . bottom &&
2721+ selectionRect . bottom > containerRect . top
2722+ )
2723+ } )
2724+ . map ( ( n ) => ( {
2725+ container : n ,
2726+ depth : getNodeDepth ( n . id ) ,
2727+ size :
2728+ ( n . data ?. width || CONTAINER_DIMENSIONS . DEFAULT_WIDTH ) *
2729+ ( n . data ?. height || CONTAINER_DIMENSIONS . DEFAULT_HEIGHT ) ,
2730+ } ) )
2731+
2732+ if ( intersectingContainers . length > 0 ) {
2733+ // Sort by depth first (deepest first), then by size
2734+ const sortedContainers = intersectingContainers . sort ( ( a , b ) => {
2735+ if ( a . depth !== b . depth ) return b . depth - a . depth
2736+ return a . size - b . size
2737+ } )
2738+
2739+ const bestMatch = sortedContainers [ 0 ]
2740+
2741+ if ( bestMatch . container . id !== potentialParentId ) {
2742+ clearDragHighlights ( )
2743+ setPotentialParentId ( bestMatch . container . id )
2744+
2745+ // Add highlight
2746+ const containerElement = document . querySelector ( `[data-id="${ bestMatch . container . id } "]` )
2747+ if ( containerElement ) {
2748+ if ( ( bestMatch . container . data as SubflowNodeData ) ?. kind === 'loop' ) {
2749+ containerElement . classList . add ( 'loop-node-drag-over' )
2750+ } else if ( ( bestMatch . container . data as SubflowNodeData ) ?. kind === 'parallel' ) {
2751+ containerElement . classList . add ( 'parallel-node-drag-over' )
2752+ }
2753+ document . body . style . cursor = 'copy'
2754+ }
2755+ }
2756+ } else if ( potentialParentId ) {
2757+ clearDragHighlights ( )
2758+ setPotentialParentId ( null )
2759+ }
2760+ } ,
2761+ [
2762+ blocks ,
2763+ getNodes ,
2764+ potentialParentId ,
2765+ getNodeAbsolutePosition ,
2766+ getNodeDepth ,
2767+ clearDragHighlights ,
2768+ ]
2769+ )
2770+
25852771 const onSelectionDragStop = useCallback (
25862772 ( _event : React . MouseEvent , nodes : any [ ] ) => {
25872773 requestAnimationFrame ( ( ) => setIsSelectionDragActive ( false ) )
2774+ clearDragHighlights ( )
25882775 if ( nodes . length === 0 ) return
25892776
25902777 const allNodes = getNodes ( )
25912778 const positionUpdates = computeClampedPositionUpdates ( nodes , blocks , allNodes )
25922779 collaborativeBatchUpdatePositions ( positionUpdates , {
25932780 previousPositions : multiNodeDragStartRef . current ,
25942781 } )
2782+
2783+ // Process parent updates if dropping into a subflow
2784+ if ( potentialParentId && potentialParentId !== dragStartParentId ) {
2785+ // Filter out nodes that cannot be moved into subflows
2786+ const validNodes = nodes . filter ( ( n : Node ) => {
2787+ const block = blocks [ n . id ]
2788+ if ( ! block ) return false
2789+ if ( n . data ?. type === 'starter' ) return false
2790+ if ( TriggerUtils . isTriggerBlock ( block ) ) return false
2791+ if ( n . type === 'subflowNode' ) return false
2792+ return true
2793+ } )
2794+
2795+ if ( validNodes . length > 0 ) {
2796+ const updates = validNodes . map ( ( n : Node ) => {
2797+ const edgesToRemove = edgesForDisplay . filter (
2798+ ( e ) => e . source === n . id || e . target === n . id
2799+ )
2800+ return {
2801+ blockId : n . id ,
2802+ newParentId : potentialParentId ,
2803+ affectedEdges : edgesToRemove ,
2804+ }
2805+ } )
2806+
2807+ collaborativeBatchUpdateParent ( updates )
2808+
2809+ logger . info ( 'Batch moved selection into subflow' , {
2810+ targetParentId : potentialParentId ,
2811+ nodeCount : validNodes . length ,
2812+ } )
2813+ }
2814+ }
2815+
2816+ // Clear drag state
2817+ setDragStartPosition ( null )
2818+ setPotentialParentId ( null )
25952819 multiNodeDragStartRef . current . clear ( )
25962820 } ,
2597- [ blocks , getNodes , collaborativeBatchUpdatePositions ]
2821+ [
2822+ blocks ,
2823+ getNodes ,
2824+ collaborativeBatchUpdatePositions ,
2825+ collaborativeBatchUpdateParent ,
2826+ potentialParentId ,
2827+ dragStartParentId ,
2828+ edgesForDisplay ,
2829+ clearDragHighlights ,
2830+ ]
25982831 )
25992832
26002833 const onPaneClick = useCallback ( ( ) => {
@@ -2818,6 +3051,8 @@ const WorkflowContent = React.memo(() => {
28183051 className = { `workflow-container h-full transition-opacity duration-150 ${ reactFlowStyles } ${ isCanvasReady ? 'opacity-100' : 'opacity-0' } ` }
28193052 onNodeDrag = { effectivePermissions . canEdit ? onNodeDrag : undefined }
28203053 onNodeDragStop = { effectivePermissions . canEdit ? onNodeDragStop : undefined }
3054+ onSelectionDragStart = { effectivePermissions . canEdit ? onSelectionDragStart : undefined }
3055+ onSelectionDrag = { effectivePermissions . canEdit ? onSelectionDrag : undefined }
28213056 onSelectionDragStop = { effectivePermissions . canEdit ? onSelectionDragStop : undefined }
28223057 onNodeDragStart = { effectivePermissions . canEdit ? onNodeDragStart : undefined }
28233058 snapToGrid = { snapToGrid }
0 commit comments