/* * 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 . */ /* 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; private index$ = new Subject(); private heights = new Map(); private groupHeights = new Map(); 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 = new Map([[SCThingType.AcademicEvent, 139]]); buffer = 4000; gap = 8; itemRenderTimeout = 1000; heightUpdateDebounceTime = 25; heightSetDebounceTime = 100; loadMore = new Subject(); // 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(); 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); } }