@joint/core
Version:
JavaScript diagramming library
1,389 lines (1,155 loc) • 48.5 kB
JavaScript
import { config } from '../config/index.mjs';
import { View } from '../mvc/index.mjs';
import {
assign,
guid,
omit,
parseDOMJSON,
isFunction,
isObject,
isPlainObject,
isBoolean,
isEmpty,
isString,
result,
sortedIndex,
merge,
uniq
} from '../util/index.mjs';
import { Point, Rect, intersection } from '../g/index.mjs';
import V from '../V/index.mjs';
import $ from '../mvc/Dom/index.mjs';
import { HighlighterView } from './HighlighterView.mjs';
import { evalAttributes, evalAttribute } from './attributes/eval.mjs';
const HighlightingTypes = {
DEFAULT: 'default',
EMBEDDING: 'embedding',
CONNECTING: 'connecting',
MAGNET_AVAILABILITY: 'magnetAvailability',
ELEMENT_AVAILABILITY: 'elementAvailability'
};
const Flags = {
TOOLS: 'TOOLS',
};
// CellView base view and controller.
// --------------------------------------------
// This is the base view and controller for `ElementView` and `LinkView`.
export const CellView = View.extend({
tagName: 'g',
svgElement: true,
selector: 'root',
metrics: null,
className: function() {
var classNames = ['cell'];
var type = this.model.get('type');
if (type) {
type.toLowerCase().split('.').forEach(function(value, index, list) {
classNames.push('type-' + list.slice(0, index + 1).join('-'));
});
}
return classNames.join(' ');
},
_presentationAttributes: null,
_flags: null,
setFlags: function() {
var flags = {};
var attributes = {};
var shift = 0;
var i, n, label;
var presentationAttributes = result(this, 'presentationAttributes');
for (var attribute in presentationAttributes) {
if (!presentationAttributes.hasOwnProperty(attribute)) continue;
var labels = presentationAttributes[attribute];
if (!Array.isArray(labels)) labels = [labels];
for (i = 0, n = labels.length; i < n; i++) {
label = labels[i];
var flag = flags[label];
if (!flag) {
flag = flags[label] = 1<<(shift++);
}
attributes[attribute] |= flag;
}
}
var initFlag = result(this, 'initFlag');
if (!Array.isArray(initFlag)) initFlag = [initFlag];
for (i = 0, n = initFlag.length; i < n; i++) {
label = initFlag[i];
if (!flags[label]) flags[label] = 1<<(shift++);
}
// 26 - 30 are reserved for paper flags
// 31+ overflows maximal number
if (shift > 25) throw new Error('dia.CellView: Maximum number of flags exceeded.');
this._flags = flags;
this._presentationAttributes = attributes;
},
hasFlag: function(flag, label) {
return flag & this.getFlag(label);
},
removeFlag: function(flag, label) {
return flag ^ (flag & this.getFlag(label));
},
getFlag: function(label) {
var flags = this._flags;
if (!flags) return 0;
var flag = 0;
if (Array.isArray(label)) {
for (var i = 0, n = label.length; i < n; i++) flag |= flags[label[i]];
} else {
flag |= flags[label];
}
return flag;
},
attributes: function() {
var cell = this.model;
return {
'model-id': cell.id,
'data-type': cell.attributes.type
};
},
constructor: function(options) {
// Make sure a global unique id is assigned to this view. Store this id also to the properties object.
// The global unique id makes sure that the same view can be rendered on e.g. different machines and
// still be associated to the same object among all those clients. This is necessary for real-time
// collaboration mechanism.
options.id = options.id || guid(this);
View.call(this, options);
},
initialize: function() {
this.setFlags();
View.prototype.initialize.apply(this, arguments);
this.cleanNodesCache();
this.startListening();
},
startListening: function() {
this.listenTo(this.model, 'change', this.onAttributesChange);
},
onAttributesChange: function(model, opt) {
var flag = model.getChangeFlag(this._presentationAttributes);
if (opt.updateHandled || !flag) return;
if (opt.dirty && this.hasFlag(flag, 'UPDATE')) flag |= this.getFlag('RENDER');
// TODO: tool changes does not need to be sync
// Fix Segments tools
if (opt.tool) opt.async = false;
this.requestUpdate(flag, opt);
},
requestUpdate: function(flags, opt) {
const { paper } = this;
if (paper && flags > 0) {
paper.requestViewUpdate(this, flags, this.UPDATE_PRIORITY, opt);
}
},
parseDOMJSON: function(markup, root) {
var doc = parseDOMJSON(markup);
var selectors = doc.selectors;
var groups = doc.groupSelectors;
for (var group in groups) {
if (selectors[group]) throw new Error('dia.CellView: ambiguous group selector');
selectors[group] = groups[group];
}
if (root) {
var rootSelector = this.selector;
if (selectors[rootSelector]) throw new Error('dia.CellView: ambiguous root selector.');
selectors[rootSelector] = root;
}
return { fragment: doc.fragment, selectors: selectors };
},
// Return `true` if cell link is allowed to perform a certain UI `feature`.
// Example: `can('labelMove')`.
can: function(feature) {
var interactive = isFunction(this.options.interactive)
? this.options.interactive(this)
: this.options.interactive;
return (isObject(interactive) && interactive[feature] !== false) ||
(isBoolean(interactive) && interactive !== false);
},
findBySelector: function(selector, root, selectors) {
// These are either descendants of `this.$el` of `this.$el` itself.
// `.` is a special selector used to select the wrapping `<g>` element.
if (!selector || selector === '.') return [root];
if (selectors) {
var nodes = selectors[selector];
if (nodes) {
if (Array.isArray(nodes)) return nodes;
return [nodes];
}
}
// Maintaining backwards compatibility
// e.g. `circle:first` would fail with querySelector() call
if (this.useCSSSelectors) return $(root).find(selector).toArray();
return [];
},
findNodes: function(selector) {
return this.findBySelector(selector, this.el, this.selectors);
},
findNode: function(selector) {
const [node = null] = this.findNodes(selector);
return node;
},
notify: function(eventName) {
if (this.paper) {
var args = Array.prototype.slice.call(arguments, 1);
// Trigger the event on both the element itself and also on the paper.
this.trigger.apply(this, [eventName].concat(args));
// Paper event handlers receive the view object as the first argument.
this.paper.trigger.apply(this.paper, [eventName, this].concat(args));
}
},
getBBox: function(opt) {
var bbox;
if (opt && opt.useModelGeometry) {
var model = this.model;
bbox = model.getBBox().bbox(model.angle());
} else {
bbox = this.getNodeBBox(this.el);
}
return this.paper.localToPaperRect(bbox);
},
getNodeBBox: function(magnet) {
const rect = this.getNodeBoundingRect(magnet);
const transformMatrix = this.getRootTranslateMatrix().multiply(this.getNodeRotateMatrix(magnet));
const magnetMatrix = this.getNodeMatrix(magnet);
return V.transformRect(rect, transformMatrix.multiply(magnetMatrix));
},
getNodeRotateMatrix(node) {
if (!this.rotatableNode || this.rotatableNode.contains(node)) {
// Rotate transformation is applied to all nodes when no rotatableGroup
// is present or to nodes inside the rotatableGroup only.
return this.getRootRotateMatrix();
}
// Nodes outside the rotatable group
return V.createSVGMatrix();
},
getNodeUnrotatedBBox: function(magnet) {
var rect = this.getNodeBoundingRect(magnet);
var magnetMatrix = this.getNodeMatrix(magnet);
var translateMatrix = this.getRootTranslateMatrix();
return V.transformRect(rect, translateMatrix.multiply(magnetMatrix));
},
getRootTranslateMatrix: function() {
var model = this.model;
var position = model.position();
var mt = V.createSVGMatrix().translate(position.x, position.y);
return mt;
},
getRootRotateMatrix: function() {
var mr = V.createSVGMatrix();
var model = this.model;
var angle = model.angle();
if (angle) {
var bbox = model.getBBox();
var cx = bbox.width / 2;
var cy = bbox.height / 2;
mr = mr.translate(cx, cy).rotate(angle).translate(-cx, -cy);
}
return mr;
},
_notifyHighlight: function(eventName, el, opt = {}) {
const { el: rootNode } = this;
let node;
if (typeof el === 'string') {
node = this.findNode(el) || rootNode;
} else {
[node = rootNode] = this.$(el);
}
// set partial flag if the highlighted element is not the entire view.
opt.partial = (node !== rootNode);
// translate type flag into a type string
if (opt.type === undefined) {
let type;
switch (true) {
case opt.embedding:
type = HighlightingTypes.EMBEDDING;
break;
case opt.connecting:
type = HighlightingTypes.CONNECTING;
break;
case opt.magnetAvailability:
type = HighlightingTypes.MAGNET_AVAILABILITY;
break;
case opt.elementAvailability:
type = HighlightingTypes.ELEMENT_AVAILABILITY;
break;
default:
type = HighlightingTypes.DEFAULT;
break;
}
opt.type = type;
}
this.notify(eventName, node, opt);
return this;
},
highlight: function(el, opt) {
return this._notifyHighlight('cell:highlight', el, opt);
},
unhighlight: function(el, opt = {}) {
return this._notifyHighlight('cell:unhighlight', el, opt);
},
// Find the closest element that has the `magnet` attribute set to `true`. If there was not such
// an element found, return the root element of the cell view.
findMagnet: function(el) {
const root = this.el;
let magnet = this.$(el)[0];
if (!magnet) {
magnet = root;
}
do {
const magnetAttribute = magnet.getAttribute('magnet');
const isMagnetRoot = (magnet === root);
if ((magnetAttribute || isMagnetRoot) && magnetAttribute !== 'false') {
return magnet;
}
if (isMagnetRoot) {
// If the overall cell has set `magnet === false`, then return `undefined` to
// announce there is no magnet found for this cell.
// This is especially useful to set on cells that have 'ports'. In this case,
// only the ports have set `magnet === true` and the overall element has `magnet === false`.
return undefined;
}
magnet = magnet.parentNode;
} while (magnet);
return undefined;
},
findProxyNode: function(el, type) {
el || (el = this.el);
const nodeSelector = el.getAttribute(`${type}-selector`);
if (nodeSelector) {
const proxyNode = this.findNode(nodeSelector);
if (proxyNode) return proxyNode;
}
return el;
},
// Construct a unique selector for the `el` element within this view.
// `prevSelector` is being collected through the recursive call.
// No value for `prevSelector` is expected when using this method.
getSelector: function(el, prevSelector) {
var selector;
if (el === this.el) {
if (typeof prevSelector === 'string') selector = ':scope > ' + prevSelector;
return selector;
}
if (el) {
var nthChild = V(el).index() + 1;
selector = el.tagName + ':nth-child(' + nthChild + ')';
if (prevSelector) {
selector += ' > ' + prevSelector;
}
selector = this.getSelector(el.parentNode, selector);
}
return selector;
},
addLinkFromMagnet: function(magnet, x, y) {
var paper = this.paper;
var graph = paper.model;
var link = paper.getDefaultLink(this, magnet);
link.set({
source: this.getLinkEnd(magnet, x, y, link, 'source'),
target: { x: x, y: y }
}).addTo(graph, {
async: false,
ui: true
});
return link.findView(paper);
},
getLinkEnd: function(magnet, ...args) {
const model = this.model;
const id = model.id;
// Find a node with the `port` attribute set on it.
const portNode = this.findAttributeNode('port', magnet);
// Find a unique `selector` of the element under pointer that is a magnet.
const selector = magnet.getAttribute('joint-selector');
const end = { id: id };
if (selector != null) end.magnet = selector;
if (portNode != null) {
let port = portNode.getAttribute('port');
if (portNode.getAttribute('port-id-type') === 'number') {
port = parseInt(port, 10);
}
end.port = port;
if (!model.hasPort(port) && !selector) {
// port created via the `port` attribute (not API)
end.selector = this.getSelector(magnet);
}
} else if (selector == null && this.el !== magnet) {
end.selector = this.getSelector(magnet);
}
return this.customizeLinkEnd(end, magnet, ...args);
},
customizeLinkEnd: function(end, magnet, x, y, link, endType) {
const { paper } = this;
const { connectionStrategy } = paper.options;
if (typeof connectionStrategy === 'function') {
var strategy = connectionStrategy.call(paper, end, this, magnet, new Point(x, y), link, endType, paper);
if (strategy) return strategy;
}
return end;
},
getMagnetFromLinkEnd: function(end) {
var port = end.port;
var selector = end.magnet;
var model = this.model;
var magnet;
if (port != null && model.isElement() && model.hasPort(port)) {
magnet = this.findPortNode(port, selector) || this.el;
} else {
if (!selector) selector = end.selector;
if (!selector && port != null) {
// link end has only `id` and `port` property referencing
// a port created via the `port` attribute (not API).
selector = '[port="' + port + '"]';
}
magnet = this.findNode(selector);
}
return this.findProxyNode(magnet, 'magnet');
},
dragLinkStart: function(evt, magnet, x, y) {
this.model.startBatch('add-link');
const linkView = this.addLinkFromMagnet(magnet, x, y);
// backwards compatibility events
linkView.notifyPointerdown(evt, x, y);
linkView.eventData(evt, linkView.startArrowheadMove('target', { whenNotAllowed: 'remove' }));
this.eventData(evt, { linkView });
},
dragLink: function(evt, x, y) {
var data = this.eventData(evt);
var linkView = data.linkView;
if (linkView) {
linkView.pointermove(evt, x, y);
} else {
var paper = this.paper;
var magnetThreshold = paper.options.magnetThreshold;
var currentTarget = this.getEventTarget(evt);
var targetMagnet = data.targetMagnet;
if (magnetThreshold === 'onleave') {
// magnetThreshold when the pointer leaves the magnet
if (targetMagnet === currentTarget || V(targetMagnet).contains(currentTarget)) return;
} else {
// magnetThreshold defined as a number of movements
if (paper.eventData(evt).mousemoved <= magnetThreshold) return;
}
this.dragLinkStart(evt, targetMagnet, x, y);
}
},
dragLinkEnd: function(evt, x, y) {
var data = this.eventData(evt);
var linkView = data.linkView;
if (!linkView) return;
linkView.pointerup(evt, x, y);
this.model.stopBatch('add-link');
},
getAttributeDefinition: function(attrName) {
return this.model.constructor.getAttributeDefinition(attrName);
},
setNodeAttributes: function(node, attrs) {
if (!isEmpty(attrs)) {
if (node instanceof SVGElement) {
V(node).attr(attrs);
} else {
$(node).attr(attrs);
}
}
},
processNodeAttributes: function(node, attrs) {
var attrName, attrVal, def, i, n;
var normalAttrs, setAttrs, positionAttrs, offsetAttrs;
var relatives = [];
const rawAttrs = {};
for (attrName in attrs) {
if (!attrs.hasOwnProperty(attrName)) continue;
rawAttrs[V.attributeNames[attrName]] = attrs[attrName];
}
// divide the attributes between normal and special
for (attrName in rawAttrs) {
if (!rawAttrs.hasOwnProperty(attrName)) continue;
attrVal = rawAttrs[attrName];
def = this.getAttributeDefinition(attrName);
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 = {});
normalAttrs[attrName] = attrVal;
}
}
// handle the rest of attributes via related method
// from the special attributes namespace.
for (i = 0, n = relatives.length; i < n; i+=2) {
attrName = relatives[i];
def = relatives[i+1];
attrVal = attrs[attrName];
if (isFunction(def.set)) {
setAttrs || (setAttrs = {});
setAttrs[attrName] = attrVal;
}
if (isFunction(def.position)) {
positionAttrs || (positionAttrs = {});
positionAttrs[attrName] = attrVal;
}
if (isFunction(def.offset)) {
offsetAttrs || (offsetAttrs = {});
offsetAttrs[attrName] = attrVal;
}
}
return {
raw: rawAttrs,
normal: normalAttrs,
set: setAttrs,
position: positionAttrs,
offset: offsetAttrs
};
},
updateRelativeAttributes: function(node, attrs, refBBox, opt) {
opt || (opt = {});
var attrName, attrVal, def;
var evalAttrs = evalAttributes(attrs.raw || {}, refBBox);
var nodeAttrs = attrs.normal || {};
for (const nodeAttrName in nodeAttrs) {
nodeAttrs[nodeAttrName] = evalAttrs[nodeAttrName];
}
var setAttrs = attrs.set;
var positionAttrs = attrs.position;
var offsetAttrs = attrs.offset;
for (attrName in setAttrs) {
attrVal = evalAttrs[attrName];
def = this.getAttributeDefinition(attrName);
// SET - set function should return attributes to be set on the node,
// which will affect the node dimensions based on the reference bounding
// box. e.g. `width`, `height`, `d`, `rx`, `ry`, `points
var setResult = def.set.call(this, attrVal, refBBox.clone(), node, evalAttrs, this);
if (isObject(setResult)) {
assign(nodeAttrs, setResult);
} else if (setResult !== undefined) {
nodeAttrs[attrName] = setResult;
}
}
if (node instanceof HTMLElement) {
// TODO: setting the `transform` attribute on HTMLElements
// via `node.style.transform = 'matrix(...)';` would introduce
// a breaking change (e.g. basic.TextBlock).
this.setNodeAttributes(node, nodeAttrs);
return;
}
// The final translation of the subelement.
var nodeTransform = nodeAttrs.transform;
var nodeMatrix = V.transformStringToMatrix(nodeTransform);
var nodePosition = Point(nodeMatrix.e, nodeMatrix.f);
if (nodeTransform) {
nodeAttrs = omit(nodeAttrs, 'transform');
nodeMatrix.e = nodeMatrix.f = 0;
}
// Calculate node scale determined by the scalable group
// only if later needed.
var sx, sy, translation;
if (positionAttrs || offsetAttrs) {
var nodeScale = this.getNodeScale(node, opt.scalableNode);
sx = nodeScale.sx;
sy = nodeScale.sy;
}
var positioned = false;
for (attrName in positionAttrs) {
attrVal = evalAttrs[attrName];
def = this.getAttributeDefinition(attrName);
// POSITION - position function should return a point from the
// reference bounding box. The default position of the node is x:0, y:0 of
// the reference bounding box or could be further specify by some
// SVG attributes e.g. `x`, `y`
translation = def.position.call(this, attrVal, refBBox.clone(), node, evalAttrs, this);
if (translation) {
nodePosition.offset(Point(translation).scale(sx, sy));
positioned || (positioned = true);
}
}
// The node bounding box could depend on the `size` set from the previous loop.
// Here we know, that all the size attributes have been already set.
this.setNodeAttributes(node, nodeAttrs);
var offseted = false;
if (offsetAttrs) {
// Check if the node is visible
var nodeBoundingRect = this.getNodeBoundingRect(node);
if (nodeBoundingRect.width > 0 && nodeBoundingRect.height > 0) {
var nodeBBox = V.transformRect(nodeBoundingRect, nodeMatrix).scale(1 / sx, 1 / sy);
for (attrName in offsetAttrs) {
attrVal = evalAttrs[attrName];
def = this.getAttributeDefinition(attrName);
// OFFSET - offset function should return a point from the element
// bounding box. The default offset point is x:0, y:0 (origin) or could be further
// specify with some SVG attributes e.g. `text-anchor`, `cx`, `cy`
translation = def.offset.call(this, attrVal, nodeBBox, node, evalAttrs, this);
if (translation) {
nodePosition.offset(Point(translation).scale(sx, sy));
offseted || (offseted = true);
}
}
}
}
// Do not touch node's transform attribute if there is no transformation applied.
if (nodeTransform !== undefined || positioned || offseted) {
// Round the coordinates to 1 decimal point.
nodePosition.round(1);
nodeMatrix.e = nodePosition.x;
nodeMatrix.f = nodePosition.y;
node.setAttribute('transform', V.matrixToTransformString(nodeMatrix));
// TODO: store nodeMatrix metrics?
}
},
getNodeScale: function(node, scalableNode) {
// Check if the node is a descendant of the scalable group.
var sx, sy;
if (scalableNode && scalableNode.contains(node)) {
var scale = scalableNode.scale();
sx = 1 / scale.sx;
sy = 1 / scale.sy;
} else {
sx = 1;
sy = 1;
}
return { sx: sx, sy: sy };
},
cleanNodesCache: function() {
this.metrics = {};
},
cleanNodeCache: function(node) {
const id = node.id;
if (!id) return;
delete this.metrics[id];
},
nodeCache: function(magnet) {
var metrics = this.metrics;
// Don't use cache? It most likely a custom view with overridden update.
if (!metrics) return {};
var id = V.ensureId(magnet);
var value = metrics[id];
if (!value) value = metrics[id] = {};
return value;
},
getNodeData: function(magnet) {
var metrics = this.nodeCache(magnet);
if (!metrics.data) metrics.data = {};
return metrics.data;
},
getNodeBoundingRect: function(magnet) {
var metrics = this.nodeCache(magnet);
if (metrics.boundingRect === undefined) metrics.boundingRect = V(magnet).getBBox();
return new Rect(metrics.boundingRect);
},
getNodeMatrix: function(magnet) {
const metrics = this.nodeCache(magnet);
if (metrics.magnetMatrix === undefined) {
const { rotatableNode, el } = this;
let target;
if (rotatableNode && rotatableNode.contains(magnet)) {
target = rotatableNode;
} else {
target = el;
}
metrics.magnetMatrix = V(magnet).getTransformToElement(target);
}
return V.createSVGMatrix(metrics.magnetMatrix);
},
getNodeShape: function(magnet) {
var metrics = this.nodeCache(magnet);
if (metrics.geometryShape === undefined) metrics.geometryShape = V(magnet).toGeometryShape();
return metrics.geometryShape.clone();
},
isNodeConnection: function(node) {
return this.model.isLink() && (!node || node === this.el);
},
findNodesAttributes: function(attrs, root, selectorCache, selectors) {
var i, n, nodeAttrs, nodeId;
var nodesAttrs = {};
var mergeIds = [];
for (var selector in attrs) {
if (!attrs.hasOwnProperty(selector)) continue;
nodeAttrs = attrs[selector];
if (!isPlainObject(nodeAttrs)) continue; // Not a valid selector-attributes pair
var selected = selectorCache[selector] = this.findBySelector(selector, root, selectors);
for (i = 0, n = selected.length; i < n; i++) {
var node = selected[i];
nodeId = V.ensureId(node);
// "unique" selectors are selectors that referencing a single node (defined by `selector`)
// groupSelector referencing a single node is not "unique"
var unique = (selectors && selectors[selector] === node);
var prevNodeAttrs = nodesAttrs[nodeId];
if (prevNodeAttrs) {
// Note, that nodes referenced by deprecated `CSS selectors` are not taken into account.
// e.g. css:`.circle` and selector:`circle` can be applied in a random order
if (!prevNodeAttrs.array) {
mergeIds.push(nodeId);
prevNodeAttrs.array = true;
prevNodeAttrs.attributes = [prevNodeAttrs.attributes];
prevNodeAttrs.selectedLength = [prevNodeAttrs.selectedLength];
}
var attributes = prevNodeAttrs.attributes;
var selectedLength = prevNodeAttrs.selectedLength;
if (unique) {
// node referenced by `selector`
attributes.unshift(nodeAttrs);
selectedLength.unshift(-1);
} else {
// node referenced by `groupSelector`
var sortIndex = sortedIndex(selectedLength, n);
attributes.splice(sortIndex, 0, nodeAttrs);
selectedLength.splice(sortIndex, 0, n);
}
} else {
nodesAttrs[nodeId] = {
attributes: nodeAttrs,
selectedLength: unique ? -1 : n,
node: node,
array: false
};
}
}
}
for (i = 0, n = mergeIds.length; i < n; i++) {
nodeId = mergeIds[i];
nodeAttrs = nodesAttrs[nodeId];
nodeAttrs.attributes = merge({}, ...nodeAttrs.attributes.reverse());
}
return nodesAttrs;
},
getEventTarget: function(evt, opt = {}) {
const { target, type, clientX = 0, clientY = 0 } = evt;
if (
// Explicitly defined `fromPoint` option
opt.fromPoint ||
// Touchmove/Touchend event's target is not reflecting the element under the coordinates as mousemove does.
// It holds the element when a touchstart triggered.
type === 'touchmove' || type === 'touchend' ||
// Pointermove/Pointerup event with the pointer captured
('pointerId' in evt && target.hasPointerCapture(evt.pointerId))
) {
return document.elementFromPoint(clientX, clientY);
}
return target;
},
// Default is to process the `model.attributes.attrs` object and set attributes on subelements based on the selectors,
// unless `attrs` parameter was passed.
updateDOMSubtreeAttributes: function(rootNode, attrs, opt) {
opt || (opt = {});
opt.rootBBox || (opt.rootBBox = Rect());
opt.selectors || (opt.selectors = this.selectors); // selector collection to use
// Cache table for query results and bounding box calculation.
// Note that `selectorCache` needs to be invalidated for all
// `updateAttributes` calls, as the selectors might pointing
// to nodes designated by an attribute or elements dynamically
// created.
var selectorCache = {};
var bboxCache = {};
var relativeItems = [];
var relativeRefItems = [];
var item, node, nodeAttrs, nodeData, processedAttrs;
var roAttrs = opt.roAttributes;
var nodesAttrs = this.findNodesAttributes(roAttrs || attrs, rootNode, selectorCache, opt.selectors);
// `nodesAttrs` are different from all attributes, when
// rendering only attributes sent to this method.
var nodesAllAttrs = (roAttrs)
? this.findNodesAttributes(attrs, rootNode, selectorCache, opt.selectors)
: nodesAttrs;
for (var nodeId in nodesAttrs) {
nodeData = nodesAttrs[nodeId];
nodeAttrs = nodeData.attributes;
node = nodeData.node;
processedAttrs = this.processNodeAttributes(node, nodeAttrs);
if (!processedAttrs.set && !processedAttrs.position && !processedAttrs.offset && !processedAttrs.raw.ref) {
// Set all the normal attributes right on the SVG/HTML element.
this.setNodeAttributes(node, evalAttributes(processedAttrs.normal, opt.rootBBox));
} else {
var nodeAllAttrs = nodesAllAttrs[nodeId] && nodesAllAttrs[nodeId].attributes;
var refSelector = (nodeAllAttrs && (nodeAttrs.ref === undefined))
? nodeAllAttrs.ref
: nodeAttrs.ref;
var refNode;
if (refSelector) {
refNode = (selectorCache[refSelector] || this.findBySelector(refSelector, rootNode, opt.selectors))[0];
if (!refNode) {
throw new Error('dia.CellView: "' + refSelector + '" reference does not exist.');
}
} else {
refNode = null;
}
item = {
node: node,
refNode: refNode,
processedAttributes: processedAttrs,
allAttributes: nodeAllAttrs
};
if (refNode) {
// If an element in the list is positioned relative to this one, then
// we want to insert this one before it in the list.
var itemIndex = relativeRefItems.findIndex(function(item) {
return item.refNode === node;
});
if (itemIndex > -1) {
relativeRefItems.splice(itemIndex, 0, item);
} else {
relativeRefItems.push(item);
}
} else {
// A node with no ref attribute. To be updated before the nodes referencing other nodes.
// The order of no-ref-items is not specified/important.
relativeItems.push(item);
}
}
}
relativeItems.push(...relativeRefItems);
for (let i = 0, n = relativeItems.length; i < n; i++) {
item = relativeItems[i];
node = item.node;
refNode = item.refNode;
// Find the reference element bounding box. If no reference was provided, we
// use the optional bounding box.
const refNodeId = refNode ? V.ensureId(refNode) : '';
let refBBox = bboxCache[refNodeId];
if (!refBBox) {
// Get the bounding box of the reference element using to the common ancestor
// transformation space.
//
// @example 1
// <g transform="translate(11, 13)">
// <rect @selector="b" x="1" y="2" width="3" height="4"/>
// <rect @selector="a"/>
// </g>
//
// In this case, the reference bounding box can not be affected
// by the `transform` attribute of the `<g>` element,
// because the exact transformation will be applied to the `a` element
// as well as to the `b` element.
//
// @example 2
// <g transform="translate(11, 13)">
// <rect @selector="b" x="1" y="2" width="3" height="4"/>
// </g>
// <rect @selector="a"/>
//
// In this case, the reference bounding box have to be affected by the
// `transform` attribute of the `<g>` element, because the `a` element
// is not descendant of the `<g>` element and will not be affected
// by the transformation.
refBBox = bboxCache[refNodeId] = (refNode)
? V(refNode).getBBox({ target: getCommonAncestorNode(node, refNode) })
: opt.rootBBox;
}
if (roAttrs) {
// if there was a special attribute affecting the position amongst passed-in attributes
// we have to merge it with the rest of the element's attributes as they are necessary
// to update the position relatively (i.e `ref-x` && 'ref-dx')
processedAttrs = this.processNodeAttributes(node, item.allAttributes);
this.mergeProcessedAttributes(processedAttrs, item.processedAttributes);
} else {
processedAttrs = item.processedAttributes;
}
this.updateRelativeAttributes(node, processedAttrs, refBBox, opt);
}
},
mergeProcessedAttributes: function(processedAttrs, roProcessedAttrs) {
processedAttrs.set || (processedAttrs.set = {});
processedAttrs.position || (processedAttrs.position = {});
processedAttrs.offset || (processedAttrs.offset = {});
assign(processedAttrs.set, roProcessedAttrs.set);
assign(processedAttrs.position, roProcessedAttrs.position);
assign(processedAttrs.offset, roProcessedAttrs.offset);
// Handle also the special transform property.
var transform = processedAttrs.normal && processedAttrs.normal.transform;
if (transform !== undefined && roProcessedAttrs.normal) {
roProcessedAttrs.normal.transform = transform;
}
processedAttrs.normal = roProcessedAttrs.normal;
},
// Lifecycle methods
// Called when the view is attached to the DOM,
// as result of `cell.addTo(graph)` being called (isInitialMount === true)
// or `paper.options.viewport` returning `true` (isInitialMount === false).
onMount(isInitialMount) {
if (isInitialMount) return;
this.mountTools();
HighlighterView.mount(this);
},
// Called when the view is detached from the DOM,
// as result of `paper.options.viewport` returning `false`.
onDetach() {
this.unmountTools();
HighlighterView.unmount(this);
},
// Called when the view is removed from the DOM
// as result of `cell.remove()`.
onRemove: function() {
this.removeTools();
this.removeHighlighters();
},
_toolsView: null,
hasTools: function(name) {
var toolsView = this._toolsView;
if (!toolsView) return false;
if (!name) return true;
return (toolsView.getName() === name);
},
addTools: function(toolsView) {
this.removeTools();
if (toolsView) {
this._toolsView = toolsView;
toolsView.configure({ relatedView: this });
toolsView.listenTo(this.paper, 'tools:event', this.onToolEvent.bind(this));
}
return this;
},
unmountTools() {
const toolsView = this._toolsView;
if (toolsView) toolsView.unmount();
return this;
},
mountTools() {
const toolsView = this._toolsView;
// Prevent unnecessary re-appending of the tools.
if (toolsView && !toolsView.isMounted()) toolsView.mount();
return this;
},
updateTools: function(opt) {
var toolsView = this._toolsView;
if (toolsView) toolsView.update(opt);
return this;
},
removeTools: function() {
var toolsView = this._toolsView;
if (toolsView) {
toolsView.remove();
this._toolsView = null;
}
return this;
},
hideTools: function() {
var toolsView = this._toolsView;
if (toolsView) toolsView.hide();
return this;
},
showTools: function() {
var toolsView = this._toolsView;
if (toolsView) toolsView.show();
return this;
},
onToolEvent: function(event) {
switch (event) {
case 'remove':
this.removeTools();
break;
case 'hide':
this.hideTools();
break;
case 'show':
this.showTools();
break;
}
},
removeHighlighters: function() {
HighlighterView.remove(this);
},
updateHighlighters: function(dirty = false) {
HighlighterView.update(this, null, dirty);
},
transformHighlighters: function() {
HighlighterView.transform(this);
},
// Interaction. The controller part.
// ---------------------------------
preventDefaultInteraction(evt) {
this.eventData(evt, { defaultInteractionPrevented: true });
},
isDefaultInteractionPrevented(evt) {
const { defaultInteractionPrevented = false } = this.eventData(evt);
return defaultInteractionPrevented;
},
// Interaction is handled by the paper and delegated to the view in interest.
// `x` & `y` parameters passed to these functions represent the coordinates already snapped to the paper grid.
// If necessary, real coordinates can be obtained from the `evt` event object.
// These functions are supposed to be overridden by the views that inherit from `joint.dia.Cell`,
// i.e. `joint.dia.Element` and `joint.dia.Link`.
pointerdblclick: function(evt, x, y) {
this.notify('cell:pointerdblclick', evt, x, y);
},
pointerclick: function(evt, x, y) {
this.notify('cell:pointerclick', evt, x, y);
},
contextmenu: function(evt, x, y) {
this.notify('cell:contextmenu', evt, x, y);
},
pointerdown: function(evt, x, y) {
const { model } = this;
const { graph } = model;
if (graph) {
model.startBatch('pointer');
this.eventData(evt, { graph });
}
this.notify('cell:pointerdown', evt, x, y);
},
pointermove: function(evt, x, y) {
this.notify('cell:pointermove', evt, x, y);
},
pointerup: function(evt, x, y) {
const { graph } = this.eventData(evt);
this.notify('cell:pointerup', evt, x, y);
if (graph) {
// we don't want to trigger event on model as model doesn't
// need to be member of collection anymore (remove)
graph.stopBatch('pointer', { cell: this.model });
}
},
mouseover: function(evt) {
this.notify('cell:mouseover', evt);
},
mouseout: function(evt) {
this.notify('cell:mouseout', evt);
},
mouseenter: function(evt) {
this.notify('cell:mouseenter', evt);
},
mouseleave: function(evt) {
this.notify('cell:mouseleave', evt);
},
mousewheel: function(evt, x, y, delta) {
this.notify('cell:mousewheel', evt, x, y, delta);
},
onevent: function(evt, eventName, x, y) {
this.notify(eventName, evt, x, y);
},
onmagnet: function() {
// noop
},
magnetpointerdblclick: function() {
// noop
},
magnetcontextmenu: function() {
// noop
},
checkMouseleave(evt) {
const { paper, model } = this;
if (paper.isAsync()) {
// Make sure the source/target views are updated before this view.
// It's not 100% bulletproof (see below) but it's a good enough solution for now.
// The connected cells could be links as well. In that case, we would
// need to recursively go through all the connected links and update
// their source/target views as well.
if (model.isLink()) {
// The `this.sourceView` and `this.targetView` might not be updated yet.
// We need to find the view by the model.
const sourceElement = model.getSourceElement();
if (sourceElement) {
const sourceView = paper.findViewByModel(sourceElement);
if (sourceView) {
paper.dumpView(sourceView);
paper.checkViewVisibility(sourceView);
}
}
const targetElement = model.getTargetElement();
if (targetElement) {
const targetView = paper.findViewByModel(targetElement);
if (targetView) {
paper.dumpView(targetView);
paper.checkViewVisibility(targetView);
}
}
}
// Do the updates of the current view synchronously now
paper.dumpView(this);
paper.checkViewVisibility(this);
}
const target = this.getEventTarget(evt, { fromPoint: true });
const view = paper.findView(target);
if (view === this) return;
// Leaving the current view
this.mouseleave(evt);
if (!view) return;
// Entering another view
view.mouseenter(evt);
},
setInteractivity: function(value) {
this.options.interactive = value;
},
isIntersecting: function(geometryShape, geometryData) {
return intersection.exists(geometryShape, this.getNodeBBox(this.el), geometryData);
},
isEnclosedIn: function(geometryRect) {
return geometryRect.containsRect(this.getNodeBBox(this.el));
},
isInArea: function(geometryRect, options = {}) {
if (options.strict) {
return this.isEnclosedIn(geometryRect);
}
return this.isIntersecting(geometryRect);
},
isAtPoint: function(point, options) {
return this.getNodeBBox(this.el).containsPoint(point, options);
}
}, {
Flags,
Highlighting: HighlightingTypes,
addPresentationAttributes: function(presentationAttributes) {
return merge({}, result(this.prototype, 'presentationAttributes'), presentationAttributes, function(a, b) {
if (!a || !b) return;
if (typeof a === 'string') a = [a];
if (typeof b === 'string') b = [b];
if (Array.isArray(a) && Array.isArray(b)) return uniq(a.concat(b));
});
},
evalAttribute,
});
Object.defineProperty(CellView.prototype, 'useCSSSelectors', {
get() {
const localUse = this.model.useCSSSelectors;
if (localUse !== undefined) return localUse;
return config.useCSSSelectors;
}
});
// TODO: Move to Vectorizer library.
function getCommonAncestorNode(node1, node2) {
let parent = node1;
do {
if (parent.contains(node2)) return parent;
parent = parent.parentNode;
} while (parent);
return null;
}