This commit is contained in:
Bruno Windels 2021-11-19 22:49:46 +01:00
parent d625d57aa4
commit 4a64d0ee17
7 changed files with 450 additions and 134 deletions

View file

@ -1,115 +0,0 @@
/*
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 {createEnum} from "../../../../utils/enum.js";
export const ScrollDirection = createEnum("upwards", "downwards");
export 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.lastIndex;
}
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 - 1;
}
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) + 1, 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};
}
}

View file

@ -0,0 +1,210 @@
/*
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.
*/
// start is included in the range,
// end is excluded,
// so [2, 2[ means an empty range
class Range {
constructor(
public readonly start: number,
public readonly end: number
) {}
get length() {
return this.end - this.start;
}
contains(range: Range): boolean {
return range.start >= this.start && range.end <= this.end;
}
containsIndex(idx: number): boolean {
return idx >= this.start && idx < this.end;
}
intersects(range: Range): boolean {
return range.start < this.end && this.start < range.end;
}
forEach(callback: ((i: number) => void)) {
for (let i = this.start; i < this.end; i += 1) {
callback(i);
}
}
forEachInIterator<T>(it: IterableIterator<T>, callback: ((T, i: number) => void)) {
let i = 0;
for (i = 0; i < this.start; i += 1) {
it.next();
}
for (i = 0; i < this.length; i += 1) {
const result = it.next();
if (result.done) {
break;
} else {
callback(result.value, this.start + i);
}
}
}
[Symbol.iterator](): Iterator<number> {
return new RangeIterator(this);
}
}
class RangeIterator implements Iterator<number> {
private idx: number;
constructor(private readonly range: Range) {
this.idx = range.start - 1;
}
next(): IteratorResult<number> {
if (this.idx < (this.range.end - 1)) {
this.idx += 1;
return {value: this.idx, done: false};
} else {
return {value: undefined, done: true};
}
}
}
export function tests() {
return {
"length": assert => {
const a = new Range(2, 5);
assert.equal(a.length, 3);
},
"iterator": assert => {
assert.deepEqual(Array.from(new Range(2, 5)), [2, 3, 4]);
},
"containsIndex": assert => {
const a = new Range(2, 5);
assert.equal(a.containsIndex(0), false);
assert.equal(a.containsIndex(1), false);
assert.equal(a.containsIndex(2), true);
assert.equal(a.containsIndex(3), true);
assert.equal(a.containsIndex(4), true);
assert.equal(a.containsIndex(5), false);
assert.equal(a.containsIndex(6), false);
},
"intersects returns false for touching ranges": assert => {
const a = new Range(2, 5);
const b = new Range(5, 10);
assert.equal(a.intersects(b), false);
assert.equal(b.intersects(a), false);
},
"intersects returns false": assert => {
const a = new Range(2, 5);
const b = new Range(50, 100);
assert.equal(a.intersects(b), false);
assert.equal(b.intersects(a), false);
},
"intersects returns true for 1 overlapping item": assert => {
const a = new Range(2, 5);
const b = new Range(4, 10);
assert.equal(a.intersects(b), true);
assert.equal(b.intersects(a), true);
},
"contains beyond left edge": assert => {
const a = new Range(2, 5);
const b = new Range(1, 3);
assert.equal(a.contains(b), false);
},
"contains at left edge": assert => {
const a = new Range(2, 5);
const b = new Range(2, 3);
assert.equal(a.contains(b), true);
},
"contains between edges": assert => {
const a = new Range(2, 5);
const b = new Range(3, 4);
assert.equal(a.contains(b), true);
},
"contains at right edge": assert => {
const a = new Range(2, 5);
const b = new Range(3, 5);
assert.equal(a.contains(b), true);
},
"contains beyond right edge": assert => {
const a = new Range(2, 5);
const b = new Range(4, 6);
assert.equal(a.contains(b), false);
},
"contains for non-intersecting ranges": assert => {
const a = new Range(2, 5);
const b = new Range(5, 6);
assert.equal(a.contains(b), false);
},
"forEachInIterator with more values available": assert => {
const callbackValues: {v: string, i: number}[] = [];
const values = ["a", "b", "c", "d", "e", "f"];
const it = values[Symbol.iterator]();
new Range(2, 5).forEachInIterator(it, (v, i) => callbackValues.push({v, i}));
assert.deepEqual(callbackValues, [
{v: "c", i: 2},
{v: "d", i: 3},
{v: "e", i: 4},
]);
},
"forEachInIterator with fewer values available": assert => {
const callbackValues: {v: string, i: number}[] = [];
const values = ["a", "b", "c"];
const it = values[Symbol.iterator]();
new Range(2, 5).forEachInIterator(it, (v, i) => callbackValues.push({v, i}));
assert.deepEqual(callbackValues, [
{v: "c", i: 2},
]);
},
};
}
export class ItemRange extends Range {
constructor(
start: number,
end: number,
public readonly totalLength: number
) {
super(start, end);
}
expand(amount: number): ItemRange {
// don't expand ranges that won't render anything
if (this.length === 0) {
return this;
}
const topGrow = Math.min(amount, this.start);
const bottomGrow = Math.min(amount, this.totalLength - this.end);
return new ItemRange(
this.start - topGrow,
this.end + topGrow + bottomGrow,
this.totalLength,
);
}
static fromViewport(listLength: number, itemHeight: number, listHeight: number, scrollTop: number) {
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), listLength);
const itemsAfterTop = listLength - topCount;
const visibleItems = listHeight !== 0 ? Math.ceil(listHeight / itemHeight) : 0;
const renderCount = Math.min(visibleItems, itemsAfterTop);
return new ItemRange(topCount, topCount + renderCount, listLength);
}
missingFrom() {
}
}

View file

@ -0,0 +1,200 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {tag} from "./html";
import {removeChildren, mountView} from "./utils";
import {ItemRange} from "./ItemRange";
import {ListView, IOptions as IParentOptions} from "./ListView";
import {IView} from "./types";
export interface IOptions<T, V> extends IParentOptions<T, V> {
itemHeight: number;
overflowMargin?: number;
overflowItems?: number;
}
export class LazyListView<T, V extends IView> extends ListView<T, V> {
private renderRange?: ItemRange;
private height?: number;
private itemHeight: number;
private overflowItems: number;
private scrollContainer?: Element;
constructor(
{itemHeight, overflowMargin = 5, overflowItems = 20,...options}: IOptions<T, V>,
childCreator: (value: T) => V
) {
super(options, childCreator);
this.itemHeight = itemHeight;
this.overflowItems = overflowItems;
// TODO: this.overflowMargin = overflowMargin;
}
handleEvent(e: Event) {
if (e.type === "scroll") {
this.handleScroll();
} else {
super.handleEvent(e);
}
}
handleScroll() {
const visibleRange = this._getVisibleRange();
// don't contain empty ranges
// as it will prevent clearing the list
// once it is scrolled far enough out of view
if (visibleRange.length !== 0 && !this.renderRange!.contains(visibleRange)) {
const prevRenderRange = this.renderRange!;
this.renderRange = visibleRange.expand(this.overflowItems);
this.renderUpdate(prevRenderRange, this.renderRange);
}
}
override async loadList() {
/*
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));
if (!this._list) {
return;
}
const visibleRange = this._getVisibleRange();
this.renderRange = visibleRange.expand(this.overflowItems);
this._childInstances = [];
this._subscription = this._list.subscribe(this);
this.reRenderFullRange(this.renderRange);
}
private _getVisibleRange() {
const {clientHeight, scrollTop} = this.root()!;
if (clientHeight === 0) {
throw new Error("LazyListView height is 0");
}
return ItemRange.fromViewport(this._list.length, this.itemHeight, clientHeight, scrollTop);
}
private reRenderFullRange(range: ItemRange) {
removeChildren(this._listElement!);
const fragment = document.createDocumentFragment();
const it = this._list[Symbol.iterator]();
this._childInstances!.length = 0;
range.forEachInIterator(it, item => {
const child = this._childCreator(item);
this._childInstances!.push(child);
fragment.appendChild(mountView(child, this._mountArgs));
});
this._listElement!.appendChild(fragment);
this.adjustPadding(range);
}
private renderUpdate(prevRange: ItemRange, newRange: ItemRange) {
if (newRange.intersects(prevRange)) {
for (const idxInList of prevRange) {
// TODO: we need to make sure we keep childInstances in order so the indices lign up.
// Perhaps we should join both ranges and see in which range it appears and either add or remove?
if (!newRange.containsIndex(idxInList)) {
const localIdx = idxInList - prevRange.start;
this.removeChild(localIdx);
}
}
const addedRange = newRange.missingFrom(prevRange);
addedRange.forEachInIterator(this._list[Symbol.iterator](), (item, idxInList) => {
const localIdx = idxInList - newRange.start;
this.addChild(localIdx, item);
});
this.adjustPadding(newRange);
} else {
this.reRenderFullRange(newRange);
}
}
private adjustPadding(range: ItemRange) {
const paddingTop = range.start * this.itemHeight;
const paddingBottom = (range.totalLength - range.end) * this.itemHeight;
const style = this.scrollContainer!.style;
style.paddingTop = `${paddingTop}px`;
style.paddingBottom = `${paddingBottom}px`;
}
mount() {
const listElement = super.mount();
this.scrollContainer = tag.div({className: "LazyListParent"}, listElement);
/*
Hooking to scroll events can be expensive.
Do we need to do more (like event throttling)?
*/
this.scrollContainer.addEventListener("scroll", this);
return this.scrollContainer;
}
unmount() {
this.root()!.removeEventListener("scroll", this);
this.scrollContainer = undefined;
super.unmount();
}
root(): Element | undefined {
return this.scrollContainer;
}
private get _listElement(): Element | undefined {
return super.root();
}
onAdd(idx: number, value: T) {
// TODO: update totalLength in renderRange
const result = this.renderRange!.queryAdd(idx);
if (result.addIdx !== -1) {
this.addChild(result.addIdx, value);
}
if (result.removeIdx !== -1) {
this.removeChild(result.removeIdx);
}
}
onRemove(idx: number, value: T) {
// TODO: update totalLength in renderRange
const result = this.renderRange!.queryRemove(idx);
if (result.removeIdx !== -1) {
this.removeChild(result.removeIdx);
}
if (result.addIdx !== -1) {
this.addChild(result.addIdx, value);
}
}
onMove(fromIdx: number, toIdx: number, value: T) {
const result = this.renderRange!.queryMove(fromIdx, toIdx);
if (result.moveFromIdx !== -1 && result.moveToIdx !== -1) {
this.moveChild(result.moveFromIdx, result.moveToIdx);
} else if (result.removeIdx !== -1) {
this.removeChild(result.removeIdx);
} else if (result.addIdx !== -1) {
this.addChild(result.addIdx, value);
}
}
onUpdate(i: number, value: T, params: any) {
const updateIdx = this.renderRange!.queryUpdate(i);
if (updateIdx !== -1) {
this.updateChild(updateIdx, value, params);
}
}
}

View file

@ -17,10 +17,10 @@ limitations under the License.
import {el} from "./html";
import {mountView, insertAt} from "./utils";
import {SubscriptionHandle} from "../../../../observable/BaseObservable";
import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList";
import {BaseObservableList as ObservableList, IListObserver} from "../../../../observable/list/BaseObservableList";
import {IView, IMountArgs} from "./types";
interface IOptions<T, V> {
export interface IOptions<T, V> {
list: ObservableList<T>,
onItemClick?: (childView: V, evt: UIEvent) => void,
className?: string,
@ -28,17 +28,17 @@ interface IOptions<T, V> {
parentProvidesUpdates?: boolean
}
export class ListView<T, V extends IView> implements IView {
export class ListView<T, V extends IView> implements IView, IListObserver<T> {
private _onItemClick?: (childView: V, evt: UIEvent) => void;
private _list: ObservableList<T>;
private _className?: string;
private _tagName: string;
private _root?: Element;
private _subscription?: SubscriptionHandle;
private _childCreator: (value: T) => V;
private _childInstances?: V[];
private _mountArgs: IMountArgs;
protected _subscription?: SubscriptionHandle;
protected _childCreator: (value: T) => V;
protected _mountArgs: IMountArgs;
protected _list: ObservableList<T>;
protected _childInstances?: V[];
constructor(
{list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}: IOptions<T, V>,
@ -145,31 +145,48 @@ export class ListView<T, V extends IView> implements IView {
}
onAdd(idx: number, value: T) {
const child = this._childCreator(value);
this._childInstances!.splice(idx, 0, child);
insertAt(this._root!, idx, mountView(child, this._mountArgs));
this.addChild(idx, value);
}
onRemove(idx: number, value: T) {
const [child] = this._childInstances!.splice(idx, 1);
this.removeChild(idx);
}
onMove(fromIdx: number, toIdx: number, value: T) {
this.moveChild(fromIdx, toIdx);
}
onUpdate(i: number, value: T, params: any) {
this.updateChild(i, value, params);
}
protected addChild(childIdx: number, value: T) {
const child = this._childCreator(value);
this._childInstances!.splice(childIdx, 0, child);
insertAt(this._root!, childIdx, mountView(child, this._mountArgs));
}
protected removeChild(childIdx: number) {
const [child] = this._childInstances!.splice(childIdx, 1);
child.root()!.remove();
child.unmount();
}
onMove(fromIdx: number, toIdx: number, value: T) {
const [child] = this._childInstances!.splice(fromIdx, 1);
this._childInstances!.splice(toIdx, 0, child);
protected moveChild(fromChildIdx: number, toChildIdx: number) {
const [child] = this._childInstances!.splice(fromChildIdx, 1);
this._childInstances!.splice(toChildIdx, 0, child);
child.root()!.remove();
insertAt(this._root!, toIdx, child.root()! as Element);
insertAt(this._root!, toChildIdx, child.root()! as Element);
}
onUpdate(i: number, value: T, params: any) {
protected updateChild(childIdx: number, value: T, params: any) {
if (this._childInstances) {
const instance = this._childInstances![i];
const instance = this._childInstances![childIdx];
instance && instance.update(value, params);
}
}
// TODO: is this the list or view index?
protected recreateItem(index: number, value: T) {
if (this._childInstances) {
const child = this._childCreator(value);

View file

@ -50,3 +50,7 @@ export function insertAt(parentNode: Element, idx: number, childNode: Node): voi
parentNode.insertBefore(childNode, nextDomNode);
}
}
export function removeChildren(parentNode: Element): void {
parentNode.innerHTML = '';
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {LazyListView} from "../../general/LazyListView.js";
import {LazyListView} from "../../general/LazyListView";
import {MemberTileView} from "./MemberTileView.js";
export class MemberListView extends LazyListView{