This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 66d37b5718 Add DnD effects to hop web, fixes #8499 (#8512)
66d37b5718 is described below

commit 66d37b571893382851772512af003621471bf727
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Sep 21 18:54:26 2026 +0200

    Add DnD effects to hop web, fixes #8499 (#8512)
---
 .../main/java/org/apache/hop/ui/hopgui/HopWeb.java |   1 +
 .../org/apache/hop/ui/hopgui/HopWebEntryPoint.java |   2 +
 .../org/apache/hop/ui/hopgui/dark-mode.css         |   8 +
 .../apache/hop/ui/hopgui/dnd-cursor-passthrough.js |  51 +++++
 .../org/apache/hop/ui/hopgui/light-mode.css        |   8 +
 .../hop/ui/hopgui/perspective/IHopPerspective.java |  25 +++
 .../ui/hopgui/perspective/ShellTabDropFrame.java   |  99 +++++++++
 .../hop/ui/hopgui/perspective/TabDropFrame.java    |  52 +++++
 .../hop/ui/hopgui/perspective/TabItemReorder.java  | 238 +++++++++++++--------
 .../hop/ui/hopgui/perspective/WebTabDropFrame.java |  69 ++++++
 .../perspective/explorer/ExplorerPerspective.java  |  22 +-
 11 files changed, 483 insertions(+), 92 deletions(-)

diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index df5c5ab51f..e3cd2da161 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -171,6 +171,7 @@ public class HopWeb implements ApplicationConfiguration {
             "org/apache/hop/ui/hopgui/context-dialog-svg.js",
             "org/apache/hop/ui/hopgui/log-console.js",
             "org/apache/hop/ui/hopgui/gc-pending-images.js",
+            "org/apache/hop/ui/hopgui/dnd-cursor-passthrough.js",
             "org/apache/hop/ui/hopgui/monaco-editor.js",
             "org/apache/hop/ui/hopgui/mac-command-keys.js")
         .forEach(
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index 09c143c21b..bbac59e6ae 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -200,6 +200,8 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
     jsLoader.require(resourceManager.getLocation("js/log-console.js"));
     // RAP's GC leaves image onload handlers alive after dispose; see the 
script.
     jsLoader.require(resourceManager.getLocation("js/gc-pending-images.js"));
+    // RAP's drag cursor icon would otherwise catch the pointer on a fast 
move; see the script.
+    
jsLoader.require(resourceManager.getLocation("js/dnd-cursor-passthrough.js"));
     jsLoader.require(resourceManager.getLocation("js/monaco-editor.js"));
     // Map Mac Command key to Ctrl so RAP ACTIVE_KEYS (CTRL+S etc.) match when 
user presses Cmd+S
     String macKeysLocation = 
resourceManager.getLocation("js/mac-command-keys.js");
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
index f4226a5b0e..f5f2fbbcaf 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
@@ -2522,6 +2522,14 @@ FileUpload-FocusIndicator {
     opacity: 1;
 }
 
+/* Frame marking where a dragged tab would land (TabItemReorder / 
WebTabDropFrame) */
+
+Composite.hopDropFrame {
+    background-color: transparent;
+    border: 3px solid #3584e4;
+    padding: 0;
+}
+
 /* Sidebar button styling */
 
 Composite.sidebarButton {
diff --git 
a/rap/src/main/resources/org/apache/hop/ui/hopgui/dnd-cursor-passthrough.js 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/dnd-cursor-passthrough.js
new file mode 100644
index 0000000000..d493e82130
--- /dev/null
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/dnd-cursor-passthrough.js
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+//# sourceURL=dnd-cursor-passthrough.js
+
+/**
+ * Keeps RAP's drag-and-drop cursor icon from catching the pointer it follows.
+ *
+ * During a drag the RAP client draws the operation icon (move / copy / 
no-drop) as a small
+ * absolutely positioned widget 5 px right of and 15 px below the pointer, on 
top of everything
+ * else. Its element is an ordinary div, so a quick downward move lands the 
pointer inside it:
+ * the drop target is then resolved from the icon, which has none, and the 
client tells the
+ * server the drag left the folder or canvas it was over. Tab drags towards 
the bottom of a
+ * folder lost their drop that way (RAP 4.7.0 / 4.8.0, unchanged upstream).
+ *
+ * The icon is feedback, not a target: let the pointer pass through it.
+ */
+(function () {
+    "use strict";
+
+    if (typeof rwt === "undefined" || !rwt.event || 
!rwt.event.DragAndDropHandler) {
+        return;
+    }
+    var handler = rwt.event.DragAndDropHandler.getInstance();
+    var renderCursor = handler._renderCursor;
+    if (typeof renderCursor !== "function" || renderCursor.hopPassesPointer) {
+        return;
+    }
+    handler._renderCursor = function () {
+        renderCursor.apply(this, arguments);
+        var cursor = this.__cursor;
+        var element = cursor && cursor.getElement ? cursor.getElement() : null;
+        if (element && element.style.pointerEvents !== "none") {
+            element.style.pointerEvents = "none";
+        }
+    };
+    handler._renderCursor.hopPassesPointer = true;
+})();
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
index 110c270ff7..dc1a3d9e86 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
@@ -2528,6 +2528,14 @@ FileUpload-FocusIndicator {
     opacity: 1;
 }
 
+/* Frame marking where a dragged tab would land (TabItemReorder / 
WebTabDropFrame) */
+
+Composite.hopDropFrame {
+    background-color: transparent;
+    border: 3px solid #3584e4;
+    padding: 0;
+}
+
 /* Sidebar button styling */
 
 Composite.sidebarButton {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java
index 3d5c84836b..27c2716b5f 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/IHopPerspective.java
@@ -25,6 +25,7 @@ import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
 import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 
@@ -148,6 +149,30 @@ public interface IHopPerspective extends 
IActionContextHandlersProvider {
     // Do nothing by default
   }
 
+  /**
+   * Remember the tab currently being dragged, or {@code null} when a drag 
ends. Every folder of a
+   * perspective has its own drop target, but only the folder the drag started 
on knows the tab; the
+   * others read it here so a drop from one pane into another can complete. 
This is what makes
+   * cross-pane tab drops work in Hop Web, where the transfer types are not on 
the drop event by the
+   * time the drop is accepted. Held on the (per-session) perspective, so two 
sessions never see
+   * each other's drag.
+   *
+   * @param tabItem the tab being dragged, or null
+   */
+  default void setDraggedTabItem(CTabItem tabItem) {
+    // Do nothing by default
+  }
+
+  /**
+   * The tab currently being dragged in this perspective, or null when none is.
+   *
+   * @return the dragged tab, or null
+   * @see #setDraggedTabItem(CTabItem)
+   */
+  default CTabItem getDraggedTabItem() {
+    return null;
+  }
+
   /** Drop into the folder itself (no split). */
   int DROP_ZONE_CENTER = 0;
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/ShellTabDropFrame.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/ShellTabDropFrame.java
new file mode 100644
index 0000000000..9c96ac873e
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/ShellTabDropFrame.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.graphics.Region;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * The desktop drop frame: a floating {@code SWT.NO_TRIM | SWT.ON_TOP} shell 
shaped into a hollow
+ * rectangle with a {@link Region}. The cut-out centre is not part of the 
window, so the drag cursor
+ * passes through it to the folder underneath (no enter/leave oscillation), 
and only the thin border
+ * is painted (no compositing flicker).
+ */
+final class ShellTabDropFrame implements TabDropFrame {
+
+  private Shell shell;
+
+  /** The frame-shaped region applied to {@link #shell}; disposed with it. */
+  private Region region;
+
+  @Override
+  public void show(CTabFolder folder, Rectangle bounds) {
+    if (folder.isDisposed() || bounds.width <= 0 || bounds.height <= 0) {
+      hide();
+      return;
+    }
+    Point topLeft = folder.toDisplay(bounds.x, bounds.y);
+    Rectangle onDisplay = new Rectangle(topLeft.x, topLeft.y, bounds.width, 
bounds.height);
+    try {
+      if (shell == null || shell.isDisposed()) {
+        shell = new Shell(folder.getShell(), SWT.NO_TRIM | SWT.ON_TOP);
+        
shell.setBackground(folder.getDisplay().getSystemColor(SWT.COLOR_LIST_SELECTION));
+        shell.addDisposeListener(e -> disposeRegion());
+      }
+      shell.setBounds(onDisplay);
+      applyFrameRegion(onDisplay.width, onDisplay.height);
+      if (!shell.getVisible()) {
+        shell.setVisible(true);
+      }
+    } catch (Exception e) {
+      hide();
+    }
+  }
+
+  @Override
+  public void hide() {
+    if (shell != null && !shell.isDisposed() && shell.getVisible()) {
+      shell.setVisible(false);
+    }
+  }
+
+  @Override
+  public void dispose() {
+    if (shell != null && !shell.isDisposed()) {
+      shell.dispose();
+    }
+    shell = null;
+    disposeRegion();
+  }
+
+  /** Shape the shell as a hollow rectangle frame of the given size. */
+  private void applyFrameRegion(int width, int height) {
+    int border = Math.max(3, Math.min(8, Math.min(width, height) / 12));
+    Region frame = new Region(shell.getDisplay());
+    frame.add(0, 0, width, height);
+    if (width > 2 * border && height > 2 * border) {
+      frame.subtract(border, border, width - 2 * border, height - 2 * border);
+    }
+    shell.setRegion(frame);
+    disposeRegion();
+    region = frame;
+  }
+
+  private void disposeRegion() {
+    if (region != null && !region.isDisposed()) {
+      region.dispose();
+    }
+    region = null;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabDropFrame.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabDropFrame.java
new file mode 100644
index 0000000000..5283c75151
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabDropFrame.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective;
+
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.graphics.Rectangle;
+
+/**
+ * The frame {@link TabItemReorder} draws while a tab is dragged over a 
folder: around the tab the
+ * drop would swap with, or around the half of the folder an edge drop would 
split off. The desktop
+ * draws it as a floating shell, Hop Web as a child of the folder; see {@link 
#create()}.
+ */
+interface TabDropFrame {
+
+  /**
+   * Show the frame around a rectangle of the folder, moving it when it is 
already up.
+   *
+   * @param folder the folder the drag is over
+   * @param bounds the rectangle to frame, in folder coordinates
+   */
+  void show(CTabFolder folder, Rectangle bounds);
+
+  /** Take the frame down; a no-op when it is not up. */
+  void hide();
+
+  /** Release whatever the frame holds. */
+  void dispose();
+
+  /** The frame for the platform Hop runs on. */
+  static TabDropFrame create() {
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      return new WebTabDropFrame();
+    }
+    return new ShellTabDropFrame();
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java
index 5ccb057069..fcb26b3b30 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/TabItemReorder.java
@@ -41,12 +41,27 @@ import org.eclipse.swt.graphics.GC;
 import org.eclipse.swt.graphics.Image;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
-import org.eclipse.swt.graphics.Region;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Listener;
-import org.eclipse.swt.widgets.Shell;
 
+/**
+ * Drag-and-drop reordering and splitting of editor tabs, on the desktop and 
in Hop Web alike.
+ *
+ * <ul>
+ *   <li>Drop a tab on another tab of the same folder to reorder them.
+ *   <li>Drop a tab in the outer {@link #EDGE_FRACTION} band of a folder to 
split it off into a new
+ *       pane on that side (right / bottom / left / top).
+ *   <li>Drop a tab into another open pane to move it there.
+ * </ul>
+ *
+ * <p>All of it works in Hop Web too, with three RAP-specific adjustments: the 
drop frame is a child
+ * composite of the folder rather than a floating shell ({@link 
TabDropFrame}); the tab being
+ * dragged is settled from the MouseDown that RAP delivers just after the 
DragStart ({@link
+ * #settleDragItem}); and the tab is shared across the panes of a perspective 
({@link
+ * #activeDragItem()}), because a pane that did not start the drag has no 
{@code dragItem} of its
+ * own and RAP has dropped the transfer types from the event by the time the 
drop is accepted.
+ */
 public class TabItemReorder {
 
   /** Fraction of the folder width/height near an edge that triggers a 
split-on-drop. */
@@ -63,6 +78,14 @@ public class TabItemReorder {
    */
   private CTabItem mouseDownItem;
 
+  /**
+   * True from {@code dragStart} to {@code dragFinished} of a drag that 
started on this folder. Hop
+   * Web needs it: RAP hands the folder its {@code DragStart} before the 
{@code MouseDown} of the
+   * same request, so {@link #dragItem} can only be settled from {@link 
#mouseDownItem} once the
+   * drag is under way, and only by the folder the drag started on (see {@link 
#settleDragItem}).
+   */
+  private boolean dragging;
+
   /**
    * Tab under the cursor during a tab drag; drop will swap with this tab. 
Painted as drop
    * indicator.
@@ -80,31 +103,39 @@ public class TabItemReorder {
   private int lastDragOverZone = IHopPerspective.DROP_ZONE_CENTER;
 
   /**
-   * Overlay marking where a split-on-drop would land; a hollow frame (see 
{@link #overlayRegion})
-   * so the cursor passes through its centre to the folder underneath instead 
of stealing the drag.
+   * The frame marking where the drop would land: around the tab to swap with 
(Hop Web only, the
+   * desktop paints that one on the folder) or around the half of the folder 
an edge drop would
+   * split off.
    */
-  private Shell zoneOverlay;
-
-  /** The frame-shaped region applied to {@link #zoneOverlay}; disposed with 
it. */
-  private Region overlayRegion;
+  private final TabDropFrame dropFrame = TabDropFrame.create();
 
-  /** Zone/folder the overlay currently reflects, so we only touch the Shell 
when they change. */
+  /** What the frame currently shows, so it is only touched when that changes. 
*/
   private int shownZone = IHopPerspective.DROP_ZONE_CENTER;
 
+  private CTabItem shownTab;
   private CTabFolder shownFolder;
 
   public TabItemReorder(IHopPerspective perspective, CTabFolder folder) {
     this.perspective = perspective;
+    folder.addListener(SWT.Dispose, e -> dropFrame.dispose());
 
     // Remember which tab the pointer went down on: dragStart can't reliably 
re-derive it from the
-    // cursor location on the first macOS drag of a session.
+    // cursor location on the first macOS drag of a session, and in Hop Web it 
runs before this
+    // listener. Forget it again on release so that a later drag never picks 
up a stale tab.
     folder.addListener(
         SWT.MouseDown,
         e -> {
           if (e.button == 1) {
             mouseDownItem = folder.getItem(new Point(e.x, e.y));
+            // In Hop Web, DragStart of the same request may have already run 
and left the drag
+            // waiting for this tab (it runs before this listener). Settle it 
now, in the same
+            // request as DragStart, so even a fast drop - one that reaches 
the target pane before
+            // any drag-over settles the tab there - already has it. Otherwise 
the merge only works
+            // when the pointer lingers long enough for a source drag-over to 
fire first.
+            settleDragItem(folder);
           }
         });
+    folder.addListener(SWT.MouseUp, e -> mouseDownItem = null);
 
     final DragSource source = new DragSource(folder, DND.DROP_MOVE);
     source.setTransfer(TabTransfer.INSTANCE);
@@ -115,13 +146,22 @@ public class TabItemReorder {
           @Override
           public void dragStart(DragSourceEvent event) {
             dragItem = itemBeingDragged(folder);
+            perspective.setDraggedTabItem(dragItem);
 
             if (dragItem == null) {
+              if (EnvironmentUtils.getInstance().isWeb()) {
+                // RAP delivers this before the MouseDown of the same request, 
and by the time the
+                // drag threshold is passed the pointer has often left the tab 
strip. Keep the drag
+                // alive: settleDragItem picks the tab up from that MouseDown 
on the next event.
+                dragging = true;
+                return;
+              }
               // Couldn't identify the tab (e.g. drag not started from a tab): 
cancel cleanly rather
               // than begin a data-less drag that would silently do nothing on 
drop.
               event.doit = false;
               return;
             }
+            dragging = true;
             Rectangle columnBounds = dragItem.getBounds();
             if (dragImage != null) {
               dragImage.dispose();
@@ -149,12 +189,16 @@ public class TabItemReorder {
 
           @Override
           public void dragSetData(DragSourceEvent event) {
+            settleDragItem(folder);
             event.data = dragItem;
           }
 
           @Override
           public void dragFinished(DragSourceEvent event) {
             dragItem = null;
+            mouseDownItem = null;
+            dragging = false;
+            perspective.setDraggedTabItem(null);
             if (EnvironmentUtils.getInstance().isWeb()) {
               return;
             }
@@ -172,7 +216,8 @@ public class TabItemReorder {
         FileTransfer.getInstance(),
         MetadataTransfer.INSTANCE);
 
-    // Paint a drop indicator (highlight) on the tab we're about to swap with
+    // Paint a drop indicator (highlight) on the tab we're about to swap with. 
RAP delivers no paint
+    // events for a folder; Hop Web shows the drop frame around that tab 
instead (updateDropFrame).
     Listener paintListener =
         event -> {
           if (dropTargetTab == null || dragItem == null || 
dropTargetTab.isDisposed()) {
@@ -196,6 +241,7 @@ public class TabItemReorder {
 
           @Override
           public void dragEnter(DropTargetEvent event) {
+            settleDragItem(folder);
             lastDragOverZone = IHopPerspective.DROP_ZONE_CENTER;
             isFileDrop = isFileTransferType(event);
             isMetadataDrop = isMetadataTransferType(event);
@@ -229,6 +275,7 @@ public class TabItemReorder {
 
           @Override
           public void dragOver(DropTargetEvent event) {
+            settleDragItem(folder);
             if (!isFileDrop && !isMetadataDrop) {
               isFileDrop = isFileTransferType(event);
               isMetadataDrop = isMetadataTransferType(event);
@@ -248,7 +295,9 @@ public class TabItemReorder {
             handleDragEvent(event);
             // Update drop indicator (tab reorder) and split zone (edge drop) 
feedback.
             boolean tabDrag =
-                !isFileDrop && !isMetadataDrop && (dragItem != null || 
hasActiveTabTransfer(event));
+                !isFileDrop
+                    && !isMetadataDrop
+                    && (activeDragItem() != null || 
hasActiveTabTransfer(event));
             if (tabDrag && event.detail != DND.DROP_NONE) {
               Point p = eventPoint(folder, event);
               CTabItem over = folder.getItem(p);
@@ -272,7 +321,7 @@ public class TabItemReorder {
               // Remember the zone while we're genuinely over the folder; 
drop() falls back to this
               // if its own event coordinates come through degenerate.
               lastDragOverZone = newZone;
-              updateZoneOverlay(folder);
+              updateDropFrame(folder);
             } else {
               clearDropFeedback(folder);
             }
@@ -280,6 +329,7 @@ public class TabItemReorder {
 
           @Override
           public void drop(DropTargetEvent event) {
+            settleDragItem(folder);
             handleDragEvent(event);
             // Resolve the drop point from the event's own display 
coordinates, which — unlike
             // Display.getCursorLocation() — are the actual drop location and 
are reliable even on
@@ -302,7 +352,7 @@ public class TabItemReorder {
               receiver.openDroppedFiles(paths);
               return;
             }
-            boolean tabDrag = dragItem != null || hasActiveTabTransfer(event);
+            boolean tabDrag = activeDragItem() != null || 
hasActiveTabTransfer(event);
             if (LogChannel.UI.isDebug() && tabDrag) {
               LogChannel.UI.logDebug(
                   "Tab drop: detail="
@@ -407,7 +457,13 @@ public class TabItemReorder {
           }
 
           private boolean isDropSupported(CTabFolder folder, DropTargetEvent 
event) {
-            if (dragItem != null && !dragItem.isDisposed()) {
+            CTabItem item = activeDragItem();
+            if (item != null) {
+              // A tab dragged in from another pane can always land here (join 
or split), and its
+              // drop point maps unreliably across panes in Hop Web, so accept 
without resolving it.
+              if (item.getParent() != folder) {
+                return true;
+              }
               // Use the event's own coordinates rather than 
Display.getCursorLocation(): the latter
               // can read stale on the first macOS drag, wrongly collapsing an 
edge drop to CENTER
               // and forcing event.detail to DROP_NONE (the "first drop does 
nothing" bug).
@@ -415,11 +471,9 @@ public class TabItemReorder {
               if (folder.getItem(point) != null) {
                 return true;
               }
-              // Allow an edge drop to split. A same-folder split must leave a 
tab behind (>1 tab);
-              // a cross-folder drop can always land, so only require an edge 
zone there.
-              boolean sameFolder = dragItem.getParent() == folder;
+              // A same-folder split must leave a tab behind (>1 tab).
               boolean edge = computeDropZone(folder, point) != 
IHopPerspective.DROP_ZONE_CENTER;
-              return edge && (!sameFolder || folder.getItemCount() > 1);
+              return edge && folder.getItemCount() > 1;
             }
             return hasActiveTabTransfer(event);
           }
@@ -474,9 +528,44 @@ public class TabItemReorder {
         && mouseDownItem.getParent() == folder) {
       return mouseDownItem;
     }
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      // The pointer is wherever the drag threshold was passed, which need not 
be the pressed tab
+      // (or any tab): the MouseDown that follows is the only reliable source, 
see settleDragItem.
+      return null;
+    }
     return 
folder.getItem(folder.toControl(folder.getDisplay().getCursorLocation()));
   }
 
+  /**
+   * Settle the tab being dragged once the drag is under way: in Hop Web 
{@code dragStart} may have
+   * run before the MouseDown that names the tab (see {@link #dragging}). Only 
the folder the drag
+   * started on does this; another folder's last pressed tab has nothing to do 
with the drag.
+   */
+  private void settleDragItem(CTabFolder folder) {
+    if (dragging
+        && dragItem == null
+        && mouseDownItem != null
+        && !mouseDownItem.isDisposed()
+        && mouseDownItem.getParent() == folder) {
+      dragItem = mouseDownItem;
+      perspective.setDraggedTabItem(dragItem);
+    }
+  }
+
+  /**
+   * The tab this drag is carrying: {@link #dragItem} on the folder the drag 
started on, or the tab
+   * the perspective is holding on any other folder of the same perspective. 
This is what lets a
+   * drop into a <em>different</em> pane complete: that pane's own {@code 
dragItem} is null, and in
+   * Hop Web the transfer types are no longer on the drop event by the time 
the drop is accepted.
+   */
+  private CTabItem activeDragItem() {
+    if (dragItem != null && !dragItem.isDisposed()) {
+      return dragItem;
+    }
+    CTabItem shared = perspective.getDraggedTabItem();
+    return (shared != null && !shared.isDisposed()) ? shared : null;
+  }
+
   private void moveTabs(CTabFolder folder, DropTargetEvent event, int zone, 
Point dropPoint) {
     CTabItem sourceItem = this.dragItem;
 
@@ -490,6 +579,16 @@ public class TabItemReorder {
       }
     }
 
+    // A drop into another pane: this folder was not the drag source, so its 
own dragItem is null
+    // and (in Hop Web) the transfer may not have delivered event.data. Fall 
back to the tab the
+    // perspective is holding for the drag.
+    if (sourceItem == null) {
+      CTabItem shared = perspective.getDraggedTabItem();
+      if (shared != null && !shared.isDisposed()) {
+        sourceItem = shared;
+      }
+    }
+
     if (sourceItem == null) {
       return;
     }
@@ -619,12 +718,6 @@ public class TabItemReorder {
    * side maps to that edge (split), the middle maps to {@code CENTER} (drop 
into the folder as-is).
    */
   private int computeDropZone(CTabFolder folder, Point p) {
-    // Drag-to-split relies on native DnD + floating overlays, which don't 
behave under RAP, so on
-    // the web every drop is a plain centre drop (no edge splits). This is the 
single choke point
-    // for edge zones (drag feedback, drop routing and isDropSupported all go 
through here).
-    if (EnvironmentUtils.getInstance().isWeb()) {
-      return IHopPerspective.DROP_ZONE_CENTER;
-    }
     Point size = folder.getSize();
     if (size.x <= 0 || size.y <= 0) {
       return IHopPerspective.DROP_ZONE_CENTER;
@@ -663,86 +756,50 @@ public class TabItemReorder {
       }
     }
     dropZone = IHopPerspective.DROP_ZONE_CENTER;
-    hideZoneOverlay();
+    hideDropFrame();
   }
 
-  /** Show (or move) the translucent overlay marking where an edge-drop split 
would land. */
-  private void updateZoneOverlay(CTabFolder folder) {
-    if (EnvironmentUtils.getInstance().isWeb()) {
-      return; // Floating overlays have no faithful equivalent under RAP.
+  /**
+   * Show (or move) the frame marking where the drop would land, or take it 
down when the drop is a
+   * plain centre drop with nothing to mark.
+   */
+  private void updateDropFrame(CTabFolder folder) {
+    if (folder.isDisposed()) {
+      hideDropFrame();
+      return;
     }
-    if (dropZone == IHopPerspective.DROP_ZONE_CENTER || folder.isDisposed()) {
-      hideZoneOverlay();
+    // The desktop paints the tab highlight itself; Hop Web gets no paint 
events and frames the tab.
+    CTabItem tab = EnvironmentUtils.getInstance().isWeb() ? dropTargetTab : 
null;
+    int zone = tab != null ? IHopPerspective.DROP_ZONE_CENTER : dropZone;
+    if (tab == null && zone == IHopPerspective.DROP_ZONE_CENTER) {
+      hideDropFrame();
       return;
     }
-    // Nothing changed since the overlay was last shown: leave the Shell 
untouched. Repositioning it
-    // on every drag-over event (they fire continuously) is what makes it 
flicker.
-    if (dropZone == shownZone
-        && folder == shownFolder
-        && zoneOverlay != null
-        && !zoneOverlay.isDisposed()
-        && zoneOverlay.getVisible()) {
+    // Nothing changed since the frame was last shown: leave it alone. Moving 
it on every drag-over
+    // event (they fire continuously) is what makes it flicker.
+    if (tab == shownTab && zone == shownZone && folder == shownFolder) {
       return;
     }
-    Rectangle r = zoneRectangleDisplay(folder, dropZone);
+    Rectangle r = tab != null ? tab.getBounds() : zoneRectangle(folder, zone);
     if (r == null || r.width <= 0 || r.height <= 0) {
-      hideZoneOverlay();
+      hideDropFrame();
       return;
     }
-    try {
-      if (zoneOverlay == null || zoneOverlay.isDisposed()) {
-        zoneOverlay = new Shell(folder.getShell(), SWT.NO_TRIM | SWT.ON_TOP);
-        
zoneOverlay.setBackground(folder.getDisplay().getSystemColor(SWT.COLOR_LIST_SELECTION));
-        zoneOverlay.addDisposeListener(e -> disposeOverlayRegion());
-      }
-      zoneOverlay.setBounds(r);
-      applyFrameRegion(r.width, r.height);
-      if (!zoneOverlay.getVisible()) {
-        zoneOverlay.setVisible(true);
-      }
-      shownZone = dropZone;
-      shownFolder = folder;
-    } catch (Exception e) {
-      hideZoneOverlay();
-    }
-  }
-
-  /**
-   * Shape {@link #zoneOverlay} as a hollow rectangle frame of the given size. 
The cut-out centre is
-   * not part of the window, so the drag cursor passes through it to the 
folder underneath (no
-   * enter/leave oscillation), and only the thin border is painted (no 
compositing flicker).
-   */
-  private void applyFrameRegion(int width, int height) {
-    int border = Math.max(3, Math.min(8, Math.min(width, height) / 12));
-    Region region = new Region(zoneOverlay.getDisplay());
-    region.add(0, 0, width, height);
-    if (width > 2 * border && height > 2 * border) {
-      region.subtract(border, border, width - 2 * border, height - 2 * border);
-    }
-    zoneOverlay.setRegion(region);
-    disposeOverlayRegion();
-    overlayRegion = region;
+    dropFrame.show(folder, r);
+    shownTab = tab;
+    shownZone = zone;
+    shownFolder = folder;
   }
 
-  private void disposeOverlayRegion() {
-    if (overlayRegion != null && !overlayRegion.isDisposed()) {
-      overlayRegion.dispose();
-    }
-    overlayRegion = null;
-  }
-
-  private void hideZoneOverlay() {
-    if (zoneOverlay != null && !zoneOverlay.isDisposed() && 
zoneOverlay.getVisible()) {
-      zoneOverlay.setVisible(false);
-    }
+  private void hideDropFrame() {
+    dropFrame.hide();
+    shownTab = null;
     shownZone = IHopPerspective.DROP_ZONE_CENTER;
     shownFolder = null;
   }
 
-  /**
-   * The half of the folder (in display coordinates) that a split-drop in 
{@code zone} would use.
-   */
-  private Rectangle zoneRectangleDisplay(CTabFolder folder, int zone) {
+  /** The half of the folder (in folder coordinates) that a split-drop in 
{@code zone} would use. */
+  private Rectangle zoneRectangle(CTabFolder folder, int zone) {
     Point size = folder.getSize();
     if (size.x <= 0 || size.y <= 0) {
       return null;
@@ -766,8 +823,7 @@ public class TabItemReorder {
         return null;
       }
     }
-    Point topLeft = folder.toDisplay(x, y);
-    return new Rectangle(topLeft.x, topLeft.y, w, h);
+    return new Rectangle(x, y, w, h);
   }
 
   private void updateTabItemHandler(IHopFileTypeHandler fileTypeHandler, 
CTabItem tabItem) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/WebTabDropFrame.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/WebTabDropFrame.java
new file mode 100644
index 0000000000..b1acad7de0
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/WebTabDropFrame.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Composite;
+
+/**
+ * The Hop Web drop frame: a child composite of the folder, drawn by the {@code
+ * Composite.hopDropFrame} rule of the theme CSS as a border with a 
transparent centre. RAP has no
+ * shell regions, but it does not need one here: its client resolves the drop 
target by walking up
+ * from the element under the pointer, so a child of the folder hands the drag 
to the folder instead
+ * of stealing it. The composite only exists while a drag is over the folder.
+ */
+final class WebTabDropFrame implements TabDropFrame {
+
+  /** Custom variant of the frame composite, styled in the Hop Web theme CSS. 
*/
+  static final String CUSTOM_VARIANT = "hopDropFrame";
+
+  private static final String RWT_CUSTOM_VARIANT = 
"org.eclipse.rap.rwt.customVariant";
+
+  private Composite frame;
+
+  @Override
+  public void show(CTabFolder folder, Rectangle bounds) {
+    if (folder.isDisposed() || bounds.width <= 0 || bounds.height <= 0) {
+      hide();
+      return;
+    }
+    if (frame == null || frame.isDisposed() || frame.getParent() != folder) {
+      dispose();
+      frame = new Composite(folder, SWT.NONE);
+      frame.setData(RWT_CUSTOM_VARIANT, CUSTOM_VARIANT);
+    }
+    frame.setBounds(bounds);
+    frame.moveAbove(null);
+    frame.setVisible(true);
+  }
+
+  @Override
+  public void hide() {
+    dispose();
+  }
+
+  @Override
+  public void dispose() {
+    if (frame != null && !frame.isDisposed()) {
+      frame.dispose();
+    }
+    frame = null;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 6e1e9e3d01..9091d11fc3 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -5413,10 +5413,18 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @Override
   public void onTabMovedBetweenFolders(CTabFolder sourceFolder, CTabFolder 
targetFolder) {
     activeTabFolder = targetFolder;
-    reclaimFolder(sourceFolder);
     if (EnvironmentUtils.getInstance().isWeb()) {
+      // A drop that empties the source pane wants to collapse it, but we are 
still inside the drop
+      // handler of the target pane's drag-and-drop request. Disposing the 
source pane (and with it
+      // its drag source) now makes RAP render a disposed widget at the end of 
that same request -
+      // "Widget is disposed". Collapse it on the next tick, once the drop 
request is done. The
+      // desktop disposes in place: native DnD does not revisit the source 
pane after the drop.
+      CTabFolder folderToReclaim = sourceFolder;
+      hopGui.getDisplay().asyncExec(() -> reclaimFolder(folderToReclaim));
       notifyZoomHandlerForActiveTab();
       updateWebUrlForActiveTab();
+    } else {
+      reclaimFolder(sourceFolder);
     }
   }
 
@@ -5427,6 +5435,18 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
   }
 
+  private CTabItem draggedTabItem;
+
+  @Override
+  public void setDraggedTabItem(CTabItem tabItem) {
+    this.draggedTabItem = tabItem;
+  }
+
+  @Override
+  public CTabItem getDraggedTabItem() {
+    return draggedTabItem;
+  }
+
   @Override
   public CTabFolder resolveDropFolderForZone(CTabFolder targetFolder, int 
zone) {
     // Only the docked layout tree supports splitting; detached windows stay 
single-pane in v1.

Reply via email to