Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions examples/foreign-object-magnet-js/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Two shapes with foreignObject HTML div rectangles positioned on their borders.">
<title>JointJS: ForeignObject Magnets</title>
</head>

<body id="app">
<div id="paper-container"></div>
<!-- Application files: -->
<script type="module" src="/src/main.js"></script>
</body>

</html>
17 changes: 17 additions & 0 deletions examples/foreign-object-magnet-js/package.json
Original file line number Diff line number Diff line change
@@ -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:^"
}
}
168 changes: 168 additions & 0 deletions examples/foreign-object-magnet-js/src/main.js
Original file line number Diff line number Diff line change
@@ -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);
18 changes: 18 additions & 0 deletions examples/foreign-object-magnet-js/src/styles.css
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 19 additions & 3 deletions packages/joint-core/src/connectionPoints/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 });
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 24 additions & 1 deletion packages/joint-core/src/dia/CellView.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
Geliogabalus marked this conversation as resolved.
// 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);
Expand Down
Loading
Loading