;(function(define){define(function(require,exports,module){
/**
* Dependencies
*/
var component = require('gaia-component');
var FastList = require('fast-list');
var scheduler = FastList.scheduler;
var poplar = require('poplar');
require('gaia-sub-header');
/**
* Pointer abstractions
*/
var isTouch = 'ontouchstart' in window;
var touchcancel = isTouch ? 'touchcancel' : 'mousecancel';
var touchstart = isTouch ? 'touchstart' : 'mousedown';
var touchmove = isTouch ? 'touchmove' : 'mousemove';
var touchend = isTouch ? 'touchend' : 'mouseup';
/**
* Mini Logger
*
* @type {Function}
*/
var debug = 0 ? (...args) => console.log('[GaiaFastList]', ...args) : () => {};
/**
* Cache to persist content.
*
* We open ASAP as we need the cached
* HTML content for the first-paint.
* As it's async it can be done in
* the background.
*
* @type {Promise}
*/
var cachesOpen = caches.open('gfl');
/**
* Used to hide private properties behind.
*
* @type {Symbol}
*/
var keys = {
internal: Symbol(),
first: Symbol(),
img: Symbol()
};
/**
* Public prototype.
*
* @type {Object}
*/
var GaiaFastListProto = {
extensible: false,
/**
* Default store up to 5mb of images.
*
* @type {Number}
*/
imageCacheSize: 5e6,
/**
* Default store up to 500 images
* before entries start getting
* discarded.
*
* @type {Number}
*/
imageCacheLength: 500,
/**
* Called when the component is first created.
*
* @private
*/
created() {
debug('create');
this.setupShadowRoot();
this.caching = this.getAttribute('caching');
this.offset = this.getAttribute('offset');
this.picker = this.getAttribute('picker');
this.bottom = this.getAttribute('bottom');
this.top = this.getAttribute('top');
this[keys.internal] = new Internal(this);
},
/**
* Used to define configuration passed
* to internal FastList instantiation.
*
* NOTE: Must be called before `model`
* if defined.
*
* @param {Object} props
* @public
*/
configure(props) {
debug('configure');
this[keys.internal].configure(props);
},
/**
* Set a data model for the list to render.
*
* The returned Promise will resolve
* when rendering has *fully* completed.
*
* The `.rendered` Promise can be used
* to determine when the list is *visually*
* complete.
*
* @param {Array} value
* @return {Promise}
* @public
*/
setModel(value) {
return this[keys.internal].setModel(value);
},
/**
* Should be called by the user when
* they have finished incrementally
* updating there model.
*
* This acts as an internal hook used
* for updating caches.
*
* This only really concerns users
* who are using caching and fetching
* their model in chunks.
*
* @public
*/
cache() {
debug('cache');
if (!this.caching) return;
this[keys.internal].cachedHeight = null;
return this[keys.internal].updateCache();
},
/**
* Clear cached height and html.
*
* @public
*/
clearCache() {
debug('clear cache');
this[keys.internal].clearCache();
},
/**
* Smooth scrolls to the given position.
*
* @param {Number} y
* @return {Promise}
*/
scrollTo(y) {
return this[keys.internal].scrollTo(y);
},
/**
* Permanently destroy the component.
*
* @public
*/
destroy() {
this[keys.internal].destroy();
},
/**
* Public attributes/properties configuration
* used by gaia-component.js.
*
* @type {Object}
*/
attrs: {
rendered: {
get() { return this[keys.internal].rendered.promise; }
},
top: {
get() { return this._top; },
set(value) {
debug('set top', value);
if (value == null) return;
value = Number(value);
if (value === this._top) return;
this.setAttribute('top', value);
this._top = value;
}
},
bottom: {
get() { return this._bottom; },
set(value) {
debug('set bottom', value);
if (value == null) return;
value = Number(value);
if (value === this._bottom) return;
this.setAttribute('bottom', value);
this._bottom = value;
}
},
caching: {
get() { return this._caching; },
set(value) {
value = value || value === '';
if (value === this._caching) return;
if (value) this.setAttribute('caching', '');
else this.removeAttribute('caching');
this._caching = value;
}
},
offset: {
get() { return this._offset || 0; },
set(value) {
if (value == this._offset) return;
if (value) this.setAttribute('offset', value);
else this.removeAttribute('offset');
this._offset = Number(value);
}
},
scrollTop: {
get() { return this[keys.internal].getScrollTop(); },
set(value) { this[keys.internal].setScrollTop(value); }
},
minScrollHeight: {
get() { return this[keys.internal].list.style.minHeight; },
set(value) { this[keys.internal].list.style.minHeight = value; }
},
picker: {
get() { return this._picker; },
set(value) {
value = value || value === '';
if (value === this._picker) return;
if (value) this.setAttr('picker', '');
else this.removeAttr('picker');
this._picker = value;
}
}
},
/*jshint ignore:start*/
template: `
`,
/*jshint ignore:end*/
// test hooks
FastList: FastList
};
/**
* Private internals.
* @param {GaiaFastList} el
*/
function Internal(el) {
var shadow = el.shadowRoot;
this.el = el;
this.renderedCache = this.renderCache();
this.listCreated = new Deferred();
this.rendered = new Deferred();
this.images = {
list: [],
hash: {},
bytes: 0
};
this.els = {
list: shadow.querySelector('ul'),
picker: shadow.querySelector('.picker'),
overlay: shadow.querySelector('.overlay'),
overlayIcon: shadow.querySelector('.overlay > .icon'),
overlayText: shadow.querySelector('.overlay > .text'),
container: shadow.querySelector('.fast-list'),
listContent: shadow.querySelector('.fast-list content'),
pickerItems: []
};
// define property names for FastList
this.container = this.els.container;
this.list = this.els.list;
this.itemContainer = el;
this.configureTemplates();
this.setupPicker();
// don't memory leak ObjectURLs
addEventListener('pagehide', () => this.emptyImageCache());
debug('initialized');
}
Internal.prototype = {
headerHeight: 40,
itemHeight: 60,
/**
* Setting the model for the first
* time creates a new FastList. Setting
* it subsequent times rerenders
* the FastList with the new data.
*
* @param {Array} model
* @private
*/
setModel(model) {
debug('set model');
if (!model) return Promise.reject(new Error('model undefined'));
this.sections = this.sectionize(model);
this.model = model;
return !this.fastList
? this.createList()
: this.reloadData();
},
/**
* Creates the FastList.
*
* We add the fast-gradient last as it's
* expensive to paint (50-80ms) and only
* required for scrolling, not first paint.
*
* @return {Promise}
* @private
*/
createList() {
return this.renderedCache
.then(() => {
debug('create list');
this.fastList = new this.el.FastList(this);
return this.fastList.rendered;
})
.then(() => {
this.rendered.resolve();
this.els.list.style.transform = '';
this.removeCachedRender();
return this.fastList.complete;
})
.then(() => {
this.updateFastGradient();
this.listCreated.resolve();
});
},
/**
* Reloading the list will completely
* re-render the list.
*
* We must empty the image cache as data
* may have changed or item indexed
* may not longer map to new model.
*
* We must update the fast-gradient
* as scrollHeight determines whether
* fast-gradient needs to be painted.
*
* @private
*/
reloadData() {
return this.listCreated.promise
.then(() => {
debug('reload data');
this.emptyImageCache();
return this.fastList.reloadData();
})
.then(() => this.updateFastGradient());
},
/**
* Sorts items into sections using
* the user provided getSectionName()
*
* @param {Array} items
* @return {Object}
*/
sectionize(items) {
debug('sectionize');
var sectioned = !!this.getSectionName;
var count = 0;
var result = {};
for (var i = 0, l = items.length; i < l; i++) {
var item = items[i];
var section = sectioned && this.getSectionName(item);
// When a section is not defined, flag
// first list items and skip logic
if (!section) {
if (i === 0) item[keys.first] = true;
else if (item[keys.first]) delete item[keys.first];
continue;
}
// When there is no section yet
// we can assume that this item
// is the first item in the section.
if (!result[section]) {
result[section] = [];
item[keys.first] = true;
// Make sure that any previously
// assigned flags are removed as
// order of items can be changed.
} else if (item[keys.first]) {
delete item[keys.first];
}
result[section].push(item);
count++;
}
this.hasSections = !!count;
return this.hasSections && result;
},
/**
* Mixin properties/methods into the
* 'data-source' object passed to FastList.
*
* @param {Object} props
*/
configure(props) {
Object.assign(this, props);
},
/**
* Defines the templates to be used
* for the sections and items.
*
* Users can provide templates by
* placing in the root
* of their
*
* @private
*/
configureTemplates() {
var templateHeader = this.el.querySelector('template[header]');
var templateItem = this.el.querySelector('template[item]');
var noTemplates = !templateItem && !templateHeader;
// If no exact templates found, use unlabeled for item
if (noTemplates) templateItem = this.el.querySelector('template');
if (templateHeader) {
this.templateHeader = templateHeader.innerHTML;
templateHeader.remove();
}
if (templateItem) {
this.templateItem = templateItem.innerHTML;
templateItem.remove();
}
},
/**
* Called by FastList when it needs
* to create a list-item element.
*
* @return {HTMLElement}
*/
createItem() {
debug('create item');
this.parsedItem = this.parsedItem || poplar.parse(this.templateItem);
var el = poplar.create(this.parsedItem.cloneNode(true));
el[keys.img] = el.querySelector('img');
el.classList.add('gfl-item');
return el;
},
/**
* Called by FastList when it needs
* to create a section element.
*
* @return {HTMLElement}
*/
createSection(name) {
this.parsedSection = this.parsedSection
|| poplar.parse(this.templateHeader);
var header = poplar.create(this.parsedSection.cloneNode(true));
var section = document.createElement('div');
header.classList.add('gfl-header');
section.appendChild(header);
section.classList.add('gfl-section');
section.id = `gfl-section-${name}`;
return section;
},
/**
* Populates a list-item with data.
*
* If items were inflated from the HTML cache
* they won't yet be poplar elements; in
* which case we have to replace them
* before we can populate them with data.
*
* @param {HTMLElement} el
* @param {Number} i
*/
populateItem(el, i) {
var record = this.getRecordAt(i);
poplar.populate(el, record);
el.classList.toggle('first', !!record[keys.first]);
},
/**
* Populates the list-item image if one is
* present in the template and the user
* has configured `getItemImageSrc()`.
*
* .populateItemDetail() is run only when the
* list is 'idle' (stopped or slow) so that
* we don't harm scrolling performance.
*
* NOTE: It seems sometimes fast-list may
* be calling this function more than
* once per item. Need to look into this.
*
* @param {HTMLElement} el list-item node
* @param {Number} i index
* @private
*/
populateItemDetail(el, i) {
if (!this.getItemImageSrc) return;
debug('populate item detail', i);
var img = el[keys.img];
if (!img) return;
var record = this.getRecordAt(i);
var cached = this.getCachedImage(i);
// if we have the image cached
// we can just load it sync
if (cached) {
load(cached);
return;
}
// if we don't have the image we
// run the user's getter function
Promise.resolve(this.getItemImageSrc(record, i))
.then(result => {
if (!result) return;
// There is a chance that the item could have
// been recycled before the user was able to
// fetch the image. Abort here if that's the case.
if (el.dataset.index != i) return debug('item recycled');
var image = {
src: normalizeImageSrc(result),
bytes: result.size || result.length
};
this.cacheImage(image, i);
load(image);
}).catch(e => { throw e; });
/**
* Loads
* @param {[type]} image [description]
* @return {[type]} [description]
*/
function load(image) {
debug('load image', image);
img.src = image.src;
img.onload = () => {
debug('image loaded', i);
img.raf = requestAnimationFrame(() => {
img.raf = null;
img.style.transition = 'opacity 500ms';
img.style.opacity = 1;
});
};
}
},
/**
* Hides the
ready for it to be
* recycled for the next item.
*
* @param {HTMLElement} el list-item
* @param {Number} i index
* @private
*/
unpopulateItemDetail(el, i) {
if (!this.getItemImageSrc) return;
debug('unpopulate item detail');
var img = el[keys.img];
if (!img) return;
// Hide image instantly
img.style.transition = 'none';
img.style.opacity = 0;
debug('raf', img.raf);
// Clear any pending callbacks
if (img.raf) cancelAnimationFrame(img.raf);
img.onload = img.raf = null;
},
/**
* Cache an image in memory so that
* is can be quickly retrieved next
* time the list-item needs to be
* rendered.
*
* If the user has set the length/size
* of the cache to falsy then we don't
* do any caching.
*
* TODO: Some list items may share the
* same image src. If we have a matching
* src in already in the cache we could
* reuse that object to save memory.
*
* Although this gets tricky when we want
* to revokeObjectURL() and don't know how
* many list-items depend on it.
*
* @param {Number} index
* @param {Blob|String} raw
* @return {Object}
* @private
*/
cacheImage(image, index) {
if (!this.el.imageCacheLength) return;
if (!this.el.imageCacheSize) return;
if (this.images.hash[index]) return;
this.images.hash[index] = image;
this.images.list.push(index);
this.images.bytes += image.bytes;
this.checkImageCacheLimit();
debug('image cached', image, this.images.bytes);
return image;
},
/**
* Attempt to fetch an image
* for the given item index.
*
* @param {Number} index
* @return {Object|*}
* @private
*/
getCachedImage(index) {
debug('get cached image', index);
return this.images.hash[index];
},
/**
* Check there is space remaining in
* the image cache discarding entries
* when we're full.
*
* The cache is full when either the
* size (bytes) or the length is breached.
* We use length as well as bytes as we
* don't want to store 1000s of strings
* in memory.
*
* When a limit is breached, we discard
* half of the cached images not currently
* in use, then we checkImageCacheLimit()
* once more.
*
* We aim to keep a relatively full cache
* to keep things fast, but don't want to
* be discarding every time a new image
* is loaded.
*
* @private
*/
checkImageCacheLimit() {
var cachedImages = this.images.list.length;
var exceeded = this.images.bytes > this.el.imageCacheSize
|| cachedImages > this.el.imageCacheLength;
if (!exceeded) return;
debug('image cache limit exceeded', exceeded);
var itemCount = this.fastList.geometry.maxItemCount;
var toDiscard = (cachedImages - itemCount) / 2;
// prevent infinite loop when
// imageCacheLength < maxItemCount
if (toDiscard <= 0) return;
debug('discarding: ', toDiscard);
while (toDiscard-- > 0) {
this.discardOldestImage();
debug('bytes used', this.images.bytes);
}
// double-check we've freed enough memory
this.checkImageCacheLimit();
},
/**
* Discard the oldest image in
* the image cache.
*
* When an image has `bytes` we assume
* it was a Blob and revokeObjectURL()
* to make sure we don't memory leak.
*
* @return {Boolean} success/falure
* @private
*/
discardOldestImage() {
debug('discard oldest image');
if (!this.images.list.length) return false;
var index = this.images.list.shift();
var image = this.images.hash[index];
if (image.bytes) {
URL.revokeObjectURL(image.src);
this.images.bytes -= image.bytes;
debug('revoked url', image.src);
}
delete this.images.hash[index];
return true;
},
/**
* Completely empty the image cache.
*
* @private
*/
emptyImageCache() {
while (this.images.list.length) this.discardOldestImage();
},
/**
* Called by FastList when it needs
* to populate a section with content.
*
* @param {HTMLElement} el
* @param {String} section
*/
populateSection(el, section) {
var title = el.firstChild;
poplar.populate(title, { section: section });
},
/**
* Called by FastList when it needs to
* know the height of the list viewport.
*
* If the user has provided `top` and `bottom`
* attributes we can calculate this value
* for free, else we must force a reflow
* by using `clientHeight`.
*
* NOTE: We use the innerHeight of the `parent`
* window to prevent forcing a reflow when
* gaia-fast-list is inside an iframe. This
* means that offsets must be relative to
* viewport *not* the closest window.
*
* @return {Number}
*/
getViewportHeight() {
debug('get viewport height');
var bottom = this.el.bottom;
var top = this.el.top;
if (top != null && bottom != null) {
return parent.innerHeight - top - bottom;
}
return parseInt(this.el.style.height)
|| this.el.clientHeight;
},
getSections() {
return Object.keys(this.sections || {});
},
getSectionHeaderHeight() {
return this.hasSections ? this.headerHeight : 0;
},
getFullSectionHeight(key) {
return this.sections[key].length * this.getItemHeight();
},
getFullSectionLength(key) {
return this.sections[key].length;
},
getRecordAt(index) {
return this.model[index];
},
// overwrite to create sections
// IDEA: We could accept an Object
// as a model and use the keys as
// section names. This would probably
// require the user do some additional
// formatting before setting the model.
getSectionName: undefined,
getSectionFor(index) {
var item = this.getRecordAt(index);
return this.getSectionName && this.getSectionName(item);
},
eachSection(fn) {
var sections = this.getSections();
var result;
if (sections.length) {
for (var key in this.sections) {
result = fn(key, this.sections[key]);
if (result !== undefined) { return result; }
}
} else {
return fn(null, this.model);
}
},
getIndexAtPosition(pos) {
// debug('get index at position', pos);
var sections = this.sections || [this.model];
var headerHeight = this.getSectionHeaderHeight();
var itemHeight = this.getItemHeight();
var fullLength = this.getFullLength();
var lastIndex = fullLength - 1;
var index = 0;
// sometimes the list is empty
if (!fullLength) return index;
for (var name in sections) {
var items = sections[name];
var sectionHeight = items.length * itemHeight;
pos -= headerHeight;
// If not in this section, jump to next
if (pos > sectionHeight) {
pos -= sectionHeight;
index += items.length;
continue;
}
// Each item in section
for (var i = 0; i < items.length; i++) {
pos -= itemHeight;
if (pos <= 0 || index === fullLength - 1) break; // found it!
else index++; // continue
}
}
// Can't be more than the last index
index = Math.min(index, lastIndex);
// debug('got index', index);
return index;
},
getPositionForIndex(index) {
// debug('get position for index', index);
var sections = this.sections || [this.model];
var headerHeight = this.getSectionHeaderHeight();
var itemHeight = this.itemHeight;
var top = this.el.offset;
var length;
for (var name in sections) {
length = sections[name].length;
top += headerHeight;
if (index < length) {
top += index * itemHeight;
break;
}
index -= length;
top += length * itemHeight;
}
// debug('got position', top);
return top;
},
/**
* Jump scroll position to the top of a section.
*
* If a section of the given name is not
* found the scroll postion will not
* be changed.
*
* @param {String} name
* @private
*/
jumpToId(id) {
debug('jump to id', id);
if (!id) return;
var children = this.els.listContent.getDistributedNodes();
var found = false;
var offset = 0;
for (var i = 0, l = children.length; i < l; i++) {
var child = children[i];
// Skip text-nodes
if (!child.tagName) continue;
if (child.id === id) {
debug('found section', child);
found = true;
break;
}
// skip