UNPKG

jointjs

Version:

JavaScript diagramming library

1,441 lines (1,222 loc) 89.1 kB
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