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 3bcea98409 issue #3111: drag transforms/actions from context dialog 
onto canvas (#7814)
3bcea98409 is described below

commit 3bcea98409c26d9d4653c0823a65f7e2e007c137
Author: Matt Casters <[email protected]>
AuthorDate: Thu Aug 6 22:09:11 2026 +0200

    issue #3111: drag transforms/actions from context dialog onto canvas (#7814)
---
 .../ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc   |  12 +
 .../apache/hop/ui/core/dialog/ContextDialog.java   | 239 ++++++++++-
 .../ui/hopgui/context/ContextDialogPlacement.java  |  69 ++++
 .../hop/ui/hopgui/context/GuiActionFavorites.java  |  40 +-
 .../hop/ui/hopgui/context/GuiContextUtil.java      |  29 ++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  | 441 ++++++++++++++++++++
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  | 459 +++++++++++++++++++++
 .../core/dialog/messages/messages_en_US.properties |   4 +-
 .../ui/hopgui/context/GuiActionFavoritesTest.java  |  52 +++
 9 files changed, 1338 insertions(+), 7 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
index 030f4d1a74..8f6dbbdf4c 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
@@ -43,6 +43,18 @@ image:hop-gui/popup-collapsed.png[Hop Gui Popup Dialog - 
Collapsed, width="65%"]
 
 image:hop-gui/popup-no-categories-no-fixed-width.png[Hop Gui Popup Dialog - No 
categories, No fixed width, width="65%"]
 
+== Adding transforms and actions
+
+When you open the popup dialog from a **pipeline** or **workflow** canvas 
(single-click on the background), the dialog lists every transform or action 
you can add.
+
+* **Click** a transform or action to place it at the canvas location where you 
opened the dialog.
+* **Click-drag** a transform or action from the dialog onto the canvas to 
place it where you release the mouse. In Hop Gui the icon follows the pointer; 
in Hop Web use drag-and-drop onto the canvas. Release outside the canvas (or 
press Esc in Hop Gui) to cancel.
+* **ALT-Click** (Option-Click on macOS) a transform or action to add or remove 
it as a *favorite* without closing the dialog. Favorites appear in their own 
category near the top of the list for quicker access.
+
+Hover over an item to see its description and these shortcuts in the tooltip.
+
+TIP: Dropping a new transform or action on top of an existing hop can split 
that hop (same confirmation dialog as when you move an icon onto a hop).
+
 == Creating Items
 
 When you create a new item, the dialog will show you a list of metadata items 
that can be created with a single click of a button.
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index 7141df2b91..d3467c6a8d 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -50,11 +50,18 @@ import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.core.widget.OsHelper;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DragSource;
+import org.eclipse.swt.dnd.DragSourceAdapter;
+import org.eclipse.swt.dnd.DragSourceEvent;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
 import org.eclipse.swt.graphics.Color;
 import org.eclipse.swt.graphics.Font;
 import org.eclipse.swt.graphics.GC;
@@ -72,6 +79,7 @@ import org.eclipse.swt.widgets.Dialog;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Listener;
 import org.eclipse.swt.widgets.Monitor;
 import org.eclipse.swt.widgets.ScrollBar;
 import org.eclipse.swt.widgets.Shell;
@@ -118,6 +126,34 @@ public class ContextDialog extends Dialog {
   private boolean ctrlClicked;
   private boolean focusLost;
 
+  /**
+   * True when the user started dragging a placeable Create item out of this 
dialog (issue #3111).
+   * On native SWT the dialog closes on drag-start and the graph continues 
placement; on Hop Web
+   * HTML5/SWT DnD is used and the shell is only hidden until dragFinished.
+   */
+  private boolean placementDrag;
+
+  /**
+   * True when a canvas DropTarget already created the transform/action (Hop 
Web DnD path). Prevents
+   * GuiContextUtil from starting a second placement gesture.
+   */
+  private boolean placementCompletedByDrop;
+
+  /** Item under the mouse when a potential placement drag was armed 
(MouseDown on Create item). */
+  private Item pressItem;
+
+  /** Display coordinates of the MouseDown that armed a potential placement 
drag. */
+  private org.eclipse.swt.graphics.Point pressDisplayLocation;
+
+  private Listener placementArmMoveFilter;
+  private Listener placementArmUpFilter;
+
+  /** Item currently being dragged via SWT DnD (Hop Web). */
+  private Item dndDragItem;
+
+  /** Minimum pointer movement (display px) before a press on a Create item 
becomes a drag. */
+  private static final int PLACEMENT_DRAG_THRESHOLD_PX = 8;
+
   /** All context items. */
   private final List<Item> items = new ArrayList<>();
 
@@ -502,9 +538,14 @@ public class ContextDialog extends Dialog {
 
     wCanvas.addListener(SWT.KeyDown, this::onKeyPressed);
     wCanvas.addListener(SWT.Paint, this::onPaint);
+    wCanvas.addListener(SWT.MouseDown, this::onMouseDown);
     wCanvas.addListener(SWT.MouseUp, this::onMouseUp);
     if (!EnvironmentUtils.getInstance().isWeb()) {
       wCanvas.addListener(SWT.MouseMove, this::onMouseMove);
+    } else {
+      // Hop Web: RAP does not deliver reliable mouse-move-while-pressed for 
Display filters.
+      // Use HTML5-backed SWT DnD so the user can drag a create item onto the 
graph canvas.
+      installWebPlacementDragSource();
     }
 
     // OS Specific listeners...
@@ -651,6 +692,11 @@ public class ContextDialog extends Dialog {
   }
 
   public void dispose() {
+    if (shell == null || shell.isDisposed()) {
+      return;
+    }
+
+    removePlacementArmFilters();
 
     // Store the toolbar settings
     storeDialogSettings();
@@ -663,8 +709,12 @@ public class ContextDialog extends Dialog {
     // There's no need to keep re-loading all the time.
     // Previously this cache was not functional so that we needed to dispose 
here.
 
-    highlightColor.dispose();
-    headerFont.dispose();
+    if (highlightColor != null && !highlightColor.isDisposed()) {
+      highlightColor.dispose();
+    }
+    if (headerFont != null && !headerFont.isDisposed()) {
+      headerFont.dispose();
+    }
   }
 
   @GuiToolbarElement(
@@ -744,7 +794,38 @@ public class ContextDialog extends Dialog {
     }
   }
 
+  private void onMouseDown(Event event) {
+    if (event.button != 1 || placementDrag) {
+      return;
+    }
+    AreaOwner areaOwner = AreaOwner.getVisibleAreaOwner(areaOwners, event.x, 
event.y);
+    if (areaOwner == null || areaOwner.getParent() != OwnerType.ITEM) {
+      return;
+    }
+    Item item = (Item) areaOwner.getOwner();
+    if (item == null || 
!GuiActionFavorites.isPlaceableCreateAction(item.getAction())) {
+      return;
+    }
+    selectItem(item, false);
+    // Native Hop GUI: arm Display-filter placement drag. Hop Web uses SWT DnD 
instead (see
+    // installWebPlacementDragSource) because RAP does not deliver 
mouse-move-while-pressed.
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      return;
+    }
+    pressItem = item;
+    pressDisplayLocation = shell.getDisplay().getCursorLocation();
+    installPlacementArmFilters();
+  }
+
   private void onMouseUp(Event event) {
+    if (placementDrag) {
+      // Drag already committed; dialog is closing or closed.
+      return;
+    }
+    removePlacementArmFilters();
+    pressItem = null;
+    pressDisplayLocation = null;
+
     AreaOwner areaOwner = AreaOwner.getVisibleAreaOwner(areaOwners, event.x, 
event.y);
     if (areaOwner == null) {
       return;
@@ -796,6 +877,148 @@ public class ContextDialog extends Dialog {
     }
   }
 
+  private void installPlacementArmFilters() {
+    removePlacementArmFilters();
+    Display display = shell.getDisplay();
+    placementArmMoveFilter =
+        event -> {
+          if (event.type != SWT.MouseMove || pressItem == null || 
placementDrag) {
+            return;
+          }
+          // Only commit while the primary button is still held (avoids stray 
move events).
+          if ((event.stateMask & SWT.BUTTON1) == 0) {
+            return;
+          }
+          if (shell.isDisposed()) {
+            removePlacementArmFilters();
+            return;
+          }
+          org.eclipse.swt.graphics.Point cursor = display.getCursorLocation();
+          int dx = cursor.x - pressDisplayLocation.x;
+          int dy = cursor.y - pressDisplayLocation.y;
+          int thresholdSq = PLACEMENT_DRAG_THRESHOLD_PX * 
PLACEMENT_DRAG_THRESHOLD_PX;
+          if (dx * dx + dy * dy > thresholdSq) {
+            commitPlacementDrag(pressItem);
+          }
+        };
+    placementArmUpFilter =
+        event -> {
+          if (event.type == SWT.MouseUp) {
+            // Click path: dialog MouseUp will select. Clear arm state only.
+            removePlacementArmFilters();
+            pressItem = null;
+            pressDisplayLocation = null;
+          }
+        };
+    display.addFilter(SWT.MouseMove, placementArmMoveFilter);
+    display.addFilter(SWT.MouseUp, placementArmUpFilter);
+  }
+
+  private void removePlacementArmFilters() {
+    if (shell == null || shell.isDisposed()) {
+      placementArmMoveFilter = null;
+      placementArmUpFilter = null;
+      return;
+    }
+    Display display = shell.getDisplay();
+    if (placementArmMoveFilter != null) {
+      display.removeFilter(SWT.MouseMove, placementArmMoveFilter);
+      placementArmMoveFilter = null;
+    }
+    if (placementArmUpFilter != null) {
+      display.removeFilter(SWT.MouseUp, placementArmUpFilter);
+      placementArmUpFilter = null;
+    }
+  }
+
+  private void commitPlacementDrag(Item item) {
+    if (item == null || placementDrag) {
+      return;
+    }
+    selectedAction = item.getAction();
+    placementDrag = true;
+    focusLost = false;
+    shiftClicked = false;
+    ctrlClicked = false;
+    pressItem = null;
+    pressDisplayLocation = null;
+    removePlacementArmFilters();
+    dispose();
+  }
+
+  /**
+   * Hop Web: DragSource on the icon canvas so HTML5 DnD can carry a placeable 
create action to the
+   * pipeline/workflow canvas DropTarget. The shell is hidden on dragStart (so 
the canvas is
+   * visible) but kept alive until dragFinished so the DragSource remains 
valid.
+   */
+  private void installWebPlacementDragSource() {
+    DragSource dragSource = new DragSource(wCanvas, DND.DROP_COPY);
+    dragSource.setTransfer(new Transfer[] {TextTransfer.getInstance()});
+    dragSource.addDragListener(
+        new DragSourceAdapter() {
+          @Override
+          public void dragStart(DragSourceEvent event) {
+            Item item = findItem(event.x, event.y);
+            if (item == null || 
!GuiActionFavorites.isPlaceableCreateAction(item.getAction())) {
+              event.doit = false;
+              dndDragItem = null;
+              return;
+            }
+            dndDragItem = item;
+            selectItem(item, false);
+            selectedAction = item.getAction();
+            placementDrag = true;
+            placementCompletedByDrop = false;
+            focusLost = false;
+            // Prefer the item icon as drag image; fall back to Hop logo on 
web if needed.
+            if (item.getImage() != null && !item.getImage().isDisposed()) {
+              event.image = item.getImage();
+            } else {
+              event.image = GuiResource.getInstance().getImageHop();
+            }
+            // Hide (do not dispose) so the graph canvas is usable while the 
DragSource stays alive.
+            if (shell != null && !shell.isDisposed()) {
+              shell.setVisible(false);
+            }
+            event.doit = true;
+          }
+
+          @Override
+          public void dragSetData(DragSourceEvent event) {
+            if (TextTransfer.getInstance().isSupportedType(event.dataType) && 
dndDragItem != null) {
+              event.data = 
ContextDialogPlacement.encode(dndDragItem.getAction());
+              event.doit = event.data != null;
+            }
+          }
+
+          @Override
+          public void dragFinished(DragSourceEvent event) {
+            dndDragItem = null;
+            // End the modal open() loop. If the drop already created the item,
+            // GuiContextUtil will see placementCompletedByDrop and skip a 
second create.
+            if (selectedAction == null && !placementCompletedByDrop) {
+              // Drag cancelled without a selection — treat as focus-lost 
style cancel.
+              placementDrag = false;
+            }
+            dispose();
+          }
+        });
+  }
+
+  /** Called by canvas drop targets when a web DnD drop successfully placed a 
transform/action. */
+  public void markPlacementCompletedByDrop() {
+    placementCompletedByDrop = true;
+    placementDrag = true;
+    focusLost = false;
+  }
+
+  /**
+   * @return true if a canvas DropTarget already handled creation for this 
placement gesture
+   */
+  public boolean isPlacementCompletedByDrop() {
+    return placementCompletedByDrop;
+  }
+
   /**
    * Rebuild the category list and icon items from the current {@link 
#actions} list. Preserves
    * collapsed state of categories when refreshing after a favorites toggle.
@@ -1228,10 +1451,22 @@ public class ContextDialog extends Dialog {
   }
 
   private void onFocusLost() {
+    // Placement drag closes the dialog intentionally; do not treat as cancel.
+    if (placementDrag || selectedAction != null) {
+      return;
+    }
     focusLost = true;
     dispose();
   }
 
+  /**
+   * @return true if the dialog closed because the user started dragging a 
placeable create item
+   *     onto the canvas (issue #3111)
+   */
+  public boolean isPlacementDrag() {
+    return placementDrag;
+  }
+
   private void onModifySearch() {
     String text = wSearch.getText();
     this.filter(text);
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
new file mode 100644
index 0000000000..3d91be0fe8
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.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.context;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.ui.core.dialog.ContextDialog;
+
+/**
+ * Shared payload helpers for dragging a placeable create action from {@link 
ContextDialog} onto a
+ * pipeline/workflow canvas (issue #3111), especially for Hop Web where 
HTML5/SWT DnD is used.
+ */
+public final class ContextDialogPlacement {
+
+  /** TextTransfer payload prefix so drop targets ignore unrelated text. */
+  public static final String TRANSFER_PREFIX = "hop-context-placement:";
+
+  private ContextDialogPlacement() {
+    // utility
+  }
+
+  public static String encode(GuiAction action) {
+    if (action == null || StringUtils.isEmpty(action.getId())) {
+      return null;
+    }
+    return TRANSFER_PREFIX + action.getId();
+  }
+
+  public static boolean isPlacementPayload(Object data) {
+    return data instanceof String s && s.startsWith(TRANSFER_PREFIX);
+  }
+
+  /**
+   * @return the GuiAction id embedded in a placement payload, or null if not 
a placement payload
+   */
+  public static String decodeActionId(Object data) {
+    if (!isPlacementPayload(data)) {
+      return null;
+    }
+    return ((String) data).substring(TRANSFER_PREFIX.length());
+  }
+
+  /**
+   * Notify the active context dialog that a canvas drop already created the 
transform/action, so
+   * {@link org.apache.hop.ui.hopgui.context.GuiContextUtil} must not start a 
second placement
+   * gesture when the dialog closes.
+   */
+  public static void markDropCompletedOnActiveDialog() {
+    ContextDialog dialog = ContextDialog.getInstance();
+    if (dialog != null) {
+      dialog.markPlacementCompletedByDrop();
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java
index 167b198f6b..870ce380d4 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java
@@ -76,8 +76,9 @@ public final class GuiActionFavorites {
   }
 
   /**
-   * Append the ALT-Click hint to a plugin description for use as a GuiAction 
tooltip. The modifier
-   * is shown with the platform specific label: "⌥" (Option) on macOS, "ALT" 
elsewhere.
+   * Append the favorites / placement hint to a plugin description for use as 
a GuiAction tooltip.
+   * The modifier is shown with the platform specific label: "⌥" (Option) on 
macOS, "ALT" elsewhere.
+   * The hint also mentions click-drag placement onto the canvas (issue #3111).
    *
    * @param description the plugin description (may be null)
    * @param favorite true if the plugin is already a favorite (show remove 
hint)
@@ -145,6 +146,38 @@ public final class GuiActionFavorites {
     return true;
   }
 
+  /**
+   * Whether this action creates a pipeline transform or workflow action that 
can be drag-placed
+   * from the context dialog onto the canvas (issue #3111).
+   */
+  public static boolean isPlaceableCreateAction(GuiAction action) {
+    return resolveFromAction(action) != null;
+  }
+
+  /**
+   * Resolve a create-transform / create-action GuiAction id to kind + plugin 
id.
+   *
+   * @return resolved pair, or null if the action is not a placeable create 
action
+   */
+  public static KindAndPluginId resolveFromAction(GuiAction action) {
+    if (action == null || StringUtils.isEmpty(action.getId())) {
+      return null;
+    }
+    return resolve(action.getId());
+  }
+
+  /**
+   * Resolve a create-transform / create-action id string (including 
favorites) to kind + plugin id.
+   *
+   * @return resolved pair, or null if not a placeable create action id
+   */
+  public static KindAndPluginId resolveFromId(String actionId) {
+    if (StringUtils.isEmpty(actionId)) {
+      return null;
+    }
+    return resolve(actionId);
+  }
+
   /**
    * Create a Favorites-category copy of a create action for the given plugin 
id.
    *
@@ -219,5 +252,6 @@ public final class GuiActionFavorites {
     return null;
   }
 
-  private record KindAndPluginId(Kind kind, String pluginId) {}
+  /** Kind + plugin id for a placeable create action from the context dialog. 
*/
+  public record KindAndPluginId(Kind kind, String pluginId) {}
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java
index 35cabcd6b0..54b9bde5c0 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java
@@ -32,6 +32,9 @@ import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.ISingletonProvider;
 import org.apache.hop.ui.hopgui.ImplementationLoader;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
+import org.apache.hop.ui.util.EnvironmentUtils;
 import org.apache.hop.ui.util.SwtErrorHandler;
 import org.eclipse.swt.widgets.Shell;
 
@@ -173,6 +176,18 @@ public class GuiContextUtil {
         shellDialogMap.remove(parent.getText());
         if (selectedAction != null) {
           final ContextDialog dialog = contextDialog;
+          // Placement drag (issue #3111):
+          // - Hop Web DnD: drop either created the item or was cancelled → 
never hand off to
+          //   Display-filter placement (RAP does not support that path).
+          // - Native: hand off to the graph for ghost icon + create-on-drop.
+          if (dialog.isPlacementDrag()) {
+            if (dialog.isPlacementCompletedByDrop() || 
EnvironmentUtils.getInstance().isWeb()) {
+              return false;
+            }
+            if (tryBeginPlacementDrag(selectedAction)) {
+              return false;
+            }
+          }
           HopGui.getInstance()
               .getDisplay()
               .asyncExec(
@@ -196,4 +211,18 @@ public class GuiContextUtil {
     }
     return false;
   }
+
+  /**
+   * Start canvas placement drag for a create-transform / create-action 
GuiAction.
+   *
+   * @return true if the active graph accepted the placement drag
+   */
+  private boolean tryBeginPlacementDrag(GuiAction selectedAction) {
+    HopGuiPipelineGraph pipelineGraph = HopGui.getActivePipelineGraph();
+    if (pipelineGraph != null && 
pipelineGraph.beginPlacementDragFromAction(selectedAction)) {
+      return true;
+    }
+    HopGuiWorkflowGraph workflowGraph = HopGui.getActiveWorkflowGraph();
+    return workflowGraph != null && 
workflowGraph.beginPlacementDragFromAction(selectedAction);
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 8eee4e91ed..cc5ab9c7ce 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -159,6 +159,8 @@ import org.apache.hop.ui.hopgui.HopGuiExtensionPoint;
 import org.apache.hop.ui.hopgui.PaletteEngineFilter;
 import org.apache.hop.ui.hopgui.ServerPushSessionFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
 import org.apache.hop.ui.hopgui.context.GuiContextUtil;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.delegates.HopGuiServerDelegate;
@@ -200,6 +202,12 @@ import org.eclipse.swt.custom.CLabel;
 import org.eclipse.swt.custom.CTabFolder;
 import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DropTarget;
+import org.eclipse.swt.dnd.DropTargetAdapter;
+import org.eclipse.swt.dnd.DropTargetEvent;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
 import org.eclipse.swt.events.MouseAdapter;
 import org.eclipse.swt.events.MouseEvent;
 import org.eclipse.swt.events.MouseListener;
@@ -350,6 +358,28 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   /** True once pointer has moved past {@link #ICON_DRAG_THRESHOLD_PX} and 
drag has started. */
   private boolean iconDragCommitted;
 
+  /**
+   * Display filters used while placing a transform dragged from the context 
dialog (issue #3111).
+   * Create happens on mouse-up (drop), not on drag-start.
+   */
+  private Listener placementDragMoveFilter;
+
+  private Listener placementDragUpFilter;
+
+  private Listener placementDragKeyFilter;
+
+  /** Pending create action while the user drags from the context dialog onto 
the canvas. */
+  private GuiAction pendingPlacementAction;
+
+  /**
+   * Ghost transform shown while dragging from the context dialog. Created on 
first move over the
+   * canvas so the icon is visible; removed if the drop is cancelled.
+   */
+  private TransformMeta pendingPlacementGhost;
+
+  /** Last hop highlighted as a split candidate during placement drag. */
+  private PipelineHopMeta pendingPlacementLastHopSplit;
+
   private boolean splitHop;
 
   private int lastButton;
@@ -615,6 +645,9 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
       canvas.addMouseMoveListener(this);
       canvas.addMouseTrackListener(this);
       canvas.addMouseWheelListener(this::mouseScrolled);
+    } else {
+      // Hop Web: accept create actions dragged from the context dialog 
(HTML5/SWT DnD).
+      installContextDialogPlacementDropTarget();
     }
 
     setBackground(GuiResource.getInstance().getColorBackground());
@@ -1320,6 +1353,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       dragSelection = false;
       iconDragStartScreen = null;
       iconDragCommitted = false;
+      removePlacementDragFilters();
 
       updateGui();
     } else {
@@ -1656,6 +1690,413 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     }
   }
 
+  /**
+   * Install a DropTarget so Hop Web can drop a context-dialog create action 
onto this canvas (issue
+   * #3111). Native Hop GUI uses Display-filter placement instead.
+   */
+  private void installContextDialogPlacementDropTarget() {
+    DropTarget dropTarget = new DropTarget(canvas, DND.DROP_COPY);
+    dropTarget.setTransfer(new Transfer[] {TextTransfer.getInstance()});
+    dropTarget.addDropListener(
+        new DropTargetAdapter() {
+          @Override
+          public void dragEnter(DropTargetEvent event) {
+            acceptPlacementDrop(event);
+          }
+
+          @Override
+          public void dragOperationChanged(DropTargetEvent event) {
+            acceptPlacementDrop(event);
+          }
+
+          @Override
+          public void dragOver(DropTargetEvent event) {
+            acceptPlacementDrop(event);
+          }
+
+          @Override
+          public void drop(DropTargetEvent event) {
+            if 
(!TextTransfer.getInstance().isSupportedType(event.currentDataType)) {
+              event.detail = DND.DROP_NONE;
+              return;
+            }
+            String actionId = 
ContextDialogPlacement.decodeActionId(event.data);
+            if (actionId == null) {
+              event.detail = DND.DROP_NONE;
+              return;
+            }
+            // DropTargetEvent x/y are relative to the Display in SWT/RAP — 
convert to canvas.
+            org.eclipse.swt.graphics.Point canvasPos = 
canvas.toControl(event.x, event.y);
+            boolean placed = placeFromContextDialogActionId(actionId, 
canvasPos.x, canvasPos.y);
+            if (placed) {
+              ContextDialogPlacement.markDropCompletedOnActiveDialog();
+              event.detail = DND.DROP_COPY;
+            } else {
+              event.detail = DND.DROP_NONE;
+            }
+          }
+
+          private void acceptPlacementDrop(DropTargetEvent event) {
+            if (event.currentDataType != null
+                && 
TextTransfer.getInstance().isSupportedType(event.currentDataType)) {
+              event.detail = DND.DROP_COPY;
+              event.feedback = DND.FEEDBACK_SELECT;
+            } else {
+              event.detail = DND.DROP_NONE;
+            }
+          }
+        });
+  }
+
+  /**
+   * Create a transform from a context-dialog action id at the given canvas 
coordinates (Hop Web DnD
+   * drop path for issue #3111).
+   *
+   * @return true if a transform was created
+   */
+  public boolean placeFromContextDialogActionId(String actionId, int canvasX, 
int canvasY) {
+    GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromId(actionId);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.TRANSFORM) {
+      return false;
+    }
+    if (canvas == null || canvas.isDisposed()) {
+      return false;
+    }
+
+    Point location = placementLocationFromCanvas(canvasX, canvasY);
+    int half = Math.max(iconSize / 2, 1);
+    String pluginName = resolved.pluginId();
+    try {
+      IPlugin plugin =
+          PluginRegistry.getInstance()
+              .findPluginWithId(TransformPluginType.class, 
resolved.pluginId());
+      if (plugin != null && plugin.getName() != null) {
+        pluginName = plugin.getName();
+      }
+    } catch (Exception e) {
+      // Keep plugin id as name fallback.
+    }
+
+    TransformMeta transformMeta =
+        pipelineTransformDelegate.newTransform(
+            pipelineMeta, resolved.pluginId(), pluginName, pluginName, false, 
true, location);
+    if (transformMeta == null) {
+      return false;
+    }
+
+    PipelineHopMeta hop = findPipelineHop(location.x + half, location.y + 
half, transformMeta);
+    if (hop != null
+        && pipelineMeta.findPipelineHop(transformMeta, hop.getFromTransform()) 
== null
+        && pipelineMeta.findPipelineHop(transformMeta, hop.getToTransform()) 
== null
+        && pipelineMeta.findPipelineHop(hop.getToTransform(), transformMeta) 
== null
+        && pipelineMeta.findPipelineHop(hop.getFromTransform(), transformMeta) 
== null) {
+      currentTransform = transformMeta;
+      splitHop(hop);
+    }
+
+    pipelineMeta.unselectAll();
+    transformMeta.setSelected(true);
+    avoidContextDialog = true;
+    pipelineGridDelegate.onPipelineSelectionChanged();
+    updateGui();
+    return true;
+  }
+
+  /**
+   * Start a placement drag from the context dialog (issue #3111). The dialog 
has already closed. A
+   * ghost transform is created when the pointer first moves over the canvas 
so the icon is visible
+   * while dragging; it is committed on mouse-up or removed on cancel. Used by 
native Hop GUI (not
+   * Hop Web DnD).
+   *
+   * @param action the selected GuiAction (must be a placeable transform 
create action)
+   * @return true if this graph accepted the placement gesture
+   */
+  public boolean beginPlacementDragFromAction(GuiAction action) {
+    GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromAction(action);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.TRANSFORM) {
+      return false;
+    }
+    if (canvas == null || canvas.isDisposed()) {
+      return true;
+    }
+
+    pendingPlacementAction = action;
+    pendingPlacementGhost = null;
+    pendingPlacementLastHopSplit = null;
+    avoidContextDialog = true;
+    canvas.setData("mode", "drag");
+    canvas.setFocus();
+    setCursor(hopGui.getDisplay().getSystemCursor(SWT.CURSOR_CROSS));
+    installPlacementDragFilters();
+    // If the pointer is already over the canvas, show the ghost immediately.
+    updatePendingPlacementPreview(hopGui.getDisplay());
+    return true;
+  }
+
+  private void installPlacementDragFilters() {
+    removePlacementDragFilters();
+    Display display = hopGui.getDisplay();
+    placementDragMoveFilter =
+        event -> {
+          if (event.type != SWT.MouseMove || pendingPlacementAction == null) {
+            return;
+          }
+          if (canvas == null || canvas.isDisposed()) {
+            cancelPendingPlacement();
+            return;
+          }
+          updatePendingPlacementPreview(display);
+        };
+    placementDragUpFilter =
+        event -> {
+          if (event.type != SWT.MouseUp || event.button != 1) {
+            return;
+          }
+          if (pendingPlacementAction == null) {
+            removePlacementDragFilters();
+            return;
+          }
+          event.doit = false;
+          finishPendingPlacementDrop(display);
+        };
+    placementDragKeyFilter =
+        event -> {
+          if (event.type == SWT.KeyDown && event.keyCode == SWT.ESC) {
+            event.doit = false;
+            cancelPendingPlacement();
+          }
+        };
+    display.addFilter(SWT.MouseMove, placementDragMoveFilter);
+    display.addFilter(SWT.MouseUp, placementDragUpFilter);
+    display.addFilter(SWT.KeyDown, placementDragKeyFilter);
+  }
+
+  /** Create/move the ghost transform under the pointer while placing from the 
context dialog. */
+  private void updatePendingPlacementPreview(Display display) {
+    if (pendingPlacementAction == null || canvas == null || 
canvas.isDisposed()) {
+      return;
+    }
+
+    org.eclipse.swt.graphics.Point cursor = display.getCursorLocation();
+    org.eclipse.swt.graphics.Point canvasPos = display.map(null, canvas, 
cursor);
+    org.eclipse.swt.graphics.Rectangle bounds = canvas.getClientArea();
+    boolean overCanvas =
+        canvasPos.x >= 0
+            && canvasPos.y >= 0
+            && canvasPos.x < bounds.width
+            && canvasPos.y < bounds.height;
+
+    if (!overCanvas) {
+      setCursor(display.getSystemCursor(SWT.CURSOR_NO));
+      clearPendingPlacementHopSplitHighlight();
+      return;
+    }
+
+    setCursor(display.getSystemCursor(SWT.CURSOR_CROSS));
+    Point location = placementLocationFromCanvas(canvasPos.x, canvasPos.y);
+    int half = Math.max(iconSize / 2, 1);
+
+    if (pendingPlacementGhost == null) {
+      ensurePendingPlacementGhost(location);
+      if (pendingPlacementGhost == null) {
+        return;
+      }
+    } else {
+      PropsUi.setLocation(pendingPlacementGhost, location.x, location.y);
+    }
+
+    // Hop-split preview (same rules as dragging an existing transform).
+    PipelineHopMeta hi =
+        findPipelineHop(location.x + half, location.y + half, 
pendingPlacementGhost);
+    if (hi != null
+        && pipelineMeta.findPipelineHop(pendingPlacementGhost, 
hi.getFromTransform()) == null
+        && pipelineMeta.findPipelineHop(pendingPlacementGhost, 
hi.getToTransform()) == null
+        && pipelineMeta.findPipelineHop(hi.getToTransform(), 
pendingPlacementGhost) == null
+        && pipelineMeta.findPipelineHop(hi.getFromTransform(), 
pendingPlacementGhost) == null) {
+      if (pendingPlacementLastHopSplit != null && pendingPlacementLastHopSplit 
!= hi) {
+        pendingPlacementLastHopSplit.setSplit(false);
+      }
+      pendingPlacementLastHopSplit = hi;
+      hi.setSplit(true);
+    } else {
+      clearPendingPlacementHopSplitHighlight();
+    }
+
+    redraw();
+  }
+
+  private Point placementLocationFromCanvas(int canvasX, int canvasY) {
+    Point real = screen2real(canvasX, canvasY);
+    int half = Math.max(iconSize / 2, 1);
+    Point location = new Point(real.x - half, real.y - half);
+    if (location.x < 0) {
+      location.x = 0;
+    }
+    if (location.y < 0) {
+      location.y = 0;
+    }
+    return location;
+  }
+
+  private void ensurePendingPlacementGhost(Point location) {
+    GuiActionFavorites.KindAndPluginId resolved =
+        GuiActionFavorites.resolveFromAction(pendingPlacementAction);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.TRANSFORM) {
+      return;
+    }
+    String pluginName = pendingPlacementAction.getName();
+    TransformMeta transformMeta =
+        pipelineTransformDelegate.newTransform(
+            pipelineMeta, resolved.pluginId(), pluginName, pluginName, false, 
true, location);
+    if (transformMeta == null) {
+      return;
+    }
+    pipelineMeta.unselectAll();
+    transformMeta.setSelected(true);
+    pendingPlacementGhost = transformMeta;
+    selectedTransform = transformMeta;
+    currentTransform = transformMeta;
+    selectedTransforms = pipelineMeta.getSelectedTransforms();
+    canvas.setData("mode", "drag");
+    pipelineGridDelegate.onPipelineSelectionChanged();
+    updateGui();
+  }
+
+  private void clearPendingPlacementHopSplitHighlight() {
+    if (pendingPlacementLastHopSplit != null) {
+      pendingPlacementLastHopSplit.setSplit(false);
+      pendingPlacementLastHopSplit = null;
+    }
+  }
+
+  private void finishPendingPlacementDrop(Display display) {
+    GuiAction action = pendingPlacementAction;
+    TransformMeta ghost = pendingPlacementGhost;
+    pendingPlacementAction = null;
+    pendingPlacementGhost = null;
+    removePlacementDragFilters();
+    setCursor(null);
+    if (canvas != null && !canvas.isDisposed()) {
+      canvas.setData("mode", "null");
+    }
+
+    if (action == null || canvas == null || canvas.isDisposed()) {
+      clearPendingPlacementHopSplitHighlight();
+      return;
+    }
+
+    org.eclipse.swt.graphics.Point cursor = display.getCursorLocation();
+    org.eclipse.swt.graphics.Point canvasPos = display.map(null, canvas, 
cursor);
+    org.eclipse.swt.graphics.Rectangle bounds = canvas.getClientArea();
+    boolean overCanvas =
+        canvasPos.x >= 0
+            && canvasPos.y >= 0
+            && canvasPos.x < bounds.width
+            && canvasPos.y < bounds.height;
+
+    // Drop outside the canvas cancels: remove ghost if we already created one 
for preview.
+    if (!overCanvas) {
+      clearPendingPlacementHopSplitHighlight();
+      if (ghost != null) {
+        pipelineTransformDelegate.delTransform(pipelineMeta, ghost);
+      }
+      selectedTransform = null;
+      currentTransform = null;
+      selectedTransforms = null;
+      avoidContextDialog = true;
+      updateGui();
+      return;
+    }
+
+    Point location = placementLocationFromCanvas(canvasPos.x, canvasPos.y);
+    int half = Math.max(iconSize / 2, 1);
+
+    TransformMeta transformMeta = ghost;
+    if (transformMeta == null) {
+      // Never moved over the canvas before drop — create at the drop point.
+      GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromAction(action);
+      if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.TRANSFORM) {
+        return;
+      }
+      transformMeta =
+          pipelineTransformDelegate.newTransform(
+              pipelineMeta,
+              resolved.pluginId(),
+              action.getName(),
+              action.getName(),
+              false,
+              true,
+              location);
+      if (transformMeta == null) {
+        return;
+      }
+    } else {
+      PropsUi.setLocation(transformMeta, location.x, location.y);
+    }
+
+    boolean doSplit =
+        pendingPlacementLastHopSplit != null && 
pendingPlacementLastHopSplit.isSplit();
+    clearPendingPlacementHopSplitHighlight();
+    if (doSplit) {
+      PipelineHopMeta hop = findPipelineHop(location.x + half, location.y + 
half, transformMeta);
+      if (hop != null) {
+        currentTransform = transformMeta;
+        splitHop(hop);
+      }
+    }
+
+    pipelineMeta.unselectAll();
+    transformMeta.setSelected(true);
+    selectedTransform = null;
+    currentTransform = null;
+    selectedTransforms = null;
+    avoidContextDialog = true;
+    pipelineGridDelegate.onPipelineSelectionChanged();
+    updateGui();
+  }
+
+  private void cancelPendingPlacement() {
+    TransformMeta ghost = pendingPlacementGhost;
+    pendingPlacementAction = null;
+    pendingPlacementGhost = null;
+    clearPendingPlacementHopSplitHighlight();
+    removePlacementDragFilters();
+    if (canvas != null && !canvas.isDisposed()) {
+      canvas.setData("mode", "null");
+    }
+    setCursor(null);
+    selectedTransform = null;
+    currentTransform = null;
+    selectedTransforms = null;
+    if (ghost != null) {
+      pipelineTransformDelegate.delTransform(pipelineMeta, ghost);
+    }
+    avoidContextDialog = true;
+    updateGui();
+  }
+
+  private void removePlacementDragFilters() {
+    Display display = hopGui.getDisplay();
+    if (display == null || display.isDisposed()) {
+      placementDragMoveFilter = null;
+      placementDragUpFilter = null;
+      placementDragKeyFilter = null;
+      return;
+    }
+    if (placementDragMoveFilter != null) {
+      display.removeFilter(SWT.MouseMove, placementDragMoveFilter);
+      placementDragMoveFilter = null;
+    }
+    if (placementDragUpFilter != null) {
+      display.removeFilter(SWT.MouseUp, placementDragUpFilter);
+      placementDragUpFilter = null;
+    }
+    if (placementDragKeyFilter != null) {
+      display.removeFilter(SWT.KeyDown, placementDragKeyFilter);
+      placementDragKeyFilter = null;
+    }
+  }
+
   private void splitHop(PipelineHopMeta hop) {
     int id = 0;
     if (!hopGui.getProps().getAutoSplit()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 3b922f0455..4d907a94b3 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -121,6 +121,8 @@ import org.apache.hop.ui.hopgui.HopGuiExtensionPoint;
 import org.apache.hop.ui.hopgui.PaletteEngineFilter;
 import org.apache.hop.ui.hopgui.ServerPushSessionFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
 import org.apache.hop.ui.hopgui.context.GuiContextUtil;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.dialog.NotePadDialog;
@@ -170,6 +172,12 @@ import org.eclipse.swt.custom.CLabel;
 import org.eclipse.swt.custom.CTabFolder;
 import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DropTarget;
+import org.eclipse.swt.dnd.DropTargetAdapter;
+import org.eclipse.swt.dnd.DropTargetEvent;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
 import org.eclipse.swt.events.MouseAdapter;
 import org.eclipse.swt.events.MouseEvent;
 import org.eclipse.swt.events.MouseListener;
@@ -310,6 +318,28 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
   /** True once pointer has moved past {@link #ACTION_DRAG_THRESHOLD_PX} and 
drag has started. */
   private boolean actionDragCommitted;
 
+  /**
+   * Display filters used while placing an action dragged from the context 
dialog (issue #3111).
+   * Create happens on mouse-up (drop), not on drag-start.
+   */
+  private Listener placementDragMoveFilter;
+
+  private Listener placementDragUpFilter;
+
+  private Listener placementDragKeyFilter;
+
+  /** Pending create action while the user drags from the context dialog onto 
the canvas. */
+  private GuiAction pendingPlacementAction;
+
+  /**
+   * Ghost action shown while dragging from the context dialog. Created on 
first move over the
+   * canvas so the icon is visible; removed if the drop is cancelled.
+   */
+  private ActionMeta pendingPlacementGhost;
+
+  /** Last hop highlighted as a split candidate during placement drag. */
+  private WorkflowHopMeta pendingPlacementLastHopSplit;
+
   protected int lastButton;
 
   protected WorkflowHopMeta lastHopSplit;
@@ -523,6 +553,9 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
       canvas.addMouseMoveListener(this);
       canvas.addMouseTrackListener(this);
       canvas.addMouseWheelListener(this::mouseScrolled);
+    } else {
+      // Hop Web: accept create actions dragged from the context dialog 
(HTML5/SWT DnD).
+      installContextDialogPlacementDropTarget();
     }
 
     hopGui.replaceKeyboardShortcutListeners(this);
@@ -1185,6 +1218,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       endHopLocation = null;
       actionDragStartScreen = null;
       actionDragCommitted = false;
+      removePlacementDragFilters();
 
       updateGui();
     } else {
@@ -1399,6 +1433,431 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
   }
 
+  /**
+   * Install a DropTarget so Hop Web can drop a context-dialog create action 
onto this canvas (issue
+   * #3111). Native Hop GUI uses Display-filter placement instead.
+   */
+  private void installContextDialogPlacementDropTarget() {
+    DropTarget dropTarget = new DropTarget(canvas, DND.DROP_COPY);
+    dropTarget.setTransfer(new Transfer[] {TextTransfer.getInstance()});
+    dropTarget.addDropListener(
+        new DropTargetAdapter() {
+          @Override
+          public void dragEnter(DropTargetEvent event) {
+            acceptPlacementDrop(event);
+          }
+
+          @Override
+          public void dragOperationChanged(DropTargetEvent event) {
+            acceptPlacementDrop(event);
+          }
+
+          @Override
+          public void dragOver(DropTargetEvent event) {
+            acceptPlacementDrop(event);
+          }
+
+          @Override
+          public void drop(DropTargetEvent event) {
+            if 
(!TextTransfer.getInstance().isSupportedType(event.currentDataType)) {
+              event.detail = DND.DROP_NONE;
+              return;
+            }
+            String actionId = 
ContextDialogPlacement.decodeActionId(event.data);
+            if (actionId == null) {
+              event.detail = DND.DROP_NONE;
+              return;
+            }
+            // DropTargetEvent x/y are relative to the Display in SWT/RAP — 
convert to canvas.
+            org.eclipse.swt.graphics.Point canvasPos = 
canvas.toControl(event.x, event.y);
+            boolean placed = placeFromContextDialogActionId(actionId, 
canvasPos.x, canvasPos.y);
+            if (placed) {
+              ContextDialogPlacement.markDropCompletedOnActiveDialog();
+              event.detail = DND.DROP_COPY;
+            } else {
+              event.detail = DND.DROP_NONE;
+            }
+          }
+
+          private void acceptPlacementDrop(DropTargetEvent event) {
+            if (event.currentDataType != null
+                && 
TextTransfer.getInstance().isSupportedType(event.currentDataType)) {
+              event.detail = DND.DROP_COPY;
+              event.feedback = DND.FEEDBACK_SELECT;
+            } else {
+              event.detail = DND.DROP_NONE;
+            }
+          }
+        });
+  }
+
+  /**
+   * Create a workflow action from a context-dialog action id at canvas 
coordinates (Hop Web DnD
+   * drop path for issue #3111).
+   *
+   * @return true if an action was created
+   */
+  public boolean placeFromContextDialogActionId(String actionId, int canvasX, 
int canvasY) {
+    GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromId(actionId);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
+      return false;
+    }
+    if (canvas == null || canvas.isDisposed()) {
+      return false;
+    }
+
+    Point location = placementLocationFromCanvas(canvasX, canvasY);
+    int half = Math.max(iconSize / 2, 1);
+    String pluginName = resolved.pluginId();
+    try {
+      IPlugin plugin =
+          PluginRegistry.getInstance()
+              .findPluginWithId(ActionPluginType.class, resolved.pluginId());
+      if (plugin != null && plugin.getName() != null) {
+        pluginName = plugin.getName();
+      }
+    } catch (Exception e) {
+      // Keep plugin id as name fallback.
+    }
+
+    ActionMeta actionMeta =
+        workflowActionDelegate.newAction(
+            workflowMeta, resolved.pluginId(), pluginName, false, location);
+    if (actionMeta == null) {
+      return false;
+    }
+
+    WorkflowHopMeta hop = findHop(location.x + half, location.y + half, 
actionMeta);
+    if (hop != null) {
+      int id = 0;
+      if (!hopGui.getProps().getAutoSplit()) {
+        MessageDialogWithToggle md =
+            new MessageDialogWithToggle(
+                hopShell(),
+                BaseMessages.getString(PKG, 
"HopGuiWorkflowGraph.Dialog.SplitHop.Title"),
+                BaseMessages.getString(PKG, 
"HopGuiWorkflowGraph.Dialog.SplitHop.Message")
+                    + Const.CR
+                    + hop,
+                SWT.ICON_QUESTION,
+                new String[] {
+                  BaseMessages.getString(PKG, "System.Button.Yes"),
+                  BaseMessages.getString(PKG, "System.Button.No")
+                },
+                BaseMessages.getString(
+                    PKG, 
"HopGuiWorkflowGraph.Dialog.Option.SplitHop.DoNotAskAgain"),
+                hopGui.getProps().getAutoSplit());
+        id = md.open();
+        hopGui.getProps().setAutoSplit(md.getToggleState());
+      }
+      if ((id & 0xFF) == 0) {
+        workflowActionDelegate.insertAction(workflowMeta, hop, actionMeta);
+      }
+    }
+
+    workflowMeta.unselectAll();
+    actionMeta.setSelected(true);
+    avoidContextDialog = true;
+    updateGui();
+    return true;
+  }
+
+  /**
+   * Start a placement drag from the context dialog (issue #3111). The dialog 
has already closed. A
+   * ghost action is created when the pointer first moves over the canvas so 
the icon is visible
+   * while dragging; it is committed on mouse-up or removed on cancel. Used by 
native Hop GUI (not
+   * Hop Web DnD).
+   *
+   * @param action the selected GuiAction (must be a placeable workflow-action 
create action)
+   * @return true if this graph accepted the placement gesture
+   */
+  public boolean beginPlacementDragFromAction(GuiAction action) {
+    GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromAction(action);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
+      return false;
+    }
+    if (canvas == null || canvas.isDisposed()) {
+      return true;
+    }
+
+    pendingPlacementAction = action;
+    pendingPlacementGhost = null;
+    pendingPlacementLastHopSplit = null;
+    avoidContextDialog = true;
+    canvas.setData("mode", "drag");
+    canvas.setFocus();
+    setCursor(hopGui.getDisplay().getSystemCursor(SWT.CURSOR_CROSS));
+    installPlacementDragFilters();
+    updatePendingPlacementPreview(hopGui.getDisplay());
+    return true;
+  }
+
+  private void installPlacementDragFilters() {
+    removePlacementDragFilters();
+    Display display = hopGui.getDisplay();
+    placementDragMoveFilter =
+        event -> {
+          if (event.type != SWT.MouseMove || pendingPlacementAction == null) {
+            return;
+          }
+          if (canvas == null || canvas.isDisposed()) {
+            cancelPendingPlacement();
+            return;
+          }
+          updatePendingPlacementPreview(display);
+        };
+    placementDragUpFilter =
+        event -> {
+          if (event.type != SWT.MouseUp || event.button != 1) {
+            return;
+          }
+          if (pendingPlacementAction == null) {
+            removePlacementDragFilters();
+            return;
+          }
+          event.doit = false;
+          finishPendingPlacementDrop(display);
+        };
+    placementDragKeyFilter =
+        event -> {
+          if (event.type == SWT.KeyDown && event.keyCode == SWT.ESC) {
+            event.doit = false;
+            cancelPendingPlacement();
+          }
+        };
+    display.addFilter(SWT.MouseMove, placementDragMoveFilter);
+    display.addFilter(SWT.MouseUp, placementDragUpFilter);
+    display.addFilter(SWT.KeyDown, placementDragKeyFilter);
+  }
+
+  private void updatePendingPlacementPreview(Display display) {
+    if (pendingPlacementAction == null || canvas == null || 
canvas.isDisposed()) {
+      return;
+    }
+
+    org.eclipse.swt.graphics.Point cursor = display.getCursorLocation();
+    org.eclipse.swt.graphics.Point canvasPos = display.map(null, canvas, 
cursor);
+    org.eclipse.swt.graphics.Rectangle bounds = canvas.getClientArea();
+    boolean overCanvas =
+        canvasPos.x >= 0
+            && canvasPos.y >= 0
+            && canvasPos.x < bounds.width
+            && canvasPos.y < bounds.height;
+
+    if (!overCanvas) {
+      setCursor(display.getSystemCursor(SWT.CURSOR_NO));
+      clearPendingPlacementHopSplitHighlight();
+      return;
+    }
+
+    setCursor(display.getSystemCursor(SWT.CURSOR_CROSS));
+    Point location = placementLocationFromCanvas(canvasPos.x, canvasPos.y);
+    int half = Math.max(iconSize / 2, 1);
+
+    if (pendingPlacementGhost == null) {
+      ensurePendingPlacementGhost(location);
+      if (pendingPlacementGhost == null) {
+        return;
+      }
+    } else {
+      PropsUi.setLocation(pendingPlacementGhost, location.x, location.y);
+    }
+
+    WorkflowHopMeta hi = findHop(location.x + half, location.y + half, 
pendingPlacementGhost);
+    if (hi != null) {
+      if (pendingPlacementLastHopSplit != null && pendingPlacementLastHopSplit 
!= hi) {
+        pendingPlacementLastHopSplit.setSplit(false);
+      }
+      pendingPlacementLastHopSplit = hi;
+      hi.setSplit(true);
+    } else {
+      clearPendingPlacementHopSplitHighlight();
+    }
+
+    redraw();
+  }
+
+  private Point placementLocationFromCanvas(int canvasX, int canvasY) {
+    Point real = screen2real(canvasX, canvasY);
+    int half = Math.max(iconSize / 2, 1);
+    Point location = new Point(real.x - half, real.y - half);
+    if (location.x < 0) {
+      location.x = 0;
+    }
+    if (location.y < 0) {
+      location.y = 0;
+    }
+    return location;
+  }
+
+  private void ensurePendingPlacementGhost(Point location) {
+    GuiActionFavorites.KindAndPluginId resolved =
+        GuiActionFavorites.resolveFromAction(pendingPlacementAction);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
+      return;
+    }
+    ActionMeta actionMeta =
+        workflowActionDelegate.newAction(
+            workflowMeta, resolved.pluginId(), 
pendingPlacementAction.getName(), false, location);
+    if (actionMeta == null) {
+      return;
+    }
+    workflowMeta.unselectAll();
+    actionMeta.setSelected(true);
+    pendingPlacementGhost = actionMeta;
+    selectedAction = actionMeta;
+    currentAction = actionMeta;
+    selectedActions = workflowMeta.getSelectedActions();
+    canvas.setData("mode", "drag");
+    updateGui();
+  }
+
+  private void clearPendingPlacementHopSplitHighlight() {
+    if (pendingPlacementLastHopSplit != null) {
+      pendingPlacementLastHopSplit.setSplit(false);
+      pendingPlacementLastHopSplit = null;
+    }
+  }
+
+  private void finishPendingPlacementDrop(Display display) {
+    GuiAction action = pendingPlacementAction;
+    ActionMeta ghost = pendingPlacementGhost;
+    pendingPlacementAction = null;
+    pendingPlacementGhost = null;
+    removePlacementDragFilters();
+    setCursor(null);
+    if (canvas != null && !canvas.isDisposed()) {
+      canvas.setData("mode", "null");
+    }
+
+    if (action == null || canvas == null || canvas.isDisposed()) {
+      clearPendingPlacementHopSplitHighlight();
+      return;
+    }
+
+    org.eclipse.swt.graphics.Point cursor = display.getCursorLocation();
+    org.eclipse.swt.graphics.Point canvasPos = display.map(null, canvas, 
cursor);
+    org.eclipse.swt.graphics.Rectangle bounds = canvas.getClientArea();
+    boolean overCanvas =
+        canvasPos.x >= 0
+            && canvasPos.y >= 0
+            && canvasPos.x < bounds.width
+            && canvasPos.y < bounds.height;
+
+    if (!overCanvas) {
+      clearPendingPlacementHopSplitHighlight();
+      if (ghost != null) {
+        workflowActionDelegate.deleteAction(workflowMeta, ghost);
+      }
+      selectedAction = null;
+      currentAction = null;
+      selectedActions = null;
+      avoidContextDialog = true;
+      updateGui();
+      return;
+    }
+
+    Point location = placementLocationFromCanvas(canvasPos.x, canvasPos.y);
+    int half = Math.max(iconSize / 2, 1);
+
+    ActionMeta actionMeta = ghost;
+    if (actionMeta == null) {
+      GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromAction(action);
+      if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
+        return;
+      }
+      actionMeta =
+          workflowActionDelegate.newAction(
+              workflowMeta, resolved.pluginId(), action.getName(), false, 
location);
+      if (actionMeta == null) {
+        return;
+      }
+    } else {
+      PropsUi.setLocation(actionMeta, location.x, location.y);
+    }
+
+    boolean doSplit =
+        pendingPlacementLastHopSplit != null && 
pendingPlacementLastHopSplit.isSplit();
+    clearPendingPlacementHopSplitHighlight();
+    if (doSplit) {
+      WorkflowHopMeta hop = findHop(location.x + half, location.y + half, 
actionMeta);
+      if (hop != null) {
+        int id = 0;
+        if (!hopGui.getProps().getAutoSplit()) {
+          MessageDialogWithToggle md =
+              new MessageDialogWithToggle(
+                  hopShell(),
+                  BaseMessages.getString(PKG, 
"HopGuiWorkflowGraph.Dialog.SplitHop.Title"),
+                  BaseMessages.getString(PKG, 
"HopGuiWorkflowGraph.Dialog.SplitHop.Message")
+                      + Const.CR
+                      + hop,
+                  SWT.ICON_QUESTION,
+                  new String[] {
+                    BaseMessages.getString(PKG, "System.Button.Yes"),
+                    BaseMessages.getString(PKG, "System.Button.No")
+                  },
+                  BaseMessages.getString(
+                      PKG, 
"HopGuiWorkflowGraph.Dialog.Option.SplitHop.DoNotAskAgain"),
+                  hopGui.getProps().getAutoSplit());
+          id = md.open();
+          hopGui.getProps().setAutoSplit(md.getToggleState());
+        }
+        if ((id & 0xFF) == 0) {
+          workflowActionDelegate.insertAction(workflowMeta, hop, actionMeta);
+        }
+      }
+    }
+
+    workflowMeta.unselectAll();
+    actionMeta.setSelected(true);
+    selectedAction = null;
+    currentAction = null;
+    selectedActions = null;
+    avoidContextDialog = true;
+    updateGui();
+  }
+
+  private void cancelPendingPlacement() {
+    ActionMeta ghost = pendingPlacementGhost;
+    pendingPlacementAction = null;
+    pendingPlacementGhost = null;
+    clearPendingPlacementHopSplitHighlight();
+    removePlacementDragFilters();
+    if (canvas != null && !canvas.isDisposed()) {
+      canvas.setData("mode", "null");
+    }
+    setCursor(null);
+    selectedAction = null;
+    currentAction = null;
+    selectedActions = null;
+    if (ghost != null) {
+      workflowActionDelegate.deleteAction(workflowMeta, ghost);
+    }
+    avoidContextDialog = true;
+    updateGui();
+  }
+
+  private void removePlacementDragFilters() {
+    Display display = hopGui.getDisplay();
+    if (display == null || display.isDisposed()) {
+      placementDragMoveFilter = null;
+      placementDragUpFilter = null;
+      placementDragKeyFilter = null;
+      return;
+    }
+    if (placementDragMoveFilter != null) {
+      display.removeFilter(SWT.MouseMove, placementDragMoveFilter);
+      placementDragMoveFilter = null;
+    }
+    if (placementDragUpFilter != null) {
+      display.removeFilter(SWT.MouseUp, placementDragUpFilter);
+      placementDragUpFilter = null;
+    }
+    if (placementDragKeyFilter != null) {
+      display.removeFilter(SWT.KeyDown, placementDragKeyFilter);
+      placementDragKeyFilter = null;
+    }
+  }
+
   @Override
   public void mouseMove(MouseEvent event) {
     boolean shift = (event.stateMask & SWT.SHIFT) != 0;
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index 07434d07f9..58fd859c8e 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -44,8 +44,8 @@ CheckResultDialog.Title=Results of pipeline checks
 CheckResultDialog.TransformName.Label=TransformName
 CheckResultDialog.WarningsErrors.Label=Warnings and errors 
 ContextDialog.Category.Favorites=Favorites
-ContextDialog.Favorite.AddHint=\n{0}-Click to add to favorite
-ContextDialog.Favorite.RemoveHint=\n{0}-Click to remove from favorite
+ContextDialog.Favorite.AddHint=\n{0}-Click to add to favorites. Click-drag to 
select and position.
+ContextDialog.Favorite.RemoveHint=\n{0}-Click to remove from favorites. 
Click-drag to select and position.
 ContextDialog.GuiAction.CollapseCategories.Tooltip=Collapse all categories
 ContextDialog.GuiAction.ExpandCategories.Tooltip=Expand all categories
 ContextDialog.GuiAction.FixedWidth.Label=Fixed width
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java
index 87fd6c5e88..e2e3c3e187 100644
--- 
a/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java
@@ -158,4 +158,56 @@ class GuiActionFavoritesTest {
     GuiActionFavorites.toggle(Kind.TRANSFORM, "B");
     assertEquals(List.of("A", "C"), 
GuiActionFavorites.getFavoriteIds(Kind.TRANSFORM));
   }
+
+  @Test
+  void placeableCreateActionResolution() {
+    GuiAction transformCreate =
+        new GuiAction(
+            GuiActionFavorites.createId(Kind.TRANSFORM, "Dummy"),
+            GuiActionType.Create,
+            "Dummy",
+            "d",
+            "i",
+            (s, c, t) -> {});
+    assertTrue(GuiActionFavorites.isPlaceableCreateAction(transformCreate));
+    GuiActionFavorites.KindAndPluginId resolved =
+        GuiActionFavorites.resolveFromAction(transformCreate);
+    assertEquals(Kind.TRANSFORM, resolved.kind());
+    assertEquals("Dummy", resolved.pluginId());
+
+    GuiAction transformFavorite =
+        new GuiAction(
+            GuiActionFavorites.favoriteId(Kind.TRANSFORM, "Dummy"),
+            GuiActionType.Create,
+            "Dummy",
+            "d",
+            "i",
+            (s, c, t) -> {});
+    assertTrue(GuiActionFavorites.isPlaceableCreateAction(transformFavorite));
+    assertEquals(Kind.TRANSFORM, 
GuiActionFavorites.resolveFromAction(transformFavorite).kind());
+
+    GuiAction workflowCreate =
+        new GuiAction(
+            GuiActionFavorites.createId(Kind.WORKFLOW_ACTION, "START"),
+            GuiActionType.Create,
+            "Start",
+            "d",
+            "i",
+            (s, c, t) -> {});
+    assertTrue(GuiActionFavorites.isPlaceableCreateAction(workflowCreate));
+    assertEquals(Kind.WORKFLOW_ACTION, 
GuiActionFavorites.resolveFromAction(workflowCreate).kind());
+    assertEquals("START", 
GuiActionFavorites.resolveFromAction(workflowCreate).pluginId());
+
+    GuiAction other =
+        new GuiAction(
+            "pipeline-graph-edit-properties",
+            GuiActionType.Modify,
+            "Edit",
+            "d",
+            "i",
+            (s, c, t) -> {});
+    assertFalse(GuiActionFavorites.isPlaceableCreateAction(other));
+    assertEquals(null, GuiActionFavorites.resolveFromAction(other));
+    assertFalse(GuiActionFavorites.isPlaceableCreateAction(null));
+  }
 }

Reply via email to