This is an automated email from the ASF dual-hosted git repository.
mattcasters 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 f61871352c Hop Web: generic SVG canvas SPI for plugin graph editors
(#7874)
f61871352c is described below
commit f61871352c529a7628ba313ba5954267cca4f389
Author: Matt Casters <[email protected]>
AuthorDate: Mon Aug 10 23:05:10 2026 +0200
Hop Web: generic SVG canvas SPI for plugin graph editors (#7874)
* Hop Web: generic SVG canvas SPI for plugin graph editors
Allow custom canvas graphs (not only pipelines/workflows) to participate
in the Hop Web SVG overlay stack. Fixes #7873.
- Add IWebCanvasGraph for hover and area-owner updates
- Add CanvasSvgFacade.publishSnapshot for pre-rendered SVG
- Guard CanvasFacadeImpl against non-PipelineMeta/WorkflowMeta casts
- Route RAP hover/sync through IWebCanvasGraph
* Hop Web: recognize label AreaOwner names for canvas drag
Plugin model graphs (Data Vault, etc.) register TRANSFORM_ICON areas with
a String owner serialized as kind "label". iconOwnerName only handled
transform/action owners, so drag previews never started.
* Hop Web: guard null nodes/hops/notes in canvas.js Paint
Plugin model graphs do not always set pipeline-style hops/nodes data.
canvas.js Paint called hops.forEach/notes.forEach on null and failed
when opening custom canvases (e.g. Data Vault modelers).
* Hop Web: card-sized node previews for drag and hop mode
Model graph nodes may carry logical width/height (table cards). Use those
sizes for drag ghost rectangles, hop candidate line anchors (card center),
and node outlines instead of always using iconSize.
* Hop Web: init dragIconArea field on CanvasSvgRenderer
* Hop Web: null-safe ExplorerPerspective.updateGui
CTabItem.setControl can fire focus/selection before tab data is set when
plugins open files. Guard against a null active handler.
* Hop Web: SVG-layer drag ghosts, hop line, and note resize handles
canvas.js paints under the SVG overlay, so its outlines are invisible on
model graphs. Drive feedback from canvas-svg.js effects layer instead:
- Hit-test nodes map when areas miss (plugin model cards)
- Card-sized drag previews; recover drag when server sets mode=drag
- Hop rubber-band line above SVG for relationship create
- Note resize handles and resize cursors on the SVG effects layer
* Hop Web: modeler canvas polish for SVG effects, zoom, and tab rebind.
Draw drag/resize/pan ghosts on the SVG effects layer, restore full opacity
after pan, keep wheel zoom working through the SVG host, rebind zoom and
SVG on explorer tab changes for plugin graphs, and only rebind the shared
SVG client when the painted canvas is the active tab (link-follow race).
---
.../org/apache/hop/ui/hopgui/CanvasFacadeImpl.java | 6 +-
.../apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java | 31 +-
.../ui/hopgui/canvas/CanvasInteractionHandler.java | 9 +-
.../org/apache/hop/ui/hopgui/canvas-svg.js | 1133 ++++++++++++++++++--
.../org/apache/hop/ui/hopgui/canvas-zoom.js | 50 +-
.../resources/org/apache/hop/ui/hopgui/canvas.js | 89 +-
.../apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java | 11 +
.../org/apache/hop/ui/hopgui/CanvasSvgFacade.java | 33 +
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 4 +-
.../hopgui/file/workflow/HopGuiWorkflowGraph.java | 4 +-
.../perspective/explorer/ExplorerPerspective.java | 47 +-
.../hop/ui/hopgui/shared/CanvasSvgHelper.java | 16 +-
.../hop/ui/hopgui/shared/IWebCanvasGraph.java | 49 +
13 files changed, 1335 insertions(+), 147 deletions(-)
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 a21a1dab6d..48a549e3d9 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
@@ -39,9 +39,11 @@ public class CanvasFacadeImpl extends CanvasFacade {
setDataCommon(canvas, magnification, offset, meta);
if (meta instanceof WorkflowMeta workflowMeta) {
setDataWorkflow(canvas, workflowMeta);
- } else {
- setDataPipeline(canvas, (PipelineMeta) meta);
+ } else if (meta instanceof PipelineMeta pipelineMeta) {
+ setDataPipeline(canvas, pipelineMeta);
}
+ // Other meta types (plugin model graphs, etc.): common props only.
+ // Callers may already have set canvas data keys such as "nodes" for
client drag previews.
}
private void setDataCommon(Canvas canvas, float magnification, DPoint
offset, Object meta) {
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
index de9b1083d4..43e014c00a 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
@@ -22,6 +22,7 @@ import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.gui.AreaOwner;
import org.apache.hop.core.gui.CanvasSvgRenderResult;
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.core.logging.LogChannel;
import org.apache.hop.pipeline.canvas.PipelineCanvasSvgRenderer;
@@ -31,6 +32,7 @@ import org.apache.hop.ui.hopgui.canvas.CanvasGraphRegistry;
import org.apache.hop.ui.hopgui.canvas.CanvasInteractionHandler;
import org.apache.hop.ui.hopgui.canvas.CanvasRenderSnapshot;
import org.apache.hop.ui.hopgui.canvas.CanvasSvgRendererHandler;
+import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
import org.apache.hop.workflow.canvas.WorkflowCanvasSvgRenderer;
import org.eclipse.rap.json.JsonObject;
import org.eclipse.rap.rwt.RWT;
@@ -63,7 +65,7 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
DPoint offset) {
try {
CanvasSvgRenderResult result = PipelineCanvasSvgRenderer.render(context);
- storeSnapshot(canvas, result, magnification, offset, context.canvasSize);
+ publishSnapshotInternal(canvas, result, magnification, offset,
context.canvasSize);
return result;
} catch (HopException e) {
LogChannel.UI.logError("Failed to render pipeline SVG for web canvas",
e);
@@ -79,7 +81,7 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
DPoint offset) {
try {
CanvasSvgRenderResult result = WorkflowCanvasSvgRenderer.render(context);
- storeSnapshot(canvas, result, magnification, offset, context.canvasSize);
+ publishSnapshotInternal(canvas, result, magnification, offset,
context.canvasSize);
return result;
} catch (HopException e) {
LogChannel.UI.logError("Failed to render workflow SVG for web canvas",
e);
@@ -87,12 +89,13 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
}
}
- private void storeSnapshot(
+ @Override
+ void publishSnapshotInternal(
Canvas canvas,
CanvasSvgRenderResult result,
float magnification,
DPoint offset,
- org.apache.hop.core.gui.Point canvasSize) {
+ Point canvasSize) {
if (result == null) {
return;
}
@@ -117,11 +120,18 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
CanvasRenderSnapshot snapshot =
new CanvasRenderSnapshot(revision, result.getSvg(),
result.getAreaOwners(), props);
- CanvasGraphRegistry.getInstance().updateSnapshot(canvasId, snapshot);
- Object graph = CanvasGraphRegistry.getInstance().getGraph(canvasId);
+ CanvasGraphRegistry registry = CanvasGraphRegistry.getInstance();
+ registry.updateSnapshot(canvasId, snapshot);
+ Object graph = registry.getGraph(canvasId);
syncAreaOwnersToGraph(graph, result.getAreaOwners());
setCanvasWidgetDataInternal(canvas, revision);
- CanvasSvgRendererHandler.notifyCanvasReady(canvas, revision);
+ // Single shared client renderer for the session: only rebind when this
canvas is the active
+ // tab. Otherwise a background paint (e.g. mouse-up on the graph we just
navigated away from
+ // after following a BV/DV link) steals the overlay back to the previous
model.
+ Canvas active = registry.getActiveCanvas();
+ if (active == null || active.isDisposed() || active == canvas) {
+ CanvasSvgRendererHandler.notifyCanvasReady(canvas, revision);
+ }
}
@Override
@@ -169,11 +179,8 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
/** Expose area list update for graph classes that populate areaOwners after
render. */
static void syncAreaOwnersToGraph(Object graph, List<AreaOwner> areaOwners) {
- if (graph instanceof
org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph pipelineGraph) {
- pipelineGraph.replaceAreaOwners(areaOwners);
- } else if (graph
- instanceof org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph
workflowGraph) {
- workflowGraph.replaceAreaOwners(areaOwners);
+ if (graph instanceof IWebCanvasGraph webCanvasGraph) {
+ webCanvasGraph.replaceAreaOwners(areaOwners);
}
}
}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
index 96a1ecda13..81b3e08035 100644
---
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
@@ -19,8 +19,7 @@ package org.apache.hop.ui.hopgui.canvas;
import org.apache.hop.core.gui.AreaOwner;
import org.apache.hop.core.logging.LogChannel;
-import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
-import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
+import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
import org.eclipse.rap.json.JsonObject;
import org.eclipse.rap.rwt.RWT;
import org.eclipse.rap.rwt.remote.AbstractOperationHandler;
@@ -96,10 +95,8 @@ public class CanvasInteractionHandler extends Widget {
int graphY = properties.get("graphY").asInt();
int screenX = properties.get("screenX") != null ?
properties.get("screenX").asInt() : graphX;
int screenY = properties.get("screenY") != null ?
properties.get("screenY").asInt() : graphY;
- if (graph instanceof HopGuiPipelineGraph pipelineGraph) {
- pipelineGraph.handleWebCanvasHover(graphX, graphY, screenX, screenY);
- } else if (graph instanceof HopGuiWorkflowGraph workflowGraph) {
- workflowGraph.handleWebCanvasHover(graphX, graphY, screenX, screenY);
+ if (graph instanceof IWebCanvasGraph webCanvasGraph) {
+ webCanvasGraph.handleWebCanvasHover(graphX, graphY, screenX, screenY);
}
}
}
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
index 4269490473..de07704935 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
@@ -147,6 +147,99 @@
if (area.owner.kind === "transform" || area.owner.kind === "action") {
return area.owner.name;
}
+ // Plugin model graphs (Data Vault, etc.) register TRANSFORM_ICON
areas with a
+ // string owner serialized as kind "label" / value.
+ if (area.owner.kind === "label" && area.owner.value) {
+ return area.owner.value;
+ }
+ if (area.owner.name) {
+ return area.owner.name;
+ }
+ return null;
+ }
+
+ /** Logical width/height for a node; falls back to iconSize for pipeline
icons. */
+ function nodeSize(node, props, area) {
+ var fallback = (props && props.iconSize) ? props.iconSize : 32;
+ var w = fallback;
+ var h = fallback;
+ if (node && node.width > 0) {
+ w = node.width;
+ } else if (area && area.width > 0) {
+ w = area.width;
+ }
+ if (node && node.height > 0) {
+ h = node.height;
+ } else if (area && area.height > 0) {
+ h = area.height;
+ }
+ return { width: w, height: h };
+ }
+
+ /**
+ * Hit-test nodes map (logical graph coords). Used when area list is
empty/stale so plugin
+ * model cards still get client drag previews on the SVG effects layer.
+ */
+ function findNodeAt(nodes, graphX, graphY, props) {
+ if (!nodes) {
+ return null;
+ }
+ var fallback = (props && props.iconSize) ? props.iconSize : 32;
+ var best = null;
+ for (var name in nodes) {
+ if (!nodes.hasOwnProperty(name)) {
+ continue;
+ }
+ var n = nodes[name];
+ if (!n) {
+ continue;
+ }
+ var w = n.width > 0 ? n.width : fallback;
+ var h = n.height > 0 ? n.height : fallback;
+ if (graphX >= n.x && graphX < n.x + w && graphY >= n.y && graphY <
n.y + h) {
+ // Prefer smaller (top-most) cards when overlapping.
+ if (!best || w * h < best.area) {
+ best = { name: name, node: n, width: w, height: h, area: w
* h };
+ }
+ }
+ }
+ return best;
+ }
+
+ /** Resize edge near a NOTE area (logical coords). Margin matches
desktop/web hit testing. */
+ function noteResizeEdge(area, graphX, graphY) {
+ if (!area || area.areaType !== "NOTE") {
+ return null;
+ }
+ var m = 10;
+ var left = graphX <= area.x + m;
+ var right = graphX >= area.x + area.width - m;
+ var top = graphY <= area.y + m;
+ var bottom = graphY >= area.y + area.height - m;
+ if (left && top) {
+ return "nw-resize";
+ }
+ if (right && top) {
+ return "ne-resize";
+ }
+ if (left && bottom) {
+ return "sw-resize";
+ }
+ if (right && bottom) {
+ return "se-resize";
+ }
+ if (left) {
+ return "w-resize";
+ }
+ if (right) {
+ return "e-resize";
+ }
+ if (top) {
+ return "n-resize";
+ }
+ if (bottom) {
+ return "s-resize";
+ }
return null;
}
@@ -208,11 +301,28 @@
this._dragActive = false;
this._dragClickedName = null;
this._dragIconOffset = null;
+ this._dragIconArea = null;
this._dragStartPositions = null;
this._dragNodes = null;
+ this._dragNotes = null;
+ this._noteHandleRects = null;
+ this._hopLineEl = null;
this._mousedownHandler = null;
this._mouseupHandler = null;
this._documentMouseMoveHandler = null;
+ // Last mousedown (graph + screen) so mode=drag / mode=resize previews
can init after
+ // the server arms mode on a later RAP round-trip (same timing pattern
as canvas.js).
+ this._lastMouseDownGraph = null;
+ this._lastMouseDownScreen = null;
+ this._noteResizeActive = false;
+ this._resizedNote = null;
+ this._resizeStartScreenX = 0;
+ this._resizeStartScreenY = 0;
+ this._modeDragListening = false;
+ // RAP/browser often reports event.buttons === 0 during drag; track
locally.
+ this._pointerHeld = false;
+ this._ghostSvg = null;
+ this._ghostRectPool = null;
this._panBoundsOutline = null;
this._panActive = false;
this._panInitialized = false;
@@ -321,6 +431,9 @@
this._svgHost = document.createElement("div");
this._svgHost.style.width = "100%";
this._svgHost.style.height = "100%";
+ // Critical: without this, the SVG subtree captures wheel
events and canvas-zoom.js
+ // (listening on the RAP canvas under the overlay) never sees
scroll zoom.
+ this._svgHost.style.pointerEvents = "none";
this._effectsLayer = document.createElement("div");
this._effectsLayer.style.position = "absolute";
@@ -329,6 +442,8 @@
this._effectsLayer.style.width = "100%";
this._effectsLayer.style.height = "100%";
this._effectsLayer.style.pointerEvents = "none";
+ // Keep ghosts above the SVG host (opacity on the host creates
a stacking context).
+ this._effectsLayer.style.zIndex = "20";
this._panBoundsOutline = document.createElement("div");
this._panBoundsOutline.style.position = "absolute";
@@ -381,6 +496,8 @@
if (!self._dragActive && !self._panActive &&
!self._navDragActive && !self._selectActive) {
self._lastHoverKey = null;
self._updateHoverChrome(null);
+ self._clearNoteResizeHandles();
+ self._clearHopLine();
}
};
this._mousedownHandler = function (event) {
@@ -487,10 +604,13 @@
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.style.display = "block";
+ svg.style.pointerEvents = "none";
}
if (self._canvas) {
self._syncOverlayLayout(self._canvas);
}
+ // Post-pan/server redraw must not keep a leftover dim
from client previews.
+ self._restoreIdleChrome();
}
})
.catch(function (err) {
@@ -500,15 +620,177 @@
});
},
- _updateHoverChrome: function (area) {
+ _updateHoverChrome: function (area, graphX, graphY) {
if (!this._canvas) {
return;
}
if (this._panActive || this._navDragActive || this._dragActive) {
this._canvas.style.cursor = "grabbing";
+ this._clearNoteResizeHandles();
+ return;
+ }
+ // Note edges: resize cursors (do not use generic pointer on whole
note).
+ if (area && area.areaType === "NOTE" && graphX != null && graphY
!= null) {
+ var edge = noteResizeEdge(area, graphX, graphY);
+ if (edge) {
+ this._canvas.style.cursor = edge;
+ this._drawNoteResizeHandles(area);
+ return;
+ }
+ this._drawNoteResizeHandles(area);
+ this._canvas.style.cursor = "move";
+ return;
+ }
+ this._clearNoteResizeHandles();
+ // Name/link: hand. Icon body: default (move). Others: clear.
+ if (area && (area.areaType === "TRANSFORM_NAME" || area.areaType
=== "NOTE_LINK"
+ || area.areaType === "ACTION_NAME")) {
+ this._canvas.style.cursor = "pointer";
+ } else if (area && (area.areaType === "TRANSFORM_ICON" ||
area.areaType === "ACTION_ICON")) {
+ this._canvas.style.cursor = "default";
+ } else {
+ this._canvas.style.cursor = "";
+ }
+ },
+
+ _ensureNoteHandle: function (index) {
+ if (!this._noteHandleRects) {
+ this._noteHandleRects = [];
+ }
+ while (this._noteHandleRects.length <= index) {
+ var div = document.createElement("div");
+ div.style.position = "absolute";
+ div.style.boxSizing = "border-box";
+ div.style.pointerEvents = "none";
+ div.style.display = "none";
+ div.style.width = "8px";
+ div.style.height = "8px";
+ div.style.backgroundColor = "rgb(0, 93, 166)";
+ div.style.border = "1px solid rgb(255, 255, 255)";
+ this._effectsLayer.appendChild(div);
+ this._noteHandleRects.push(div);
+ }
+ return this._noteHandleRects[index];
+ },
+
+ _clearNoteResizeHandles: function () {
+ if (!this._noteHandleRects) {
+ return;
+ }
+ for (var i = 0; i < this._noteHandleRects.length; i++) {
+ this._noteHandleRects[i].style.display = "none";
+ }
+ },
+
+ _drawNoteResizeHandles: function (area) {
+ // Prefer all selected notes from widget data; fall back to the
hovered NOTE area.
+ var notes = getWidgetData(this._canvasId, "notes");
+ var props = this._getCanvasProps();
+ var list = [];
+ if (notes && notes.length) {
+ for (var n = 0; n < notes.length; n++) {
+ if (notes[n] && notes[n].selected) {
+ list.push(notes[n]);
+ }
+ }
+ }
+ if (!list.length && area && area.areaType === "NOTE") {
+ list.push({
+ x: area.x,
+ y: area.y,
+ width: area.width,
+ height: area.height,
+ selected: true
+ });
+ }
+ if (!list.length || !this._effectsLayer) {
+ this._clearNoteResizeHandles();
+ return;
+ }
+ var hs = 8;
+ var handleIndex = 0;
+ for (var i = 0; i < list.length; i++) {
+ var note = list[i];
+ var screen = graphRectToScreen(note.x, note.y, note.width,
note.height, props);
+ var positions = [
+ [screen.left - hs / 2, screen.top - hs / 2],
+ [screen.left + screen.width / 2 - hs / 2, screen.top - hs
/ 2],
+ [screen.left + screen.width - hs / 2, screen.top - hs / 2],
+ [screen.left + screen.width - hs / 2, screen.top +
screen.height / 2 - hs / 2],
+ [screen.left + screen.width - hs / 2, screen.top +
screen.height - hs / 2],
+ [screen.left + screen.width / 2 - hs / 2, screen.top +
screen.height - hs / 2],
+ [screen.left - hs / 2, screen.top + screen.height - hs /
2],
+ [screen.left - hs / 2, screen.top + screen.height / 2 - hs
/ 2]
+ ];
+ for (var p = 0; p < positions.length; p++) {
+ var el = this._ensureNoteHandle(handleIndex++);
+ el.style.display = "block";
+ el.style.left = Math.round(positions[p][0]) + "px";
+ el.style.top = Math.round(positions[p][1]) + "px";
+ }
+ }
+ // Hide any leftover handles from a previous multi-note selection.
+ if (this._noteHandleRects) {
+ for (var h = handleIndex; h < this._noteHandleRects.length;
h++) {
+ this._noteHandleRects[h].style.display = "none";
+ }
+ }
+ },
+
+ _ensureHopLine: function () {
+ if (!this._hopLineEl && this._effectsLayer) {
+ var svg =
document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.style.position = "absolute";
+ svg.style.left = "0";
+ svg.style.top = "0";
+ svg.style.width = "100%";
+ svg.style.height = "100%";
+ svg.style.pointerEvents = "none";
+ svg.style.overflow = "visible";
+ var line =
document.createElementNS("http://www.w3.org/2000/svg", "line");
+ line.setAttribute("stroke", "rgb(0, 93, 166)");
+ line.setAttribute("stroke-width", "2");
+ line.setAttribute("stroke-dasharray", "6,4");
+ svg.appendChild(line);
+ this._effectsLayer.appendChild(svg);
+ this._hopLineEl = { svg: svg, line: line };
+ }
+ return this._hopLineEl;
+ },
+
+ _clearHopLine: function () {
+ if (this._hopLineEl) {
+ this._hopLineEl.svg.style.display = "none";
+ }
+ },
+
+ _updateHopLine: function (screenX, screenY) {
+ var mode = getWidgetData(this._canvasId, "mode");
+ if (mode !== "hop") {
+ this._clearHopLine();
return;
}
- this._canvas.style.cursor = (area && area.hover) ? "pointer" : "";
+ var startName = getWidgetData(this._canvasId, "startHopNode");
+ var nodes = getWidgetData(this._canvasId, "nodes") || {};
+ var startNode = startName ? nodes[startName] : null;
+ if (!startNode) {
+ this._clearHopLine();
+ return;
+ }
+ var props = this._getCanvasProps();
+ var size = nodeSize(startNode, props, null);
+ var startScreen = graphRectToScreen(
+ startNode.x + size.width / 2,
+ startNode.y + size.height / 2,
+ 0,
+ 0,
+ props);
+ var hop = this._ensureHopLine();
+ hop.svg.style.display = "block";
+ hop.line.setAttribute("x1", Math.round(startScreen.left));
+ hop.line.setAttribute("y1", Math.round(startScreen.top));
+ hop.line.setAttribute("x2", Math.round(screenX));
+ hop.line.setAttribute("y2", Math.round(screenY));
},
_getCanvasProps: function () {
@@ -521,11 +803,16 @@
}
if (this._mouseupHandler) {
document.removeEventListener("mouseup", this._mouseupHandler);
+ document.removeEventListener("pointerup",
this._mouseupHandler);
+ document.removeEventListener("pointercancel",
this._mouseupHandler);
}
+ this._modeDragListening = false;
this._endPan();
+ this._endNoteResize();
this._endDrag();
this._endNavDrag();
this._endSelect();
+ this._restoreIdleChrome();
},
_refreshPanBoundaries: function () {
@@ -535,6 +822,37 @@
}
},
+ /**
+ * Dim SVG while client wireframe previews run (pan/drag/resize).
Always clear when idle —
+ * leaving opacity at 0.35 after pan end (or after a post-pan SVG
refresh) makes the model
+ * look permanently muted.
+ */
+ _setSvgDimmed: function (dimmed) {
+ if (!this._svgHost) {
+ return;
+ }
+ this._svgHost.style.opacity = dimmed ? "0.35" : "";
+ },
+
+ _isInteractionActive: function () {
+ return !!(this._panActive || this._dragActive ||
this._noteResizeActive
+ || this._navDragActive || this._selectActive);
+ },
+
+ _restoreIdleChrome: function () {
+ if (this._isInteractionActive()) {
+ return;
+ }
+ this._setSvgDimmed(false);
+ this._clearDragPreview();
+ if (this._panBoundsOutline) {
+ this._panBoundsOutline.style.display = "none";
+ }
+ if (this._canvas) {
+ this._canvas.style.cursor = "";
+ }
+ },
+
_beginPan: function (screenX, screenY) {
var props = this._getCanvasProps();
this._panActive = true;
@@ -548,11 +866,15 @@
this._panBounds = props.panBoundaries || null;
this._lastHoverKey = null;
this._updateHoverChrome(null);
- if (this._svgHost) {
- this._svgHost.style.opacity = "0.35";
+ this._setSvgDimmed(true);
+ if (this._canvas) {
+ this._canvas.style.cursor = "grabbing";
}
document.addEventListener("mousemove",
this._documentMouseMoveHandler);
document.addEventListener("mouseup", this._mouseupHandler);
+ // Middle-button pan: also listen for auxclick / lost pointer in
case mouseup is skipped.
+ document.addEventListener("pointerup", this._mouseupHandler);
+ document.addEventListener("pointercancel", this._mouseupHandler);
var self = this;
setTimeout(function () {
if (self._panActive) {
@@ -570,12 +892,12 @@
if (this._panBoundsOutline) {
this._panBoundsOutline.style.display = "none";
}
- if (this._svgHost && !this._dragActive) {
- this._svgHost.style.opacity = "";
- }
- if (this._canvas && !this._dragActive) {
+ // Always undim — do not gate on other flags (stuck drag/resize
left canvas muted).
+ this._setSvgDimmed(false);
+ if (this._canvas && !this._dragActive && !this._noteResizeActive) {
this._canvas.style.cursor = "";
}
+ this._restoreIdleChrome();
},
_computePanOffset: function (screenX, screenY) {
@@ -647,10 +969,11 @@
var node = nodes[name];
var x = node.x;
var y = node.y;
- extendGraphBounds(graphBounds, x, y, iconSize, iconSize);
+ var size = nodeSize(node, props, null);
+ extendGraphBounds(graphBounds, x, y, size.width,
size.height);
hasBounds = true;
var screen = graphRectToScreen(
- x - 1, y - 1, iconSize + 1, iconSize + 1, props,
offsetX, offsetY);
+ x - 1, y - 1, size.width + 1, size.height + 1, props,
offsetX, offsetY);
var rect = this._ensureDragPreviewRect(previewIndex++);
rect.style.display = "block";
rect.style.left = Math.round(screen.left) + "px";
@@ -723,24 +1046,235 @@
}
},
+ /**
+ * Normalize notes widget data to a plain array (RAP JsonArray is
array-like).
+ */
+ _notesAsArray: function (notes) {
+ if (!notes) {
+ return [];
+ }
+ if (Object.prototype.toString.call(notes) === "[object Array]") {
+ return notes;
+ }
+ // RAP JsonArray: length + numeric index, or values() / asArray()
+ if (typeof notes.length === "number") {
+ var out = [];
+ for (var i = 0; i < notes.length; i++) {
+ out.push(notes[i]);
+ }
+ return out;
+ }
+ if (typeof notes.asArray === "function") {
+ return notes.asArray();
+ }
+ return [];
+ },
+
+ _findNoteAreaAt: function (graphX, graphY) {
+ if (!this._areas || !this._areas.length) {
+ return null;
+ }
+ for (var i = this._areas.length - 1; i >= 0; i--) {
+ var a = this._areas[i];
+ if (!a || a.areaType !== "NOTE") {
+ continue;
+ }
+ var w = a.width > 0 ? a.width : 0;
+ var h = a.height > 0 ? a.height : 0;
+ if (w > 0 && h > 0
+ && graphX >= a.x && graphX < a.x + w
+ && graphY >= a.y && graphY < a.y + h) {
+ return a;
+ }
+ }
+ return null;
+ },
+
+ _noteFromArea: function (area, selected) {
+ return {
+ x: area.x,
+ y: area.y,
+ width: area.width > 0 ? area.width : 100,
+ height: area.height > 0 ? area.height : 50,
+ selected: !!selected
+ };
+ },
+
+ _captureDragNotes: function () {
+ var notes = this._notesAsArray(getWidgetData(this._canvasId,
"notes"));
+ this._dragNotes = [];
+ var anySelected = false;
+ var i;
+ for (i = 0; i < notes.length; i++) {
+ var note = notes[i];
+ if (!note) {
+ continue;
+ }
+ var w = note.width > 0 ? note.width : 0;
+ var h = note.height > 0 ? note.height : 0;
+ // Geometry from matching NOTE area when widget data omits
size.
+ if (!(w > 0) || !(h > 0)) {
+ var areaMatch = null;
+ if (this._areas) {
+ for (var ai = 0; ai < this._areas.length; ai++) {
+ var ar = this._areas[ai];
+ if (ar && ar.areaType === "NOTE"
+ && ar.x === note.x && ar.y === note.y) {
+ areaMatch = ar;
+ break;
+ }
+ }
+ }
+ if (areaMatch) {
+ w = areaMatch.width > 0 ? areaMatch.width : w;
+ h = areaMatch.height > 0 ? areaMatch.height : h;
+ }
+ }
+ var sel = !!note.selected;
+ if (sel) {
+ anySelected = true;
+ }
+ this._dragNotes.push({
+ x: note.x,
+ y: note.y,
+ width: w > 0 ? w : 100,
+ height: h > 0 ? h : 50,
+ selected: sel
+ });
+ }
+
+ // Prefer geometry from the SVG area map (always has full note
bounds).
+ if (this._lastMouseDownGraph) {
+ var gx = this._lastMouseDownGraph.x;
+ var gy = this._lastMouseDownGraph.y;
+ var hitArea = this._findNoteAreaAt(gx, gy);
+ if (hitArea) {
+ // Mark matching captured note selected, or inject from
area.
+ var found = false;
+ for (var j = 0; j < this._dragNotes.length; j++) {
+ var n = this._dragNotes[j];
+ var near = Math.abs(n.x - hitArea.x) < 2 &&
Math.abs(n.y - hitArea.y) < 2;
+ var inside = gx >= n.x && gx < n.x + n.width
+ && gy >= n.y && gy < n.y + n.height;
+ if (near || inside) {
+ n.x = hitArea.x;
+ n.y = hitArea.y;
+ n.width = hitArea.width > 0 ? hitArea.width :
n.width;
+ n.height = hitArea.height > 0 ? hitArea.height :
n.height;
+ n.selected = true;
+ anySelected = true;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ this._dragNotes.push(this._noteFromArea(hitArea,
true));
+ anySelected = true;
+ }
+ }
+ }
+
+ // Race: mode=drag before selected flags — pick note under
mousedown by bounds.
+ if (!anySelected && this._lastMouseDownGraph) {
+ var gx2 = this._lastMouseDownGraph.x;
+ var gy2 = this._lastMouseDownGraph.y;
+ for (var k = 0; k < this._dragNotes.length; k++) {
+ var nn = this._dragNotes[k];
+ if (gx2 >= nn.x && gx2 < nn.x + nn.width
+ && gy2 >= nn.y && gy2 < nn.y + nn.height) {
+ nn.selected = true;
+ anySelected = true;
+ break;
+ }
+ }
+ }
+ if (!anySelected && this._dragNotes.length === 1) {
+ this._dragNotes[0].selected = true;
+ }
+ },
+
+ _hasSelectedDragNotes: function () {
+ if (!this._dragNotes || !this._dragNotes.length) {
+ return false;
+ }
+ for (var i = 0; i < this._dragNotes.length; i++) {
+ var n = this._dragNotes[i];
+ if (n && n.selected && n.width > 0 && n.height > 0) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ _ensureDocumentDragListeners: function () {
+ document.addEventListener("mousemove",
this._documentMouseMoveHandler);
+ document.addEventListener("mouseup", this._mouseupHandler);
+ this._modeDragListening = true;
+ },
+
_beginDrag: function (clickedName, graphX, graphY, iconArea) {
this._dragActive = true;
this._dragClickedName = clickedName;
+ this._dragIconArea = iconArea || null;
this._dragIconOffset = {
- x: graphX - iconArea.x,
- y: graphY - iconArea.y
+ x: graphX - (iconArea ? iconArea.x : graphX),
+ y: graphY - (iconArea ? iconArea.y : graphY)
};
this._captureDragNodes();
- if (!this._dragStartPositions[clickedName]) {
+ this._captureDragNotes();
+ if (iconArea && !this._dragStartPositions[clickedName]) {
this._dragStartPositions[clickedName] = { x: iconArea.x, y:
iconArea.y };
}
+ // Enrich node size from hit area when server nodes omit
width/height.
+ if (this._dragNodes && this._dragNodes[clickedName] && iconArea) {
+ var n = this._dragNodes[clickedName];
+ if (!(n.width > 0) && iconArea.width > 0) {
+ n.width = iconArea.width;
+ }
+ if (!(n.height > 0) && iconArea.height > 0) {
+ n.height = iconArea.height;
+ }
+ }
this._lastHoverKey = null;
this._updateHoverChrome(null);
- if (this._svgHost) {
- this._svgHost.style.opacity = "0.35";
+ this._clearNoteResizeHandles();
+ this._setSvgDimmed(true);
+ if (this._canvas) {
+ this._canvas.style.cursor = "grabbing";
}
- document.addEventListener("mousemove",
this._documentMouseMoveHandler);
- document.addEventListener("mouseup", this._mouseupHandler);
+ this._ensureDocumentDragListeners();
+ this._updateDragPreview(graphX, graphY);
+ },
+
+ /**
+ * Server armed mode=drag (notes and/or nodes) but client did not
start via icon hit.
+ * Uses last mousedown graph point so dx matches canvas.js (screen
delta / mag).
+ */
+ _beginModeDrag: function (graphX, graphY) {
+ if (this._dragActive || this._noteResizeActive) {
+ return;
+ }
+ var start = this._lastMouseDownGraph || { x: graphX, y: graphY };
+ this._dragActive = true;
+ // Synthetic anchor: iconOffset 0 → dx = graphX - start.x
(mousedown origin).
+ this._dragClickedName = "__mode_drag__";
+ this._dragIconArea = null;
+ this._dragIconOffset = { x: 0, y: 0 };
+ // Capture nodes first (rewrites _dragStartPositions), then seed
synthetic anchor.
+ this._captureDragNodes();
+ this._captureDragNotes();
+ if (!this._dragStartPositions) {
+ this._dragStartPositions = {};
+ }
+ this._dragStartPositions["__mode_drag__"] = { x: start.x, y:
start.y };
+ this._lastHoverKey = null;
+ this._updateHoverChrome(null);
+ this._clearNoteResizeHandles();
+ this._setSvgDimmed(true);
+ if (this._canvas) {
+ this._canvas.style.cursor = "grabbing";
+ }
+ this._ensureDocumentDragListeners();
this._updateDragPreview(graphX, graphY);
},
@@ -748,42 +1282,109 @@
this._dragActive = false;
this._dragClickedName = null;
this._dragIconOffset = null;
+ this._dragIconArea = null;
this._dragStartPositions = null;
this._dragNodes = null;
+ this._dragNotes = null;
+ this._modeDragListening = false;
this._clearDragPreview();
- if (this._svgHost && !this._panActive) {
- this._svgHost.style.opacity = "";
+ this._clearHopLine();
+ this._restoreIdleChrome();
+ },
+
+ /**
+ * SVG ghost layer for drag/resize outlines. HTML div borders were
unreliable under RAP
+ * (stacking / empty node maps); SVG rects match the hop rubber-band
path that works.
+ */
+ _ensureGhostSvg: function () {
+ if (!this._ghostSvg && this._effectsLayer) {
+ var svg =
document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("height", "100%");
+ svg.style.position = "absolute";
+ svg.style.left = "0";
+ svg.style.top = "0";
+ svg.style.width = "100%";
+ svg.style.height = "100%";
+ svg.style.pointerEvents = "none";
+ svg.style.overflow = "visible";
+ svg.style.zIndex = "30";
+ this._effectsLayer.appendChild(svg);
+ this._ghostSvg = svg;
+ this._ghostRectPool = [];
+ }
+ return this._ghostSvg;
+ },
+
+ _ensureGhostRect: function (index) {
+ this._ensureGhostSvg();
+ if (!this._ghostRectPool) {
+ this._ghostRectPool = [];
+ }
+ while (this._ghostRectPool.length <= index) {
+ var rect =
document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("fill", "rgba(0, 93, 166, 0.12)");
+ rect.setAttribute("stroke", "rgb(0, 93, 166)");
+ rect.setAttribute("stroke-width", "2");
+ rect.setAttribute("rx", "6");
+ rect.setAttribute("ry", "6");
+ rect.style.display = "none";
+ this._ghostSvg.appendChild(rect);
+ this._ghostRectPool.push(rect);
+ }
+ return this._ghostRectPool[index];
+ },
+
+ _placeGhostRect: function (index, left, top, width, height, selected) {
+ var rect = this._ensureGhostRect(index);
+ var w = Math.max(4, Math.round(width));
+ var h = Math.max(4, Math.round(height));
+ rect.style.display = "block";
+ rect.setAttribute("x", Math.round(left));
+ rect.setAttribute("y", Math.round(top));
+ rect.setAttribute("width", w);
+ rect.setAttribute("height", h);
+ rect.setAttribute("stroke-width", selected ? "3" : "2");
+ rect.setAttribute("stroke", selected ? "rgb(0, 93, 166)" :
"rgb(61, 99, 128)");
+ rect.setAttribute("fill", selected ? "rgba(0, 93, 166, 0.15)" :
"rgba(61, 99, 128, 0.08)");
+ return rect;
+ },
+
+ _clearDragPreview: function () {
+ if (this._dragPreviewRects) {
+ for (var i = 0; i < this._dragPreviewRects.length; i++) {
+ this._dragPreviewRects[i].style.display = "none";
+ }
}
- if (this._canvas && !this._panActive) {
- this._canvas.style.cursor = "";
+ if (this._ghostRectPool) {
+ for (var g = 0; g < this._ghostRectPool.length; g++) {
+ this._ghostRectPool[g].style.display = "none";
+ }
+ }
+ if (this._ghostSvg) {
+ this._ghostSvg.style.display = "none";
}
},
- _ensureDragPreviewRect: function (index) {
- if (!this._dragPreviewRects) {
- this._dragPreviewRects = [];
+ _showGhostSvg: function () {
+ var svg = this._ensureGhostSvg();
+ if (svg) {
+ svg.style.display = "block";
}
- while (this._dragPreviewRects.length <= index) {
- var div = document.createElement("div");
- div.style.position = "absolute";
- div.style.boxSizing = "border-box";
- div.style.pointerEvents = "none";
- div.style.display = "none";
- div.style.backgroundColor = "transparent";
- div.style.borderRadius = "8px";
- this._effectsLayer.appendChild(div);
- this._dragPreviewRects.push(div);
- }
- return this._dragPreviewRects[index];
},
- _clearDragPreview: function () {
- if (!this._dragPreviewRects) {
- return;
+ _nodeNames: function (nodes) {
+ var names = [];
+ if (!nodes) {
+ return names;
}
- for (var i = 0; i < this._dragPreviewRects.length; i++) {
- this._dragPreviewRects[i].style.display = "none";
+ // Prefer for-in: RAP/JSON maps are not always plain objects for
Object.keys.
+ for (var name in nodes) {
+ if (Object.prototype.hasOwnProperty.call(nodes, name)) {
+ names.push(name);
+ }
}
+ return names;
},
_updateDragPreview: function (graphX, graphY) {
@@ -793,13 +1394,18 @@
return;
}
- var props = this._props || {};
- var iconSize = props.iconSize || 32;
+ // Live widget props (offset/magnification) — snapshot this._props
can be stale/empty.
+ var props = this._getCanvasProps();
+ // Prefer SVG snapshot props when widget props lack magnification
(first paint race).
+ if (props.magnification == null && this._props &&
this._props.magnification != null) {
+ props = this._props;
+ }
var gridSize = props.showGrid ? (props.gridSize || 1) : 1;
var clickedStart = this._dragStartPositions[this._dragClickedName];
if (!clickedStart) {
- this._clearDragPreview();
- return;
+ // Recover synthetic / missing anchor so we still draw
something.
+ clickedStart = this._lastMouseDownGraph || { x: graphX, y:
graphY };
+ this._dragStartPositions[this._dragClickedName] = clickedStart;
}
var iconTargetX = graphX - this._dragIconOffset.x;
@@ -809,13 +1415,20 @@
var dx = iconTargetX - clickedStart.x;
var dy = iconTargetY - clickedStart.y;
- var selectedColor = "rgb(0, 93, 166)";
+ this._showGhostSvg();
var previewIndex = 0;
var nodes = this._dragNodes;
- var names = nodes ? Object.keys(nodes) : [this._dragClickedName];
+ var names = this._nodeNames(nodes);
+ var modeDrag = this._dragClickedName === "__mode_drag__";
+ if (!modeDrag && names.indexOf(this._dragClickedName) < 0) {
+ names = names.concat([this._dragClickedName]);
+ }
for (var i = 0; i < names.length; i++) {
var name = names[i];
+ if (name === "__mode_drag__") {
+ continue;
+ }
var node = nodes ? nodes[name] : null;
var start = this._dragStartPositions[name];
if (!start) {
@@ -828,26 +1441,274 @@
if (!node && !isClicked) {
continue;
}
+ if (modeDrag && node && !node.selected) {
+ continue;
+ }
var x = start.x + dx;
var y = start.y + dy;
- var screen = graphRectToScreen(x - 1, y - 1, iconSize + 1,
iconSize + 1, props);
- var rect = this._ensureDragPreviewRect(previewIndex++);
- rect.style.display = "block";
- rect.style.left = Math.round(screen.left) + "px";
- rect.style.top = Math.round(screen.top) + "px";
- rect.style.width = Math.round(screen.width) + "px";
- rect.style.height = Math.round(screen.height) + "px";
- rect.style.border = (node && node.selected) || isClicked
- ? "3px solid " + selectedColor
- : "1px solid rgb(61, 99, 128)";
+ var size = nodeSize(node, props, isClicked ?
this._dragIconArea : null);
+ var screen = graphRectToScreen(x - 1, y - 1, size.width + 1,
size.height + 1, props);
+ this._placeGhostRect(
+ previewIndex++,
+ screen.left,
+ screen.top,
+ screen.width,
+ screen.height,
+ (node && node.selected) || isClicked);
+ }
+
+ // Selected notes move with the same dx/dy (canvas.js drawNotes
mode=drag).
+ // Re-capture once if empty so late-arriving notes[] / areas
populate geometry.
+ if ((!this._dragNotes || !this._dragNotes.length) && modeDrag) {
+ this._captureDragNotes();
+ }
+ var notes = this._dragNotes;
+ var anyNoteDrawn = false;
+ if (notes && notes.length) {
+ for (var ni = 0; ni < notes.length; ni++) {
+ var note = notes[ni];
+ if (!note.selected) {
+ continue;
+ }
+ var nw = note.width > 0 ? note.width : 100;
+ var nh = note.height > 0 ? note.height : 50;
+ var nx = note.x + dx;
+ var ny = note.y + dy;
+ var noteScreen = graphRectToScreen(nx, ny, nw, nh, props);
+ this._placeGhostRect(
+ previewIndex++,
+ noteScreen.left,
+ noteScreen.top,
+ noteScreen.width,
+ noteScreen.height,
+ true);
+ anyNoteDrawn = true;
+ }
}
- for (var j = previewIndex; j < this._dragPreviewRects.length; j++)
{
- this._dragPreviewRects[j].style.display = "none";
+ // Fallback: NOTE area under mousedown (full pad size / top-left),
never a 32px cursor square.
+ if (!anyNoteDrawn && this._lastMouseDownGraph) {
+ var hitNote = this._findNoteAreaAt(
+ this._lastMouseDownGraph.x, this._lastMouseDownGraph.y);
+ if (hitNote) {
+ var hs = graphRectToScreen(
+ hitNote.x + dx,
+ hitNote.y + dy,
+ hitNote.width > 0 ? hitNote.width : 100,
+ hitNote.height > 0 ? hitNote.height : 50,
+ props);
+ this._placeGhostRect(
+ previewIndex++, hs.left, hs.top, hs.width, hs.height,
true);
+ anyNoteDrawn = true;
+ }
+ }
+
+ // Fallback: node icon area (tables/cards).
+ if (previewIndex === 0 && this._dragIconArea) {
+ var fb = this._dragIconArea;
+ var fbScreen = graphRectToScreen(
+ fb.x + dx, fb.y + dy, fb.width || 32, fb.height || 32,
props);
+ this._placeGhostRect(
+ previewIndex++, fbScreen.left, fbScreen.top,
fbScreen.width, fbScreen.height, true);
+ }
+
+ if (this._ghostRectPool) {
+ for (var j = previewIndex; j < this._ghostRectPool.length;
j++) {
+ this._ghostRectPool[j].style.display = "none";
+ }
+ }
+ },
+
+ _beginNoteResize: function (screenX, screenY) {
+ if (this._noteResizeActive) {
+ return;
+ }
+ var direction = getWidgetData(this._canvasId, "resizeDirection");
+ var notes = getWidgetData(this._canvasId, "notes");
+ if (!direction || !notes || !notes.length) {
+ return;
+ }
+ var selected = null;
+ for (var i = 0; i < notes.length; i++) {
+ if (notes[i] && notes[i].selected) {
+ selected = notes[i];
+ break;
+ }
+ }
+ // Race: mode=resize before selected flags — use note under
mousedown / first note.
+ if (!selected && this._lastMouseDownGraph) {
+ var gx = this._lastMouseDownGraph.x;
+ var gy = this._lastMouseDownGraph.y;
+ for (var j = 0; j < notes.length; j++) {
+ var cand = notes[j];
+ if (!cand) {
+ continue;
+ }
+ if (gx >= cand.x && gx < cand.x + cand.width
+ && gy >= cand.y && gy < cand.y + cand.height) {
+ selected = cand;
+ break;
+ }
+ }
+ }
+ if (!selected && notes.length === 1) {
+ selected = notes[0];
+ }
+ if (!selected) {
+ return;
+ }
+ var startScreen = this._lastMouseDownScreen || { x: screenX, y:
screenY };
+ this._noteResizeActive = true;
+ this._resizeStartScreenX = startScreen.x;
+ this._resizeStartScreenY = startScreen.y;
+ this._resizedNote = {
+ direction: direction,
+ startX: selected.x,
+ startY: selected.y,
+ startWidth: selected.width,
+ startHeight: selected.height,
+ currentX: selected.x,
+ currentY: selected.y,
+ currentWidth: selected.width,
+ currentHeight: selected.height
+ };
+ this._lastHoverKey = null;
+ this._updateHoverChrome(null);
+ this._setSvgDimmed(true);
+ this._ensureDocumentDragListeners();
+ this._updateNoteResizePreview(screenX, screenY);
+ },
+
+ _endNoteResize: function () {
+ this._noteResizeActive = false;
+ this._resizedNote = null;
+ this._clearDragPreview();
+ this._clearNoteResizeHandles();
+ this._restoreIdleChrome();
+ },
+
+ _updateNoteResizePreview: function (screenX, screenY) {
+ if (!this._noteResizeActive || !this._resizedNote) {
+ return;
+ }
+ var props = this._getCanvasProps();
+ var mag = props.magnification || 1.0;
+ var deltaX = (screenX - this._resizeStartScreenX) / mag;
+ var deltaY = (screenY - this._resizeStartScreenY) / mag;
+ var minWidth = 100;
+ var minHeight = 50;
+ var rn = this._resizedNote;
+ rn.currentX = rn.startX;
+ rn.currentY = rn.startY;
+ rn.currentWidth = rn.startWidth;
+ rn.currentHeight = rn.startHeight;
+ var dir = rn.direction;
+ var newW;
+ var newH;
+
+ if (dir === "EAST" || dir === "SOUTH_EAST" || dir ===
"NORTH_EAST") {
+ rn.currentWidth = Math.max(minWidth, rn.startWidth + deltaX);
+ }
+ if (dir === "WEST" || dir === "SOUTH_WEST" || dir ===
"NORTH_WEST") {
+ newW = Math.max(minWidth, rn.startWidth - deltaX);
+ rn.currentX = rn.startX + (rn.startWidth - newW);
+ rn.currentWidth = newW;
+ }
+ if (dir === "SOUTH" || dir === "SOUTH_EAST" || dir ===
"SOUTH_WEST") {
+ rn.currentHeight = Math.max(minHeight, rn.startHeight +
deltaY);
+ }
+ if (dir === "NORTH" || dir === "NORTH_EAST" || dir ===
"NORTH_WEST") {
+ newH = Math.max(minHeight, rn.startHeight - deltaY);
+ rn.currentY = rn.startY + (rn.startHeight - newH);
+ rn.currentHeight = newH;
+ }
+
+ var screen = graphRectToScreen(
+ rn.currentX, rn.currentY, rn.currentWidth, rn.currentHeight,
props);
+ this._showGhostSvg();
+ this._placeGhostRect(
+ 0, screen.left, screen.top, screen.width, screen.height, true);
+ if (this._ghostRectPool) {
+ for (var j = 1; j < this._ghostRectPool.length; j++) {
+ this._ghostRectPool[j].style.display = "none";
+ }
+ }
+
+ // Move the 8 resize handles with the live outline (not stale
notes[] sizes).
+ var hs = 8;
+ var handleIndex = 0;
+ var positions = [
+ [screen.left - hs / 2, screen.top - hs / 2],
+ [screen.left + screen.width / 2 - hs / 2, screen.top - hs / 2],
+ [screen.left + screen.width - hs / 2, screen.top - hs / 2],
+ [screen.left + screen.width - hs / 2, screen.top +
screen.height / 2 - hs / 2],
+ [screen.left + screen.width - hs / 2, screen.top +
screen.height - hs / 2],
+ [screen.left + screen.width / 2 - hs / 2, screen.top +
screen.height - hs / 2],
+ [screen.left - hs / 2, screen.top + screen.height - hs / 2],
+ [screen.left - hs / 2, screen.top + screen.height / 2 - hs / 2]
+ ];
+ for (var p = 0; p < positions.length; p++) {
+ var el = this._ensureNoteHandle(handleIndex++);
+ el.style.display = "block";
+ el.style.left = Math.round(positions[p][0]) + "px";
+ el.style.top = Math.round(positions[p][1]) + "px";
+ }
+ if (this._noteHandleRects) {
+ for (var h = handleIndex; h < this._noteHandleRects.length;
h++) {
+ this._noteHandleRects[h].style.display = "none";
+ }
}
},
+ /**
+ * Mirror canvas.js: when the server sets mode=drag / mode=resize,
draw ghosts on the
+ * SVG effects layer (canvas.js paint sits under the SVG and is
invisible).
+ */
+ _syncServerModePreviews: function (screenX, screenY, graphX, graphY,
buttonsDown) {
+ var mode = getWidgetData(this._canvasId, "mode");
+ // Prefer local pointer flag: RAP often reports event.buttons ===
0 while dragging.
+ var held = buttonsDown || this._pointerHeld;
+ if (mode === "resize" && held) {
+ if (!this._noteResizeActive) {
+ this._beginNoteResize(screenX, screenY);
+ }
+ if (this._noteResizeActive) {
+ this._updateNoteResizePreview(screenX, screenY);
+ return true;
+ }
+ }
+ if (mode === "drag" && held) {
+ if (!this._dragActive) {
+ // Prefer icon-based drag when mousedown was on a node
(grab offset accurate).
+ var props = this._getCanvasProps();
+ var nodes = getWidgetData(this._canvasId, "nodes") || {};
+ var down = this._lastMouseDownGraph || { x: graphX, y:
graphY };
+ var hit = findNodeAt(nodes, down.x, down.y, props);
+ if (hit) {
+ this._beginDrag(hit.name, down.x, down.y, {
+ x: hit.node.x,
+ y: hit.node.y,
+ width: hit.width,
+ height: hit.height
+ });
+ } else {
+ // Notes-only (or miss): mode-drag uses mousedown as
origin for dx/dy.
+ this._beginModeDrag(down.x, down.y);
+ }
+ }
+ if (this._dragActive) {
+ // One-shot re-capture if we never got note geometry (late
notes[] / areas).
+ if (!this._hasSelectedDragNotes()) {
+ this._captureDragNotes();
+ }
+ this._updateDragPreview(graphX, graphY);
+ return true;
+ }
+ }
+ return false;
+ },
+
_isPanGesture: function (event) {
return event.button === 1
|| (event.button === 0 && (event.ctrlKey || event.metaKey));
@@ -945,15 +1806,41 @@
var rect = this._canvas.getBoundingClientRect();
var screenX = event.clientX - rect.left;
var screenY = event.clientY - rect.top;
+ // Prefer live widget props (includes mode/nodes) over last SVG
snapshot props.
var props = this._getCanvasProps();
+ var graphProps = props.magnification != null ? props :
(this._props || props);
+ if (event.button === 0) {
+ this._pointerHeld = true;
+ }
if (event.button === 0 && props.viewPort &&
containsRect(props.viewPort, screenX, screenY)) {
this._beginNavDrag(screenX, screenY, props.viewPort);
return;
}
- var graph = graphCoords(screenX, screenY, this._props);
+ var graph = graphCoords(screenX, screenY, graphProps);
+ // Always remember mousedown for mode=drag / mode=resize previews
after server arms mode.
+ this._lastMouseDownGraph = { x: graph.x, y: graph.y };
+ this._lastMouseDownScreen = { x: screenX, y: screenY };
+
var iconArea = this._areas.length
? findIconAreaAt(this._areas, graph.x, graph.y)
: null;
+ var clickedName = iconArea ? iconOwnerName(iconArea) : null;
+
+ // Fallback: hit-test nodes map (plugin model cards). Areas may be
empty/stale while
+ // nodes still carry correct logical positions and sizes.
+ if (!clickedName) {
+ var nodes = getWidgetData(this._canvasId, "nodes");
+ var hit = findNodeAt(nodes, graph.x, graph.y, graphProps);
+ if (hit) {
+ clickedName = hit.name;
+ iconArea = {
+ x: hit.node.x,
+ y: hit.node.y,
+ width: hit.width,
+ height: hit.height
+ };
+ }
+ }
if (this._isPanGesture(event)) {
if (!iconArea) {
@@ -963,17 +1850,35 @@
}
if (event.button === 0 && !iconArea) {
+ // Note area: do not start lasso — server arms mode=drag /
mode=resize; client
+ // draws ghosts via _syncServerModePreviews once mode arrives.
+ var noteArea = getVisibleArea(this._areas, graph.x, graph.y);
+ if (noteArea && noteArea.areaType === "NOTE") {
+ this._ensureDocumentDragListeners();
+ return;
+ }
+ // Also treat notes[] hit when areas are empty/stale.
+ var notes = getWidgetData(this._canvasId, "notes");
+ if (notes && notes.length) {
+ for (var ni = 0; ni < notes.length; ni++) {
+ var note = notes[ni];
+ if (!note) {
+ continue;
+ }
+ if (graph.x >= note.x && graph.x < note.x + note.width
+ && graph.y >= note.y && graph.y < note.y +
note.height) {
+ this._ensureDocumentDragListeners();
+ return;
+ }
+ }
+ }
if (!props.viewPort || !containsRect(props.viewPort, screenX,
screenY)) {
this._beginSelect(screenX, screenY);
}
return;
}
- if (event.button !== 0 || !this._areas.length || !iconArea) {
- return;
- }
- var clickedName = iconOwnerName(iconArea);
- if (!clickedName) {
+ if (event.button !== 0 || !clickedName || !iconArea) {
return;
}
this._beginDrag(clickedName, graph.x, graph.y, iconArea);
@@ -981,6 +1886,7 @@
setTimeout(function () {
if (self._dragActive) {
self._captureDragNodes();
+ self._captureDragNotes();
self._updateDragPreview(graph.x, graph.y);
}
}, 0);
@@ -993,7 +1899,18 @@
var rect = this._canvas.getBoundingClientRect();
var screenX = event.clientX - rect.left;
var screenY = event.clientY - rect.top;
+ var props = this._getCanvasProps();
+ var graph = graphCoords(screenX, screenY, props);
+ var buttonsDown = event.buttons === 1 || event.which === 1;
+ var buttons = typeof event.buttons === "number" ? event.buttons :
0;
+ var panButtonsHeld = (buttons & 4) !== 0 || (buttons & 1) !== 0;
if (this._panActive) {
+ var panMode = getWidgetData(this._canvasId, "mode");
+ if (panMode !== "pan" && !panButtonsHeld) {
+ this._endPan();
+ this._restoreIdleChrome();
+ return;
+ }
this._computePanOffset(screenX, screenY);
this._updatePanPreview();
return;
@@ -1006,44 +1923,77 @@
this._updateSelectLasso(screenX, screenY);
return;
}
+ if (this._noteResizeActive) {
+ this._updateNoteResizePreview(screenX, screenY);
+ return;
+ }
if (this._dragActive) {
- var graph = graphCoords(screenX, screenY, this._props);
this._updateDragPreview(graph.x, graph.y);
+ return;
}
+ // mode=drag / mode=resize may arrive after mousedown; pick up
here while button held.
+ this._syncServerModePreviews(screenX, screenY, graph.x, graph.y,
buttonsDown);
},
_handleMouseUp: function (event) {
- if (!this._panActive && !this._dragActive && !this._navDragActive
&& !this._selectActive) {
+ this._pointerHeld = false;
+ if (!this._panActive && !this._dragActive && !this._navDragActive
+ && !this._selectActive && !this._noteResizeActive &&
!this._modeDragListening) {
+ // Still force undim — covers lost mouseup during pan where
flags already cleared.
+ this._restoreIdleChrome();
return;
}
document.removeEventListener("mousemove",
this._documentMouseMoveHandler);
document.removeEventListener("mouseup", this._mouseupHandler);
+ document.removeEventListener("pointerup", this._mouseupHandler);
+ document.removeEventListener("pointercancel",
this._mouseupHandler);
+ this._modeDragListening = false;
if (this._panActive) {
this._endPan();
}
if (this._navDragActive) {
this._endNavDrag();
}
+ if (this._noteResizeActive) {
+ this._endNoteResize();
+ }
if (this._dragActive) {
this._endDrag();
}
if (this._selectActive) {
this._endSelect();
}
+ this._restoreIdleChrome();
},
_handleMouseMove: function (event) {
- if (!this._canvas || !this._remoteObject || !this._areas.length) {
+ if (!this._canvas || !this._remoteObject) {
return;
}
var rect = this._canvas.getBoundingClientRect();
var screenX = event.clientX - rect.left;
var screenY = event.clientY - rect.top;
- var graph = graphCoords(screenX, screenY, this._props);
+ var props = this._getCanvasProps();
+ var graphProps = props.magnification != null ? props :
(this._props || props);
+ var graph = graphCoords(screenX, screenY, graphProps);
+ var buttonsDown = event.buttons === 1 || event.which === 1;
+ var buttons = typeof event.buttons === "number" ? event.buttons :
0;
+ // Middle = 4; left = 1. Ctrl-left pan uses left.
+ var panButtonsHeld = (buttons & 4) !== 0 || (buttons & 1) !== 0;
+ var mode = getWidgetData(this._canvasId, "mode");
+
+ // Relationship hop rubber-band on the SVG effects layer (above
SVG; canvas.js is under).
+ this._updateHopLine(screenX, screenY);
+
if (this._panActive) {
- this._computePanOffset(screenX, screenY);
- this._updatePanPreview();
- return;
+ // Server already cleared pan and no button held → end
wireframe even if mouseup missed.
+ if (mode !== "pan" && !panButtonsHeld) {
+ this._endPan();
+ } else {
+ this._computePanOffset(screenX, screenY);
+ this._updatePanPreview();
+ return;
+ }
}
if (this._navDragActive) {
this._updateNavPreview(screenX, screenY);
@@ -1053,24 +2003,37 @@
this._updateSelectLasso(screenX, screenY);
return;
}
+ if (this._noteResizeActive) {
+ this._updateNoteResizePreview(screenX, screenY);
+ return;
+ }
if (this._dragActive) {
this._updateDragPreview(graph.x, graph.y);
return;
}
- var props = this._getCanvasProps();
+ // Server mode=drag / mode=resize: draw ghosts on effects layer
(canvas.js is under SVG).
+ if (this._syncServerModePreviews(screenX, screenY, graph.x,
graph.y, buttonsDown)) {
+ return;
+ }
if (props.viewPort && containsRect(props.viewPort, screenX,
screenY)) {
this._canvas.style.cursor = "grab";
+ this._clearNoteResizeHandles();
return;
}
- var panMode = getWidgetData(this._canvasId, "mode");
- if (panMode === "pan" && !this._panActive) {
+ if (mode === "pan" && !this._panActive) {
this._beginPan(screenX, screenY);
return;
}
- var area = getVisibleArea(this._areas, graph.x, graph.y);
- this._updateHoverChrome(area);
+ // Idle with leftover dim (e.g. after pan + SVG refresh race).
+ this._restoreIdleChrome();
+ var area = this._areas.length
+ ? getVisibleArea(this._areas, graph.x, graph.y)
+ : null;
+ this._updateHoverChrome(area, graph.x, graph.y);
- var hoverKey = area ? area.areaType + ":" +
JSON.stringify(area.owner) : "";
+ var hoverKey = area
+ ? area.areaType + ":" + JSON.stringify(area.owner) + ":" +
Math.round(graph.x / 4) + ":" + Math.round(graph.y / 4)
+ : "";
if (hoverKey === this._lastHoverKey) {
return;
}
@@ -1101,9 +2064,19 @@
widget._sessionUuid = value;
},
canvasId: function (widget, value) {
+ var changed = widget._canvasId !== value;
widget._canvasId = value;
- widget._revision = 0;
- widget._findAndAttachCanvas();
+ // Tab switch: re-bind overlay to the newly active canvas and
force SVG fetch.
+ if (changed) {
+ widget._revision = 0;
+ if (widget._svgHost) {
+ widget._svgHost.innerHTML = "";
+ }
+ widget._findAndAttachCanvas();
+ widget._fetchAndRender(0);
+ } else {
+ widget._findAndAttachCanvas();
+ }
},
renderRevision: function (widget, value) {
if (value !== widget._revision) {
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
index 9fdab32876..f03e1b3df7 100644
--- 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
@@ -90,27 +90,37 @@
_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;
-
+ var maxAttempts = 20;
+
+ var findCanvasElement = function() {
+ // Prefer the RAP widget id from Java (same approach as
canvas-svg.js).
+ if (self._canvasId) {
+ var widgetElement =
document.getElementById(self._canvasId);
+ if (widgetElement) {
+ if (widgetElement.tagName === "CANVAS") {
+ return widgetElement;
+ }
+ var nested = widgetElement.querySelector("canvas");
+ if (nested) {
+ return nested;
+ }
+ }
+ }
+ // Fallback: first large visible graph canvas
+ var allCanvases = document.querySelectorAll("canvas");
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 (rect.width > 500 && rect.height > 500 &&
c.offsetParent !== null) {
+ return c;
}
}
+ return null;
+ };
+
+ var tryFindCanvas = function() {
+ attempts++;
+ var canvas = findCanvasElement();
if (!canvas) {
if (attempts < maxAttempts) {
@@ -121,8 +131,9 @@
}
}
- // Check if this is a different canvas than the one we already
have
- if (self._canvas === canvas) {
+ // Same canvas element: still re-ensure the wheel listener
(RAP may replace nodes).
+ if (self._canvas === canvas && self._wheelHandler) {
+ self._applyCanvasSizeFix();
return;
}
@@ -195,8 +206,9 @@
events: ["zoom"],
propertyHandler: {
canvas: function(widget, value) {
- // When canvas property is updated from Java, update _canvasId
+ // When canvas property is updated from Java, re-attach wheel
to that canvas.
widget._canvasId = value;
+ widget._findAndAttachCanvas();
}
}
});
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 cb666d4b1d..bebf67a9d8 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
@@ -59,9 +59,10 @@ function getThemeColors(theme) {
//
const handleEvent = function (event) {
const mode = event.widget.getData("mode");
- const nodes = event.widget.getData("nodes");
- const hops = event.widget.getData("hops");
- const notes = event.widget.getData("notes");
+ // Plugin graphs (Data Vault modelers, etc.) may omit nodes/hops/notes;
default so Paint never NPE.
+ const nodes = event.widget.getData("nodes") || {};
+ const hops = event.widget.getData("hops") || [];
+ const notes = event.widget.getData("notes") || [];
const props = event.widget.getData("props");
const startHopNodeName = event.widget.getData("startHopNode");
const resizeDirection = event.widget.getData("resizeDirection");
@@ -70,6 +71,11 @@ const handleEvent = function (event) {
const panStartOffset = props ? props.panStartOffset : null;
const panBoundaries = props ? props.panBoundaries : null;
+ // Props can be missing on the first paint before the server sets canvas
data.
+ if (!props) {
+ return;
+ }
+
// Global vars to make the coordinate calculation function simpler.
//
magnification = props.magnification;
@@ -116,12 +122,14 @@ const handleEvent = function (event) {
x2 = x1;
y2 = y1;
- // Determine which node is clicked if any
+ // Determine which node is clicked if any (card width/height when
present)
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
- && node.y <= y1 && y1 < node.y + iconLogicalSize) {
+ const nw = (node.width > 0) ? node.width : iconLogicalSize;
+ const nh = (node.height > 0) ? node.height : iconLogicalSize;
+ if (node.x <= x1 && x1 < node.x + nw
+ && node.y <= y1 && y1 < node.y + nh) {
clicked = node;
// Calculate iconOffset: where within the icon did user
click?
@@ -463,10 +471,13 @@ const handleEvent = function (event) {
// Draw notes
drawNotes(notes, gc, mode, dx, dy);
- // Draw a new hop candidate line (matching desktop client style)
+ // Draw a new hop candidate line (matching desktop client style).
+ // Use node width/height when present (model cards); else pipeline
icon size.
if (mode === "hop" && hopStartNode) {
- const startX = Math.round(fx(hopStartNode.x) + iconSize / 2);
- const startY = Math.round(fy(hopStartNode.y) + iconSize / 2);
+ const hopW = (hopStartNode.width > 0) ? hopStartNode.width :
props.iconSize;
+ const hopH = (hopStartNode.height > 0) ? hopStartNode.height :
props.iconSize;
+ const startX = Math.round(fx(hopStartNode.x + hopW / 2));
+ const startY = Math.round(fy(hopStartNode.y + hopH / 2));
// Use raw screen coordinates for the mouse position (not
transformed)
const endX = mouseScreenX;
@@ -564,7 +575,26 @@ function drawGrid(gc, gridSize) {
gc.globalAlpha = 1.0; // Reset alpha
}
+function nodeLogicalSize(node, defaultIconSize) {
+ const w = (node && node.width > 0) ? node.width : defaultIconSize;
+ const h = (node && node.height > 0) ? node.height : defaultIconSize;
+ return { width: w, height: h };
+}
+
+function nodeCenterScreen(node, x, y, defaultIconSize) {
+ const size = nodeLogicalSize(node, defaultIconSize);
+ return {
+ x: Math.round(fx(x + size.width / 2)),
+ y: Math.round(fy(y + size.height / 2))
+ };
+}
+
function drawHops(hops, gc, mode, nodes, dx, iconSize, dy) {
+ if (!hops || !nodes) {
+ return;
+ }
+ // iconSize is magnified; convert back to logical default for width/height
fallback.
+ const defaultLogicalIcon = (magnification > 0) ? (iconSize /
magnification) : 32;
// Set consistent stroke style for all hops
gc.strokeStyle = fgColor;
gc.lineWidth = 1;
@@ -591,11 +621,13 @@ function drawHops(hops, gc, mode, nodes, dx, iconSize,
dy) {
x = snapToGrid(x);
y = snapToGrid(y);
}
- fromX = Math.round(fx(x) + iconSize / 2);
- fromY = Math.round(fy(y) + iconSize / 2);
+ const c = nodeCenterScreen(fromNode, x, y, defaultLogicalIcon);
+ fromX = c.x;
+ fromY = c.y;
} else {
- fromX = Math.round(fx(fromNode.x) + iconSize / 2);
- fromY = Math.round(fy(fromNode.y) + iconSize / 2);
+ const c = nodeCenterScreen(fromNode, fromNode.x, fromNode.y,
defaultLogicalIcon);
+ fromX = c.x;
+ fromY = c.y;
}
// Calculate to coordinates (with client-side snapping when configured)
@@ -607,11 +639,13 @@ function drawHops(hops, gc, mode, nodes, dx, iconSize,
dy) {
x = snapToGrid(x);
y = snapToGrid(y);
}
- toX = Math.round(fx(x) + iconSize / 2);
- toY = Math.round(fy(y) + iconSize / 2);
+ const c = nodeCenterScreen(toNode, x, y, defaultLogicalIcon);
+ toX = c.x;
+ toY = c.y;
} else {
- toX = Math.round(fx(toNode.x) + iconSize / 2);
- toY = Math.round(fy(toNode.y) + iconSize / 2);
+ const c = nodeCenterScreen(toNode, toNode.x, toNode.y,
defaultLogicalIcon);
+ toX = c.x;
+ toY = c.y;
}
gc.moveTo(fromX, fromY);
@@ -621,10 +655,18 @@ function drawHops(hops, gc, mode, nodes, dx, iconSize,
dy) {
}
function drawNodes(nodes, mode, dx, dy, gc, iconSize) {
+ if (!nodes) {
+ return;
+ }
+ // iconSize is magnified; logical default for cards without width/height.
+ const defaultLogicalIcon = (magnification > 0) ? (iconSize /
magnification) : 32;
for (let nodeName in nodes) {
const node = nodes[nodeName];
let x = node.x;
let y = node.y;
+ const size = nodeLogicalSize(node, defaultLogicalIcon);
+ const screenW = Math.round(size.width * magnification) + 1;
+ const screenH = Math.round(size.height * magnification) + 1;
// Move selected nodes with client-side snapping
//
@@ -651,24 +693,27 @@ function drawNodes(nodes, mode, dx, dy, gc, iconSize) {
gc.lineWidth = 1;
gc.strokeStyle = nodeColor;
}
- // Use rounded screen coordinates for pixel-perfect rendering
- drawRoundRectangle(gc, Math.round(fx(x - 1)), Math.round(fy(y - 1)),
iconSize + 1, iconSize + 1, 8, 8, false);
+ // Use rounded screen coordinates for pixel-perfect rendering
(card-sized when width/height set)
+ drawRoundRectangle(gc, Math.round(fx(x - 1)), Math.round(fy(y - 1)),
screenW, screenH, 8, 8, false);
gc.strokeStyle = fgColor;
gc.lineWidth = 1;
- // Draw node name
+ // Draw node name under the card/icon
//
gc.fillStyle = fgColor;
// Calculate the font size and magnify it as well.
//
gc.fillText(nodeName,
- Math.round(fx(x) + iconSize / 2 - gc.measureText(nodeName).width /
2),
- Math.round(fy(y) + iconSize + 7));
+ Math.round(fx(x) + (size.width * magnification) / 2 -
gc.measureText(nodeName).width / 2),
+ Math.round(fy(y) + size.height * magnification + 7));
}
}
function drawNotes(notes, gc, mode, dx, dy) {
+ if (!notes) {
+ return;
+ }
notes.forEach(function (note) {
let noteX = note.x;
let noteY = note.y;
diff --git
a/rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
b/rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
index 471b853251..fac1257209 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.hopgui;
import org.apache.hop.core.gui.CanvasSvgRenderResult;
import org.apache.hop.core.gui.DPoint;
+import org.apache.hop.core.gui.Point;
import org.apache.hop.pipeline.canvas.PipelineCanvasSvgRenderer;
import org.apache.hop.workflow.canvas.WorkflowCanvasSvgRenderer;
import org.eclipse.swt.widgets.Canvas;
@@ -55,6 +56,16 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
return null;
}
+ @Override
+ void publishSnapshotInternal(
+ Canvas canvas,
+ CanvasSvgRenderResult result,
+ float magnification,
+ DPoint offset,
+ Point canvasSize) {
+ // Desktop paints on SWT canvas directly
+ }
+
@Override
String getSessionUuidInternal() {
return null;
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacade.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacade.java
index 660c20bb03..5d03089de0 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacade.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacade.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.hopgui;
import org.apache.hop.core.gui.CanvasSvgRenderResult;
import org.apache.hop.core.gui.DPoint;
+import org.apache.hop.core.gui.Point;
import org.apache.hop.pipeline.canvas.PipelineCanvasSvgRenderer;
import org.apache.hop.workflow.canvas.WorkflowCanvasSvgRenderer;
import org.eclipse.swt.widgets.Canvas;
@@ -26,6 +27,10 @@ import org.eclipse.swt.widgets.Composite;
/**
* Facade for Hop Web server-side SVG canvas rendering. Desktop (RCP) uses a
no-op implementation.
+ *
+ * <p>Pipeline and workflow graphs use {@link #renderPipeline} / {@link
#renderWorkflow}. Plugins
+ * with custom graph editors should render SVG themselves (for example with
{@code SvgGc}) and call
+ * {@link #publishSnapshot} so the existing Hop Web client overlay can display
the result.
*/
public abstract class CanvasSvgFacade {
@@ -59,6 +64,27 @@ public abstract class CanvasSvgFacade {
return IMPL.renderWorkflowInternal(canvas, context, magnification, offset);
}
+ /**
+ * Publish a pre-rendered SVG snapshot for a canvas registered via {@link
#registerCanvas}.
+ *
+ * <p>Intended for plugin graph editors on Hop Web. Pipeline/workflow graphs
continue to use
+ * {@link #renderPipeline} / {@link #renderWorkflow}, which store snapshots
the same way.
+ *
+ * @param canvas canvas registered with {@link #registerCanvas}
+ * @param result SVG XML, area owners, and optional view/graph ports
+ * @param magnification graph magnification (before native zoom factor)
+ * @param offset pan offset in graph coordinates
+ * @param canvasSize canvas widget size in pixels (may be null)
+ */
+ public static void publishSnapshot(
+ Canvas canvas,
+ CanvasSvgRenderResult result,
+ float magnification,
+ DPoint offset,
+ Point canvasSize) {
+ IMPL.publishSnapshotInternal(canvas, result, magnification, offset,
canvasSize);
+ }
+
public static String getSessionUuid() {
return IMPL.getSessionUuidInternal();
}
@@ -85,6 +111,13 @@ public abstract class CanvasSvgFacade {
abstract CanvasSvgRenderResult renderWorkflowInternal(
Canvas canvas, WorkflowCanvasSvgRenderer.Context context, float
magnification, DPoint offset);
+ abstract void publishSnapshotInternal(
+ Canvas canvas,
+ CanvasSvgRenderResult result,
+ float magnification,
+ DPoint offset,
+ Point canvasSize);
+
abstract String getSessionUuidInternal();
abstract long getRevisionInternal(String canvasId);
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 03fe189453..1f6e04f19d 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
@@ -193,6 +193,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.shared.CanvasZoomHelper;
+import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.pipeline.dialog.PipelineDialog;
import org.apache.hop.ui.util.EnvironmentUtils;
@@ -248,7 +249,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
IHasLogChannel,
ILogParentProvided,
IHopFileTypeHandler,
- IGuiRefresher {
+ IGuiRefresher,
+ IWebCanvasGraph {
private static final Class<?> PKG = HopGui.class;
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 c230175cd5..3d0d7517ad 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
@@ -150,6 +150,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.shared.CanvasZoomHelper;
+import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.apache.hop.ui.util.HelpUtils;
@@ -213,7 +214,8 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
IHasLogChannel,
ILogParentProvided,
IHopFileTypeHandler,
- IGuiRefresher {
+ IGuiRefresher,
+ IWebCanvasGraph {
private static final Class<?> PKG = HopGuiWorkflowGraph.class;
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 43d52e2b8e..5fbabddccd 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
@@ -106,6 +106,7 @@ import org.apache.hop.ui.hopgui.file.empty.EmptyFileType;
import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
+import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
import org.apache.hop.ui.hopgui.file.workflow.HopWorkflowFileType;
import org.apache.hop.ui.hopgui.perspective.HopPerspectivePlugin;
@@ -2769,6 +2770,9 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
false,
false);
if (EnvironmentUtils.getInstance().isWeb()) {
+ // openFile / link navigation often uses setActiveFileTypeHandler
without a full
+ // CTabFolder Selection cycle that paints; rebind the SVG canvas
client here.
+ notifyZoomHandlerForActiveTab();
updateWebUrlForActiveTab();
}
return;
@@ -4626,7 +4630,11 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
return;
}
final IHopFileTypeHandler activeHandler = getActiveFileTypeHandler();
- activeHandler.updateGui();
+ // Under Hop Web, CTabItem.setControl can fire focus/selection before tab
data is set
+ // (plugin file openers, first tab, etc.). Avoid NPE during that race.
+ if (activeHandler != null) {
+ activeHandler.updateGui();
+ }
}
/** Notify the zoom handler when tab is switched (for web/RAP) */
@@ -4649,9 +4657,46 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
CanvasZoomHelper.notifyCanvasReady(zoomHandler);
}
CanvasSvgHelper.notifyCanvasReady(workflowGraph.getCanvas());
+ } else if (activeHandler instanceof HopGuiAbstractGraph abstractGraph) {
+ // Plugin model graphs (Data Vault, Business Vault, dimensional, source
model, …)
+ // share the single Hop Web SVG renderer and zoom remote; re-bind on tab
switch.
+ Object zoomHandler = getModelGraphZoomHandler(abstractGraph);
+ if (zoomHandler != null) {
+ CanvasZoomHelper.notifyCanvasReady(zoomHandler);
+ }
+ CanvasSvgHelper.notifyCanvasReady(abstractGraph.getCanvas());
+ if (!abstractGraph.isDisposed()) {
+ abstractGraph.redraw();
+ }
}
}
+ /**
+ * Model graphs store the RAP zoom handler as a private/protected field (or
getter). Resolve via
+ * reflection so Explorer does not depend on hop-datavault types.
+ */
+ private static Object getModelGraphZoomHandler(HopGuiAbstractGraph graph) {
+ if (graph == null) {
+ return null;
+ }
+ try {
+ return graph.getClass().getMethod("getCanvasZoomHandler").invoke(graph);
+ } catch (ReflectiveOperationException ignored) {
+ // fall through
+ }
+ Class<?> type = graph.getClass();
+ while (type != null && type != Object.class) {
+ try {
+ var field = type.getDeclaredField("canvasZoomHandler");
+ field.setAccessible(true);
+ return field.get(graph);
+ } catch (ReflectiveOperationException ignored) {
+ type = type.getSuperclass();
+ }
+ }
+ return null;
+ }
+
/**
* 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/CanvasSvgHelper.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/CanvasSvgHelper.java
index d104f7fa54..6b478a6153 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/CanvasSvgHelper.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/CanvasSvgHelper.java
@@ -37,14 +37,24 @@ public final class CanvasSvgHelper {
.getMethod("getId", org.eclipse.swt.widgets.Widget.class)
.invoke(null, canvas);
long revision = CanvasSvgFacade.getRevision(canvasId);
- if (revision == 0) {
- canvas.getDisplay().asyncExec(canvas::redraw);
- }
Class<?> handlerClass =
Class.forName("org.apache.hop.ui.hopgui.canvas.CanvasSvgRendererHandler");
handlerClass
.getMethod("notifyCanvasReady", Canvas.class, long.class)
.invoke(null, canvas, revision);
+ // Always schedule a paint after rebinding the client to this canvas.
Without this, tab
+ // switches / link navigation can leave the previous graph's SVG overlay
visible until the
+ // user clicks (first paint publishes a snapshot and bumps revision).
+ if (!canvas.isDisposed()) {
+ canvas
+ .getDisplay()
+ .asyncExec(
+ () -> {
+ if (!canvas.isDisposed()) {
+ canvas.redraw();
+ }
+ });
+ }
} catch (ClassNotFoundException e) {
// Desktop build without RAP fragment
} catch (Exception e) {
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java
new file mode 100644
index 0000000000..9995dd31d3
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java
@@ -0,0 +1,49 @@
+/*
+ * 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.util.List;
+import org.apache.hop.core.gui.AreaOwner;
+
+/**
+ * Contract for graph editors that participate in the Hop Web SVG canvas stack.
+ *
+ * <p>Pipeline and workflow graphs implement this interface. Plugins with
custom canvas editors can
+ * implement it as well, then {@link
org.apache.hop.ui.hopgui.CanvasSvgFacade#registerCanvas} and
+ * {@link org.apache.hop.ui.hopgui.CanvasSvgFacade#publishSnapshot} to reuse
the existing client
+ * overlay without hard-coding graph types in the RAP module.
+ */
+public interface IWebCanvasGraph {
+
+ /**
+ * Replace the server-side click-map used for mouse hit testing after an SVG
render.
+ *
+ * @param owners area owners produced by the SVG renderer (may be empty, not
null preferred)
+ */
+ void replaceAreaOwners(List<AreaOwner> owners);
+
+ /**
+ * Handle a hover event from the Hop Web client (graph coordinates plus
screen coordinates).
+ *
+ * @param graphX graph X (logical canvas space)
+ * @param graphY graph Y (logical canvas space)
+ * @param screenX screen X relative to the canvas widget
+ * @param screenY screen Y relative to the canvas widget
+ */
+ void handleWebCanvasHover(int graphX, int graphY, int screenX, int screenY);
+}