diff --git a/examples/tree-of-life/src/index.ts b/examples/tree-of-life/src/index.ts index 7852a1106..24bb3685c 100644 --- a/examples/tree-of-life/src/index.ts +++ b/examples/tree-of-life/src/index.ts @@ -202,16 +202,16 @@ class Branch extends dia.Link { static attributes = { // The `organicStroke` attribute is used to set the `d` attribute of the `` element. // It works similarly to the `connection` attribute of JointJS. - organicStroke: { - qualify: function () { - return this.model.isLink(); - }, + 'organic-stroke': { set: function ( _value: any, _refBBox: g.Rect, _node: SVGElement, attrs: attributes.NativeSVGAttributes ) { + if (!this.model.isLink()) { + throw new Error('The `organicStroke` attribute can only be used with links.'); + } // The path of the link as returned by the `connector`. const path = this.getConnection(); const segmentSubdivisions = this.getConnectionSubdivisions(); @@ -231,7 +231,7 @@ class Branch extends dia.Link { // Using the `getStroke` function from the `perfect-freehand` library, // we get the points that represent the outline of the stroke. const outlinePoints = getStroke(points, { - size: attrs.organicStrokeSize || 20, + size: attrs['organic-stroke-size'] || 20, thinning: 0.5, simulatePressure: false, last: true, @@ -241,10 +241,11 @@ class Branch extends dia.Link { // The `d` attribute is set on the `node` element. return { d }; }, + unset: 'd' }, // Empty attributes definition to prevent the attribute from being set on the element. // They are only meant to be used in the `organicStroke` function. - organicStrokeSize: {}, + 'organic-stroke-size': {}, }; } diff --git a/packages/joint-core/demo/custom-shapes/src/custom-shapes.mjs b/packages/joint-core/demo/custom-shapes/src/custom-shapes.mjs index 046696ed3..6f620fed0 100644 --- a/packages/joint-core/demo/custom-shapes/src/custom-shapes.mjs +++ b/packages/joint-core/demo/custom-shapes/src/custom-shapes.mjs @@ -2,9 +2,9 @@ import * as joint from '../../../joint.mjs'; import * as g from '../../../src/g/index.mjs'; import V from '../../../src/V/index.mjs'; -var graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); +const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); -var paper = new joint.dia.Paper({ +const paper = new joint.dia.Paper({ el: document.getElementById('paper'), cellViewNamespace: joint.shapes, width: 650, @@ -14,20 +14,21 @@ var paper = new joint.dia.Paper({ }); // Global special attributes -joint.dia.attributes['line-style'] = { +const lineStyleAttribute = { set: function(lineStyle, refBBox, node, attrs) { - var n = attrs['stroke-width'] || 1; - var dasharray = { + const n = attrs['stroke-width'] || 1; + const dasharray = { 'dashed': (4*n) + ',' + (2*n), 'dotted': n + ',' + n }[lineStyle] || 'none'; return { 'stroke-dasharray': dasharray }; - } + }, + unset: 'stroke-dasharray' }; -joint.dia.attributes['fit-ref'] = { +const fitRefAttribute = { set: function(fitRef, refBBox, node) { switch (node.tagName.toUpperCase()) { case 'ELLIPSE': @@ -42,17 +43,96 @@ joint.dia.attributes['fit-ref'] = { width: refBBox.width, height: refBBox.height }; - case 'PATH': - var rect = joint.util.assign(refBBox.toJSON(), fitRef); + case 'PATH': { + const rect = joint.util.assign(refBBox.toJSON(), fitRef); return { d: V.rectToPath(rect) }; + } } return {}; - } + }, + unset: ['rx', 'ry', 'cx', 'cy', 'width', 'height', 'd'] +}; + +const shapeAttribute = { + set: function(shape, refBBox, node) { + if (!(node instanceof SVGPathElement)) { + throw new Error('The shape attribute can only be set on a path element.'); + } + let data; + switch (shape) { + case 'hexagon': { + data = [ + g.Line(refBBox.topMiddle(), refBBox.origin()).midpoint(), + g.Line(refBBox.topMiddle(), refBBox.topRight()).midpoint(), + refBBox.rightMiddle(), + g.Line(refBBox.bottomMiddle(), refBBox.corner()).midpoint(), + g.Line(refBBox.bottomMiddle(), refBBox.bottomLeft()).midpoint(), + refBBox.leftMiddle() + ]; + break; + } + case 'rhombus': { + data = [ + refBBox.topMiddle(), + refBBox.rightMiddle(), + refBBox.bottomMiddle(), + refBBox.leftMiddle() + ]; + break; + } + case 'rounded-rectangle': { + const rect = refBBox.toJSON(); + rect.rx = 5; + rect.ry = 5; + return { d: V.rectToPath(rect) }; + } + default: + throw new Error('Unknown shape: ' + shape); + } + return { d: 'M ' + data.join(' ').replace(/@/g, ' ') + ' Z' }; + }, + unset: 'd' +}; + +const progressDataAttribute = { + set: function(value, bbox) { + + function polarToCartesian(centerX, centerY, radius, angleInDegrees) { + const angleInRadians = (angleInDegrees-90) * Math.PI / 180.0; + return { + x: centerX + (radius * Math.cos(angleInRadians)), + y: centerY + (radius * Math.sin(angleInRadians)) + }; + } + + function describeArc(x, y, radius, startAngle, endAngle){ + const start = polarToCartesian(x, y, radius, endAngle); + const end = polarToCartesian(x, y, radius, startAngle); + const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; + const d = [ + 'M', start.x, start.y, + 'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y + ].join(' '); + return d; + } + + const center = bbox.center(); + return { + d: describeArc( + center.x, + center.y, + Math.min(bbox.width / 2, bbox.height / 2), + 0, + Math.min(360 / 100 * value, 359.99) + ) + }; + }, + unset: 'd' }; -var Circle = joint.dia.Element.define('custom.Circle', { +const Circle = joint.dia.Element.define('custom.Circle', { markup: [{ tagName: 'ellipse', selector: 'body' @@ -91,9 +171,14 @@ var Circle = joint.dia.Element.define('custom.Circle', { setText: function(text) { return this.attr('label/text', text); } +}, { + attributes: { + 'line-style': lineStyleAttribute, + 'fit-ref': fitRefAttribute + } }); -var circle = (new Circle()) +const circle = (new Circle()) .size(100, 100) .position(500,200) .setText('Special\nAttributes') @@ -102,7 +187,7 @@ var circle = (new Circle()) circle.transition('angle', 0, { delay: 500 }); -var Rectangle = joint.dia.Element.define('custom.Rectangle', { +const Rectangle = joint.dia.Element.define('custom.Rectangle', { markup: [{ tagName: 'rect', selector: 'body' @@ -155,7 +240,7 @@ var Rectangle = joint.dia.Element.define('custom.Rectangle', { } }); -var rectangle = (new Rectangle()) +const rectangle = (new Rectangle()) .size(100,90) .position(250,50) .addTo(graph); @@ -167,7 +252,7 @@ paper.on('element:delete', function(elementView, evt) { } }); -var Header = joint.dia.Element.define('custom.Header', { +const Header = joint.dia.Element.define('custom.Header', { markup: [{ tagName: 'rect', @@ -232,9 +317,15 @@ var Header = joint.dia.Element.define('custom.Header', { xlinkHref: 'http://placehold.it/30x40' } } +}, { + // prototype methods +}, { + attributes: { + 'fit-ref': fitRefAttribute + } }); -var header = (new Header()) +const header = (new Header()) .size(200,140) .position(420,40) .addTo(graph); @@ -307,7 +398,7 @@ new joint.shapes.standard.Link({ } }).addTo(graph); -var Shape = joint.dia.Element.define('custom.Shape', { +const Shape = joint.dia.Element.define('custom.Shape', { markup: [{ tagName: 'path', selector: 'body' @@ -326,7 +417,7 @@ var Shape = joint.dia.Element.define('custom.Shape', { main: { markup: [{ tagName: 'path', - selector: 'body' + selector: 'portBody' }], position: { name: 'absolute', @@ -336,7 +427,7 @@ var Shape = joint.dia.Element.define('custom.Shape', { }, size: { width: 20, height: 20 }, attrs: { - body: { + portBody: { fill: 'green', transform: 'translate(-10,-10)', magnet: true @@ -347,83 +438,45 @@ var Shape = joint.dia.Element.define('custom.Shape', { } }, { /* no prototype methods */ }, { attributes: { - - shape: { - qualify: function(value, node) { - return ([ - 'hexagon', - 'rhombus', - 'rounded-rectangle' - ].indexOf(value) > -1) && (node instanceof SVGPathElement); - }, - set: function(shape, refBBox) { - var data; - switch (shape) { - case 'hexagon': - data = [ - g.Line(refBBox.topMiddle(), refBBox.origin()).midpoint(), - g.Line(refBBox.topMiddle(), refBBox.topRight()).midpoint(), - refBBox.rightMiddle(), - g.Line(refBBox.bottomMiddle(), refBBox.corner()).midpoint(), - g.Line(refBBox.bottomMiddle(), refBBox.bottomLeft()).midpoint(), - refBBox.leftMiddle() - ]; - break; - case 'rhombus': - data = [ - refBBox.topMiddle(), - refBBox.rightMiddle(), - refBBox.bottomMiddle(), - refBBox.leftMiddle() - ]; - break; - case 'rounded-rectangle': - var rect = refBBox.toJSON(); - rect.rx = 5; - rect.ry = 5; - return { d: V.rectToPath(rect) }; - } - return { d: 'M ' + data.join(' ').replace(/@/g, ' ') + ' Z' }; - } - } + 'shape': shapeAttribute } }); -var shape1 = (new Shape()) - .attr('path/shape', 'hexagon') +const shape1 = (new Shape()) + .attr('body/shape', 'hexagon') .size(100, 100) .position(100, 50) .addPort({ group: 'main', - attrs: { body: { shape: 'hexagon' }} + attrs: { portBody: { shape: 'hexagon' }} }) .addPort({ group: 'main', args: { x: '100%' }, - attrs: { body: { shape: 'hexagon' }} + attrs: { portBody: { shape: 'hexagon' }} }); -var shape2 = (new Shape()) - .attr('path/shape', 'rhombus') +const shape2 = (new Shape()) + .attr('body/shape', 'rhombus') .size(100, 100) .position(100, 170) .addPort({ group: 'main', - attrs: { body: { shape: 'rhombus' }} + attrs: { portBody: { shape: 'rhombus' }} }) .addPort({ group: 'main', args: { x: '100%' }, - attrs: { body: { shape: 'rhombus' }} + attrs: { portBody: { shape: 'rhombus' }} }); -var shape3 = (new Shape()) - .attr('path/shape', 'rounded-rectangle') +const shape3 = (new Shape()) + .attr('body/shape', 'rounded-rectangle') .size(100, 100) .position(100, 290) .addPort({ group: 'main', - attrs: { path: { shape: 'rounded-rectangle' }} + attrs: { portBody: { shape: 'rounded-rectangle' }} }) .addPort({ id: 'circle-port', @@ -457,13 +510,13 @@ var shape3 = (new Shape()) graph.addCells([shape1, shape2, shape3]); -var portIndex = shape3.getPortIndex('circle-port'); +const portIndex = shape3.getPortIndex('circle-port'); shape3.transition('ports/items/' + portIndex + '/attrs/first/r', 5, { delay: 2000 }); -var Progress = joint.dia.Element.define('progress', { +const Progress = joint.dia.Element.define('progress', { attrs: { progressBackground: { stroke: 'gray', @@ -509,51 +562,12 @@ var Progress = joint.dia.Element.define('progress', { } }, { attributes: { - 'progress-d': { - set: function(value, bbox) { - - function polarToCartesian(centerX, centerY, radius, angleInDegrees) { - var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0; - return { - x: centerX + (radius * Math.cos(angleInRadians)), - y: centerY + (radius * Math.sin(angleInRadians)) - }; - } - - function describeArc(x, y, radius, startAngle, endAngle){ - var start = polarToCartesian(x, y, radius, endAngle); - var end = polarToCartesian(x, y, radius, startAngle); - var largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; - var d = [ - 'M', start.x, start.y, - 'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y - ].join(' '); - return d; - } - - var center = bbox.center(); - return { - d: describeArc( - center.x, - center.y, - Math.min(bbox.width / 2, bbox.height / 2), - 0, - Math.min(360 / 100 * value, 359.99) - ) - }; - } - } - + 'progress-d': progressDataAttribute } - - - }); -var progress = new Progress(); +const progress = new Progress(); progress.resize(100, 100); progress.position(400, 280); progress.setProgress(50); progress.addTo(graph); - - diff --git a/packages/joint-core/demo/puzzle/src/puzzle.js b/packages/joint-core/demo/puzzle/src/puzzle.js index 3e23eee66..2d9280019 100644 --- a/packages/joint-core/demo/puzzle/src/puzzle.js +++ b/packages/joint-core/demo/puzzle/src/puzzle.js @@ -14,8 +14,8 @@ joint.dia.Element.define('jigsaw.Piece', { } , { attributes: { tabs: { /* [topTab, rightTab, bottomTab, leftTab] */ - qualify: Array.isArray, set: function(tabs, refBBox) { + if (!Array.isArray(tabs)) return; var tabSize = this.model.prop('tabSize'); var points = []; var refCenter = refBBox.center(); @@ -41,11 +41,12 @@ joint.dia.Element.define('jigsaw.Piece', { return { points: points.join(' ').replace(/@/g,' ') }; - } + }, + unset: 'points' }, image: { /* [imageId, rowIndex, columnIndex] */ - qualify: Array.isArray, set: function(image) { + if (!Array.isArray(image)) return; var paper = this.paper; var model = this.model; var width = model.prop('size/width'); @@ -71,7 +72,8 @@ joint.dia.Element.define('jigsaw.Piece', { return { fill: 'url(#' + id + ')' }; - } + }, + unset: 'fill' } } }); diff --git a/packages/joint-core/demo/rough/src/rough.js b/packages/joint-core/demo/rough/src/rough.js index f52f18705..44a4aa44d 100644 --- a/packages/joint-core/demo/rough/src/rough.js +++ b/packages/joint-core/demo/rough/src/rough.js @@ -247,7 +247,8 @@ } var sets = shape.sets; return { d: r.opsToPath(sets[opt.fillSketch ? 0 : 1]) }; - } + }, + unset: 'd' }, 'pointer-shape': { set: function(type, bbox) { @@ -271,7 +272,8 @@ break; } return { d: vel.convertToPathData() }; - } + }, + unset: 'd' } } }); @@ -323,7 +325,8 @@ bowing: opt.bowing || 1 }; return { d: r.opsToPath(r.generator.path(this.getSerializedConnection(), rOpt).sets[0]) }; - } + }, + unset: 'd' } } }); diff --git a/packages/joint-core/demo/shapes/src/standard.js b/packages/joint-core/demo/shapes/src/standard.js index d52b789e7..1a1c45182 100644 --- a/packages/joint-core/demo/shapes/src/standard.js +++ b/packages/joint-core/demo/shapes/src/standard.js @@ -10,12 +10,11 @@ V.attributeNames['placeholderURL'] = 'placeholderURL'; // Custom attribute for retrieving image placeholder with specific size dia.attributes.placeholderURL = { - qualify: function(url) { - return typeof url === 'string'; - }, set: function(url, refBBox) { + if (typeof url !== 'string') return; return { 'xlink:href': util.template(url)(refBBox.round().toJSON()) }; - } + }, + unset: 'xlink:href' }; var CylinderTiltTool = elementTools.Control.extend({ diff --git a/packages/joint-core/docs/demo/shapes/shapes.standard.js b/packages/joint-core/docs/demo/shapes/shapes.standard.js index be11d744c..3cf60abd0 100644 --- a/packages/joint-core/docs/demo/shapes/shapes.standard.js +++ b/packages/joint-core/docs/demo/shapes/shapes.standard.js @@ -9,12 +9,11 @@ V.attributeNames['placeholderURL'] = 'placeholderURL'; // Custom attribute for retrieving image placeholder with specific size dia.attributes.placeholderURL = { - qualify: function(url) { - return typeof url === 'string'; - }, set: function(url, refBBox) { + if (typeof url !== 'string') return; return { 'xlink:href': util.template(url)(refBBox.round().toJSON()) }; - } + }, + unset: 'xlink:href' }; var graph = new dia.Graph({}, { cellNamespace: joint.shapes }); diff --git a/packages/joint-core/src/dia/CellView.mjs b/packages/joint-core/src/dia/CellView.mjs index 5d3ad6f4a..e1c5f2dba 100644 --- a/packages/joint-core/src/dia/CellView.mjs +++ b/packages/joint-core/src/dia/CellView.mjs @@ -559,13 +559,54 @@ export const CellView = View.extend({ if (!rawAttrs.hasOwnProperty(attrName)) continue; attrVal = rawAttrs[attrName]; def = this.getAttributeDefinition(attrName); - if (def && (!isFunction(def.qualify) || def.qualify.call(this, attrVal, node, rawAttrs, this))) { - if (isString(def.set)) { - normalAttrs || (normalAttrs = {}); - normalAttrs[def.set] = attrVal; - } - if (attrVal !== null) { - relatives.push(attrName, def); + if (def) { + if (attrVal === null) { + // Assign the unset attribute name. + let unsetAttrName; + if (isFunction(def.unset)) { + unsetAttrName = def.unset.call(this, node, rawAttrs, this); + } else { + unsetAttrName = def.unset; + } + if (!unsetAttrName && isString(def.set)) { + // We unset an alias attribute. + unsetAttrName = def.set; + } + if (!unsetAttrName) { + // There is no alias for the attribute. We unset the attribute itself. + unsetAttrName = attrName; + } + // Unset the attribute. + if (isString(unsetAttrName) && unsetAttrName) { + // Unset a single attribute. + normalAttrs || (normalAttrs = {}); + // values takes precedence over unset values + if (unsetAttrName in normalAttrs) continue; + normalAttrs[unsetAttrName] = attrVal; + } else if (Array.isArray(unsetAttrName) && unsetAttrName.length > 0) { + // Unset multiple attributes. + normalAttrs || (normalAttrs = {}); + for (i = 0, n = unsetAttrName.length; i < n; i++) { + const attrName = unsetAttrName[i]; + // values takes precedence over unset values + if (attrName in normalAttrs) continue; + normalAttrs[attrName] = attrVal; + } + } + // The unset value is neither a string nor an array. + // The attribute is not unset. + } else { + if (!isFunction(def.qualify) || def.qualify.call(this, attrVal, node, rawAttrs, this)) { + if (isString(def.set)) { + // An alias e.g 'xlink:href' -> 'href' + normalAttrs || (normalAttrs = {}); + normalAttrs[def.set] = attrVal; + } + relatives.push(attrName, def); + } else { + normalAttrs || (normalAttrs = {}); + normalAttrs[attrName] = attrVal; + } } } else { normalAttrs || (normalAttrs = {}); diff --git a/packages/joint-core/src/dia/attributes/connection.mjs b/packages/joint-core/src/dia/attributes/connection.mjs index 8a050e5fa..2e8b063e8 100644 --- a/packages/joint-core/src/dia/attributes/connection.mjs +++ b/packages/joint-core/src/dia/attributes/connection.mjs @@ -25,6 +25,7 @@ const connectionAttributesNS = { 'connection': { qualify: isLinkView, + unset: 'd', set: function({ stubs = 0 }) { let d; if (isFinite(stubs) && stubs !== 0) { @@ -49,21 +50,25 @@ const connectionAttributesNS = { 'at-connection-length-keep-gradient': { qualify: isLinkView, + unset: 'transform', set: atConnectionWrapper('getTangentAtLength', { rotate: true }) }, 'at-connection-length-ignore-gradient': { qualify: isLinkView, + unset: 'transform', set: atConnectionWrapper('getTangentAtLength', { rotate: false }) }, 'at-connection-ratio-keep-gradient': { qualify: isLinkView, + unset: 'transform', set: atConnectionWrapper('getTangentAtRatio', { rotate: true }) }, 'at-connection-ratio-ignore-gradient': { qualify: isLinkView, + unset: 'transform', set: atConnectionWrapper('getTangentAtRatio', { rotate: false }) } diff --git a/packages/joint-core/src/dia/attributes/defs.mjs b/packages/joint-core/src/dia/attributes/defs.mjs index 7c4123012..05e336bc3 100644 --- a/packages/joint-core/src/dia/attributes/defs.mjs +++ b/packages/joint-core/src/dia/attributes/defs.mjs @@ -33,6 +33,7 @@ const defsAttributesNS = { 'source-marker': { qualify: isPlainObject, + unset: 'marker-start', set: function(marker, refBBox, node, attrs) { marker = assign(contextMarker(attrs), marker); return { 'marker-start': 'url(#' + this.paper.defineMarker(marker) + ')' }; @@ -41,6 +42,7 @@ const defsAttributesNS = { 'target-marker': { qualify: isPlainObject, + unset: 'marker-end', set: function(marker, refBBox, node, attrs) { marker = assign(contextMarker(attrs), { 'transform': 'rotate(180)' }, marker); return { 'marker-end': 'url(#' + this.paper.defineMarker(marker) + ')' }; @@ -49,6 +51,7 @@ const defsAttributesNS = { 'vertex-marker': { qualify: isPlainObject, + unset: 'marker-mid', set: function(marker, refBBox, node, attrs) { marker = assign(contextMarker(attrs), marker); return { 'marker-mid': 'url(#' + this.paper.defineMarker(marker) + ')' }; diff --git a/packages/joint-core/src/dia/attributes/index.mjs b/packages/joint-core/src/dia/attributes/index.mjs index 571b31aa6..1ff28e092 100644 --- a/packages/joint-core/src/dia/attributes/index.mjs +++ b/packages/joint-core/src/dia/attributes/index.mjs @@ -49,6 +49,9 @@ const attributesNS = { }, 'html': { + unset: function(node) { + $(node).empty(); + }, set: function(html, refBBox, node) { $(node).html(html + ''); } diff --git a/packages/joint-core/src/dia/attributes/shape.mjs b/packages/joint-core/src/dia/attributes/shape.mjs index 90e2996e9..168db90ed 100644 --- a/packages/joint-core/src/dia/attributes/shape.mjs +++ b/packages/joint-core/src/dia/attributes/shape.mjs @@ -69,18 +69,22 @@ function pointsWrapper(opt) { const shapeAttributesNS = { 'ref-d-reset-offset': { + unset: 'd', set: dWrapper({ resetOffset: true }) }, 'ref-d-keep-offset': { + unset: 'd', set: dWrapper({ resetOffset: false }) }, 'ref-points-reset-offset': { + unset: 'points', set: pointsWrapper({ resetOffset: true }) }, 'ref-points-keep-offset': { + unset: 'points', set: pointsWrapper({ resetOffset: false }) }, }; diff --git a/packages/joint-core/src/dia/attributes/text.mjs b/packages/joint-core/src/dia/attributes/text.mjs index 973774345..0aefef4d8 100644 --- a/packages/joint-core/src/dia/attributes/text.mjs +++ b/packages/joint-core/src/dia/attributes/text.mjs @@ -40,6 +40,9 @@ const textAttributesNS = { const textWrap = attrs['text-wrap']; return !textWrap || !isPlainObject(textWrap); }, + unset: function(node) { + node.textContent = ''; + }, set: function(text, refBBox, node, attrs) { const cacheName = 'joint-text'; const cache = $.data.get(node, cacheName); @@ -171,6 +174,13 @@ const textAttributesNS = { // HTMLElement title is specified via an attribute (i.e. not an element) return node instanceof SVGElement; }, + unset: function(node) { + $.data.remove(node, 'joint-title'); + const titleNode = node.firstElementChild; + if (titleNode) { + titleNode.remove(); + } + }, set: function(title, refBBox, node) { var cacheName = 'joint-title'; var cache = $.data.get(node, cacheName); diff --git a/packages/joint-core/src/shapes/standard.mjs b/packages/joint-core/src/shapes/standard.mjs index f673373f3..8dc9898f3 100644 --- a/packages/joint-core/src/shapes/standard.mjs +++ b/packages/joint-core/src/shapes/standard.mjs @@ -530,7 +530,8 @@ export const Cylinder = Element.define('standard.Cylinder', { 'Z' ]; return { d: data.join(' ') }; - } + }, + unset: 'd' } } }); @@ -613,6 +614,12 @@ export const TextBlock = Element.define('standard.TextBlock', { return { fill: style.color || null }; } }, + unset: function(node) { + node.textContent = ''; + if (node instanceof SVGElement) { + return 'fill'; + } + }, position: function(text, refBBox, node) { // No foreign object if (node instanceof SVGElement) return refBBox.center(); diff --git a/packages/joint-core/test/jointjs/dia/attributes.js b/packages/joint-core/test/jointjs/dia/attributes.js index 372b66920..f5fcfce79 100644 --- a/packages/joint-core/test/jointjs/dia/attributes.js +++ b/packages/joint-core/test/jointjs/dia/attributes.js @@ -404,6 +404,242 @@ QUnit.module('Attributes', function() { }); + QUnit.module('Unset Attributes', function(hooks) { + + var paper, graph, cell, cellView; + + hooks.beforeEach(function() { + graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + var fixtures = document.getElementById('qunit-fixture'); + var paperEl = document.createElement('div'); + fixtures.appendChild(paperEl); + paper = new joint.dia.Paper({ el: paperEl, model: graph, cellViewNamespace: joint.shapes }); + cell = new joint.shapes.standard.Rectangle(); + cell.addTo(graph); + cellView = cell.findView(paper); + // custom presentation attributes + joint.dia.attributes.test1 = { set: 'test2' }; + }); + + hooks.afterEach(function() { + paper.remove(); + }); + + QUnit.test('unset() callback', function(assert) { + const unsetSpy = sinon.spy(); + joint.dia.attributes['test-attribute'] = { + unset: unsetSpy + }; + cell.attr('body/fill', 'purple'); + assert.ok(unsetSpy.notCalled); + cell.attr('body/testAttribute', 'test'); + assert.ok(unsetSpy.notCalled); + cell.attr('body/testAttribute', null); + assert.ok(unsetSpy.calledOnce); + assert.ok(unsetSpy.calledWithExactly( + cellView.findNode('body'), + sinon.match({ 'test-attribute': null, fill: 'purple' }), + cellView + )); + delete joint.dia.attributes['test-attribute']; + }); + + QUnit.module('unset() single attribute', function() { + QUnit.test('string', function(assert) { + joint.dia.attributes['test-attribute'] = { + set: 'a', + unset: 'a' + }; + cell.attr('body/testAttribute', 'value'); + const bodyNode = cellView.findNode('body'); + assert.equal(bodyNode.getAttribute('a'), 'value'); + cell.attr('body/testAttribute', null); + assert.notOk(bodyNode.getAttribute('a')); + delete joint.dia.attributes['test-attribute']; + }); + QUnit.test('function', function(assert) { + joint.dia.attributes['test-attribute'] = { + set: function(value) { + return { a: value }; + }, + unset: function() { + return 'a'; + } + }; + cell.attr('body/testAttribute', 'value'); + const bodyNode = cellView.findNode('body'); + assert.equal(bodyNode.getAttribute('a'), 'value'); + cell.attr('body/testAttribute', null); + assert.notOk(bodyNode.getAttribute('a')); + delete joint.dia.attributes['test-attribute']; + }); + }); + + QUnit.module('unset() multiple attributes', function() { + QUnit.test('string', function(assert) { + joint.dia.attributes['test-attribute'] = { + set: function(value) { + return { a: value, b: value }; + }, + unset: ['a', 'b'] + }; + cell.attr('body/testAttribute', 'value'); + const bodyNode = cellView.findNode('body'); + assert.equal(bodyNode.getAttribute('a'), 'value'); + assert.equal(bodyNode.getAttribute('b'), 'value'); + cell.attr('body/testAttribute', null); + assert.notOk(bodyNode.getAttribute('a')); + assert.notOk(bodyNode.getAttribute('b')); + }); + QUnit.test('function', function(assert) { + joint.dia.attributes['test-attribute'] = { + set: function(value) { + return { a: value, b: value }; + }, + unset: function() { + return ['a', 'b']; + } + }; + cell.attr('body/testAttribute', 'value'); + const bodyNode = cellView.findNode('body'); + assert.equal(bodyNode.getAttribute('a'), 'value'); + assert.equal(bodyNode.getAttribute('b'), 'value'); + cell.attr('body/testAttribute', null); + assert.notOk(bodyNode.getAttribute('a')); + assert.notOk(bodyNode.getAttribute('b')); + delete joint.dia.attributes['test-attribute']; + }); + }); + + [{ + attribute: 'no-def' + }, { + attribute: 'fill', + label: 'with-def', + value: 'blue', + }, { + attribute: 'test1', + label: 'alias', + svgAttribute: 'test2', + }, { + attribute: 'sourceMarker', + svgAttribute: 'marker-start', + value: { type: 'path', d: 'M 0 0 10 10' } + }, { + attribute: 'targetMarker', + svgAttribute: 'marker-end', + value: { type: 'path', d: 'M 0 0 10 10' } + }, { + attribute: 'vertexMarker', + svgAttribute: 'marker-mid', + value: { type: 'path', d: 'M 0 0 10 10' } + }, { + attribute: 'refD', + svgAttribute: 'd', + value: 'M 0 0 10 10', + }, { + attribute: 'refPoints', + svgAttribute: 'points', + value: '0,0 10,10', + }].forEach(function({ + attribute, + label = attribute, + svgAttribute = attribute, + value = true + }) { + QUnit.test(`attribute: ${label}`, function(assert) { + const path = ['body', attribute]; + const bodyNode = cellView.findNode('body'); + // set + cell.attr(path, value); + assert.ok(bodyNode.getAttribute(svgAttribute)); + // unset + cell.attr(path, null); + assert.notOk(bodyNode.getAttribute(svgAttribute)); + }); + }); + + [{ + test1: null, // unset `test2` + test2: 'test3' + }, { + test2: 'test3', + test1: null, // unset `test2` + }].forEach(function(attributes, index) { + QUnit.test(`unset order: ${index + 1}`, function(assert) { + cell.attr('body', attributes); + const bodyNode = cellView.findNode('body'); + assert.ok(bodyNode.getAttribute('test2')); + }); + }); + + QUnit.test('attribute: title', function(assert) { + cell.attr('body/title', 'test'); + const bodyNode = cellView.findNode('body'); + assert.ok(bodyNode.querySelector('title')); + cell.attr('body/title', null); + assert.notOk(bodyNode.querySelector('title')); + }); + + QUnit.test('attribute: text', function(assert) { + cell.attr('label/text', 'test'); + const textNode = cellView.findNode('label'); + assert.ok(textNode.firstChild); + cell.attr('label/text', null); + assert.notOk(textNode.firstChild); + cell.attr('label/text', ''); + assert.ok(textNode.firstChild); + }); + + QUnit.test('attribute: html', function(assert) { + cell.set('markup', joint.util.svg` + +
test
+
+ `); + const divNode = cellView.findNode('div'); + assert.ok(divNode); + cell.attr('div/html', 'test'); + assert.ok(divNode.firstChild); + cell.attr('div/html', null); + assert.notOk(divNode.firstChild); + }); + + QUnit.test('unset transform & position callback', function(assert) { + joint.dia.attributes['test-transform-attribute'] = { + unset: 'transform', + set: function(value) { + return { transform: `translate(${value},${value})` }; + } + }; + joint.dia.attributes['test-position-attribute'] = { + position(value) { + return new g.Point(value, value); + } + }; + + // set transform attribute + cell.attr('body/testTransformAttribute', 7); + const bodyNode = cellView.findNode('body'); + assert.ok(bodyNode.getAttribute('transform')); + assert.deepEqual(V(bodyNode).translate(), { tx: 7, ty: 7 }); + // unset transform attribute + cell.attr('body/testTransformAttribute', null); + assert.notOk(bodyNode.getAttribute('transform')); + assert.deepEqual(V(bodyNode).translate(), { tx: 0, ty: 0 }); + // position attribute and deleted transform + cell.attr('body/testPositionAttribute', 11); + assert.deepEqual(V(bodyNode).translate(), { tx: 11, ty: 11 }); + // position and set transform attribute + cell.attr('body/testTransformAttribute', 13); + assert.deepEqual(V(bodyNode).translate(), { tx: 13 + 11, ty: 13 + 11 }); + + delete joint.dia.attributes['test-transform-attribute']; + delete joint.dia.attributes['test-position-attribute']; + }); + }); + + QUnit.module('Calc()', function(hooks) { var X = 13; diff --git a/packages/joint-core/test/ts/index.test.ts b/packages/joint-core/test/ts/index.test.ts index e458c7ad6..621a715ed 100644 --- a/packages/joint-core/test/ts/index.test.ts +++ b/packages/joint-core/test/ts/index.test.ts @@ -198,3 +198,27 @@ rectangle.prop({ size: { width: 100 }}); new joint.shapes.standard.Rectangle({ position: { x: 100 }, }); + +class MyElement extends joint.dia.Element { + + static attributes = { + 'empty-attribute': {}, + 'set1-attribute': { + set: 'alias', + unset: 'alias' + }, + 'set2-attribute': { + set: () => ({ 'alias': 21 }), + unset: ['alias'] + }, + 'set3-attribute': { + set: function() { return 21; }, + }, + 'position-attribute': { + position: () => ({ x: 5, y: 7 }) + }, + 'offset-attribute': { + offset: () => ({ x: 11, y: 13 }) + }, + }; +} diff --git a/packages/joint-core/types/joint.d.ts b/packages/joint-core/types/joint.d.ts index 2d93cebed..a06489af5 100644 --- a/packages/joint-core/types/joint.d.ts +++ b/packages/joint-core/types/joint.d.ts @@ -339,6 +339,47 @@ export namespace dia { ignoreDefault?: boolean | string[]; ignoreEmptyAttributes?: boolean; } + + type UnsetCallback = ( + this: V, + node: Element, + nodeAttributes: { [name: string]: any }, + cellView: V + ) => string | Array | null | void; + + type SetCallback = ( + this: V, + attributeValue: any, + refBBox: g.Rect, + node: Element, + nodeAttributes: { [name: string]: any }, + cellView: V + ) => { [key: string]: any } | string | number | void; + + type PositionCallback = ( + this: V, + attributeValue: any, + refBBox: g.Rect, + node: Element, + nodeAttributes: { [name: string]: any }, + cellView: V + ) => dia.Point | null | void; + + type OffsetCallback = ( + this: V, + attributeValue: any, + nodeBBox: g.Rect, + node: Element, + nodeAttributes: { [name: string]: any }, + cellView: V + ) => dia.Point | null | void; + + interface PresentationAttributeDefinition { + set?: SetCallback | string; + unset?: UnsetCallback | string | Array; + position?: PositionCallback; + offset?: OffsetCallback; + } } class Cell extends mvc.Model { @@ -510,7 +551,7 @@ export namespace dia { } interface ResizeOptions extends Cell.Options { - direction?: Direction + direction?: Direction; } interface BBoxOptions extends Cell.EmbeddableOptions { @@ -578,6 +619,8 @@ export namespace dia { protected generatePortId(): string | number; static define(type: string, defaults?: any, protoProps?: any, staticProps?: any): Cell.Constructor; + + static attributes: { [attributeName: string]: Cell.PresentationAttributeDefinition }; } // dia.Link @@ -726,6 +769,8 @@ export namespace dia { translate(tx: number, ty: number, opt?: S): this; static define(type: string, defaults?: any, protoProps?: any, staticProps?: any): Cell.Constructor; + + static attributes: { [attributeName: string]: Cell.PresentationAttributeDefinition }; } // dia.CellView