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.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.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 items = new ArrayList<>(); @@ -502,9 +538,14 @@ public GuiAction open() { 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 boolean isDisposed() { } public void dispose() { + if (shell == null || shell.isDisposed()) { + return; + } + + removePlacementArmFilters(); // Store the toolbar settings storeDialogSettings(); @@ -663,8 +709,12 @@ public void dispose() { // 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 @@ private void onMouseMove(Event event) { } } + 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 @@ private void onMouseUp(Event event) { } } + 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 @@ else if (!filteredItems.contains(selectedItem)) { } 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 static String getFavoritesCategoryName() { } /** - * 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 static boolean tryToggleFromAction(GuiAction action) { 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 @@ private static KindAndPluginId resolve(String actionId) { 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.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 synchronized boolean handleActionSelection( 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 synchronized boolean handleActionSelection( } 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 f957c93e87..b3856aaa8a 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.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.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 HopGuiPipelineGraph( 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()); @@ -1314,6 +1347,7 @@ public void mouseUp(MouseEvent e) { dragSelection = false; iconDragStartScreen = null; iconDragCommitted = false; + removePlacementDragFilters(); updateGui(); } else { @@ -1649,6 +1683,413 @@ private void showActionDialog( } } + /** + * 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 bf15ca081b..198ffce154 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.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.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 HopGuiWorkflowGraph( 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); @@ -1172,6 +1205,7 @@ public void mouseUp(MouseEvent event) { endHopLocation = null; actionDragStartScreen = null; actionDragCommitted = false; + removePlacementDragFilters(); updateGui(); } else { @@ -1385,6 +1419,431 @@ private void showContextDialog( } } + /** + * 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 @@ void multiFavoriteOrderPreserved() { 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)); + } }