This repository has been archived on 2022-08-19. You can view files and clone it, but cannot push or open issues or pull requests.
hydrogen-web/src/platform/web/ui/general/ListRange.ts
2021-12-01 09:43:58 +01:00

558 lines
22 KiB
TypeScript

/*
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";
import {defaultObserverWith} from "../../../../observable/list/BaseObservableList";
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";
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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(defaultObserverWith({
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.
};
}