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


Reply via email to