'use strict'; define(function(require, exports, module) { var evt = require('evt'), slice = Array.prototype.slice, useTransform = false; /** * Indirection for setting the top of a node. Used to allow * experimenting with either a transform or using top */ function setTop(node, value) { if (useTransform) { node.style.transform = 'translateY(' + value + 'px)'; } else { node.style.top = value + 'px'; } } // VScroll -------------------------------------------------------- /** * Creates a new VScroll instance. Needs .setData() called on it * to actually show content, the constructor just wires up nodes * and sets starting state. * * @param {Node} container the DOM node that will show the items. * * @param {Node} scrollingContainer the scrolling DOM node, which * contains the `container` node. Note that in email, there are * other nodes in the scrollingContainer besides just container. * * @param {Node} template a DOM node that is cloned to provide * the DOM node to use for an item that is shown on the screen. * The clones of this node are cached and reused for multiple * data items. * * @param {Object} defaultData a placeholder data object to use * if list(index) does not return an object. Usually shows up when * the scroll gets to a place in the list that does not have data * loaded yet from the back end. */ function VScroll(container, scrollingContainer, template, defaultData) { evt.Emitter.call(this); this.container = container; this.scrollingContainer = scrollingContainer; this.template = template; this.defaultData = defaultData; this._inited = false; // Because the FxOS keyboard works by resizing our window, we/our caller // need to be careful about when we sample things involving the screen size. // So, we want to only capture this once and do it separably from other // things. this._capturedScreenMetrics = false; /** * What is the first/lowest rendered index? Tracked so the HTML * cache logic can know if we've got the data for it to be able to * render the first N nodes. */ this.firstRenderedIndex = 0; this._limited = false; /** * The list of reused Element nodes. Their order in this list has * no correlation with their display position. If you decide to * reorder them you may break/hurt _nextAvailableNode. */ this.nodes = []; /** * Maps data indexes to their reusable Element nodes if currently * rendered, or -1 if previously (but not currently rendered). * Populated as nodes are rendered so not being in the map is * effectively the same as having a value of -1. * * Maintained by _setNodeDataIndex and accessed by * _getNodeFromDataIndex. Use those methods and do not touch this * map directly. */ this.nodesDataIndices = {}; /** Internal state variable of _nextAvailableNode for efficiency. */ this.nodesIndex = -1; this.scrollTop = 0; /** * Any visible height offset to where container sits in relation * to scrollingContainer. Expected to be set by owner of the * VScroll instance. In email, the search box height is an * example of a visibleOffset. */ this.visibleOffset = 0; /** * The old list size is used for display purposes, to know if new data would * affect the scroll offset or if the total display height needs to be * adjusted. */ this.oldListSize = 0; this._lastEventTime = 0; // Bind to this to make reuse in functional APIs easier. this.onEvent = this.onEvent.bind(this); this.onChange = this.onChange.bind(this); this._scrollTimeoutPoll = this._scrollTimeoutPoll.bind(this); } VScroll.nodeClassName = 'vscroll-node'; /** * Given a node that is handled by VScroll, trim it down for use * in a string cache, like email's html cache. Modifies the * node in place. * @param {Node} node the containerNode that is bound to * a VScroll instance. * @param {Number} itemLimit number of items to cache. If greater * than the length of items in a NodeCache, the NodeCache item * length will be used. */ VScroll.trimMessagesForCache = function(container, itemLimit) { // Find the NodeCache that is at the top var nodes = slice.call(container.querySelectorAll( '.' + VScroll.nodeClassName)); nodes.forEach(function(node) { var index = parseInt(node.dataset.index, 10); // None of the clones need this value after we read it off, so reduce // the size of the cache by clearing it. delete node.dataset.index; if (index > itemLimit - 1) { container.removeChild(node); } }); }; VScroll.prototype = { /** * rate limit for event handler, in milliseconds, so that * it does not do work for every event received. If set to * zero, it means always do the work for every scroll event. * If this code continues to use 0, then the onEvent/onChange * duality could be removed, and just use onChange directly. * A non-zero value, like 50 subjectively seems to result in * more checkerboarding of half the screen every so often. */ eventRateLimitMillis: 0, /** * The maximum number of items visible on the screen at once * (derived from available space and rounded up). */ itemsPerScreen: undefined, /** * The number of screens worth of items to pre-render in the * direction we are scrolling beyond the current screen. */ prerenderScreens: 3, /** * The number of screens worth of items to prefetch (but not * render!) beyond what we prerender. */ prefetchScreens: 2, /** * The number of extra screens worth of rendered items to keep * around beyond what is required for prerendering. When * scrolling in a single direction, this ends up being the number * of screens worth of items to keep rendered behind us. If this * is less than the value of `prerenderScreens` then a user just * jiggling the screen up and down by even a pixel will cause us * work as we move the delta back and forth. * * In other words, don't have this be less than * `prerenderScreens`, but you can have it be more. (Although * having it be more is probably wasteful since extra DOM nodes * the user is moving away from don't help us.) */ retainExtraRenderedScreens: 3, /** * When recalculating, pre-render this many screens of messages on * each side of the current screen. This may be a fractional * value (we round up). * * In the initial case we very much want to minimize rendering * latency, so it makes sense for this to be smaller than * `prerenderScreens`. * * In the non-initial case we wait for scrolling to have quiesced, * so there's no overriding need to bias in either direction. */ recalculatePaddingScreens: 1.5, /** * Track when the last time vscroll manually changed the scrollTop * of the scrolling container. Useful for when knowing if a recent * scroll event was triggered by this component or by user action. * The value resets to 0 periodically to avoid interested code from * doing too many timestamp checks on every scroll event. */ lastScrollTopSetTime: 0, /** * The number of items to prerender (computed). */ prerenderItemCount: undefined, /** * The number of items to prefetch (computed). */ prefetchItemCount: undefined, /** * The number of items to render when (non-initial) recalculating. */ recalculatePaddingItemCount: undefined, /** * The class to find items that have their default data set, * in the case where a scroll into a cache has skipped updates * because a previous fast scroll skipped the updates since they * were not visible at the time of that fast scroll. */ itemDefaultDataClass: 'default-data', /** * Hook that is implemented by the creator of a VScroll instance. * Called when the VScroll thinks it will need the next set of * data, but before the VScroll actually shows that section of * data. Passed the inclusive high absolute index for which it * wants data. ASSUMES data sources that only need to grow * downward. */ prepareData: function(highAbsoluteIndex) {}, /** * Hook that is implemented by the creator of a VScroll instance. * Called when the VScroll wants to bind a model object to a * display node. */ bindData: function(model, node) {}, /** * Sets the list data source, and then triggers a recalculate * since the data changed. * @param {Function} list the list data source. */ setData: function(list) { this.list = list; if (this._inited) { if (!this.waitingForRecalculate) { this._recalculate(0); } this.emit('dataChanged'); } else { this._init(); this.renderCurrentPosition(); } }, /** * Called by code that created the VScroll instance, when that * code has data fetched and wants to let the VScroll know * about it. This is useful from removing the display of * defaultData and showing the finally fetched data. * @param {Number} index the list item index for which the * data update is available * @param {Array} dataList the list of data items that are * now available. The first item in that list corresponds to * the data list index given in the first argument. * @param {number} removedCount the count of any items removed. * Used mostly to know if a recalculation needs to be done. */ updateDataBind: function(index, dataList, removedCount) { if (!this._inited) { return; } // If the list data set length is different from before, that // indicates state is now invalid and a recalculate is needed, // but wait until scrolling stops. This can happen if items // were removed, or if new things were added to the list. if (this.oldListSize !== this.list.size() || removedCount) { if (!this.waitingForRecalculate) { this.waitingForRecalculate = true; this.once('scrollStopped', function() { this._recalculate(index); }.bind(this)); } return; } // Not a list data size change, just an update to existing // data items, so update them in place. for (var i = 0; i < dataList.length; i++) { var absoluteIndex = index + i; var node = this._getNodeFromDataIndex(absoluteIndex); if (node) { this.bindData(dataList[i], node); } } }, /** * Handles events fired, and allows rate limiting the work if * this.eventRateLimitMillis has been set. Otherwise just calls * directly to onChange. */ onEvent: function() { this._lastEventTime = Date.now(); if (!this.eventRateLimitMillis) { this.onChange(); return; } if (this._limited) { return; } this._limited = true; setTimeout(this.onChange, this.eventRateLimitMillis); }, /** * Process a scroll event (possibly delayed). */ onChange: function() { // Rate limit is now expired since doing actual work. this._limited = false; if (!this._inited) { return; } if (this.lastScrollTopSetTime) { // Keep the last scroll time for about a second, which should // be enough time for interested parties to check the value. if (this.lastScrollTopSetTime + 1000 < Date.now()) { this.lastScrollTopSetTime = 0; } } var startIndex, endIndex, scrollTop = this.scrollingContainer.scrollTop, scrollingDown = scrollTop >= this.scrollTop; this.scrollTop = scrollTop; // must get after updating this.scrollTop since it uses that var visibleRange = this.getVisibleIndexRange(); if (scrollingDown) { // both _render and prepareData clamp appropriately startIndex = visibleRange[0]; endIndex = visibleRange[1] + this.prerenderItemCount; this.prepareData(endIndex + this.prefetchItemCount); } else { // scrolling up startIndex = visibleRange[0] - this.prerenderItemCount; endIndex = visibleRange[1]; // no need to prepareData; it's already there! } this._render(startIndex, endIndex); this._startScrollStopPolling(); }, /** * Called when the vscroll becomes visible. In cases where the vscroll * may have been intially created for an element that is not visible, * the sizing information would not be correct and the vscroll instance * would not be initialized correctly. So the instance needs to know * when it should check again to properly initialize. Otherwise, there * may not be any new data signals from the the list data that a display * needs to be tried. */ nowVisible: function() { // Only do work if not initialized and have data. if (!this._inited && this.list) { this._init(); this.onChange(); } }, /** * Renders the list at the current scroll position. */ renderCurrentPosition: function() { if (!this._inited) { return; } var scrollTop = this.scrollingContainer.scrollTop; this.scrollTop = scrollTop; var visibleRange = this.getVisibleIndexRange(); // (_render clamps these values for sanity; we don't have to) var startIndex = visibleRange[0] - this.recalculatePaddingItemCount; var endIndex = visibleRange[1] + this.recalculatePaddingItemCount; this._render(startIndex, endIndex); // make sure we have at least enough data to cover what we want // to display this.prepareData(endIndex); }, /** * Determine what data index is at the given scroll position. * @param {Number} position scroll position * @return {Number} the data index. */ indexAtScrollPosition: function (position) { var top = position - this.visibleOffset; if (top < 0) { top = 0; } return this.itemHeight ? Math.floor(top / this.itemHeight) : 0; }, /** * Returns the start index and end index of the list items that * are currently visible to the user using the currently cached * scrollTop value. * @return {Array} first and last index. Array could be undefined * if the VScroll is not in a position to show data yet. */ getVisibleIndexRange: function() { // Do not bother if itemHeight has not bee initialized yet. if (this.itemHeight === undefined) { return undefined; } var top = this.scrollTop; return [ this.indexAtScrollPosition(top), this.indexAtScrollPosition(top + this.innerHeight) ]; }, /** * Given the list index, scroll to the top of that item. * @param {Number} index the list item index. */ jumpToIndex: function(index) { this._setContainerScrollTop((index * this.itemHeight) + this.visibleOffset); }, /** * Removes items from display in the container. Just a visual * change, does not change data in any way. Data-related * elements, like the positions of this.nodes, are reset in * the data entry points that follow a clearDisplay, like * _init() or recalculate(). */ clearDisplay: function() { // Clear the HTML content. this.container.innerHTML = ''; this.container.style.height = '0px'; // Also clear the oldListSize, since it used for height/scroll offset // updates, and now that the container does not have any children, this // property should be reset to zero. If this is not done, it is possible // for an update that matches the same size as the previous data will not // end up showing items. This happened for search in bug 1081403. this.oldListSize = 0; }, /** * Call this method before the VScroll instance will be destroyed. * Used to clean up the VScroll. */ destroy: function() { this.scrollingContainer.removeEventListener('scroll', this.onEvent); if (this._scrollTimeoutPoll) { clearTimeout(this._scrollTimeoutPoll); this._scrollTimeoutPoll = 0; } }, _setContainerScrollTop: function(value) { this.scrollingContainer.scrollTop = value; // Opt for using a property set instead of an event emitter, since the // timing of that event emit is not guaranteed to get to listeners before // scroll events. this.lastScrollTopSetTime = Date.now(); }, /** * Ensure that we are rendering at least all messages in the * inclusive range [startIndex, endIndex]. Already rendered * messages outside this range may be reused but will not be * removed or de-rendered unless they are needed. * * * @param {Number} startIndex first inclusive index in this.list's * data that should be used. Will be clamped to the bounds of * this.list but what's visible on the screen is not considered * @param {Number} endIndex last inclusive index in this.list's * data that should be used. Clamped like startIndex. */ _render: function(startIndex, endIndex) { var i, listSize = this.list.size(); // Paranoia clamp the inputs; we depend on callers to deal with // the visible range. if (startIndex < 0) { startIndex = 0; } if (endIndex >= listSize) { endIndex = listSize - 1; } this.firstRenderedIndex = startIndex; if (!this._inited) { this._init(); } for (i = startIndex; i <= endIndex; i++) { // If node already bound and placed correctly, skip it. if (this._getNodeFromDataIndex(i)) { continue; } var node = this._nextAvailableNode(startIndex, endIndex), data = this.list(i); if (!data) { data = this.defaultData; } // Remove the node while doing updates in positioning to // avoid extra layers from being created which really slows // down scrolling. if (node.parentNode) { node.parentNode.removeChild(node); } setTop(node, i * this.itemHeight); this._setNodeDataIndex(this.nodesIndex, i); this.bindData(data, node); this.container.appendChild(node); } }, _setNodeDataIndex: function(nodesIndex, dataIndex) { // Clear dataIndices map for old dataIndex value. var oldDataIndex = this.nodes[nodesIndex].vScrollDataIndex; if (oldDataIndex > -1) { this.nodesDataIndices[oldDataIndex] = -1; } var node = this.nodes[nodesIndex]; node.vScrollDataIndex = dataIndex; // Expose the index into the DOM so that the cache logic can // consider them since only the underlying DOM node is cloned by // cloneNode(). (vScrollDataIndex is an "expando" property on // the JS wrapper on the native DOM object.) node.dataset.index = dataIndex; this.nodesDataIndices[dataIndex] = nodesIndex; }, _getNodeFromDataIndex: function (dataIndex) { var index = this.nodesDataIndices[dataIndex]; if (index === undefined) { index = -1; } return index === -1 ? null : this.nodes[index]; }, captureScreenMetrics: function() { if (this._capturedScreenMetrics) { return; } this.innerHeight = this.scrollingContainer.getBoundingClientRect().height; if (this.innerHeight > 0) { this._capturedScreenMetrics = true; } }, /** * Handles final initialization, once the VScroll is expected * to actually show data. * * XXX eventually consume 'resize' events. Right now we are * assuming that the email app only supports a single orientation * (portrait) and that the only time a resize event will trigger * is if the keyboard is shown or hidden. When used in the * message_list's search mode, it explicitly calls _init on us * prior to causing the keyboard to be displayed, which currently * saves us from getting super confused. */ _init: function() { if (this._inited) { return; } // Clear out any previous container contents. For example, a // cached HTML of a previous card may have been used to init // this VScroll instance. this.container.innerHTML = ''; // Get the height of an item node. var node = this.template.cloneNode(true); this.container.appendChild(node); this.itemHeight = node.clientHeight; this.container.removeChild(node); // Set up all the bounds used in scroll calculations this.captureScreenMetrics(); // The instance is not visible yet, so cannot finish initialization. // Wait for the next instance API call to see if initialization can // complete. if (!this.itemHeight || !this.innerHeight) { return; } this.scrollingContainer.addEventListener('scroll', this.onEvent); this.itemsPerScreen = Math.ceil(this.innerHeight / this.itemHeight); this.prerenderItemCount = Math.ceil(this.itemsPerScreen * this.prerenderScreens); this.prefetchItemCount = Math.ceil(this.itemsPerScreen * this.prefetchScreens); this.recalculatePaddingItemCount = Math.ceil(this.itemsPerScreen * this.recalculatePaddingScreens); this.nodeCount = this.itemsPerScreen + this.prerenderItemCount + Math.ceil(this.retainExtraRenderedScreens * this.itemsPerScreen); // Fill up the pool of nodes to use for data items. for (var i = 0; i < this.nodeCount; i++) { node = this.template.cloneNode(true); node.classList.add(VScroll.nodeClassName); setTop(node, (-1 * this.itemHeight)); this.nodes.push(node); this._setNodeDataIndex(i, -1); } this._calculateTotalHeight(); this._inited = true; this.emit('inited'); }, /** * Finds the next node in the pool to use in the visible area. * Uses a hidden persistent index to provide efficient lookup for * repeated calls using the same stratIndex/endIndex as long as * there are at least (endIndex - beginIndex + 1) * 2 nodes. * * @param {Number} beginIndex the starting data index for the * range of already visible data indices. They should be * avoided as choices since they are already in visible area. * @param {Number} endIndex the ending data index for the * range of already visible data indices. * @return {Node} the DOM node that can be used next for display. */ _nextAvailableNode: function(beginIndex, endIndex) { var i, node, vScrollDataIndex, count = 0; // Loop over nodes finding the first one that is out of visible // range, making sure to loop back to the beginning of the // nodes if cycling over the end of the list. for (i = this.nodesIndex + 1; count < this.nodes.length; count++, i++) { // Loop back to the beginning if past the end of the nodes. if (i > this.nodes.length - 1) { i = 0; } node = this.nodes[i]; vScrollDataIndex = node.vScrollDataIndex; if (vScrollDataIndex < beginIndex || vScrollDataIndex > endIndex) { this.nodesIndex = i; break; } } return node; }, /** * Recalculates the size of the container, and resets the * display of items in the container. Maintains the scroll * position inside the list. * @param {Number} refIndex a reference index that spawned * the recalculate. If that index is "above" the targeted * computed index found by recalculate, then it means the * the absolute scroll position may need to change. */ _recalculate: function(refIndex) { if (!this._inited) { return; } var node, index = this.indexAtScrollPosition(this.scrollTop), remainder = this.scrollTop % this.itemHeight, sizeDiff = this.list.size() - this.oldListSize; // If this recalculate was spawned from the top and more // items, then new messages from the top, and account for // them so the scroll position does not jump. Only do this // though if old size was not 0, which is common on first // folder sync, or if the reference index that spawned the // recalculate is "above" the target index, since that // means the contents above the target index shifted. if (refIndex && refIndex < index && sizeDiff > 0 && this.oldListSize !== 0 && index !== 0) { index += sizeDiff; } console.log('VSCROLL scrollTop: ' + this.scrollTop + ', RECALCULATE: ' + index + ', ' + remainder); this._calculateTotalHeight(); // Now clear the caches from the visible area for (var i = 0; i < this.nodeCount; i++) { node = this.nodes[i]; setTop(node, (-1 * this.itemHeight)); this._setNodeDataIndex(i, -1); } this.waitingForRecalculate = false; this._setContainerScrollTop((this.itemHeight * index) + remainder); this.renderCurrentPosition(); this.emit('recalculated', index === 0, refIndex); }, /** * Sets the total height of the container. */ _calculateTotalHeight: function() { // Size the scrollable area to the full height if all items // were rendered inside of it, so that there is no weird // scroll bar grow/shrink effects and so that inertia // scrolling is not artificially truncated. var newListSize = this.list.size(); // Do not bother if same size, or if the container was set to 0 height, // most likely by a clearDisplay. if (this.oldListSize !== newListSize || parseInt(this.container.style.height, 10) === 0) { this.totalHeight = this.itemHeight * newListSize; this.container.style.height = this.totalHeight + 'px'; this.oldListSize = newListSize; } }, /** * Handles checking for the end of a scroll, based on a time * delay since the last scroll event. */ _scrollTimeoutPoll: function() { this._scrollStopTimeout = 0; if (Date.now() > this._lastEventTime + 300) { this.emit('scrollStopped'); } else { this._scrollStopTimeout = setTimeout(this._scrollTimeoutPoll, 300); } }, /** * Starts checking for the end of scroll events. */ _startScrollStopPolling: function() { if (!this._scrollStopTimeout) { // "this" binding for _scrollTimeoutPoll done in constructor this._scrollStopTimeout = setTimeout(this._scrollTimeoutPoll, 300); } } }; evt.mix(VScroll.prototype); // Override on() to allow for a lazy firing of scrollStopped, // particularly when the list is not scrolling, so the stop // polling is not currently running. This is useful for "once" // listeners that just want to be sure to do work when scroll // is not in action. var originalOn = VScroll.prototype.on; VScroll.prototype.on = function(id, fn) { if (id === 'scrollStopped') { this._startScrollStopPolling(); } return originalOn.apply(this, slice.call(arguments)); }; // Introspection tools -------------------------------------------- // uncomment this section to use them. Useful for tracing how the // code is called. /* require('debug_trace_methods')(VScroll.prototype, module.id); */ return VScroll; });