diff --git a/examples/foreign-object-magnet-js/index.html b/examples/foreign-object-magnet-js/index.html new file mode 100644 index 0000000000..93e7f05cf7 --- /dev/null +++ b/examples/foreign-object-magnet-js/index.html @@ -0,0 +1,17 @@ + + + + + + + + JointJS: ForeignObject Magnets + + + +
+ + + + + diff --git a/examples/foreign-object-magnet-js/package.json b/examples/foreign-object-magnet-js/package.json new file mode 100644 index 0000000000..3a40f77190 --- /dev/null +++ b/examples/foreign-object-magnet-js/package.json @@ -0,0 +1,17 @@ +{ + "name": "@joint/demo-foreign-object-magnet-js", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^7.3.1" + }, + "dependencies": { + "@joint/core": "workspace:^" + } +} diff --git a/examples/foreign-object-magnet-js/src/main.js b/examples/foreign-object-magnet-js/src/main.js new file mode 100644 index 0000000000..92c79e6909 --- /dev/null +++ b/examples/foreign-object-magnet-js/src/main.js @@ -0,0 +1,168 @@ +import { dia, shapes } from '@joint/core'; +import './styles.css'; + +// Shared badge style (position set per-badge below) +const BASE_BADGE_STYLE = { + position: 'absolute', + width: '84px', + height: '28px', + borderRadius: '5px', + color: '#fff', + fontSize: '11px', + fontWeight: 'bold', + fontFamily: 'sans-serif', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxShadow: '0 2px 4px rgba(0,0,0,.25)', + letterSpacing: '.5px', + textTransform: 'uppercase', + userSelect: 'none', + pointerEvents: 'none' +}; + +// CSS positions for each border edge (centered via transform) +const BADGE_POSITIONS = { + top: { top: '0', left: '50%', transform: 'translate(-50%, -50%)' }, + right: { top: '50%', right: '0', transform: 'translate(50%, -50%)' }, + bottom: { bottom: '0', left: '50%', transform: 'translate(-50%, 50%)' }, + left: { top: '50%', left: '0', transform: 'translate(-50%, -50%)' } +}; + +// Single foreignObject containing all 4 badge divs as direct children. +// Each badge div gets a `selector` which JointJS serialises as the +// `joint-selector` attribute on the DOM element. +const borderBadgeMarkup = [ + { tagName: 'rect', selector: 'body' }, + { tagName: 'text', selector: 'label' }, + { + tagName: 'foreignObject', + selector: 'badgesFO', + children: [ + { tagName: 'div', namespaceURI: 'http://www.w3.org/1999/xhtml', selector: 'topBadge' }, + { tagName: 'div', namespaceURI: 'http://www.w3.org/1999/xhtml', selector: 'rightBadge' }, + { tagName: 'div', namespaceURI: 'http://www.w3.org/1999/xhtml', selector: 'bottomBadge' }, + { tagName: 'div', namespaceURI: 'http://www.w3.org/1999/xhtml', selector: 'leftBadge' } + ] + }, +]; + +// Base attrs: foreignObject covers the full shape; badges are absolutely +// positioned relative to the foreignObject's implicit HTML viewport. +const BASE_ATTRS = { + body: { + width: 'calc(w)', + height: 'calc(h)', + rx: 10, + ry: 10, + strokeWidth: 2, + stroke: '#7b8cde', + fill: '#eef0fb' + }, + label: { + text: 'Shape', + textVerticalAnchor: 'middle', + textAnchor: 'middle', + x: 'calc(w/2)', + y: 'calc(h/2)', + fontSize: 15, + fontFamily: 'sans-serif', + fill: '#3d4a7a' + }, + badgesFO: { + x: 0, + y: 0, + width: 'calc(w)', + height: 'calc(h)', + overflow: 'visible' + }, + topBadge: { style: { ...BASE_BADGE_STYLE, ...BADGE_POSITIONS.top } }, + rightBadge: { style: { ...BASE_BADGE_STYLE, ...BADGE_POSITIONS.right } }, + bottomBadge: { style: { ...BASE_BADGE_STYLE, ...BADGE_POSITIONS.bottom } }, + leftBadge: { style: { ...BASE_BADGE_STYLE, ...BADGE_POSITIONS.left } } +}; + +const BorderBadgeElement = dia.Element.define( + 'custom.BorderBadgeElement', + { size: { width: 200, height: 120 }, attrs: BASE_ATTRS }, + { markup: borderBadgeMarkup } +); + +// --- Paper & Graph --- + +const namespace = { ...shapes, custom: { BorderBadgeElement } }; + +const graph = new dia.Graph({}, { cellNamespace: namespace }); + +const paper = new dia.Paper({ + el: document.getElementById('paper-container'), + model: graph, + width: '100%', + height: '100%', + gridSize: 20, + async: false, + sorting: dia.Paper.sorting.APPROX, + background: { color: '#F3F7F6' }, + cellViewNamespace: namespace +}); + +paper.setGrid('mesh'); + +paper.scale(1.5, 1.5); +paper.translate(100, 0); + +// --- Shape 1: Process Node --- + +const shape1 = new BorderBadgeElement({ + position: { x: 140, y: 160 }, + attrs: { + body: { stroke: '#2980b9', fill: '#ebf5fb' }, + label: { text: 'Process Node', fill: '#1a5276' }, + topBadge: { html: 'In: 3', style: { backgroundColor: '#2980b9' } }, + rightBadge: { html: 'Out: 2', style: { backgroundColor: '#27ae60' } }, + bottomBadge: { html: 'Status: OK', style: { backgroundColor: '#16a085' } }, + leftBadge: { html: 'ID: A1', style: { backgroundColor: '#8e44ad' } } + } +}); + +shape1.addTo(graph); + +shape1.rotate(30); + +// --- Shape 2: Data Store --- + +const shape2 = new BorderBadgeElement({ + position: { x: 520, y: 160 }, + attrs: { + body: { stroke: '#c0392b', fill: '#fdedec' }, + label: { text: 'Data Store', fill: '#78281f' }, + topBadge: { html: 'Read', style: { backgroundColor: '#e74c3c' } }, + rightBadge: { html: 'Write', style: { backgroundColor: '#e67e22' } }, + bottomBadge: { html: 'Cache', style: { backgroundColor: '#d35400' } }, + leftBadge: { html: 'Index', style: { backgroundColor: '#c0392b' } } + } +}); + +shape2.addTo(graph); + +// --- Link --- + +const link = new shapes.standard.Link({ + source: { + id: shape1.id, + magnet: 'rightBadge', + connectionPoint: { name: 'rectangle' } + }, + target: { + id: shape2.id, + magnet: 'leftBadge' + }, + attrs: { + line: { + stroke: '#999', + strokeWidth: 2 + } + } +}); + +link.addTo(graph); diff --git a/examples/foreign-object-magnet-js/src/styles.css b/examples/foreign-object-magnet-js/src/styles.css new file mode 100644 index 0000000000..9f42079351 --- /dev/null +++ b/examples/foreign-object-magnet-js/src/styles.css @@ -0,0 +1,18 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: sans-serif; + background-color: #F3F7F6; + width: 100%; + height: 100vh; +} + +#paper-container { + position: absolute; + inset: 0; + overflow: hidden; +} diff --git a/packages/joint-core/src/connectionPoints/index.mjs b/packages/joint-core/src/connectionPoints/index.mjs index defaf471f5..d84cd9734d 100644 --- a/packages/joint-core/src/connectionPoints/index.mjs +++ b/packages/joint-core/src/connectionPoints/index.mjs @@ -100,7 +100,11 @@ function rectangleIntersection(line, view, magnet, opt) { ? getNodeModelBBox(view, magnet, false) : view.getNodeUnrotatedBBox(magnet); if (opt.stroke) bboxWORotation.inflate(stroke(magnet) / 2); - const center = bboxWORotation.center(); + + // Use model center because rotation is applied around the center of the model. + // Currently doesn't work with rotatable group + const center = view.model.getCenter(); + const lineWORotation = line.clone().rotate(center, angle); const intersections = lineWORotation.setLength(1e6).intersect(bboxWORotation); const cp = (intersections) @@ -114,7 +118,13 @@ function getNodeModelBBox(elementView, magnet, rotate) { const portId = elementView.findAttribute('port', magnet); if (element.hasPort(portId)) { - return element.getPortBBox(portId, { rotate }); + if (rotate) { + return element.getPortBBox(portId, { rotate }); + } else { + const portBBox = new g.Rect(element.getPortRelativeRect(portId)); + portBBox.offset(element.position()); + return portBBox; + } } return element.getBBox({ rotate }); @@ -155,8 +165,14 @@ function boundaryIntersection(line, view, magnet, opt) { node = findShapeNode(magnet); } + if (node instanceof HTMLElement) { + return rectangleIntersection(line, view, node, opt); + } + if (!V.isSVGGraphicsElement(node)) { - if (node === magnet || !V.isSVGGraphicsElement(magnet)) return anchor; + if (node === magnet || !V.isSVGGraphicsElement(magnet)) { + return anchor; + } node = magnet; } diff --git a/packages/joint-core/src/dia/CellView.mjs b/packages/joint-core/src/dia/CellView.mjs index 901ac0a5d6..e623fc2c62 100644 --- a/packages/joint-core/src/dia/CellView.mjs +++ b/packages/joint-core/src/dia/CellView.mjs @@ -802,7 +802,30 @@ export const CellView = View.extend({ // Measure the node bounding box using the paper's measureNode method. metrics.boundingRect = measureNode(magnet, this); } else { - metrics.boundingRect = V(magnet).getBBox(); + if (magnet instanceof HTMLElement) { + if (magnet.checkVisibility()) { + const clientRect = new Rect(magnet.getBoundingClientRect()); + const clientCenter = clientRect.center(); + const localCenter = this.paper.clientToLocalPoint(clientCenter); + const ctm = this.getRootTranslateMatrix().multiply(this.getNodeRotateMatrix(magnet)).inverse(); + const preRotationCenter = V.transformPoint(localCenter, ctm); + // get non-rotated bounding box dimensions + const w = magnet.offsetWidth; + const h = magnet.offsetHeight; + metrics.boundingRect = new Rect( + preRotationCenter.x - w / 2, + preRotationCenter.y - h / 2, + w, + h + ); + } else { + console.warn('dia.CellView: A node is not part of the render tree — it may not be visible, or not attached to the DOM. Its bounding box cannot be measured, so anything that depends on its position will be incorrect. Ensure the node and its containing elements are visible and attached to the DOM, or provide a custom measureNode method in the paper options.'); + + metrics.boundingRect = new Rect(); + } + } else { + metrics.boundingRect = V(magnet).getBBox(); + } } } return new Rect(metrics.boundingRect); diff --git a/packages/joint-core/test/jointjs/cellView.js b/packages/joint-core/test/jointjs/cellView.js index 909a867622..0a22bc8cf0 100644 --- a/packages/joint-core/test/jointjs/cellView.js +++ b/packages/joint-core/test/jointjs/cellView.js @@ -685,4 +685,152 @@ QUnit.module('cellView', function(hooks) { }); }); }); + + QUnit.module('getNodeBoundingRect()', function(hooks) { + + hooks.beforeEach(function() { + cellView.model.resize(200, 100).position(50, 30); + fixtures.moveToViewport(); + }); + + hooks.afterEach(function() { + fixtures.moveOffscreen(); + }); + + QUnit.test('SVG magnet - returns V(magnet).getBBox()', function(assert) { + const magnet = cellView.el.querySelector('rect'); + const rect = cellView.getNodeBoundingRect(magnet); + assert.ok(rect instanceof g.Rect); + const expected = V(magnet).getBBox(); + assert.equal(rect.x, expected.x); + assert.equal(rect.y, expected.y); + assert.equal(rect.width, expected.width); + assert.equal(rect.height, expected.height); + }); + + QUnit.test('measureNode option - delegates to the function', function(assert) { + const magnet = cellView.el.querySelector('rect'); + const expected = { x: 5, y: 10, width: 50, height: 60 }; + paper.options.measureNode = sinon.stub().returns(expected); + const rect = cellView.getNodeBoundingRect(magnet); + assert.ok(paper.options.measureNode.calledOnceWith(magnet, cellView)); + assert.equal(rect.x, expected.x); + assert.equal(rect.y, expected.y); + assert.equal(rect.width, expected.width); + assert.equal(rect.height, expected.height); + delete paper.options.measureNode; + }); + + QUnit.module('HTML element', function(hooks) { + + var foCell; + var foView; + var htmlMagnet; + + hooks.beforeEach(function() { + foCell = new joint.dia.Element({ + type: 'foElement', + position: { x: 50, y: 30 }, + size: { width: 100, height: 80 }, + markup: joint.util.svg` + +
+
+ ` + }); + paper.model.addCell(foCell); + foView = paper.findViewByModel(foCell); + htmlMagnet = foView.el.querySelector('div'); + }); + + hooks.afterEach(function() { + foCell.remove(); + }); + + QUnit.test('visible, unrotated - uses BCR center and offsetWidth/offsetHeight', function(assert) { + sinon.stub(htmlMagnet, 'checkVisibility').returns(true); + // BCR: element at client (50,30), size 100×80, center = (100,70) + sinon.stub(htmlMagnet, 'getBoundingClientRect').returns({ + x: 50, y: 30, width: 100, height: 80 + }); + sinon.stub(htmlMagnet, 'offsetWidth').get(() => 100); + sinon.stub(htmlMagnet, 'offsetHeight').get(() => 80); + // paper at origin with identity CTM + sinon.stub(foView.paper, 'clientToLocalPoint').callsFake(p => new g.Point(p)); + + const rect = foView.getNodeBoundingRect(htmlMagnet); + + assert.equal(rect.x, 0); + assert.equal(rect.y, 0); + assert.equal(rect.width, 100); + assert.equal(rect.height, 80); + + foView.paper.clientToLocalPoint.restore(); + }); + + QUnit.test('visible, rotated - dimensions use offsetWidth/offsetHeight, not inflated BCR', function(assert) { + foCell.set('angle', 90); + sinon.stub(htmlMagnet, 'checkVisibility').returns(true); + // BCR for 90°-rotated 100×80 element: AABB is 80×100 (swapped), same center (100,70) + sinon.stub(htmlMagnet, 'getBoundingClientRect').returns({ + x: 60, y: 20, width: 80, height: 100 + }); + sinon.stub(htmlMagnet, 'offsetWidth').get(() => 100); + sinon.stub(htmlMagnet, 'offsetHeight').get(() => 80); + sinon.stub(foView.paper, 'clientToLocalPoint').callsFake(p => new g.Point(p)); + + const rect = foView.getNodeBoundingRect(htmlMagnet); + + // BCR center (100,70) → localCenter (100,70) → undo T(50,30)×R(90°) → preCenter (50,40) + // rect = { x:0, y:0, w:100, h:80 } — dimensions are offsetWidth/offsetHeight, not 80×100 + assert.equal(rect.x, 0); + assert.equal(rect.y, 0); + assert.equal(rect.width, 100); + assert.equal(rect.height, 80); + + foView.paper.clientToLocalPoint.restore(); + }); + + QUnit.test('invisible - returns a zero rect and produces a warning', function(assert) { + sinon.stub(htmlMagnet, 'checkVisibility').returns(false); + const warnStub = sinon.stub(console, 'warn'); + const rect = foView.getNodeBoundingRect(htmlMagnet); + assert.ok(rect instanceof g.Rect); + assert.equal(rect.x, 0); + assert.equal(rect.y, 0); + assert.equal(rect.width, 0); + assert.equal(rect.height, 0); + assert.ok(warnStub.calledOnce); + warnStub.restore(); + }); + + QUnit.test('invisible - produces a warning regardless of DOM structure', function(assert) { + const standaloneDiv = document.createElement('div'); + sinon.stub(standaloneDiv, 'checkVisibility').returns(false); + const warnStub = sinon.stub(console, 'warn'); + const rect = foView.getNodeBoundingRect(standaloneDiv); + assert.ok(rect instanceof g.Rect); + assert.equal(rect.width, 0); + assert.equal(rect.height, 0); + assert.ok(warnStub.calledOnce); + warnStub.restore(); + }); + + QUnit.test('result is cached after the first call', function(assert) { + sinon.stub(htmlMagnet, 'checkVisibility').returns(true); + const getBCRStub = sinon.stub(htmlMagnet, 'getBoundingClientRect').returns({ + x: 50, y: 30, width: 100, height: 80 + }); + sinon.stub(htmlMagnet, 'offsetWidth').get(() => 100); + sinon.stub(htmlMagnet, 'offsetHeight').get(() => 80); + sinon.stub(foView.paper, 'clientToLocalPoint').callsFake(p => new g.Point(p)); + + foView.getNodeBoundingRect(htmlMagnet); + foView.getNodeBoundingRect(htmlMagnet); + assert.equal(getBCRStub.callCount, 1); + + foView.paper.clientToLocalPoint.restore(); + }); + }); + }); }); diff --git a/packages/joint-core/test/jointjs/connectionPoints.js b/packages/joint-core/test/jointjs/connectionPoints.js index 88ec5c6471..e212cceb86 100644 --- a/packages/joint-core/test/jointjs/connectionPoints.js +++ b/packages/joint-core/test/jointjs/connectionPoints.js @@ -359,17 +359,61 @@ QUnit.module('connectionPoints', function(hooks) { const r1BBoxWR = r1.getBBox({ rotate: true }); line = new g.Line(r1BBoxWR.bottomMiddle().offset(0, 1000), r1BBoxWR.bottomMiddle()); - cp = connectionPointFn.call(lv1, line, rv1, portNode, { useModelGeometry: true }); - assert.ok(cp.equals(r1.getPortBBox('p1', { rotate: true }).bottomMiddle())); + cp = connectionPointFn.call(lv1, line, rv1, portNode, { useModelGeometry: true }).round(); + assert.ok(cp.equals(r1.getPortBBox('p1', { rotate: true }).bottomMiddle().round())); line = new g.Line(r1BBoxWR.bottomMiddle().offset(1000, 0), r1BBoxWR.bottomMiddle()); - cp = connectionPointFn.call(lv1, line, rv1, portNode, { useModelGeometry: true }); - assert.ok(cp.equals(r1.getPortBBox('p1', { rotate: true }).rightMiddle())); + cp = connectionPointFn.call(lv1, line, rv1, portNode, { useModelGeometry: true }).round(); + assert.ok(cp.equals(r1.getPortBBox('p1', { rotate: true }).rightMiddle().round())); }); }); }); }); + QUnit.module('rectangle - HTML element magnet', function(hooks) { + + var connectionPointFn = joint.connectionPoints.rectangle; + var foElement, foView, htmlMagnet; + + hooks.beforeEach(function() { + foElement = new joint.dia.Element({ + type: 'foTest', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + markup: joint.util.svg` + +
+
+ ` + }); + graph.addCell(foElement); + foView = foElement.findView(paper); + htmlMagnet = foView.el.querySelector('div'); + }); + + hooks.afterEach(function() { + foElement.remove(); + }); + + QUnit.test('unrotated element - connects at the correct boundary point', function(assert) { + // Stub bbox measurement so the test doesn't rely on real DOM layout. + sinon.stub(foView, 'getNodeBBox').returns(new g.Rect(0, 0, 60, 40)); + var line = new g.Line(new g.Point(200, 25), new g.Point(50, 25)); + var cp = connectionPointFn.call(lv1, line, foView, htmlMagnet, {}); + assert.ok(cp.round().equals(new g.Point(60, 25))); + foView.getNodeBBox.restore(); + }); + + QUnit.test('rotated element - uses model center as pivot, not magnet bbox center', function(assert) { + foElement.rotate(90); + sinon.stub(foView, 'getNodeUnrotatedBBox').returns(new g.Rect(0, 0, 60, 40)); + var line = new g.Line(new g.Point(80, 200), new g.Point(80, 10)); + var cp = connectionPointFn.call(lv1, line, foView, htmlMagnet, {}); + assert.ok(cp.round().equals(new g.Point(80, 60)), 'connection point is on the actual magnet boundary'); + foView.getNodeUnrotatedBBox.restore(); + }); + }); + QUnit.module('boundary', function() { QUnit.test('sanity', function(assert) { @@ -448,5 +492,52 @@ QUnit.module('connectionPoints', function(hooks) { }); + + QUnit.module('HTML element magnet', function(hooks) { + + var foElement, foView, htmlMagnet; + + hooks.beforeEach(function() { + foElement = new joint.dia.Element({ + type: 'foTest', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + markup: joint.util.svg` + +
+
+ ` + }); + graph.addCell(foElement); + foView = foElement.findView(paper); + htmlMagnet = foView.el.querySelector('div'); + }); + + hooks.afterEach(function() { + foElement.remove(); + }); + + QUnit.test('connects at boundary, not at raw anchor', function(assert) { + // boundaryIntersection used to return line.end (the anchor inside the element) + // for non-SVG magnets. It should now delegate to rectangleIntersection. + var connectionPointFn = joint.connectionPoints.boundary; + sinon.stub(foView, 'getNodeBBox').returns(new g.Rect(0, 0, 60, 40)); + var line = new g.Line(new g.Point(200, 25), new g.Point(50, 25)); + var cp = connectionPointFn.call(lv1, line, foView, htmlMagnet, {}); + assert.ok(cp.round().equals(new g.Point(60, 25))); + foView.getNodeBBox.restore(); + }); + + QUnit.test('rotated element - boundary connects at correct side', function(assert) { + var connectionPointFn = joint.connectionPoints.boundary; + foElement.rotate(45); + sinon.stub(foView, 'getNodeBBox').returns(new g.Rect(0, 0, 60, 40)); + // Line coming from above (lower y) should hit the top after rotation. + var line = new g.Line(new g.Point(60, 200), new g.Point(60, 10)); + var cp = connectionPointFn.call(lv1, line, foView, htmlMagnet, {}); + assert.ok(cp.round().equals(new g.Point(60, 46)), 'connection point is on the actual bbox boundary'); + foView.getNodeBBox.restore(); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 93daebec62..5217fae44d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4394,6 +4394,15 @@ __metadata: languageName: unknown linkType: soft +"@joint/demo-foreign-object-magnet-js@workspace:examples/foreign-object-magnet-js": + version: 0.0.0-use.local + resolution: "@joint/demo-foreign-object-magnet-js@workspace:examples/foreign-object-magnet-js" + dependencies: + "@joint/core": "workspace:^" + vite: "npm:^7.3.1" + languageName: unknown + linkType: soft + "@joint/demo-fta-js@workspace:examples/fta-js": version: 0.0.0-use.local resolution: "@joint/demo-fta-js@workspace:examples/fta-js"