define([ './List', './_StoreMixin', 'dojo/_base/declare', 'dojo/_base/lang', 'dojo/dom-construct', 'dojo/on', 'dojo/when', 'dojo/query', './util/misc' ], function (List, _StoreMixin, declare, lang, domConstruct, on, when, query, miscUtil) { var preloadId = 0; function nextPreloadId() { return preloadId++; } function isRowNode(node) { return node && (node.className.indexOf('dgrid-row') >= 0 || node.className.indexOf('dgrid-loading') >= 0); } function isPreloadNode(node) { return node && node.className.indexOf('dgrid-preload') >= 0; } return declare([List, _StoreMixin], { // summary: // Extends List to include virtual scrolling functionality, querying a // dojo/store instance for the appropriate range when the user scrolls. // minRowsPerPage: Integer // The minimum number of rows to request at one time. minRowsPerPage: 25, // maxRowsPerPage: Integer // The maximum number of rows to request at one time. maxRowsPerPage: 250, // maxEmptySpace: Integer // Defines the maximum size (in pixels) of unrendered space below the // currently-rendered rows. Setting this to less than Infinity can be useful if you // wish to limit the initial vertical scrolling of the grid so that the scrolling is // not excessively sensitive. With very large grids of data this may make scrolling // easier to use, albiet it can limit the ability to instantly scroll to the end. maxEmptySpace: Infinity, // bufferRows: Integer // The number of rows to keep ready on each side of the viewport area so that the user can // perform local scrolling without seeing the grid being built. Increasing this number can // improve perceived performance when the data is being retrieved over a slow network. bufferRows: 10, // farOffRemoval: Integer // Defines the minimum distance (in pixels) from the visible viewport area // rows must be in order to be removed. Setting to Infinity causes rows // to never be removed. farOffRemoval: 2000, // queryRowsOverlap: Integer // Indicates the number of rows to overlap queries. This helps keep // continuous data when underlying data changes (and thus pages don't // exactly align) queryRowsOverlap: 0, // pagingMethod: String // Method (from dgrid/util/misc) to use to either throttle or debounce // requests. Default is "debounce" which will cause the grid to wait until // the user pauses scrolling before firing any requests; can be set to // "throttleDelayed" instead to progressively request as the user scrolls, // which generally incurs more overhead but might appear more responsive. pagingMethod: 'debounce', // pagingDelay: Integer // Indicates the delay (in milliseconds) imposed upon pagingMethod, to wait // before paging in more data on scroll events. This can be increased to // reduce client-side overhead or the number of requests sent to a server. pagingDelay: miscUtil.defaultDelay, // keepScrollPosition: Boolean // When refreshing the list, controls whether the scroll position is // preserved, or reset to the top. This can also be overridden for // specific calls to refresh. keepScrollPosition: false, // rowHeight: Number // Average row height, computed in renderQuery during the rendering of // the first range of data. rowHeight: 0, // _deleteQueue: Array // List of DOM nodes queued for deletion. _deleteQueue: [], postCreate: function () { this.inherited(arguments); var self = this; // check visibility on scroll events on(this.bodyNode, 'scroll', miscUtil[this.pagingMethod](function (event) { self._processScroll(event); }, null, this.pagingDelay) ); }, renderQuery: function (query, options) { // summary: // Creates a preload node for rendering a query into, and executes the query // for the first page of data. Subsequent data will be downloaded as it comes // into view. // query: Function // Function to be called when requesting new data. // options: Object? // Optional object containing the following: // * container: Container to build preload nodes within; defaults to this.contentNode var self = this, container = (options && options.container) || this.contentNode, preload, topPreloadNode, preloadNode, queryLevel, preloadLevel = 0, start = (options && options.start) || 0; if ('level' in query) { preloadLevel = queryLevel = query.level; } preload = { query: query, count: 0, level: preloadLevel, top: false }; // Initial query; set up top and bottom preload nodes var topPreload = { node: domConstruct.create('div', { className: 'dgrid-preload', style: { height: '0' } }, container), count: 0, query: query, next: preload, level: preloadLevel, top: true }; topPreloadNode = topPreload.node; topPreloadNode.rowIndex = 0; preload.previous = topPreload; preloadNode = preload.node = domConstruct.create('div', { className: 'dgrid-preload', style: { height: '0' } }, container); // Add preload ids. topPreload.id = nextPreloadId(); topPreloadNode.setAttribute('data-preloadid', topPreload.id); preload.id = nextPreloadId(); preloadNode.setAttribute('data-preloadid', preload.id); // this preload node is used to represent the area of the grid that hasn't been // downloaded yet preloadNode.rowIndex = this.minRowsPerPage; self._insertPreload(topPreload); var loadingNode = domConstruct.create('div', { className: 'dgrid-loading' }, preloadNode, 'before'), innerNode = domConstruct.create('div', { className: 'dgrid-below' }, loadingNode); innerNode.innerHTML = this.loadingMessage; // Establish query options, mixing in our own options = lang.mixin({ start: 0, count: this.minRowsPerPage }, options); if (queryLevel != null) { options.queryLevel = queryLevel; } // Protect the query within a _trackError call, but return the resulting collection return this._trackError(function () { var results = query(options); // Render the result set return self.renderQueryResults(results, preloadNode, options).then(function (trs) { return results.totalLength.then(function (total) { var trCount = trs.length; var parentNode = preloadNode.parentNode; if (self._rows && !('queryLevel' in options)) { self._rows.min = 0; self._rows.max = trCount === total ? Infinity : trCount - 1; } domConstruct.destroy(loadingNode); if (!('queryLevel' in options)) { self._total = total; } // now we need to adjust the height and total count based on the first result set if (total === 0 && parentNode) { if (self.noDataNode) { domConstruct.destroy(self.noDataNode); } self._insertNoDataNode(parentNode); } topPreload.count = start; preload.count = total - trCount - start; preloadNode.rowIndex = start + trCount; if (total) { self._updatePreloadRowHeights(topPreload); } else { preloadNode.style.display = 'none'; topPreloadNode.style.display = 'none'; } if (self._previousScrollPosition && parentNode.offsetHeight) { // Restore position after a refresh operation w/ keepScrollPosition but only // if the rows have been inserted into the DOM. self.scrollTo(self._previousScrollPosition); delete self._previousScrollPosition; } // Redo scroll processing in case the query didn't fill the screen, // or in case scroll position was restored return when(self._processScroll()).then(function () { return trs; }); }); }).otherwise(function (err) { // remove the loadingNode and re-throw domConstruct.destroy(loadingNode); throw err; }); }); }, _insertPreload: function (newTopPreload) { var preload = this.preload; if (!preload) { // first one this.preload = newTopPreload; return; } while (preload.node.compareDocumentPosition(newTopPreload.node) & Node.DOCUMENT_POSITION_PRECEDING) { preload = preload.previous; if (preload == null) { return; } } while (preload.node.compareDocumentPosition(newTopPreload.node) & Node.DOCUMENT_POSITION_FOLLOWING) { if (!preload.next) { break; } preload = preload.next; } // insert, newPreload before preload preload.previous.next = newTopPreload; newTopPreload.previous = preload.previous; var newBottomPreload = newTopPreload.next; newBottomPreload.next = preload; preload.previous = newBottomPreload; }, refresh: function (options) { // summary: // Refreshes the contents of the grid. // options: Object? // Optional object, supporting the following parameters: // * keepScrollPosition: like the keepScrollPosition instance property; // specifying it in the options here will override the instance // property's value for this specific refresh call only. var self = this, keep = (options && options.keepScrollPosition); // Fall back to instance property if option is not defined if (typeof keep === 'undefined') { keep = this.keepScrollPosition; } // Store scroll position to be restored after new total is received if (keep) { this._previousScrollPosition = this.getScrollPosition(); } this.inherited(arguments); if (this._renderedCollection) { // render the query // renderQuery calls _trackError internally return this.renderQuery(function (queryOptions) { return self._renderedCollection.fetchRange({ start: queryOptions.start, end: queryOptions.start + queryOptions.count }); }).then(function () { self._emitRefreshComplete(); }); } }, resize: function () { this.inherited(arguments); this._processScroll(); }, cleanup: function () { this.inherited(arguments); this.preload = null; }, renderQueryResults: function (results) { var rows = this.inherited(arguments); var collection = this._getRenderedCollection(this.preload); if (collection && collection.releaseRange) { rows.then(function (resolvedRows) { if (resolvedRows[0] && !resolvedRows[0].parentNode.tagName) { // Release this range, since it was never actually rendered; // need to wait until totalLength promise resolves, since // Trackable only adds the range then to begin with results.totalLength.then(function () { collection.releaseRange(resolvedRows[0].rowIndex, resolvedRows[resolvedRows.length - 1].rowIndex + 1); }); } }); } return rows; }, _getFirstRowSibling: function (container) { // summary: // Returns the DOM node that a new row should be inserted before // when there are no other rows in the current result set. // In the case of OnDemandList, this will always be the last child // of the container (which will be a trailing preload node). return container.lastChild; }, _calcRowHeight: function (rowElement) { // summary: // Calculate the height of a row. This is a method so it can be overriden for // plugins that add connected elements to a row, like the tree var sibling = rowElement.nextSibling; // If a next row exists, compare the top of this row with the // next one (in case "rows" are actually rendering side-by-side). // If no next row exists, this is either the last or only row, // in which case we count its own height. if (sibling && !/\bdgrid-preload\b/.test(sibling.className)) { return sibling.offsetTop - rowElement.offsetTop; } return rowElement.offsetHeight; }, _calcAverageRowHeight: function (rowElements) { // summary: // Sets this.rowHeight based on the average from heights of the provided row elements. var count = rowElements.length; var height = 0; for (var i = 0; i < count; i++) { height += this._calcRowHeight(rowElements[i]); } // only update rowHeight if elements were passed and are in flow if (count && height) { return height / count; } else { return 0; } }, _updatePreloadRowHeights: function () { var preload = this.preload; if (!preload) { return; } while (preload.previous) { preload = preload.previous; } while (preload) { if (!preload.rowHeight) { preload.rowHeight = this.rowHeight || this._calcAverageRowHeight(preload.node.parentNode.querySelectorAll('.dgrid-row')); this._adjustPreloadHeight(preload); } preload = preload.next; } }, lastScrollTop: 0, _processScroll: function (evt) { // summary:x // Checks to make sure that everything in the viewable area has been // downloaded, and triggering a request for the necessary data when needed. var preload = this.preload, rowHeight; this._updatePreloadRowHeights(); rowHeight = preload && preload.rowHeight; if (!rowHeight) { return; } var grid = this, scrollNode = grid.bodyNode, // grab current visible top from event if provided, otherwise from node visibleTop = (evt && evt.scrollTop) || this.getScrollPosition().y, visibleBottom = scrollNode.offsetHeight + visibleTop, priorPreload, preloadNode, lastScrollTop = grid.lastScrollTop, requestBuffer = grid.bufferRows * rowHeight, searchBuffer = requestBuffer - rowHeight, // Avoid rounding causing multiple queries // References related to emitting dgrid-refresh-complete if applicable lastRows, preloadSearchNext = true; // XXX: I do not know why this happens. // munging the actual location of the viewport relative to the preload node by a few pixels in either // direction is necessary because at least WebKit on Windows seems to have an error that causes it to // not quite get the entire element being focused in the viewport during keyboard navigation, // which means it becomes impossible to load more data using keyboard navigation because there is // no more data to scroll to to trigger the fetch. // 1 is arbitrary and just gets it to work correctly with our current test cases; don’t wanna go // crazy and set it to a big number without understanding more about what is going on. // wondering if it has to do with border-box or something, but changing the border widths does not // seem to make it break more or less, so I do not know… var mungeAmount = 1; grid.lastScrollTop = visibleTop; function calculateDistanceOffset(preload, removeBelow) { if (removeBelow) { return preload.node.offsetTop - visibleBottom; } else { return visibleTop - (preload.node.offsetTop + preload.node.offsetHeight); } } function traverseToEndPreload(preload, removeBelow) { var direction = removeBelow ? 'next' : 'previous'; var nextPreload; while ((nextPreload = preload[direction])) { preload = nextPreload; } return preload; } function removeDistantNodes(preload, removeBelow) { // we check to see the the nodes are "far off" var startingPreload = preload; preload = traverseToEndPreload(preload, removeBelow); var distanceOff = calculateDistanceOffset(preload, removeBelow); var farOffRemoval = grid.farOffRemoval; var preloadNode = preload.node; var domTraversal = removeBelow ? 'previousSibling' : 'nextSibling'; var count = 0; var reclaimedHeight = 0; var firstRowIndex; var lastRowIndex; function findNextPreload() { var topPreloadWanted = !removeBelow; var newPreload = preload; while ((newPreload = newPreload[removeBelow ? 'previous' : 'next'])) { if (topPreloadWanted === newPreload.top) { return newPreload; } } } function isEmpty(aPreload) { return isPreloadNode(aPreload.top ? aPreload.node.nextSibling : aPreload.node.previousSibling); } function traversePreload() { var newPreload = findNextPreload(); var node; if (newPreload && startingPreload !== newPreload && !isEmpty(newPreload)) { adjustPreloadStats(); preload = newPreload; preloadNode = preload.node; distanceOff = calculateDistanceOffset(preload, removeBelow); node = traverseNode(preloadNode); resetRowIndexes(node); return node; } } function traverseNode(referenceNode) { // Preload node referenced was first moved to the appropriate end of the list and // now we are moving toward the viewable area. var refIsPreload = isPreloadNode(referenceNode); var node = referenceNode[domTraversal]; var childNode; if (node) { if (!isRowNode(node)) { if (refIsPreload && isPreloadNode(node)) { node = null; } else { childNode = traversePreload(); if (childNode) { node = childNode; } else { node = traverseNode(node); } } } } return node; } function adjustPreloadStats() { // adjust the preloadNode based on the reclaimed space preload.count += count; if (removeBelow) { preloadNode.rowIndex -= count; } grid._adjustPreloadHeight(preload); count = 0; grid._releaseRange(preload, removeBelow, firstRowIndex, lastRowIndex); } function resetRowIndexes(row) { firstRowIndex = row && row.rowIndex; lastRowIndex = undefined; } if (distanceOff > 2 * farOffRemoval) { // there is a preloadNode that is far off; // remove rows until we get to in the current viewport var row; var nextRow = traverseNode(preloadNode); resetRowIndexes(nextRow); while ((row = nextRow) && startingPreload !== preload) { var currentRowHeight = grid._calcRowHeight(row); if (reclaimedHeight + currentRowHeight + farOffRemoval > distanceOff || !isRowNode(row)) { // we have reclaimed enough rows or we have gone beyond grid rows nextRow = traversePreload(); continue; } reclaimedHeight += currentRowHeight; count += row.count || 1; grid._pruneRow(row, removeBelow); if ('rowIndex' in row) { lastRowIndex = row.rowIndex; } nextRow = traverseNode(row); } adjustPreloadStats(); grid._deleteNodeQueue(); } } function traversePreload(preload, moveNext) { // Skip past preloads that are not currently connected do { preload = moveNext ? preload.next : preload.previous; } while (preload && !preload.node.offsetWidth); return preload; } while (preload && !preload.node.offsetWidth) { // skip past preloads that are not currently connected preload = preload.previous; } // there can be multiple preloadNodes (if they split, or multiple queries are created), // so we can traverse them until we find whatever is in the current viewport, making // sure we don't backtrack while (preload && preload !== priorPreload) { priorPreload = grid.preload; grid.preload = preload; preloadNode = preload.node; var preloadTop = preloadNode.offsetTop; if (visibleBottom + mungeAmount + searchBuffer < preloadTop) { // the preload is below the line of sight preload = traversePreload(preload, (preloadSearchNext = false)); } else if (visibleTop - mungeAmount - searchBuffer > preloadTop + preloadNode.offsetHeight) { // the preload is above the line of sight preload = traversePreload(preload, (preloadSearchNext = true)); } else { // the preload node is visible, or close to visible, better show it var offset = ((preloadNode.top ? visibleTop - requestBuffer : visibleBottom) - preloadTop) / preload.rowHeight; var count = (visibleBottom - visibleTop + 2 * requestBuffer) / preload.rowHeight; // utilize momentum for predictions var momentum = Math.max( Math.min((visibleTop - lastScrollTop) * preload.rowHeight, grid.maxRowsPerPage / 2), grid.maxRowsPerPage / -2); count += Math.min(Math.abs(momentum), 10); if (preloadNode.top) { // at the top, adjust from bottom to top offset -= count; } offset = Math.max(offset, 0); if (offset < 10 && offset > 0 && count + offset < grid.maxRowsPerPage) { // connect to the top of the preloadNode if possible to avoid excessive adjustments count += Math.max(0, offset); offset = 0; } count = Math.min(Math.max(count, grid.minRowsPerPage), grid.maxRowsPerPage, preload.count); if (count === 0) { preload = traversePreload(preload, preloadSearchNext); continue; } count = Math.ceil(count); offset = Math.min(Math.floor(offset), preload.count - count); var options = {}; preload.count -= count; var beforeNode = preloadNode, keepScrollTo, queryRowsOverlap = grid.queryRowsOverlap, bottomPreload = !preload.top && preload; if (bottomPreload) { // add new rows below var previous = preload.previous; if (previous) { removeDistantNodes(preload); if (offset > 0 && isPreloadNode(preloadNode.previousSibling)) { // all of the nodes above were removed offset = Math.min(preload.count, offset); preload.previous.count += offset; grid._adjustPreloadHeight(preload.previous, true); preloadNode.rowIndex += offset; queryRowsOverlap = 0; } else { count += offset; } preload.count -= offset; } options.start = preloadNode.rowIndex - queryRowsOverlap; options.count = Math.min(count + queryRowsOverlap, grid.maxRowsPerPage); preloadNode.rowIndex = options.start + options.count; } else { // add new rows above if (preload.next) { // remove out of sight nodes first beforeNode = preloadNode.nextSibling; removeDistantNodes(preload, true); if (isPreloadNode(preloadNode.nextSibling)) { // all of the nodes were removed, can position wherever we want preload.next.count += preload.count - offset; preload.next.node.rowIndex = offset + count; grid._adjustPreloadHeight(preload.next); preload.count = offset; queryRowsOverlap = 0; beforeNode = preload.next.node; } else { keepScrollTo = true; } } options.start = preload.count; options.count = Math.min(count + queryRowsOverlap, grid.maxRowsPerPage); options.scrollingUp = true; } if (keepScrollTo && beforeNode && beforeNode.offsetWidth) { // Before adjusting the size of the preload node for the new rows yet to be loaded, remember // the current position of beforeNode so the scroll position can be adjusted after // the new rows are added. keepScrollTo = beforeNode.offsetTop; } grid._adjustPreloadHeight(preload); // use the query associated with the preload node to get the next "page" if ('level' in preload.query) { options.queryLevel = preload.query.level; } // Avoid spurious queries (ideally this should be unnecessary...) if (!('queryLevel' in options) && (options.start > grid._total || options.count < 0)) { continue; } // create a loading node as a placeholder while the data is loaded var loadingNode = domConstruct.create('div', { className: 'dgrid-loading', style: { height: count * preload.rowHeight + 'px' } }, beforeNode, 'before'); domConstruct.create('div', { className: 'dgrid-' + (bottomPreload ? 'below' : 'above'), innerHTML: grid.loadingMessage }, loadingNode); loadingNode.count = count; // Query now to fill in these rows. grid._trackError(function () { // Use function to isolate the variables in case we make multiple requests // (which can happen if we need to render on both sides of an island of already-rendered rows) (function (loadingNode, below, keepScrollTo) { /* jshint maxlen: 122 */ var rangeResults = preload.query(options); lastRows = grid.renderQueryResults(rangeResults, loadingNode, options).then(function (rows) { var gridRows = grid._rows; if (gridRows && !('queryLevel' in options) && rows.length) { // Update relevant observed range for top-level items if (below) { if (gridRows.max <= gridRows.min) { // All rows were removed; update start of rendered range as well gridRows.min = rows[0].rowIndex; } gridRows.max = rows[rows.length - 1].rowIndex; } else { if (gridRows.max <= gridRows.min) { // All rows were removed; update end of rendered range as well gridRows.max = rows[rows.length - 1].rowIndex; } gridRows.min = rows[0].rowIndex; } } // can remove the loading node now beforeNode = loadingNode.nextSibling; domConstruct.destroy(loadingNode); // beforeNode may have been removed if the query results loading node was removed // as a distant node before rendering if (keepScrollTo && beforeNode && beforeNode.offsetWidth) { // if the preload area above the nodes is approximated based on average // row height, we may need to adjust the scroll once they are filled in // so we don't "jump" in the scrolling position grid.scrollTo({ y: grid.bodyNode.scrollTop + beforeNode.offsetTop - keepScrollTo }); } rangeResults.totalLength.then(function (total) { if (!('queryLevel' in options)) { grid._total = total; if (grid._rows && grid._rows.max >= grid._total - 1) { grid._rows.max = Infinity; } } if (below) { // if it is below, we will use the total from the collection to update // the count of the last preload in case the total changes as // later pages are retrieved // recalculate the count below.count = total - below.node.rowIndex; // readjust the height grid._adjustPreloadHeight(below); } }); // make sure we have covered the visible area grid._processScroll(); return rows; }, function (e) { domConstruct.destroy(loadingNode); throw e; }); })(loadingNode, bottomPreload, keepScrollTo); }); preload = preload.previous; } } // return the promise from the last render return lastRows; }, _adjustPreloadHeight: function (preload, noMax) { preload.node.style.height = this._calculatePreloadHeight(preload, noMax) + 'px'; }, _calculatePreloadHeight: function (preload, noMax) { return Math.min(preload.count * preload.rowHeight, noMax ? Infinity : this.maxEmptySpace); }, _pruneRow: function (rowElement, removeBelow, options) { // Calling _pruneRow indicates the row is not being deleted permanantly but could be restored // as the grid scrolls. // Just do cleanup here, as we will do a more efficient node destruction will be done later. this.removeRow(rowElement, true, options); this._queueNodeForDeletion(rowElement); }, _queueNodeForDeletion: function (node) { this._deleteQueue.push(node); }, _deleteNodeQueue: function () { var trashBin = document.createElement('div'); var toDelete = this._deleteQueue; for (var i = toDelete.length; i--;) { trashBin.appendChild(toDelete[i]); } this._deleteQueue = []; setTimeout(function () { // we can defer the destruction until later domConstruct.destroy(trashBin); }, 1); }, _removePreloads: function (preloadNodes) { // summary: // Remove the preload objects from the linked list that correspond to the // supplied DOM nodes. if (!preloadNodes || !preloadNodes.length) { return; } var grid = this; var headPreload = this._getHeadPreload(); preloadNodes.forEach(function (preloadNode) { var preload = grid._findPreload(preloadNode, headPreload); if (preload) { // Remove the found preload object from the linked list. if (preload.previous) { preload.previous.next = preload.next; } if (preload.next) { preload.next.previous = preload.previous; } } }); }, _getHeadPreload: function () { var headPreload = this.preload; if (headPreload) { while (headPreload.previous) { headPreload = headPreload.previous; } } return headPreload; }, _findPreload: function (preloadNode, startingPreload) { if (!startingPreload) { startingPreload = this._getHeadPreload(); } var preload = startingPreload; while (preload) { if (preload.node === preloadNode) { return preload; } preload = preload.next; } }, _getRenderedCollection: function (/* preload */) { // This allows extensions to overload the collection retrieval mechanism. return this._renderedCollection; }, _releaseRange: function (preload, removeBelow, firstRowIndex, lastRowIndex) { if (!preload) { return; } var level = preload.level; var renderedCollection = this._getRenderedCollection(preload); if (lastRowIndex != null) { if (renderedCollection.releaseRange && typeof firstRowIndex === 'number' && typeof lastRowIndex === 'number') { // Note that currently child rows in Tree structures are never unrendered; // this logic will need to be revisited when that is addressed. // releaseRange is end-exclusive, and won't remove anything if start >= end. if (removeBelow) { renderedCollection.releaseRange(lastRowIndex, firstRowIndex + 1); } else { renderedCollection.releaseRange(firstRowIndex, lastRowIndex + 1); } if (this._rows && !level) { this._rows[removeBelow ? 'max' : 'min'] = lastRowIndex; if (this._rows.max >= this._total - 1) { this._rows.max = Infinity; } } } } } }); });