mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-21 09:03:02 +00:00
288 lines
9.0 KiB
TypeScript
288 lines
9.0 KiB
TypeScript
/*
|
|
* Copyright (C) 2023 StApps
|
|
* This program is free software: you can redistribute it and/or modify it
|
|
* under the terms of the GNU General Public License as published by the Free
|
|
* Software Foundation, version 3.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
* more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along with
|
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
import {CdkVirtualForOf, CdkVirtualScrollViewport, VirtualScrollStrategy} from '@angular/cdk/scrolling';
|
|
import {BehaviorSubject, Subject, Subscription, takeUntil, timer} from 'rxjs';
|
|
import {debounceTime, distinctUntilChanged, tap} from 'rxjs/operators';
|
|
import {SCThingType} from '@openstapps/core';
|
|
|
|
export class ScThingListItemVirtualScrollStrategy implements VirtualScrollStrategy {
|
|
private viewport?: CdkVirtualScrollViewport;
|
|
|
|
private virtualForOf?: CdkVirtualForOf<unknown>;
|
|
|
|
private index$ = new Subject<number>();
|
|
|
|
private heights = new Map<unknown, number | undefined>();
|
|
|
|
private groupHeights = new Map<unknown, number | undefined>();
|
|
|
|
private offsets: number[] = [];
|
|
|
|
private totalHeight = 0;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private _items?: readonly unknown[];
|
|
|
|
private _groups?: readonly unknown[];
|
|
|
|
/**
|
|
* We use this to track loadMore
|
|
*/
|
|
private currentLength = 0;
|
|
|
|
private dataStreamSubscription?: Subscription;
|
|
|
|
private mutationObserver?: MutationObserver;
|
|
|
|
approximateItemHeight = 67;
|
|
|
|
approximateGroupSizes: Map<unknown, number> = new Map([[SCThingType.AcademicEvent, 139]]);
|
|
|
|
buffer = 4000;
|
|
|
|
gap = 8;
|
|
|
|
itemRenderTimeout = 1000;
|
|
|
|
heightUpdateDebounceTime = 25;
|
|
|
|
heightSetDebounceTime = 100;
|
|
|
|
loadMore = new Subject<void>();
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
trackGroupBy: (value: any) => unknown = it => it.type;
|
|
|
|
attach(viewport: CdkVirtualScrollViewport): void {
|
|
this.viewport = viewport;
|
|
// @ts-expect-error private property
|
|
this.virtualForOf = viewport._forOf;
|
|
this.dataStreamSubscription = this.virtualForOf?.dataStream.subscribe(items => {
|
|
this.items = items;
|
|
});
|
|
|
|
this.mutationObserver = new MutationObserver(() => {
|
|
const renderedItems = this.renderedItems;
|
|
if (!renderedItems) {
|
|
this.mutationObserver?.disconnect();
|
|
return this.onPrematureDisconnect();
|
|
}
|
|
|
|
this.intersectionObserver?.disconnect();
|
|
this.intersectionObserver = new IntersectionObserver(this.observeIntersection.bind(this), {
|
|
rootMargin: `${this.buffer - 64}px`,
|
|
threshold: 1,
|
|
});
|
|
|
|
for (const node of renderedItems) {
|
|
const [item, group] = this.getItemByNode(node, renderedItems);
|
|
|
|
if (!this.heights.has(item)) {
|
|
this.intersectionObserver.observe(node);
|
|
} else {
|
|
node.style.height = `${this.getHeight(item, group)}px`;
|
|
}
|
|
}
|
|
});
|
|
|
|
const contentWrapper = this.contentWrapper;
|
|
if (!contentWrapper) return this.onPrematureDisconnect();
|
|
this.mutationObserver.observe(contentWrapper, {childList: true, subtree: true});
|
|
|
|
this.setTotalContentSize();
|
|
}
|
|
|
|
detach(): void {
|
|
this.index$.complete();
|
|
this.dataStreamSubscription?.unsubscribe();
|
|
this.mutationObserver?.disconnect();
|
|
delete this.viewport;
|
|
}
|
|
|
|
private getHeight(item: unknown, group: unknown) {
|
|
return (
|
|
this.heights.get(item) ||
|
|
this.groupHeights.get(group) ||
|
|
this.approximateGroupSizes.get(group) ||
|
|
this.approximateItemHeight
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private set items(value: readonly unknown[]) {
|
|
const trackBy = this.virtualForOf?.cdkVirtualForTrackBy;
|
|
const tracks = value.map((it, i) => (trackBy ? trackBy(i, it) : it));
|
|
if (
|
|
this._items &&
|
|
tracks.length === this._items.length &&
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
tracks.every((it, i) => it === this._items![i])
|
|
)
|
|
return;
|
|
|
|
this._items = tracks;
|
|
this._groups = value.map(this.trackGroupBy);
|
|
this.currentLength = tracks.length;
|
|
|
|
this.updateHeights();
|
|
}
|
|
|
|
scrolledIndexChange = this.index$.pipe(distinctUntilChanged());
|
|
|
|
private getOffsetFromIndex(index: number): number {
|
|
return this.offsets[index];
|
|
}
|
|
|
|
private getIndexFromOffset(offset: number): number {
|
|
return Math.max(
|
|
0,
|
|
this.offsets.findIndex((it, i, array) => it >= offset || i === array.length - 1),
|
|
);
|
|
}
|
|
|
|
private updateHeights() {
|
|
if (!this._items) return;
|
|
const heights = this._items.map((it, i) => this.getHeight(it, this._groups![i]) + this.gap);
|
|
this.offsets = Array.from({length: heights.length}, () => 0);
|
|
this.totalHeight = heights.reduce((a, b, index) => (this.offsets[index + 1] = a + b), 0) + this.gap;
|
|
|
|
this.setTotalContentSize();
|
|
this.updateRenderedRange();
|
|
}
|
|
|
|
private updateRenderedRange() {
|
|
if (!this.viewport) return;
|
|
const offset = this.viewport.measureScrollOffset('top');
|
|
const viewportSize = this.viewport.getViewportSize();
|
|
const firstVisibleIndex = Math.max(0, this.getIndexFromOffset(offset) - 1);
|
|
const range = {
|
|
start: this.getIndexFromOffset(Math.max(0, offset - this.buffer)),
|
|
end: this.getIndexFromOffset(offset + viewportSize + this.buffer),
|
|
};
|
|
const {start, end} = this.viewport.getRenderedRange();
|
|
if (range.start === start && range.end === end) return;
|
|
|
|
if (this.currentLength !== 0 && range.end === this.currentLength) {
|
|
this.currentLength++;
|
|
this.loadMore.next();
|
|
}
|
|
|
|
this.viewport.setRenderedRange(range);
|
|
this.viewport.setRenderedContentOffset(this.getOffsetFromIndex(range.start), 'to-start');
|
|
|
|
this.index$.next(firstVisibleIndex);
|
|
}
|
|
|
|
private setTotalContentSize() {
|
|
this.viewport?.setTotalContentSize(this.totalHeight);
|
|
// @ts-expect-error TODO
|
|
this.viewport?._measureViewportSize();
|
|
}
|
|
|
|
observeIntersection(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
|
|
const renderedItems = this.renderedItems;
|
|
if (!renderedItems) return this.onPrematureDisconnect();
|
|
|
|
const update = new Subject<void>();
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue;
|
|
|
|
const outerNode = entry.target as HTMLElement;
|
|
const [item] = this.getItemByNode(outerNode, renderedItems);
|
|
const node = outerNode.firstChild! as HTMLElement;
|
|
|
|
const height = new BehaviorSubject(node.offsetHeight);
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
const renderedItems = this.renderedItems;
|
|
if (!renderedItems) {
|
|
resizeObserver.disconnect();
|
|
return this.onPrematureDisconnect();
|
|
}
|
|
const [newItem] = this.getItemByNode(node, renderedItems);
|
|
if (newItem !== item) {
|
|
this.heights.delete(item);
|
|
resizeObserver.disconnect();
|
|
return;
|
|
}
|
|
height.next(node.offsetHeight);
|
|
});
|
|
resizeObserver.observe(node);
|
|
height
|
|
.pipe(
|
|
distinctUntilChanged(),
|
|
debounceTime(this.heightSetDebounceTime),
|
|
takeUntil(timer(this.itemRenderTimeout)),
|
|
tap({complete: () => resizeObserver.disconnect()}),
|
|
)
|
|
.subscribe(height => {
|
|
this.heights.set(item, height);
|
|
outerNode.style.height = `${height}px`;
|
|
update.next();
|
|
});
|
|
|
|
observer.unobserve(node);
|
|
}
|
|
update
|
|
.pipe(debounceTime(this.heightUpdateDebounceTime), takeUntil(timer(this.itemRenderTimeout)))
|
|
.subscribe(() => {
|
|
this.updateHeights();
|
|
});
|
|
}
|
|
|
|
intersectionObserver?: IntersectionObserver;
|
|
|
|
get contentWrapper() {
|
|
return this.viewport?._contentWrapper.nativeElement;
|
|
}
|
|
|
|
get renderedItems() {
|
|
const contentWrapper = this.contentWrapper;
|
|
return contentWrapper
|
|
? // eslint-disable-next-line unicorn/prefer-spread
|
|
(Array.from(contentWrapper.children).filter(it => it instanceof HTMLElement) as HTMLElement[])
|
|
: undefined;
|
|
}
|
|
|
|
getItemByNode(node: HTMLElement, renderedItems: HTMLElement[]) {
|
|
const {start} = this.viewport!.getRenderedRange();
|
|
const index = renderedItems.indexOf(node) + start;
|
|
return [this._items![index], this._groups![index]];
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
onContentRendered(): void {}
|
|
|
|
onContentScrolled() {
|
|
this.updateRenderedRange();
|
|
}
|
|
|
|
onPrematureDisconnect() {
|
|
console.warn('Virtual Scroll strategy was disconnected unexpectedly', new Error('foo').stack);
|
|
}
|
|
|
|
onDataLengthChanged(): void {
|
|
this.setTotalContentSize();
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
onRenderedOffsetChanged(): void {}
|
|
|
|
scrollToIndex(index: number, behavior: ScrollBehavior): void {
|
|
this.viewport?.scrollToOffset(this.getOffsetFromIndex(index), behavior);
|
|
}
|
|
}
|