This commit is contained in:
Bruno Windels 2021-11-22 20:35:57 +01:00
parent 4a64d0ee17
commit cf9f43ab9e
4 changed files with 555 additions and 90 deletions

View file

@ -44,6 +44,14 @@ export class ObservableArray extends BaseObservableList {
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) {
if (idx < this._items.length) {
this._items[idx] = item;

View file

@ -16,7 +16,7 @@ limitations under the License.
import {tag} from "./html";
import {removeChildren, mountView} from "./utils";
import {ItemRange} from "./ItemRange";
import {ListRange, ResultType, AddRemoveResult} from "./ListRange";
import {ListView, IOptions as IParentOptions} from "./ListView";
import {IView} from "./types";
@ -27,11 +27,11 @@ export interface IOptions<T, V> extends IParentOptions<T, V> {
}
export class LazyListView<T, V extends IView> extends ListView<T, V> {
private renderRange?: ItemRange;
private renderRange?: ListRange;
private height?: number;
private itemHeight: number;
private overflowItems: number;
private scrollContainer?: Element;
private scrollContainer?: HTMLElement;
constructor(
{itemHeight, overflowMargin = 5, overflowItems = 20,...options}: IOptions<T, V>,
@ -87,10 +87,10 @@ export class LazyListView<T, V extends IView> extends ListView<T, V> {
if (clientHeight === 0) {
throw new Error("LazyListView height is 0");
}
return ItemRange.fromViewport(this._list.length, this.itemHeight, clientHeight, scrollTop);
return ListRange.fromViewport(this._list.length, this.itemHeight, clientHeight, scrollTop);
}
private reRenderFullRange(range: ItemRange) {
private reRenderFullRange(range: ListRange) {
removeChildren(this._listElement!);
const fragment = document.createDocumentFragment();
const it = this._list[Symbol.iterator]();
@ -104,20 +104,22 @@ export class LazyListView<T, V extends IView> extends ListView<T, V> {
this.adjustPadding(range);
}
private renderUpdate(prevRange: ItemRange, newRange: ItemRange) {
private renderUpdate(prevRange: ListRange, newRange: ListRange) {
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?
// 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);
}
}
const addedRange = newRange.missingFrom(prevRange);
addedRange.forEachInIterator(this._list[Symbol.iterator](), (item, idxInList) => {
const localIdx = idxInList - newRange.start;
this.addChild(localIdx, item);
// 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 {
@ -125,7 +127,7 @@ export class LazyListView<T, V extends IView> extends ListView<T, V> {
}
}
private adjustPadding(range: ItemRange) {
private adjustPadding(range: ListRange) {
const paddingTop = range.start * this.itemHeight;
const paddingBottom = (range.totalLength - range.end) * this.itemHeight;
const style = this.scrollContainer!.style;
@ -135,11 +137,7 @@ export class LazyListView<T, V extends IView> extends ListView<T, V> {
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 = tag.div({className: "LazyListParent"}, listElement) as HTMLElement;
this.scrollContainer.addEventListener("scroll", this);
return this.scrollContainer;
}
@ -159,42 +157,46 @@ export class LazyListView<T, V extends IView> extends ListView<T, V> {
}
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);
}
const result = this.renderRange!.queryAdd(idx, value, this._list);
this.applyRemoveAddResult(result);
}
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);
}
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);
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);
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) {
const updateIdx = this.renderRange!.queryUpdate(i);
if (updateIdx !== -1) {
this.updateChild(updateIdx, value, params);
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,440 @@
/*
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;
}
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, this.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.derive(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.derive(-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 {
// TODO
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.derive(1, 1)};
} else {
const removeIdx = this.clampIndex(Number.MAX_SAFE_INTEGER);
return {type: ResultType.RemoveAndAdd, removeIdx, addIdx, value, newRange: this.derive(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.derive(-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.derive(-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.derive(-1, 0)};
}
}
private derive(totalLengthInc: number, viewportItemCountDecr: number, startDecr: number = 0): ListRange {
return new ListRange(
this.start - startDecr,
this.end - startDecr + viewportItemCountDecr,
this.totalLength + totalLengthInc,
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);
},
"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);
},
};
}
// TODO: test with view larger than space needed by list

View file

@ -17,7 +17,7 @@ limitations under the License.
// start is included in the range,
// end is excluded,
// so [2, 2[ means an empty range
class Range {
export class Range {
constructor(
public readonly start: number,
public readonly end: number
@ -35,14 +35,12 @@ class Range {
return idx >= this.start && idx < this.end;
}
intersects(range: Range): boolean {
return range.start < this.end && this.start < range.end;
toLocalIndex(idx: number) {
return idx - this.start;
}
forEach(callback: ((i: number) => void)) {
for (let i = this.start; i < this.end; i += 1) {
callback(i);
}
intersects(range: Range): boolean {
return range.start < this.end && this.start < range.end;
}
forEachInIterator<T>(it: IterableIterator<T>, callback: ((T, i: number) => void)) {
@ -63,6 +61,30 @@ class Range {
[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> {
@ -81,6 +103,26 @@ class RangeIterator implements Iterator<number> {
}
}
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 => {
@ -90,6 +132,9 @@ export function tests() {
"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);
@ -168,43 +213,13 @@ export function tests() {
{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);
}
};
}
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() {
}
}