jointjs
Version:
JavaScript diagramming library
1,441 lines (1,222 loc) • 89.1 kB
JavaScript
import V from '../V/index.mjs';
import {
isNumber,
assign,
nextFrame,
isObject,
cancelFrame,
defaults,
defaultsDeep,
addClassNamePrefix,
normalizeSides,
isFunction,
getByPath,
sortElements,
isString,
normalizeEvent,
omit,
merge,
camelCase,
cloneDeep,
invoke,
hashCode,
filter as _filter,
template,
toArray,
has
} from '../util/index.mjs';
import { Rect, Point, toRad } from '../g/index.mjs';
import { View, views } from '../mvc/index.mjs';
import { CellView } from './CellView.mjs';
import { ElementView } from './ElementView.mjs';
import { LinkView } from './LinkView.mjs';
import { Link } from './Link.mjs';
import { Cell } from './Cell.mjs';
import { Graph } from './Graph.mjs';
import * as highlighters from '../highlighters/index.mjs';
import * as linkAnchors from '../linkAnchors/index.mjs';
import * as connectionPoints from '../connectionPoints/index.mjs';
import * as anchors from '../anchors/index.mjs';
import $ from 'jquery';
import Backbone from 'backbone';
var sortingTypes = {
NONE: 'sorting-none',
APPROX: 'sorting-approximate',
EXACT: 'sorting-exact'
};
var FLAG_INSERT = 1<<30;
var FLAG_REMOVE = 1<<29;
var MOUNT_BATCH_SIZE = 1000;
var UPDATE_BATCH_SIZE = Infinity;
var MIN_PRIORITY = 2;
export const Paper = View.extend({
className: 'paper',
options: {
width: 800,
height: 600,
origin: { x: 0, y: 0 }, // x,y coordinates in top-left corner
gridSize: 1,
// Whether or not to draw the grid lines on the paper's DOM element.
// e.g drawGrid: true, drawGrid: { color: 'red', thickness: 2 }
drawGrid: false,
// Whether or not to draw the background on the paper's DOM element.
// e.g. background: { color: 'lightblue', image: '/paper-background.png', repeat: 'flip-xy' }
background: false,
perpendicularLinks: false,
elementView: ElementView,
linkView: LinkView,
snapLinks: false, // false, true, { radius: value }
// When set to FALSE, an element may not have more than 1 link with the same source and target element.
multiLinks: true,
// For adding custom guard logic.
guard: function(evt, view) {
// FALSE means the event isn't guarded.
return false;
},
highlighting: {
'default': {
name: 'stroke',
options: {
padding: 3
}
},
magnetAvailability: {
name: 'addClass',
options: {
className: 'available-magnet'
}
},
elementAvailability: {
name: 'addClass',
options: {
className: 'available-cell'
}
}
},
// Prevent the default context menu from being displayed.
preventContextMenu: true,
// Prevent the default action for blank:pointer<action>.
preventDefaultBlankAction: true,
// Restrict the translation of elements by given bounding box.
// Option accepts a boolean:
// true - the translation is restricted to the paper area
// false - no restrictions
// A method:
// restrictTranslate: function(elementView) {
// var parentId = elementView.model.get('parent');
// return parentId && this.model.getCell(parentId).getBBox();
// },
// Or a bounding box:
// restrictTranslate: { x: 10, y: 10, width: 790, height: 590 }
restrictTranslate: false,
// Marks all available magnets with 'available-magnet' class name and all available cells with
// 'available-cell' class name. Marks them when dragging a link is started and unmark
// when the dragging is stopped.
markAvailable: false,
// Defines what link model is added to the graph after an user clicks on an active magnet.
// Value could be the Backbone.model or a function returning the Backbone.model
// defaultLink: function(elementView, magnet) { return condition ? new customLink1() : new customLink2() }
defaultLink: new Link,
// A connector that is used by links with no connector defined on the model.
// e.g. { name: 'rounded', args: { radius: 5 }} or a function
defaultConnector: { name: 'normal' },
// A router that is used by links with no router defined on the model.
// e.g. { name: 'oneSide', args: { padding: 10 }} or a function
defaultRouter: { name: 'normal' },
defaultAnchor: { name: 'center' },
defaultLinkAnchor: { name: 'connectionRatio' },
defaultConnectionPoint: { name: 'bbox' },
/* CONNECTING */
connectionStrategy: null,
// Check whether to add a new link to the graph when user clicks on an a magnet.
validateMagnet: function(cellView, magnet) {
return magnet.getAttribute('magnet') !== 'passive';
},
// Check whether to allow or disallow the link connection while an arrowhead end (source/target)
// being changed.
validateConnection: function(cellViewS, magnetS, cellViewT, magnetT, end, linkView) {
return (end === 'target' ? cellViewT : cellViewS) instanceof ElementView;
},
/* EMBEDDING */
// Enables embedding. Re-parent the dragged element with elements under it and makes sure that
// all links and elements are visible taken the level of embedding into account.
embeddingMode: false,
// Check whether to allow or disallow the element embedding while an element being translated.
validateEmbedding: function(childView, parentView) {
// by default all elements can be in relation child-parent
return true;
},
// Determines the way how a cell finds a suitable parent when it's dragged over the paper.
// The cell with the highest z-index (visually on the top) will be chosen.
findParentBy: 'bbox', // 'bbox'|'center'|'origin'|'corner'|'topRight'|'bottomLeft'
// If enabled only the element on the very front is taken into account for the embedding.
// If disabled the elements under the dragged view are tested one by one
// (from front to back) until a valid parent found.
frontParentOnly: true,
// Interactive flags. See online docs for the complete list of interactive flags.
interactive: {
labelMove: false
},
// When set to true the links can be pinned to the paper.
// i.e. link source/target can be a point e.g. link.get('source') ==> { x: 100, y: 100 };
linkPinning: true,
// Custom validation after an interaction with a link ends.
// Recognizes a function. If `false` is returned, the link is disallowed (removed or reverted)
// (linkView, paper) => boolean
allowLink: null,
// Allowed number of mousemove events after which the pointerclick event will be still triggered.
clickThreshold: 0,
// Number of required mousemove events before the first pointermove event will be triggered.
moveThreshold: 0,
// Number of required mousemove events before the a link is created out of the magnet.
// Or string `onleave` so the link is created when the pointer leaves the magnet
magnetThreshold: 0,
// Rendering Options
sorting: sortingTypes.EXACT,
frozen: false,
onViewUpdate: function(view, flag, opt, paper) {
if ((flag & FLAG_INSERT) || opt.mounting) return;
paper.requestConnectedLinksUpdate(view, opt);
},
onViewPostponed: function(view, flag /* paper */) {
return this.forcePostponedViewUpdate(view, flag);
},
viewport: null,
// Default namespaces
cellViewNamespace: null,
highlighterNamespace: highlighters,
anchorNamespace: anchors,
linkAnchorNamespace: linkAnchors,
connectionPointNamespace: connectionPoints
},
events: {
'dblclick': 'pointerdblclick',
'contextmenu': 'contextmenu',
'mousedown': 'pointerdown',
'touchstart': 'pointerdown',
'mouseover': 'mouseover',
'mouseout': 'mouseout',
'mouseenter': 'mouseenter',
'mouseleave': 'mouseleave',
'mousewheel': 'mousewheel',
'DOMMouseScroll': 'mousewheel',
'mouseenter .joint-cell': 'mouseenter',
'mouseleave .joint-cell': 'mouseleave',
'mouseenter .joint-tools': 'mouseenter',
'mouseleave .joint-tools': 'mouseleave',
'mousedown .joint-cell [event]': 'onevent', // interaction with cell with `event` attribute set
'touchstart .joint-cell [event]': 'onevent',
'mousedown .joint-cell [magnet]': 'onmagnet', // interaction with cell with `magnet` attribute set
'touchstart .joint-cell [magnet]': 'onmagnet',
'dblclick .joint-cell [magnet]': 'magnetpointerdblclick',
'contextmenu .joint-cell [magnet]': 'magnetcontextmenu',
'mousedown .joint-link .label': 'onlabel', // interaction with link label
'touchstart .joint-link .label': 'onlabel',
'dragstart .joint-cell image': 'onImageDragStart' // firefox fix
},
documentEvents: {
'mousemove': 'pointermove',
'touchmove': 'pointermove',
'mouseup': 'pointerup',
'touchend': 'pointerup',
'touchcancel': 'pointerup'
},
svg: null,
viewport: null,
defs: null,
tools: null,
$background: null,
layers: null,
$grid: null,
$document: null,
_highlights: null,
_zPivots: null,
// For storing the current transformation matrix (CTM) of the paper's viewport.
_viewportMatrix: null,
// For verifying whether the CTM is up-to-date. The viewport transform attribute
// could have been manipulated directly.
_viewportTransformString: null,
// Updates data (priorities, unmounted views etc.)
_updates: null,
SORT_DELAYING_BATCHES: ['add', 'to-front', 'to-back'],
UPDATE_DELAYING_BATCHES: ['translate'],
MIN_SCALE: 1e-6,
init: function() {
const { options, el } = this;
if (!options.cellViewNamespace) {
/* global joint: true */
options.cellViewNamespace = typeof joint !== 'undefined' && has(joint, 'shapes') ? joint.shapes : null;
/* global joint: false */
}
const model = this.model = options.model || new Graph;
this.setGrid(options.drawGrid);
this.cloneOptions();
this.render();
this.setDimensions();
this.startListening();
// Hash of all cell views.
this._views = {};
// z-index pivots
this._zPivots = {};
// Reference to the paper owner document
this.$document = $(el.ownerDocument);
// Highlighters references
this._highlights = {};
// Render existing cells in the graph
this.resetViews(model.attributes.cells.models);
// Start the Rendering Loop
if (!this.isFrozen() && this.isAsync()) this.updateViewsAsync();
},
_resetUpdates: function() {
return this._updates = {
id: null,
priorities: [{}, {}, {}],
unmountedCids: [],
mountedCids: [],
unmounted: {},
mounted: {},
count: 0,
keyFrozen: false,
freezeKey: null,
sort: false
};
},
startListening: function() {
var model = this.model;
this.listenTo(model, 'add', this.onCellAdded)
.listenTo(model, 'remove', this.onCellRemoved)
.listenTo(model, 'change', this.onCellChange)
.listenTo(model, 'reset', this.onGraphReset)
.listenTo(model, 'sort', this.onGraphSort)
.listenTo(model, 'batch:stop', this.onGraphBatchStop);
this.on('cell:highlight', this.onCellHighlight)
.on('cell:unhighlight', this.onCellUnhighlight)
.on('scale translate', this.update);
},
onCellAdded: function(cell, _, opt) {
var position = opt.position;
if (this.isAsync() || !isNumber(position)) {
this.renderView(cell, opt);
} else {
if (opt.maxPosition === position) this.freeze({ key: 'addCells' });
this.renderView(cell, opt);
if (position === 0) this.unfreeze({ key: 'addCells' });
}
},
onCellRemoved: function(cell, _, opt) {
const view = this.findViewByModel(cell);
if (view) this.requestViewUpdate(view, FLAG_REMOVE, view.UPDATE_PRIORITY, opt);
},
onCellChange: function(cell, opt) {
if (cell === this.model.attributes.cells) return;
if (cell.hasChanged('z') && this.options.sorting === sortingTypes.APPROX) {
const view = this.findViewByModel(cell);
if (view) this.requestViewUpdate(view, FLAG_INSERT, view.UPDATE_PRIORITY, opt);
}
},
onGraphReset: function(collection, opt) {
this.removeZPivots();
this.resetViews(collection.models, opt);
},
onGraphSort: function() {
if (this.model.hasActiveBatch(this.SORT_DELAYING_BATCHES)) return;
this.sortViews();
},
onGraphBatchStop: function(data) {
if (this.isFrozen()) return;
var name = data && data.batchName;
var graph = this.model;
if (!this.isAsync()) {
var updateDelayingBatches = this.UPDATE_DELAYING_BATCHES;
if (updateDelayingBatches.includes(name) && !graph.hasActiveBatch(updateDelayingBatches)) {
this.updateViews(data);
}
}
var sortDelayingBatches = this.SORT_DELAYING_BATCHES;
if (sortDelayingBatches.includes(name) && !graph.hasActiveBatch(sortDelayingBatches)) {
this.sortViews();
}
},
cloneOptions: function() {
var options = this.options;
// This is a fix for the case where two papers share the same options.
// Changing origin.x for one paper would change the value of origin.x for the other.
// This prevents that behavior.
options.origin = assign({}, options.origin);
options.defaultConnector = assign({}, options.defaultConnector);
// Return the default highlighting options into the user specified options.
options.highlighting = defaultsDeep(
{},
options.highlighting,
this.constructor.prototype.options.highlighting
);
// Default cellView namespace for ES5
/* global joint: true */
if (!options.cellViewNamespace && typeof joint !== 'undefined' && has(joint, 'shapes')) {
options.cellViewNamespace = joint.shapes;
}
/* global joint: false */
},
children: function() {
var ns = V.namespace;
return [{
namespaceURI: ns.xhtml,
tagName: 'div',
className: addClassNamePrefix('paper-background'),
selector: 'background'
}, {
namespaceURI: ns.xhtml,
tagName: 'div',
className: addClassNamePrefix('paper-grid'),
selector: 'grid'
}, {
namespaceURI: ns.svg,
tagName: 'svg',
attributes: {
'width': '100%',
'height': '100%',
'xmlns:xlink': ns.xlink
},
selector: 'svg',
children: [{
// Append `<defs>` element to the SVG document. This is useful for filters and gradients.
// It's desired to have the defs defined before the viewport (e.g. to make a PDF document pick up defs properly).
tagName: 'defs',
selector: 'defs'
}, {
tagName: 'g',
className: addClassNamePrefix('layers'),
selector: 'layers',
children: [{
tagName: 'g',
className: addClassNamePrefix('cells-layer viewport'),
selector: 'cells',
}, {
tagName: 'g',
className: addClassNamePrefix('tools-layer'),
selector: 'tools'
}]
}]
}];
},
render: function() {
this.renderChildren();
const { childNodes, options } = this;
const { svg, cells, defs, tools, layers, background, grid } = childNodes;
this.svg = svg;
this.defs = defs;
this.tools = tools;
this.cells = cells;
this.layers = layers;
this.$background = $(background);
this.$grid = $(grid);
V.ensureId(svg);
// backwards compatibility
this.viewport = cells;
if (options.background) {
this.drawBackground(options.background);
}
if (options.drawGrid) {
this.drawGrid();
}
return this;
},
update: function() {
if (this.options.drawGrid) {
this.drawGrid();
}
if (this._background) {
this.updateBackgroundImage(this._background);
}
return this;
},
matrix: function(ctm) {
var viewport = this.layers;
// Getter:
if (ctm === undefined) {
var transformString = viewport.getAttribute('transform');
if ((this._viewportTransformString || null) === transformString) {
// It's ok to return the cached matrix. The transform attribute has not changed since
// the matrix was stored.
ctm = this._viewportMatrix;
} else {
// The viewport transform attribute has changed. Measure the matrix and cache again.
ctm = viewport.getCTM();
this._viewportMatrix = ctm;
this._viewportTransformString = transformString;
}
// Clone the cached current transformation matrix.
// If no matrix previously stored the identity matrix is returned.
return V.createSVGMatrix(ctm);
}
// Setter:
ctm = V.createSVGMatrix(ctm);
var ctmString = V.matrixToTransformString(ctm);
viewport.setAttribute('transform', ctmString);
this._viewportMatrix = ctm;
this._viewportTransformString = viewport.getAttribute('transform');
return this;
},
clientMatrix: function() {
return V.createSVGMatrix(this.cells.getScreenCTM());
},
requestConnectedLinksUpdate: function(view, opt) {
if (view instanceof CellView) {
var model = view.model;
var links = this.model.getConnectedLinks(model);
for (var j = 0, n = links.length; j < n; j++) {
var link = links[j];
var linkView = this.findViewByModel(link);
if (!linkView) continue;
var flagLabels = ['UPDATE'];
if (link.getTargetCell() === model) flagLabels.push('TARGET');
if (link.getSourceCell() === model) flagLabels.push('SOURCE');
this.scheduleViewUpdate(linkView, linkView.getFlag(flagLabels), linkView.UPDATE_PRIORITY, opt);
}
}
},
forcePostponedViewUpdate: function(view, flag) {
if (!view || !(view instanceof CellView)) return false;
var model = view.model;
if (model.isElement()) return false;
if ((flag & view.getFlag(['SOURCE', 'TARGET'])) === 0) {
// LinkView is waiting for the target or the source cellView to be rendered
// This can happen when the cells are not in the viewport.
var sourceFlag = 0;
var sourceView = this.findViewByModel(model.getSourceCell());
if (sourceView && !this.isViewMounted(sourceView)) {
sourceFlag = this.dumpView(sourceView);
view.updateEndMagnet('source');
}
var targetFlag = 0;
var targetView = this.findViewByModel(model.getTargetCell());
if (targetView && !this.isViewMounted(targetView)) {
targetFlag = this.dumpView(targetView);
view.updateEndMagnet('target');
}
if (sourceFlag === 0 && targetFlag === 0) {
return !!this.dumpView(view);
}
}
return false;
},
requestViewUpdate: function(view, flag, priority, opt) {
opt || (opt = {});
this.scheduleViewUpdate(view, flag, priority, opt);
var isAsync = this.isAsync();
if (this.isFrozen() || (isAsync && opt.async !== false)) return;
if (this.model.hasActiveBatch(this.UPDATE_DELAYING_BATCHES)) return;
var stats = this.updateViews(opt);
if (isAsync) this.trigger('render:done', stats, opt);
},
scheduleViewUpdate: function(view, type, priority, opt) {
var updates = this._updates;
var priorityUpdates = updates.priorities[priority];
if (!priorityUpdates) priorityUpdates = updates.priorities[priority] = {};
var currentType = priorityUpdates[view.cid] || 0;
// prevent cycling
if ((currentType & type) === type) return;
if (!currentType) updates.count++;
if (type & FLAG_REMOVE && currentType & FLAG_INSERT) {
// When a view is removed we need to remove the insert flag as this is a reinsert
priorityUpdates[view.cid] ^= FLAG_INSERT;
} else if (type & FLAG_INSERT && currentType & FLAG_REMOVE) {
// When a view is added we need to remove the remove flag as this is view was previously removed
priorityUpdates[view.cid] ^= FLAG_REMOVE;
}
priorityUpdates[view.cid] |= type;
var viewUpdateFn = this.options.onViewUpdate;
if (typeof viewUpdateFn === 'function') viewUpdateFn.call(this, view, type, opt || {}, this);
},
dumpViewUpdate: function(view) {
if (!view) return 0;
var updates = this._updates;
var cid = view.cid;
var priorityUpdates = updates.priorities[view.UPDATE_PRIORITY];
var flag = this.registerMountedView(view) | priorityUpdates[cid];
delete priorityUpdates[cid];
return flag;
},
dumpView: function(view, opt) {
var flag = this.dumpViewUpdate(view);
if (!flag) return 0;
return this.updateView(view, flag, opt);
},
updateView: function(view, flag, opt) {
if (!view) return 0;
if (view instanceof CellView) {
if (flag & FLAG_REMOVE) {
this.removeView(view.model);
return 0;
}
if (flag & FLAG_INSERT) {
this.insertView(view);
flag ^= FLAG_INSERT;
}
}
if (!flag) return 0;
return view.confirmUpdate(flag, opt || {});
},
requireView: function(model, opt) {
var view = this.findViewByModel(model);
if (!view) return null;
this.dumpView(view, opt);
return view;
},
registerUnmountedView: function(view) {
var cid = view.cid;
var updates = this._updates;
if (cid in updates.unmounted) return 0;
var flag = updates.unmounted[cid] |= FLAG_INSERT;
updates.unmountedCids.push(cid);
delete updates.mounted[cid];
return flag;
},
registerMountedView: function(view) {
var cid = view.cid;
var updates = this._updates;
if (cid in updates.mounted) return 0;
updates.mounted[cid] = true;
updates.mountedCids.push(cid);
var flag = updates.unmounted[cid] || 0;
delete updates.unmounted[cid];
return flag;
},
isViewMounted: function(view) {
if (!view) return false;
var cid = view.cid;
var updates = this._updates;
return (cid in updates.mounted);
},
dumpViews: function(opt) {
var passingOpt = defaults({}, opt, { viewport: null });
this.checkViewport(passingOpt);
this.updateViews(passingOpt);
},
updateViews: function(opt) {
var stats;
var updateCount = 0;
var batchCount = 0;
var priority = MIN_PRIORITY;
do {
batchCount++;
stats = this.updateViewsBatch(opt);
updateCount += stats.updated;
priority = Math.min(stats.priority, priority);
} while (!stats.empty);
return { updated: updateCount, batches: batchCount, priority };
},
updateViewsAsync: function(opt, data) {
opt || (opt = {});
data || (data = { processed: 0, priority: MIN_PRIORITY });
var updates = this._updates;
var id = updates.id;
if (id) {
cancelFrame(id);
var stats = this.updateViewsBatch(opt);
var passingOpt = defaults({}, opt, {
mountBatchSize: MOUNT_BATCH_SIZE - stats.mounted,
unmountBatchSize: MOUNT_BATCH_SIZE - stats.unmounted
});
var checkStats = this.checkViewport(passingOpt);
var unmountCount = checkStats.unmounted;
var mountCount = checkStats.mounted;
var processed = data.processed;
var total = updates.count;
if (stats.updated > 0) {
// Some updates have been just processed
processed += stats.updated + stats.unmounted;
stats.processed = processed;
data.priority = Math.min(stats.priority, data.priority);
if (stats.empty && mountCount === 0) {
stats.unmounted += unmountCount;
stats.mounted += mountCount;
stats.priority = data.priority;
this.trigger('render:done', stats, opt);
data.processed = 0;
updates.count = 0;
} else {
data.processed = processed;
}
}
// Progress callback
var progressFn = opt.progress;
if (total && typeof progressFn === 'function') {
progressFn.call(this, stats.empty, processed, total, stats, this);
}
// The current frame could have been canceled in a callback
if (updates.id !== id) return;
}
updates.id = nextFrame(this.updateViewsAsync, this, opt, data);
},
updateViewsBatch: function(opt) {
opt || (opt = {});
var batchSize = opt.batchSize || UPDATE_BATCH_SIZE;
var updates = this._updates;
var updateCount = 0;
var postponeCount = 0;
var unmountCount = 0;
var mountCount = 0;
var maxPriority = MIN_PRIORITY;
var empty = true;
var options = this.options;
var priorities = updates.priorities;
var viewportFn = 'viewport' in opt ? opt.viewport : options.viewport;
if (typeof viewportFn !== 'function') viewportFn = null;
var postponeViewFn = options.onViewPostponed;
if (typeof postponeViewFn !== 'function') postponeViewFn = null;
main: for (var priority = 0, n = priorities.length; priority < n; priority++) {
var priorityUpdates = priorities[priority];
for (var cid in priorityUpdates) {
if (updateCount >= batchSize) {
empty = false;
break main;
}
var view = views[cid];
if (!view) {
// This should not occur
delete priorityUpdates[cid];
continue;
}
var currentFlag = priorityUpdates[cid];
var isDetached = cid in updates.unmounted;
if (viewportFn && !viewportFn.call(this, view, isDetached, this)) {
// Unmount View
if (!isDetached) {
this.registerUnmountedView(view);
view.unmount();
}
updates.unmounted[cid] |= currentFlag;
delete priorityUpdates[cid];
unmountCount++;
continue;
}
// Mount View
if (isDetached) {
currentFlag |= FLAG_INSERT;
mountCount++;
}
currentFlag |= this.registerMountedView(view);
var leftoverFlag = this.updateView(view, currentFlag, opt);
if (leftoverFlag > 0) {
// View update has not finished completely
priorityUpdates[cid] = leftoverFlag;
if (!postponeViewFn || !postponeViewFn.call(this, view, leftoverFlag, this) || priorityUpdates[cid]) {
postponeCount++;
empty = false;
continue;
}
}
if (maxPriority > priority) maxPriority = priority;
updateCount++;
delete priorityUpdates[cid];
}
}
return {
priority: maxPriority,
updated: updateCount,
postponed: postponeCount,
unmounted: unmountCount,
mounted: mountCount,
empty: empty
};
},
checkUnmountedViews: function(viewportFn, opt) {
opt || (opt = {});
var mountCount = 0;
if (typeof viewportFn !== 'function') viewportFn = null;
var batchSize = 'mountBatchSize' in opt ? opt.mountBatchSize : Infinity;
var updates = this._updates;
var unmountedCids = updates.unmountedCids;
var unmounted = updates.unmounted;
for (var i = 0, n = Math.min(unmountedCids.length, batchSize); i < n; i++) {
var cid = unmountedCids[i];
if (!(cid in unmounted)) continue;
var view = views[cid];
if (!view) continue;
if (viewportFn && !viewportFn.call(this, view, true, this)) {
// Push at the end of all unmounted ids, so this can be check later again
unmountedCids.push(cid);
continue;
}
mountCount++;
var flag = this.registerMountedView(view);
if (flag) this.scheduleViewUpdate(view, flag, view.UPDATE_PRIORITY, { mounting: true });
}
// Get rid of views, that have been mounted
unmountedCids.splice(0, i);
return mountCount;
},
checkMountedViews: function(viewportFn, opt) {
opt || (opt = {});
var unmountCount = 0;
if (typeof viewportFn !== 'function') return unmountCount;
var batchSize = 'unmountBatchSize' in opt ? opt.unmountBatchSize : Infinity;
var updates = this._updates;
var mountedCids = updates.mountedCids;
var mounted = updates.mounted;
for (var i = 0, n = Math.min(mountedCids.length, batchSize); i < n; i++) {
var cid = mountedCids[i];
if (!(cid in mounted)) continue;
var view = views[cid];
if (!view) continue;
if (viewportFn.call(this, view, true)) {
// Push at the end of all mounted ids, so this can be check later again
mountedCids.push(cid);
continue;
}
unmountCount++;
var flag = this.registerUnmountedView(view);
if (flag) view.unmount();
}
// Get rid of views, that have been unmounted
mountedCids.splice(0, i);
return unmountCount;
},
checkViewport: function(opt) {
var passingOpt = defaults({}, opt, {
mountBatchSize: Infinity,
unmountBatchSize: Infinity
});
var viewportFn = 'viewport' in passingOpt ? passingOpt.viewport : this.options.viewport;
var unmountedCount = this.checkMountedViews(viewportFn, passingOpt);
if (unmountedCount > 0) {
// Do not check views, that have been just unmounted and pushed at the end of the cids array
var unmountedCids = this._updates.unmountedCids;
passingOpt.mountBatchSize = Math.min(unmountedCids.length - unmountedCount, passingOpt.mountBatchSize);
}
var mountedCount = this.checkUnmountedViews(viewportFn, passingOpt);
return {
mounted: mountedCount,
unmounted: unmountedCount
};
},
freeze: function(opt) {
opt || (opt = {});
var updates = this._updates;
var key = opt.key;
var isFrozen = this.options.frozen;
var freezeKey = updates.freezeKey;
if (key && key !== freezeKey) {
// key passed, but the paper is already freezed with another key
if (isFrozen && freezeKey) return;
updates.freezeKey = key;
updates.keyFrozen = isFrozen;
}
this.options.frozen = true;
var id = updates.id;
updates.id = null;
if (this.isAsync() && id) cancelFrame(id);
},
unfreeze: function(opt) {
opt || (opt = {});
var updates = this._updates;
var key = opt.key;
var freezeKey = updates.freezeKey;
// key passed, but the paper is already freezed with another key
if (key && freezeKey && key !== freezeKey) return;
updates.freezeKey = null;
// key passed, but the paper is already freezed
if (key && key === freezeKey && updates.keyFrozen) return;
if (this.isAsync()) {
this.freeze();
this.updateViewsAsync(opt);
} else {
this.updateViews(opt);
}
this.options.frozen = updates.keyFrozen = false;
if (updates.sort) {
this.sortViews();
updates.sort = false;
}
},
isAsync: function() {
return !!this.options.async;
},
isFrozen: function() {
return !!this.options.frozen;
},
isExactSorting: function() {
return this.options.sorting === sortingTypes.EXACT;
},
onRemove: function() {
this.freeze();
//clean up all DOM elements/views to prevent memory leaks
this.removeViews();
},
getComputedSize: function() {
var options = this.options;
var w = options.width;
var h = options.height;
if (!isNumber(w)) w = this.el.clientWidth;
if (!isNumber(h)) h = this.el.clientHeight;
return { width: w, height: h };
},
setDimensions: function(width, height) {
var options = this.options;
var w = (width === undefined) ? options.width : width;
var h = (height === undefined) ? options.height : height;
this.options.width = w;
this.options.height = h;
if (isNumber(w)) w = Math.round(w);
if (isNumber(h)) h = Math.round(h);
this.$el.css({
width: (w === null) ? '' : w,
height: (h === null) ? '' : h
});
var computedSize = this.getComputedSize();
this.trigger('resize', computedSize.width, computedSize.height);
},
setOrigin: function(ox, oy) {
return this.translate(ox || 0, oy || 0, { absolute: true });
},
// Expand/shrink the paper to fit the content. Snap the width/height to the grid
// defined in `gridWidth`, `gridHeight`. `padding` adds to the resulting width/height of the paper.
// When options { fitNegative: true } it also translates the viewport in order to make all
// the content visible.
fitToContent: function(gridWidth, gridHeight, padding, opt) { // alternatively function(opt)
if (isObject(gridWidth)) {
// first parameter is an option object
opt = gridWidth;
gridWidth = opt.gridWidth || 1;
gridHeight = opt.gridHeight || 1;
padding = opt.padding || 0;
} else {
opt || (opt = {});
gridWidth = gridWidth || 1;
gridHeight = gridHeight || 1;
padding = padding || 0;
}
// Calculate the paper size to accomodate all the graph's elements.
padding = normalizeSides(padding);
var area = ('contentArea' in opt) ? new Rect(opt.contentArea) : this.getContentArea(opt);
var currentScale = this.scale();
var currentTranslate = this.translate();
var sx = currentScale.sx;
var sy = currentScale.sy;
area.x *= sx;
area.y *= sy;
area.width *= sx;
area.height *= sy;
var calcWidth = Math.max(Math.ceil((area.width + area.x) / gridWidth), 1) * gridWidth;
var calcHeight = Math.max(Math.ceil((area.height + area.y) / gridHeight), 1) * gridHeight;
var tx = 0;
var ty = 0;
if ((opt.allowNewOrigin == 'negative' && area.x < 0) || (opt.allowNewOrigin == 'positive' && area.x >= 0) || opt.allowNewOrigin == 'any') {
tx = Math.ceil(-area.x / gridWidth) * gridWidth;
tx += padding.left;
calcWidth += tx;
}
if ((opt.allowNewOrigin == 'negative' && area.y < 0) || (opt.allowNewOrigin == 'positive' && area.y >= 0) || opt.allowNewOrigin == 'any') {
ty = Math.ceil(-area.y / gridHeight) * gridHeight;
ty += padding.top;
calcHeight += ty;
}
calcWidth += padding.right;
calcHeight += padding.bottom;
// Make sure the resulting width and height are greater than minimum.
calcWidth = Math.max(calcWidth, opt.minWidth || 0);
calcHeight = Math.max(calcHeight, opt.minHeight || 0);
// Make sure the resulting width and height are lesser than maximum.
calcWidth = Math.min(calcWidth, opt.maxWidth || Number.MAX_VALUE);
calcHeight = Math.min(calcHeight, opt.maxHeight || Number.MAX_VALUE);
var computedSize = this.getComputedSize();
var dimensionChange = calcWidth != computedSize.width || calcHeight != computedSize.height;
var originChange = tx != currentTranslate.tx || ty != currentTranslate.ty;
// Change the dimensions only if there is a size discrepency or an origin change
if (originChange) {
this.translate(tx, ty);
}
if (dimensionChange) {
this.setDimensions(calcWidth, calcHeight);
}
return new Rect(-tx / sx, -ty / sy, calcWidth / sx, calcHeight / sy);
},
scaleContentToFit: function(opt) {
opt || (opt = {});
var contentBBox, contentLocalOrigin;
if ('contentArea' in opt) {
var contentArea = opt.contentArea;
contentBBox = this.localToPaperRect(contentArea);
contentLocalOrigin = new Point(contentArea);
} else {
contentBBox = this.getContentBBox(opt);
contentLocalOrigin = this.paperToLocalPoint(contentBBox);
}
if (!contentBBox.width || !contentBBox.height) return;
defaults(opt, {
padding: 0,
preserveAspectRatio: true,
scaleGrid: null,
minScale: 0,
maxScale: Number.MAX_VALUE
//minScaleX
//minScaleY
//maxScaleX
//maxScaleY
//fittingBBox
});
var padding = opt.padding;
var minScaleX = opt.minScaleX || opt.minScale;
var maxScaleX = opt.maxScaleX || opt.maxScale;
var minScaleY = opt.minScaleY || opt.minScale;
var maxScaleY = opt.maxScaleY || opt.maxScale;
var fittingBBox;
if (opt.fittingBBox) {
fittingBBox = opt.fittingBBox;
} else {
var currentTranslate = this.translate();
var computedSize = this.getComputedSize();
fittingBBox = {
x: currentTranslate.tx,
y: currentTranslate.ty,
width: computedSize.width,
height: computedSize.height
};
}
fittingBBox = new Rect(fittingBBox).inflate(-padding);
var currentScale = this.scale();
var newSx = fittingBBox.width / contentBBox.width * currentScale.sx;
var newSy = fittingBBox.height / contentBBox.height * currentScale.sy;
if (opt.preserveAspectRatio) {
newSx = newSy = Math.min(newSx, newSy);
}
// snap scale to a grid
if (opt.scaleGrid) {
var gridSize = opt.scaleGrid;
newSx = gridSize * Math.floor(newSx / gridSize);
newSy = gridSize * Math.floor(newSy / gridSize);
}
// scale min/max boundaries
newSx = Math.min(maxScaleX, Math.max(minScaleX, newSx));
newSy = Math.min(maxScaleY, Math.max(minScaleY, newSy));
var origin = this.options.origin;
var newOx = fittingBBox.x - contentLocalOrigin.x * newSx - origin.x;
var newOy = fittingBBox.y - contentLocalOrigin.y * newSy - origin.y;
this.scale(newSx, newSy);
this.translate(newOx, newOy);
},
// Return the dimensions of the content area in local units (without transformations).
getContentArea: function(opt) {
if (opt && opt.useModelGeometry) {
var graph = this.model;
return graph.getCellsBBox(graph.getCells(), { includeLinks: true }) || new Rect();
}
return V(this.cells).getBBox();
},
// Return the dimensions of the content bbox in the paper units (as it appears on screen).
getContentBBox: function(opt) {
return this.localToPaperRect(this.getContentArea(opt));
},
// Returns a geometry rectangle represeting the entire
// paper area (coordinates from the left paper border to the right one
// and the top border to the bottom one).
getArea: function() {
return this.paperToLocalRect(this.getComputedSize());
},
getRestrictedArea: function() {
var restrictedArea;
if (isFunction(this.options.restrictTranslate)) {
// A method returning a bounding box
restrictedArea = this.options.restrictTranslate.apply(this, arguments);
} else if (this.options.restrictTranslate === true) {
// The paper area
restrictedArea = this.getArea();
} else {
// Either false or a bounding box
restrictedArea = this.options.restrictTranslate || null;
}
return restrictedArea;
},
createViewForModel: function(cell) {
// A class taken from the paper options.
var optionalViewClass;
// A default basic class (either dia.ElementView or dia.LinkView)
var defaultViewClass;
// A special class defined for this model in the corresponding namespace.
// e.g. joint.shapes.basic.Rect searches for joint.shapes.basic.RectView
var namespace = this.options.cellViewNamespace;
var type = cell.get('type') + 'View';
var namespaceViewClass = getByPath(namespace, type, '.');
if (cell.isLink()) {
optionalViewClass = this.options.linkView;
defaultViewClass = LinkView;
} else {
optionalViewClass = this.options.elementView;
defaultViewClass = ElementView;
}
// a) the paper options view is a class (deprecated)
// 1. search the namespace for a view
// 2. if no view was found, use view from the paper options
// b) the paper options view is a function
// 1. call the function from the paper options
// 2. if no view was return, search the namespace for a view
// 3. if no view was found, use the default
var ViewClass = (optionalViewClass.prototype instanceof Backbone.View)
? namespaceViewClass || optionalViewClass
: optionalViewClass.call(this, cell) || namespaceViewClass || defaultViewClass;
return new ViewClass({
model: cell,
interactive: this.options.interactive
});
},
removeView: function(cell) {
var id = cell.id;
var view = this._views[id];
if (view) {
view.remove();
delete this._views[id];
}
return view;
},
renderView: function(cell, opt) {
var id = cell.id;
var views = this._views;
var view, flag;
if (id in views) {
view = views[id];
flag = FLAG_INSERT;
} else {
view = views[cell.id] = this.createViewForModel(cell);
view.paper = this;
flag = FLAG_INSERT | view.getFlag(view.initFlag);
}
this.requestViewUpdate(view, flag, view.UPDATE_PRIORITY, opt);
return view;
},
onImageDragStart: function() {
// This is the only way to prevent image dragging in Firefox that works.
// Setting -moz-user-select: none, draggable="false" attribute or user-drag: none didn't help.
return false;
},
resetViews: function(cells, opt) {
opt || (opt = {});
cells || (cells = []);
this._resetUpdates();
// clearing views removes any event listeners
this.removeViews();
this.freeze({ key: 'reset' });
for (var i = 0, n = cells.length; i < n; i++) {
this.renderView(cells[i], opt);
}
this.unfreeze({ key: 'reset' });
this.sortViews();
},
removeViews: function() {
invoke(this._views, 'remove');
this._views = {};
},
sortViews: function() {
if (!this.isExactSorting()) {
// noop
return;
}
if (this.isFrozen()) {
// sort views once unfrozen
this._updates.sort = true;
return;
}
this.sortViewsExact();
},
sortViewsExact: function() {
// Run insertion sort algorithm in order to efficiently sort DOM elements according to their
// associated model `z` attribute.
var $cells = $(this.cells).children('[model-id]');
var cells = this.model.get('cells');
sortElements($cells, function(a, b) {
var cellA = cells.get(a.getAttribute('model-id'));
var cellB = cells.get(b.getAttribute('model-id'));
var zA = cellA.attributes.z || 0;
var zB = cellB.attributes.z || 0;
return (zA === zB) ? 0 : (zA < zB) ? -1 : 1;
});
},
insertView: function(view) {
var layer = this.cells;
switch (this.options.sorting) {
case sortingTypes.APPROX:
var z = view.model.get('z');
var pivot = this.addZPivot(z);
layer.insertBefore(view.el, pivot);
break;
case sortingTypes.EXACT:
default:
layer.appendChild(view.el);
break;
}
},
addZPivot: function(z) {
z = +z;
z || (z = 0);
var pivots = this._zPivots;
var pivot = pivots[z];
if (pivot) return pivot;
pivot = pivots[z] = document.createComment('z-index:' + (z + 1));
var neighborZ = -Infinity;
for (var currentZ in pivots) {
currentZ = +currentZ;
if (currentZ < z && currentZ > neighborZ) {
neighborZ = currentZ;
if (neighborZ === z - 1) continue;
}
}
var layer = this.cells;
if (neighborZ !== -Infinity) {
var neighborPivot = pivots[neighborZ];
// Insert After
layer.insertBefore(pivot, neighborPivot.nextSibling);
} else {
// First Child
layer.insertBefore(pivot, layer.firstChild);
}
return pivot;
},
removeZPivots: function() {
var { _zPivots: pivots, viewport } = this;
for (var z in pivots) viewport.removeChild(pivots[z]);
this._zPivots = {};
},
scale: function(sx, sy, ox, oy) {
// getter
if (sx === undefined) {
return V.matrixToScale(this.matrix());
}
// setter
if (sy === undefined) {
sy = sx;
}
if (ox === undefined) {
ox = 0;
oy = 0;
}
var translate = this.translate();
if (ox || oy || translate.tx || translate.ty) {
var newTx = translate.tx - ox * (sx - 1);
var newTy = translate.ty - oy * (sy - 1);
this.translate(newTx, newTy);
}
sx = Math.max(sx || 0, this.MIN_SCALE);
sy = Math.max(sy || 0, this.MIN_SCALE);
var ctm = this.matrix();
ctm.a = sx;
ctm.d = sy;
this.matrix(ctm);
this.trigger('scale', sx, sy, ox, oy);
return this;
},
// Experimental - do not use in production.
rotate: function(angle, cx, cy) {
// getter
if (angle === undefined) {
return V.matrixToRotate(this.matrix());
}
// setter
// If the origin is not set explicitely, rotate around the center. Note that
// we must use the plain bounding box (`this.el.getBBox()` instead of the one that gives us
// the real bounding box (`bbox()`) including transformations).
if (cx === undefined) {
var bbox = this.cells.getBBox();
cx = bbox.width / 2;
cy = bbox.height / 2;
}
var ctm = this.matrix().translat