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 2103cd9150 Add zoom to hop web, fixes #6442 (#6444)
2103cd9150 is described below
commit 2103cd91509f9ff28f8cee3c64889968b1f9e554
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Feb 2 13:43:18 2026 +0100
Add zoom to hop web, fixes #6442 (#6444)
---
.../apache/hop/core/gui/plugin/GuiRegistry.java | 9 +
.../org/apache/hop/ui/hopgui/CanvasFacadeImpl.java | 21 ++
.../apache/hop/ui/hopgui/CanvasListenerImpl.java | 9 +-
.../apache/hop/ui/hopgui/CanvasZoomHandler.java | 159 ++++++++
.../main/java/org/apache/hop/ui/hopgui/HopWeb.java | 39 +-
.../org/apache/hop/ui/hopgui/HopWebEntryPoint.java | 190 +++++++++-
.../org/apache/hop/ui/hopgui/canvas-zoom.js | 204 +++++++++++
.../resources/org/apache/hop/ui/hopgui/canvas.js | 398 +++++++++++++++++++--
.../org/apache/hop/ui/hopgui/clipboard.js | 5 -
.../org/apache/hop/ui/hopgui/dark-mode.css | 7 -
.../org/apache/hop/ui/hopgui/light-mode.css | 7 -
.../file/delegates/HopGuiNotePadDelegate.java | 2 +
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 67 +++-
.../delegates/HopGuiPipelineClipboardDelegate.java | 2 +
.../ui/hopgui/file/shared/HopGuiAbstractGraph.java | 19 +-
.../hopgui/file/workflow/HopGuiWorkflowGraph.java | 84 ++++-
.../delegates/HopGuiWorkflowClipboardDelegate.java | 2 +
.../perspective/execution/DragViewZoomBase.java | 40 +++
.../execution/PipelineExecutionViewer.java | 10 +
.../execution/WorkflowExecutionViewer.java | 10 +
.../perspective/explorer/ExplorerPerspective.java | 32 +-
.../hop/ui/hopgui/shared/BaseExecutionViewer.java | 10 +
.../hop/ui/hopgui/shared/CanvasZoomHelper.java | 97 +++++
23 files changed, 1330 insertions(+), 93 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
index e7c6a9b471..5a43478879 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
@@ -496,6 +496,15 @@ public class GuiRegistry {
return shortCutsMap.get(parentClassName);
}
+ /**
+ * Get all keyboard shortcuts from all registered classes
+ *
+ * @return Map of all keyboard shortcuts (className -> List of shortcuts)
+ */
+ public Map<String, List<KeyboardShortcut>> getAllKeyboardShortcuts() {
+ return shortCutsMap;
+ }
+
// Shortcuts are pretty much global so we'll look everywhere...
//
public KeyboardShortcut findKeyboardShortcut(
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
index 90bd06d7d0..4e415edc8e 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
@@ -19,6 +19,8 @@ package org.apache.hop.ui.hopgui;
import org.apache.hop.base.AbstractMeta;
import org.apache.hop.core.gui.DPoint;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.gui.Rectangle;
import org.apache.hop.pipeline.PipelineHopMeta;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.ui.core.PropsUi;
@@ -51,6 +53,25 @@ public class CanvasFacadeImpl extends CanvasFacade {
jsonProps.add("magnification", (float) (magnification *
PropsUi.getNativeZoomFactor()));
jsonProps.add("offsetX", offset.x);
jsonProps.add("offsetY", offset.y);
+
+ // Add pan data if available
+ Point panStartOffset = (Point) canvas.getData("panStartOffset");
+ Rectangle panBoundaries = (Rectangle) canvas.getData("panBoundaries");
+ if (panStartOffset != null) {
+ JsonObject jsonPanStartOffset = new JsonObject();
+ jsonPanStartOffset.add("x", panStartOffset.x);
+ jsonPanStartOffset.add("y", panStartOffset.y);
+ jsonProps.add("panStartOffset", jsonPanStartOffset);
+ }
+ if (panBoundaries != null) {
+ JsonObject jsonPanBoundaries = new JsonObject();
+ jsonPanBoundaries.add("x", panBoundaries.x);
+ jsonPanBoundaries.add("y", panBoundaries.y);
+ jsonPanBoundaries.add("width", panBoundaries.width);
+ jsonPanBoundaries.add("height", panBoundaries.height);
+ jsonProps.add("panBoundaries", jsonPanBoundaries);
+ }
+
canvas.setData("props", jsonProps);
JsonArray jsonNotes = new JsonArray();
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
index 1482b59c48..7947e09a97 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
@@ -19,7 +19,9 @@ package org.apache.hop.ui.hopgui;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.util.Objects;
import org.apache.commons.io.IOUtils;
+import org.apache.hop.core.logging.LogChannel;
import org.eclipse.rap.rwt.SingletonUtil;
import org.eclipse.rap.rwt.scripting.ClientListener;
@@ -39,9 +41,10 @@ public class CanvasListenerImpl extends ClientListener
implements ISingletonProv
try {
canvasScript =
IOUtils.toString(
- CanvasListenerImpl.class.getResourceAsStream("canvas.js"),
StandardCharsets.UTF_8);
- } catch (IOException e1) {
- e1.printStackTrace();
+
Objects.requireNonNull(CanvasListenerImpl.class.getResourceAsStream("canvas.js")),
+ StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ LogChannel.UI.logError("Error loading canvas.js", e);
}
return canvasScript;
}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasZoomHandler.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasZoomHandler.java
new file mode 100644
index 0000000000..9de4581670
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasZoomHandler.java
@@ -0,0 +1,159 @@
+/*
+ * 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;
+
+import org.apache.hop.core.logging.LogChannel;
+import org.eclipse.rap.json.JsonObject;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.remote.AbstractOperationHandler;
+import org.eclipse.rap.rwt.remote.Connection;
+import org.eclipse.rap.rwt.remote.RemoteObject;
+import org.eclipse.rap.rwt.widgets.WidgetUtil;
+import org.eclipse.swt.events.MouseEvent;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Widget;
+
+public class CanvasZoomHandler extends Widget {
+
+ // Singleton: ONE remote object per UI session, reused for all canvases
+ private static RemoteObject globalRemoteObject;
+ private static IZoomable currentZoomable;
+ private static Canvas currentCanvas;
+ private static final Object sessionLock = new Object();
+
+ private final IZoomable zoomable;
+ private final Canvas canvas;
+
+ public interface IZoomable {
+ void zoomIn(MouseEvent mouseEvent);
+
+ void zoomOut(MouseEvent mouseEvent);
+ }
+
+ public CanvasZoomHandler(Composite parent, Canvas canvas, IZoomable
zoomable) {
+ super(parent, 0);
+ this.zoomable = zoomable;
+ this.canvas = canvas;
+ }
+
+ public void notifyCanvasReady() {
+ if (isDisposed()) {
+ return;
+ }
+
+ synchronized (sessionLock) {
+ // Update the current canvas and zoomable
+ currentCanvas = this.canvas;
+ currentZoomable = this.zoomable;
+
+ // Create remote object only once per session
+ if (globalRemoteObject == null) {
+ createGlobalRemoteObject();
+ } else {
+ updateCanvas();
+ }
+ }
+ }
+
+ private void createGlobalRemoteObject() {
+ try {
+ Connection connection = RWT.getUISession().getConnection();
+
+ // Create ONE remote object for the entire session
+ globalRemoteObject = connection.createRemoteObject("hop.CanvasZoom");
+ globalRemoteObject.set("self", globalRemoteObject.getId());
+
+ String canvasId =
org.eclipse.rap.rwt.widgets.WidgetUtil.getId(currentCanvas);
+ globalRemoteObject.set("canvas", canvasId);
+
+ globalRemoteObject.setHandler(
+ new AbstractOperationHandler() {
+ @Override
+ public void handleNotify(String event, JsonObject properties) {
+ if ("zoom".equals(event)) {
+ synchronized (sessionLock) {
+ // Use the current canvas and zoomable (may have changed
since creation)
+ if (currentCanvas == null || currentZoomable == null) {
+ return;
+ }
+
+ int count = properties.get("count").asInt();
+ int x = properties.get("x").asInt();
+ int y = properties.get("y").asInt();
+
+ // Create a mouse event with Event object
+ org.eclipse.swt.widgets.Event swtEvent = new
org.eclipse.swt.widgets.Event();
+ swtEvent.widget = currentCanvas;
+ swtEvent.x = x;
+ swtEvent.y = y;
+ swtEvent.count = count;
+ MouseEvent mouseEvent = new MouseEvent(swtEvent);
+
+ // Call the appropriate zoom method on the current zoomable
+ if (count > 0) {
+ currentZoomable.zoomIn(mouseEvent);
+ } else {
+ currentZoomable.zoomOut(mouseEvent);
+ }
+ }
+ }
+ }
+ });
+
+ globalRemoteObject.listen("zoom", true);
+
+ // Signal the client to attach the wheel listener
+ getDisplay()
+ .timerExec(
+ 50,
+ () -> {
+ if (globalRemoteObject != null) {
+ globalRemoteObject.call("attachListener", null);
+ }
+ });
+
+ } catch (Exception e) {
+ LogChannel.UI.logError("Failed to create CanvasZoomHandler remote
object: ", e);
+ }
+ }
+
+ private void updateCanvas() {
+ // Update which canvas the remote object is attached to
+ String canvasId = WidgetUtil.getId(currentCanvas);
+
+ // Update the canvas property - this will trigger property change on client
+ globalRemoteObject.set("canvas", canvasId);
+
+ // Then call attachListener to reattach to the new canvas
+ globalRemoteObject.call("attachListener", null);
+ }
+
+ @Override
+ public void dispose() {
+ synchronized (sessionLock) {
+ // If this handler was the current one, clear the current references
+ if (currentCanvas == this.canvas) {
+ currentCanvas = null;
+ currentZoomable = null;
+ }
+ }
+ // Note: Don't destroy globalRemoteObject - it's shared across all
canvases in the session
+ super.dispose();
+ }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index 060df62d1c..edd3d02928 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -37,6 +37,7 @@ import org.apache.commons.lang.StringUtils;
import org.apache.hop.core.Const;
import org.apache.hop.core.gui.plugin.GuiRegistry;
import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarItem;
+import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.plugins.IPlugin;
import org.apache.hop.core.plugins.PluginRegistry;
import org.apache.hop.core.svg.SvgCache;
@@ -87,14 +88,14 @@ public class HopWeb implements ApplicationConfiguration {
addResource(application, plugin.getImageFile(), classLoader);
}
} catch (Exception e) {
- e.printStackTrace();
+ LogChannel.UI.logError("General exception", e);
}
application.addResource(
"ui/images/logo_icon.png",
new ResourceLoader() {
@Override
- public InputStream getResourceAsStream(String resourceName) throws
IOException {
+ public InputStream getResourceAsStream(String resourceName) {
// Convert svg to png without Display
PNGTranscoder t = new PNGTranscoder();
InputStream inputStream =
@@ -105,20 +106,19 @@ public class HopWeb implements ApplicationConfiguration {
try {
t.transcode(input, output);
} catch (TranscoderException e) {
- e.printStackTrace();
+ LogChannel.UI.logError("Transcoder exception", e);
}
return new ByteArrayInputStream(outputStream.toByteArray());
}
});
- Stream.of("org/apache/hop/ui/hopgui/clipboard.js")
+ Stream.of("org/apache/hop/ui/hopgui/clipboard.js",
"org/apache/hop/ui/hopgui/canvas-zoom.js")
.forEach(
str ->
application.addResource(
"js/" + FilenameUtils.getName(str),
new ResourceLoader() {
@Override
- public InputStream getResourceAsStream(String
resourceName)
- throws IOException {
+ public InputStream getResourceAsStream(String
resourceName) {
return
this.getClass().getClassLoader().getResourceAsStream(str);
}
}));
@@ -132,26 +132,26 @@ public class HopWeb implements ApplicationConfiguration {
if ("dark".equalsIgnoreCase(themeId)) {
themeId = "dark";
PropsUi.getInstance().setDarkMode(true);
- System.out.println("Hop web: enabled dark mode rendering");
+ LogChannel.UI.logBasic("Hop web: enabled dark mode rendering");
} else {
themeId = CONST_LIGHT;
PropsUi.getInstance().setDarkMode(false);
}
- System.out.println("Hop web: selected theme is: " + themeId);
+ LogChannel.UI.logBasic("Hop web: selected theme is: " + themeId);
Map<String, String> properties = new HashMap<>();
properties.put(WebClient.PAGE_TITLE, "Apache Hop Web");
properties.put(WebClient.FAVICON, "ui/images/logo_icon.png");
properties.put(WebClient.THEME_ID, themeId);
- properties.put(WebClient.HEAD_HTML, readTextFromResource("head.html",
"UTF-8"));
+ properties.put(WebClient.HEAD_HTML, readTextFromResource("head.html"));
application.addEntryPoint("/ui", HopWebEntryPoint.class, properties);
application.setOperationMode(Application.OperationMode.SWT_COMPATIBILITY);
// Print some important system settings...
//
- System.out.println("HOP_CONFIG_FOLDER: " + Const.HOP_CONFIG_FOLDER);
- System.out.println("HOP_AUDIT_FOLDER: " + Const.HOP_AUDIT_FOLDER);
- System.out.println("HOP_GUI_ZOOM_FACTOR: " +
System.getProperty("HOP_GUI_ZOOM_FACTOR"));
+ LogChannel.UI.logBasic("HOP_CONFIG_FOLDER: " + Const.HOP_CONFIG_FOLDER);
+ LogChannel.UI.logBasic("HOP_AUDIT_FOLDER: " + Const.HOP_AUDIT_FOLDER);
+ LogChannel.UI.logBasic("HOP_GUI_ZOOM_FACTOR: " +
System.getProperty("HOP_GUI_ZOOM_FACTOR"));
}
private void addResource(
@@ -179,16 +179,17 @@ public class HopWeb implements ApplicationConfiguration {
application.addResource(imageFilename, loader);
}
- private static String readTextFromResource(String resourceName, String
charset) {
+ private static String readTextFromResource(String resourceName) {
String result;
try {
ClassLoader classLoader = HopWeb.class.getClassLoader();
InputStream inputStream = classLoader.getResourceAsStream(resourceName);
- if (inputStream == null) {
- throw new RuntimeException("Resource not found: " + resourceName);
- }
- try {
- BufferedReader reader = new BufferedReader(new
InputStreamReader(inputStream, charset));
+ try (inputStream) {
+ if (inputStream == null) {
+ throw new RuntimeException("Resource not found: " + resourceName);
+ }
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(inputStream,
StandardCharsets.UTF_8));
StringBuilder stringBuilder = new StringBuilder();
String line = reader.readLine();
while (line != null) {
@@ -197,8 +198,6 @@ public class HopWeb implements ApplicationConfiguration {
line = reader.readLine();
}
result = stringBuilder.toString();
- } finally {
- inputStream.close();
}
} catch (IOException e) {
throw new RuntimeException("Failed to read text from resource: " +
resourceName);
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index 9759739588..39ab75a41a 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -18,20 +18,45 @@
package org.apache.hop.ui.hopgui;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import org.apache.hop.core.extension.ExtensionPointHandler;
import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.key.KeyboardShortcut;
import org.apache.hop.ui.core.PropsUi;
import org.eclipse.rap.rwt.RWT;
import org.eclipse.rap.rwt.application.AbstractEntryPoint;
+import org.eclipse.rap.rwt.client.service.JavaScriptLoader;
import org.eclipse.rap.rwt.client.service.StartupParameters;
+import org.eclipse.rap.rwt.service.ResourceManager;
import org.eclipse.rap.rwt.widgets.WidgetUtil;
+import org.eclipse.swt.SWT;
import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
public class HopWebEntryPoint extends AbstractEntryPoint {
@Override
protected void createContents(Composite parent) {
+ ResourceManager resourceManager = RWT.getResourceManager();
+ JavaScriptLoader jsLoader =
RWT.getClient().getService(JavaScriptLoader.class);
+
+ // Load canvas zoom handler
+ String jsLocation = resourceManager.getLocation("js/canvas-zoom.js");
+ jsLoader.require(jsLocation);
+
+ // Configure keyboard shortcuts for RAP dynamically from annotations
+ // ACTIVE_KEYS tells RAP to send these key combinations to the server
+ // CANCEL_KEYS prevents the browser from handling these shortcuts
+ // Note: CTRL automatically maps to Command key on Mac
+ Display display = parent.getDisplay();
+ String[] shortcuts = buildKeyboardShortcuts();
+ display.setData(RWT.ACTIVE_KEYS, shortcuts);
+ display.setData(RWT.CANCEL_KEYS, shortcuts);
+
// Transferring Widget Data for client-side canvas drawing instructions
WidgetUtil.registerDataKeys("props");
WidgetUtil.registerDataKeys("mode");
@@ -39,7 +64,7 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
WidgetUtil.registerDataKeys("hops");
WidgetUtil.registerDataKeys("notes");
WidgetUtil.registerDataKeys("startHopNode");
- // WidgetUtil.registerDataKeys("svg");
+ WidgetUtil.registerDataKeys("resizeDirection");
// The following options are session specific.
//
@@ -52,7 +77,6 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
}
}
- // Execute Spoon.createContents
HopGui.getInstance().setCommandLineArguments(args);
HopGui.getInstance().setShell(parent.getShell());
HopGui.getInstance().setProps(PropsUi.getInstance());
@@ -70,4 +94,166 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
HopGui.getInstance().open();
}
+
+ /**
+ * Build keyboard shortcuts for RAP from all @GuiKeyboardShortcut and
@GuiOsxKeyboardShortcut
+ * annotations
+ *
+ * @return Array of shortcut strings in RAP format (e.g., "CTRL+C", "ALT+F1")
+ */
+ private String[] buildKeyboardShortcuts() {
+ Set<String> shortcuts = new HashSet<>();
+
+ // Get all keyboard shortcuts from GuiRegistry
+ GuiRegistry registry = GuiRegistry.getInstance();
+ Map<String, List<KeyboardShortcut>> allShortcuts =
registry.getAllKeyboardShortcuts();
+
+ if (allShortcuts == null) {
+ return new String[0];
+ }
+
+ // Convert each shortcut to RAP format
+ for (Map.Entry<String, List<KeyboardShortcut>> entry :
allShortcuts.entrySet()) {
+ List<KeyboardShortcut> shortcutList = entry.getValue();
+ if (shortcutList != null) {
+ for (KeyboardShortcut shortcut : shortcutList) {
+ String rapShortcut = convertToRapFormat(shortcut);
+ if (rapShortcut != null && !rapShortcut.isEmpty()) {
+ shortcuts.add(rapShortcut);
+ }
+ }
+ }
+ }
+
+ return shortcuts.toArray(new String[0]);
+ }
+
+ /**
+ * Convert a KeyboardShortcut to RAP format
+ *
+ * @param shortcut The keyboard shortcut to convert
+ * @return RAP format string (e.g., "CTRL+C", "ALT+SHIFT+F1") or null if
invalid
+ */
+ private String convertToRapFormat(KeyboardShortcut shortcut) {
+ if (shortcut.getKeyCode() == 0) {
+ return null;
+ }
+
+ StringBuilder sb = new StringBuilder();
+
+ // Add modifiers in RAP order
+ if (shortcut.isAlt()) {
+ sb.append("ALT+");
+ }
+ if (shortcut.isControl() || shortcut.isCommand()) {
+ // CTRL maps to Command on Mac automatically
+ sb.append("CTRL+");
+ }
+ if (shortcut.isShift()) {
+ sb.append("SHIFT+");
+ }
+
+ // Convert keyCode to character or special key name
+ int keyCode = shortcut.getKeyCode();
+
+ // Character keys (a-z, A-Z)
+ if (keyCode >= 65 && keyCode <= 90) {
+ sb.append((char) keyCode);
+ } else if (keyCode >= 97 && keyCode <= 122) {
+ sb.append(Character.toUpperCase((char) keyCode));
+ }
+ // Digit keys (0-9)
+ else if (keyCode >= 48 && keyCode <= 57) {
+ sb.append((char) keyCode);
+ }
+ // Special characters
+ else if (keyCode == '+'
+ || keyCode == '-'
+ || keyCode == '*'
+ || keyCode == '/'
+ || keyCode == '=') {
+ sb.append((char) keyCode);
+ }
+ // SWT special keys (have bit 24 set)
+ else if ((keyCode & (1 << 24)) != 0) {
+ String specialKey = convertSwtKeyToRap(keyCode & 0xFFFF);
+ if (specialKey != null) {
+ sb.append(specialKey);
+ } else {
+ return null; // Unknown special key
+ }
+ }
+ // DEL key
+ else if (keyCode == SWT.DEL || keyCode == 127) {
+ sb.append("DEL");
+ }
+ // ESC key
+ else if (keyCode == SWT.ESC || keyCode == 27) {
+ sb.append("ESC");
+ }
+ // Space
+ else if (keyCode == ' ' || keyCode == 32) {
+ sb.append("SPACE");
+ } else {
+ // Unknown key code, skip it
+ return null;
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Convert SWT special key codes to RAP format
+ *
+ * @param swtKey SWT key code (with bit 24 masked off)
+ * @return RAP key name or null if not supported
+ */
+ private String convertSwtKeyToRap(int swtKey) {
+ switch (swtKey) {
+ case 1:
+ return "ARROW_UP";
+ case 2:
+ return "ARROW_DOWN";
+ case 3:
+ return "ARROW_LEFT";
+ case 4:
+ return "ARROW_RIGHT";
+ case 5:
+ return "PAGE_UP";
+ case 6:
+ return "PAGE_DOWN";
+ case 7:
+ return "HOME";
+ case 8:
+ return "END";
+ case 9:
+ return "INSERT";
+ case 10:
+ return "F1";
+ case 11:
+ return "F2";
+ case 12:
+ return "F3";
+ case 13:
+ return "F4";
+ case 14:
+ return "F5";
+ case 15:
+ return "F6";
+ case 16:
+ return "F7";
+ case 17:
+ return "F8";
+ case 18:
+ return "F9";
+ case 19:
+ return "F10";
+ case 20:
+ return "F11";
+ case 21:
+ return "F12";
+ default:
+ return null;
+ }
+ }
}
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-zoom.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-zoom.js
new file mode 100644
index 0000000000..9fdab32876
--- /dev/null
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-zoom.js
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+//# sourceURL=canvas-zoom.js
+(function() {
+ 'use strict';
+
+ // Ensure hop namespace exists FIRST
+ if (!window.hop) {
+ window.hop = {};
+ }
+
+ // Define the CanvasZoom constructor BEFORE registering the type handler
+ hop.CanvasZoom = function(properties) {
+ this._canvas = null;
+ this._canvasId = properties.canvas; // This is the Canvas widget ID
(Composite), not the actual canvas element
+ this._remoteObject = null;
+ this._wheelHandler = null;
+ this._sizeCheckInterval = null;
+
+ // DON'T attach in constructor - wait for explicit attachListener call
from Java
+ // This ensures the canvas is fully created and the remote object is
ready
+ };
+
+ hop.CanvasZoom.prototype = {
+ destroy: function() {
+ if (this._canvas && this._wheelHandler) {
+ this._canvas.removeEventListener('wheel', this._wheelHandler);
+ }
+ if (this._sizeCheckInterval) {
+ clearInterval(this._sizeCheckInterval);
+ this._sizeCheckInterval = null;
+ }
+ },
+
+ // Method called from Java backend to attach/reattach the listener
+ attachListener: function() {
+ this._findAndAttachCanvas();
+ },
+
+ // Fix for canvas shrinking at low zoom levels
+ _applyCanvasSizeFix: function() {
+ if (!this._canvas) return;
+
+ // Check for scaling transforms on the canvas
+ var computedStyle = window.getComputedStyle(this._canvas);
+ var transform = computedStyle.transform;
+
+ // Remove any transform that includes scaling
+ if (transform && transform !== 'none') {
+ // Parse matrix values to check for scaling
+ var match =
transform.match(/matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+)/);
+ if (match) {
+ var scaleX = parseFloat(match[1]);
+ var scaleY = parseFloat(match[4]);
+
+ // If there's scaling (not 1.0), remove the transform
+ if (Math.abs(scaleX - 1.0) > 0.01 || Math.abs(scaleY -
1.0) > 0.01) {
+ this._canvas.style.transform = 'none';
+ }
+ }
+ }
+
+ // Ensure canvas fills its container
+ this._canvas.style.width = '100%';
+ this._canvas.style.height = '100%';
+ this._canvas.style.display = 'block';
+ },
+
+ // Method called when the canvas property is updated from Java
+ setCanvas: function(properties) {
+ this._canvasId = properties.canvasId;
+ this._findAndAttachCanvas();
+ },
+
+ _findAndAttachCanvas: function() {
+ var self = this;
+ var attempts = 0;
+ var maxAttempts = 10;
+
+ var tryFindCanvas = function() {
+ attempts++;
+
+ // Find the active/visible canvas - typically the one in the
currently selected tab
+ // Look for canvas elements that are large and visible (not
hidden)
+ var allCanvases = document.querySelectorAll('canvas');
+ var canvas = null;
+
+ for (var i = 0; i < allCanvases.length; i++) {
+ var c = allCanvases[i];
+ // Check if canvas is large enough (graph canvases are
typically > 500px)
+ // and is visible (not display:none or visibility:hidden)
+ var rect = c.getBoundingClientRect();
+ if (rect.width > 500 && rect.height > 500 &&
+ c.offsetParent !== null) { // offsetParent is null if
element or ancestor is hidden
+ canvas = c;
+ break;
+ }
+ }
+
+ if (!canvas) {
+ if (attempts < maxAttempts) {
+ setTimeout(tryFindCanvas, 100);
+ return;
+ } else {
+ return;
+ }
+ }
+
+ // Check if this is a different canvas than the one we already
have
+ if (self._canvas === canvas) {
+ return;
+ }
+
+ // Remove old listener if switching to a different canvas
+ if (self._canvas && self._wheelHandler) {
+ self._canvas.removeEventListener('wheel',
self._wheelHandler);
+ }
+
+ // Setup successful - attach listener
+ self._canvas = canvas;
+
+ // Apply initial canvas sizing fix
+ self._applyCanvasSizeFix();
+
+ // Start periodic check to continuously remove any transforms
+ // This catches transforms applied by RAP at any time,
including initial load at low zoom
+ if (!self._sizeCheckInterval) {
+ self._sizeCheckInterval = setInterval(function() {
+ self._applyCanvasSizeFix();
+ }, 200); // Check every 200ms
+ }
+
+ // Get or create remote object
+ if (!self._remoteObject) {
+ self._remoteObject = rap.getRemoteObject(self);
+ }
+
+ // Create wheel handler if it doesn't exist
+ if (!self._wheelHandler) {
+ self._wheelHandler = function(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ var count = event.deltaY < 0 ? 1 : -1;
+ var rect = self._canvas.getBoundingClientRect();
+ var x = event.clientX - rect.left;
+ var y = event.clientY - rect.top;
+
+ self._remoteObject.notify("zoom", {
+ count: count,
+ x: Math.round(x),
+ y: Math.round(y)
+ });
+
+ // Fix canvas sizing after zoom event
+ // This prevents the canvas from visually shrinking at
low zoom levels
+ setTimeout(function() {
+ self._applyCanvasSizeFix();
+ }, 10);
+ };
+ }
+
+ // Attach the listener to the canvas
+ self._canvas.addEventListener('wheel', self._wheelHandler, {
passive: false });
+ };
+
+ // Start trying to find the canvas
+ tryFindCanvas();
+ }
+ };
+
+ // Register the type handler AFTER the class is fully defined
+ rap.registerTypeHandler("hop.CanvasZoom", {
+ factory: function(properties) {
+ return new hop.CanvasZoom(properties);
+ },
+ destructor: "destroy",
+ properties: ["canvas"],
+ methods: ["attachListener"],
+ events: ["zoom"],
+ propertyHandler: {
+ canvas: function(widget, value) {
+ // When canvas property is updated from Java, update _canvasId
+ widget._canvasId = value;
+ }
+ }
+ });
+
+})();
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas.js
index c0d087976d..49b4180ba8 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas.js
@@ -19,11 +19,21 @@
"use strict";
let x1, x2, y1, y2;
+let mouseScreenX = 0, mouseScreenY = 0; // Track raw screen coordinates for
hop drawing
let clicked = null;
let hopStartNode = null; // Track the start node for hop creation separately
let iconOffsetX = 0, iconOffsetY = 0; // Track where in the icon the user
clicked (like desktop client)
let offsetX, offsetY, magnification, gridSize, showGrid;
let fgColor, bgColor, selectedNodeColor, nodeColor;
+let canvasWidth = 0, canvasHeight = 0; // Store canvas dimensions for mouse
wheel zoom calculations
+let panStartMouseX = 0, panStartMouseY = 0; // Mouse position when pan started
+let panStartOffsetX = 0, panStartOffsetY = 0; // Canvas offset when pan
started
+let panCurrentOffsetX = 0, panCurrentOffsetY = 0; // Current calculated
offset during pan
+let panBounds = null; // Cached pan boundaries
+let isPanning = false; // Track if we're actively panning
+let panInitialized = false; // Track if pan has processed at least one mouse
move
+let resizeStartMouseX = 0, resizeStartMouseY = 0; // Mouse position when
resize started
+let resizedNote = null; // The note being resized with updated dimensions
// These are the colors for the theme. They are picked up by the next event
handler function.
//
@@ -54,18 +64,33 @@ const handleEvent = function (event) {
const notes = event.widget.getData("notes");
const props = event.widget.getData("props");
const startHopNodeName = event.widget.getData("startHopNode");
+ const resizeDirection = event.widget.getData("resizeDirection");
+
+ // Get pan data from props (it's sent via JSON)
+ const panStartOffset = props ? props.panStartOffset : null;
+ const panBoundaries = props ? props.panBoundaries : null;
// Global vars to make the coordinate calculation function simpler.
//
- // Round offsets to avoid sub-pixel grid misalignment
- offsetX = Math.round(props.offsetX);
- offsetY = Math.round(props.offsetY);
magnification = props.magnification;
gridSize = props.gridSize; // Always set for snapping
showGrid = props.showGrid; // Control visibility only
const iconSize = Math.round(props.iconSize * magnification);
+ // Round offsets to avoid sub-pixel grid misalignment
+ // In pan mode, use the calculated offset from MouseMove (only if properly
initialized and valid)
+ if (mode === "pan" && isPanning &&
+ typeof panCurrentOffsetX === 'number' && !isNaN(panCurrentOffsetX) &&
isFinite(panCurrentOffsetX) &&
+ typeof panCurrentOffsetY === 'number' && !isNaN(panCurrentOffsetY) &&
isFinite(panCurrentOffsetY)) {
+ offsetX = panCurrentOffsetX;
+ offsetY = panCurrentOffsetY;
+ } else {
+ // Use server-provided offset (safe fallback)
+ offsetX = Math.round(props.offsetX || 0);
+ offsetY = Math.round(props.offsetY || 0);
+ }
+
// Synchronize hopStartNode with server data
if (mode === "hop" && startHopNodeName && nodes[startHopNodeName]) {
// Always update hopStartNode when we have server data
@@ -76,17 +101,23 @@ const handleEvent = function (event) {
}
switch (event.type) {
+ case SWT.MouseWheel:
+ case SWT.MouseVerticalWheel:
+ // Mouse wheel zoom is handled by CanvasZoomHandler
(canvas-zoom.js)
+ // Don't process the event here to avoid interference
+ break;
case SWT.MouseDown:
- // Convert screen coordinates to logical coordinates
- x1 = event.x / magnification;
- y1 = event.y / magnification;
+ // Convert screen coordinates to logical coordinates (props might
not be set yet, use previous magnification or 1.0)
+ const magForDown = props ? props.magnification : (magnification ||
1.0);
+ x1 = event.x / magForDown;
+ y1 = event.y / magForDown;
// Set x2/y2 to current position (needed for hop drawing after
mode change)
x2 = x1;
y2 = y1;
// Determine which node is clicked if any
- const iconLogicalSize = iconSize / magnification;
+ const iconLogicalSize = (props ? Math.round(props.iconSize *
magForDown) : 32) / magForDown;
for (let key in nodes) {
const node = nodes[key];
if (node.x <= x1 && x1 < node.x + iconLogicalSize
@@ -100,6 +131,9 @@ const handleEvent = function (event) {
break;
}
}
+
+ // Don't initialize resize in MouseDown - there's a timing issue
+ // The mode data arrives after MouseDown, so we'll initialize in
MouseMove instead
break;
case SWT.MouseUp:
// Only reset clicked for non-hop modes
@@ -109,10 +143,233 @@ const handleEvent = function (event) {
iconOffsetX = 0;
iconOffsetY = 0;
}
+
+ // Clear pan state
+ if (isPanning) {
+ // Uncomment for debugging: console.log("Pan ended - final
offset:", panCurrentOffsetX, panCurrentOffsetY);
+ isPanning = false;
+ panInitialized = false;
+ panBounds = null;
+ panStartMouseX = 0;
+ panStartMouseY = 0;
+ panStartOffsetX = 0;
+ panStartOffsetY = 0;
+ panCurrentOffsetX = 0;
+ panCurrentOffsetY = 0;
+ }
+
+ // Clear resize state
+ if (resizedNote) {
+ resizedNote = null;
+ resizeStartMouseX = 0;
+ resizeStartMouseY = 0;
+ }
break;
case SWT.MouseMove:
- x2 = event.x / magnification;
- y2 = event.y / magnification;
+ // Store raw screen coordinates for hop drawing
+ mouseScreenX = event.x;
+ mouseScreenY = event.y;
+
+ // Initialize resize state if we're in resize mode and haven't
initialized yet
+ // (Handle timing issue where mode data arrives after MouseDown)
+ if (mode === "resize" && !resizedNote && resizeDirection && notes)
{
+ resizeStartMouseX = event.x;
+ resizeStartMouseY = event.y;
+ // Find the selected note
+ notes.forEach(function(note) {
+ if (note.selected) {
+ resizedNote = {
+ note: note,
+ direction: resizeDirection,
+ startX: note.x,
+ startY: note.y,
+ startWidth: note.width,
+ startHeight: note.height,
+ currentX: note.x,
+ currentY: note.y,
+ currentWidth: note.width,
+ currentHeight: note.height
+ };
+ }
+ });
+ }
+
+ // Initialize pan state if we're in pan mode and have all required
data
+ // This can happen on any MouseMove event, not just the first one
(handles timing issues)
+ if (mode === "pan" && !isPanning && panStartOffset &&
panBoundaries && props) {
+ // Validate all required data before initializing
+ if (typeof panStartOffset.x === 'number' && typeof
panStartOffset.y === 'number' &&
+ typeof panBoundaries.x === 'number' && typeof
panBoundaries.y === 'number' &&
+ typeof panBoundaries.width === 'number' && typeof
panBoundaries.height === 'number' &&
+ typeof event.x === 'number' && typeof event.y === 'number'
&&
+ typeof props.offsetX === 'number' && typeof props.offsetY
=== 'number') {
+ // Use current mouse position as the start position
(wherever we are when data arrives)
+ isPanning = true;
+ panInitialized = false; // Not fully initialized until we
process first move
+ panStartMouseX = event.x;
+ panStartMouseY = event.y;
+
+ // IMPORTANT: Use the current server offset
(props.offsetX/Y) as the starting point,
+ // not panStartOffset which might be stale due to timing
+ // This prevents the "jump" when pan mode initializes
+ panStartOffsetX = Math.round(props.offsetX);
+ panStartOffsetY = Math.round(props.offsetY);
+ panCurrentOffsetX = panStartOffsetX;
+ panCurrentOffsetY = panStartOffsetY;
+ panBounds = {
+ x: panBoundaries.x,
+ y: panBoundaries.y,
+ width: panBoundaries.width,
+ height: panBoundaries.height
+ };
+ }
+ }
+
+ // Handle pan mode - calculate offset in real-time
+ if (mode === "pan" && isPanning && panBounds && props && typeof
panStartOffsetX === 'number') {
+ // Get magnification from props (it's not set as global var
yet)
+ const mag = props.magnification;
+
+ // Validate we have all required data before calculating
+ if (mag && mag > 0 && typeof panStartMouseX === 'number' &&
typeof panStartMouseY === 'number') {
+ // If mouse start position is still 0 (initialized from
Paint), set it now
+ if (panStartMouseX === 0 && panStartMouseY === 0) {
+ panStartMouseX = event.x;
+ panStartMouseY = event.y;
+ panInitialized = false; // Reset since we just set
the start position
+ }
+
+ // On the very first MouseMove after initialization, just
update the reference point
+ // without calculating any delta - this prevents the
initial jump
+ if (!panInitialized) {
+ panInitialized = true;
+ panStartMouseX = event.x;
+ panStartMouseY = event.y;
+ // Keep the current offset as-is, don't calculate
delta yet
+ x2 = event.x / mag;
+ y2 = event.y / mag;
+ } else {
+ // Normal pan calculation after first move
+ // Calculate the zoom factor (matching server-side
calculation)
+ const nativeZoomFactor = 1.0;
+ const zoomFactor = nativeZoomFactor * Math.max(0.1,
mag);
+
+ // Validate event coordinates are numbers
+ const currentMouseX = (typeof event.x === 'number' &&
!isNaN(event.x)) ? event.x : panStartMouseX;
+ const currentMouseY = (typeof event.y === 'number' &&
!isNaN(event.y)) ? event.y : panStartMouseY;
+
+ // Calculate delta from pan start position
+ const deltaX = (panStartMouseX - currentMouseX) /
zoomFactor;
+ const deltaY = (panStartMouseY - currentMouseY) /
zoomFactor;
+
+ // Validate deltas are finite numbers
+ if (!isFinite(deltaX) || !isFinite(deltaY)) {
+ // Invalid delta, skip this update
+ x2 = event.x / mag;
+ y2 = event.y / mag;
+ } else {
+ // Apply delta to starting offset
+ let newOffsetX = panStartOffsetX - deltaX;
+ let newOffsetY = panStartOffsetY - deltaY;
+
+ // Validate bounds exist and are valid
+ if (typeof panBounds.x === 'number' && typeof
panBounds.width === 'number' &&
+ typeof panBounds.y === 'number' && typeof
panBounds.height === 'number') {
+ // Apply boundary constraints
+ if (newOffsetX < panBounds.x) {
+ newOffsetX = panBounds.x;
+ }
+ if (newOffsetX > panBounds.width) {
+ newOffsetX = panBounds.width;
+ }
+ if (newOffsetY < panBounds.y) {
+ newOffsetY = panBounds.y;
+ }
+ if (newOffsetY > panBounds.height) {
+ newOffsetY = panBounds.height;
+ }
+ }
+
+ // Store the calculated offset for Paint to use
(only if finite)
+ if (isFinite(newOffsetX) && isFinite(newOffsetY)) {
+ panCurrentOffsetX = Math.round(newOffsetX);
+ panCurrentOffsetY = Math.round(newOffsetY);
+ }
+
+ x2 = currentMouseX / mag;
+ y2 = currentMouseY / mag;
+ }
+ }
+ } else {
+ // Data not ready yet, use fallback
+ x2 = event.x / (props ? props.magnification : 1.0);
+ y2 = event.y / (props ? props.magnification : 1.0);
+ }
+ } else if (mode === "resize" && resizedNote &&
resizedNote.direction) {
+ // Handle resize mode - calculate new dimensions
+ const mag = props ? props.magnification : 1.0;
+ const deltaX = (event.x - resizeStartMouseX) / mag;
+ const deltaY = (event.y - resizeStartMouseY) / mag;
+ const minWidth = 100;
+ const minHeight = 50;
+
+ // Calculate new position and size based on resize direction
+ // Always reset position to start values first, then adjust as
needed
+ resizedNote.currentX = resizedNote.startX;
+ resizedNote.currentY = resizedNote.startY;
+ resizedNote.currentWidth = resizedNote.startWidth;
+ resizedNote.currentHeight = resizedNote.startHeight;
+
+ switch (resizedNote.direction) {
+ case "EAST":
+ resizedNote.currentWidth = Math.max(minWidth,
resizedNote.startWidth + deltaX);
+ break;
+ case "WEST":
+ const newWidthW = Math.max(minWidth,
resizedNote.startWidth - deltaX);
+ resizedNote.currentX = resizedNote.startX +
(resizedNote.startWidth - newWidthW);
+ resizedNote.currentWidth = newWidthW;
+ break;
+ case "SOUTH":
+ resizedNote.currentHeight = Math.max(minHeight,
resizedNote.startHeight + deltaY);
+ break;
+ case "NORTH":
+ const newHeightN = Math.max(minHeight,
resizedNote.startHeight - deltaY);
+ resizedNote.currentY = resizedNote.startY +
(resizedNote.startHeight - newHeightN);
+ resizedNote.currentHeight = newHeightN;
+ break;
+ case "SOUTH_EAST":
+ resizedNote.currentWidth = Math.max(minWidth,
resizedNote.startWidth + deltaX);
+ resizedNote.currentHeight = Math.max(minHeight,
resizedNote.startHeight + deltaY);
+ break;
+ case "SOUTH_WEST":
+ const newWidthSW = Math.max(minWidth,
resizedNote.startWidth - deltaX);
+ resizedNote.currentX = resizedNote.startX +
(resizedNote.startWidth - newWidthSW);
+ resizedNote.currentWidth = newWidthSW;
+ resizedNote.currentHeight = Math.max(minHeight,
resizedNote.startHeight + deltaY);
+ break;
+ case "NORTH_EAST":
+ resizedNote.currentWidth = Math.max(minWidth,
resizedNote.startWidth + deltaX);
+ const newHeightNE = Math.max(minHeight,
resizedNote.startHeight - deltaY);
+ resizedNote.currentY = resizedNote.startY +
(resizedNote.startHeight - newHeightNE);
+ resizedNote.currentHeight = newHeightNE;
+ break;
+ case "NORTH_WEST":
+ const newWidthNW = Math.max(minWidth,
resizedNote.startWidth - deltaX);
+ const newHeightNW = Math.max(minHeight,
resizedNote.startHeight - deltaY);
+ resizedNote.currentX = resizedNote.startX +
(resizedNote.startWidth - newWidthNW);
+ resizedNote.currentY = resizedNote.startY +
(resizedNote.startHeight - newHeightNW);
+ resizedNote.currentWidth = newWidthNW;
+ resizedNote.currentHeight = newHeightNW;
+ break;
+ }
+
+ x2 = event.x / mag;
+ y2 = event.y / mag;
+ } else {
+ x2 = event.x / (props ? props.magnification : 1.0);
+ y2 = event.y / (props ? props.magnification : 1.0);
+ }
+
if (mode == null) {
break;
}
@@ -121,6 +378,35 @@ const handleEvent = function (event) {
}
break;
case SWT.Paint:
+ // Initialize pan state from Paint event if MouseMove hasn't done
it yet
+ // This handles the case where Paint arrives with data before
MouseMove
+ if (mode === "pan" && !isPanning && panStartOffset &&
panBoundaries && props) {
+ // Validate panStartOffset and panBoundaries have valid values
+ if (typeof panStartOffset.x === 'number' && typeof
panStartOffset.y === 'number' &&
+ typeof panBoundaries.x === 'number' && typeof
panBoundaries.y === 'number' &&
+ typeof panBoundaries.width === 'number' && typeof
panBoundaries.height === 'number' &&
+ typeof props.offsetX === 'number' && typeof props.offsetY
=== 'number') {
+ // We don't have mouse position in Paint, so we'll
initialize with offset only
+ // The first MouseMove will set the mouse position
+ isPanning = true;
+ panInitialized = false; // Not fully initialized until we
process first move
+ panStartMouseX = 0; // Will be set on first MouseMove
+ panStartMouseY = 0;
+
+ // Use current server offset to prevent jump
+ panStartOffsetX = Math.round(props.offsetX);
+ panStartOffsetY = Math.round(props.offsetY);
+ panCurrentOffsetX = panStartOffsetX;
+ panCurrentOffsetY = panStartOffsetY;
+ panBounds = {
+ x: panBoundaries.x,
+ y: panBoundaries.y,
+ width: panBoundaries.width,
+ height: panBoundaries.height
+ };
+ }
+ }
+
// Client-side does not redraw when first-drawing (null) and after
mouseup ("null")
if (mode == null || mode === "null") {
break;
@@ -130,6 +416,12 @@ const handleEvent = function (event) {
const gc = event.gc;
+ // Store canvas dimensions
+ if (gc && gc.canvas) {
+ canvasWidth = gc.canvas.width;
+ canvasHeight = gc.canvas.height;
+ }
+
// Calculate delta matching desktop client logic:
// Desktop: icon.x = real.x - iconOffset.x (where icon top-left
should be)
// dx = icon.x - selectedTransform.getLocation().x
@@ -173,14 +465,12 @@ const handleEvent = function (event) {
// Draw a new hop candidate line (matching desktop client style)
if (mode === "hop" && hopStartNode) {
- // Use current mouse position, or fall back to x2/y2
- const targetX = x2 !== undefined ? x2 : x1;
- const targetY = y2 !== undefined ? y2 : y1;
-
const startX = Math.round(fx(hopStartNode.x) + iconSize / 2);
const startY = Math.round(fy(hopStartNode.y) + iconSize / 2);
- const endX = Math.round(fx(targetX));
- const endY = Math.round(fy(targetY));
+
+ // Use raw screen coordinates for the mouse position (not
transformed)
+ const endX = mouseScreenX;
+ const endY = mouseScreenY;
// Draw solid blue line (matching desktop client)
gc.strokeStyle = 'rgb(0, 93, 166)'; // Blue color
@@ -380,19 +670,77 @@ function drawNodes(nodes, mode, dx, dy, gc, iconSize) {
function drawNotes(notes, gc, mode, dx, dy) {
notes.forEach(function (note) {
- gc.beginPath();
+ let noteX = note.x;
+ let noteY = note.y;
+ let noteWidth = note.width;
+ let noteHeight = note.height;
+
+ // Handle drag mode - apply client-side offset preview
if (mode === "drag" && note.selected) {
- gc.rect(
- Math.round(fx(note.x + dx)),
- Math.round(fy(note.y + dy)),
- note.width + 10, note.height + 10);
- } else {
- gc.rect(
- Math.round(fx(note.x)),
- Math.round(fy(note.y)),
- note.width + 10, note.height + 10);
+ noteX = note.x + dx;
+ noteY = note.y + dy;
}
+ // Handle resize mode - use client-calculated dimensions
+ // Match by selected state since note object gets recreated on server
updates
+ else if (mode === "resize" && note.selected && resizedNote) {
+ noteX = resizedNote.currentX;
+ noteY = resizedNote.currentY;
+ noteWidth = resizedNote.currentWidth;
+ noteHeight = resizedNote.currentHeight;
+ }
+
+ // Draw note rectangle
+ // Note: Width and height need to be scaled by magnification, just
like position
+ const screenW = Math.round((noteWidth + 10) * magnification);
+ const screenH = Math.round((noteHeight + 10) * magnification);
+
+ const screenX = Math.round(fx(noteX));
+ const screenY = Math.round(fy(noteY));
+
+ gc.beginPath();
+ gc.rect(screenX, screenY, screenW, screenH);
gc.stroke();
+
+ // Draw resize handles on selected notes (always visible, not just in
resize mode)
+ // But skip when dragging
+ if (note.selected && mode !== "drag" && mode !== "pan" && mode !==
"hop") {
+ const handleSize = 8;
+ // Use the same screen coordinates we used for drawing the
rectangle
+ const handleScreenX = screenX;
+ const handleScreenY = screenY;
+ const handleScreenW = screenW;
+ const handleScreenH = screenH;
+
+ // Save current style
+ const savedStrokeStyle = gc.strokeStyle;
+ const savedFillStyle = gc.fillStyle;
+
+ // Set handle style
+ gc.fillStyle = 'rgb(0, 93, 166)'; // Blue handles
+ gc.strokeStyle = 'rgb(255, 255, 255)'; // White border
+ gc.lineWidth = 1;
+
+ // Draw 8 resize handles (corners and edges)
+ const handles = [
+ { x: handleScreenX - handleSize/2, y: handleScreenY -
handleSize/2 }, // NW
+ { x: handleScreenX + handleScreenW/2 - handleSize/2, y:
handleScreenY - handleSize/2 }, // N
+ { x: handleScreenX + handleScreenW - handleSize/2, y:
handleScreenY - handleSize/2 }, // NE
+ { x: handleScreenX + handleScreenW - handleSize/2, y:
handleScreenY + handleScreenH/2 - handleSize/2 }, // E
+ { x: handleScreenX + handleScreenW - handleSize/2, y:
handleScreenY + handleScreenH - handleSize/2 }, // SE
+ { x: handleScreenX + handleScreenW/2 - handleSize/2, y:
handleScreenY + handleScreenH - handleSize/2 }, // S
+ { x: handleScreenX - handleSize/2, y: handleScreenY +
handleScreenH - handleSize/2 }, // SW
+ { x: handleScreenX - handleSize/2, y: handleScreenY +
handleScreenH/2 - handleSize/2 } // W
+ ];
+
+ handles.forEach(function(handle) {
+ gc.fillRect(handle.x, handle.y, handleSize, handleSize);
+ gc.strokeRect(handle.x, handle.y, handleSize, handleSize);
+ });
+
+ // Restore styles
+ gc.strokeStyle = savedStrokeStyle;
+ gc.fillStyle = savedFillStyle;
+ }
});
}
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/clipboard.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/clipboard.js
index fdbba7f7fe..155bc8cee3 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/clipboard.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/clipboard.js
@@ -57,7 +57,6 @@
}
event.preventDefault();
remoteObject.notify("paste", {"text": text, "widgetId":
event.target.value});
- console.log('paste');
}, this);
x.addEventListener("copy", function (event) {
var obj = rap.getObject(this.getAttribute('remoteObjectid'));
@@ -70,7 +69,6 @@
event.preventDefault();
if (rwt.client.Client._browserName != 'explorer') {
remoteObject.notify("copy", {"widgetId": event.target.value});
- console.log('copy');
}
}, this);
x.addEventListener("cut", function (event) {
@@ -83,7 +81,6 @@
}
event.preventDefault();
remoteObject.notify("cut", {"widgetId": event.target.value});
- console.log('cut');
}, this);
document.body.appendChild(x);
};
@@ -133,7 +130,6 @@
if (keyName === 'c') {
document.execCommand('copy');
remoteObject.notify("copy", {"widgetId": x.value});
- console.log('copy');
} else if (keyName === 'x') {
/*
* cut event cannot be invoked programmatically on IE11
for some reason,
@@ -141,7 +137,6 @@
*/
document.execCommand('copy');
remoteObject.notify("cut", {"widgetId": x.value});
- console.log('cut');
}
}
}
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
index ee76dfd58a..a9656701e0 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
@@ -2522,10 +2522,3 @@ Shell.jface_contentProposalPopup,
Shell.jface_infoPopupDialog {
padding: 0;
box-shadow: 0 0 4px #ababab;
}
-
-/* Canvas sizing - ensure canvas fills its container */
-canvas {
- width: 100% !important;
- height: 100% !important;
- display: block !important;
-}
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
index ed604890cf..0d0179c456 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
@@ -2534,10 +2534,3 @@ Shell.jface_contentProposalPopup,
Shell.jface_infoPopupDialog {
box-shadow: 0 0 4px #ababab;
}
-/* Canvas sizing - ensure canvas fills its container */
-canvas {
- width: 100% !important;
- height: 100% !important;
- display: block !important;
-}
-
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
index 269461e882..55fecf987e 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
@@ -95,6 +95,8 @@ public class HopGuiNotePadDelegate {
note.getBorderColorRed(),
note.getBorderColorGreen(),
note.getBorderColorBlue());
+ // Apply grid snapping to ensure correct initial size
+ PropsUi.setSize(newNote, ConstUi.NOTE_MIN_SIZE, ConstUi.NOTE_MIN_SIZE);
meta.addNote(newNote);
hopGui.undoDelegate.addUndoNew(
meta, new NotePadMeta[] {newNote}, new int[]
{meta.indexOfNote(newNote)});
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 35a055e262..59bbd2e490 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
@@ -180,6 +180,7 @@ import
org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.selection.HopGuiSelectionTracker;
+import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.pipeline.dialog.PipelineDialog;
import org.apache.hop.ui.util.EnvironmentUtils;
@@ -312,6 +313,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
private NotePadMeta selectedNote;
+ @Getter private Object canvasZoomHandler; // For web/RAP zoom handling
+
private PipelineHopMeta candidate;
private boolean dragSelection;
@@ -495,11 +498,20 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
// Add a canvas below it, use up all space initially
//
canvas = new Canvas(sashForm, SWT.NO_BACKGROUND | SWT.BORDER);
+ canvas.setData("hop-zoom-canvas", "true"); // Mark this canvas for zoom
handling
Listener listener = CanvasListener.getInstance();
canvas.addListener(SWT.MouseDown, listener);
canvas.addListener(SWT.MouseMove, listener);
canvas.addListener(SWT.MouseUp, listener);
canvas.addListener(SWT.Paint, listener);
+ canvas.addListener(SWT.MouseWheel, listener);
+ canvas.addListener(SWT.MouseVerticalWheel, listener);
+
+ // For web/RAP, create a zoom handler to sync mouse wheel zoom back to
server
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ canvasZoomHandler = CanvasZoomHelper.createZoomHandler(this, canvas,
this);
+ }
+
FormData fdCanvas = new FormData();
fdCanvas.left = new FormAttachment(0, 0);
fdCanvas.top = new FormAttachment(0, 0);
@@ -523,6 +535,12 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
setVisible(true);
newProps();
+ // Notify zoom handler that canvas is ready (for web/RAP)
+ // Tab selection changes are handled by ExplorerPerspective.tabFolder
selection listener
+ if (EnvironmentUtils.getInstance().isWeb() && canvasZoomHandler != null) {
+ getDisplay().asyncExec(() ->
CanvasZoomHelper.notifyCanvasReady(canvasZoomHandler));
+ }
+
canvas.addPaintListener(this::paintControl);
selectedTransforms = null;
@@ -833,6 +851,20 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
noteOffset = new Point(real.x - loc.x, real.y - loc.y);
resize = this.getResize(areaOwner.getArea(), real);
+
+ // For web environment, set canvas mode for visual feedback
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ if (resize != null) {
+ canvas.setData("mode", "resize");
+ canvas.setData("resizeDirection", resize.name());
+ } else {
+ canvas.setData("mode", "drag");
+ dragSelection = true;
+ }
+ // Force immediate sync of mode and resize direction to client
+ redraw();
+ }
+
// Keep the original area of the resizing note
resizeArea =
new Rectangle(
@@ -895,6 +927,16 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
@Override
public void mouseUp(MouseEvent e) {
+ // Track if we just completed a resize operation
+ boolean wasResizing = false;
+
+ // For web environment, perform final resize if in resize mode
+ if (EnvironmentUtils.getInstance().isWeb() && resize != null &&
selectedNote != null) {
+ wasResizing = true;
+ Point real = screen2real(e.x, e.y);
+ resizeNote(selectedNote, real);
+ }
+
resize = null;
forbiddenTransform = null;
@@ -903,9 +945,11 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
if (startHopTransform == null && endHopTransform == null) {
canvas.setData("mode", "null");
canvas.setData(START_HOP_NODE, null);
+ canvas.setData("resizeDirection", null);
}
- if (EnvironmentUtils.getInstance().isWeb()) {
+ // Don't call mouseMove after a resize - it would trigger unwanted drag
logic
+ if (EnvironmentUtils.getInstance().isWeb() && !wasResizing) {
// RAP does not support certain mouse events.
mouseMove(e);
}
@@ -918,6 +962,16 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
viewDragStart = null;
viewPortNavigation = false;
viewPortStart = null;
+
+ // Clear pan mode and feedback data for web environment
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ canvas.setData("mode", "null");
+ canvas.setData("panStartOffset", null);
+ canvas.setData("panCurrentOffset", null);
+ canvas.setData("panOffsetDelta", null);
+ canvas.setData("panBoundaries", null);
+ }
+
return;
}
@@ -1477,7 +1531,8 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
lastMove = real;
// Resizing the current note
- if (resize != null) {
+ // For web, don't resize during mouse move - let client handle preview
+ if (resize != null && !EnvironmentUtils.getInstance().isWeb()) {
resizeNote(selectedNote, real);
return;
}
@@ -2944,6 +2999,8 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
n.getBorderColorRed(),
n.getBorderColorGreen(),
n.getBorderColorBlue());
+ // Apply grid snapping to ensure correct initial size
+ PropsUi.setSize(npi, ConstUi.NOTE_MIN_SIZE, ConstUi.NOTE_MIN_SIZE);
pipelineMeta.addNote(npi);
hopGui.undoDelegate.addUndoNew(
pipelineMeta, new NotePadMeta[] {npi}, new int[]
{pipelineMeta.indexOfNote(npi)});
@@ -3941,6 +3998,12 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
@Override
public boolean isCloseable() {
try {
+ // If we're in the middle of a closeAllFiles operation, skip the dialog
+ // (it was already shown in saveGuardAllFiles)
+ if (hopGui.fileDelegate.isClosing()) {
+ return true;
+ }
+
// Check if the file is saved. If not, ask for it to be stopped before
closing
//
if (pipeline != null && (pipeline.isRunning() || pipeline.isPaused())) {
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
index 2a585be722..4aa20a69ec 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
@@ -265,6 +265,8 @@ public class HopGuiPipelineClipboardDelegate {
location.y,
ConstUi.NOTE_MIN_SIZE,
ConstUi.NOTE_MIN_SIZE);
+ // Apply grid snapping to ensure correct initial size
+ PropsUi.setSize(notePadMeta, ConstUi.NOTE_MIN_SIZE,
ConstUi.NOTE_MIN_SIZE);
pipelineMeta.addNote(notePadMeta);
hopGui.undoDelegate.addUndoNew(
pipelineMeta,
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
index 3278901a6a..276b6ee103 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
@@ -243,7 +243,9 @@ public abstract class HopGuiAbstractGraph extends
DragViewZoomBase
if (width < noteMeta.getMinimumWidth()) {
width = noteMeta.getMinimumWidth();
}
- PropsUi.setSize(noteMeta, width, resizeArea.height);
+ // Use note's current height, not resizeArea.height, to avoid changing
height
+ int height = noteMeta.getHeight();
+ PropsUi.setSize(noteMeta, width, height);
}
case NORTH -> {
int y = real.y;
@@ -254,10 +256,10 @@ public abstract class HopGuiAbstractGraph extends
DragViewZoomBase
y = resizeArea.y + resizeArea.height - noteMeta.getMinimumHeight();
}
PropsUi.setLocation(noteMeta, resizeArea.x, y);
+ // Use note's current width to avoid changing it
+ int width = noteMeta.getWidth();
PropsUi.setSize(
- noteMeta,
- resizeArea.width,
- resizeArea.y + resizeArea.height - noteMeta.getLocation().y);
+ noteMeta, width, resizeArea.y + resizeArea.height -
noteMeta.getLocation().y);
}
case NORTH_EAST -> {
int x = real.x;
@@ -305,7 +307,8 @@ public abstract class HopGuiAbstractGraph extends
DragViewZoomBase
if (height < noteMeta.getMinimumHeight()) {
height = noteMeta.getMinimumHeight();
}
- PropsUi.setSize(noteMeta, resizeArea.width, height);
+ // Use note's current width, not resizeArea.width, to avoid changing
width during resize
+ PropsUi.setSize(noteMeta, noteMeta.getWidth(), height);
}
case SOUTH_EAST -> {
int width = real.x - resizeArea.x;
@@ -343,10 +346,10 @@ public abstract class HopGuiAbstractGraph extends
DragViewZoomBase
x = resizeArea.x + resizeArea.width - noteMeta.getMinimumWidth();
}
PropsUi.setLocation(noteMeta, x, resizeArea.y);
+ // Use note's current height to avoid changing it
+ int height = noteMeta.getHeight();
PropsUi.setSize(
- noteMeta,
- resizeArea.x + resizeArea.width - noteMeta.getLocation().x,
- resizeArea.height);
+ noteMeta, resizeArea.x + resizeArea.width -
noteMeta.getLocation().x, height);
}
}
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 a99063517a..f1737c4543 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
@@ -136,6 +136,7 @@ import
org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.selection.HopGuiSelectionTracker;
+import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.apache.hop.ui.util.HelpUtils;
@@ -294,6 +295,8 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
private NotePadMeta currentNotePad = null;
+ @Getter private Object canvasZoomHandler; // For web/RAP zoom handling
+
private SashForm sashForm;
public CTabFolder extraViewTabFolder;
@@ -407,11 +410,20 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
// Add a canvas below it, use up all space
//
canvas = new Canvas(sashForm, SWT.NO_BACKGROUND | SWT.BORDER);
+ canvas.setData("hop-zoom-canvas", "true"); // Mark this canvas for zoom
handling
Listener listener = CanvasListener.getInstance();
canvas.addListener(SWT.MouseDown, listener);
canvas.addListener(SWT.MouseMove, listener);
canvas.addListener(SWT.MouseUp, listener);
canvas.addListener(SWT.Paint, listener);
+ canvas.addListener(SWT.MouseWheel, listener);
+ canvas.addListener(SWT.MouseVerticalWheel, listener);
+
+ // For web/RAP, create a zoom handler to sync mouse wheel zoom back to
server
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ canvasZoomHandler = CanvasZoomHelper.createZoomHandler(this, canvas,
this);
+ }
+
FormData fdCanvas = new FormData();
fdCanvas.left = new FormAttachment(0, 0);
fdCanvas.top = new FormAttachment(0, 0);
@@ -438,6 +450,12 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
// Set canvas background to match application background for web
if (EnvironmentUtils.getInstance().isWeb()) {
canvas.setBackground(GuiResource.getInstance().getColorBackground());
+
+ // Notify zoom handler that canvas is ready
+ // Tab selection changes are handled by ExplorerPerspective.tabFolder
selection listener
+ if (canvasZoomHandler != null) {
+ getDisplay().asyncExec(() ->
CanvasZoomHelper.notifyCanvasReady(canvasZoomHandler));
+ }
}
canvas.addPaintListener(this::paintControl);
@@ -707,21 +725,29 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
noteOffset = new Point(real.x - loc.x, real.y - loc.y);
- canvas.setData("mode", "resize");
resize = this.getResize(areaOwner.getArea(), real);
- if (resize != null) {
- // Keep the original area of the resizing note
- resizeArea =
- new Rectangle(
- currentNotePad.getLocation().x,
- currentNotePad.getLocation().y,
- currentNotePad.getWidth(),
- currentNotePad.getHeight());
- } else {
- dragSelection = true;
+ // For web environment, set canvas mode for visual feedback
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ if (resize != null) {
+ canvas.setData("mode", "resize");
+ canvas.setData("resizeDirection", resize.name());
+ } else {
+ canvas.setData("mode", "drag");
+ dragSelection = true;
+ }
+ // Force immediate sync of mode and resize direction to client
+ redraw();
}
+ // Keep the original area of the resizing note
+ resizeArea =
+ new Rectangle(
+ currentNotePad.getLocation().x,
+ currentNotePad.getLocation().y,
+ currentNotePad.getWidth(),
+ currentNotePad.getHeight());
+
updateGui();
done = true;
}
@@ -775,6 +801,16 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
@Override
public void mouseUp(MouseEvent event) {
+ // Track if we just completed a resize operation
+ boolean wasResizing = false;
+
+ // For web environment, perform final resize if in resize mode
+ if (EnvironmentUtils.getInstance().isWeb() && resize != null &&
selectedNote != null) {
+ wasResizing = true;
+ Point real = screen2real(event.x, event.y);
+ resizeNote(selectedNote, real);
+ }
+
resize = null;
dragSelection = false;
forbiddenAction = null;
@@ -784,8 +820,11 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
if (startHopAction == null && endHopAction == null) {
canvas.setData("mode", "null");
canvas.setData(START_HOP_NODE, null);
+ canvas.setData("resizeDirection", null);
}
- if (EnvironmentUtils.getInstance().isWeb()) {
+
+ // Don't call mouseMove after a resize - it would trigger unwanted drag
logic
+ if (EnvironmentUtils.getInstance().isWeb() && !wasResizing) {
// RAP does not support certain mouse events.
mouseMove(event);
}
@@ -798,6 +837,16 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
viewDragStart = null;
viewPortNavigation = false;
viewPortStart = null;
+
+ // Clear pan mode and feedback data for web environment
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ canvas.setData("mode", "null");
+ canvas.setData("panStartOffset", null);
+ canvas.setData("panCurrentOffset", null);
+ canvas.setData("panOffsetDelta", null);
+ canvas.setData("panBoundaries", null);
+ }
+
return;
}
@@ -1223,7 +1272,8 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
lastMove = real;
// Resizing the current note
- if (resize != null) {
+ // For web, don't resize during mouse move - let client handle preview
+ if (resize != null && !EnvironmentUtils.getInstance().isWeb()) {
resizeNote(selectedNote, real);
return;
}
@@ -2214,6 +2264,8 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
note.getBorderColorRed(),
note.getBorderColorGreen(),
note.getBorderColorBlue());
+ // Apply grid snapping to ensure correct initial size
+ PropsUi.setSize(newNote, ConstUi.NOTE_MIN_SIZE, ConstUi.NOTE_MIN_SIZE);
workflowMeta.addNote(newNote);
hopGui.undoDelegate.addUndoNew(
workflowMeta, new NotePadMeta[] {newNote}, new int[]
{workflowMeta.indexOfNote(newNote)});
@@ -3725,6 +3777,12 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
@Override
public boolean isCloseable() {
try {
+ // If we're in the middle of a closeAllFiles operation, skip the dialog
+ // (it was already shown in saveGuardAllFiles)
+ if (hopGui.fileDelegate.isClosing()) {
+ return true;
+ }
+
// Check if the file is saved. If not, ask for it to be stopped before
closing
//
if (workflow != null && (workflow.isActive())) {
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
index b6668e2252..a6990d870d 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
@@ -265,6 +265,8 @@ public class HopGuiWorkflowClipboardDelegate {
location.y,
ConstUi.NOTE_MIN_SIZE,
ConstUi.NOTE_MIN_SIZE);
+ // Apply grid snapping to ensure correct initial size
+ PropsUi.setSize(notePadMeta, ConstUi.NOTE_MIN_SIZE,
ConstUi.NOTE_MIN_SIZE);
workflowMeta.addNote(notePadMeta);
hopGui.undoDelegate.addUndoNew(
workflowMeta,
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
index 7bf3a19d36..4eb072afe5 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
@@ -23,6 +23,7 @@ import org.apache.hop.core.gui.Point;
import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.util.EnvironmentUtils;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.widgets.Canvas;
@@ -273,6 +274,34 @@ public abstract class DragViewZoomBase extends Composite {
viewDragBaseOffset = new DPoint(offset);
// Change cursor when dragging view
setCursor(getDisplay().getSystemCursor(SWT.CURSOR_SIZEALL));
+
+ // For web environment, enable pan mode for client-side visual feedback
during drag
+ if (EnvironmentUtils.getInstance().isWeb() && canvas != null) {
+ canvas.setData("mode", "pan");
+ canvas.setData("panStartOffset", new Point((int) offset.x, (int)
offset.y));
+ canvas.setData("panCurrentOffset", new Point((int) offset.x, (int)
offset.y));
+
+ // Pass boundary information to client for constraint validation
+ double zoomFactor = PropsUi.getNativeZoomFactor() * Math.max(0.1,
magnification);
+ Point area = getArea();
+ double viewWidth = area.x / zoomFactor;
+ double viewHeight = area.y / zoomFactor;
+
+ // Calculate min/max offset boundaries (same as validateOffset())
+ double minX = -maximum.x + viewWidth;
+ double minY = -maximum.y + viewHeight;
+ double maxX = 0;
+ double maxY = 0;
+
+ canvas.setData(
+ "panBoundaries",
+ new org.apache.hop.core.gui.Rectangle((int) minX, (int) minY,
(int) maxX, (int) maxY));
+
+ // Force immediate redraw to sync pan data to client BEFORE mouse move
events
+ // This ensures the client has the pan data when the first MouseMove
arrives
+ redraw();
+ }
+
return true;
}
return false;
@@ -382,6 +411,17 @@ public abstract class DragViewZoomBase extends Composite {
validateOffset();
+ // For web environment, update canvas data for client-side visual feedback
during drag
+ if (EnvironmentUtils.getInstance().isWeb() && canvas != null) {
+ Point startOffset = (Point) canvas.getData("panStartOffset");
+ if (startOffset != null) {
+ int offsetDeltaX = (int) (offset.x - startOffset.x);
+ int offsetDeltaY = (int) (offset.y - startOffset.y);
+ canvas.setData("panOffsetDelta", new Point(offsetDeltaX,
offsetDeltaY));
+ canvas.setData("panCurrentOffset", new Point((int) offset.x, (int)
offset.y));
+ }
+ }
+
redraw();
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java
index ac135715ff..7ff8edfbc8 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java
@@ -83,6 +83,7 @@ import org.apache.hop.ui.hopgui.HopGuiExtensionPoint;
import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.shared.BaseExecutionViewer;
+import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.eclipse.swt.SWT;
@@ -200,11 +201,20 @@ public class PipelineExecutionViewer extends
BaseExecutionViewer
// The canvas at the top
//
canvas = new Canvas(sash, SWT.NO_BACKGROUND | SWT.BORDER);
+ canvas.setData("hop-zoom-canvas", "true"); // Mark this canvas for zoom
handling
Listener listener = CanvasListener.getInstance();
canvas.addListener(SWT.MouseDown, listener);
canvas.addListener(SWT.MouseMove, listener);
canvas.addListener(SWT.MouseUp, listener);
canvas.addListener(SWT.Paint, listener);
+ canvas.addListener(SWT.MouseWheel, listener);
+ canvas.addListener(SWT.MouseVerticalWheel, listener);
+
+ // For web/RAP, create a zoom handler to sync mouse wheel zoom back to
server
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ CanvasZoomHelper.createZoomHandler(this, canvas, this);
+ }
+
FormData fdCanvas = new FormData();
fdCanvas.left = new FormAttachment(0, 0);
fdCanvas.top = new FormAttachment(0, 0);
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java
index 522839198b..ebe70e501a 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java
@@ -76,6 +76,7 @@ import org.apache.hop.ui.hopgui.HopGui;
import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.shared.BaseExecutionViewer;
+import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.apache.hop.workflow.ActionResult;
@@ -198,11 +199,20 @@ public class WorkflowExecutionViewer extends
BaseExecutionViewer
// The canvas at the top
//
canvas = new Canvas(sash, SWT.NO_BACKGROUND | SWT.BORDER);
+ canvas.setData("hop-zoom-canvas", "true"); // Mark this canvas for zoom
handling
Listener listener = CanvasListener.getInstance();
canvas.addListener(SWT.MouseDown, listener);
canvas.addListener(SWT.MouseMove, listener);
canvas.addListener(SWT.MouseUp, listener);
canvas.addListener(SWT.Paint, listener);
+ canvas.addListener(SWT.MouseWheel, listener);
+ canvas.addListener(SWT.MouseVerticalWheel, listener);
+
+ // For web/RAP, create a zoom handler to sync mouse wheel zoom back to
server
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ CanvasZoomHelper.createZoomHandler(this, canvas, this);
+ }
+
FormData fdCanvas = new FormData();
fdCanvas.left = new FormAttachment(0, 0);
fdCanvas.top = new FormAttachment(0, 0);
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 8cc69f3571..d33c083e29 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -98,6 +98,7 @@ import
org.apache.hop.ui.hopgui.perspective.explorer.file.IExplorerFileTypeHandl
import org.apache.hop.ui.hopgui.perspective.explorer.file.types.FolderFileType;
import
org.apache.hop.ui.hopgui.perspective.explorer.file.types.GenericFileType;
import org.apache.hop.ui.hopgui.selection.HopGuiSelectionTracker;
+import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.workflow.WorkflowMeta;
import org.apache.hop.workflow.engine.IWorkflowEngine;
import org.eclipse.swt.SWT;
@@ -1071,7 +1072,15 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable {
protected void createTabFolder(Composite parent) {
tabFolder = new CTabFolder(parent, SWT.MULTI | SWT.BORDER);
- tabFolder.addListener(SWT.Selection, e -> updateGui());
+ tabFolder.addListener(
+ SWT.Selection,
+ e -> {
+ updateGui();
+ // Notify zoom handler when tab is switched (for web/RAP)
+ if (org.apache.hop.ui.util.EnvironmentUtils.getInstance().isWeb()) {
+ notifyZoomHandlerForActiveTab();
+ }
+ });
tabFolder.addCTabFolder2Listener(
new CTabFolder2Adapter() {
@Override
@@ -2404,6 +2413,27 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable {
activeHandler.updateGui();
}
+ /** Notify the zoom handler when tab is switched (for web/RAP) */
+ private void notifyZoomHandlerForActiveTab() {
+ final IHopFileTypeHandler activeHandler = getActiveFileTypeHandler();
+ if (activeHandler == null) {
+ return;
+ }
+
+ // Check if it's a pipeline or workflow graph and notify its zoom handler
+ if (activeHandler instanceof HopGuiPipelineGraph pipelineGraph) {
+ Object zoomHandler = pipelineGraph.getCanvasZoomHandler();
+ if (zoomHandler != null) {
+ CanvasZoomHelper.notifyCanvasReady(zoomHandler);
+ }
+ } else if (activeHandler instanceof HopGuiWorkflowGraph workflowGraph) {
+ Object zoomHandler = workflowGraph.getCanvasZoomHandler();
+ if (zoomHandler != null) {
+ CanvasZoomHelper.notifyCanvasReady(zoomHandler);
+ }
+ }
+ }
+
/**
* Toggle the visibility of the file explorer panel (tree). When hidden, the
tab folder is
* maximized. When shown, normal sash weights are restored.
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java
index f6dae31c52..07b2b4d0ad 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java
@@ -46,6 +46,7 @@ import org.apache.hop.ui.core.metadata.MetadataManager;
import org.apache.hop.ui.hopgui.HopGui;
import org.apache.hop.ui.hopgui.perspective.execution.DragViewZoomBase;
import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
+import org.apache.hop.ui.util.EnvironmentUtils;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.CTabFolder;
import org.eclipse.swt.custom.SashForm;
@@ -193,6 +194,15 @@ public abstract class BaseExecutionViewer extends
DragViewZoomBase
viewDrag = false;
viewPortNavigation = false;
viewPortStart = null;
+
+ // Clear pan mode and feedback data for web environment
+ if (EnvironmentUtils.getInstance().isWeb() && canvas != null) {
+ canvas.setData("mode", "null");
+ canvas.setData("panStartOffset", null);
+ canvas.setData("panCurrentOffset", null);
+ canvas.setData("panOffsetDelta", null);
+ canvas.setData("panBoundaries", null);
+ }
}
// Default cursor
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/CanvasZoomHelper.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/CanvasZoomHelper.java
new file mode 100644
index 0000000000..ca5a3d85f5
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/CanvasZoomHelper.java
@@ -0,0 +1,97 @@
+/*
+ * 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.shared;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Proxy;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.hopgui.perspective.execution.DragViewZoomBase;
+import org.eclipse.swt.events.MouseEvent;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Composite;
+
+/**
+ * Helper class to create CanvasZoomHandler for web environments using
reflection to avoid direct
+ * module dependencies.
+ */
+public class CanvasZoomHelper {
+
+ private CanvasZoomHelper() {}
+
+ /**
+ * Creates a zoom handler for the given canvas in web/RAP environments. Uses
reflection to load
+ * the CanvasZoomHandler from the RAP module to avoid compile-time
dependencies.
+ *
+ * @param parent The parent composite (typically the graph instance)
+ * @param canvas The canvas to attach zoom handling to
+ * @param zoomable The object that implements zoom methods (zoomIn/zoomOut)
+ * @return The created zoom handler instance (as Object to avoid direct
dependency)
+ */
+ public static Object createZoomHandler(
+ Composite parent, Canvas canvas, DragViewZoomBase zoomable) {
+ try {
+ Class<?> zoomHandlerClass =
Class.forName("org.apache.hop.ui.hopgui.CanvasZoomHandler");
+ Class<?> zoomableInterface =
+
Class.forName("org.apache.hop.ui.hopgui.CanvasZoomHandler$IZoomable");
+
+ Object zoomableProxy =
+ Proxy.newProxyInstance(
+ CanvasZoomHelper.class.getClassLoader(),
+ new Class<?>[] {zoomableInterface},
+ (proxy, method, args) ->
+ switch (method.getName()) {
+ case "zoomIn" -> {
+ zoomable.zoomIn((MouseEvent) args[0]);
+ yield null;
+ }
+ case "zoomOut" -> {
+ zoomable.zoomOut((MouseEvent) args[0]);
+ yield null;
+ }
+ default -> null;
+ });
+
+ Constructor<?> constructor =
+ zoomHandlerClass.getConstructor(
+ org.eclipse.swt.widgets.Composite.class,
+ org.eclipse.swt.widgets.Canvas.class,
+ zoomableInterface);
+ return constructor.newInstance(parent, canvas, zoomableProxy);
+ } catch (Exception e) {
+ LogChannel.UI.logError("Failed to create CanvasZoomHandler", e);
+ return null;
+ }
+ }
+
+ /**
+ * Notifies the zoom handler that the canvas is ready for listener
attachment. This should be
+ * called after the canvas is visible and ready to receive events.
+ *
+ * @param zoomHandler The zoom handler instance (created by
createZoomHandler)
+ */
+ public static void notifyCanvasReady(Object zoomHandler) {
+ if (zoomHandler == null) {
+ return;
+ }
+ try {
+
zoomHandler.getClass().getMethod("notifyCanvasReady").invoke(zoomHandler);
+ } catch (Exception e) {
+ LogChannel.UI.logError("Failed to notify canvas ready", e);
+ }
+ }
+}