11package net .sourceforge .fddtools .ui .fx ;
22
3+ import javafx .animation .KeyFrame ;
34import javafx .animation .PauseTransition ;
5+ import javafx .animation .Timeline ;
46import javafx .application .Platform ;
57import javafx .css .PseudoClass ;
8+ import javafx .geometry .Bounds ;
69import javafx .scene .control .TreeCell ;
710import javafx .scene .control .ScrollBar ;
811import javafx .scene .control .Tooltip ;
@@ -35,6 +38,13 @@ enum DropType { INTO, BEFORE, AFTER }
3538 private ScrollBar vbar ; // vertical scroll bar cache
3639 private boolean dragActive = false ;
3740 private Double lockedV = null ;
41+
42+ // Edge scrolling configuration
43+ private static final double EDGE_SCROLL_THRESHOLD = 20.0 ; // pixels from edge to trigger scrolling
44+ private static final double SCROLL_SPEED = 0.02 ; // scroll increment per timer tick
45+ private Timeline edgeScrollTimer ;
46+ private boolean isScrollingUp = false ;
47+ private boolean isScrollingDown = false ;
3848 FDDTreeDragAndDropController (FDDTreeViewFX tree ){ this .tree = tree ; }
3949
4050 void attachTo (TreeCell <FDDINode > cell ){
@@ -54,14 +64,18 @@ void attachTo(TreeCell<FDDINode> cell){
5464 String id = item .getId () != null ? item .getId () : item .getName ();
5565 content .put (FDD_NODE_FORMAT , id ); content .putString (id );
5666 db .setContent (content );
57- try { var img = cell .snapshot (null , null ); if (img !=null ) db .setDragView (img , img .getWidth ()/4 , img .getHeight ()/2 ); } catch (Exception ex ) { LOGGER . debug ( "Snapshot for drag view failed: {}" , ex . getMessage ()); }
67+ try { var img = cell .snapshot (null , null ); if (img !=null ) db .setDragView (img , img .getWidth ()/4 , img .getHeight ()/2 ); } catch (Exception ignored ) { }
5868 e .consume ();
5969 });
6070 cell .setOnDragOver (e -> {
6171 FDDINode dragSource = tree .dragSourceNode ;
6272 FDDINode target = cell .getItem ();
6373 if (dragSource == null || target == null ) { e .consume (); return ; }
6474 if (dragSource == target ) { clear (cell , DROP_TARGET , INSERT_BEFORE , INSERT_AFTER ); e .consume (); return ; }
75+
76+ // Check for edge scrolling first
77+ handleEdgeScrolling (e .getSceneY ());
78+
6579 boolean okInto = tree .isValidReparent (dragSource , target );
6680 DropType dropType = deriveDropType (e .getY (), cell .getHeight ());
6781 boolean ok = switch (dropType ){
@@ -74,8 +88,8 @@ void attachTo(TreeCell<FDDINode> cell){
7488 // Only auto-expand when hovering the center (INTO) and near edges to avoid mid-view scroll
7589 boolean nearEdge = shouldAutoExpand (cell , /*edgeOnlyDefault*/ true );
7690 if (dropType == DropType .INTO && nearEdge ) scheduleAutoExpand (cell , state ); else cancelAutoExpand (state );
77- // Freeze scroll unless near edges
78- if (dragActive ) maintainScrollLock ( nearEdge );
91+ // During edge scrolling, don't maintain scroll lock - allow controlled scrolling
92+ if (dragActive && ! isScrollingUp && ! isScrollingDown ) maintainScrollLockAlways ( );
7993 } else {
8094 clear (cell , DROP_TARGET , INSERT_BEFORE , INSERT_AFTER );
8195 cancelAutoExpand (state );
@@ -89,34 +103,58 @@ void attachTo(TreeCell<FDDINode> cell){
89103 FDDINode target = cell .getItem ();
90104 if (dragSource != null && target != null && dragSource != target ){
91105 DropType dt = state .currentDropType == null ? DropType .INTO : state .currentDropType ;
92- double beforeV = captureV ();
93- switch (dt ){
94- case INTO -> {
95- if (tree .isValidReparent (dragSource , target )) {
96- CommandExecutionService .getInstance ().execute (new MoveNodeCommand (dragSource , target ));
97- // Incremental UI update avoids full refresh (prevents scroll jump)
98- tree .updateAfterMove (dragSource , target , -1 );
99- success = true ;
106+
107+ // Capture the current scroll position BEFORE any changes
108+ double preservedScrollPosition = captureV ();
109+
110+ // Enable scroll suppression to prevent automatic scrolling during move
111+ tree .setSuppressAutoScroll (true );
112+
113+ try {
114+ switch (dt ){
115+ case INTO -> {
116+ if (tree .isValidReparent (dragSource , target )) {
117+ CommandExecutionService .getInstance ().execute (new MoveNodeCommand (dragSource , target ));
118+ tree .updateAfterMove (dragSource , target , -1 );
119+ success = true ;
120+ }
100121 }
101- }
102- case BEFORE , AFTER -> {
103- FDDINode parent = ( FDDINode ) target . getParentNode ();
104- if ( parent != null && canInsertSibling ( dragSource , target )) {
105- int idx = parent . getChildren ().indexOf ( target ); if ( dt == DropType . AFTER ) idx += 1 ;
106- CommandExecutionService . getInstance (). execute ( new MoveNodeCommand ( dragSource , parent , idx ) );
107- tree . updateAfterMove ( dragSource , parent , idx ) ;
108- success = true ;
122+ case BEFORE , AFTER -> {
123+ FDDINode parent = ( FDDINode ) target . getParentNode ();
124+ if ( parent != null && canInsertSibling ( dragSource , target )) {
125+ int idx = parent . getChildren (). indexOf ( target ); if ( dt == DropType . AFTER ) idx += 1 ;
126+ CommandExecutionService . getInstance ().execute ( new MoveNodeCommand ( dragSource , parent , idx )) ;
127+ tree . updateAfterMove ( dragSource , parent , idx );
128+ success = true ;
129+ }
109130 }
110131 }
132+
133+ // After all operations are complete, restore the original scroll position
134+ if (success && preservedScrollPosition >= 0 ) {
135+ Platform .runLater (() -> {
136+ ensureVBar ();
137+ if (vbar != null ) {
138+ vbar .setValue (preservedScrollPosition );
139+ }
140+ // Delay disabling suppression to allow updateAfterMove's deferred selection to complete
141+ Platform .runLater (() -> {
142+ tree .setSuppressAutoScroll (false );
143+ });
144+ });
145+ } else {
146+ // If not successful, just disable suppression
147+ tree .setSuppressAutoScroll (false );
148+ }
149+ } catch (Exception ex ) {
150+ LOGGER .error ("Error during drag and drop operation" , ex );
151+ tree .setSuppressAutoScroll (false );
111152 }
112- double afterV = captureV ();
113- if (LOGGER .isDebugEnabled ()) LOGGER .debug ("DnD drop type={} source={} target={} success={} scrollV:{}->{}" , dt , safeName (dragSource ), safeName (target ), success , beforeV , afterV );
114- if (!success ){ showTransientTooltip (cell , invalidReason (dragSource , target , dt )); LOGGER .debug ("Invalid drop {} from {} to {}" , dt , dragSource .getName (), target .getName ()); }
153+
154+ if (!success ){ showTransientTooltip (cell , invalidReason (dragSource , target , dt )); }
115155 }
116156 clear (cell , DROP_TARGET , INSERT_BEFORE , INSERT_AFTER );
117157 e .setDropCompleted (success );
118- if (success ) restoreScrollPositionAsync ();
119- // avoid tree.refresh() to keep viewport stable
120158 e .consume ();
121159 });
122160 cell .setOnDragEntered (e -> {
@@ -127,7 +165,15 @@ void attachTo(TreeCell<FDDINode> cell){
127165 e .consume ();
128166 });
129167 cell .setOnDragExited (e -> { clear (cell , DROP_TARGET , INSERT_BEFORE , INSERT_AFTER ); cancelAutoExpand (state ); e .consume (); });
130- cell .setOnDragDone (e -> { tree .dragSourceNode = null ; clear (cell , DROP_TARGET , INSERT_BEFORE , INSERT_AFTER ); cancelAutoExpand (state ); restoreScrollPositionAsync (); dragActive =false ; lockedV =null ; });
168+ cell .setOnDragDone (e -> {
169+ tree .dragSourceNode = null ;
170+ clear (cell , DROP_TARGET , INSERT_BEFORE , INSERT_AFTER );
171+ cancelAutoExpand (state );
172+ stopEdgeScrolling (); // Stop any active edge scrolling
173+ restoreScrollPositionAsync ();
174+ dragActive =false ;
175+ lockedV =null ;
176+ });
131177 }
132178
133179 /**
@@ -173,7 +219,7 @@ private void scheduleAutoExpand(TreeCell<FDDINode> cell, CellState state){
173219 state .expandDelay = new PauseTransition (Duration .millis (700 )); // slightly longer to reduce churn
174220 state .expandDelay .setOnFinished (ev -> {
175221 try { if (cell .getTreeItem ()!=null && !cell .getTreeItem ().isExpanded ()) cell .getTreeItem ().setExpanded (true ); }
176- catch (Exception ex ){ LOGGER . debug ( "Auto-expand failed: {}" , ex . getMessage ()); }
222+ catch (Exception ignored ) { }
177223 finally { state .expandDelay = null ; }
178224 });
179225 state .expandDelay .play ();
@@ -206,22 +252,120 @@ private void ensureVBar(){
206252 }
207253 } catch (Exception ignored ) {}
208254 }
209- private void maintainScrollLock (boolean nearEdge ){
210- ensureVBar (); if (vbar == null ) return ;
211- if (!nearEdge ) {
212- if (lockedV == null ) lockedV = vbar .getValue ();
213- vbar .setValue (lockedV );
214- } else {
255+
256+ /**
257+ * Always maintain scroll lock during drag operations.
258+ * This prevents JavaFX's automatic scrolling behavior that occurs when dragging near edges.
259+ * The scroll position will only change if explicitly controlled by our logic.
260+ */
261+ private void maintainScrollLockAlways (){
262+ ensureVBar ();
263+ if (vbar == null ) return ;
264+
265+ // Capture the initial locked position if not already set
266+ if (lockedV == null ) {
215267 lockedV = vbar .getValue ();
216268 }
269+
270+ // Always restore the locked position to prevent any automatic scrolling
271+ if (Math .abs (vbar .getValue () - lockedV ) > 0.001 ) {
272+ vbar .setValue (lockedV );
273+ }
217274 }
218275 private double captureV (){ try { ensureVBar (); return vbar ==null ? -1 : vbar .getValue (); } catch (Exception ex ){ return -1 ; } }
219- private String safeName (FDDINode n ){ return n ==null ?"null" : (n .getName ()!=null ?n .getName ():"#" +n .hashCode ()); }
220276
221277 private void restoreScrollPositionAsync (){
222278 if (lockedV == null ) return ; ensureVBar (); if (vbar == null ) return ;
223279 Platform .runLater (() -> {
224280 try { vbar .setValue (lockedV ); } catch (Exception ignored ) {}
225281 });
226282 }
283+
284+ /**
285+ * Handles edge scrolling during drag operations.
286+ * When the user drags near the top or bottom edge of the TreeView,
287+ * automatically scroll to reveal more drop targets.
288+ */
289+ private void handleEdgeScrolling (double sceneY ) {
290+ if (!dragActive ) return ;
291+
292+ ensureVBar ();
293+ if (vbar == null ) return ;
294+
295+ // Get the TreeView bounds in scene coordinates
296+ Bounds treeBounds = tree .localToScene (tree .getBoundsInLocal ());
297+ double treeTop = treeBounds .getMinY ();
298+ double treeBottom = treeBounds .getMaxY ();
299+
300+ // Check if we're near the top or bottom edge
301+ boolean nearTop = sceneY < (treeTop + EDGE_SCROLL_THRESHOLD );
302+ boolean nearBottom = sceneY > (treeBottom - EDGE_SCROLL_THRESHOLD );
303+
304+ if (nearTop && !isScrollingUp ) {
305+ startEdgeScrolling (true ); // scroll up
306+ } else if (nearBottom && !isScrollingDown ) {
307+ startEdgeScrolling (false ); // scroll down
308+ } else if (!nearTop && !nearBottom ) {
309+ stopEdgeScrolling ();
310+ }
311+ }
312+
313+ /**
314+ * Handles TreeView-level drag over events for edge scrolling detection.
315+ * This catches cases where the user drags outside visible cells.
316+ */
317+ void handleTreeViewDragOver (javafx .scene .input .DragEvent e ) {
318+ if (dragActive && tree .dragSourceNode != null ) {
319+ handleEdgeScrolling (e .getSceneY ());
320+ }
321+ }
322+
323+ /**
324+ * Starts continuous scrolling in the specified direction.
325+ */
326+ private void startEdgeScrolling (boolean scrollUp ) {
327+ stopEdgeScrolling (); // Stop any existing scrolling
328+
329+ isScrollingUp = scrollUp ;
330+ isScrollingDown = !scrollUp ;
331+
332+ // Create a timer that continuously scrolls
333+ edgeScrollTimer = new Timeline (new KeyFrame (Duration .millis (50 ), e -> {
334+ if (vbar != null && dragActive && (isScrollingUp || isScrollingDown )) {
335+ double currentValue = vbar .getValue ();
336+ double newValue ;
337+
338+ if (scrollUp ) {
339+ newValue = Math .max (0 , currentValue - SCROLL_SPEED );
340+ } else {
341+ newValue = Math .min (1 , currentValue + SCROLL_SPEED );
342+ }
343+
344+ if (Math .abs (newValue - currentValue ) > 0.001 ) {
345+ vbar .setValue (newValue );
346+ // Update locked position since this is user-initiated scrolling
347+ lockedV = newValue ;
348+ } else {
349+ // No scroll applied: difference too small
350+ }
351+ } else {
352+ // Edge scroll timer skipped: conditions not met
353+ }
354+ }));
355+ edgeScrollTimer .setCycleCount (Timeline .INDEFINITE );
356+ edgeScrollTimer .play ();
357+ }
358+
359+ /**
360+ * Stops edge scrolling.
361+ */
362+ private void stopEdgeScrolling () {
363+ if (edgeScrollTimer != null ) {
364+ edgeScrollTimer .stop ();
365+ edgeScrollTimer = null ;
366+ }
367+
368+ isScrollingUp = false ;
369+ isScrollingDown = false ;
370+ }
227371}
0 commit comments