Skip to content

feat(dia): support HTML element bounding rect in getNodeBoundingRect#3258

Open
Geliogabalus wants to merge 2 commits intoclientIO:masterfrom
Geliogabalus:html-node-bbox
Open

feat(dia): support HTML element bounding rect in getNodeBoundingRect#3258
Geliogabalus wants to merge 2 commits intoclientIO:masterfrom
Geliogabalus:html-node-bbox

Conversation

@Geliogabalus
Copy link
Copy Markdown
Contributor

@Geliogabalus Geliogabalus commented Mar 31, 2026

Summary

  • getNodeBoundingRect previously fell through to V(magnet).getBBox() for all non-measureNode cases, which does not work for HTML elements (e.g. inside a <foreignObject>)
  • For visible HTMLElement magnets, getBoundingClientRect() is used and the result is converted to the cellView's local coordinate space via getRootTranslateMatrix().inverse()
  • For invisible HTMLElement magnets (checkVisibility() returns false), walks parentNode up the DOM until an SVGElement is found (e.g. the enclosing <foreignObject>) and falls back to V(svgAncestor).getBBox()closest() cannot be used here as it does not cross the HTML/SVG namespace boundary
  • If no SVG ancestor exists, an empty rect is returned
  • Tests added to test/jointjs/cellView.js covering all branches: SVG magnet, measureNode option, visible HTML, invisible HTML with SVG ancestor, invisible HTML without SVG ancestor, and result caching

Test plan

  • yarn test-clientcellView > getNodeBoundingRect tests pass
  • Verify getNodeBBox returns correct paper coordinates for an HTML magnet inside a <foreignObject> at various pan/zoom levels
  • Verify invisible HTML magnet (e.g. display:none) correctly falls back to the foreignObject bbox
  • Verify SVG magnet behaviour is unchanged

🤖 Generated with Claude Code

@Geliogabalus Geliogabalus marked this pull request as draft March 31, 2026 12:57
@Geliogabalus Geliogabalus marked this pull request as ready for review April 1, 2026 12:37
@Geliogabalus Geliogabalus requested a review from Copilot April 1, 2026 12:37
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for measuring HTML magnets (e.g. inside <foreignObject>) in CellView#getNodeBoundingRect() so bounding rectangles can be computed for non-SVG nodes.

Changes:

  • Updated getNodeBoundingRect() to use HTMLElement#getBoundingClientRect() for visible HTML magnets and fall back to an SVG ancestor bbox for invisible ones.
  • Added QUnit tests covering SVG magnets, measureNode, visible/invisible HTML magnets, and caching behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/joint-core/src/dia/CellView.mjs Implements new HTML-element measurement logic in getNodeBoundingRect().
packages/joint-core/test/jointjs/cellView.js Adds test coverage for the new HTML measurement branches and caching.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +819 to +820
const ctm = this.getRootTranslateMatrix().inverse();
metrics.boundingRect = V.transformRect({ x: left, y: top, width, height }, ctm);
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For visible HTML magnets, this converts getBoundingClientRect() (client/screen space, affected by paper pan/zoom and element rotation) using only getRootTranslateMatrix().inverse() (model translation only). This will return incorrect results when the paper viewport is transformed (pan/zoom) and/or when the cell is rotated (rotation is applied later again in getNodeBBox(), causing a double-rotation effect). Consider converting the client rect to paper local coordinates via paper.clientToLocalRect(...) (or equivalent) and then transforming into the view’s local coordinates using the inverse of the same matrix used in getNodeBBox() (getRootTranslateMatrix() * getNodeRotateMatrix(magnet)), instead of only inverting the translation.

Suggested change
const ctm = this.getRootTranslateMatrix().inverse();
metrics.boundingRect = V.transformRect({ x: left, y: top, width, height }, ctm);
const clientRect = { x: left, y: top, width, height };
const paper = this.paper;
if (paper && typeof paper.clientToLocalRect === 'function') {
// Convert the client-space rect into paper-local coordinates (accounting for pan/zoom).
const paperRect = paper.clientToLocalRect(clientRect);
// Transform the paper-local rect into the view's local coordinates using
// the same composite matrix as used in getNodeBBox():
// localToPaper = getRootTranslateMatrix() * getNodeRotateMatrix(magnet)
const rootMatrix = this.getRootTranslateMatrix();
const rotateMatrix = (typeof this.getNodeRotateMatrix === 'function')
? this.getNodeRotateMatrix(magnet)
: V.createSVGMatrix();
const localToPaperMatrix = rootMatrix.multiply(rotateMatrix);
const paperToLocalMatrix = localToPaperMatrix.inverse();
metrics.boundingRect = V.transformRect(paperRect, paperToLocalMatrix);
} else {
// Fallback: only account for root translation (legacy behavior).
const ctm = this.getRootTranslateMatrix().inverse();
metrics.boundingRect = V.transformRect(clientRect, ctm);
}

Copilot uses AI. Check for mistakes.
svgAncestor = svgAncestor.parentNode;
}
if (svgAncestor) {
metrics.boundingRect = V(svgAncestor).getBBox();
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the !magnet.checkVisibility() branch, when no SVG ancestor is found, metrics.boundingRect is left as undefined. While new Rect(undefined) returns an empty rect, it also means this path is never cached and will repeat the DOM walk on every call. Set metrics.boundingRect to an explicit empty rect (e.g. { x: 0, y: 0, width: 0, height: 0 }) when there is no SVG ancestor so the result is cached and the behavior matches the comment/PR description.

Suggested change
metrics.boundingRect = V(svgAncestor).getBBox();
metrics.boundingRect = V(svgAncestor).getBBox();
} else {
// No SVG ancestor found; cache an explicit empty bounding rect.
metrics.boundingRect = { x: 0, y: 0, width: 0, height: 0 };

Copilot uses AI. Check for mistakes.
Comment on lines +755 to +759
const rect = foView.getNodeBoundingRect(htmlMagnet);
assert.equal(rect.x, 160 - 50);
assert.equal(rect.y, 130 - 30);
assert.equal(rect.width, 100);
assert.equal(rect.height, 80);
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test hard-codes rect.x = left - 50 / rect.y = top - 30, which matches the current implementation but does not account for the paper’s viewport transform (offset, pan/zoom) or rotation. If getNodeBoundingRect() is updated to correctly convert from client coordinates (e.g. via paper.clientToLocalRect() and inverse view transforms), this expectation will become incorrect and/or environment-dependent. Consider computing the expected rect using the same coordinate conversion utilities used by the implementation (paper client-to-local + inverse root transforms) so the test remains robust across viewport transforms.

Suggested change
const rect = foView.getNodeBoundingRect(htmlMagnet);
assert.equal(rect.x, 160 - 50);
assert.equal(rect.y, 130 - 30);
assert.equal(rect.width, 100);
assert.equal(rect.height, 80);
// Compute the expected rect using the same kind of client-to-local
// transformation that the implementation is expected to use.
const clientRect = { x: 160, y: 130, width: 100, height: 80 };
const expected = (paper.clientToLocalRect)
? paper.clientToLocalRect(clientRect)
: new g.Rect({
x: 160 - 50,
y: 130 - 30,
width: 100,
height: 80
});
const rect = foView.getNodeBoundingRect(htmlMagnet);
assert.equal(rect.x, expected.x);
assert.equal(rect.y, expected.y);
assert.equal(rect.width, expected.width);
assert.equal(rect.height, expected.height);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants