UNPKG

jointjs

Version:

JavaScript diagramming library

670 lines (572 loc) 20.6 kB
import { Point, Path, Polyline } from '../../g/index.mjs'; import { assign, isPlainObject, pick, isObject, isPercentage, breakText } from '../../util/util.mjs'; import { isCalcAttribute, evalCalcAttribute } from './calc.mjs'; import $ from 'jquery'; import V from '../../V/index.mjs'; function setWrapper(attrName, dimension) { return function(value, refBBox) { var isValuePercentage = isPercentage(value); value = parseFloat(value); if (isValuePercentage) { value /= 100; } var attrs = {}; if (isFinite(value)) { var attrValue = (isValuePercentage || value >= 0 && value <= 1) ? value * refBBox[dimension] : Math.max(value + refBBox[dimension], 0); attrs[attrName] = attrValue; } return attrs; }; } function positionWrapper(axis, dimension, origin) { return function(value, refBBox) { var valuePercentage = isPercentage(value); value = parseFloat(value); if (valuePercentage) { value /= 100; } var delta; if (isFinite(value)) { var refOrigin = refBBox[origin](); if (valuePercentage || value > 0 && value < 1) { delta = refOrigin[axis] + refBBox[dimension] * value; } else { delta = refOrigin[axis] + value; } } var point = Point(); point[axis] = delta || 0; return point; }; } function offsetWrapper(axis, dimension, corner) { return function(value, nodeBBox) { var delta; if (value === 'middle') { delta = nodeBBox[dimension] / 2; } else if (value === corner) { delta = nodeBBox[dimension]; } else if (isFinite(value)) { // TODO: or not to do a breaking change? delta = (value > -1 && value < 1) ? (-nodeBBox[dimension] * value) : -value; } else if (isPercentage(value)) { delta = nodeBBox[dimension] * parseFloat(value) / 100; } else { delta = 0; } var point = Point(); point[axis] = -(nodeBBox[axis] + delta); return point; }; } function shapeWrapper(shapeConstructor, opt) { var cacheName = 'joint-shape'; var resetOffset = opt && opt.resetOffset; return function(value, refBBox, node) { var $node = $(node); var cache = $node.data(cacheName); if (!cache || cache.value !== value) { // only recalculate if value has changed var cachedShape = shapeConstructor(value); cache = { value: value, shape: cachedShape, shapeBBox: cachedShape.bbox() }; $node.data(cacheName, cache); } var shape = cache.shape.clone(); var shapeBBox = cache.shapeBBox.clone(); var shapeOrigin = shapeBBox.origin(); var refOrigin = refBBox.origin(); shapeBBox.x = refOrigin.x; shapeBBox.y = refOrigin.y; var fitScale = refBBox.maxRectScaleToFit(shapeBBox, refOrigin); // `maxRectScaleToFit` can give Infinity if width or height is 0 var sx = (shapeBBox.width === 0 || refBBox.width === 0) ? 1 : fitScale.sx; var sy = (shapeBBox.height === 0 || refBBox.height === 0) ? 1 : fitScale.sy; shape.scale(sx, sy, shapeOrigin); if (resetOffset) { shape.translate(-shapeOrigin.x, -shapeOrigin.y); } return shape; }; } // `d` attribute for SVGPaths function dWrapper(opt) { function pathConstructor(value) { return new Path(V.normalizePathData(value)); } var shape = shapeWrapper(pathConstructor, opt); return function(value, refBBox, node) { var path = shape(value, refBBox, node); return { d: path.serialize() }; }; } // `points` attribute for SVGPolylines and SVGPolygons function pointsWrapper(opt) { var shape = shapeWrapper(Polyline, opt); return function(value, refBBox, node) { var polyline = shape(value, refBBox, node); return { points: polyline.serialize() }; }; } function atConnectionWrapper(method, opt) { var zeroVector = new Point(1, 0); return function(value) { var p, angle; var tangent = this[method](value); if (tangent) { angle = (opt.rotate) ? tangent.vector().vectorAngle(zeroVector) : 0; p = tangent.start; } else { p = this.path.start; angle = 0; } if (angle === 0) return { transform: 'translate(' + p.x + ',' + p.y + ')' }; return { transform: 'translate(' + p.x + ',' + p.y + ') rotate(' + angle + ')' }; }; } function isTextInUse(_value, _node, attrs) { return (attrs.text !== undefined); } function isLinkView() { return this.model.isLink(); } function contextMarker(context) { var marker = {}; // Stroke // The context 'fill' is disregared here. The usual case is to use the marker with a connection // (for which 'fill' attribute is set to 'none'). var stroke = context.stroke; if (typeof stroke === 'string') { marker['stroke'] = stroke; marker['fill'] = stroke; } // Opacity // Again the context 'fill-opacity' is ignored. var strokeOpacity = context.strokeOpacity; if (strokeOpacity === undefined) strokeOpacity = context['stroke-opacity']; if (strokeOpacity === undefined) strokeOpacity = context.opacity; if (strokeOpacity !== undefined) { marker['stroke-opacity'] = strokeOpacity; marker['fill-opacity'] = strokeOpacity; } return marker; } const attributesNS = { xlinkHref: { set: 'xlink:href' }, xlinkShow: { set: 'xlink:show' }, xlinkRole: { set: 'xlink:role' }, xlinkType: { set: 'xlink:type' }, xlinkArcrole: { set: 'xlink:arcrole' }, xlinkTitle: { set: 'xlink:title' }, xlinkActuate: { set: 'xlink:actuate' }, xmlSpace: { set: 'xml:space' }, xmlBase: { set: 'xml:base' }, xmlLang: { set: 'xml:lang' }, preserveAspectRatio: { set: 'preserveAspectRatio' }, requiredExtension: { set: 'requiredExtension' }, requiredFeatures: { set: 'requiredFeatures' }, systemLanguage: { set: 'systemLanguage' }, externalResourcesRequired: { set: 'externalResourceRequired' }, filter: { qualify: isPlainObject, set: function(filter) { return 'url(#' + this.paper.defineFilter(filter) + ')'; } }, fill: { qualify: isPlainObject, set: function(fill) { return 'url(#' + this.paper.defineGradient(fill) + ')'; } }, stroke: { qualify: isPlainObject, set: function(stroke) { return 'url(#' + this.paper.defineGradient(stroke) + ')'; } }, sourceMarker: { qualify: isPlainObject, set: function(marker, refBBox, node, attrs) { marker = assign(contextMarker(attrs), marker); return { 'marker-start': 'url(#' + this.paper.defineMarker(marker) + ')' }; } }, targetMarker: { qualify: isPlainObject, set: function(marker, refBBox, node, attrs) { marker = assign(contextMarker(attrs), { 'transform': 'rotate(180)' }, marker); return { 'marker-end': 'url(#' + this.paper.defineMarker(marker) + ')' }; } }, vertexMarker: { qualify: isPlainObject, set: function(marker, refBBox, node, attrs) { marker = assign(contextMarker(attrs), marker); return { 'marker-mid': 'url(#' + this.paper.defineMarker(marker) + ')' }; } }, text: { qualify: function(_text, _node, attrs) { return !attrs.textWrap || !isPlainObject(attrs.textWrap); }, set: function(text, refBBox, node, attrs) { var $node = $(node); var cacheName = 'joint-text'; var cache = $node.data(cacheName); var textAttrs = pick(attrs, 'lineHeight', 'annotations', 'textPath', 'x', 'textVerticalAnchor', 'eol', 'displayEmpty'); // eval `x` if using calc() const { x } = textAttrs; if (isCalcAttribute(x)) { textAttrs.x = evalCalcAttribute(x, refBBox); } var fontSize = textAttrs.fontSize = attrs['font-size'] || attrs['fontSize']; var textHash = JSON.stringify([text, textAttrs]); // Update the text only if there was a change in the string // or any of its attributes. if (cache === undefined || cache !== textHash) { // Chrome bug: // Tspans positions defined as `em` are not updated // when container `font-size` change. if (fontSize) node.setAttribute('font-size', fontSize); // Text Along Path Selector var textPath = textAttrs.textPath; if (isObject(textPath)) { var pathSelector = textPath.selector; if (typeof pathSelector === 'string') { var pathNode = this.findBySelector(pathSelector)[0]; if (pathNode instanceof SVGPathElement) { textAttrs.textPath = assign({ 'xlink:href': '#' + pathNode.id }, textPath); } } } V(node).text('' + text, textAttrs); $node.data(cacheName, textHash); } } }, textWrap: { qualify: isPlainObject, set: function(value, refBBox, node, attrs) { // option `width` var width = value.width || 0; if (isPercentage(width)) { refBBox.width *= parseFloat(width) / 100; } else if (width <= 0) { refBBox.width += width; } else { refBBox.width = width; } // option `height` var height = value.height || 0; if (isPercentage(height)) { refBBox.height *= parseFloat(height) / 100; } else if (height <= 0) { refBBox.height += height; } else { refBBox.height = height; } // option `text` var wrappedText; var text = value.text; if (text === undefined) text = attrs.text; if (text !== undefined) { wrappedText = breakText('' + text, refBBox, { 'font-weight': attrs['font-weight'] || attrs.fontWeight, 'font-size': attrs['font-size'] || attrs.fontSize, 'font-family': attrs['font-family'] || attrs.fontFamily, 'lineHeight': attrs.lineHeight, 'letter-spacing': 'letter-spacing' in attrs ? attrs['letter-spacing'] : attrs.letterSpacing }, { // Provide an existing SVG Document here // instead of creating a temporary one over again. svgDocument: this.paper.svg, ellipsis: value.ellipsis, hyphen: value.hyphen, maxLineCount: value.maxLineCount }); } else { wrappedText = ''; } attributesNS.text.set.call(this, wrappedText, refBBox, node, attrs); } }, title: { qualify: function(title, node) { // HTMLElement title is specified via an attribute (i.e. not an element) return node instanceof SVGElement; }, set: function(title, refBBox, node) { var $node = $(node); var cacheName = 'joint-title'; var cache = $node.data(cacheName); if (cache === undefined || cache !== title) { $node.data(cacheName, title); // Generally <title> element should be the first child element of its parent. var firstChild = node.firstChild; if (firstChild && firstChild.tagName.toUpperCase() === 'TITLE') { // Update an existing title firstChild.textContent = title; } else { // Create a new title var titleNode = document.createElementNS(node.namespaceURI, 'title'); titleNode.textContent = title; node.insertBefore(titleNode, firstChild); } } } }, lineHeight: { qualify: isTextInUse }, textVerticalAnchor: { qualify: isTextInUse }, textPath: { qualify: isTextInUse }, annotations: { qualify: isTextInUse }, eol: { qualify: isTextInUse }, displayEmpty: { qualify: isTextInUse }, // `port` attribute contains the `id` of the port that the underlying magnet represents. port: { set: function(port) { return (port === null || port.id === undefined) ? port : port.id; } }, // `style` attribute is special in the sense that it sets the CSS style of the subelement. style: { qualify: isPlainObject, set: function(styles, refBBox, node) { $(node).css(styles); } }, html: { set: function(html, refBBox, node) { $(node).html(html + ''); } }, ref: { // We do not set `ref` attribute directly on an element. // The attribute itself does not qualify for relative positioning. }, // if `refX` is in [0, 1] then `refX` is a fraction of bounding box width // if `refX` is < 0 then `refX`'s absolute values is the right coordinate of the bounding box // otherwise, `refX` is the left coordinate of the bounding box refX: { position: positionWrapper('x', 'width', 'origin') }, refY: { position: positionWrapper('y', 'height', 'origin') }, // `ref-dx` and `ref-dy` define the offset of the subelement relative to the right and/or bottom // coordinate of the reference element. refDx: { position: positionWrapper('x', 'width', 'corner') }, refDy: { position: positionWrapper('y', 'height', 'corner') }, // 'ref-width'/'ref-height' defines the width/height of the subelement relatively to // the reference element size // val in 0..1 ref-width = 0.75 sets the width to 75% of the ref. el. width // val < 0 || val > 1 ref-height = -20 sets the height to the ref. el. height shorter by 20 refWidth: { set: setWrapper('width', 'width') }, refHeight: { set: setWrapper('height', 'height') }, refRx: { set: setWrapper('rx', 'width') }, refRy: { set: setWrapper('ry', 'height') }, refRInscribed: { set: (function(attrName) { var widthFn = setWrapper(attrName, 'width'); var heightFn = setWrapper(attrName, 'height'); return function(value, refBBox) { var fn = (refBBox.height > refBBox.width) ? widthFn : heightFn; return fn(value, refBBox); }; })('r') }, refRCircumscribed: { set: function(value, refBBox) { var isValuePercentage = isPercentage(value); value = parseFloat(value); if (isValuePercentage) { value /= 100; } var diagonalLength = Math.sqrt((refBBox.height * refBBox.height) + (refBBox.width * refBBox.width)); var rValue; if (isFinite(value)) { if (isValuePercentage || value >= 0 && value <= 1) rValue = value * diagonalLength; else rValue = Math.max(value + diagonalLength, 0); } return { r: rValue }; } }, refCx: { set: setWrapper('cx', 'width') }, refCy: { set: setWrapper('cy', 'height') }, // `x-alignment` when set to `middle` causes centering of the subelement around its new x coordinate. // `x-alignment` when set to `right` uses the x coordinate as referenced to the right of the bbox. xAlignment: { offset: offsetWrapper('x', 'width', 'right') }, // `y-alignment` when set to `middle` causes centering of the subelement around its new y coordinate. // `y-alignment` when set to `bottom` uses the y coordinate as referenced to the bottom of the bbox. yAlignment: { offset: offsetWrapper('y', 'height', 'bottom') }, resetOffset: { offset: function(val, nodeBBox) { return (val) ? { x: -nodeBBox.x, y: -nodeBBox.y } : { x: 0, y: 0 }; } }, refDResetOffset: { set: dWrapper({ resetOffset: true }) }, refDKeepOffset: { set: dWrapper({ resetOffset: false }) }, refPointsResetOffset: { set: pointsWrapper({ resetOffset: true }) }, refPointsKeepOffset: { set: pointsWrapper({ resetOffset: false }) }, // LinkView Attributes connection: { qualify: isLinkView, set: function({ stubs = 0 }) { let d; if (isFinite(stubs) && stubs !== 0) { let offset; if (stubs < 0) { offset = (this.getConnectionLength() + stubs) / 2; } else { offset = stubs; } const path = this.getConnection(); const sourceParts = path.divideAtLength(offset); const targetParts = path.divideAtLength(-offset); if (sourceParts && targetParts) { d = `${sourceParts[0].serialize()} ${targetParts[1].serialize()}`; } } return { d: d || this.getSerializedConnection() }; } }, atConnectionLengthKeepGradient: { qualify: isLinkView, set: atConnectionWrapper('getTangentAtLength', { rotate: true }) }, atConnectionLengthIgnoreGradient: { qualify: isLinkView, set: atConnectionWrapper('getTangentAtLength', { rotate: false }) }, atConnectionRatioKeepGradient: { qualify: isLinkView, set: atConnectionWrapper('getTangentAtRatio', { rotate: true }) }, atConnectionRatioIgnoreGradient: { qualify: isLinkView, set: atConnectionWrapper('getTangentAtRatio', { rotate: false }) } }; // Support `calc()` with the following SVG attributes [ 'transform', // g 'd', // path 'points', // polyline / polygon 'width', 'height', // rect / image 'cx', 'cy', // circle / ellipse 'r', // circle 'rx', 'ry', // rect / ellipse 'x1', 'x2', 'y1', 'y2', // line 'x', 'y', // rect / text / image 'dx', 'dy' // text ].forEach(attribute => { attributesNS[attribute] = { qualify: isCalcAttribute, set: function setCalcAttribute(value, refBBox) { return { [attribute]: evalCalcAttribute(value, refBBox) }; } }; }); // Aliases attributesNS.refR = attributesNS.refRInscribed; attributesNS.refD = attributesNS.refDResetOffset; attributesNS.refPoints = attributesNS.refPointsResetOffset; attributesNS.atConnectionLength = attributesNS.atConnectionLengthKeepGradient; attributesNS.atConnectionRatio = attributesNS.atConnectionRatioKeepGradient; // This allows to combine both absolute and relative positioning // refX: 50%, refX2: 20 attributesNS.refX2 = attributesNS.refX; attributesNS.refY2 = attributesNS.refY; attributesNS.refWidth2 = attributesNS.refWidth; attributesNS.refHeight2 = attributesNS.refHeight; // Aliases for backwards compatibility attributesNS['ref-x'] = attributesNS.refX; attributesNS['ref-y'] = attributesNS.refY; attributesNS['ref-dy'] = attributesNS.refDy; attributesNS['ref-dx'] = attributesNS.refDx; attributesNS['ref-width'] = attributesNS.refWidth; attributesNS['ref-height'] = attributesNS.refHeight; attributesNS['x-alignment'] = attributesNS.xAlignment; attributesNS['y-alignment'] = attributesNS.yAlignment; export const attributes = attributesNS;