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));
+ }
}