@joint/core
Version:
JavaScript diagramming library
2,265 lines • 77.3 kB
JavaScript
import { CellView } from './CellView.mjs';
import { Link } from './Link.mjs';
import V from '../V/index.mjs';
import { addClassNamePrefix, merge, assign, isObject, isFunction, clone, isPercentage, result, isEqual } from '../util/index.mjs';
import { Point, Line, Path, normalizeAngle, Rect, Polyline, intersection } from '../g/index.mjs';
import * as routers from '../routers/index.mjs';
import * as connectors from '../connectors/index.mjs';
import { env } from '../env/index.mjs';
const Flags = {
TOOLS: CellView.Flags.TOOLS,
RENDER: 'RENDER',
UPDATE: 'UPDATE',
LABELS: 'LABELS',
SOURCE: 'SOURCE',
TARGET: 'TARGET',
CONNECTOR: 'CONNECTOR'
};
// Link base view and controller.
// ----------------------------------------
export const LinkView = CellView.extend({
className: function() {
var classNames = CellView.prototype.className.apply(this).split(' ');
classNames.push('link');
return classNames.join(' ');
},
_labelCache: null,
_labelSelectors: null,
_V: null,
_dragData: null, // deprecated
metrics: null,
decimalsRounding: 2,
initialize: function() {
CellView.prototype.initialize.apply(this, arguments);
// `_.labelCache` is a mapping of indexes of labels in the `this.get('labels')` array to
// `<g class="label">` nodes wrapped by Vectorizer. This allows for quick access to the
// nodes in `updateLabelPosition()` in order to update the label positions.
this._labelCache = {};
// a cache of label selectors
this._labelSelectors = {};
// cache of default markup nodes
this._V = {};
// connection path metrics
this.cleanNodesCache();
},
presentationAttributes: {
markup: [Flags.RENDER],
attrs: [Flags.UPDATE],
router: [Flags.UPDATE],
connector: [Flags.CONNECTOR],
labels: [Flags.LABELS, Flags.TOOLS],
labelMarkup: [Flags.LABELS],
vertices: [Flags.UPDATE],
source: [Flags.SOURCE, Flags.UPDATE],
target: [Flags.TARGET, Flags.UPDATE]
},
initFlag: [Flags.RENDER, Flags.SOURCE, Flags.TARGET, Flags.TOOLS],
UPDATE_PRIORITY: 1,
EPSILON: 1e-6,
confirmUpdate: function(flags, opt) {
opt || (opt = {});
if (this.hasFlag(flags, Flags.SOURCE)) {
if (!this.updateEndProperties('source')) return flags;
flags = this.removeFlag(flags, Flags.SOURCE);
}
if (this.hasFlag(flags, Flags.TARGET)) {
if (!this.updateEndProperties('target')) return flags;
flags = this.removeFlag(flags, Flags.TARGET);
}
const { paper, sourceView, targetView } = this;
if (paper && ((sourceView && !paper.isViewMounted(sourceView)) || (targetView && !paper.isViewMounted(targetView)))) {
// Wait for the sourceView and targetView to be rendered
return flags;
}
if (this.hasFlag(flags, Flags.RENDER)) {
this.render();
this.updateHighlighters(true);
this.updateTools(opt);
flags = this.removeFlag(flags, [Flags.RENDER, Flags.UPDATE, Flags.LABELS, Flags.TOOLS, Flags.CONNECTOR]);
if (env.test('isSafari')) {
this.__fixSafariBug268376();
}
return flags;
}
let updateHighlighters = false;
const { model } = this;
const { attributes } = model;
let updateLabels = this.hasFlag(flags, Flags.LABELS);
if (updateLabels) {
this.onLabelsChange(model, attributes.labels, opt);
flags = this.removeFlag(flags, Flags.LABELS);
updateHighlighters = true;
}
const updateAll = this.hasFlag(flags, Flags.UPDATE);
const updateConnector = this.hasFlag(flags, Flags.CONNECTOR);
if (updateAll || updateConnector) {
if (!updateAll) {
// Keep the current route and update the geometry
this.updatePath();
this.updateDOM();
} else if (opt.translateBy && model.isRelationshipEmbeddedIn(opt.translateBy)) {
// The link is being translated by an ancestor that will
// shift source point, target point and all vertices
// by an equal distance.
this.translate(opt.tx, opt.ty);
} else {
this.update();
}
this.updateTools(opt);
flags = this.removeFlag(flags, [Flags.UPDATE, Flags.TOOLS, Flags.CONNECTOR]);
updateLabels = false;
updateHighlighters = true;
}
if (updateLabels) {
this.updateLabelPositions();
}
if (updateHighlighters) {
this.updateHighlighters();
}
if (this.hasFlag(flags, Flags.TOOLS)) {
this.updateTools(opt);
flags = this.removeFlag(flags, Flags.TOOLS);
}
return flags;
},
__fixSafariBug268376: function() {
// Safari has a bug where any change after the first render is not reflected in the DOM.
// https://bugs.webkit.org/show_bug.cgi?id=268376
const { el } = this;
const childNodes = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
for (let i = 0, n = childNodes.length; i < n; i++) {
el.removeChild(childNodes[i]);
fragment.appendChild(childNodes[i]);
}
el.appendChild(fragment);
},
requestConnectionUpdate: function(opt) {
this.requestUpdate(this.getFlag(Flags.UPDATE), opt);
},
isLabelsRenderRequired: function(opt = {}) {
const previousLabels = this.model.previous('labels');
if (!previousLabels) return true;
// Here is an optimization for cases when we know, that change does
// not require re-rendering of all labels.
if (('propertyPathArray' in opt) && ('propertyValue' in opt)) {
// The label is setting by `prop()` method
var pathArray = opt.propertyPathArray || [];
var pathLength = pathArray.length;
if (pathLength > 1) {
// We are changing a single label here e.g. 'labels/0/position'
var labelExists = !!previousLabels[pathArray[1]];
if (labelExists) {
if (pathLength === 2) {
// We are changing the entire label. Need to check if the
// markup is also being changed.
return ('markup' in Object(opt.propertyValue));
} else if (pathArray[2] !== 'markup') {
// We are changing a label property but not the markup
return false;
}
}
}
}
return true;
},
onLabelsChange: function(_link, _labels, opt) {
// Note: this optimization works in async=false mode only
if (this.isLabelsRenderRequired(opt)) {
this.renderLabels();
} else {
this.updateLabels();
}
},
// Rendering.
// ----------
render: function() {
this.vel.empty();
this.unmountLabels();
this._V = {};
this.renderMarkup();
// rendering labels has to be run after the link is appended to DOM tree. (otherwise <Text> bbox
// returns zero values)
this.renderLabels();
this.update();
return this;
},
renderMarkup: function() {
var link = this.model;
var markup = link.get('markup') || link.markup;
if (!markup) throw new Error('dia.LinkView: markup required');
if (Array.isArray(markup)) return this.renderJSONMarkup(markup);
if (typeof markup === 'string') return this.renderStringMarkup(markup);
throw new Error('dia.LinkView: invalid markup');
},
renderJSONMarkup: function(markup) {
var doc = this.parseDOMJSON(markup, this.el);
// Selectors
this.selectors = doc.selectors;
// Fragment
this.vel.append(doc.fragment);
},
renderStringMarkup: function(markup) {
// A special markup can be given in the `properties.markup` property. This might be handy
// if e.g. arrowhead markers should be `<image>` elements or any other element than `<path>`s.
// `.connection`, `.connection-wrap`, `.marker-source` and `.marker-target` selectors
// of elements with special meaning though. Therefore, those classes should be preserved in any
// special markup passed in `properties.markup`.
var children = V(markup);
// custom markup may contain only one children
if (!Array.isArray(children)) children = [children];
this.vel.append(children);
},
_getLabelMarkup: function(labelMarkup) {
if (!labelMarkup) return undefined;
if (Array.isArray(labelMarkup)) return this.parseDOMJSON(labelMarkup, null);
if (typeof labelMarkup === 'string') return this._getLabelStringMarkup(labelMarkup);
throw new Error('dia.linkView: invalid label markup');
},
_getLabelStringMarkup: function(labelMarkup) {
var children = V(labelMarkup);
var fragment = document.createDocumentFragment();
if (!Array.isArray(children)) {
fragment.appendChild(children.node);
} else {
for (var i = 0, n = children.length; i < n; i++) {
var currentChild = children[i].node;
fragment.appendChild(currentChild);
}
}
return { fragment: fragment, selectors: {}}; // no selectors
},
// Label markup fragment may come wrapped in <g class="label" />, or not.
// If it doesn't, add the <g /> container here.
_normalizeLabelMarkup: function(markup) {
if (!markup) return undefined;
var fragment = markup.fragment;
if (!(markup.fragment instanceof DocumentFragment) || !markup.fragment.hasChildNodes()) throw new Error('dia.LinkView: invalid label markup.');
var vNode;
var childNodes = fragment.childNodes;
if ((childNodes.length > 1) || childNodes[0].nodeName.toUpperCase() !== 'G') {
// default markup fragment is not wrapped in <g />
// add a <g /> container
vNode = V('g').append(fragment);
} else {
vNode = V(childNodes[0]);
}
vNode.addClass('label');
return { node: vNode.node, selectors: markup.selectors };
},
renderLabels: function() {
var cache = this._V;
var vLabels = cache.labels;
var labelCache = this._labelCache = {};
var labelSelectors = this._labelSelectors = {};
var model = this.model;
var labels = model.attributes.labels || [];
var labelsCount = labels.length;
if (labelsCount === 0) {
if (vLabels) vLabels.remove();
return this;
}
if (vLabels) {
vLabels.empty();
} else {
// there is no label container in the markup but some labels are defined
// add a <g class="labels" /> container
vLabels = cache.labels = V('g').addClass('labels');
if (this.options.labelsLayer) {
vLabels.addClass(addClassNamePrefix(result(this, 'className')));
vLabels.attr('model-id', model.id);
}
}
for (var i = 0; i < labelsCount; i++) {
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;
}
labelNode.setAttribute('label-idx', i); // assign label-idx
vLabels.append(labelNode);
labelCache[i] = labelNode; // cache node for `updateLabels()` so it can just update label node positions
var rootSelector = this.selector;
if (selectors[rootSelector]) throw new Error('dia.LinkView: ambiguous label root selector.');
selectors[rootSelector] = labelNode;
labelSelectors[i] = selectors; // cache label selectors for `updateLabels()`
}
if (!vLabels.parent()) {
this.mountLabels();
}
this.updateLabels();
return this;
},
mountLabels: function() {
const { el, paper, model, _V, options } = this;
const { labels: vLabels } = _V;
if (!vLabels || !model.hasLabels()) return;
const { node } = vLabels;
if (options.labelsLayer) {
paper.getLayerView(options.labelsLayer).insertSortedNode(node, model.get('z'));
} else {
if (node.parentNode !== el) {
el.appendChild(node);
}
}
},
unmountLabels: function() {
const { options, _V } = this;
if (!_V) return;
const { labels: vLabels } = _V;
if (vLabels && options.labelsLayer) {
vLabels.remove();
}
},
findLabelNodes: function(labelIndex, selector) {
const labelRoot = this._labelCache[labelIndex];
if (!labelRoot) return [];
const labelSelectors = this._labelSelectors[labelIndex];
return this.findBySelector(selector, labelRoot, labelSelectors);
},
findLabelNode: function(labelIndex, selector) {
const [node = null] = this.findLabelNodes(labelIndex, selector);
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;
if (!this.paper.options.labelLayer) {
// If there is no label layer, the cache needs to be cleared
// of the root node because the labels are attached
// to it and could affect the bounding box.
this.cleanNodeCache(this.el);
}
var model = this.model;
var labels = model.get('labels') || [];
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),
selectors: selectors
});
}
return this;
},
// remove vertices that lie on (or nearly on) straight lines within the link
// return the number of removed points
removeRedundantLinearVertices: function(opt) {
const SIMPLIFY_THRESHOLD = 0.001;
const link = this.model;
const vertices = link.vertices();
const routePoints = [this.sourceAnchor, ...vertices, this.targetAnchor];
const numRoutePoints = routePoints.length;
// put routePoints into a polyline and try to simplify
const polyline = new Polyline(routePoints);
polyline.simplify({ threshold: SIMPLIFY_THRESHOLD });
const polylinePoints = polyline.points.map((point) => (point.toJSON())); // JSON of points after simplification
const numPolylinePoints = polylinePoints.length; // number of points after simplification
// shortcut if simplification did not remove any redundant vertices:
if (numRoutePoints === numPolylinePoints) return 0;
// else: set simplified polyline points as link vertices
// remove first and last polyline points again (= source/target anchors)
link.vertices(polylinePoints.slice(1, numPolylinePoints - 1), opt);
return (numRoutePoints - numPolylinePoints);
},
getEndView: function(type) {
switch (type) {
case 'source':
return this.sourceView || null;
case 'target':
return this.targetView || null;
default:
throw new Error('dia.LinkView: type parameter required.');
}
},
getEndAnchor: function(type) {
switch (type) {
case 'source':
return new Point(this.sourceAnchor);
case 'target':
return new Point(this.targetAnchor);
default:
throw new Error('dia.LinkView: type parameter required.');
}
},
getEndConnectionPoint: function(type) {
switch (type) {
case 'source':
return new Point(this.sourcePoint);
case 'target':
return new Point(this.targetPoint);
default:
throw new Error('dia.LinkView: type parameter required.');
}
},
getEndMagnet: function(type) {
switch (type) {
case 'source':
var sourceView = this.sourceView;
if (!sourceView) break;
return this.sourceMagnet || sourceView.el;
case 'target':
var targetView = this.targetView;
if (!targetView) break;
return this.targetMagnet || targetView.el;
default:
throw new Error('dia.LinkView: type parameter required.');
}
return null;
},
// Updating.
// ---------
update: function() {
this.updateRoute();
this.updatePath();
this.updateDOM();
return this;
},
translate: function(tx = 0, ty = 0) {
const { route, path } = this;
if (!route || !path) return;
// translate the route
const polyline = new Polyline(route);
polyline.translate(tx, ty);
this.route = polyline.points;
// translate source and target connection and anchor points.
this.sourcePoint.offset(tx, ty);
this.targetPoint.offset(tx, ty);
this.sourceAnchor.offset(tx, ty);
this.targetAnchor.offset(tx, ty);
// translate the geometry path
path.translate(tx, ty);
this.updateDOM();
},
updateDOM() {
const { el, model, selectors } = this;
this.cleanNodesCache();
// update SVG attributes defined by 'attrs/'.
this.updateDOMSubtreeAttributes(el, model.attr(), { selectors });
// update the label position etc.
this.updateLabelPositions();
// *Deprecated*
// Local perpendicular flag (as opposed to one defined on paper).
// Could be enabled inside a connector/router. It's valid only
// during the update execution.
this.options.perpendicular = null;
},
updateRoute: function() {
const { model } = this;
const vertices = model.vertices();
// 1. Find Anchors
const anchors = this.findAnchors(vertices);
const sourceAnchor = this.sourceAnchor = anchors.source;
const targetAnchor = this.targetAnchor = anchors.target;
// 2. Find Route
const route = this.findRoute(vertices);
this.route = route;
// 3. Find Connection Points
var connectionPoints = this.findConnectionPoints(route, sourceAnchor, targetAnchor);
this.sourcePoint = connectionPoints.source;
this.targetPoint = connectionPoints.target;
},
updatePath: function() {
const { route, sourcePoint, targetPoint } = this;
// 4. Find Connection
const path = this.findPath(route, sourcePoint.clone(), targetPoint.clone());
this.path = path;
},
findAnchorsOrdered: function(firstEndType, firstRef, secondEndType, secondRef) {
var firstAnchor, secondAnchor;
var firstAnchorRef, secondAnchorRef;
var model = this.model;
var firstDef = model.get(firstEndType);
var secondDef = model.get(secondEndType);
var firstView = this.getEndView(firstEndType);
var secondView = this.getEndView(secondEndType);
var firstMagnet = this.getEndMagnet(firstEndType);
var secondMagnet = this.getEndMagnet(secondEndType);
// Anchor first
if (firstView) {
if (firstRef) {
firstAnchorRef = new Point(firstRef);
} else if (secondView) {
firstAnchorRef = secondMagnet;
} else {
firstAnchorRef = new Point(secondDef);
}
firstAnchor = this.getAnchor(firstDef.anchor, firstView, firstMagnet, firstAnchorRef, firstEndType);
} else {
firstAnchor = new Point(firstDef);
}
// Anchor second
if (secondView) {
secondAnchorRef = new Point(secondRef || firstAnchor);
secondAnchor = this.getAnchor(secondDef.anchor, secondView, secondMagnet, secondAnchorRef, secondEndType);
} else {
secondAnchor = new Point(secondDef);
}
var res = {};
res[firstEndType] = firstAnchor;
res[secondEndType] = secondAnchor;
return res;
},
findAnchors: function(vertices) {
var model = this.model;
var firstVertex = vertices[0];
var lastVertex = vertices[vertices.length - 1];
if (model.target().priority && !model.source().priority) {
// Reversed order
return this.findAnchorsOrdered('target', lastVertex, 'source', firstVertex);
}
// Usual order
return this.findAnchorsOrdered('source', firstVertex, 'target', lastVertex);
},
findConnectionPoints: function(route, sourceAnchor, targetAnchor) {
var firstWaypoint = route[0];
var lastWaypoint = route[route.length - 1];
var model = this.model;
var sourceDef = model.get('source');
var targetDef = model.get('target');
var sourceView = this.sourceView;
var targetView = this.targetView;
var paperOptions = this.paper.options;
var sourceMagnet, targetMagnet;
// Connection Point Source
var sourcePoint;
if (sourceView && !sourceView.isNodeConnection(this.sourceMagnet)) {
sourceMagnet = (this.sourceMagnet || sourceView.el);
var sourceConnectionPointDef = sourceDef.connectionPoint || paperOptions.defaultConnectionPoint;
var sourcePointRef = firstWaypoint || targetAnchor;
var sourceLine = new Line(sourcePointRef, sourceAnchor);
sourcePoint = this.getConnectionPoint(
sourceConnectionPointDef,
sourceView,
sourceMagnet,
sourceLine,
'source'
);
} else {
sourcePoint = sourceAnchor;
}
// Connection Point Target
var targetPoint;
if (targetView && !targetView.isNodeConnection(this.targetMagnet)) {
targetMagnet = (this.targetMagnet || targetView.el);
var targetConnectionPointDef = targetDef.connectionPoint || paperOptions.defaultConnectionPoint;
var targetPointRef = lastWaypoint || sourceAnchor;
var targetLine = new Line(targetPointRef, targetAnchor);
targetPoint = this.getConnectionPoint(
targetConnectionPointDef,
targetView,
targetMagnet,
targetLine,
'target'
);
} else {
targetPoint = targetAnchor;
}
return {
source: sourcePoint,
target: targetPoint
};
},
getAnchor: function(anchorDef, cellView, magnet, ref, endType) {
var isConnection = cellView.isNodeConnection(magnet);
var paperOptions = this.paper.options;
if (!anchorDef) {
if (isConnection) {
anchorDef = paperOptions.defaultLinkAnchor;
} else {
if (this.options.perpendicular) {
// Backwards compatibility
// See `manhattan` router for more details
anchorDef = { name: 'perpendicular' };
} else {
anchorDef = paperOptions.defaultAnchor;
}
}
}
if (!anchorDef) throw new Error('Anchor required.');
var anchorFn;
if (typeof anchorDef === 'function') {
anchorFn = anchorDef;
} else {
var anchorName = anchorDef.name;
var anchorNamespace = isConnection ? 'linkAnchorNamespace' : 'anchorNamespace';
anchorFn = paperOptions[anchorNamespace][anchorName];
if (typeof anchorFn !== 'function') throw new Error('Unknown anchor: ' + anchorName);
}
var anchor = anchorFn.call(
this,
cellView,
magnet,
ref,
anchorDef.args || {},
endType,
this
);
if (!anchor) return new Point();
return anchor.round(this.decimalsRounding);
},
getConnectionPoint: function(connectionPointDef, view, magnet, line, endType) {
var connectionPoint;
var anchor = line.end;
var paperOptions = this.paper.options;
if (!connectionPointDef) return anchor;
var connectionPointFn;
if (typeof connectionPointDef === 'function') {
connectionPointFn = connectionPointDef;
} else {
var connectionPointName = connectionPointDef.name;
connectionPointFn = paperOptions.connectionPointNamespace[connectionPointName];
if (typeof connectionPointFn !== 'function') throw new Error('Unknown connection point: ' + connectionPointName);
}
connectionPoint = connectionPointFn.call(this, line, view, magnet, connectionPointDef.args || {}, endType, this);
if (!connectionPoint) return anchor;
return connectionPoint.round(this.decimalsRounding);
},
isIntersecting: function(geometryShape, geometryData) {
const connection = this.getConnection();
if (!connection) return false;
return intersection.exists(
geometryShape,
connection,
geometryData,
{ segmentSubdivisions: this.getConnectionSubdivisions() },
);
},
isEnclosedIn: function(geometryRect) {
const connection = this.getConnection();
if (!connection) return false;
const bbox = connection.bbox();
if (!bbox) return false;
return geometryRect.containsRect(bbox);
},
isAtPoint: function(point /*, options */) {
// Note: `strict` option is not applicable for links.
// There is currently no method to determine if a path contains a point.
const area = new Rect(point);
// Intersection with a zero-size area is not possible.
area.inflate(this.EPSILON);
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;
var path = this.path;
if (!path) return this;
// 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') || [];
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 transformationMatrix = this._getLabelTransformationMatrix(position);
labelNode.setAttribute('transform', V.matrixToTransformString(transformationMatrix));
this._cleanLabelMatrices(idx);
}
return this;
},
_cleanLabelMatrices: function(index) {
// Clean magnetMatrix for all nodes of the label.
// Cached BoundingRect does not need to updated when the position changes
// TODO: this doesn't work for labels with XML String markups.
const { metrics, _labelSelectors } = this;
const selectors = _labelSelectors[index];
if (!selectors) return;
for (let selector in selectors) {
const { id } = selectors[selector];
if (id && (id in metrics)) delete metrics[id].magnetMatrix;
}
},
updateEndProperties: function(endType) {
const { model, paper } = this;
const endViewProperty = `${endType}View`;
const endDef = model.get(endType);
const endId = endDef && endDef.id;
if (!endId) {
// the link end is a point ~ rect 0x0
this[endViewProperty] = null;
this.updateEndMagnet(endType);
return true;
}
const endModel = paper.getModelById(endId);
if (!endModel) throw new Error('LinkView: invalid ' + endType + ' cell.');
const endView = endModel.findView(paper);
if (!endView) {
// A view for a model should always exist
return false;
}
this[endViewProperty] = endView;
this.updateEndMagnet(endType);
return true;
},
updateEndMagnet: function(endType) {
const endMagnetProperty = `${endType}Magnet`;
const endView = this.getEndView(endType);
if (endView) {
let connectedMagnet = endView.getMagnetFromLinkEnd(this.model.get(endType));
if (connectedMagnet === endView.el) connectedMagnet = null;
this[endMagnetProperty] = connectedMagnet;
} else {
this[endMagnetProperty] = null;
}
},
_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;
},
_getDefaultLabelPositionArgs: function() {
var defaultLabel = this.model._getDefaultLabel();
var defaultLabelPosition = defaultLabel.position || {};
return defaultLabelPosition.args;
},
// merge default label position args into label position args
// keep `undefined` or `null` because `{}` means something else
_mergeLabelPositionArgs: function(labelPositionArgs, defaultLabelPositionArgs) {
if (labelPositionArgs === null) return null;
if (labelPositionArgs === undefined) {
if (defaultLabelPositionArgs === null) return null;
return defaultLabelPositionArgs;
}
return merge({}, defaultLabelPositionArgs, labelPositionArgs);
},
// Add default label at given position at end of `labels` array.
// Four signatures:
// - obj, obj = point, opt
// - obj, num, obj = point, angle, opt
// - num, num, obj = x, y, opt
// - num, num, num, obj = x, y, angle, opt
// Assigns relative coordinates by default:
// `opt.absoluteDistance` forces absolute coordinates.
// `opt.reverseDistance` forces reverse absolute coordinates (if absoluteDistance = true).
// `opt.absoluteOffset` forces absolute coordinates for offset.
// Additional args:
// `opt.keepGradient` auto-adjusts the angle of the label to match path gradient at position.
// `opt.ensureLegibility` rotates labels so they are never upside-down.
addLabel: function(p1, p2, p3, p4) {
// normalize data from the four possible signatures
var localX;
var localY;
var localAngle = 0;
var localOpt;
if (typeof p1 !== 'number') {
// {x, y} object provided as first parameter
localX = p1.x;
localY = p1.y;
if (typeof p2 === 'number') {
// angle and opt provided as second and third parameters
localAngle = p2;
localOpt = p3;
} else {
// opt provided as second parameter
localOpt = p2;
}
} else {
// x and y provided as first and second parameters
localX = p1;
localY = p2;
if (typeof p3 === 'number') {
// angle and opt provided as third and fourth parameters
localAngle = p3;
localOpt = p4;
} else {
// opt provided as third parameter
localOpt = p3;
}
}
// merge label position arguments
var defaultLabelPositionArgs = this._getDefaultLabelPositionArgs();
var labelPositionArgs = localOpt;
var positionArgs = this._mergeLabelPositionArgs(labelPositionArgs, defaultLabelPositionArgs);
// append label to labels array
var label = { position: this.getLabelPosition(localX, localY, localAngle, positionArgs) };
var idx = -1;
this.model.insertLabel(idx, label, localOpt);
return idx;
},
// Add a new vertex at calculated index to the `vertices` array.
addVertex: function(x, y, opt) {
// accept input in form `{ x, y }, opt` or `x, y, opt`
var isPointProvided = (typeof x !== 'number');
var localX = isPointProvided ? x.x : x;
var localY = isPointProvided ? x.y : y;
var localOpt = isPointProvided ? y : opt;
var vertex = { x: localX, y: localY };
var idx = this.getVertexIndex(localX, localY);
this.model.insertVertex(idx, vertex, localOpt);
return idx;
},
// Send a token (an SVG element, usually a circle) along the connection path.
// Example: `link.findView(paper).sendToken(V('circle', { r: 7, fill: 'green' }).node)`
// `opt.duration` is optional and is a time in milliseconds that the token travels from the source to the target of the link. Default is `1000`.
// `opt.direction` is optional and it determines whether the token goes from source to target or other way round (`reverse`)
// `opt.connection` is an optional selector to the connection path.
// `callback` is optional and is a function to be called once the token reaches the target.
sendToken: function(token, opt, callback) {
function onAnimationEnd(vToken, callback) {
return function() {
vToken.remove();
if (typeof callback === 'function') {
callback();
}
};
}
var duration, isReversed, selector;
if (isObject(opt)) {
duration = opt.duration;
isReversed = (opt.direction === 'reverse');
selector = opt.connection;
} else {
// Backwards compatibility
duration = opt;
isReversed = false;
selector = null;
}
duration = duration || 1000;
var animationAttributes = {
dur: duration + 'ms',
repeatCount: 1,
calcMode: 'linear',
fill: 'freeze'
};
if (isReversed) {
animationAttributes.keyPoints = '1;0';
animationAttributes.keyTimes = '0;1';
}
var vToken = V(token);
var connection;
if (typeof selector === 'string') {
// Use custom connection path.
connection = this.findNode(selector);
} else {
// Select connection path automatically.
var cache = this._V;
connection = (cache.connection) ? cache.connection.node : this.el.querySelector('path');
}
if (!(connection instanceof SVGPathElement)) {
throw new Error('dia.LinkView: token animation requires a valid connection path.');
}
vToken
.appendTo(this.paper.cells)
.animateAlongPath(animationAttributes, connection);
setTimeout(onAnimationEnd(vToken, callback), duration);
},
findRoute: function(vertices) {
vertices || (vertices = []);
var namespace = this.paper.options.routerNamespace || routers;
var router = this.model.router();
var defaultRouter = this.paper.options.defaultRouter;
if (!router) {
if (defaultRouter) router = defaultRouter;
else return vertices.map(Point); // no router specified
}
var routerFn = isFunction(router) ? router : namespace[router.name];
if (!isFunction(routerFn)) {
throw new Error('dia.LinkView: unknown router: "' + router.name + '".');
}
var args = router.args || {};
var route = routerFn.call(
this, // context
vertices, // vertices
args, // options
this // linkView
);
if (!route) return vertices.map(Point);
return route;
},
// Return the `d` attribute value of the `<path>` element representing the link
// between `source` and `target`.
findPath: function(route, sourcePoint, targetPoint) {
var namespace = this.paper.options.connectorNamespace || connectors;
var connector = this.model.connector();
var defaultConnector = this.paper.options.defaultConnector;
if (!connector) {
connector = defaultConnector || {};
}
var connectorFn = isFunction(connector) ? connector : namespace[connector.name];
if (!isFunction(connectorFn)) {
throw new Error('dia.LinkView: unknown connector: "' + connector.name + '".');
}
var args = clone(connector.args || {});
args.raw = true; // Request raw g.Path as the result.
var path = connectorFn.call(
this, // context
sourcePoint, // start point
targetPoint, // end point
route, // vertices
args, // options
this // linkView
);
if (typeof path === 'string') {
// Backwards compatibility for connectors not supporting `raw` option.
path = new Path(V.normalizePathData(path));
}
return path;
},
// Public API.
// -----------
getConnection: function() {
var path = this.path;
if (!path) return null;
return path.clone();
},
getSerializedConnection: function() {
var path = this.path;
if (!path) return null;
var metrics = this.metrics;
if (metrics.hasOwnProperty('data')) return metrics.data;
var data = path.serialize();
metrics.data = data;
return data;
},
getConnectionSubdivisions: function() {
var path = this.path;
if (!path) return null;
var metrics = this.metrics;
if (metrics.hasOwnProperty('segmentSubdivisions')) return metrics.segmentSubdivisions;
var subdivisions = path.getSegmentSubdivisions();
metrics.segmentSubdivisions = subdivisions;
return subdivisions;
},
getConnectionLength: function() {
var path = this.path;
if (!path) return 0;
var metrics = this.metrics;
if (metrics.hasOwnProperty('length')) return metrics.length;
var length = path.length({ segmentSubdivisions: this.getConnectionSubdivisions() });
metrics.length = length;
return length;
},
getPointAtLength: function(length) {
var path = this.path;
if (!path) return null;
return path.pointAtLength(length, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
getPointAtRatio: function(ratio) {
var path = this.path;
if (!path) return null;
if (isPercentage(ratio)) ratio = parseFloat(ratio) / 100;
return path.pointAt(ratio, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
getTangentAtLength: function(length) {
var path = this.path;
if (!path) return null;
return path.tangentAtLength(length, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
getTangentAtRatio: function(ratio) {
var path = this.path;
if (!path) return null;
return path.tangentAt(ratio, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
getClosestPoint: function(point) {
var path = this.path;
if (!path) return null;
return path.closestPoint(point, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
getClosestPointLength: function(point) {
var path = this.path;
if (!path) return null;
return path.closestPointLength(point, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
getClosestPointRatio: function(point) {
var path = this.path;
if (!path) return null;
return path.closestPointNormalizedLength(point, { segmentSubdivisions: this.getConnectionSubdivisions() });
},
// Get label position object based on two provided coordinates, x and y.
// (Used behind the scenes when user moves labels around.)
// Two signatures:
// - num, num, obj = x, y, options
// - num, num, num, obj = x, y, angle, options
// Accepts distance/offset options = `absoluteDistance: boolean`, `reverseDistance: boolean`, `absoluteOffset: boolean`
// - `absoluteOffset` is necessary in order to move beyond connection endpoints
// Additional options = `keepGradient: boolean`, `ensureLegibility: boolean`
getLabelPosition: function(x, y, p3, p4) {
var position = {};
// normalize data from the two possible signatures
var localAngle = 0;
var localOpt;
if (typeof p3 === 'number') {
// angle and opt provided as third and fourth argument
localAngle = p3;
localOpt = p4;
} else {
// opt provided as third argument
localOpt = p3;
}
// save localOpt as `args` of the position object that is passed along
if (localOpt) position.args = localOpt;
// identify distance/offset settings
var isDistanceRelative = !(localOpt && localOpt.absoluteDistance); // relative by default
var isDistanceAbsoluteReverse = (localOpt && localOpt.absoluteDistance && localOpt.reverseDistance); // non-reverse by default
var isOffsetAbsolute = localOpt && localOpt.absoluteOffset; // offset is non-absolute by default
// find closest point t
var path = this.path;
var pathOpt = { segmentSubdivisions: this.getConnectionSubdivisions() };
var labelPoint = new Point(x, y);
var t = path.closestPointT(labelPoint, pathOpt);
// DISTANCE:
var labelDistance = path.lengthAtT(t, pathOpt);
if (isDistanceRelative) labelDistance = (labelDistance / this.getConnectionLength()) || 0; // fix to prevent NaN for 0 length
if (isDistanceAbsoluteReverse) labelDistance = (-1 * (this.getConnectionLength() - labelDistance)) || 1; // fix for end point (-0 => 1)
position.distance = labelDistance;
// OFFSET:
// use absolute offset if:
// - opt.absoluteOffset is true,
// - opt.absoluteOffset is not true but there is no tangent
var tangent;
if (!isOffsetAbsolute) tangent = path.tangentAtT(t);
var labelOffset;
if (tangent) {
labelOffset = tangent.pointOffset(labelPoint);
} else {
var closestPoint = path.pointAtT(t);
var labelOffsetDiff = labelPoint.difference(closestPoint);
labelOffset = { x: labelOffsetDiff.x, y: labelOffsetDiff.y };
}
position.offset = labelOffset;
// ANGLE:
position.angle = localAngle;
return 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 {
throw new Error('dia.LinkView: invalid label position distance.');
}
var isDistanceRelative = ((labelDistance > 0) && (labelDistance <= 1));
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 isOffsetAbsolute = ((labelOffsetCoordinates.x !== 0) || (labelOffsetCoordinates.y !== 0) || labelOffset === 0);
var isKeepGradient = args.keepGradient;
var isEnsureLegibility = args.ensureLegibility;
var path = this.path;
var pathOpt = { segmentSubdivisions: this.getConnectionSubdivisions() };
var distance = isDistanceRelative ? (labelDistance * this.getConnectionLength()) : labelDistance;
var tangent = path.tangentAtLength(distance, pathOpt);
var translation;
var angle = labelAngle;
if (tangent) {
if (isOffsetAbsolute) {
translation = tangent.start.clone();
translation.offset(labelOffsetCoordinates);
} else {
var normal = tangent.clone();
normal.rotate(tangent.start, -90);
normal.setLength(labelOffset);
translation = normal.end;
}
if (isKeepGradient) {
angle = (tangent.angle() + labelAngle);
if (isEnsureLegibility) {
angle = normalizeAngle(((angle + 90) % 180) - 90);
}
}
} else {
// fallback - the connection has zero length
translation = path.start.clone();
if (isOffsetAbsolute) translation.offset(labelOffsetCoordinates);
}
return V.createSVGMatrix()
.translate(translation.x, translation.y)
.rotate(angle);
},
getLabelCoordinates: function(labelPosition) {
var transformationMatrix = this._getLabelTransformationMatrix(labelPosition);
return new Point(transformationMatrix.e, transformationMatrix.f);
},
getVertexIndex: function(x, y) {
var model = this.model;
var vertices = model.vertices();
var vertexLength = this.getClosestPointLength(new Point(x, y));
var idx = 0;
for (var n = vertices.length; idx < n; idx++) {
var currentVertex = vertices[idx];
var currentVertexLength = this.getClosestPointLength(currentVertex);
if (vertexLength < currentVertexLength) break;
}
return idx;
},
// Interaction. The controller part.
// ---------------------------------
notifyPointerdown(evt, x, y) {
CellView.prototype.pointerdown.call(this, evt, x, y);
this.notify('link:pointerdown', evt, x, y);
},
notifyPointermove(evt, x, y) {
CellView.prototype.pointermove.call(this, evt, x, y);
this.notify('link:pointermove', evt, x, y);
},
notifyPointerup(evt, x, y) {
this.notify('link:pointerup', evt, x, y);
CellView.prototype.pointerup.call(this, evt, x, y);
},
pointerdblclick: function(evt, x, y) {
CellView.prototype.pointerdblclick.apply(this, arguments);
this.notify('link:pointerdblclick', evt, x, y);
},
pointerclick: function(evt, x, y) {
CellView.prototype.pointerclick.apply(this, arguments);
this.notify('link:pointerclick', evt, x, y);
},
contextmenu: function(evt, x, y) {
CellView.prototype.contextmenu.apply(this, arguments);
this.notify('link:contextmenu', evt, x, y);
},
pointerdown: function(evt, x, y) {
this.notifyPointerdown(evt, x, y);
this.dragStart(evt, x, y);
},
pointermove: function(evt, x, y) {
// Backwards compatibility
var dragData = this._dragData;
if (dragData) this.eventData(evt, dragData);
var data = this.eventData(evt);
switch (data.action) {
case 'label-move':
this.dragLabel(evt, x, y);
break;
case 'arrowhead-move':
this.dragArrowhead(evt, x, y);
break;
case 'move':
this.drag(evt, x, y);
break;
}
// Backwards compatibility
if (dragData) assign(dragData, this.eventData(evt));
this.notifyPointermove(evt, x, y);
},
pointerup: function(evt, x, y) {
// Backwards compatibility
var dragData = this._dragData;
if (dragData) {
this.eventData(evt, dragData);
this._dragData = null;
}
var data = this.eventData(evt);
switch (data.action) {
case 'label-move':
this.dragLabelEnd(evt, x, y);
break;
case 'arrowhead-move':
this.dragArrowheadEnd(evt, x, y);
break;
case 'move':
this.dragEnd(evt, x, y);
}
this.notifyPointerup(evt, x, y);
this.checkMouseleave(evt);
},
mouseover: function(evt) {
CellView.prototype.mouseover.apply(this, arguments);
this.notify('link:mouseover', evt);
},
mouseout: function(evt) {
CellView.prototype.mouseout.apply(this, arguments);
this.notify('link:mouseout', evt);
},
mouseenter: function(evt) {
CellView.prototype.mouseenter.apply(this, arguments);
this.notify('link:mouseenter', evt);
},
mouseleave: function(evt) {
CellView.prototype.mouseleave.apply(this, arguments);
this.notify('link:mouseleave', evt);
},
mousewheel: function(evt, x, y, delta) {
CellView.prototype.mousewheel.apply(this, arguments);
this.notify('link:mousewheel', evt, x, y, delta);
},
onlabel: function(evt, x, y) {
this.notifyPointerdown(evt, x, y);
this.dragLabelStart(evt, x, y);
var stopPropagation = this.eventData(evt).stopPropagation;
if (stopPropagation) evt.stopPropagation();
},
// Drag Start Handlers
dragLabelStart: function(evt, x, y) {
if (this.can('labelMove')) {
if (this.isDefaultInteractionPrevented(evt)) return;
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 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,
stopPropagation: true
});
} else {
// Backwards compatibility:
// If labels can't be dragged no default action is triggered.
this.eventData(evt, { stopPropagation: true });
}
this.paper.delegateDragEvents(this, evt.data);
},
dragArrowheadStart: function(evt, x, y) {
if (!this.can('arrowheadMove')) return;
var arrowheadNode = evt.target;
var arrowheadType = arrowheadNode.getAttribute('end');
var data = this.startArrowheadMove(arrowheadType, { ignoreBackwardsCompatibility: true });
this.eventData(evt, data);
},
dragStart: function(evt, x, y) {
if (this.isDefaultInteractionPrevented(evt)) return;
if (!this.can('linkMove')) return;
this.eventData(evt, {
action: 'move',
dx: x,
dy: y
});
},
// Drag Handlers
dragLabel: function(evt, x, y) {
var data = this.eventData(evt);
var label = { position: this.getLabelPosition((x + data.dx), (y + data.dy), data.positionAngle, data.positionArgs) };
if (this.paper.options.snapLabels) delete label.position.offset;
// The `touchmove' events are not fired
// when the original event target is removed from the DOM.
// The labels are currently re-rendered completely when only
// the position changes. This is why we need to make sure that
// the label is updated synchronously.
// TODO: replace `touchmove` with `pointermove` (breaking change).
const setOptions = { ui: true };
if (this.paper.isAsync() && evt.type === 'touchmove') {
setOptions.async = false;
}
this.model.label(data.labelIdx, label, setOptions);
},
dragArrowhead: function(evt, x, y) {
if (this.paper.options.snapLinks) {
const isSnapped = this._snapArrowhead(evt, x, y);
if (!isSnapped && this.paper.options.snapLinksSelf) {
this._snapArrowheadSelf(evt, x, y);
}
} else {
if (this.paper.options.snapLinksSelf) {
this._snapArrowheadSelf(evt, x, y);
} else {
this._connectArrowhead(this.getEventTarget(evt), x, y, this.eventData(evt));
}
}
},
drag: function(evt, x, y) {
var data = this.eventData(evt);
this.model.translate(x - data.dx, y - data.dy, { ui: true });
this.eventData(evt, {
dx: x,
dy: y
});
},
// Drag End Handlers
dragLabelEnd: function() {
// noop
},
dragArrowheadEnd: function(evt, x, y) {
var data = this.eventData(evt);
var paper = this.paper;
if (paper.options.snapLinks) {
this._snapArrowheadEnd(data);
} else {
this._connectArrowheadEnd(data, x, y);
}
if (!paper.linkAllowed(this)) {
// If the changed link is not allowed, revert to its previous state.
this._disallow(data);
} else {
this._finishEmbedding(data);
this._notifyConnectEvent(data, evt);
}
this._afterArrowheadMove(data);
},
dragEnd: function() {
// noop
},
_disallow: function(data) {
switch (data.whenNotAllowed) {
case 'remove':
this.model.remove({ ui: true });
break;
case 'revert':
default:
this.model.set(data.arrowhead, data.initialEnd, { ui: true });
break;
}
},
_finishEmbedding: function(data) {
// Reparent the link if embedding is enabled
if (this.paper.options.embeddingMode && this.model.reparent()) {
// Make sure we don't reverse to the original 'z' index (see afterArrowheadMove()).
data.z = null;
}
},
_notifyConnectEvent: function(data, evt) {
var arrowhead = data.arrowhead;
var initialEnd = data.initialEnd;
var currentEnd = this.model.prop(arrowhead);
var endChanged = currentEnd && !Link.endsEqual(initialEnd, currentEnd);
if (endChanged) {
var paper = this.paper;
if (initialEnd.id) {
this.notify('link:disconnect', evt, paper.findViewByModel(initialEnd.id), data.initialMagnet, arrowhead);
}
if (currentEnd.id) {
this.notify('link:connect', evt, paper.findViewByModel(currentEnd.id), data.magnetUnderPointer, arrowhead);
}
}
},
_snapToPoints: function(snapPoint, points, radius) {
let closestPointX = null;
let closestDistanceX = Infinity;
let closestPointY = null;
let closestDistanceY = Infinity;
let x = snapPoint.x;
let y = snapPoint.y;
for (let i = 0; i < points.length; i++) {
const distX = Math.abs(points[i].x - snapPoint.x);
if (distX < closestDistanceX) {
closestDistanceX = distX;
closestPointX = points[i];
}
const distY = Math.abs(points[i].y - snapPoint.y);
if (distY < closestDistanceY) {
closestDistanceY = distY;
closestPointY = points[i];
}
}
if (closestDistanceX < radius) {
x = closestPointX.x;
}
if (closestDistanceY < radius) {
y = closestPointY.y;
}
return { x, y };
},
_snapArrowheadSelf: function(evt, x, y) {
const { paper, model } = this;
const { snapLinksSelf } = paper.options;
const data = this.eventData(evt);
const radius = snapLinksSelf.radius || 20;
const anchor = this.getEndAnchor(data.arrowhead === 'source' ? 'target' : 'source');
const vertices = model.vertices();
const points = [anchor, ...vertices];
const snapPoint = this._snapToPoints({ x: x, y: y }, points, radius);
const point = paper.localToClientPoint(snapPoint);
this._connectArrowhead(document.elementFromPoint(point.x, point.y), snapPoint.x, snapPoint.y, this.eventData(evt));
},
_snapArrowhead: function(evt, x, y) {
const { paper } = this;
const { snapLinks, connectionStrategy } = paper.options;
const data = this.eventData(evt);
let isSnapped = false;
// checking view in close area of the pointer
var r = snapLinks.radius || 50;
var viewsInArea = paper.findElementViewsInArea(
{ x: x - r, y: y - r, width: 2 * r, height: 2 * r },
snapLinks.findInAreaOptions
);
var prevClosestView = data.closestView || null;
var prevClosestMagnet = data.closestMagnet || null;
var prevMagnetProxy = data.magnetProxy || null;
data.closestView = data.closestMagnet = data.magnetProxy = null;
var minDistance = Number.MAX_VALUE;
var pointer = new Point(x, y);
viewsInArea.forEach(function(view) {
const candidates = [];
// skip connecting to the element in case '.': { magnet: false } attribute present
if (view.el.getAttribute('magnet') !== 'false') {
candidates.push({
bbox: view.model.getBBox(),
magnet: view.el
});
}
view.$('[magnet]').toArray().forEach(magnet => {
candidates.push({
bbox: view.getNodeBBox(magnet),
magnet
});
});
candidates.forEach(candidate => {
const { magnet, bbox } = candidate;
// find distance from the center of the model to pointer coordinates
const distance = bbox.center().squaredDistance(pointer);
// the connection is looked up in a circle area by `distance < r`
if (distance < minDistance) {
const isAlreadyValidated = prevClosestMagnet === magnet;
if (isAlreadyValidated || paper.options.validateConnection.apply(
paper, data.validateConnectionArgs(view, (view.el === magnet) ? null : magnet)
)) {
minDistance = distance;
data.closestView = view;
data.closestMagnet = magnet;
}
}
});
}, this);
var end;
var magnetProxy = null;
var closestView = data.closestView;
var closestMagnet = data.closestMagnet;
if (closestMagnet) {
magnetProxy = data.magnetProxy = closestView.findProxyNode(closestMagnet, 'highlighter');
}
var endType = data.arrowhead;
var newClosestMagnet = (prevClosestMagnet !== closestMagnet);
if (prevClosestView && newClosestMagnet) {
prevClosestView.unhighlight(prevMagnetProxy, {
connecting: true,
snapping: true
});
}
if (closestView) {
const { prevEnd, prevX, prevY } = data;
data.prevX = x;
data.prevY = y;
isSnapped = true;
if (!newClosestMagnet) {
if (typeof connectionStrategy !== 'function' || (prevX === x && prevY === y)) {
// the magnet has not changed and the link's end does not depend on the x and y
return isSnapped;
}
}
end = closestView.getLinkEnd(closestMagnet, x, y, this.model, endType);
if (!newClosestMagnet && isEqual(prevEnd, end)) {
// the source/target json has not changed
return isSnapped;
}
data.prevEnd = end;
if (newClosestMagnet) {
closestView.highlight(magnetProxy, {
connecting: true,
snapping: true
});
}
} else {
end = { x: x, y: y };
}
this.model.set(endType, end || { x: x, y: y }, { ui: true });
if (prevClosestView) {
this.notify('link:snap:disconnect', evt, prevClosestView, prevClosestMagnet, endType);
}
if (closestView) {
this.notify('link:snap:connect', evt, closestView, closestMagnet, endType);
}
return isSnapped;
},
_snapArrowheadEnd: function(data) {
// Finish off link snapping.
// Everything except view unhighlighting was already done on pointermove.
var closestView = data.closestView;
var closestMagnet = data.closestMagnet;
if (closestView && closestMagnet) {
closestView.unhighlight(data.magnetProxy, { connecting: true, snapping: true });
data.magnetUnderPointer = closestView.findMagnet(closestMagnet);
}
data.closestView = data.closestMagnet = null;
},
_connectArrowhead: function(target, x, y, data) {
// checking views right under the pointer
const { paper, model } = this;
if (data.eventTarget !== target) {
// Unhighlight the previous view under pointer if there was one.
if (data.magnetProxy) {
data.viewUnderPointer.unhighlight(data.magnetProxy, {
connecting: true
});
}
const viewUnderPointer = data.viewUnderPointer = paper.findView(target);
if (viewUnderPointer) {
// If we found a view that is under the pointer, we need to find the closest
// magnet based on the real target element of the event.
const magnetUnderPointer = data.magnetUnderPointer = viewUnderPointer.findMagnet(target);
const magnetProxy = data.magnetProxy = viewUnderPointer.findProxyNode(magnetUnderPointer, 'highlighter');
if (magnetUnderPointer && this.paper.options.validateConnection.apply(
paper,
data.validateConnectionArgs(viewUnderPointer, magnetUnderPointer)
)) {
// If there was no magnet found, do not highlight anything and assume there
// is no view under pointer we're interested in reconnecting to.
// This can only happen if the overall element has the attribute `'.': { magnet: false }`.
if (magnetProxy) {
viewUnderPointer.highlight(magnetProxy, {
connecting: true
});
}
} else {
// This type of connection is not valid. Disregard this magnet.
data.magnetUnderPointer = null;
data.magnetProxy = null;
}
} else {
// Make sure we'll unset previous magnet.
data.magnetUnderPointer = null;
data.magnetProxy = null;
}
}
data.eventTarget = target;
model.set(data.arrowhead, { x: x, y: y }, { ui: true });
},
_connectArrowheadEnd: function(data = {}, x, y) {
const { model } = this;
const { viewUnderPointer, magnetUnderPointer, magnetProxy, arrowhead } = data;
if (!magnetUnderPointer || !magnetProxy || !viewUnderPointer) return;
viewUnderPointer.unhighlight(magnetProxy, { connecting: true });
// The link end is taken from the magnet under the pointer, not the proxy.
const end = viewUnderPointer.getLinkEnd(magnetUnderPointer, x, y, model, arrowhead);
model.set(arrowhead, end, { ui: true });
},
_beforeArrowheadMove: function(data) {
data.z = this.model.get('z');
this.model.toFront();
// Let the pointer propagate through the link view elements so that
// the `evt.target` is another element under the pointer, not the link itself.
var style = this.el.style;
data.pointerEvents = style.pointerEvents;
style.pointerEvents = 'none';
if (this.paper.options.markAvailable) {
this._markAvailableMagnets(data);
}
},
_afterArrowheadMove: function(data) {
if (data.z !== null) {
this.model.set('z', data.z, { ui: true });
data.z = null;
}
// Put `pointer-events` back to its original value. See `_beforeArrowheadMove()` for explanation.
this.el.style.pointerEvents = data.pointerEvents;
if (this.paper.options.markAvailable) {
this._unmarkAvailableMagnets(data);
}
},
_createValidateConnectionArgs: function(arrowhead) {
// It makes sure the arguments for validateConnection have the following form:
// (source view, source magnet, target view, target magnet and link view)
var args = [];
args[4] = arrowhead;
args[5] = this;
var oppositeArrowhead;
var i = 0;
var j = 0;
if (arrowhead === 'source') {
i = 2;
oppositeArrowhead = 'target';
} else {
j = 2;
oppositeArrowhead = 'source';
}
var end = this.model.get(oppositeArrowhead);
if (end.id) {
var view = args[i] = this.paper.findViewByModel(end.id);
var magnet = view.getMagnetFromLinkEnd(end);
if (magnet === view.el) magnet = undefined;
args[i + 1] = magnet;
}
function validateConnectionArgs(cellView, magnet) {
args[j] = cellView;
args[j + 1] = cellView.el === magnet ? undefined : magnet;
return args;
}
return validateConnectionArgs;
},
_markAvailableMagnets: function(data) {
function isMagnetAvailable(view, magnet) {
var paper = view.paper;
var validate = paper.options.validateConnection;
return validate.apply(paper, this.validateConnectionArgs(view, magnet));
}
var paper = this.paper;
var elements = paper.model.getCells();
data.marked = {};
for (var i = 0, n = elements.length; i < n; i++) {
var view = elements[i].findView(paper);
if (!view) {
continue;
}
var magnets = Array.prototype.slice.call(view.el.querySelectorAll('[magnet]'));
if (view.el.getAttribute('magnet') !== 'false') {
// Element wrapping group is also a magnet
magnets.push(view.el);
}
var availableMagnets = magnets.filter(isMagnetAvailable.bind(data, view));
if (availableMagnets.length > 0) {
// highlight all available magnets
for (var j = 0, m = availableMagnets.length; j < m; j++) {
view.highlight(availableMagnets[j], { magnetAvailability: true });
}
// highlight the entire view
view.highlight(null, { elementAvailability: true });
data.marked[view.model.id] = availableMagnets;
}
}
},
_unmarkAvailableMagnets: function(data) {
var markedKeys = Object.keys(data.marked);
var id;
var markedMagnets;
for (var i = 0, n = markedKeys.length; i < n; i++) {
id = markedKeys[i];
markedMagnets = data.marked[id];
var view = this.paper.findViewByModel(id);
if (view) {
for (var j = 0, m = markedMagnets.length; j < m; j++) {
view.unhighlight(markedMagnets[j], { magnetAvailability: true });
}
view.unhighlight(null, { elementAvailability: true });
}
}
data.marked = null;
},
startArrowheadMove: function(end, opt) {
opt || (opt = {});
// Allow to delegate events from an another view to this linkView in order to trigger arrowhead
// move without need to click on the actual arrowhead dom element.
var data = {
action: 'arrowhead-move',
arrowhead: end,
whenNotAllowed: opt.whenNotAllowed || 'revert',
initialMagnet: this[end + 'Magnet'] || (this[end + 'View'] ? this[end + 'View'].el : null),
initialEnd: clone(this.model.get(end)),
validateConnectionArgs: this._createValidateConnectionArgs(end)
};
this._beforeArrowheadMove(data);
if (opt.ignoreBackwardsCompatibility !== true) {
this._dragData = data;
}
return data;
},
// Lifecycle methods
onMount: function() {
CellView.prototype.onMount.apply(this, arguments);
this.mountLabels();
},
onDetach: function() {
CellView.prototype.onDetach.apply(this, arguments);
this.unmountLabels();
},
onRemove: function() {
CellView.prototype.onRemove.apply(this, arguments);
this.unmountLabels();
}
}, {
Flags: Flags,
});
Object.defineProperty(LinkView.prototype, 'sourceBBox', {
enumerable: true,
get: function() {
var sourceView = this.sourceView;
if (!sourceView) {
var sourceDef = this.model.source();
return new Rect(sourceDef.x, sourceDef.y);
}
var sourceMagnet = this.sourceMagnet;
if (sourceView.isNodeConnection(sourceMagnet)) {
return new Rect(this.sourceAnchor);
}
return sourceView.getNodeBBox(sourceMagnet || sourceView.el);
}
});
Object.defineProperty(LinkView.prototype, 'targetBBox', {
enumerable: true,
get: function() {
var targetView = this.targetView;
if (!targetView) {
var targetDef = this.model.target();
return new Rect(targetDef.x, targetDef.y);
}
var targetMagnet = this.targetMagnet;
if (targetView.isNodeConnection(targetMagnet)) {
return new Rect(this.targetAnchor);
}
return targetView.getNodeBBox(targetMagnet || targetView.el);
}
});