forked from mystiq/hydrogen-web
346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
/*
|
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import {el} from "./html";
|
|
import {mountView} from "./utils";
|
|
import {ListView} from "./ListView";
|
|
import {insertAt} from "./utils";
|
|
|
|
class ScrollDirection {
|
|
static get downwards() { return 1; }
|
|
static get upwards() { return -1; }
|
|
}
|
|
|
|
class ItemRange {
|
|
constructor(topCount, renderCount, bottomCount) {
|
|
this.topCount = topCount;
|
|
this.renderCount = renderCount;
|
|
this.bottomCount = bottomCount;
|
|
}
|
|
|
|
contains(range) {
|
|
// don't contain empty ranges
|
|
// as it will prevent clearing the list
|
|
// once it is scrolled far enough out of view
|
|
if (!range.renderCount && this.renderCount) {
|
|
return false;
|
|
}
|
|
return range.topCount >= this.topCount &&
|
|
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
|
|
}
|
|
|
|
containsIndex(idx) {
|
|
return idx >= this.topCount && idx <= (this.topCount + this.renderCount);
|
|
}
|
|
|
|
expand(amount) {
|
|
// don't expand ranges that won't render anything
|
|
if (this.renderCount === 0) {
|
|
return this;
|
|
}
|
|
|
|
const topGrow = Math.min(amount, this.topCount);
|
|
const bottomGrow = Math.min(amount, this.bottomCount);
|
|
return new ItemRange(
|
|
this.topCount - topGrow,
|
|
this.renderCount + topGrow + bottomGrow,
|
|
this.bottomCount - bottomGrow,
|
|
);
|
|
}
|
|
|
|
get lastIndex() {
|
|
return this.topCount + this.renderCount;
|
|
}
|
|
|
|
totalSize() {
|
|
return this.topCount + this.renderCount + this.bottomCount;
|
|
}
|
|
|
|
normalize(idx) {
|
|
/*
|
|
map index from list to index in rendered range
|
|
eg: if the index range of this._list is [0, 200] and we have rendered
|
|
elements in range [50, 60] then index 50 in list must map to index 0
|
|
in DOM tree/childInstance array.
|
|
*/
|
|
return idx - this.topCount;
|
|
}
|
|
|
|
scrollDirectionTo(range) {
|
|
return range.bottomCount < this.bottomCount ? ScrollDirection.downwards : ScrollDirection.upwards;
|
|
}
|
|
|
|
/**
|
|
* Check if this range intersects with another range
|
|
* @param {ItemRange} range The range to check against
|
|
* @param {ScrollDirection} scrollDirection
|
|
* @returns {Boolean}
|
|
*/
|
|
intersects(range) {
|
|
return !!Math.max(0, Math.min(this.lastIndex, range.lastIndex) - Math.max(this.topCount, range.topCount));
|
|
}
|
|
|
|
diff(range) {
|
|
/**
|
|
* Range-1
|
|
* |----------------------|
|
|
* Range-2
|
|
* |---------------------|
|
|
* <-------><------------><------->
|
|
* bisect-1 intersection bisect-2
|
|
*/
|
|
const scrollDirection = this.scrollDirectionTo(range);
|
|
if (!this.intersects(range)) {
|
|
// There is no intersection between the ranges; which can happen if you scroll really fast
|
|
// In this case, we need to do full render of the new range
|
|
const toRemove = { start: this.topCount, end: this.lastIndex };
|
|
const toAdd = { start: range.topCount, end: range.lastIndex };
|
|
return {toRemove, toAdd, scrollDirection};
|
|
}
|
|
const bisection1 = {start: Math.min(this.topCount, range.topCount), end: Math.max(this.topCount, range.topCount) - 1};
|
|
const bisection2 = {start: Math.min(this.lastIndex, range.lastIndex), end: Math.max(this.lastIndex, range.lastIndex)};
|
|
// When scrolling down, bisection1 needs to be removed and bisection2 needs to be added
|
|
// When scrolling up, vice versa
|
|
const toRemove = scrollDirection === ScrollDirection.downwards ? bisection1 : bisection2;
|
|
const toAdd = scrollDirection === ScrollDirection.downwards ? bisection2 : bisection1;
|
|
return {toAdd, toRemove, scrollDirection};
|
|
}
|
|
}
|
|
|
|
export class LazyListView extends ListView {
|
|
constructor({itemHeight, overflowMargin = 5, overflowItems = 20,...options}, childCreator) {
|
|
super(options, childCreator);
|
|
this._itemHeight = itemHeight;
|
|
this._overflowMargin = overflowMargin;
|
|
this._overflowItems = overflowItems;
|
|
}
|
|
|
|
_getVisibleRange() {
|
|
const length = this._list ? this._list.length : 0;
|
|
const scrollTop = this._parent.scrollTop;
|
|
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / this._itemHeight)), length);
|
|
const itemsAfterTop = length - topCount;
|
|
const visibleItems = this._height !== 0 ? Math.ceil(this._height / this._itemHeight) : 0;
|
|
const renderCount = Math.min(visibleItems, itemsAfterTop);
|
|
const bottomCount = itemsAfterTop - renderCount;
|
|
return new ItemRange(topCount, renderCount, bottomCount);
|
|
}
|
|
|
|
_renderIfNeeded(forceRender = false) {
|
|
/*
|
|
forceRender only because we don't optimize onAdd/onRemove yet.
|
|
Ideally, onAdd/onRemove should only render whatever has changed + update padding + update renderRange
|
|
*/
|
|
const range = this._getVisibleRange();
|
|
const intersectRange = range.expand(this._overflowMargin);
|
|
const renderRange = range.expand(this._overflowItems);
|
|
// only update render Range if the new range + overflowMargin isn't contained by the old anymore
|
|
// or if we are force rendering
|
|
if (forceRender || !this._renderRange.contains(intersectRange)) {
|
|
console.log("new", renderRange);
|
|
console.log("current", this._renderRange);
|
|
console.log("diff", this._renderRange.diff(renderRange));
|
|
this._renderElementsInRange(renderRange);
|
|
}
|
|
}
|
|
|
|
async _initialRender() {
|
|
/*
|
|
Wait two frames for the return from mount() to be inserted into DOM.
|
|
This should be enough, but if this gives us trouble we can always use
|
|
MutationObserver.
|
|
*/
|
|
await new Promise(r => requestAnimationFrame(r));
|
|
await new Promise(r => requestAnimationFrame(r));
|
|
|
|
this._height = this._parent.clientHeight;
|
|
if (this._height === 0) { console.error("LazyListView could not calculate parent height."); }
|
|
const initialRange = this._getVisibleRange();
|
|
const initialRenderRange = initialRange.expand(this._overflowItems);
|
|
this._renderRange = new ItemRange(0, 0, 0);
|
|
this._renderElementsInRange(initialRenderRange);
|
|
}
|
|
|
|
_itemsFromList({start, end}) {
|
|
const array = [];
|
|
let i = 0;
|
|
for (const item of this._list) {
|
|
if (i >= start && i < end) {
|
|
array.push(item);
|
|
}
|
|
i = i + 1;
|
|
}
|
|
return array;
|
|
}
|
|
|
|
_itemAtIndex(idx) {
|
|
let i = 0;
|
|
for (const item of this._list) {
|
|
if (i === idx) {
|
|
return item;
|
|
}
|
|
i = i + 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_adjustPadding(range) {
|
|
const { topCount, bottomCount } = range;
|
|
const paddingTop = topCount * this._itemHeight;
|
|
const paddingBottom = bottomCount * this._itemHeight;
|
|
this._root.style.paddingTop = `${paddingTop}px`;
|
|
this._root.style.paddingBottom = `${paddingBottom}px`;
|
|
}
|
|
|
|
_renderedFragment(items, childInstanceModifier) {
|
|
const fragment = document.createDocumentFragment();
|
|
for (const item of items) {
|
|
const view = this._childCreator(item);
|
|
childInstanceModifier(view);
|
|
fragment.appendChild(mountView(view, this._mountArgs));
|
|
}
|
|
return fragment;
|
|
}
|
|
|
|
_renderElementsInRange(range) {
|
|
const diff = this._renderRange.diff(range);
|
|
const renderedItems = this._itemsFromList(diff.toAdd);
|
|
this._adjustPadding(range);
|
|
|
|
const {start, end} = diff.toRemove;
|
|
const normalizedStart = this._renderRange.normalize(start);
|
|
this._childInstances.splice(normalizedStart, end - start + 1).forEach(child => this._removeChild(child));
|
|
|
|
if (diff.scrollDirection === ScrollDirection.downwards) {
|
|
const fragment = this._renderedFragment(renderedItems, view => this._childInstances.push(view));
|
|
this._root.appendChild(fragment);
|
|
}
|
|
else {
|
|
const fragment = this._renderedFragment(renderedItems, view => this._childInstances.unshift(view));
|
|
this._root.insertBefore(fragment, this._root.firstChild);
|
|
}
|
|
this._renderRange = range;
|
|
}
|
|
|
|
mount() {
|
|
const root = super.mount();
|
|
this._subscription = this._list.subscribe(this);
|
|
this._childInstances = [];
|
|
this._parent = el("div", {className: "LazyListParent"}, root);
|
|
/*
|
|
Hooking to scroll events can be expensive.
|
|
Do we need to do more (like event throttling)?
|
|
*/
|
|
this._parent.addEventListener("scroll", () => this._renderIfNeeded());
|
|
this._initialRender();
|
|
return this._parent;
|
|
}
|
|
|
|
update(attributes) {
|
|
this._renderRange = null;
|
|
super.update(attributes);
|
|
this._childInstances = [];
|
|
this._initialRender();
|
|
}
|
|
|
|
loadList() {
|
|
// We don't render the entire list; so nothing to see here.
|
|
}
|
|
|
|
_removeChild(child) {
|
|
child.root().remove();
|
|
child.unmount();
|
|
}
|
|
|
|
// If size of the list changes, re-render
|
|
onAdd() {
|
|
this._renderIfNeeded(true);
|
|
}
|
|
|
|
onRemove() {
|
|
this._renderIfNeeded(true);
|
|
}
|
|
|
|
onUpdate(idx, value, params) {
|
|
if (this._renderRange.containsIndex(idx)) {
|
|
const normalizedIdx = this._renderRange.normalize(idx);
|
|
super.onUpdate(normalizedIdx, value, params);
|
|
}
|
|
}
|
|
|
|
recreateItem(idx, value) {
|
|
if (this._renderRange.containsIndex(idx)) {
|
|
const normalizedIdx = this._renderRange.normalize(idx);
|
|
super.recreateItem(normalizedIdx, value)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render additional element from top or bottom to offset the outgoing element
|
|
*/
|
|
_renderExtraOnMove(fromIdx, toIdx) {
|
|
const {topCount, renderCount} = this._renderRange;
|
|
if (toIdx < fromIdx) {
|
|
// Element is moved up the list, so render element from top boundary
|
|
const index = topCount;
|
|
const child = this._childCreator(this._itemAtIndex(index));
|
|
this._childInstances.unshift(child);
|
|
this._root.insertBefore(mountView(child, this._mountArgs), this._root.firstChild);
|
|
}
|
|
else {
|
|
// Element is moved down the list, so render element from bottom boundary
|
|
const index = topCount + renderCount - 1;
|
|
const child = this._childCreator(this._itemAtIndex(index));
|
|
this._childInstances.push(child);
|
|
this._root.appendChild(mountView(child, this._mountArgs));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove an element from top or bottom to make space for the incoming element
|
|
*/
|
|
_removeElementOnMove(fromIdx, toIdx) {
|
|
// If element comes from the bottom, remove element at bottom and vice versa
|
|
const child = toIdx < fromIdx ? this._childInstances.pop() : this._childInstances.shift();
|
|
this._removeChild(child);
|
|
}
|
|
|
|
onMove(fromIdx, toIdx, value) {
|
|
const fromInRange = this._renderRange.containsIndex(fromIdx);
|
|
const toInRange = this._renderRange.containsIndex(toIdx);
|
|
const normalizedFromIdx = this._renderRange.normalize(fromIdx);
|
|
const normalizedToIdx = this._renderRange.normalize(toIdx);
|
|
if (fromInRange && toInRange) {
|
|
super.onMove(normalizedFromIdx, normalizedToIdx, value);
|
|
}
|
|
else if (fromInRange && !toInRange) {
|
|
this.onBeforeListChanged();
|
|
const [child] = this._childInstances.splice(normalizedFromIdx, 1);
|
|
this._removeChild(child);
|
|
this._renderExtraOnMove(fromIdx, toIdx);
|
|
this.onListChanged();
|
|
}
|
|
else if (!fromInRange && toInRange) {
|
|
this.onBeforeListChanged();
|
|
const child = this._childCreator(value);
|
|
this._removeElementOnMove(fromIdx, toIdx);
|
|
this._childInstances.splice(normalizedToIdx, 0, child);
|
|
insertAt(this._root, normalizedToIdx, mountView(child, this._mountArgs));
|
|
this.onListChanged();
|
|
}
|
|
}
|
|
|
|
}
|