Merge pull request #592 from vector-im/bwindels/lazylist-enhancements

Lazylist enhancements
This commit is contained in:
Bruno Windels 2021-11-23 14:35:18 +01:00 committed by GitHub
commit 93abbe83e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1031 additions and 300 deletions

View file

@ -44,6 +44,14 @@ export class ObservableArray extends BaseObservableList {
this.emitAdd(idx, item); this.emitAdd(idx, item);
} }
move(fromIdx, toIdx) {
if (fromIdx < this._items.length && toIdx < this._items.length) {
const [item] = this._items.splice(fromIdx, 1);
this._items.splice(toIdx, 0, item);
this.emitMove(fromIdx, toIdx, item);
}
}
update(idx, item, params = null) { update(idx, item, params = null) {
if (idx < this._items.length) { if (idx < this._items.length) {
this._items[idx] = item; this._items[idx] = item;

View file

@ -1,280 +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 {el} from "./html";
import {mountView} from "./utils";
import {ListView} from "./ListView";
import {insertAt} from "./utils";
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,
);
}
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;
}
}
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)) {
this._renderRange = renderRange;
this._renderElementsInRange();
}
}
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 range = this._getVisibleRange();
const renderRange = range.expand(this._overflowItems);
this._renderRange = renderRange;
this._renderElementsInRange();
}
_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;
}
_renderElementsInRange() {
const { topCount, renderCount, bottomCount } = this._renderRange;
const paddingTop = topCount * this._itemHeight;
const paddingBottom = bottomCount * this._itemHeight;
const renderedItems = this._itemsFromList(topCount, topCount + renderCount);
this._root.style.paddingTop = `${paddingTop}px`;
this._root.style.paddingBottom = `${paddingBottom}px`;
for (const child of this._childInstances) {
this._removeChild(child);
}
this._childInstances = [];
const fragment = document.createDocumentFragment();
for (const item of renderedItems) {
const view = this._childCreator(item);
this._childInstances.push(view);
fragment.appendChild(mountView(view, this._mountArgs));
}
this._root.appendChild(fragment);
}
mount() {
const root = super.mount();
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._initialRender();
}
loadList() {
if (!this._list) { return; }
this._subscription = this._list.subscribe(this);
this._childInstances = [];
/*
super.loadList() would render the entire list at this point.
We instead lazy render a part of the list in _renderIfNeeded
*/
}
_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();
}
}
}

View file

@ -0,0 +1,201 @@
/*
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 {tag} from "./html";
import {removeChildren, mountView} from "./utils";
import {ListRange, ResultType, AddRemoveResult} from "./ListRange";
import {ListView, IOptions as IParentOptions} from "./ListView";
import {IView} from "./types";
export interface IOptions<T, V> extends IParentOptions<T, V> {
itemHeight: number;
overflowItems?: number;
}
export class LazyListView<T, V extends IView> extends ListView<T, V> {
private renderRange?: ListRange;
private height?: number;
private itemHeight: number;
private overflowItems: number;
private scrollContainer?: HTMLElement;
constructor(
{itemHeight, overflowItems = 20, ...options}: IOptions<T, V>,
childCreator: (value: T) => V
) {
super(options, childCreator);
this.itemHeight = itemHeight;
this.overflowItems = overflowItems;
}
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;
}
this._subscription = this._list.subscribe(this);
const visibleRange = this._getVisibleRange();
this.renderRange = visibleRange.expand(this.overflowItems);
this._childInstances = [];
this.reRenderFullRange(this.renderRange);
}
private _getVisibleRange() {
const {clientHeight, scrollTop} = this.root()!;
if (clientHeight === 0) {
throw new Error("LazyListView height is 0");
}
return ListRange.fromViewport(this._list.length, this.itemHeight, clientHeight, scrollTop);
}
private reRenderFullRange(range: ListRange) {
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: ListRange, newRange: ListRange) {
if (newRange.intersects(prevRange)) {
// remove children in reverse order so child index isn't affected by previous removals
for (const idxInList of prevRange.reverseIterable()) {
if (!newRange.containsIndex(idxInList)) {
const localIdx = idxInList - prevRange.start;
this.removeChild(localIdx);
}
}
// use forEachInIterator instead of for loop as we need to advance
// the list iterator to the start of the range first
newRange.forEachInIterator(this._list[Symbol.iterator](), (item, idxInList) => {
if (!prevRange.containsIndex(idxInList)) {
const localIdx = idxInList - newRange.start;
this.addChild(localIdx, item);
}
});
this.adjustPadding(newRange);
} else {
this.reRenderFullRange(newRange);
}
}
private adjustPadding(range: ListRange) {
const paddingTop = range.start * this.itemHeight;
const paddingBottom = (range.totalLength - range.end) * this.itemHeight;
const style = this._listElement!.style;
style.paddingTop = `${paddingTop}px`;
style.paddingBottom = `${paddingBottom}px`;
}
mount() {
const listElement = super.mount();
this.scrollContainer = tag.div({className: "LazyListParent"}, listElement) as HTMLElement;
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(): HTMLElement | undefined {
return super.root() as HTMLElement | undefined;
}
onAdd(idx: number, value: T) {
const result = this.renderRange!.queryAdd(idx, value, this._list);
this.applyRemoveAddResult(result);
}
onRemove(idx: number, value: T) {
const result = this.renderRange!.queryRemove(idx, this._list);
this.applyRemoveAddResult(result);
}
onMove(fromIdx: number, toIdx: number, value: T) {
const result = this.renderRange!.queryMove(fromIdx, toIdx, value, this._list);
if (result) {
if (result.type === ResultType.Move) {
this.moveChild(
this.renderRange!.toLocalIndex(result.fromIdx),
this.renderRange!.toLocalIndex(result.toIdx)
);
} else {
this.applyRemoveAddResult(result);
}
}
}
onUpdate(i: number, value: T, params: any) {
if (this.renderRange!.containsIndex(i)) {
this.updateChild(this.renderRange!.toLocalIndex(i), value, params);
}
}
private applyRemoveAddResult(result: AddRemoveResult<T>) {
// order is important here, the new range can have a different start
if (result.type === ResultType.Remove || result.type === ResultType.RemoveAndAdd) {
this.removeChild(this.renderRange!.toLocalIndex(result.removeIdx));
}
if (result.newRange) {
this.renderRange = result.newRange;
this.adjustPadding(this.renderRange)
}
if (result.type === ResultType.Add || result.type === ResultType.RemoveAndAdd) {
this.addChild(this.renderRange!.toLocalIndex(result.addIdx), result.value);
}
}
}

View file

@ -0,0 +1,556 @@
/*
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 {Range, RangeZone} from "./Range";
function skipOnIterator<T>(it: Iterator<T>, pos: number): boolean {
let i = 0;
while (i < pos) {
i += 1;
if(it.next().done) {
return false;
}
}
return true;
}
function getIteratorValueAtIdx<T>(it: Iterator<T>, idx: number): undefined | T {
if (skipOnIterator(it, idx)) {
const result = it.next();
if (!result.done) {
return result.value;
}
}
return undefined;
}
export enum ResultType {
Move,
Add,
Remove,
RemoveAndAdd,
UpdateRange
}
export interface MoveResult {
type: ResultType.Move;
fromIdx: number;
toIdx: number
}
interface AddResult<T> {
type: ResultType.Add;
newRange?: ListRange;
/** the list index of an item to add */
addIdx: number;
/** the value to add at addIdx */
value: T
}
interface RemoveResult {
type: ResultType.Remove;
newRange?: ListRange;
/** the list index of an item to remove, before the add or remove event has been taken into account */
removeIdx: number;
}
// need to repeat the fields from RemoveResult and AddResult here
// to make the discriminated union work
interface RemoveAndAddResult<T> {
type: ResultType.RemoveAndAdd;
newRange?: ListRange;
/** the list index of an item to remove, before the add or remove event has been taken into account */
removeIdx: number;
/** the list index of an item to add */
addIdx: number;
/** the value to add at addIdx */
value: T;
}
interface UpdateRangeResult {
type: ResultType.UpdateRange;
newRange?: ListRange;
}
export type AddRemoveResult<T> = AddResult<T> | RemoveResult | RemoveAndAddResult<T> | UpdateRangeResult;
export class ListRange extends Range {
constructor(
start: number,
end: number,
private _totalLength: number,
private _viewportItemCount: number = end - start
) {
super(start, end);
}
expand(amount: number): ListRange {
// don't expand ranges that won't render anything
if (this.length === 0) {
return this;
}
const newStart = Math.max(0, this.start - amount);
const newEnd = Math.min(this.totalLength, this.end + amount);
return new ListRange(
newStart,
newEnd,
this.totalLength,
this._viewportItemCount
);
}
get totalLength(): number {
return this._totalLength;
}
get viewportItemCount(): number {
return this._viewportItemCount;
}
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 viewportItemCount = listHeight !== 0 ? Math.ceil(listHeight / itemHeight) : 0;
const renderCount = Math.min(viewportItemCount, itemsAfterTop);
return new ListRange(topCount, topCount + renderCount, listLength, viewportItemCount);
}
queryAdd<T>(idx: number, value: T, list: Iterable<T>): AddRemoveResult<T> {
const maxAddIdx = this.viewportItemCount > this.length ? this.end : this.end - 1;
if (idx <= maxAddIdx) {
// use maxAddIdx to allow to grow the range by one at a time
// if the viewport isn't filled yet
const addIdx = this.clampIndex(idx, maxAddIdx);
const addValue = addIdx === idx ? value : getIteratorValueAtIdx(list[Symbol.iterator](), addIdx)!;
return this.createAddResult<T>(addIdx, addValue);
} else {
// if the add happened after the range, we only update the range with the new length
return {type: ResultType.UpdateRange, newRange: this.deriveRange(1, 0)};
}
}
queryRemove<T>(idx: number, list: Iterable<T>): AddRemoveResult<T> {
if (idx < this.end) {
const removeIdx = this.clampIndex(idx);
return this.createRemoveResult(removeIdx, list);
} else {
return {type: ResultType.UpdateRange, newRange: this.deriveRange(-1, 0)};
}
}
queryMove<T>(fromIdx: number, toIdx: number, value: T, list: Iterable<T>): MoveResult | AddRemoveResult<T> | undefined {
const fromZone = this.getIndexZone(fromIdx);
const toZone = this.getIndexZone(toIdx);
if (fromZone === toZone) {
if (fromZone === RangeZone.Before || fromZone === RangeZone.After) {
return;
} else if (fromZone === RangeZone.Inside) {
return {type: ResultType.Move, fromIdx, toIdx};
}
} else {
const addIdx = this.clampIndex(toIdx);
const removeIdx = this.clampIndex(fromIdx);
const addValue = addIdx === toIdx ? value : getIteratorValueAtIdx(list[Symbol.iterator](), addIdx)!;
return {type: ResultType.RemoveAndAdd, removeIdx, addIdx, value: addValue};
}
}
private createAddResult<T>(addIdx: number, value: T): AddRemoveResult<T> {
// if the view port isn't filled yet, we don't remove
if (this.viewportItemCount > this.length) {
return {type: ResultType.Add, addIdx, value, newRange: this.deriveRange(1, 1)};
} else {
const removeIdx = this.clampIndex(Number.MAX_SAFE_INTEGER);
return {type: ResultType.RemoveAndAdd, removeIdx, addIdx, value, newRange: this.deriveRange(1, 0)};
}
}
private createRemoveResult<T>(removeIdx: number, list: Iterable<T>): AddRemoveResult<T> {
if (this.end < this.totalLength) {
// we have items below the range, we can add one from there to fill the viewport
const addIdx = this.clampIndex(Number.MAX_SAFE_INTEGER);
// we assume the value has already been removed from the list,
// so we can just look up the next value which is already at the same idx
const value = getIteratorValueAtIdx(list[Symbol.iterator](), addIdx)!;
return {type: ResultType.RemoveAndAdd, removeIdx, value, addIdx, newRange: this.deriveRange(-1, 0)};
} else if (this.start !== 0) {
// move the range 1 item up so we still display a viewport full of items
const newRange = this.deriveRange(-1, 0, 1);
const addIdx = newRange.start;
// we assume the value has already been removed from the list,
// so we can just look up the next value which is already at the same idx
const value = getIteratorValueAtIdx(list[Symbol.iterator](), addIdx)!;
return {type: ResultType.RemoveAndAdd, removeIdx, value, addIdx, newRange};
} else {
// we can't add at the bottom nor top, already constrained
return {type: ResultType.Remove, removeIdx, newRange: this.deriveRange(-1, 0)};
}
}
private deriveRange(totalLengthInc: number, viewportItemCountDecr: number, startDecr: number = 0): ListRange {
const start = this.start - startDecr;
const totalLength = this.totalLength + totalLengthInc;
// prevent end being larger than totalLength
const end = Math.min(Math.max(start, this.end - startDecr + viewportItemCountDecr), totalLength);
return new ListRange(
start,
end,
totalLength,
this.viewportItemCount
);
}
}
import {ObservableArray} from "../../../../observable/list/ObservableArray.js";
export function tests() {
return {
"fromViewport": assert => {
const range = ListRange.fromViewport(10, 20, 90, 30);
assert.equal(range.start, 1);
assert.equal(range.end, 6);
assert.equal(range.totalLength, 10);
},
"fromViewport at end": assert => {
const itemHeight = 20;
const range = ListRange.fromViewport(10, itemHeight, 3 * itemHeight, 7 * itemHeight);
assert.equal(range.start, 7);
assert.equal(range.end, 10);
assert.equal(range.totalLength, 10);
},
"fromViewport with not enough items to fill viewport": assert => {
const itemHeight = 20;
const range = ListRange.fromViewport(5, itemHeight, 8 * itemHeight, 0);
assert.equal(range.start, 0);
assert.equal(range.end, 5);
assert.equal(range.totalLength, 5);
assert.equal(range.length, 5);
assert.equal(range.viewportItemCount, 8);
},
"expand at start of list": assert => {
const range = new ListRange(1, 5, 10);
const expanded = range.expand(2);
assert.equal(expanded.start, 0);
assert.equal(expanded.end, 7);
assert.equal(expanded.totalLength, 10);
assert.equal(expanded.length, 7);
},
"expand at end of list": assert => {
const range = new ListRange(7, 9, 10);
const expanded = range.expand(2);
assert.equal(expanded.start, 5);
assert.equal(expanded.end, 10);
assert.equal(expanded.totalLength, 10);
assert.equal(expanded.length, 5);
},
"expand in middle of list": assert => {
const range = new ListRange(4, 6, 10);
const expanded = range.expand(2);
assert.equal(expanded.start, 2);
assert.equal(expanded.end, 8);
assert.equal(expanded.totalLength, 10);
assert.equal(expanded.length, 6);
},
"queryAdd with addition before range": assert => {
const list = new ObservableArray(["b", "c", "d", "e"]);
const range = new ListRange(1, 3, list.length);
let added = false;
list.subscribe({
onAdd(idx, value) {
added = true;
const result = range.queryAdd(idx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 2,
addIdx: 1,
value: "b",
newRange: new ListRange(1, 3, 5)
});
}
});
list.insert(0, "a");
assert(added);
},
"queryAdd with addition within range": assert => {
const list = new ObservableArray(["a", "b", "d", "e"]);
const range = new ListRange(1, 3, list.length);
let added = false;
list.subscribe({
onAdd(idx, value) {
added = true;
const result = range.queryAdd(idx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 2,
addIdx: 2,
value: "c",
newRange: new ListRange(1, 3, 5)
});
}
});
list.insert(2, "c");
assert(added);
},
"queryAdd with addition after range": assert => {
const list = new ObservableArray(["a", "b", "c", "d"]);
const range = new ListRange(1, 3, list.length);
let added = false;
list.subscribe({
onAdd(idx, value) {
added = true;
const result = range.queryAdd(idx, value, list);
assert.deepEqual(result, {
type: ResultType.UpdateRange,
newRange: new ListRange(1, 3, 5)
});
}
});
list.insert(4, "e");
assert(added);
},
"queryAdd with too few items to fill viewport grows the range": assert => {
const list = new ObservableArray(["a", "b", "d"]);
const viewportItemCount = 4;
const range = new ListRange(0, 3, list.length, viewportItemCount);
let added = false;
list.subscribe({
onAdd(idx, value) {
added = true;
const result = range.queryAdd(idx, value, list);
assert.deepEqual(result, {
type: ResultType.Add,
newRange: new ListRange(0, 4, 4),
addIdx: 2,
value: "c"
});
}
});
list.insert(2, "c");
assert(added);
},
"queryRemove with removal before range": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(1, 3, list.length);
let removed = false;
list.subscribe({
onRemove(idx) {
removed = true;
const result = range.queryRemove(idx, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 1,
addIdx: 2,
value: "d",
newRange: new ListRange(1, 3, 4)
});
}
});
list.remove(0);
assert(removed);
},
"queryRemove with removal within range": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(1, 3, list.length);
let removed = false;
list.subscribe({
onRemove(idx) {
removed = true;
const result = range.queryRemove(idx, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 2,
addIdx: 2,
value: "d",
newRange: new ListRange(1, 3, 4)
});
assert.equal(list.length, 4);
}
});
list.remove(2);
assert(removed);
},
"queryRemove with removal after range": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(1, 3, list.length);
let removed = false;
list.subscribe({
onRemove(idx) {
removed = true;
const result = range.queryRemove(idx, list);
assert.deepEqual(result, {
type: ResultType.UpdateRange,
newRange: new ListRange(1, 3, 4)
});
}
});
list.remove(3);
assert(removed);
},
"queryRemove at bottom of range moves range one up": assert => {
const list = new ObservableArray(["a", "b", "c"]);
const range = new ListRange(1, 3, list.length);
let removed = false;
list.subscribe({
onRemove(idx) {
removed = true;
const result = range.queryRemove(idx, list);
assert.deepEqual(result, {
newRange: new ListRange(0, 2, 2),
type: ResultType.RemoveAndAdd,
removeIdx: 2,
addIdx: 0,
value: "a"
});
}
});
list.remove(2);
assert(removed);
},
"queryRemove with range on full length shrinks range": assert => {
const list = new ObservableArray(["a", "b", "c"]);
const range = new ListRange(0, 3, list.length);
let removed = false;
list.subscribe({
onRemove(idx) {
removed = true;
const result = range.queryRemove(idx, list);
assert.deepEqual(result, {
newRange: new ListRange(0, 2, 2, 3),
type: ResultType.Remove,
removeIdx: 2,
});
}
});
list.remove(2);
assert(removed);
},
"queryMove with move inside range": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(1, 4, list.length);
let moved = false;
list.subscribe({
onMove(fromIdx, toIdx, value) {
moved = true;
const result = range.queryMove(fromIdx, toIdx, value, list);
assert.deepEqual(result, {
type: ResultType.Move,
fromIdx: 2,
toIdx: 3
});
}
});
list.move(2, 3);
assert(moved);
},
"queryMove with move from before to inside range": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(2, 5, list.length);
let moved = false;
list.subscribe({
onMove(fromIdx, toIdx, value) {
moved = true;
const result = range.queryMove(fromIdx, toIdx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 2,
addIdx: 3,
value: "a"
});
}
});
list.move(0, 3); // move "a" to after "d"
assert(moved);
},
"queryMove with move from after to inside range": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(0, 3, list.length);
let moved = false;
list.subscribe({
onMove(fromIdx, toIdx, value) {
moved = true;
const result = range.queryMove(fromIdx, toIdx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 2,
addIdx: 1,
value: "e"
});
}
});
list.move(4, 1); // move "e" to before "b"
assert(moved);
},
"queryMove with move inside range to after": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(0, 3, list.length);
let moved = false;
list.subscribe({
onMove(fromIdx, toIdx, value) {
moved = true;
const result = range.queryMove(fromIdx, toIdx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 1,
addIdx: 2,
value: "d"
});
}
});
list.move(1, 3); // move "b" to after "d"
assert(moved);
},
"queryMove with move inside range to before": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(2, 5, list.length);
let moved = false;
list.subscribe({
onMove(fromIdx, toIdx, value) {
moved = true;
const result = range.queryMove(fromIdx, toIdx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 3,
addIdx: 2,
value: "b"
});
}
});
list.move(3, 0); // move "d" to before "a"
assert(moved);
},
"queryMove with move from before range to after": assert => {
const list = new ObservableArray(["a", "b", "c", "d", "e"]);
const range = new ListRange(1, 4, list.length);
let moved = false;
list.subscribe({
onMove(fromIdx, toIdx, value) {
moved = true;
const result = range.queryMove(fromIdx, toIdx, value, list);
assert.deepEqual(result, {
type: ResultType.RemoveAndAdd,
removeIdx: 1,
addIdx: 3,
value: "e"
});
}
});
list.move(0, 4); // move "a" to after "e"
assert(moved);
},
// would be good to test here what multiple mutations look like with executing the result of queryXXX
// on an array, much like we do in the view.
};
}

View file

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

View file

@ -0,0 +1,225 @@
/*
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
export 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;
}
toLocalIndex(idx: number) {
return idx - this.start;
}
intersects(range: Range): boolean {
return range.start < this.end && this.start < range.end;
}
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);
}
reverseIterable(): Iterable<number> {
return new ReverseRangeIterator(this);
}
clampIndex(idx: number, end = this.end - 1) {
return Math.min(Math.max(this.start, idx), end);
}
getIndexZone(idx): RangeZone {
if (idx < this.start) {
return RangeZone.Before;
} else if (idx < this.end) {
return RangeZone.Inside;
} else {
return RangeZone.After;
}
}
}
export enum RangeZone {
Before = 1,
Inside,
After
}
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};
}
}
}
class ReverseRangeIterator implements Iterable<number>, Iterator<number> {
private idx: number;
constructor(private readonly range: Range) {
this.idx = range.end;
}
[Symbol.iterator]() {
return this;
}
next(): IteratorResult<number> {
if (this.idx > this.range.start) {
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]);
},
"reverseIterable": assert => {
assert.deepEqual(Array.from(new Range(2, 5).reverseIterable()), [4, 3, 2]);
},
"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},
]);
},
"clampIndex": assert => {
assert.equal(new Range(2, 5).clampIndex(0), 2);
assert.equal(new Range(2, 5).clampIndex(2), 2);
assert.equal(new Range(2, 5).clampIndex(3), 3);
assert.equal(new Range(2, 5).clampIndex(4), 4);
assert.equal(new Range(2, 5).clampIndex(5), 4);
assert.equal(new Range(2, 5).clampIndex(10), 4);
}
};
}

View file

@ -50,3 +50,7 @@ export function insertAt(parentNode: Element, idx: number, childNode: Node): voi
parentNode.insertBefore(childNode, nextDomNode); 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. limitations under the License.
*/ */
import {LazyListView} from "../../general/LazyListView.js"; import {LazyListView} from "../../general/LazyListView";
import {MemberTileView} from "./MemberTileView.js"; import {MemberTileView} from "./MemberTileView.js";
export class MemberListView extends LazyListView { export class MemberListView extends LazyListView {