VulpesOS/gaia/apps/music/components/fast-list/fast-list.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

1015 lines
26 KiB
JavaScript
Executable file

!(function(define){'use strict';define(function(require,exports,module){
/**
* Mini logger
*
* @type {Function}
*/
var debug = 0 ? console.log.bind(console, '[FastList]') : function() {};
/**
* Use the dom-scheduler if it's around,
* else fallback to fake shim.
*
* @type {Object}
*/
var schedule = (window.scheduler && typeof window.scheduler.mutation === "function" ? window.scheduler : schedulerShim());
/**
* Exports
*/
exports = module.exports = FastList;
exports.scheduler = schedule;
function FastList(source) {
debug('initialize');
this._scrollStopTimeout = null;
this.source = source;
this.els = {
itemContainer: source.itemContainer,
container: source.container,
list: source.list,
sections: [],
items: [],
itemsInDOM: []
};
this.geometry = {
topPosition: 0,
forward: true,
busy: false,
idle: true,
hasScrolled: false,
itemHeight: source.getItemHeight(),
viewportHeight: 0,
maxItemCount: 0,
switchWindow: 0
};
// TODO: Move this to fast-list-edit.js
this.reorderingContext = {
item: null,
initialY: null,
identifier: null,
moveUp: null,
moveDown: null,
};
// TODO: Move this out of core
on(this.els.container, 'click', this);
// Update geometry on resize
on(window, 'resize', this);
// Create a list element if one wasn't provided
if (!this.els.list) this.els.list = document.createElement('ul');
if (!this.els.itemContainer) this.els.itemContainer = this.els.list;
// Phase1 renders just enough content
// for the viewport (without detail).
this.rendered = schedule
.mutation(this.setupPhase1.bind(this));
// Phase2 renders more list-items and detail
this.complete = this.rendered
.then(this.setupPhase2.bind(this));
}
FastList.prototype = {
PRERENDER_MULTIPLIER: 3.5,
/**
* The first render of the list aims
* to get content on the screen as
* fast as possible.
*
* Only the list-items in the viewport
* (critical) are rendered, and the
* populateItemDetail() stage is
* skipped to prevent expensive
* render paths being hit.
*
* @private
*/
setupPhase1: function() {
debug('setup phase 1');
var fragment = document.createDocumentFragment();
var container = this.els.container;
this.updateContainerGeometry();
// Make the container scrollable
container.style.overflowX = 'hidden';
container.style.overflowY = 'scroll';
this.updateListHeight();
// If the list detached (created internally), attach it
if (!this.els.list.parentNode) container.appendChild(this.els.list);
// We can't set initial scrollTop
// until the list has a height.
// WARNING: Setting an initial scrollTop
// before render forces an extra reflow!
if (this.source.initialScrollTop) {
container.scrollTop = this.source.initialScrollTop;
this.geometry.topPosition = container.scrollTop;
}
// Using a fragment container means we only
// endure one document mutation on inititalization
this.updateSections({ fragment: fragment });
// Lightweight render into fragment
this.render({
fragment: fragment,
criticalOnly: true,
skipDetail: true
});
// Inject the content fragment
this.els.itemContainer.appendChild(fragment);
// Bind scroll listeners after setting the
// initialScrollTop to avoid triggering 'scroll'
// handler. Setting the handler early means
// we can render if the user or Gecko
// changes the scrollTop before phase2.
this.handleScroll = this.handleScroll.bind(this);
schedule.attachDirect(
this.els.container,
'scroll',
this.handleScroll
);
},
/**
* The second render phase completes
* the initialization of the list.
*
* All list items will be rendered
* and the detail (eg. images) will
* be populated.
*
* .processScrollPosition() is called
* before render to update the
*
* @return {Promise}
* @private
*/
setupPhase2: function() {
return new Promise(function(resolve) {
setTimeout(function() {
debug('setup phase 2');
var fragment = document.createDocumentFragment();
this.render({ fragment: fragment });
this.els.itemContainer.appendChild(fragment);
resolve();
}.bind(this), 360);
}.bind(this));
},
/**
* Updates the container geometry
* state needed for the rendering.
*
* 'switchWindow' refers to an allocation
* of list items that are rendered 'behind'
* the current scroll direction of the
* list, so that if/when the user changes
* direction, we have something prerendered.
*
* The remaining items are used to render
* content 'ahead' of the current scroll
* direction.
*
* @private
*/
updateContainerGeometry: function() {
var geo = this.geometry;
var viewportHeight = this.getViewportHeight();
var itemPerScreen = viewportHeight / geo.itemHeight;
geo.viewportHeight = viewportHeight;
geo.maxItemCount = Math.floor(itemPerScreen * this.PRERENDER_MULTIPLIER);
geo.switchWindow = Math.floor(itemPerScreen / 2);
debug('maxItemCount: ' + geo.maxItemCount);
},
/**
* Returns the height of the list container.
*
* Attempts to use user provided .getViewportHeight()
* from the configuration, falling back to reflow
* forcing .offsetHeight :(
*
* @return {Number}
* @private
*/
getViewportHeight: function() {
return this.source.getViewportHeight
? this.source.getViewportHeight()
: this.els.container.offsetHeight;
},
/**
* Updates the rendering window geometry informations
*
* - top position
* - busyness state
* - idling state
*
* We monitor the distance traveled between 2 handled scroll events to
* determine if we can do more expensive rendering (idle) or if we need
* to skip rendering altogether (busy).
*
* @private
*/
processScrollPosition: function(instant) {
var position = this.els.container.scrollTop;
var geo = this.geometry;
// Don't compute lag on first reading
if (!geo.hasScrolled) {
geo.topPosition = position;
geo.hasScrolled = true;
}
var viewportHeight = geo.viewportHeight;
var previousTop = geo.topPosition;
var delta = position - previousTop;
geo.forward = isForward(geo.forward, delta);
geo.topPosition = position;
var onTop = geo.topPosition === 0;
var topReached = onTop && previousTop !== 0;
var fullHeight = this.source.getFullHeight();
var maxScrollTop = fullHeight - viewportHeight;
var atBottom = geo.topPosition === maxScrollTop;
if (topReached) this.emit('top-reached');
// Full stop, forcefuly idle
if (onTop || atBottom || instant) {
geo.busy = false;
geo.idle = true;
return;
}
var moved = Math.abs(delta);
geo.busy = isBusy(geo.busy, moved, viewportHeight);
geo.idle = isIdle(geo.idle, moved, viewportHeight);
},
/**
* Places and populates the item
* in the rendering window
*
* @param {Object} options
* @param {Object} options.reload forces the re-population of all items
* @param {Object} options.fragment optional fragment to insert items
* @param {Object} options.changedIndex flags item as new
* (for animated insertions)
*/
render: function(options) {
debug('render');
options = options || {};
var changedIndex = options.changedIndex;
var criticalOnly = options.criticalOnly;
var skipDetail = options.skipDetail;
var fragment = options.fragment;
var reload = options.reload;
var itemContainer = fragment || this.els.itemContainer;
var itemsInDOM = this.els.itemsInDOM;
var items = this.els.items;
var source = this.source;
var geo = this.geometry;
var indices = computeIndices(this.source, this.geometry);
var fullLength = source.getFullLength();
var criticalStart = indices.cStart;
var criticalEnd = indices.cEnd;
var startIndex = indices.start;
var endIndex = indices.end;
var self = this;
// Only render the list-items visible
// in the viewport (critical).
if (criticalOnly) {
startIndex = criticalStart;
endIndex = criticalEnd;
}
var recyclableItems = recycle(
items,
criticalStart,
criticalEnd,
geo.forward ? endIndex : startIndex
);
// Only render if there are items
if (fullLength) {
if (geo.forward) {
for (var i = startIndex; i <= endIndex; ++i) renderItem(i);
} else {
for (var j = endIndex; j >= startIndex; --j) renderItem(j);
}
}
// When the data changes we need to make sure we're not keeping
// outdated pre-rendered content lying around
if (reload) cleanUpPrerenderedItems(items, source);
function findItemFor(index) {
// debug('find item for', index, recyclableItems);
var item;
if (recyclableItems.length > 0) {
var recycleIndex = recyclableItems.pop();
item = items[recycleIndex];
delete items[recycleIndex];
debug('found node to recycle', recycleIndex, recyclableItems);
} else if (itemsInDOM.length < geo.maxItemCount){
item = self.createItem();
itemContainer.appendChild(item);
itemsInDOM.push(item);
} else {
console.warn('missing a cell');
return;
}
items[index] = item;
return item;
}
function renderItem(i) {
// debug('render item', i);
var item = items[i];
if (!item) {
item = findItemFor(i);
self.unpopulateItemDetail(item);
tryToPopulate(item, i, source, true);
item.classList.toggle('new', i === changedIndex);
// Reloading, re-populating all items
// We expect the DataSource to be ready
// to populate all items so not
// going through `tryToPopulate()`
} else if (reload) {
self.unpopulateItemDetail(item);
source.populateItem(item, i);
item.dataset.populated = true;
if (item.style.display === 'none') {
item.style.removeProperty('display');
}
} // else item is already populated
var section = source.getSectionFor(i);
placeItem(item, i, section, geo, source, reload);
if (skipDetail || !source.populateItemDetail) return;
// Populating the item detail when we're not too busy scrolling
// Note: we're always settling back to an idle stage at some point where
// we do all the pending detail populations
if (!geo.idle) return;
// Detail already populated, skipping
if (item.dataset.detailPopulated === 'true') return;
var result = source.populateItemDetail(item, i);
if (result !== false) item.dataset.detailPopulated = true;
}
debugViewport(
items,
geo.forward,
criticalStart,
criticalEnd,
startIndex,
endIndex
);
},
unpopulateItemDetail: function(item) {
var shouldUnpopulate = this.source.unpopulateItemDetail
&& item.dataset.detailPopulated === 'true';
// Recycling, need to unpopulate and
// populate with the new content
if (shouldUnpopulate) {
this.source.unpopulateItemDetail(item);
item.dataset.detailPopulated = false;
}
},
/**
* Creates a list item.
*
* @return {HTMLElement}
*/
createItem: function() {
var el = this.source.createItem();
el.style.position = 'absolute';
el.style.left = el.style.top = 0;
el.style.overflow = 'hidden';
return el;
},
/**
* Creates a list section.
*
* @return {HTMLElement}
*/
createSection: function(name) {
var el = this.source.createSection(name);
el.classList.add('fl-section');
return el;
},
reloadData: function() {
return schedule.mutation(function() {
this.updateSections();
this.updateListHeight();
this.render({ reload: true });
}.bind(this));
},
updateSections: function(options) {
debug('update sections');
var fragment = (options && options.fragment);
var nodes = this.els.itemContainer.querySelectorAll('.fl-section');
var items = fragment || document.createDocumentFragment();
var source = this.source;
// remove any old sections
for (var i = 0; i < nodes.length; i++) {
var toRemove = nodes[i];
toRemove.remove();
}
var headerHeight = source.getSectionHeaderHeight();
var sections = this.source.getSections();
// create new sections
for (var j = 0; j < sections.length; j++) {
var height = source.getFullSectionHeight(sections[j]);
var el = this.createSection(sections[j]);
el.style.height = headerHeight + height + 'px';
this.source.populateSection(el, sections[j], j);
items.appendChild(el);
}
// don't append the items if an outside fragment was given
if (!fragment) this.els.itemContainer.appendChild(items);
},
/**
* Scrolling
*/
handleScroll: function(evt) {
clearTimeout(this._scrollStopTimeout);
this.processScrollPosition();
if (this.geometry.busy) debug('[x] ---------- faaaaassssstttt');
else this.render();
if (this.geometry.idle) return;
var self = this;
// We just did a partial rendering
// and need to make sure it won't
// get stuck this way if the
// scrolling comes to a hard stop.
this._scrollStopTimeout = setTimeout(function() {
self.processScrollPosition(true);
self.render();
}, 200);
},
updateListHeight: function() {
this.els.list.style.height = this.source.getFullHeight() + 'px';
debug('updated list height', this.els.list.style.height);
},
get scrollTop() {
return this.geometry.topPosition;
},
scrollInstantly: function(position) {
debug('scroll instantly', position);
this.els.container.scrollTop = position;
this.processScrollPosition(true);
this.render();
},
/**
* External Content Changes
*/
insertedAtIndex: function(index) {
debug('inserted at index', index);
if (index !== 0) {
//TODO: support any point of insertion
return;
}
if (this.geometry.topPosition > this.geometry.itemHeight ||
this.editing) {
// No transition needed, just keep the scroll position
this._insertOnTop(true);
return;
}
var domItems = this.els.itemsInDOM;
var list = this.els.itemContainer;
list.classList.add('reordering');
pushDown(domItems, this.geometry)
.then(this._insertOnTop.bind(this, false))
.then(cleanInlineStyles.bind(null, domItems))
.then(reveal.bind(null, list))
.then(function() {
list.classList.remove('reordering');
});
},
_insertOnTop: function(keepScrollPosition) {
debug('insert on top', keepScrollPosition);
return schedule.mutation((function() {
this.els.items.unshift(null);
delete this.els.items[0]; // keeping it sparse
this.updateSections();
if (keepScrollPosition) {
var scrollTop = this.els.container.scrollTop;
this.scrollInstantly(scrollTop + this.geometry.itemHeight);
this.els.container.dispatchEvent(new CustomEvent('hidden-new-content'));
} else {
this.render({ changedIndex: 0 });
}
this.updateListHeight();
}).bind(this));
},
handleEvent: function(evt) {
switch (evt.type) {
case 'resize':
this.updateContainerGeometry();
break;
// TODO: Move out of core
case 'click':
if (this.editing) {
break;
}
var li = evt.target;
var index = this.els.items.indexOf(li);
this.els.itemContainer.dispatchEvent(new CustomEvent('item-selected', {
bubbles: true,
detail: {
index: index,
clickEvt: evt,
}
}));
break;
}
},
/**
* Attach a plugin to a list.
*
* @param {Function} fn
* @return {FastList}
*/
plugin: function(fn) {
fn(this);
return this;
},
/**
* Emit an event.
*
* @param {String} name
* @private
*/
emit: function(name, detail) {
var e = new CustomEvent(name, {
bubbles: false,
detail: detail
});
this.els.container.dispatchEvent(e);
},
/**
* Permanently destroy the list.
*
* @public
*/
destroy: function() {
this.els.itemContainer.innerHTML = '';
schedule.detachDirect(
this.els.container,
'scroll',
this.handleScroll
);
}
};
/**
* Internals
*/
/**
* ASCII Art viewport debugging
*
* @param {[type]} items [description]
* @param {[type]} forward [description]
* @param {[type]} cStart [description]
* @param {[type]} cEnd [description]
* @param {[type]} start [description]
* @param {[type]} end [description]
* @return {[type]} [description]
*/
function debugViewport(items, forward, cStart, cEnd, start, end) {
if (!debug.name) {
return;
}
var str = '[' + (forward ? 'v' : '^') + ']';
for (var i = 0; i < items.length; i++) {
if (i == start) str += '|';
if (i == cStart) str += '[';
if (items[i]) str += 'x';
else str += '-';
if (i == cEnd) str += ']';
if (i == end) str += '|';
}
debug(str);
}
/**
* Computes the indices of the first and
* and last list items to be rendered and
* the indices of the first and last
* 'critical' items within the viewport.
*
* @param {Object} source
* @param {Object} geometry
* @return {Object} {start, end, cStart, cEnd}
*/
function computeIndices(source, geometry) {
debug('compute indices', geometry.topPosition);
var criticalStart = source.getIndexAtPosition(geometry.topPosition);
var criticalEnd = source.getIndexAtPosition(geometry.topPosition +
geometry.viewportHeight);
var canPrerender = geometry.maxItemCount -
(criticalEnd - criticalStart) - 1;
var before = geometry.switchWindow;
var after = canPrerender - before;
var fullLength = source.getFullLength();
var lastIndex = fullLength && fullLength - 1;
var startIndex;
var endIndex;
var extra;
if (geometry.forward) {
startIndex = criticalStart - before;
endIndex = criticalEnd + after;
} else {
startIndex = criticalStart - after;
endIndex = criticalEnd + before;
}
if (startIndex < 0) {
extra = -startIndex;
startIndex = 0;
endIndex = Math.min(lastIndex, endIndex + extra);
}
if (endIndex > lastIndex) {
extra = endIndex - lastIndex;
endIndex = lastIndex;
startIndex = Math.max(0, startIndex - extra);
}
return {
cStart: criticalStart,
cEnd: criticalEnd,
start: startIndex,
end: endIndex
};
}
function recycle(items, start, end, action) {
debug('recycle', start, end, action);
var recyclableItems = [];
for (var i in items) {
if ((i < start) || (i > end)) recyclableItems.push(i);
}
// Put the items that are furthest away from the displayport edge
// at the end of the array.
recyclableItems.sort(function(a, b) {
return Math.abs(a - action) - Math.abs(b - action);
});
return recyclableItems;
}
function cleanUpPrerenderedItems(items, source) {
var fullLength = source.getFullLength();
for (var i in items) {
if (i >= fullLength) {
var item = items[i];
item.dataset.populated = false;
item.style.display = 'none';
}
}
}
function tryToPopulate(item, index, source, first) {
// debug('try to populate');
// The item was probably reused
if (item.dataset.index != index && !first) return;
// TODO: should be in a mutation block when !first
var populateResult = source.populateItem(item, index);
if (populateResult instanceof Promise) {
item.dataset.populated = false;
populateResult.then(
tryToPopulate.bind(
null,
item,
index,
source
)
);
return;
}
if (first) {
item.dataset.populated = true;
return;
}
// Promise-delayed population, once resolved
// Doing any pending itemDetail population on it
if (source.populateItemDetail && item.dataset.detailPopulated !== 'true') {
source.populateItemDetail(item, index);
item.dataset.detailPopulated = true;
}
// Revealing the populated item
debug('revealing populated item');
item.style.transition = 'opacity 0.2s linear';
schedule.transition(function() {
item.dataset.populated = true;
}, item, 'transitionend').then(function() {
debug('populated item revealed');
item.style.transition = '';
});
}
function placeItem(item, index, section, geometry, source, reload) {
if (item.dataset.index == index && !reload) {
// The item was probably reused
// debug('abort: item resused');
return;
}
item.dataset.position = source.getPositionForIndex(index);
item.dataset.index = index;
item.dataset.section = section;
var tweakedBy = item.dataset.tweakDelta;
if (tweakedBy) tweakTransform(item, tweakedBy);
else resetTransform(item);
}
function resetTransform(item) {
var position = item.dataset.position;
var transform = 'translateY(' + position + 'px)';
style(item, 'webkitTransform', transform);
style(item, 'transform', transform);
}
function tweakTransform(item, delta) {
debug('tweak transform', item, delta);
var position = ~~item.dataset.position + ~~delta;
var transform = 'translateY(' + position + 'px)';
style(item, 'webkitTransform', transform);
style(item, 'transform', transform);
item.dataset.tweakDelta = delta;
}
/**
* Internals
*/
function cleanInlineStyles(domItems) {
return schedule.mutation(function() {
for (var i = 0; i < domItems.length; i++) {
var item = domItems[i];
item.style.transition = '';
item.style.webkitTransition = '';
resetTransform(item);
}
domItems[0] && domItems[0].scrollTop; // flushing
});
}
function pushDown(domItems, geometry) {
if (!domItems.length) return Promise.resolve();
return schedule.transition(function() {
for (var i = 0; i < domItems.length; i++) {
var item = domItems[i];
item.style.transition = 'transform 0.15s ease-in';
item.style.webkitTransition = '-webkit-transform 0.15s ease-in';
tweakTransform(item, geometry.itemHeight);
}
}, domItems[0], 'transitionend');
}
function reveal(list) {
var newEl = list.querySelector('li.new');
return schedule.transition(function() {
newEl.style.transition = 'opacity 0.25s ease-out';
newEl.style.webkitTransition = 'opacity 0.25s ease-out';
setTimeout(function() {
newEl.classList.remove('new');
});
}, newEl, 'transitionend').then(function() {
newEl.style.transition = '';
newEl.style.webkitTransition = '';
});
}
/**
* Detects if scrolling appears 'blocked'.
*
* We're BUSY if:
*
* The scroll position moved more than
* twice the height of the viewport
* since the last 'scroll' event.
*
* We're NO LONGER BUSY if:
*
* The scroll position moved less than
* *half* the viewport since the last
* 'scroll' event.
*
* If neither of these conditions are
* met we return the previous value.
*
* @param {Boolean} wasBusy
* @param {Number} moved distance since last 'scroll'
* @param {Number} viewportHeight
* @return {Boolean}
*/
function isBusy(wasBusy, moved, viewportHeight) {
if (!wasBusy && moved > viewportHeight * 2) return true;
else if (wasBusy && moved && moved < viewportHeight / 2) return false;
else return wasBusy;
}
/**
* Detects if scrolling appears 'idle';
* meaning scrolling is slow or stopped.
*
* We're IDLE if:
*
* The scroll position moved less than
* 16th of the height of the viewport
* since the last 'scroll' event.
*
* We're NO LONGER IDLE if:
*
* The scroll position moved more than
* a *quarter* of the viewport since
* the last 'scroll' event.
*
* If neither of these conditions are
* met we return the previous value.
*
* @param {Boolean} wasIdle
* @param {Number} moved distance since last 'scroll'
* @param {Number} viewportHeight
* @return {Boolean}
*/
function isIdle(wasIdle, moved, viewportHeight) {
if (!wasIdle && moved && moved < viewportHeight / 16) return true;
else if (wasIdle && moved && moved > viewportHeight / 4) return false;
else return wasIdle;
}
/**
* Detects if the scroll moved
* forward or backwards.
*
* Sometimes the scoll position doesn't
* move at all between renders, in
* which case we just return the
* last known direction.
*
* @param {Boolean} wasForward
* @param {Number} delta
* @return {Boolean}
*/
function isForward(wasForward, delta) {
if (!delta) return wasForward;
else return delta > 0;
}
/**
* Utils
*/
function schedulerShim() {
var raf = window.requestAnimationFrame;
return {
mutation: function(block) { return Promise.resolve().then(block); },
transition: function(block, el, event, timeout) {
block();
return after(el, event, timeout || 500);
},
// Not sure if this should be different from .transition()
feedback: function(block, el, event, timeout) {
block();
return after(el, event, timeout || 500);
},
attachDirect: function(el, event, fn) {
fn._raffed = function(e) { raf(function() { fn(e); } ); };
on(el, event, fn._raffed);
},
detachDirect: function(el, event, fn) {
off(el, event, fn._raffed);
}
};
function after(target, event, timeout) {
return new Promise(function(resolve) {
var timer = timeout && setTimeout(cb, timeout);
on(target, event, cb);
function cb() {
off(target, event, cb);
clearTimeout(timer);
resolve();
}
});
}
}
// Shorthand
function on(el, name, fn) { el.addEventListener(name, fn); }
function off(el, name, fn) { el.removeEventListener(name, fn); }
/**
* Set a style property on an elements.
*
* First checks that the style property
* doesn't already match the given value
* to avoid Gecko doing unnecessary
* style-recalc.
*
* @param {HTMLElement} el
* @param {String} key
* @param {String} value
*/
function style(el, key, value) {
if (el.style[key] !== value) el.style[key] = value;
}
});})((typeof define)[0]=='f'&&define.amd?define:(function(n,n2,w){return(typeof module)[0]=='o'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){w[n];},m.exports,m);w[n]=w[n2]=m.exports;};})('FastList','fast-list',this));/*jshint ignore:line*/