Merge pull request #592 from vector-im/bwindels/lazylist-enhancements
Lazylist enhancements
This commit is contained in:
commit
93abbe83e8
8 changed files with 1031 additions and 300 deletions
|
@ -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;
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
201
src/platform/web/ui/general/LazyListView.ts
Normal file
201
src/platform/web/ui/general/LazyListView.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
556
src/platform/web/ui/general/ListRange.ts
Normal file
556
src/platform/web/ui/general/ListRange.ts
Normal 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.
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
225
src/platform/web/ui/general/Range.ts
Normal file
225
src/platform/web/ui/general/Range.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -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 = '';
|
||||||
|
}
|
||||||
|
|
|
@ -14,10 +14,10 @@ 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 {
|
||||||
constructor(vm) {
|
constructor(vm) {
|
||||||
super({
|
super({
|
||||||
list: vm.memberTileViewModels,
|
list: vm.memberTileViewModels,
|
||||||
|
|
Reference in a new issue