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..183edccfc0 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 { getComputedLabel } from './link-labels.mjs'; // Link base model. // -------------------------- @@ -55,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 } } }, @@ -216,6 +223,31 @@ export const Link = Cell.extend({ return this.prop(['labels', idx], label, opt); }, + /** + * 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.ComputedLabel | null} A new object with the resolved label, or `null` + * if there is no label at `idx`. + */ + getComputedLabel: function(idx) { + + const labels = this.labels(); + + let index = (isFinite(idx) && idx !== null) ? (idx | 0) : 0; + if (index < 0) { + index = labels.length + index; + } + + const label = labels[index]; + return (label === undefined) ? null : getComputedLabel(this, label); + }, + labels: function(labels, opt) { // getter @@ -229,6 +261,22 @@ export const Link = Cell.extend({ return this.set('labels', labels, opt); }, + /** + * 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.ComputedLabel[]} 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) => getComputedLabel(this, label)); + }, + hasLabels: function() { const { labels } = this.attributes; return Array.isArray(labels) && labels.length > 0; @@ -541,16 +589,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 `getComputedLabel` (`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 = {}; + const 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..8f05f9bd9a 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 `getComputedLabel` + // (`link-labels.mjs`), does not need the built-in default (which never has `position.args`). _getDefaultLabelPositionArgs: function() { var defaultLabel = this.model._getDefaultLabel(); @@ -1360,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; @@ -1602,26 +1469,19 @@ 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); + 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..334ad038c9 --- /dev/null +++ b/packages/joint-core/src/dia/link-labels.mjs @@ -0,0 +1,83 @@ +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. +export function getComputedLabel(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); + + // 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: 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) + }); +} + +// 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 merge({}, builtinDefaultLabelAttrs); + } + + if (hasCustomMarkup) return merge({}, defaultLabelAttrs); + return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs); + } + + if (hasCustomMarkup) return merge({}, defaultLabelAttrs, labelAttrs); + return merge({}, builtinDefaultLabelAttrs, defaultLabelAttrs, labelAttrs); +} + +// 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) { + + return merge({}, builtinDefaultLabelSize, defaultLabelSize, labelSize); +} + +// 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: 0, + angle: 0, + args: null + }; + } + return labelPosition; +} diff --git a/packages/joint-core/src/linkTools/RotateLabel.mjs b/packages/joint-core/src/linkTools/RotateLabel.mjs index d8c2f23bd0..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) @@ -99,13 +100,11 @@ export const RotateLabel = Control.extend({ }, 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 8d5a7bcbbc..a0afd1f3d5 100644 --- a/packages/joint-core/test/jointjs/links.js +++ b/packages/joint-core/test/jointjs/links.js @@ -1931,6 +1931,107 @@ 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` 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(0).size, { width: 0, height: 0 }); + assert.strictEqual(link.getComputedLabel(2), null); + + 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('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' }, + 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 +2048,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 +2068,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 +2077,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..57bf29d0b4 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1034,6 +1034,27 @@ 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 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 { @@ -1074,9 +1095,13 @@ 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;