UNPKG

jointjs

Version:

JavaScript diagramming library

815 lines (587 loc) 24 kB
import Backbone from 'backbone'; import { uniqueId, result, merge, forIn, isObject, isEqual, isString, cloneDeep, omit, uuid, isEmpty, assign, uniq, toArray, defaults, setByPath, unsetByPath, getByPath, timing, interpolate, nextFrame, without, cancelFrame, defaultsDeep, has } from '../util/util.mjs'; import { cloneCells } from '../util/cloneCells.mjs'; import { attributes } from './attributes/index.mjs'; import * as g from '../g/index.mjs'; // Cell base model. // -------------------------- export const Cell = Backbone.Model.extend({ // This is the same as Backbone.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 of Backbone (prior v1.4) 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. attrs = merge({}, defaults, attrs); //</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() { const defaults = result(this.constructor.prototype, 'defaults'); const defaultAttrs = defaults.attrs || {}; const attrs = this.attributes.attrs; const finalAttrs = {}; // Loop through all the attributes and // omit the default attributes as they are implicitly reconstructible by the cell 'type'. forIn(attrs, function(attr, selector) { const defaultAttr = defaultAttrs[selector]; forIn(attr, function(value, name) { // attr is mainly flat though it might have one more level (consider the `style` attribute). // Check if the `value` is object and if yes, go one level deep. if (isObject(value) && !Array.isArray(value)) { forIn(value, function(value2, name2) { if (!defaultAttr || !defaultAttr[name] || !isEqual(defaultAttr[name][name2], value2)) { finalAttrs[selector] = finalAttrs[selector] || {}; (finalAttrs[selector][name] || (finalAttrs[selector][name] = {}))[name2] = value2; } }); } else if (!defaultAttr || !isEqual(defaultAttr[name], value)) { // `value` is not an object, default attribute for such a selector does not exist // or it is different than the attribute value set on the model. finalAttrs[selector] = finalAttrs[selector] || {}; finalAttrs[selector][name] = value; } }); }); const attributes = cloneDeep(omit(this.attributes, 'attrs')); attributes.attrs = finalAttrs; return attributes; }, initialize: function(options) { if (!options || !options.id) { this.set('id', this.generateId(), { silent: true }); } this._transitionIds = {}; // Collect ports defined in `attrs` and keep collecting whenever `attrs` object changes. this.processPorts(); this.on('change:attrs', this.processPorts, this); }, 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 Backbone 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 = opt || {}; var z = graph.maxZIndex(); var cells; if (opt.deep) { cells = this.getEmbeddedCells({ deep: true, breadthFirst: opt.breadthFirst !== false }); cells.unshift(this); } else { cells = [this]; } z = z - cells.length + 1; var collection = graph.get('cells'); var shouldUpdate = (collection.indexOf(this) !== (collection.length - cells.length)); if (!shouldUpdate) { shouldUpdate = cells.some(function(cell, index) { return cell.get('z') !== z + index; }); } if (shouldUpdate) { this.startBatch('to-front'); z = z + cells.length; cells.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 = opt || {}; var z = graph.minZIndex(); var cells; if (opt.deep) { cells = this.getEmbeddedCells({ deep: true, breadthFirst: opt.breadthFirst !== false }); cells.unshift(this); } else { cells = [this]; } var collection = graph.get('cells'); var shouldUpdate = (collection.indexOf(this) !== 0); if (!shouldUpdate) { shouldUpdate = cells.some(function(cell, index) { return cell.get('z') !== z + index; }); } if (shouldUpdate) { this.startBatch('to-back'); z -= cells.length; cells.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) { if (this === cell || this.isEmbeddedIn(cell)) { throw new Error('Recursive embedding not allowed.'); } else { this.startBatch('embed'); var embeds = assign([], this.get('embeds')); // 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('embed'); } return this; }, unembed: function(cell, opt) { this.startBatch('unembed'); cell.unset('parent', opt); this.set('embeds', without(this.get('embeds'), cell.id), opt); this.stopBatch('unembed'); return this; }, 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) { var cells; if (opt.deep) { if (opt.breadthFirst) { // breadthFirst algorithm cells = []; var queue = this.getEmbeddedCells(); while (queue.length > 0) { var parent = queue.shift(); cells.push(parent); queue.push.apply(queue, parent.getEmbeddedCells()); } } else { // depthFirst algorithm cells = this.getEmbeddedCells(); cells.forEach(function(cell) { cells.push.apply(cells, cell.getEmbeddedCells(opt)); }); } } else { cells = toArray(this.get('embeds')).map(this.graph.getCell, this.graph); } return cells; } return []; }, isEmbeddedIn: function(cell, opt) { var cellId = isString(cell) ? cell : cell.id; var parentId = this.parent(); opt = defaults({ 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 = Backbone.Model.prototype.clone.apply(this, arguments); // We don't want the clone to have the same ID as the original. clone.set('id', 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; opt = opt || {}; opt.propertyPath = path; opt.propertyValue = value; opt.propertyPathArray = pathArray; if (pathArrayLength === 1) { // Property is not nested. We can simply use `set()`. return this.set(property, value, opt); } var update = {}; // Initialize the nested object. Subobjects 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). opt.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], opt); } else { return getByPath(this.attributes, props, delim); } } return this.set(merge({}, this.attributes, props), value); }, // 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 = cloneDeep(this.get(property)); 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); var initiator = function(callback) { this.stopTransitions(path); interpolatingFunction = opt.valueFunction(getByPath(this.attributes, path, delim), value); this._transitionIds[path] = nextFrame(callback); this.trigger('transition:start', this, path); }.bind(this); return setTimeout(initiator, opt.delay, setter); }, getTransitions: function() { return Object.keys(this._transitionIds); }, stopTransitions: function(path, delim) { delim = delim || '/'; var pathArray = path && path.split(delim); Object.keys(this._transitionIds).filter(pathArray && function(key) { return isEqual(pathArray, key.split(delim).slice(0, pathArray.length)); }).forEach(function(key) { cancelFrame(this._transitionIds[key]); delete this._transitionIds[key]; this.trigger('transition:end', this, key); }, this); return this; }, // A shorcut making it easy to create constructs like the following: // `var el = (new joint.shapes.basic.Rect).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); }, getPointFromConnectedLink: function() { // To be overridden return new g.Point(); }, getBBox: function() { // To be overridden return new g.Rect(0, 0, 0, 0); } }, { 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 /* global joint: true */ if (typeof joint !== 'undefined' && has(joint, 'shapes')) { setByPath(joint.shapes, type, Cell, '.'); } /* global joint: false */ return Cell; } });