From 4a64d0ee171b442b3b4f1354b3ef641b2a25cd86 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 19 Nov 2021 22:49:46 +0100 Subject: [PATCH] WIP --- src/platform/web/ui/general/ItemRange.js | 115 ---------- src/platform/web/ui/general/ItemRange.ts | 210 ++++++++++++++++++ src/platform/web/ui/general/LazyListView.ts | 200 +++++++++++++++++ src/platform/web/ui/general/ListView.ts | 53 +++-- .../{LazyListView.js => foo-LazyListView.js} | 0 src/platform/web/ui/general/utils.ts | 4 + .../ui/session/rightpanel/MemberListView.js | 2 +- 7 files changed, 450 insertions(+), 134 deletions(-) delete mode 100644 src/platform/web/ui/general/ItemRange.js create mode 100644 src/platform/web/ui/general/ItemRange.ts create mode 100644 src/platform/web/ui/general/LazyListView.ts rename src/platform/web/ui/general/{LazyListView.js => foo-LazyListView.js} (100%) diff --git a/src/platform/web/ui/general/ItemRange.js b/src/platform/web/ui/general/ItemRange.js deleted file mode 100644 index 494f5dcb..00000000 --- a/src/platform/web/ui/general/ItemRange.js +++ /dev/null @@ -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}; - } -} diff --git a/src/platform/web/ui/general/ItemRange.ts b/src/platform/web/ui/general/ItemRange.ts new file mode 100644 index 00000000..66db6078 --- /dev/null +++ b/src/platform/web/ui/general/ItemRange.ts @@ -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(it: IterableIterator, 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 { + return new RangeIterator(this); + } +} + +class RangeIterator implements Iterator { + private idx: number; + constructor(private readonly range: Range) { + this.idx = range.start - 1; + } + + next(): IteratorResult { + 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() { + + } +} diff --git a/src/platform/web/ui/general/LazyListView.ts b/src/platform/web/ui/general/LazyListView.ts new file mode 100644 index 00000000..65c53c2d --- /dev/null +++ b/src/platform/web/ui/general/LazyListView.ts @@ -0,0 +1,200 @@ +/* +Copyright 2020 Bruno Windels + +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 extends IParentOptions { + itemHeight: number; + overflowMargin?: number; + overflowItems?: number; +} + +export class LazyListView extends ListView { + private renderRange?: ItemRange; + private height?: number; + private itemHeight: number; + private overflowItems: number; + private scrollContainer?: Element; + + constructor( + {itemHeight, overflowMargin = 5, overflowItems = 20,...options}: IOptions, + 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); + } + } +} diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index f76bd961..9d639098 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -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 { +export interface IOptions { list: ObservableList, onItemClick?: (childView: V, evt: UIEvent) => void, className?: string, @@ -28,17 +28,17 @@ interface IOptions { parentProvidesUpdates?: boolean } -export class ListView implements IView { +export class ListView implements IView, IListObserver { private _onItemClick?: (childView: V, evt: UIEvent) => void; - private _list: ObservableList; 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; + protected _childInstances?: V[]; constructor( {list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}: IOptions, @@ -145,31 +145,48 @@ export class ListView 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); diff --git a/src/platform/web/ui/general/LazyListView.js b/src/platform/web/ui/general/foo-LazyListView.js similarity index 100% rename from src/platform/web/ui/general/LazyListView.js rename to src/platform/web/ui/general/foo-LazyListView.js diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index 7eb1d7f9..f8d407e9 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -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 = ''; +} diff --git a/src/platform/web/ui/session/rightpanel/MemberListView.js b/src/platform/web/ui/session/rightpanel/MemberListView.js index 6c3d252e..6096f919 100644 --- a/src/platform/web/ui/session/rightpanel/MemberListView.js +++ b/src/platform/web/ui/session/rightpanel/MemberListView.js @@ -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{