UNPKG

@joint/core

Version:

JavaScript diagramming library

1,379 lines (1,211 loc) 113 kB
import V from '../V/index.mjs'; import { isNumber, assign, nextFrame, isObject, cancelFrame, defaults, defaultsDeep, addClassNamePrefix, normalizeSides, isFunction, isPlainObject, getByPath, sortElements, isString, guid, normalizeEvent, normalizeWheel, cap, debounce, omit, result, camelCase, cloneDeep, invoke, hashCode, filter as _filter, parseDOMJSON, toArray, has } from '../util/index.mjs'; import { ViewBase } from '../mvc/ViewBase.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 { Cell } from './Cell.mjs'; import { Graph } from './Graph.mjs'; import { LayersNames, PaperLayer } from './PaperLayer.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 '../mvc/Dom/index.mjs'; import { GridLayer } from './layers/GridLayer.mjs'; const sortingTypes = { NONE: 'sorting-none', APPROX: 'sorting-approximate', EXACT: 'sorting-exact' }; const WHEEL_CAP = 50; const WHEEL_WAIT_MS = 20; const MOUNT_BATCH_SIZE = 1000; const UPDATE_BATCH_SIZE = Infinity; const MIN_PRIORITY = 9007199254740991; // Number.MAX_SAFE_INTEGER const HighlightingTypes = CellView.Highlighting; const defaultHighlighting = { [HighlightingTypes.DEFAULT]: { name: 'stroke', options: { padding: 3 } }, [HighlightingTypes.MAGNET_AVAILABILITY]: { name: 'addClass', options: { className: 'available-magnet' } }, [HighlightingTypes.ELEMENT_AVAILABILITY]: { name: 'addClass', options: { className: 'available-cell' } } }; const defaultLayers = [{ name: LayersNames.GRID, }, { name: LayersNames.BACK, }, { name: LayersNames.CELLS, }, { name: LayersNames.LABELS, }, { name: LayersNames.FRONT }, { name: LayersNames.TOOLS }]; export const Paper = View.extend({ className: 'paper', options: { width: 800, height: 600, 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, // If not set, the size of the visual grid is the same as the `gridSize`. drawGridSize: null, // 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, elementView: ElementView, linkView: LinkView, snapLabels: false, // false, true snapLinks: false, // false, true, { radius: value } snapLinksSelf: false, // false, true, { radius: value } // Should the link labels be rendered into its own layer? // `false` - the labels are part of the links // `true` - the labels are appended to LayersName.LABELS // [LayersName] - the labels are appended to the layer specified labelsLayer: false, // 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: defaultHighlighting, // Prevent the default context menu from being displayed. preventContextMenu: true, // Prevent the default action for blank:pointer<action>. preventDefaultBlankAction: true, // Prevent the default action for cell:pointer<action>. preventDefaultViewAction: 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 mvc.model or a function returning the mvc.model // defaultLink: (elementView, magnet) => { // return condition ? new customLink1() : new customLink2() // } defaultLink: function() { // Do not create hard dependency on the joint.shapes.standard namespace (by importing the standard.Link model directly) const { cellNamespace } = this.model.get('cells'); const ctor = getByPath(cellNamespace, ['standard', 'Link']); if (!ctor) throw new Error('dia.Paper: no default link model found. Use `options.defaultLink` to specify a default link model.'); return new ctor(); }, // 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: 'boundary' }, /* CONNECTING */ connectionStrategy: null, // Check whether to add a new link to the graph when user clicks on an a magnet. validateMagnet: function(_cellView, magnet, _evt) { 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; }, // Check whether to allow or disallow an embedded element to be unembedded / to become a root. validateUnembedding: function(childView) { // by default all elements can become roots 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 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.APPROX, frozen: false, autoFreeze: false, // no docs yet onViewUpdate: function(view, flag, priority, opt, paper) { // Do not update connected links when: // 1. the view was just inserted (added to the graph and rendered) // 2. the view was just mounted (added back to the paper by viewport function) // 3. the change was marked as `isolate`. // 4. the view model was just removed from the graph if ((flag & (view.FLAG_INSERT | view.FLAG_REMOVE)) || opt.mounting || opt.isolate) return; paper.requestConnectedLinksUpdate(view, priority, opt); }, // no docs yet onViewPostponed: function(view, flag, paper) { return paper.forcePostponedViewUpdate(view, flag); }, beforeRender: null, // function(opt, paper) { }, afterRender: null, // function(stats, opt, paper) { viewport: null, // Default namespaces cellViewNamespace: null, routerNamespace: null, connectorNamespace: null, highlighterNamespace: highlighters, anchorNamespace: anchors, linkAnchorNamespace: linkAnchors, connectionPointNamespace: connectionPoints, overflow: false }, events: { 'dblclick': 'pointerdblclick', 'dbltap': 'pointerdblclick', 'contextmenu': 'contextmenu', 'mousedown': 'pointerdown', 'touchstart': 'pointerdown', 'mouseover': 'mouseover', 'mouseout': 'mouseout', 'mouseenter': 'mouseenter', 'mouseleave': 'mouseleave', 'wheel': 'mousewheel', 'mouseenter .joint-cell': 'mouseenter', 'mouseleave .joint-cell': 'mouseleave', 'mouseenter .joint-tools': 'mouseenter', 'mouseleave .joint-tools': 'mouseleave', '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' }, /* CSS within the SVG document * 1. Adding vector-effect: non-scaling-stroke; to prevent the stroke width from scaling for * elements that use the `scalable` group. */ stylesheet: /*css*/` .joint-element .scalable * { vector-effect: non-scaling-stroke; } `, svg: null, viewport: null, defs: null, tools: null, layers: 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, // Paper Layers _layers: null, SORT_DELAYING_BATCHES: ['add', 'to-front', 'to-back'], UPDATE_DELAYING_BATCHES: ['translate'], // If you interact with these elements, // the default interaction such as `element move` is prevented. FORM_CONTROL_TAG_NAMES: ['TEXTAREA', 'INPUT', 'BUTTON', 'SELECT', 'OPTION'] , // If you interact with these elements, the events are not propagated to the paper // i.e. paper events such as `element:pointerdown` are not triggered. GUARDED_TAG_NAMES: [ // Guard <select> for consistency. When you click on it: // Chrome: triggers `pointerdown`, `pointerup`, `pointerclick` to open // Firefox: triggers `pointerdown` on open, `pointerup` (and `pointerclick` only if you haven't moved). // on close. However, if you open and then close by clicking elsewhere on the page, // no other event is triggered. // Safari: when you open it, it triggers `pointerdown`. That's it. 'SELECT', ], MIN_SCALE: 1e-6, // Default find buffer for the findViewsInArea and findViewsAtPoint methods. // The find buffer is used to extend the area of the search // to mitigate the differences between the model and view geometry. DEFAULT_FIND_BUFFER: 200, init: function() { const { options } = this; if (!options.cellViewNamespace) { /* eslint-disable no-undef */ options.cellViewNamespace = typeof joint !== 'undefined' && has(joint, 'shapes') ? joint.shapes : null; /* eslint-enable no-undef */ } const model = this.model = options.model || new Graph; // Layers (SVGGroups) this._layers = {}; this.cloneOptions(); this.render(); this._setDimensions(); this.startListening(); // Hash of all cell views. this._views = {}; // Mouse wheel events buffer this._mw_evt_buffer = { event: null, deltas: [], }; // 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() { if (this._updates && this._updates.id) cancelFrame(this._updates.id); return this._updates = { id: null, priorities: [{}, {}, {}], unmountedCids: [], mountedCids: [], unmounted: {}, mounted: {}, count: 0, keyFrozen: false, freezeKey: null, sort: false, disabled: false, idle: 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('transform', 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, 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, view.FLAG_INSERT, view.UPDATE_PRIORITY, opt); } }, onGraphReset: function(collection, opt) { this.resetLayers(); 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() { const { options } = this; const { defaultConnector, defaultRouter, defaultConnectionPoint, defaultAnchor, defaultLinkAnchor, highlighting, cellViewNamespace, interactive } = options; // Default cellView namespace for ES5 /* eslint-disable no-undef */ if (!cellViewNamespace && typeof joint !== 'undefined' && has(joint, 'shapes')) { options.cellViewNamespace = joint.shapes; } /* eslint-enable no-undef */ // Here if a function was provided, we can not clone it, as this would result in loosing the function. // If the default is used, the cloning is necessary in order to prevent modifying the options on prototype. if (!isFunction(defaultConnector)) { options.defaultConnector = cloneDeep(defaultConnector); } if (!isFunction(defaultRouter)) { options.defaultRouter = cloneDeep(defaultRouter); } if (!isFunction(defaultConnectionPoint)) { options.defaultConnectionPoint = cloneDeep(defaultConnectionPoint); } if (!isFunction(defaultAnchor)) { options.defaultAnchor = cloneDeep(defaultAnchor); } if (!isFunction(defaultLinkAnchor)) { options.defaultLinkAnchor = cloneDeep(defaultLinkAnchor); } if (isPlainObject(interactive)) { options.interactive = assign({}, interactive); } if (isPlainObject(highlighting)) { // Return the default highlighting options into the user specified options. options.highlighting = defaultsDeep({}, highlighting, defaultHighlighting); } }, children: function() { var ns = V.namespace; return [{ namespaceURI: ns.xhtml, tagName: 'div', className: addClassNamePrefix('paper-background'), selector: 'background', style: { position: 'absolute', inset: 0 } }, { namespaceURI: ns.svg, tagName: 'svg', attributes: { 'width': '100%', 'height': '100%', 'xmlns:xlink': ns.xlink }, selector: 'svg', style: { position: 'absolute', inset: 0 }, 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' }] }]; }, hasLayerView(layerName) { return (layerName in this._layers); }, getLayerView(layerName) { const { _layers } = this; if (layerName in _layers) return _layers[layerName]; throw new Error(`dia.Paper: Unknown layer "${layerName}"`); }, getLayerNode(layerName) { return this.getLayerView(layerName).el; }, render: function() { this.renderChildren(); const { el, childNodes, options, stylesheet } = this; const { svg, defs, layers } = childNodes; el.style.position = 'relative'; svg.style.overflow = options.overflow ? 'visible' : 'hidden'; this.svg = svg; this.defs = defs; this.layers = layers; this.renderLayers(); V.ensureId(svg); this.addStylesheet(stylesheet); if (options.background) { this.drawBackground(options.background); } if (options.drawGrid) { this.setGrid(options.drawGrid); } return this; }, addStylesheet: function(css) { if (!css) return; V(this.svg).prepend(V.createSVGStyle(css)); }, createLayer(name) { switch (name) { case LayersNames.GRID: return new GridLayer({ name, paper: this, patterns: this.constructor.gridPatterns }); default: return new PaperLayer({ name }); } }, renderLayers: function(layers = defaultLayers) { this.removeLayers(); // TODO: Layers to be read from the graph `layers` attribute layers.forEach(({ name, sorted }) => { const layerView = this.createLayer(name); this.layers.appendChild(layerView.el); this._layers[name] = layerView; }); // Throws an exception if doesn't exist const cellsLayerView = this.getLayerView(LayersNames.CELLS); const toolsLayerView = this.getLayerView(LayersNames.TOOLS); const labelsLayerView = this.getLayerView(LayersNames.LABELS); // backwards compatibility this.tools = toolsLayerView.el; this.cells = this.viewport = cellsLayerView.el; // user-select: none; cellsLayerView.vel.addClass(addClassNamePrefix('viewport')); labelsLayerView.vel.addClass(addClassNamePrefix('viewport')); cellsLayerView.el.style.webkitUserSelect = 'none'; cellsLayerView.el.style.userSelect = 'none'; labelsLayerView.el.style.webkitUserSelect = 'none'; labelsLayerView.el.style.userSelect = 'none'; }, removeLayers: function() { const { _layers } = this; Object.keys(_layers).forEach(name => { _layers[name].remove(); delete _layers[name]; }); }, resetLayers: function() { const { _layers } = this; Object.keys(_layers).forEach(name => { _layers[name].removePivots(); }); }, update: function() { if (this._background) { this.updateBackgroundImage(this._background); } return this; }, scale: function(sx, sy, data) { const ctm = this.matrix(); // getter if (sx === undefined) { return V.matrixToScale(ctm); } // setter if (sy === undefined) { sy = sx; } sx = Math.max(sx || 0, this.MIN_SCALE); sy = Math.max(sy || 0, this.MIN_SCALE); ctm.a = sx; ctm.d = sy; this.matrix(ctm, data); return this; }, scaleUniformAtPoint: function(scale, point, data) { const { a: sx, d: sy, e: tx, f: ty } = this.matrix(); scale = Math.max(scale || 0, this.MIN_SCALE); if (scale === sx && scale === sy) { // The scale is the same as the current one. return this; } const matrix = V.createSVGMatrix() .translate( tx - point.x * (scale - sx), ty - point.y * (scale - sy) ) .scale(scale, scale); this.matrix(matrix, data); return this; }, translate: function(tx, ty, data) { const ctm = this.matrix(); // getter if (tx === undefined) { return V.matrixToTranslate(ctm); } // setter tx || (tx = 0); ty || (ty = 0); if (ctm.e === tx && ctm.f === ty) return this; ctm.e = tx; ctm.f = ty; this.matrix(ctm, data); return this; }, matrix: function(ctm, data = {}) { 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: const prev = this.matrix(); const current = V.createSVGMatrix(ctm); const currentTransformString = this._viewportTransformString; const ctmString = V.matrixToTransformString(current); if (ctmString === currentTransformString) { // The new transform string is the same as the current one. // No need to update the transform attribute. return this; } if (!currentTransformString && V.matrixToTransformString() === ctmString) { // The current transform string is empty and the new one is an identity matrix. // No need to update the transform attribute. return this; } const { a, d, e, f } = current; viewport.setAttribute('transform', ctmString); this._viewportMatrix = current; this._viewportTransformString = viewport.getAttribute('transform'); // scale event if (a !== prev.a || d !== prev.d) { this.trigger('scale', a, d, data); } // translate event if (e !== prev.e || f !== prev.f) { this.trigger('translate', e, f, data); } this.trigger('transform', current, data); return this; }, clientMatrix: function() { return V.createSVGMatrix(this.cells.getScreenCTM()); }, requestConnectedLinksUpdate: function(view, priority, 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'); var nextPriority = Math.max(priority + 1, linkView.UPDATE_PRIORITY); this.scheduleViewUpdate(linkView, linkView.getFlag(flagLabels), nextPriority, 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) { var dumpOptions = { silent: true }; // 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, dumpOptions); view.updateEndMagnet('source'); } var targetFlag = 0; var targetView = this.findViewByModel(model.getTargetCell()); if (targetView && !this.isViewMounted(targetView)) { targetFlag = this.dumpView(targetView, dumpOptions); view.updateEndMagnet('target'); } if (sourceFlag === 0 && targetFlag === 0) { // If leftover flag is 0, all view updates were done. return !this.dumpView(view, dumpOptions); } } 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.notifyAfterRender(stats, opt); }, scheduleViewUpdate: function(view, type, priority, opt) { const { _updates: updates, options } = this; if (updates.idle) { if (options.autoFreeze) { updates.idle = false; this.unfreeze(); } } const { FLAG_REMOVE, FLAG_INSERT, UPDATE_PRIORITY, cid } = view; let priorityUpdates = updates.priorities[priority]; if (!priorityUpdates) priorityUpdates = updates.priorities[priority] = {}; // Move higher priority updates to this priority if (priority > UPDATE_PRIORITY) { // Not the default priority for this view. It's most likely a link view // connected to another link view, which triggered the update. // TODO: If there is an update scheduled with a lower priority already, we should // change the requested priority to the lowest one. Does not seem to be critical // right now, as it "only" results in multiple updates on the same view. for (let i = priority - 1; i >= UPDATE_PRIORITY; i--) { const prevPriorityUpdates = updates.priorities[i]; if (!prevPriorityUpdates || !(cid in prevPriorityUpdates)) continue; priorityUpdates[cid] |= prevPriorityUpdates[cid]; delete prevPriorityUpdates[cid]; } } let currentType = priorityUpdates[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[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[cid] ^= FLAG_REMOVE; } priorityUpdates[cid] |= type; const viewUpdateFn = options.onViewUpdate; if (typeof viewUpdateFn === 'function') viewUpdateFn.call(this, view, type, priority, 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 = {}) { const flag = this.dumpViewUpdate(view); if (!flag) return 0; const shouldNotify = !opt.silent; if (shouldNotify) this.notifyBeforeRender(opt); const leftover = this.updateView(view, flag, opt); if (shouldNotify) { const stats = { updated: 1, priority: view.UPDATE_PRIORITY }; this.notifyAfterRender(stats, opt); } return leftover; }, updateView: function(view, flag, opt) { if (!view) return 0; const { FLAG_REMOVE, FLAG_INSERT, FLAG_INIT, model } = view; if (view instanceof CellView) { if (flag & FLAG_REMOVE) { this.removeView(model); return 0; } if (flag & FLAG_INSERT) { const isInitialInsert = !!(flag & FLAG_INIT); if (isInitialInsert) { flag ^= FLAG_INIT; } this.insertView(view, isInitialInsert); 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] |= view.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); }, // Synchronous views update updateViews: function(opt) { this.notifyBeforeRender(opt); let batchStats; let updateCount = 0; let batchCount = 0; let priority = MIN_PRIORITY; do { batchCount++; batchStats = this.updateViewsBatch(opt); updateCount += batchStats.updated; priority = Math.min(batchStats.priority, priority); } while (!batchStats.empty); const stats = { updated: updateCount, batches: batchCount, priority }; this.notifyAfterRender(stats, opt); return stats; }, hasScheduledUpdates: function() { const priorities = this._updates.priorities; const priorityIndexes = Object.keys(priorities); // convert priorities to a dense array let i = priorityIndexes.length; while (i > 0 && i--) { // a faster way how to check if an object is empty for (let _key in priorities[priorityIndexes[i]]) return true; } return false; }, updateViewsAsync: function(opt, data) { opt || (opt = {}); data || (data = { processed: 0, priority: MIN_PRIORITY }); const { _updates: updates, options } = this; const id = updates.id; if (id) { cancelFrame(id); if (data.processed === 0 && this.hasScheduledUpdates()) { this.notifyBeforeRender(opt); } const stats = this.updateViewsBatch(opt); const passingOpt = defaults({}, opt, { mountBatchSize: MOUNT_BATCH_SIZE - stats.mounted, unmountBatchSize: MOUNT_BATCH_SIZE - stats.unmounted }); const checkStats = this.checkViewport(passingOpt); const unmountCount = checkStats.unmounted; const mountCount = checkStats.mounted; let processed = data.processed; const 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.notifyAfterRender(stats, opt); data.processed = 0; data.priority = MIN_PRIORITY; updates.count = 0; } else { data.processed = processed; } } else { if (!updates.idle) { if (options.autoFreeze) { this.freeze(); updates.idle = true; this.trigger('render:idle', opt); } } } // Progress callback const 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; } if (updates.disabled) { throw new Error('dia.Paper: can not unfreeze the paper after it was removed'); } updates.id = nextFrame(this.updateViewsAsync, this, opt, data); }, notifyBeforeRender: function(opt = {}) { let beforeFn = opt.beforeRender; if (typeof beforeFn !== 'function') { beforeFn = this.options.beforeRender; if (typeof beforeFn !== 'function') return; } beforeFn.call(this, opt, this); }, notifyAfterRender: function(stats, opt = {}) { let afterFn = opt.afterRender; if (typeof afterFn !== 'function') { afterFn = this.options.afterRender; } if (typeof afterFn === 'function') { afterFn.call(this, stats, opt, this); } this.trigger('render:done', stats, opt); }, 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; var priorityIndexes = Object.keys(priorities); // convert priorities to a dense array main: for (var i = 0, n = priorityIndexes.length; i < n; i++) { var priority = +priorityIndexes[i]; 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]; if ((currentFlag & view.FLAG_REMOVE) === 0) { // We should never check a view for viewport if we are about to remove the view var isDetached = cid in updates.unmounted; if (view.DETACHABLE && viewportFn && !viewportFn.call(this, view, !isDetached, this)) { // Unmount View if (!isDetached) { this.registerUnmountedView(view); this.detachView(view); } updates.unmounted[cid] |= currentFlag; delete priorityUpdates[cid]; unmountCount++; continue; } // Mount View if (isDetached) { currentFlag |= view.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 }; }, getUnmountedViews: function() { const updates = this._updates; const unmountedCids = Object.keys(updates.unmounted); const n = unmountedCids.length; const unmountedViews = new Array(n); for (var i = 0; i < n; i++) { unmountedViews[i] = views[unmountedCids[i]]; } return unmountedViews; }, getMountedViews: function() { const updates = this._updates; const mountedCids = Object.keys(updates.mounted); const n = mountedCids.length; const mountedViews = new Array(n); for (var i = 0; i < n; i++) { mountedViews[i] = views[mountedCids[i]]; } return mountedViews; }, 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 (view.DETACHABLE && viewportFn && !viewportFn.call(this, view, false, 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 (!view.DETACHABLE || viewportFn.call(this, view, true, this)) { // 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) this.detachView(view); } // Get rid of views, that have been unmounted mountedCids.splice(0, i); return unmountCount; }, checkViewVisibility: function(cellView, opt = {}) { let viewportFn = 'viewport' in opt ? opt.viewport : this.options.viewport; if (typeof viewportFn !== 'function') viewportFn = null; const updates = this._updates; const { mounted, unmounted } = updates; const visible = !cellView.DETACHABLE || !viewportFn || viewportFn.call(this, cellView, false, this); let isUnmounted = false; let isMounted = false; if (cellView.cid in mounted && !visible) { const flag = this.registerUnmountedView(cellView); if (flag) this.detachView(cellView); const i = updates.mountedCids.indexOf(cellView.cid); updates.mountedCids.splice(i, 1); isUnmounted = true; } if (!isUnmounted && cellView.cid in unmounted && visible) { const i = updates.unmountedCids.indexOf(cellView.cid); updates.unmountedCids.splice(i, 1); var flag = this.registerMountedView(cellView); if (flag) this.scheduleViewUpdate(cellView, flag, cellView.UPDATE_PRIORITY, { mounting: true }); isMounted = true; } return { mounted: isMounted ? 1 : 0, unmounted: isUnmounted ? 1 : 0 }; }, 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(); this._updates.disabled = true; //clean up all DOM elements/views to prevent memory leaks this.removeLayers(); 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 }; },