@joint/core
Version:
JavaScript diagramming library
973 lines (745 loc) • 30.3 kB
JavaScript
import {
uniqueId,
union,
result,
merge,
forIn,
isObject,
isEqual,
isString,
cloneDeep,
omit,
uuid,
isEmpty,
assign,
uniq,
toArray,
setByPath,
unsetByPath,
getByPath,
timing,
interpolate,
nextFrame,
without,
cancelFrame,
defaultsDeep,
has,
sortBy,
defaults,
objectDifference
} from '../util/util.mjs';
import { Model } from '../mvc/Model.mjs';
import { cloneCells } from '../util/cloneCells.mjs';
import { attributes } from './attributes/index.mjs';
import * as g from '../g/index.mjs';
// Cell base model.
// --------------------------
const attributesMerger = function(a, b) {
if (Array.isArray(a)) {
return b;
}
};
function removeEmptyAttributes(obj) {
// Remove toplevel empty attributes
for (const key in obj) {
const objValue = obj[key];
const isRealObject = isObject(objValue) && !Array.isArray(objValue);
if (!isRealObject) continue;
if (isEmpty(objValue)) {
delete obj[key];
}
}
}
export const Cell = Model.extend({
// This is the same as mvc.Model with the only difference that is uses util.merge
// instead of just _.extend. The reason is that we want to mixin attributes set in upper classes.
constructor: function(attributes, options) {
var defaults;
var attrs = attributes || {};
if (typeof this.preinitialize === 'function') {
// Check to support an older version
this.preinitialize.apply(this, arguments);
}
this.cid = uniqueId('c');
this.attributes = {};
if (options && options.collection) this.collection = options.collection;
if (options && options.parse) attrs = this.parse(attrs, options) || {};
if ((defaults = result(this, 'defaults'))) {
//<custom code>
// Replaced the call to _.defaults with util.merge.
const customizer = (options && options.mergeArrays === true) ? false : attributesMerger;
attrs = merge({}, defaults, attrs, customizer);
//</custom code>
}
this.set(attrs, options);
this.changed = {};
this.initialize.apply(this, arguments);
},
translate: function(dx, dy, opt) {
throw new Error('Must define a translate() method.');
},
toJSON: function(opt) {
const { ignoreDefaults, ignoreEmptyAttributes = false } = opt || {};
const defaults = result(this.constructor.prototype, 'defaults');
if (ignoreDefaults === false) {
// Return all attributes without omitting the defaults
const finalAttributes = cloneDeep(this.attributes);
if (!ignoreEmptyAttributes) return finalAttributes;
removeEmptyAttributes(finalAttributes);
return finalAttributes;
}
let defaultAttributes = {};
let attributes = cloneDeep(this.attributes);
if (ignoreDefaults === true) {
// Compare all attributes with the defaults
defaultAttributes = defaults;
} else {
// Compare only the specified attributes with the defaults, use `attrs` as a default if not specified
const differentiateKeys = Array.isArray(ignoreDefaults) ? ignoreDefaults : ['attrs'];
differentiateKeys.forEach((key) => {
defaultAttributes[key] = defaults[key] || {};
});
}
// Omit `id` and `type` attribute from the defaults since it should be always present
const finalAttributes = objectDifference(attributes, omit(defaultAttributes, 'id', 'type'), { maxDepth: 4 });
if (ignoreEmptyAttributes) {
removeEmptyAttributes(finalAttributes);
}
return finalAttributes;
},
initialize: function(options) {
const idAttribute = this.getIdAttribute();
if (!options || options[idAttribute] === undefined) {
this.set(idAttribute, this.generateId(), { silent: true });
}
this._transitionIds = {};
this._scheduledTransitionIds = {};
// Collect ports defined in `attrs` and keep collecting whenever `attrs` object changes.
this.processPorts();
this.on('change:attrs', this.processPorts, this);
},
getIdAttribute: function() {
return this.idAttribute || 'id';
},
generateId: function() {
return uuid();
},
/**
* @deprecated
*/
processPorts: function() {
// Whenever `attrs` changes, we extract ports from the `attrs` object and store it
// in a more accessible way. Also, if any port got removed and there were links that had `target`/`source`
// set to that port, we remove those links as well (to follow the same behaviour as
// with a removed element).
var previousPorts = this.ports;
// Collect ports from the `attrs` object.
var ports = {};
forIn(this.get('attrs'), function(attrs, selector) {
if (attrs && attrs.port) {
// `port` can either be directly an `id` or an object containing an `id` (and potentially other data).
if (attrs.port.id !== undefined) {
ports[attrs.port.id] = attrs.port;
} else {
ports[attrs.port] = { id: attrs.port };
}
}
});
// Collect ports that have been removed (compared to the previous ports) - if any.
// Use hash table for quick lookup.
var removedPorts = {};
forIn(previousPorts, function(port, id) {
if (!ports[id]) removedPorts[id] = true;
});
// Remove all the incoming/outgoing links that have source/target port set to any of the removed ports.
if (this.graph && !isEmpty(removedPorts)) {
var inboundLinks = this.graph.getConnectedLinks(this, { inbound: true });
inboundLinks.forEach(function(link) {
if (removedPorts[link.get('target').port]) link.remove();
});
var outboundLinks = this.graph.getConnectedLinks(this, { outbound: true });
outboundLinks.forEach(function(link) {
if (removedPorts[link.get('source').port]) link.remove();
});
}
// Update the `ports` object.
this.ports = ports;
},
remove: function(opt = {}) {
// Store the graph in a variable because `this.graph` won't be accessible
// after `this.trigger('remove', ...)` down below.
const { graph, collection } = this;
if (!graph) {
// The collection is a common mvc collection (not the graph collection).
if (collection) collection.remove(this, opt);
return this;
}
graph.startBatch('remove');
// First, unembed this cell from its parent cell if there is one.
const parentCell = this.getParentCell();
if (parentCell) {
parentCell.unembed(this, opt);
}
// Remove also all the cells, which were embedded into this cell
const embeddedCells = this.getEmbeddedCells();
for (let i = 0, n = embeddedCells.length; i < n; i++) {
const embed = embeddedCells[i];
if (embed) {
embed.remove(opt);
}
}
this.trigger('remove', this, graph.attributes.cells, opt);
graph.stopBatch('remove');
return this;
},
toFront: function(opt) {
var graph = this.graph;
if (graph) {
opt = defaults(opt || {}, { foregroundEmbeds: true });
let cells;
if (opt.deep) {
cells = this.getEmbeddedCells({ deep: true, breadthFirst: opt.breadthFirst !== false, sortSiblings: opt.foregroundEmbeds });
cells.unshift(this);
} else {
cells = [this];
}
const sortedCells = opt.foregroundEmbeds ? cells : sortBy(cells, cell => cell.z());
const maxZ = graph.maxZIndex();
let z = maxZ - cells.length + 1;
const collection = graph.get('cells');
let shouldUpdate = (collection.toArray().indexOf(sortedCells[0]) !== (collection.length - cells.length));
if (!shouldUpdate) {
shouldUpdate = sortedCells.some(function(cell, index) {
return cell.z() !== z + index;
});
}
if (shouldUpdate) {
this.startBatch('to-front');
z = z + cells.length;
sortedCells.forEach(function(cell, index) {
cell.set('z', z + index, opt);
});
this.stopBatch('to-front');
}
}
return this;
},
toBack: function(opt) {
var graph = this.graph;
if (graph) {
opt = defaults(opt || {}, { foregroundEmbeds: true });
let cells;
if (opt.deep) {
cells = this.getEmbeddedCells({ deep: true, breadthFirst: opt.breadthFirst !== false, sortSiblings: opt.foregroundEmbeds });
cells.unshift(this);
} else {
cells = [this];
}
const sortedCells = opt.foregroundEmbeds ? cells : sortBy(cells, cell => cell.z());
let z = graph.minZIndex();
var collection = graph.get('cells');
let shouldUpdate = (collection.toArray().indexOf(sortedCells[0]) !== 0);
if (!shouldUpdate) {
shouldUpdate = sortedCells.some(function(cell, index) {
return cell.z() !== z + index;
});
}
if (shouldUpdate) {
this.startBatch('to-back');
z -= cells.length;
sortedCells.forEach(function(cell, index) {
cell.set('z', z + index, opt);
});
this.stopBatch('to-back');
}
}
return this;
},
parent: function(parent, opt) {
// getter
if (parent === undefined) return this.get('parent');
// setter
return this.set('parent', parent, opt);
},
embed: function(cell, opt = {}) {
const cells = Array.isArray(cell) ? cell : [cell];
if (!this.canEmbed(cells)) {
throw new Error('Recursive embedding not allowed.');
}
if (opt.reparent) {
const parents = uniq(cells.map(c => c.getParentCell()));
// Unembed cells from their current parents.
parents.forEach((parent) => {
// Cell doesn't have to be embedded.
if (!parent) return;
// Pass all the `cells` since the `dia.Cell._unembedCells` method can handle cases
// where not all elements of `cells` are embedded in the same parent.
parent._unembedCells(cells, opt);
});
} else if (cells.some(c => c.isEmbedded() && this.id !== c.parent())) {
throw new Error('Embedding of already embedded cells is not allowed.');
}
this._embedCells(cells, opt);
return this;
},
unembed: function(cell, opt) {
const cells = Array.isArray(cell) ? cell : [cell];
this._unembedCells(cells, opt);
return this;
},
canEmbed: function(cell) {
const cells = Array.isArray(cell) ? cell : [cell];
return cells.every(c => this !== c && !this.isEmbeddedIn(c));
},
_embedCells: function(cells, opt) {
const batchName = 'embed';
this.startBatch(batchName);
const embeds = assign([], this.get('embeds'));
cells.forEach(cell => {
// We keep all element ids after link ids.
embeds[cell.isLink() ? 'unshift' : 'push'](cell.id);
cell.parent(this.id, opt);
});
this.set('embeds', uniq(embeds), opt);
this.stopBatch(batchName);
},
_unembedCells: function(cells, opt) {
const batchName = 'unembed';
this.startBatch(batchName);
cells.forEach(cell => cell.unset('parent', opt));
this.set('embeds', without(this.get('embeds'), ...cells.map(cell => cell.id)), opt);
this.stopBatch(batchName);
},
getParentCell: function() {
// unlike link.source/target, cell.parent stores id directly as a string
var parentId = this.parent();
var graph = this.graph;
return (parentId && graph && graph.getCell(parentId)) || null;
},
// Return an array of ancestor cells.
// The array is ordered from the parent of the cell
// to the most distant ancestor.
getAncestors: function() {
var ancestors = [];
if (!this.graph) {
return ancestors;
}
var parentCell = this.getParentCell();
while (parentCell) {
ancestors.push(parentCell);
parentCell = parentCell.getParentCell();
}
return ancestors;
},
getEmbeddedCells: function(opt) {
opt = opt || {};
// Cell models can only be retrieved when this element is part of a collection.
// There is no way this element knows about other cells otherwise.
// This also means that calling e.g. `translate()` on an element with embeds before
// adding it to a graph does not translate its embeds.
if (!this.graph) {
return [];
}
if (opt.deep) {
if (opt.breadthFirst) {
return this._getEmbeddedCellsBfs(opt.sortSiblings);
} else {
return this._getEmbeddedCellsDfs(opt.sortSiblings);
}
}
const embeddedIds = this.get('embeds');
if (isEmpty(embeddedIds)) {
return [];
}
let cells = embeddedIds.map(this.graph.getCell, this.graph);
if (opt.sortSiblings) {
cells = sortBy(cells, cell => cell.z());
}
return cells;
},
_getEmbeddedCellsBfs: function(sortSiblings) {
const cells = [];
const queue = [];
queue.push(this);
while (queue.length > 0) {
const current = queue.shift();
cells.push(current);
const embeddedCells = current.getEmbeddedCells({ sortSiblings: sortSiblings });
queue.push(...embeddedCells);
}
cells.shift();
return cells;
},
_getEmbeddedCellsDfs: function(sortSiblings) {
const cells = [];
const stack = [];
stack.push(this);
while (stack.length > 0) {
const current = stack.pop();
cells.push(current);
const embeddedCells = current.getEmbeddedCells({ sortSiblings: sortSiblings });
// When using the stack, cells that are embedded last are processed first.
// To maintain the original order, we need to push the cells in reverse order
for (let i = embeddedCells.length - 1; i >= 0; --i) {
stack.push(embeddedCells[i]);
}
}
cells.shift();
return cells;
},
isEmbeddedIn: function(cell, opt) {
var cellId = isString(cell) ? cell : cell.id;
var parentId = this.parent();
opt = assign({ deep: true }, opt);
// See getEmbeddedCells().
if (this.graph && opt.deep) {
while (parentId) {
if (parentId === cellId) {
return true;
}
parentId = this.graph.getCell(parentId).parent();
}
return false;
} else {
// When this cell is not part of a collection check
// at least whether it's a direct child of given cell.
return parentId === cellId;
}
},
// Whether or not the cell is embedded in any other cell.
isEmbedded: function() {
return !!this.parent();
},
// Isolated cloning. Isolated cloning has two versions: shallow and deep (pass `{ deep: true }` in `opt`).
// Shallow cloning simply clones the cell and returns a new cell with different ID.
// Deep cloning clones the cell and all its embedded cells recursively.
clone: function(opt) {
opt = opt || {};
if (!opt.deep) {
// Shallow cloning.
var clone = Model.prototype.clone.apply(this, arguments);
// We don't want the clone to have the same ID as the original.
clone.set(this.getIdAttribute(), this.generateId());
// A shallow cloned element does not carry over the original embeds.
clone.unset('embeds');
// And can not be embedded in any cell
// as the clone is not part of the graph.
clone.unset('parent');
return clone;
} else {
// Deep cloning.
// For a deep clone, simply call `graph.cloneCells()` with the cell and all its embedded cells.
return toArray(cloneCells([this].concat(this.getEmbeddedCells({ deep: true }))));
}
},
// A convenient way to set nested properties.
// This method merges the properties you'd like to set with the ones
// stored in the cell and makes sure change events are properly triggered.
// You can either set a nested property with one object
// or use a property path.
// The most simple use case is:
// `cell.prop('name/first', 'John')` or
// `cell.prop({ name: { first: 'John' } })`.
// Nested arrays are supported too:
// `cell.prop('series/0/data/0/degree', 50)` or
// `cell.prop({ series: [ { data: [ { degree: 50 } ] } ] })`.
prop: function(props, value, opt) {
var delim = '/';
var _isString = isString(props);
if (_isString || Array.isArray(props)) {
// Get/set an attribute by a special path syntax that delimits
// nested objects by the colon character.
if (arguments.length > 1) {
var path;
var pathArray;
if (_isString) {
path = props;
pathArray = path.split('/');
} else {
path = props.join(delim);
pathArray = props.slice();
}
var property = pathArray[0];
var pathArrayLength = pathArray.length;
const options = opt || {};
options.propertyPath = path;
options.propertyValue = value;
options.propertyPathArray = pathArray;
if (!('rewrite' in options)) {
options.rewrite = false;
}
var update = {};
// Initialize the nested object. Sub-objects are either arrays or objects.
// An empty array is created if the sub-key is an integer. Otherwise, an empty object is created.
// Note that this imposes a limitation on object keys one can use with Inspector.
// Pure integer keys will cause issues and are therefore not allowed.
var initializer = update;
var prevProperty = property;
for (var i = 1; i < pathArrayLength; i++) {
var pathItem = pathArray[i];
var isArrayIndex = Number.isFinite(_isString ? Number(pathItem) : pathItem);
initializer = initializer[prevProperty] = isArrayIndex ? [] : {};
prevProperty = pathItem;
}
// Fill update with the `value` on `path`.
update = setByPath(update, pathArray, value, '/');
var baseAttributes = merge({}, this.attributes);
// if rewrite mode enabled, we replace value referenced by path with
// the new one (we don't merge).
options.rewrite && unsetByPath(baseAttributes, path, '/');
// Merge update with the model attributes.
var attributes = merge(baseAttributes, update);
// Finally, set the property to the updated attributes.
return this.set(property, attributes[property], options);
} else {
return getByPath(this.attributes, props, delim);
}
}
const options = value || {};
// Note: '' is not the path to the root. It's a path with an empty string i.e. { '': {}}.
options.propertyPath = null;
options.propertyValue = props;
options.propertyPathArray = [];
if (!('rewrite' in options)) {
options.rewrite = false;
}
// Create a new object containing only the changed attributes.
const changedAttributes = {};
for (const key in props) {
// Merging the values of changed attributes with the current ones.
const { changedValue } = merge({}, { changedValue: this.attributes[key] }, { changedValue: props[key] });
changedAttributes[key] = changedValue;
}
return this.set(changedAttributes, options);
},
// A convenient way to unset nested properties
removeProp: function(path, opt) {
opt = opt || {};
var pathArray = Array.isArray(path) ? path : path.split('/');
// Once a property is removed from the `attrs` attribute
// the cellView will recognize a `dirty` flag and re-render itself
// in order to remove the attribute from SVG element.
var property = pathArray[0];
if (property === 'attrs') opt.dirty = true;
if (pathArray.length === 1) {
// A top level property
return this.unset(path, opt);
}
// A nested property
var nestedPath = pathArray.slice(1);
var propertyValue = this.get(property);
if (propertyValue === undefined || propertyValue === null) return this;
propertyValue = cloneDeep(propertyValue);
unsetByPath(propertyValue, nestedPath, '/');
return this.set(property, propertyValue, opt);
},
// A convenient way to set nested attributes.
attr: function(attrs, value, opt) {
var args = Array.from(arguments);
if (args.length === 0) {
return this.get('attrs');
}
if (Array.isArray(attrs)) {
args[0] = ['attrs'].concat(attrs);
} else if (isString(attrs)) {
// Get/set an attribute by a special path syntax that delimits
// nested objects by the colon character.
args[0] = 'attrs/' + attrs;
} else {
args[0] = { 'attrs' : attrs };
}
return this.prop.apply(this, args);
},
// A convenient way to unset nested attributes
removeAttr: function(path, opt) {
if (Array.isArray(path)) {
return this.removeProp(['attrs'].concat(path));
}
return this.removeProp('attrs/' + path, opt);
},
transition: function(path, value, opt, delim) {
delim = delim || '/';
var defaults = {
duration: 100,
delay: 10,
timingFunction: timing.linear,
valueFunction: interpolate.number
};
opt = assign(defaults, opt);
var firstFrameTime = 0;
var interpolatingFunction;
var setter = function(runtime) {
var id, progress, propertyValue;
firstFrameTime = firstFrameTime || runtime;
runtime -= firstFrameTime;
progress = runtime / opt.duration;
if (progress < 1) {
this._transitionIds[path] = id = nextFrame(setter);
} else {
progress = 1;
delete this._transitionIds[path];
}
propertyValue = interpolatingFunction(opt.timingFunction(progress));
opt.transitionId = id;
this.prop(path, propertyValue, opt);
if (!id) this.trigger('transition:end', this, path);
}.bind(this);
const { _scheduledTransitionIds } = this;
let initialId;
var initiator = (callback) => {
if (_scheduledTransitionIds[path]) {
_scheduledTransitionIds[path] = without(_scheduledTransitionIds[path], initialId);
if (_scheduledTransitionIds[path].length === 0) {
delete _scheduledTransitionIds[path];
}
}
this.stopPendingTransitions(path, delim);
interpolatingFunction = opt.valueFunction(getByPath(this.attributes, path, delim), value);
this._transitionIds[path] = nextFrame(callback);
this.trigger('transition:start', this, path);
};
initialId = setTimeout(initiator, opt.delay, setter);
_scheduledTransitionIds[path] || (_scheduledTransitionIds[path] = []);
_scheduledTransitionIds[path].push(initialId);
return initialId;
},
getTransitions: function() {
return union(
Object.keys(this._transitionIds),
Object.keys(this._scheduledTransitionIds)
);
},
stopScheduledTransitions: function(path, delim = '/') {
const { _scheduledTransitionIds = {}} = this;
let transitions = Object.keys(_scheduledTransitionIds);
if (path) {
const pathArray = path.split(delim);
transitions = transitions.filter((key) => {
return isEqual(pathArray, key.split(delim).slice(0, pathArray.length));
});
}
transitions.forEach((key) => {
const transitionIds = _scheduledTransitionIds[key];
// stop the initiator
transitionIds.forEach(transitionId => clearTimeout(transitionId));
delete _scheduledTransitionIds[key];
// Note: we could trigger transition:cancel` event here
});
return this;
},
stopPendingTransitions(path, delim = '/') {
const { _transitionIds = {}} = this;
let transitions = Object.keys(_transitionIds);
if (path) {
const pathArray = path.split(delim);
transitions = transitions.filter((key) => {
return isEqual(pathArray, key.split(delim).slice(0, pathArray.length));
});
}
transitions.forEach((key) => {
const transitionId = _transitionIds[key];
// stop the setter
cancelFrame(transitionId);
delete _transitionIds[key];
this.trigger('transition:end', this, key);
});
},
stopTransitions: function(path, delim = '/') {
this.stopScheduledTransitions(path, delim);
this.stopPendingTransitions(path, delim);
return this;
},
// A shorcut making it easy to create constructs like the following:
// `var el = (new joint.shapes.standard.Rectangle()).addTo(graph)`.
addTo: function(graph, opt) {
graph.addCell(this, opt);
return this;
},
// A shortcut for an equivalent call: `paper.findViewByModel(cell)`
// making it easy to create constructs like the following:
// `cell.findView(paper).highlight()`
findView: function(paper) {
return paper.findViewByModel(this);
},
isElement: function() {
return false;
},
isLink: function() {
return false;
},
startBatch: function(name, opt) {
if (this.graph) { this.graph.startBatch(name, assign({}, opt, { cell: this })); }
return this;
},
stopBatch: function(name, opt) {
if (this.graph) { this.graph.stopBatch(name, assign({}, opt, { cell: this })); }
return this;
},
getChangeFlag: function(attributes) {
var flag = 0;
if (!attributes) return flag;
for (var key in attributes) {
if (!attributes.hasOwnProperty(key) || !this.hasChanged(key)) continue;
flag |= attributes[key];
}
return flag;
},
angle: function() {
// To be overridden.
return 0;
},
position: function() {
// To be overridden.
return new g.Point(0, 0);
},
z: function() {
return this.get('z') || 0;
},
getPointFromConnectedLink: function() {
// To be overridden
return new g.Point();
},
getBBox: function() {
// To be overridden
return new g.Rect(0, 0, 0, 0);
},
getPointRotatedAroundCenter(angle, x, y) {
const point = new g.Point(x, y);
if (angle) point.rotate(this.getBBox().center(), angle);
return point;
},
getAbsolutePointFromRelative(x, y) {
// Rotate the position to take the model angle into account
return this.getPointRotatedAroundCenter(
-this.angle(),
// Transform the relative position to absolute
this.position().offset(x, y)
);
},
getRelativePointFromAbsolute(x, y) {
return this
// Rotate the coordinates to mitigate the element's rotation.
.getPointRotatedAroundCenter(this.angle(), x, y)
// Transform the absolute position into relative
.difference(this.position());
}
}, {
getAttributeDefinition: function(attrName) {
var defNS = this.attributes;
var globalDefNS = attributes;
return (defNS && defNS[attrName]) || globalDefNS[attrName];
},
define: function(type, defaults, protoProps, staticProps) {
protoProps = assign({
defaults: defaultsDeep({ type: type }, defaults, this.prototype.defaults)
}, protoProps);
var Cell = this.extend(protoProps, staticProps);
// es5 backward compatibility
/* eslint-disable no-undef */
if (typeof joint !== 'undefined' && has(joint, 'shapes')) {
setByPath(joint.shapes, type, Cell, '.');
}
/* eslint-enable no-undef */
return Cell;
}
});