From a175e0d1a0254fc5dd9b230fd26c01d9f6820e45 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Wed, 30 Sep 2026 20:33:48 +0200 Subject: [PATCH 1/5] dia.Link: add getComputedLabel()/getComputedLabels() label()/labels() always returned a label exactly as stored, leaving every caller to separately re-resolve it against defaultLabel/the built-in default (LinkView duplicated this merge logic in several places: rendering, label dragging, RotateLabel). getComputedLabel()/getComputedLabels() centralize that resolution in link-labels.mjs and expose it directly, and LinkView now calls them instead of re-implementing the merge inline. A label (or defaultLabel) may also carry custom properties beyond markup/ attrs/size/position - these pass through resolution unmodified, with the label's own value winning over defaultLabel's. --- .changeset/brave-labels-resolve.md | 5 + packages/joint-core/src/dia/Link.mjs | 34 +++- packages/joint-core/src/dia/LinkView.mjs | 176 +++--------------- packages/joint-core/src/dia/link-labels.mjs | 98 ++++++++++ .../joint-core/src/linkTools/RotateLabel.mjs | 8 +- packages/joint-core/test/jointjs/links.js | 54 +++++- packages/joint-core/types/dia.d.ts | 8 + 7 files changed, 213 insertions(+), 170 deletions(-) create mode 100644 .changeset/brave-labels-resolve.md create mode 100644 packages/joint-core/src/dia/link-labels.mjs diff --git a/.changeset/brave-labels-resolve.md b/.changeset/brave-labels-resolve.md new file mode 100644 index 0000000000..8ec6a7999e --- /dev/null +++ b/.changeset/brave-labels-resolve.md @@ -0,0 +1,5 @@ +--- +"@joint/core": minor +--- + +dia.Link - add `getComputedLabel()`/`getComputedLabels()`, resolved against `defaultLabel` diff --git a/packages/joint-core/src/dia/Link.mjs b/packages/joint-core/src/dia/Link.mjs index 8071f85ad9..6559c8dade 100644 --- a/packages/joint-core/src/dia/Link.mjs +++ b/packages/joint-core/src/dia/Link.mjs @@ -1,6 +1,7 @@ import { Cell } from './Cell.mjs'; -import { clone, isPlainObject, isFunction, isString, isNumber } from '../util/index.mjs'; +import { clone, isPlainObject, isFunction, isString, isNumber, assign } from '../util/index.mjs'; import { Point, Polyline } from '../g/index.mjs'; +import { getResolvedLabel } from './link-labels.mjs'; // Link base model. // -------------------------- @@ -216,6 +217,20 @@ export const Link = Cell.extend({ return this.prop(['labels', idx], label, opt); }, + // Like `label()`'s getter, but resolved against `defaultLabel`/the built-in default + // (see `getResolvedLabel` in `link-labels.mjs`) - `label()` itself always returns the + // label exactly as stored (or `undefined` if there isn't one at that index). + getComputedLabel: function(idx) { + + var labels = this.labels(); + + idx = (isFinite(idx) && idx !== null) ? (idx | 0) : 0; + if (idx < 0) idx = labels.length + idx; + + var label = labels[idx]; + return (label === undefined) ? undefined : getResolvedLabel(this, label); + }, + labels: function(labels, opt) { // getter @@ -229,6 +244,14 @@ export const Link = Cell.extend({ return this.set('labels', labels, opt); }, + // Like `labels()`'s getter, but with every label resolved against `defaultLabel`/the + // built-in default (see `getResolvedLabel` in `link-labels.mjs`) - `labels()` itself + // always returns each label exactly as stored. + getComputedLabels: function() { + + return this.labels().map((label) => getResolvedLabel(this, label)); + }, + hasLabels: function() { const { labels } = this.attributes; return Array.isArray(labels) && labels.length > 0; @@ -541,16 +564,15 @@ export const Link = Cell.extend({ return !!ancestor && (ancestor.id === cellId || ancestor.isEmbeddedIn(cellId)); }, - // Get resolved default label. + // Get resolved default label. Kept as-is (including any custom property, e.g. a + // `@joint/layout-elk` `elkLayoutOptions`) - not just the known `markup`/`attrs`/ + // `size`/`position` - so `getResolvedLabel` (`link-labels.mjs`) can pass it through too. _getDefaultLabel: function() { var defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; - var label = {}; + var label = assign({}, defaultLabel); label.markup = defaultLabel.markup || this.get('labelMarkup') || this.labelMarkup; - label.position = defaultLabel.position; - label.attrs = defaultLabel.attrs; - label.size = defaultLabel.size; return label; } diff --git a/packages/joint-core/src/dia/LinkView.mjs b/packages/joint-core/src/dia/LinkView.mjs index 05c2a57a94..013513e3ed 100644 --- a/packages/joint-core/src/dia/LinkView.mjs +++ b/packages/joint-core/src/dia/LinkView.mjs @@ -331,7 +331,9 @@ export const LinkView = CellView.extend({ var labelCache = this._labelCache = {}; var labelSelectors = this._labelSelectors = {}; var model = this.model; - var labels = model.attributes.labels || []; + // Resolved (see `Link#getComputedLabels`) - `label.markup` is always set (falls + // back all the way to the built-in default), so there's no need to resolve it again here. + var labels = model.getComputedLabels(); var labelsCount = labels.length; if (labelsCount === 0) { @@ -355,24 +357,8 @@ export const LinkView = CellView.extend({ var label = labels[i]; var labelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(label.markup)); - var labelNode; - var selectors; - if (labelMarkup) { - - labelNode = labelMarkup.node; - selectors = labelMarkup.selectors; - - } else { - - var builtinDefaultLabel = model._builtins.defaultLabel; - var builtinDefaultLabelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(builtinDefaultLabel.markup)); - var defaultLabel = model._getDefaultLabel(); - var defaultLabelMarkup = this._normalizeLabelMarkup(this._getLabelMarkup(defaultLabel.markup)); - var defaultMarkup = defaultLabelMarkup || builtinDefaultLabelMarkup; - - labelNode = defaultMarkup.node; - selectors = defaultMarkup.selectors; - } + var labelNode = labelMarkup.node; + var selectors = labelMarkup.selectors; labelNode.setAttribute('label-idx', i); // assign label-idx vLabels.append(labelNode); @@ -428,44 +414,6 @@ export const LinkView = CellView.extend({ return node; }, - // merge default label attrs into label attrs (or use built-in default label attrs if neither is provided) - // keep `undefined` or `null` because `{}` means something else - _mergeLabelAttrs: function(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtinDefaultLabelAttrs) { - - if (labelAttrs === null) return null; - if (labelAttrs === undefined) { - - if (defaultLabelAttrs === null) return null; - if (defaultLabelAttrs === undefined) { - - if (hasCustomMarkup) return undefined; - return builtinDefaultLabelAttrs; - } - - if (hasCustomMarkup) return defaultLabelAttrs; - return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); - } - - if (hasCustomMarkup) return merge({}, defaultLabelAttrs, labelAttrs); - return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); - }, - - // merge default label size into label size (no built-in default) - // keep `undefined` or `null` because `{}` means something else - _mergeLabelSize: function(labelSize, defaultLabelSize) { - - if (labelSize === null) return null; - if (labelSize === undefined) { - - if (defaultLabelSize === null) return null; - if (defaultLabelSize === undefined) return undefined; - - return defaultLabelSize; - } - - return merge({}, defaultLabelSize, labelSize); - }, - updateLabels: function() { if (!this._V.labels) return this; @@ -477,44 +425,21 @@ export const LinkView = CellView.extend({ this.cleanNodeCache(this.el); } - var model = this.model; - var labels = model.get('labels') || []; + // Resolved (see `Link#getComputedLabels`) - `attrs`/`size` are already merged with + // `defaultLabel`/the built-in default. + var labels = this.model.getComputedLabels(); var canLabelMove = this.can('labelMove'); - var builtinDefaultLabel = model._builtins.defaultLabel; - var builtinDefaultLabelAttrs = builtinDefaultLabel.attrs; - - var defaultLabel = model._getDefaultLabel(); - var defaultLabelMarkup = defaultLabel.markup; - var defaultLabelAttrs = defaultLabel.attrs; - var defaultLabelSize = defaultLabel.size; - for (var i = 0, n = labels.length; i < n; i++) { var labelNode = this._labelCache[i]; labelNode.setAttribute('cursor', (canLabelMove ? 'move' : 'default')); var selectors = this._labelSelectors[i]; - var label = labels[i]; - var labelMarkup = label.markup; - var labelAttrs = label.attrs; - var labelSize = label.size; - - var attrs = this._mergeLabelAttrs( - (labelMarkup || defaultLabelMarkup), - labelAttrs, - defaultLabelAttrs, - builtinDefaultLabelAttrs - ); - var size = this._mergeLabelSize( - labelSize, - defaultLabelSize - ); - - this.updateDOMSubtreeAttributes(labelNode, attrs, { - rootBBox: new Rect(size), + this.updateDOMSubtreeAttributes(labelNode, label.attrs, { + rootBBox: new Rect(label.size), selectors: selectors }); } @@ -860,42 +785,6 @@ export const LinkView = CellView.extend({ return this.isIntersecting(area); }, - // combine default label position with built-in default label position - _getDefaultLabelPositionProperty: function() { - - var model = this.model; - - var builtinDefaultLabel = model._builtins.defaultLabel; - var builtinDefaultLabelPosition = builtinDefaultLabel.position; - - var defaultLabel = model._getDefaultLabel(); - var defaultLabelPosition = this._normalizeLabelPosition(defaultLabel.position); - - return merge({}, builtinDefaultLabelPosition, defaultLabelPosition); - }, - - // if label position is a number, normalize it to a position object - // this makes sure that label positions can be merged properly - _normalizeLabelPosition: function(labelPosition) { - - if (typeof labelPosition === 'number') return { distance: labelPosition, offset: null, angle: 0, args: null }; - return labelPosition; - }, - - // expects normalized position properties - // e.g. `this._normalizeLabelPosition(labelPosition)` and `this._getDefaultLabelPositionProperty()` - _mergeLabelPositionProperty: function(normalizedLabelPosition, normalizedDefaultLabelPosition) { - - if (normalizedLabelPosition === null) return null; - if (normalizedLabelPosition === undefined) { - - if (normalizedDefaultLabelPosition === null) return null; - return normalizedDefaultLabelPosition; - } - - return merge({}, normalizedDefaultLabelPosition, normalizedLabelPosition); - }, - updateLabelPositions: function() { if (!this._V.labels) return this; @@ -906,18 +795,15 @@ export const LinkView = CellView.extend({ // This method assumes all the label nodes are stored in the `this._labelCache` hash table // by their indices in the `this.get('labels')` array. This is done in the `renderLabels()` method. - var model = this.model; - var labels = model.get('labels') || []; + // Resolved (see `Link#getComputedLabels`) - `position` is already merged with + // `defaultLabel`/the built-in default. + var labels = this.model.getComputedLabels(); if (!labels.length) return this; - var defaultLabelPosition = this._getDefaultLabelPositionProperty(); - for (var idx = 0, n = labels.length; idx < n; idx++) { var labelNode = this._labelCache[idx]; if (!labelNode) continue; - var label = labels[idx]; - var labelPosition = this._normalizeLabelPosition(label.position); - var position = this._mergeLabelPositionProperty(labelPosition, defaultLabelPosition); + var position = labels[idx].position; var transformationMatrix = this._getLabelTransformationMatrix(position); labelNode.setAttribute('transform', V.matrixToTransformString(transformationMatrix)); this._cleanLabelMatrices(idx); @@ -947,23 +833,8 @@ export const LinkView = CellView.extend({ } }, - _getLabelPositionProperty: function(idx) { - - return (this.model.label(idx).position || {}); - }, - - _getLabelPositionAngle: function(idx) { - - var labelPosition = this._getLabelPositionProperty(idx); - return (labelPosition.angle || 0); - }, - - _getLabelPositionArgs: function(idx) { - - var labelPosition = this._getLabelPositionProperty(idx); - return labelPosition.args; - }, - + // Used by `addLabel` below, for a not-yet-existing label - unlike `getResolvedLabel` + // (`link-labels.mjs`), does not need the built-in default (which never has `position.args`). _getDefaultLabelPositionArgs: function() { var defaultLabel = this.model._getDefaultLabel(); @@ -1602,26 +1473,21 @@ export const LinkView = CellView.extend({ var labelNode = evt.currentTarget; var labelIdx = parseInt(labelNode.getAttribute('label-idx'), 10); - var defaultLabelPosition = this._getDefaultLabelPositionProperty(); - var initialLabelPosition = this._normalizeLabelPosition(this._getLabelPositionProperty(labelIdx)); - var position = this._mergeLabelPositionProperty(initialLabelPosition, defaultLabelPosition); + // Resolved (see `Link#getComputedLabel`) - already merged with `defaultLabel`/ + // the built-in default. + var position = this.model.getComputedLabel(labelIdx).position || {}; var coords = this.getLabelCoordinates(position); var dx = coords.x - x; // how much needs to be added to cursor x to get to label x var dy = coords.y - y; // how much needs to be added to cursor y to get to label y - var positionAngle = this._getLabelPositionAngle(labelIdx); - var labelPositionArgs = this._getLabelPositionArgs(labelIdx); - var defaultLabelPositionArgs = this._getDefaultLabelPositionArgs(); - var positionArgs = this._mergeLabelPositionArgs(labelPositionArgs, defaultLabelPositionArgs); - this.eventData(evt, { action: 'label-move', labelIdx: labelIdx, dx: dx, dy: dy, - positionAngle: positionAngle, - positionArgs: positionArgs, + positionAngle: position.angle || 0, + positionArgs: position.args, stopPropagation: true }); diff --git a/packages/joint-core/src/dia/link-labels.mjs b/packages/joint-core/src/dia/link-labels.mjs new file mode 100644 index 0000000000..0115aa04fe --- /dev/null +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -0,0 +1,98 @@ +import { merge } from '../util/index.mjs'; + +// A label as given (own `markup`/`attrs`/`size`/`position`, any of which may be missing), +// resolved against `link`'s `defaultLabel` and its built-in default - the same resolution +// `LinkView` used to do at render time. Passing `{}` resolves to the pure default. Any +// other (custom) property on the label or `defaultLabel` passes through unresolved - the +// label's own value wins over `defaultLabel`'s. +export function getResolvedLabel(link, label) { + + label = label || {}; + + const builtinDefaultLabel = link._builtins.defaultLabel; + const defaultLabel = link._getDefaultLabel(); + + // A label's own or `defaultLabel`'s markup, if either is set, is "custom" - the + // built-in default attrs (`builtinDefaultLabelAttrs`) only make sense for the + // built-in markup, so they don't apply once a custom one is in play. + const hasCustomMarkup = !!(label.markup || defaultLabel.markup); + + return Object.assign({}, defaultLabel, label, { + markup: label.markup || defaultLabel.markup || builtinDefaultLabel.markup, + attrs: mergeLabelAttrs(hasCustomMarkup, label.attrs, defaultLabel.attrs, builtinDefaultLabel.attrs), + size: mergeLabelSize(label.size, defaultLabel.size), + position: mergeLabelPositionProperty( + normalizeLabelPosition(label.position), + getDefaultLabelPositionProperty(link, defaultLabel) + ) + }); +} + +// merge default label attrs into label attrs (or use built-in default label attrs if neither is provided) +// keep `undefined` or `null` because `{}` means something else +function mergeLabelAttrs(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtinDefaultLabelAttrs) { + + if (labelAttrs === null) return null; + if (labelAttrs === undefined) { + + if (defaultLabelAttrs === null) return null; + if (defaultLabelAttrs === undefined) { + + if (hasCustomMarkup) return undefined; + return builtinDefaultLabelAttrs; + } + + if (hasCustomMarkup) return defaultLabelAttrs; + return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); + } + + if (hasCustomMarkup) return merge({}, defaultLabelAttrs, labelAttrs); + return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); +} + +// merge default label size into label size (no built-in default) +// keep `undefined` or `null` because `{}` means something else +function mergeLabelSize(labelSize, defaultLabelSize) { + + if (labelSize === null) return null; + if (labelSize === undefined) { + + if (defaultLabelSize === null) return null; + if (defaultLabelSize === undefined) return undefined; + + return defaultLabelSize; + } + + return merge({}, defaultLabelSize, labelSize); +} + +// combine default label position with built-in default label position +function getDefaultLabelPositionProperty(link, defaultLabel) { + + const builtinDefaultLabelPosition = link._builtins.defaultLabel.position; + const defaultLabelPosition = normalizeLabelPosition(defaultLabel.position); + + return merge({}, builtinDefaultLabelPosition, defaultLabelPosition); +} + +// if label position is a number, normalize it to a position object +// this makes sure that label positions can be merged properly +function normalizeLabelPosition(labelPosition) { + + if (typeof labelPosition === 'number') return { distance: labelPosition, offset: null, angle: 0, args: null }; + return labelPosition; +} + +// expects normalized position properties +// e.g. `normalizeLabelPosition(labelPosition)` and `getDefaultLabelPositionProperty(link, defaultLabel)` +function mergeLabelPositionProperty(normalizedLabelPosition, normalizedDefaultLabelPosition) { + + if (normalizedLabelPosition === null) return null; + if (normalizedLabelPosition === undefined) { + + if (normalizedDefaultLabelPosition === null) return null; + return normalizedDefaultLabelPosition; + } + + return merge({}, normalizedDefaultLabelPosition, normalizedLabelPosition); +} diff --git a/packages/joint-core/src/linkTools/RotateLabel.mjs b/packages/joint-core/src/linkTools/RotateLabel.mjs index d8c2f23bd0..77f9813ba0 100644 --- a/packages/joint-core/src/linkTools/RotateLabel.mjs +++ b/packages/joint-core/src/linkTools/RotateLabel.mjs @@ -99,13 +99,13 @@ export const RotateLabel = Control.extend({ }, getLabel() { - return this.relatedView.model.label(this.getLabelIndex()) || null; + // Resolved (see `Link#getComputedLabel`) - already merged with `defaultLabel`/ + // the built-in default. + return this.relatedView.model.getComputedLabel(this.getLabelIndex()) || null; }, getLabelPosition(label) { - const view = this.relatedView; - const labelPosition = view._normalizeLabelPosition(label.position); - return view._mergeLabelPositionProperty(labelPosition, view._getDefaultLabelPositionProperty()); + return label.position; }, }); diff --git a/packages/joint-core/test/jointjs/links.js b/packages/joint-core/test/jointjs/links.js index 8d5a7bcbbc..d1cca5ff84 100644 --- a/packages/joint-core/test/jointjs/links.js +++ b/packages/joint-core/test/jointjs/links.js @@ -1931,6 +1931,46 @@ QUnit.module('links', function(hooks) { }); }); + QUnit.module('getComputedLabel/getComputedLabels', function() { + + QUnit.test('resolved against `defaultLabel`/the built-in default - unlike `label`/`labels`', function(assert) { + var link = new joint.shapes.standard.Link({ labels: [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}] }); + // A label's own `position` is kept as-is, `markup`/`attrs` fall back to the + // built-in default. + assert.deepEqual(link.getComputedLabel(0).position, { distance: 10, offset: 10 }); + assert.deepEqual(link.getComputedLabel(1).position, { distance: 20, offset: 20 }); + assert.ok(link.getComputedLabel(0).markup); + assert.ok(link.getComputedLabel(0).attrs); + assert.deepEqual(link.getComputedLabel(2), undefined); + + assert.deepEqual(link.getComputedLabels()[0].position, { distance: 10, offset: 10 }); + assert.ok(link.getComputedLabels()[0].markup); + }); + + QUnit.test('pass through custom properties, own value winning over `defaultLabel`\'s', function(assert) { + var link = new joint.shapes.standard.Link({ + defaultLabel: { custom: 'default', onlyOnDefault: 'd' }, + labels: [ + { custom: 'own', position: 0 }, + { position: 0 } + ] + }); + + assert.equal(link.getComputedLabel(0).custom, 'own'); + assert.equal(link.getComputedLabel(0).onlyOnDefault, 'd'); + assert.equal(link.getComputedLabel(1).custom, 'default'); + + assert.equal(link.getComputedLabels()[0].custom, 'own'); + assert.equal(link.getComputedLabels()[1].custom, 'default'); + + // Raw storage (and `label`/`labels`) are unaffected - `defaultLabel`'s value + // isn't baked into either. + assert.equal(link.get('labels')[1].custom, undefined); + assert.equal(link.label(1).custom, undefined); + assert.equal(link.labels()[1].custom, undefined); + }); + }); + QUnit.module('insertLabel', function() { QUnit.test('sanity', function(assert) { @@ -1947,7 +1987,9 @@ QUnit.module('links', function(hooks) { link.insertLabel(-1, { position: { distance: 20, offset: 20 }}); link.insertLabel(0, { position: { distance: 10, offset: 10 }}); link.insertLabel(100, { position: { distance: 30, offset: 30 }}); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); + // Raw, as stored (not resolved - see `labels > getter` above) - confirms the + // insert didn't bake any resolved defaults into the other, untouched labels. + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); }); }); @@ -1965,7 +2007,8 @@ QUnit.module('links', function(hooks) { assert.equal(!!error, true); link.appendLabel({ position: { distance: 10, offset: 10 }}); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}]); + // Raw, as stored - see `insertLabel > sanity` above. + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}]); }); }); @@ -1973,12 +2016,13 @@ QUnit.module('links', function(hooks) { QUnit.test('sanity', function(assert) { var link = new joint.shapes.standard.Link({ labels: [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}, { position: { distance: 40, offset: 40 }}] }); + // Raw, as stored - see `insertLabel > sanity` above. link.removeLabel(100); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}, { position: { distance: 40, offset: 40 }}]); + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}, { position: { distance: 40, offset: 40 }}]); link.removeLabel(-1); - assert.deepEqual(link.labels(), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); + assert.deepEqual(link.get('labels'), [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); link.removeLabel(0); - assert.deepEqual(link.labels(), [{ position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); + assert.deepEqual(link.get('labels'), [{ position: { distance: 20, offset: 20 }}, { position: { distance: 30, offset: 30 }}]); }); }); }); diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 356555820a..4e841a1f96 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1034,6 +1034,10 @@ export namespace Link { position?: LabelPosition | number; // optional for default labels attrs?: Cell.Selectors; size?: Size; + // Any other custom property - passed + // through as-is by `Link#labels`/`Link#label`, whether set on the label itself or + // on `defaultLabel` (the label's own value wins). + [key: string]: any; } interface Vertex extends Point { @@ -1074,9 +1078,13 @@ export class Link Date: Thu, 1 Oct 2026 11:41:34 +0200 Subject: [PATCH 2/5] fixes --- packages/joint-core/src/dia/Link.mjs | 43 +++++++++++++------ packages/joint-core/src/dia/LinkView.mjs | 4 +- packages/joint-core/src/dia/link-labels.mjs | 7 +-- .../joint-core/src/linkTools/RotateLabel.mjs | 9 ++-- packages/joint-core/types/dia.d.ts | 2 +- 5 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/joint-core/src/dia/Link.mjs b/packages/joint-core/src/dia/Link.mjs index 6559c8dade..c066c01213 100644 --- a/packages/joint-core/src/dia/Link.mjs +++ b/packages/joint-core/src/dia/Link.mjs @@ -1,7 +1,7 @@ import { Cell } from './Cell.mjs'; import { clone, isPlainObject, isFunction, isString, isNumber, assign } from '../util/index.mjs'; import { Point, Polyline } from '../g/index.mjs'; -import { getResolvedLabel } from './link-labels.mjs'; +import { getComputedLabel } from './link-labels.mjs'; // Link base model. // -------------------------- @@ -217,18 +217,27 @@ export const Link = Cell.extend({ return this.prop(['labels', idx], label, opt); }, - // Like `label()`'s getter, but resolved against `defaultLabel`/the built-in default - // (see `getResolvedLabel` in `link-labels.mjs`) - `label()` itself always returns the - // label exactly as stored (or `undefined` if there isn't one at that index). + /** + * Returns the label at the given index, resolved against the link's `defaultLabel` + * and the built-in default label. + * + * Unlike the `label()` getter, which returns the label exactly as stored, the result + * has `markup`, `attrs`, `size` and `position` merged with the defaults. + * The stored label is not modified. + * + * @param {number} [idx=0] - The index of the label. Negative values count from the end. + * @returns {dia.Link.Label | null} A new object with the resolved label, or `null` + * if there is no label at `idx`. + */ getComputedLabel: function(idx) { - var labels = this.labels(); + const labels = this.labels(); idx = (isFinite(idx) && idx !== null) ? (idx | 0) : 0; if (idx < 0) idx = labels.length + idx; - var label = labels[idx]; - return (label === undefined) ? undefined : getResolvedLabel(this, label); + const label = labels[idx]; + return (label === undefined) ? null : getComputedLabel(this, label); }, labels: function(labels, opt) { @@ -244,12 +253,20 @@ export const Link = Cell.extend({ return this.set('labels', labels, opt); }, - // Like `labels()`'s getter, but with every label resolved against `defaultLabel`/the - // built-in default (see `getResolvedLabel` in `link-labels.mjs`) - `labels()` itself - // always returns each label exactly as stored. + /** + * Returns all labels of the link, each resolved against the link's `defaultLabel` + * and the built-in default label. + * + * Unlike the `labels()` getter, which returns the labels exactly as stored, each + * has `markup`, `attrs`, `size` and `position` merged with the defaults. + * The stored labels are not modified. + * + * @returns {dia.Link.Label[]} A new array of resolved labels, in the same order as + * `labels()`. Empty if the link has no labels. + */ getComputedLabels: function() { - return this.labels().map((label) => getResolvedLabel(this, label)); + return this.labels().map((label) => getComputedLabel(this, label)); }, hasLabels: function() { @@ -569,9 +586,9 @@ export const Link = Cell.extend({ // `size`/`position` - so `getResolvedLabel` (`link-labels.mjs`) can pass it through too. _getDefaultLabel: function() { - var defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; + const defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; - var label = assign({}, defaultLabel); + const label = assign({}, defaultLabel); label.markup = defaultLabel.markup || this.get('labelMarkup') || this.labelMarkup; return label; diff --git a/packages/joint-core/src/dia/LinkView.mjs b/packages/joint-core/src/dia/LinkView.mjs index 013513e3ed..e9faa00dcc 100644 --- a/packages/joint-core/src/dia/LinkView.mjs +++ b/packages/joint-core/src/dia/LinkView.mjs @@ -1473,9 +1473,7 @@ export const LinkView = CellView.extend({ var labelNode = evt.currentTarget; var labelIdx = parseInt(labelNode.getAttribute('label-idx'), 10); - // Resolved (see `Link#getComputedLabel`) - already merged with `defaultLabel`/ - // the built-in default. - var position = this.model.getComputedLabel(labelIdx).position || {}; + var position = this.model.getComputedLabel(labelIdx).position; var coords = this.getLabelCoordinates(position); var dx = coords.x - x; // how much needs to be added to cursor x to get to label x diff --git a/packages/joint-core/src/dia/link-labels.mjs b/packages/joint-core/src/dia/link-labels.mjs index 0115aa04fe..f7f28bdcd4 100644 --- a/packages/joint-core/src/dia/link-labels.mjs +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -1,11 +1,8 @@ import { merge } from '../util/index.mjs'; // A label as given (own `markup`/`attrs`/`size`/`position`, any of which may be missing), -// resolved against `link`'s `defaultLabel` and its built-in default - the same resolution -// `LinkView` used to do at render time. Passing `{}` resolves to the pure default. Any -// other (custom) property on the label or `defaultLabel` passes through unresolved - the -// label's own value wins over `defaultLabel`'s. -export function getResolvedLabel(link, label) { +// resolved against `link`'s `defaultLabel` and its built-in default. +export function getComputedLabel(link, label) { label = label || {}; diff --git a/packages/joint-core/src/linkTools/RotateLabel.mjs b/packages/joint-core/src/linkTools/RotateLabel.mjs index 77f9813ba0..e3328c10fc 100644 --- a/packages/joint-core/src/linkTools/RotateLabel.mjs +++ b/packages/joint-core/src/linkTools/RotateLabel.mjs @@ -98,14 +98,15 @@ export const RotateLabel = Control.extend({ return this.options.labelIndex || 0; }, + // Change to getComputedLabel() in future release getLabel() { - // Resolved (see `Link#getComputedLabel`) - already merged with `defaultLabel`/ - // the built-in default. - return this.relatedView.model.getComputedLabel(this.getLabelIndex()) || null; + return this.relatedView.model.label(this.getLabelIndex()) || null; }, getLabelPosition(label) { - return label.position; + const view = this.relatedView; + const labelPosition = view._normalizeLabelPosition(label.position); + return view._mergeLabelPositionProperty(labelPosition, view._getDefaultLabelPositionProperty()); }, }); diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 4e841a1f96..7d1c3ef6fb 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1078,7 +1078,7 @@ export class Link Date: Thu, 1 Oct 2026 12:58:44 +0200 Subject: [PATCH 3/5] updates --- packages/joint-core/src/dia/Link.mjs | 18 +++-- packages/joint-core/src/dia/LinkView.mjs | 38 +++++------ packages/joint-core/src/dia/link-labels.mjs | 65 +++++++------------ .../joint-core/src/linkTools/RotateLabel.mjs | 14 ++-- packages/joint-core/test/jointjs/linkView.js | 18 +++++ packages/joint-core/test/jointjs/links.js | 42 ++++++++++-- packages/joint-core/types/dia.d.ts | 23 ++++++- 7 files changed, 135 insertions(+), 83 deletions(-) diff --git a/packages/joint-core/src/dia/Link.mjs b/packages/joint-core/src/dia/Link.mjs index c066c01213..f824195695 100644 --- a/packages/joint-core/src/dia/Link.mjs +++ b/packages/joint-core/src/dia/Link.mjs @@ -56,7 +56,13 @@ export const Link = Cell.extend({ // used if neither defaultLabel.position // nor label.position is set position: { - distance: 0.5 + distance: 0.5, + offset: 0, + angle: 0 + }, + size: { + width: 0, + height: 0 } } }, @@ -233,10 +239,12 @@ export const Link = Cell.extend({ const labels = this.labels(); - idx = (isFinite(idx) && idx !== null) ? (idx | 0) : 0; - if (idx < 0) idx = labels.length + idx; + let index = (isFinite(idx) && idx !== null) ? (idx | 0) : 0; + if (index < 0) { + index = labels.length + index; + } - const label = labels[idx]; + const label = labels[index]; return (label === undefined) ? null : getComputedLabel(this, label); }, @@ -583,7 +591,7 @@ export const Link = Cell.extend({ // Get resolved default label. Kept as-is (including any custom property, e.g. a // `@joint/layout-elk` `elkLayoutOptions`) - not just the known `markup`/`attrs`/ - // `size`/`position` - so `getResolvedLabel` (`link-labels.mjs`) can pass it through too. + // `size`/`position` - so `getComputedLabel` (`link-labels.mjs`) can pass it through too. _getDefaultLabel: function() { const defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; diff --git a/packages/joint-core/src/dia/LinkView.mjs b/packages/joint-core/src/dia/LinkView.mjs index e9faa00dcc..8f05f9bd9a 100644 --- a/packages/joint-core/src/dia/LinkView.mjs +++ b/packages/joint-core/src/dia/LinkView.mjs @@ -833,7 +833,7 @@ export const LinkView = CellView.extend({ } }, - // Used by `addLabel` below, for a not-yet-existing label - unlike `getResolvedLabel` + // Used by `addLabel` below, for a not-yet-existing label - unlike `getComputedLabel` // (`link-labels.mjs`), does not need the built-in default (which never has `position.args`). _getDefaultLabelPositionArgs: function() { @@ -1231,33 +1231,29 @@ export const LinkView = CellView.extend({ return position; }, + // Expects a label position object (e.g. `Link#getComputedLabel(idx).position`). _getLabelTransformationMatrix: function(labelPosition) { - var labelDistance; - var labelAngle = 0; - var args = {}; - if (typeof labelPosition === 'number') { - labelDistance = labelPosition; - } else if (typeof labelPosition.distance === 'number') { - args = labelPosition.args || {}; - labelDistance = labelPosition.distance; - labelAngle = labelPosition.angle || 0; - } else { + if (!labelPosition) { + throw new Error('dia.LinkView: invalid label position.'); + } + + var labelDistance = labelPosition.distance; + if (typeof labelDistance !== 'number') { throw new Error('dia.LinkView: invalid label position distance.'); } - var isDistanceRelative = ((labelDistance > 0) && (labelDistance <= 1)); + var labelAngle = labelPosition.angle || 0; + var args = labelPosition.args || {}; - var labelOffset = 0; - var labelOffsetCoordinates = { x: 0, y: 0 }; - if (labelPosition.offset) { - var positionOffset = labelPosition.offset; - if (typeof positionOffset === 'number') labelOffset = positionOffset; - if (positionOffset.x) labelOffsetCoordinates.x = positionOffset.x; - if (positionOffset.y) labelOffsetCoordinates.y = positionOffset.y; - } + var isDistanceRelative = ((labelDistance > 0) && (labelDistance <= 1)); - var isOffsetAbsolute = ((labelOffsetCoordinates.x !== 0) || (labelOffsetCoordinates.y !== 0) || labelOffset === 0); + // a number offset is perpendicular to the path, an `{ x, y }` offset is absolute + var labelOffset = labelPosition.offset || 0; + var isOffsetAbsolute = (typeof labelOffset !== 'number'); + var labelOffsetCoordinates = isOffsetAbsolute + ? { x: labelOffset.x || 0, y: labelOffset.y || 0 } + : { x: 0, y: 0 }; var isKeepGradient = args.keepGradient; var isEnsureLegibility = args.ensureLegibility; diff --git a/packages/joint-core/src/dia/link-labels.mjs b/packages/joint-core/src/dia/link-labels.mjs index f7f28bdcd4..728f18c907 100644 --- a/packages/joint-core/src/dia/link-labels.mjs +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -17,11 +17,8 @@ export function getComputedLabel(link, label) { return Object.assign({}, defaultLabel, label, { markup: label.markup || defaultLabel.markup || builtinDefaultLabel.markup, attrs: mergeLabelAttrs(hasCustomMarkup, label.attrs, defaultLabel.attrs, builtinDefaultLabel.attrs), - size: mergeLabelSize(label.size, defaultLabel.size), - position: mergeLabelPositionProperty( - normalizeLabelPosition(label.position), - getDefaultLabelPositionProperty(link, defaultLabel) - ) + size: mergeLabelSize(label.size, defaultLabel.size, builtinDefaultLabel.size), + position: mergeLabelPosition(label.position, defaultLabel.position, builtinDefaultLabel.position) }); } @@ -47,49 +44,37 @@ function mergeLabelAttrs(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtin return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); } -// merge default label size into label size (no built-in default) -// keep `undefined` or `null` because `{}` means something else -function mergeLabelSize(labelSize, defaultLabelSize) { - - if (labelSize === null) return null; - if (labelSize === undefined) { +// merge label size with default label size and built-in default label size +// the result is always a size object (`null` or `undefined` falls back to the defaults) +function mergeLabelSize(labelSize, defaultLabelSize, builtinDefaultLabelSize) { - if (defaultLabelSize === null) return null; - if (defaultLabelSize === undefined) return undefined; - - return defaultLabelSize; - } - - return merge({}, defaultLabelSize, labelSize); + return merge({}, builtinDefaultLabelSize, defaultLabelSize, labelSize); } -// combine default label position with built-in default label position -function getDefaultLabelPositionProperty(link, defaultLabel) { - - const builtinDefaultLabelPosition = link._builtins.defaultLabel.position; - const defaultLabelPosition = normalizeLabelPosition(defaultLabel.position); - - return merge({}, builtinDefaultLabelPosition, defaultLabelPosition); +// merge label position with default label position and built-in default label position +// keep `null` (an invalid position, set on purpose), only `undefined` falls back to the defaults +function mergeLabelPosition(labelPosition, defaultLabelPosition, builtinDefaultLabelPosition) { + + if (labelPosition === null) return null; + return merge( + {}, + builtinDefaultLabelPosition, + normalizeLabelPosition(defaultLabelPosition), + normalizeLabelPosition(labelPosition) + ); } // if label position is a number, normalize it to a position object // this makes sure that label positions can be merged properly function normalizeLabelPosition(labelPosition) { - if (typeof labelPosition === 'number') return { distance: labelPosition, offset: null, angle: 0, args: null }; - return labelPosition; -} - -// expects normalized position properties -// e.g. `normalizeLabelPosition(labelPosition)` and `getDefaultLabelPositionProperty(link, defaultLabel)` -function mergeLabelPositionProperty(normalizedLabelPosition, normalizedDefaultLabelPosition) { - - if (normalizedLabelPosition === null) return null; - if (normalizedLabelPosition === undefined) { - - if (normalizedDefaultLabelPosition === null) return null; - return normalizedDefaultLabelPosition; + if (typeof labelPosition === 'number') { + return { + distance: labelPosition, + offset: 0, + angle: 0, + args: null + }; } - - return merge({}, normalizedDefaultLabelPosition, normalizedLabelPosition); + return labelPosition; } diff --git a/packages/joint-core/src/linkTools/RotateLabel.mjs b/packages/joint-core/src/linkTools/RotateLabel.mjs index e3328c10fc..89884c8a3c 100644 --- a/packages/joint-core/src/linkTools/RotateLabel.mjs +++ b/packages/joint-core/src/linkTools/RotateLabel.mjs @@ -52,9 +52,10 @@ export const RotateLabel = Control.extend({ const label = this.getLabel(); const labelPosition = this.getLabelPosition(label); const coords = view.getLabelCoordinates(labelPosition); - let { angle = 0 } = labelPosition; - const { args = {}} = labelPosition; - const keepGradient = args.keepGradient; + // `args` is `null` for a label with a number position + const { args } = labelPosition; + const keepGradient = args && args.keepGradient; + let { angle } = labelPosition; if (keepGradient) { const tangent = view.getTangentAtRatio( view.getClosestPointRatio(coords) @@ -98,15 +99,12 @@ export const RotateLabel = Control.extend({ return this.options.labelIndex || 0; }, - // Change to getComputedLabel() in future release getLabel() { - return this.relatedView.model.label(this.getLabelIndex()) || null; + return this.relatedView.model.getComputedLabel(this.getLabelIndex()); }, getLabelPosition(label) { - const view = this.relatedView; - const labelPosition = view._normalizeLabelPosition(label.position); - return view._mergeLabelPositionProperty(labelPosition, view._getDefaultLabelPositionProperty()); + return label.position; }, }); diff --git a/packages/joint-core/test/jointjs/linkView.js b/packages/joint-core/test/jointjs/linkView.js index aedca785ae..f37aa4f724 100644 --- a/packages/joint-core/test/jointjs/linkView.js +++ b/packages/joint-core/test/jointjs/linkView.js @@ -547,6 +547,24 @@ QUnit.module('linkView', function(hooks) { QUnit.module('getLabelCoordinates', function(hooks) { + QUnit.test('invalid position', function(assert) { + + assert.throws(function() { + linkView.getLabelCoordinates(null); + }, /invalid label position/); + + assert.throws(function() { + linkView.getLabelCoordinates({ distance: null }); + }, /invalid label position distance/); + + // A label's own `null` position is kept by `getComputedLabel()` - it isn't + // replaced by the default position. + assert.throws(function() { + link.appendLabel({ position: null }); + }, /invalid label position/); + link.removeLabel(-1); + }); + QUnit.test('default', function(assert) { var labelCoordinates; diff --git a/packages/joint-core/test/jointjs/links.js b/packages/joint-core/test/jointjs/links.js index d1cca5ff84..2cc4ea8674 100644 --- a/packages/joint-core/test/jointjs/links.js +++ b/packages/joint-core/test/jointjs/links.js @@ -1935,18 +1935,48 @@ QUnit.module('links', function(hooks) { QUnit.test('resolved against `defaultLabel`/the built-in default - unlike `label`/`labels`', function(assert) { var link = new joint.shapes.standard.Link({ labels: [{ position: { distance: 10, offset: 10 }}, { position: { distance: 20, offset: 20 }}] }); - // A label's own `position` is kept as-is, `markup`/`attrs` fall back to the - // built-in default. - assert.deepEqual(link.getComputedLabel(0).position, { distance: 10, offset: 10 }); - assert.deepEqual(link.getComputedLabel(1).position, { distance: 20, offset: 20 }); + // A label's own `position` wins over the built-in default, `markup`/`attrs`/`size` + // fall back to the built-in default. + assert.deepEqual(link.getComputedLabel(0).position, { distance: 10, offset: 10, angle: 0 }); + assert.deepEqual(link.getComputedLabel(1).position, { distance: 20, offset: 20, angle: 0 }); assert.ok(link.getComputedLabel(0).markup); assert.ok(link.getComputedLabel(0).attrs); - assert.deepEqual(link.getComputedLabel(2), undefined); + assert.deepEqual(link.getComputedLabel(0).size, { width: 0, height: 0 }); + assert.strictEqual(link.getComputedLabel(2), null); - assert.deepEqual(link.getComputedLabels()[0].position, { distance: 10, offset: 10 }); + assert.deepEqual(link.getComputedLabels()[0].position, { distance: 10, offset: 10, angle: 0 }); assert.ok(link.getComputedLabels()[0].markup); }); + QUnit.test('`position` and `size` are resolved objects (unless `position` is `null`)', function(assert) { + var link = new joint.shapes.standard.Link({ + labels: [ + {}, + { position: null, size: null }, + { position: 0.3 }, + { position: { offset: { x: 5, y: 5 }}, size: { width: 10 }} + ] + }); + + var labels = link.getComputedLabels(); + assert.deepEqual(labels[0].position, { distance: 0.5, offset: 0, angle: 0 }); + assert.deepEqual(labels[0].size, { width: 0, height: 0 }); + // `null` is kept - only `undefined` falls back to the defaults. + assert.strictEqual(labels[1].position, null); + assert.deepEqual(labels[1].size, { width: 0, height: 0 }); + assert.deepEqual(labels[2].position, { distance: 0.3, offset: 0, angle: 0, args: null }); + assert.deepEqual(labels[3].position, { distance: 0.5, offset: { x: 5, y: 5 }, angle: 0 }); + assert.deepEqual(labels[3].size, { width: 10, height: 0 }); + + link.set('defaultLabel', { position: { distance: 0.2, angle: 45 }, size: { width: 20, height: 20 }}); + labels = link.getComputedLabels(); + assert.deepEqual(labels[0].position, { distance: 0.2, offset: 0, angle: 45 }); + assert.deepEqual(labels[0].size, { width: 20, height: 20 }); + // A number position resets `defaultLabel`'s `offset`/`angle`/`args`. + assert.deepEqual(labels[2].position, { distance: 0.3, offset: 0, angle: 0, args: null }); + assert.deepEqual(labels[3].size, { width: 10, height: 20 }); + }); + QUnit.test('pass through custom properties, own value winning over `defaultLabel`\'s', function(assert) { var link = new joint.shapes.standard.Link({ defaultLabel: { custom: 'default', onlyOnDefault: 'd' }, diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 7d1c3ef6fb..57bf29d0b4 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1040,6 +1040,23 @@ export namespace Link { [key: string]: any; } + interface ComputedLabelPosition { + distance: number; + offset: number | { x: number, y: number }; + angle: number; + args?: LinkView.LabelOptions | null; + } + + interface ComputedLabel { + markup: string | MarkupJSON; + position: ComputedLabelPosition | null; + attrs?: Cell.Selectors | null; + size: Size; + // Any other custom property - passed + // through as-is from the label itself or from `defaultLabel`. + [key: string]: any; + } + interface Vertex extends Point { [key: string]: any; } @@ -1078,12 +1095,12 @@ export class Link extends CellViewGeneric { getLabelPosition(x: number, y: number, opt?: LinkView.LabelOptions): Link.LabelPosition; getLabelPosition(x: number, y: number, angle: number, opt?: LinkView.LabelOptions): Link.LabelPosition; - getLabelCoordinates(labelPosition: Link.LabelPosition): g.Point; + getLabelCoordinates(labelPosition: Link.LabelPosition | Link.ComputedLabelPosition): g.Point; getVertexIndex(x: number, y: number): number; getVertexIndex(point: Point): number; From 092bd6d0d5571c6aec4f09f15a6654d17ef7d5d1 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Thu, 1 Oct 2026 21:41:10 +0200 Subject: [PATCH 4/5] update --- packages/joint-core/src/dia/Link.mjs | 6 ++-- packages/joint-core/src/dia/link-labels.mjs | 11 +++++--- packages/joint-core/test/jointjs/links.js | 31 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/joint-core/src/dia/Link.mjs b/packages/joint-core/src/dia/Link.mjs index f824195695..b1ca3af709 100644 --- a/packages/joint-core/src/dia/Link.mjs +++ b/packages/joint-core/src/dia/Link.mjs @@ -232,7 +232,7 @@ export const Link = Cell.extend({ * The stored label is not modified. * * @param {number} [idx=0] - The index of the label. Negative values count from the end. - * @returns {dia.Link.Label | null} A new object with the resolved label, or `null` + * @returns {dia.Link.ComputedLabel | null} A new object with the resolved label, or `null` * if there is no label at `idx`. */ getComputedLabel: function(idx) { @@ -269,7 +269,7 @@ export const Link = Cell.extend({ * has `markup`, `attrs`, `size` and `position` merged with the defaults. * The stored labels are not modified. * - * @returns {dia.Link.Label[]} A new array of resolved labels, in the same order as + * @returns {dia.Link.ComputedLabel[]} A new array of resolved labels, in the same order as * `labels()`. Empty if the link has no labels. */ getComputedLabels: function() { @@ -594,7 +594,7 @@ export const Link = Cell.extend({ // `size`/`position` - so `getComputedLabel` (`link-labels.mjs`) can pass it through too. _getDefaultLabel: function() { - const defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; + const defaultLabel = this.get('defaultLabel') || this.defaultLabel; const label = assign({}, defaultLabel); label.markup = defaultLabel.markup || this.get('labelMarkup') || this.labelMarkup; diff --git a/packages/joint-core/src/dia/link-labels.mjs b/packages/joint-core/src/dia/link-labels.mjs index 728f18c907..334ad038c9 100644 --- a/packages/joint-core/src/dia/link-labels.mjs +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -1,4 +1,4 @@ -import { merge } from '../util/index.mjs'; +import { cloneDeep, merge } from '../util/index.mjs'; // A label as given (own `markup`/`attrs`/`size`/`position`, any of which may be missing), // resolved against `link`'s `defaultLabel` and its built-in default. @@ -14,8 +14,11 @@ export function getComputedLabel(link, label) { // built-in markup, so they don't apply once a custom one is in play. const hasCustomMarkup = !!(label.markup || defaultLabel.markup); + // The resolved `markup`/`attrs`/`size`/`position` are always new objects - never the + // stored label's, `defaultLabel`'s or the (shared by all links) built-in default's - + // so mutating a computed label can't change them. Custom properties are passed through as-is. return Object.assign({}, defaultLabel, label, { - markup: label.markup || defaultLabel.markup || builtinDefaultLabel.markup, + markup: cloneDeep(label.markup || defaultLabel.markup || builtinDefaultLabel.markup), attrs: mergeLabelAttrs(hasCustomMarkup, label.attrs, defaultLabel.attrs, builtinDefaultLabel.attrs), size: mergeLabelSize(label.size, defaultLabel.size, builtinDefaultLabel.size), position: mergeLabelPosition(label.position, defaultLabel.position, builtinDefaultLabel.position) @@ -33,10 +36,10 @@ function mergeLabelAttrs(hasCustomMarkup, labelAttrs, defaultLabelAttrs, builtin if (defaultLabelAttrs === undefined) { if (hasCustomMarkup) return undefined; - return builtinDefaultLabelAttrs; + return merge({}, builtinDefaultLabelAttrs); } - if (hasCustomMarkup) return defaultLabelAttrs; + if (hasCustomMarkup) return merge({}, defaultLabelAttrs); return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); } diff --git a/packages/joint-core/test/jointjs/links.js b/packages/joint-core/test/jointjs/links.js index 2cc4ea8674..a0afd1f3d5 100644 --- a/packages/joint-core/test/jointjs/links.js +++ b/packages/joint-core/test/jointjs/links.js @@ -1977,6 +1977,37 @@ QUnit.module('links', function(hooks) { assert.deepEqual(labels[3].size, { width: 10, height: 20 }); }); + QUnit.test('returns new `markup`/`attrs`/`size`/`position` objects', function(assert) { + var builtinDefaultLabel = joint.util.cloneDeep(joint.dia.Link.prototype._builtins.defaultLabel); + var ownMarkup = [{ tagName: 'text', selector: 'text' }]; + var defaultMarkup = [{ tagName: 'rect', selector: 'body' }]; + var defaultAttrs = { body: { fill: 'red' }}; + + function mutate(label) { + label.markup.push({ tagName: 'circle' }); + label.attrs.mutated = { fill: 'blue' }; + label.size.width = 100; + label.position.distance = 100; + } + + // Built-in default (shared by all links). + var link = new joint.shapes.standard.Link({ labels: [{}] }); + mutate(link.getComputedLabel(0)); + assert.deepEqual(link._builtins.defaultLabel, builtinDefaultLabel); + assert.deepEqual(link.get('labels'), [{}]); + + // `defaultLabel` with a custom markup (attrs are not merged with the built-in ones). + link.set('defaultLabel', { markup: defaultMarkup, attrs: defaultAttrs }); + mutate(link.getComputedLabel(0)); + assert.deepEqual(defaultMarkup, [{ tagName: 'rect', selector: 'body' }]); + assert.deepEqual(defaultAttrs, { body: { fill: 'red' }}); + + // The label's own markup. + link.labels([{ markup: ownMarkup }]); + mutate(link.getComputedLabel(0)); + assert.deepEqual(ownMarkup, [{ tagName: 'text', selector: 'text' }]); + }); + QUnit.test('pass through custom properties, own value winning over `defaultLabel`\'s', function(assert) { var link = new joint.shapes.standard.Link({ defaultLabel: { custom: 'default', onlyOnDefault: 'd' }, From 7833389353636e2b53d2a47c6be72c2e57aca854 Mon Sep 17 00:00:00 2001 From: Arthur Khokhlov Date: Fri, 2 Oct 2026 15:17:05 +0200 Subject: [PATCH 5/5] fix --- packages/joint-core/src/dia/Link.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/joint-core/src/dia/Link.mjs b/packages/joint-core/src/dia/Link.mjs index b1ca3af709..183edccfc0 100644 --- a/packages/joint-core/src/dia/Link.mjs +++ b/packages/joint-core/src/dia/Link.mjs @@ -594,7 +594,7 @@ export const Link = Cell.extend({ // `size`/`position` - so `getComputedLabel` (`link-labels.mjs`) can pass it through too. _getDefaultLabel: function() { - const defaultLabel = this.get('defaultLabel') || this.defaultLabel; + const defaultLabel = this.get('defaultLabel') || this.defaultLabel || {}; const label = assign({}, defaultLabel); label.markup = defaultLabel.markup || this.get('labelMarkup') || this.labelMarkup;