feat(dia): support HTML element bounding rect in getNodeBoundingRect#3258
feat(dia): support HTML element bounding rect in getNodeBoundingRect#3258Geliogabalus wants to merge 2 commits intoclientIO:masterfrom
Conversation
There was a problem hiding this comment.
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 useHTMLElement#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.
| const ctm = this.getRootTranslateMatrix().inverse(); | ||
| metrics.boundingRect = V.transformRect({ x: left, y: top, width, height }, ctm); |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| svgAncestor = svgAncestor.parentNode; | ||
| } | ||
| if (svgAncestor) { | ||
| metrics.boundingRect = V(svgAncestor).getBBox(); |
There was a problem hiding this comment.
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.
| 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 }; |
| 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); |
There was a problem hiding this comment.
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.
| 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); |
Summary
getNodeBoundingRectpreviously fell through toV(magnet).getBBox()for all non-measureNodecases, which does not work for HTML elements (e.g. inside a<foreignObject>)HTMLElementmagnets,getBoundingClientRect()is used and the result is converted to the cellView's local coordinate space viagetRootTranslateMatrix().inverse()HTMLElementmagnets (checkVisibility()returns false), walksparentNodeup the DOM until anSVGElementis found (e.g. the enclosing<foreignObject>) and falls back toV(svgAncestor).getBBox()—closest()cannot be used here as it does not cross the HTML/SVG namespace boundarytest/jointjs/cellView.jscovering all branches: SVG magnet,measureNodeoption, visible HTML, invisible HTML with SVG ancestor, invisible HTML without SVG ancestor, and result cachingTest plan
yarn test-client—cellView > getNodeBoundingRecttests passgetNodeBBoxreturns correct paper coordinates for an HTML magnet inside a<foreignObject>at various pan/zoom levelsdisplay:none) correctly falls back to the foreignObject bbox🤖 Generated with Claude Code