@@ -24,6 +24,9 @@ class WorkflowBuilder {
2424 this . isDirty = false ;
2525 this . isSaving = false ;
2626 this . lastSavedWorkflowSnapshot = null ;
27+ this . nodeStackOrder = new Map ( ) ;
28+ this . nextNodeStackOrder = 1 ;
29+ this . draggingNodeId = null ;
2730
2831 // Pan & zoom state
2932 this . panX = 0 ;
@@ -253,6 +256,10 @@ class WorkflowBuilder {
253256
254257 setupEventListeners ( ) {
255258 document . getElementById ( 'btnSave' ) . addEventListener ( 'click' , ( ) => this . saveWorkflow ( ) ) ;
259+ const autoArrangeBtn = document . getElementById ( 'btnAutoArrange' ) ;
260+ if ( autoArrangeBtn ) {
261+ autoArrangeBtn . addEventListener ( 'click' , ( ) => this . autoArrangeNodes ( ) ) ;
262+ }
256263 const deleteConnectionBtn = document . getElementById ( 'btnDeleteConnection' ) ;
257264 if ( deleteConnectionBtn ) {
258265 deleteConnectionBtn . addEventListener ( 'click' , ( ) => this . deleteSelectedConnection ( ) ) ;
@@ -457,6 +464,11 @@ class WorkflowBuilder {
457464 } ) ) ;
458465 this . nodeIdCounter = maxId + 1 ;
459466 }
467+
468+ this . initializeNodeStackOrder ( ) ;
469+ if ( this . layoutNeedsNormalization ( ) ) {
470+ this . autoArrangeNodes ( { suppressRender : true , silent : true } ) ;
471+ }
460472 } else {
461473 console . error ( 'Failed to load workflow:' , data . error ) ;
462474 this . setBuilderMessage ( 'danger' , 'Failed to load workflow builder data.' , [ data . error || 'Unknown error' ] ) ;
@@ -570,6 +582,7 @@ class WorkflowBuilder {
570582 data : { }
571583 } ;
572584 this . nodes . push ( node ) ;
585+ this . bringNodeToFront ( node . id ) ;
573586 this . render ( ) ;
574587 }
575588
@@ -582,9 +595,194 @@ class WorkflowBuilder {
582595 data : this . getDefaultNodeData ( type )
583596 } ;
584597 this . nodes . push ( node ) ;
598+ this . bringNodeToFront ( node . id ) ;
585599 this . render ( ) ;
586600 }
587601
602+ initializeNodeStackOrder ( ) {
603+ this . nodeStackOrder = new Map ( ) ;
604+ this . nextNodeStackOrder = 1 ;
605+ this . nodes . forEach ( ( node ) => {
606+ this . nodeStackOrder . set ( node . id , this . nextNodeStackOrder ++ ) ;
607+ } ) ;
608+ }
609+
610+ bringNodeToFront ( nodeId ) {
611+ if ( ! nodeId ) return ;
612+ this . nodeStackOrder . set ( nodeId , this . nextNodeStackOrder ++ ) ;
613+ }
614+
615+ getEstimatedNodeWidth ( type ) {
616+ switch ( type ) {
617+ case 'workflow_settings' :
618+ return 320 ;
619+ case 'form' :
620+ case 'sub_workflow' :
621+ return 300 ;
622+ case 'stage' :
623+ case 'approval' :
624+ case 'approval_config' :
625+ return 280 ;
626+ case 'action' :
627+ case 'email' :
628+ case 'condition' :
629+ return 260 ;
630+ case 'join' :
631+ return 140 ;
632+ case 'start' :
633+ case 'end' :
634+ return 180 ;
635+ default :
636+ return 240 ;
637+ }
638+ }
639+
640+ getEstimatedNodeHeight ( type ) {
641+ switch ( type ) {
642+ case 'workflow_settings' :
643+ return 180 ;
644+ case 'form' :
645+ case 'sub_workflow' :
646+ return 170 ;
647+ case 'stage' :
648+ case 'approval' :
649+ case 'approval_config' :
650+ return 160 ;
651+ case 'action' :
652+ case 'email' :
653+ case 'condition' :
654+ return 150 ;
655+ case 'join' :
656+ return 96 ;
657+ default :
658+ return 140 ;
659+ }
660+ }
661+
662+ nodesOverlap ( a , b , padding = 16 ) {
663+ const aWidth = this . getEstimatedNodeWidth ( a . type ) ;
664+ const aHeight = this . getEstimatedNodeHeight ( a . type ) ;
665+ const bWidth = this . getEstimatedNodeWidth ( b . type ) ;
666+ const bHeight = this . getEstimatedNodeHeight ( b . type ) ;
667+
668+ return ! (
669+ a . x + aWidth + padding <= b . x
670+ || b . x + bWidth + padding <= a . x
671+ || a . y + aHeight + padding <= b . y
672+ || b . y + bHeight + padding <= a . y
673+ ) ;
674+ }
675+
676+ layoutNeedsNormalization ( ) {
677+ const nodes = [ ...this . nodes ] ;
678+ const sameLaneThreshold = 110 ;
679+ const minimumGap = 56 ;
680+
681+ for ( let i = 0 ; i < nodes . length ; i ++ ) {
682+ for ( let j = i + 1 ; j < nodes . length ; j ++ ) {
683+ if ( this . nodesOverlap ( nodes [ i ] , nodes [ j ] , 20 ) ) {
684+ return true ;
685+ }
686+ }
687+ }
688+
689+ const byLane = [ ...nodes ] . sort ( ( a , b ) => ( a . y - b . y ) || ( a . x - b . x ) ) ;
690+ for ( let i = 1 ; i < byLane . length ; i ++ ) {
691+ const prev = byLane [ i - 1 ] ;
692+ const current = byLane [ i ] ;
693+ if ( Math . abs ( current . y - prev . y ) > sameLaneThreshold || current . x < prev . x ) {
694+ continue ;
695+ }
696+ const requiredX = prev . x + this . getEstimatedNodeWidth ( prev . type ) + minimumGap ;
697+ if ( current . x < requiredX ) {
698+ return true ;
699+ }
700+ }
701+
702+ return false ;
703+ }
704+
705+ resolveNodeCollisions ( ) {
706+ const sortedNodes = [ ...this . nodes ] . sort ( ( a , b ) => ( a . x - b . x ) || ( a . y - b . y ) ) ;
707+
708+ sortedNodes . forEach ( ( node , index ) => {
709+ let attempts = 0 ;
710+ while ( attempts < 24 ) {
711+ const blockingNode = sortedNodes
712+ . slice ( 0 , index )
713+ . find ( ( candidate ) => this . nodesOverlap ( candidate , node , 12 ) ) ;
714+ if ( ! blockingNode ) {
715+ break ;
716+ }
717+
718+ const sameLane = Math . abs ( blockingNode . y - node . y ) <= 110 ;
719+ if ( sameLane ) {
720+ node . x = Math . max (
721+ node . x ,
722+ blockingNode . x + this . getEstimatedNodeWidth ( blockingNode . type ) + 72 ,
723+ ) ;
724+ } else {
725+ node . y = Math . max (
726+ node . y ,
727+ blockingNode . y + this . getEstimatedNodeHeight ( blockingNode . type ) + 52 ,
728+ ) ;
729+ }
730+ attempts += 1 ;
731+ }
732+ } ) ;
733+ }
734+
735+ autoArrangeNodes ( options = { } ) {
736+ const { suppressRender = false , silent = false } = options ;
737+ if ( ! this . nodes . length ) return ;
738+
739+ const laneThreshold = 120 ;
740+ const horizontalGap = 84 ;
741+ const sortedByY = [ ...this . nodes ] . sort ( ( a , b ) => ( a . y - b . y ) || ( a . x - b . x ) ) ;
742+ const lanes = [ ] ;
743+
744+ sortedByY . forEach ( ( node ) => {
745+ let lane = lanes . find ( ( candidate ) => Math . abs ( candidate . centerY - node . y ) <= laneThreshold ) ;
746+ if ( ! lane ) {
747+ lane = { centerY : node . y , nodes : [ ] } ;
748+ lanes . push ( lane ) ;
749+ }
750+ lane . nodes . push ( node ) ;
751+ lane . centerY = Math . round ( lane . nodes . reduce ( ( total , entry ) => total + entry . y , 0 ) / lane . nodes . length ) ;
752+ } ) ;
753+
754+ lanes
755+ . sort ( ( a , b ) => a . centerY - b . centerY )
756+ . forEach ( ( lane ) => {
757+ lane . nodes . sort ( ( a , b ) => a . x - b . x ) ;
758+ let cursorX = Math . max ( 80 , lane . nodes [ 0 ] ?. x || 80 ) ;
759+ lane . nodes . forEach ( ( node , index ) => {
760+ if ( index === 0 ) {
761+ node . x = Math . max ( 80 , node . x ) ;
762+ } else {
763+ node . x = Math . max ( node . x , cursorX ) ;
764+ }
765+ cursorX = node . x + this . getEstimatedNodeWidth ( node . type ) + horizontalGap ;
766+ } ) ;
767+ } ) ;
768+
769+ this . resolveNodeCollisions ( ) ;
770+ this . updateWorkspaceBounds ( ) ;
771+
772+ if ( ! silent ) {
773+ this . setBuilderMessage (
774+ 'info' ,
775+ 'Workflow layout auto-arranged.' ,
776+ [ 'Nodes were spaced out to reduce overlap and make dragging easier.' ] ,
777+ true ,
778+ ) ;
779+ }
780+
781+ if ( ! suppressRender ) {
782+ this . render ( ) ;
783+ }
784+ }
785+
588786 getDefaultNodeData ( type ) {
589787 switch ( type ) {
590788 case 'form' :
@@ -2474,7 +2672,10 @@ class WorkflowBuilder {
24742672 this . transformWrapper . querySelectorAll ( '.workflow-node' ) . forEach ( n => n . remove ( ) ) ;
24752673
24762674 // Render each node into the transform wrapper (alongside the SVG)
2477- this . nodes . forEach ( node => {
2675+ const orderedNodes = [ ...this . nodes ] . sort ( ( a , b ) => {
2676+ return ( this . nodeStackOrder . get ( a . id ) || 0 ) - ( this . nodeStackOrder . get ( b . id ) || 0 ) ;
2677+ } ) ;
2678+ orderedNodes . forEach ( node => {
24782679 console . log ( 'Creating node element for:' , node ) ;
24792680 const nodeEl = this . createNodeElement ( node ) ;
24802681 this . transformWrapper . appendChild ( nodeEl ) ;
@@ -2500,6 +2701,9 @@ class WorkflowBuilder {
25002701 if ( this . selectedNode === node . id ) {
25012702 div . className += ' selected' ;
25022703 }
2704+ if ( this . draggingNodeId === node . id ) {
2705+ div . className += ' dragging' ;
2706+ }
25032707 div . style . left = `${ node . x } px` ;
25042708 div . style . top = `${ node . y } px` ;
25052709 div . dataset . nodeId = node . id ;
@@ -2696,6 +2900,9 @@ class WorkflowBuilder {
26962900
26972901 startDragNode ( e , node ) {
26982902 this . isDraggingNode = true ;
2903+ this . draggingNodeId = node . id ;
2904+ this . bringNodeToFront ( node . id ) ;
2905+ this . render ( ) ;
26992906 const startX = e . clientX ;
27002907 const startY = e . clientY ;
27012908 const nodeStartX = node . x ;
@@ -2712,6 +2919,8 @@ class WorkflowBuilder {
27122919
27132920 const onMouseUp = ( ) => {
27142921 this . isDraggingNode = false ;
2922+ this . draggingNodeId = null ;
2923+ this . render ( ) ;
27152924 document . removeEventListener ( 'mousemove' , onMouseMove ) ;
27162925 document . removeEventListener ( 'mouseup' , onMouseUp ) ;
27172926 } ;
0 commit comments