VulpesOS/gaia/apps/email/js/vscroll.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

818 lines
28 KiB
JavaScript
Executable file

'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;
});