UNPKG

@rx-angular/template

Version:

**Fully** Reactive Component Template Rendering in Angular. @rx-angular/template aims to be a reflection of Angular's built in renderings just reactive.

1,181 lines (1,174 loc) 119 kB
import * as i0 from '@angular/core'; import { Directive, InjectionToken, Injectable, inject, Input, isSignal, IterableDiffers, ChangeDetectorRef, NgZone, Injector, ViewContainerRef, ErrorHandler, ElementRef, Component, ViewEncapsulation, ChangeDetectionStrategy, ViewChild, ContentChild, Output } from '@angular/core'; import { RxDefaultListViewContext } from '@rx-angular/cdk/template'; import { Subject, of, Observable, from, ReplaySubject, combineLatest, merge, isObservable, NEVER, concat, defer } from 'rxjs'; import { coalesceWith } from '@rx-angular/cdk/coalescing'; import { distinctUntilChanged, takeUntil, filter, map, pairwise, tap, finalize, startWith, switchMap, groupBy, mergeMap, exhaustMap, take, takeWhile, shareReplay, switchAll, ignoreElements, catchError } from 'rxjs/operators'; import { getZoneUnPatchedApi } from '@rx-angular/cdk/internals/core'; import { requestAnimationFrame, cancelAnimationFrame, Promise as Promise$1 } from '@rx-angular/cdk/zone-less/browser'; import { toObservable } from '@angular/core/rxjs-interop'; import { coerceObservableWith } from '@rx-angular/cdk/coercing'; import { RxStrategyProvider, strategyHandling, onStrategy } from '@rx-angular/cdk/render-strategies'; import { NgIf, DOCUMENT } from '@angular/common'; /** * @Directive RxVirtualScrollStrategy * * @description * Abstract implementation for the actual implementations of the ScrollStrategies * being consumed by `*rxVirtualFor` and `rx-virtual-scroll-viewport`. * * This is one of the core parts for the virtual scrolling implementation. It has * to determine the `ListRange` being rendered to the DOM as well as managing * the layouting task for the `*rxVirtualFor` directive. * * @docsCategory RxVirtualFor * @docsPage RxVirtualFor * @publicApi */ class RxVirtualScrollStrategy { constructor() { /** * @description * * Emits whenever an update to a single view was rendered */ this.viewRenderCallback = new Subject(); } /** @internal */ get isStable() { return of(true); } /** @internal */ getElement(view) { if (this.nodeIndex !== undefined) { return view.rootNodes[this.nodeIndex]; } const rootNode = view.rootNodes[0]; this.nodeIndex = rootNode instanceof HTMLElement ? 0 : 1; return view.rootNodes[this.nodeIndex]; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollStrategy, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualScrollStrategy, isStandalone: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollStrategy, decorators: [{ type: Directive }] }); /** @internal */ class RxVirtualScrollViewport { /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollViewport, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualScrollViewport, isStandalone: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollViewport, decorators: [{ type: Directive }] }); /** @internal */ class RxVirtualViewRepeater { /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualViewRepeater, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualViewRepeater, isStandalone: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualViewRepeater, decorators: [{ type: Directive }] }); /** @internal */ class RxVirtualForViewContext extends RxDefaultListViewContext { constructor(item, rxVirtualForOf, customProps) { super(item, customProps); this.rxVirtualForOf = rxVirtualForOf; } } class RxVirtualScrollElement { /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollElement, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualScrollElement, isStandalone: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollElement, decorators: [{ type: Directive }] }); function toBoolean(input) { return input != null && `${input}` !== 'false'; } function unpatchedAnimationFrameTick() { return new Observable((observer) => { const tick = requestAnimationFrame(() => { observer.next(); observer.complete(); }); return () => { cancelAnimationFrame(tick); }; }); } function unpatchedMicroTask() { return from(Promise$1.resolve()); } function unpatchedScroll(el) { return new Observable((observer) => { const listener = () => observer.next(); getZoneUnPatchedApi(el, 'addEventListener').call(el, 'scroll', listener, { passive: true, }); return () => { getZoneUnPatchedApi(el, 'removeEventListener').call(el, 'scroll', listener, { passive: true }); }; }); } /** * @description * * calculates the correct scrollTop value in which the rx-virtual-scroll-viewport * is actually visible */ function parseScrollTopBoundaries(scrollTop, offset, contentSize, containerSize) { const scrollTopWithOutOffset = scrollTop - offset; const maxSize = Math.max(contentSize - containerSize, containerSize); const maxScrollTop = Math.max(contentSize, containerSize); const adjustedScrollTop = Math.max(0, scrollTopWithOutOffset); const scrollTopAfterOffset = adjustedScrollTop - maxSize; return { scrollTopWithOutOffset, scrollTopAfterOffset, scrollTop: Math.min(adjustedScrollTop, maxScrollTop), }; } /** * @description * * Calculates the visible size of the rx-virtual-scroll-viewport container. It * accounts for the fact that the viewport can partially or fully be out of viewport because * static contents that are living between the boundaries of rx-virtual-scroll-viewport * and its scrollable element. */ function calculateVisibleContainerSize(containerSize, scrollTopWithOutOffset, scrollTopAfterOffset) { let clamped = containerSize; if (scrollTopWithOutOffset < 0) { clamped = Math.max(0, containerSize + scrollTopWithOutOffset); } else if (scrollTopAfterOffset > 0) { clamped = Math.max(0, containerSize - scrollTopAfterOffset); } return clamped; } /** Injection token to be used to override the default options. */ const RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS = new InjectionToken('rx-virtual-scrolling-default-options', { providedIn: 'root', factory: RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS_FACTORY, }); /** @internal */ function RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS_FACTORY() { return { runwayItems: DEFAULT_RUNWAY_ITEMS, runwayItemsOpposite: DEFAULT_RUNWAY_ITEMS_OPPOSITE, templateCacheSize: DEFAULT_TEMPLATE_CACHE_SIZE, itemSize: DEFAULT_ITEM_SIZE, }; } /** @internal */ const DEFAULT_TEMPLATE_CACHE_SIZE = 20; /** @internal */ const DEFAULT_ITEM_SIZE = 50; /** @internal */ const DEFAULT_RUNWAY_ITEMS = 10; /** @internal */ const DEFAULT_RUNWAY_ITEMS_OPPOSITE = 2; class RxaResizeObserver { constructor() { this.resizeObserver = new ResizeObserver((events) => { this.viewsResized$.next(events); }); /** @internal */ this.viewsResized$ = new Subject(); } observeElement(element, options) { this.resizeObserver.observe(element, options); return new Observable((observer) => { const inner = this.viewsResized$.subscribe((events) => { const event = events.find((event) => event.target === element); if (event) { observer.next(event); } }); return () => { this.resizeObserver.unobserve(element); inner.unsubscribe(); }; }); } destroy() { this.resizeObserver.disconnect(); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxaResizeObserver, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxaResizeObserver }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxaResizeObserver, decorators: [{ type: Injectable }] }); const defaultSizeExtract = (entry) => entry.borderBoxSize[0].blockSize; /** * @Directive AutosizeVirtualScrollStrategy * * @description * * The `AutosizeVirtualScrollStrategy` provides a twitter-like virtual-scrolling * experience. It is able to render and position items based on their individual * size. It is comparable to \@angular/cdk/experimental `AutosizeVirtualScrollStrategy`, but * with a high performant layouting technique and more features. * * On top of this the `AutosizeVirtualScrollStrategy` is leveraging the native * `ResizeObserver` in order to detect size changes for each individual view * rendered to the DOM and properly re-position accordingly. * * In order to provide top-notch runtime performance the `AutosizeVirtualScrollStrategy` * builds up caches that prevent DOM interactions whenever possible. Once a view * was visited, its properties will be stored instead of re-read from the DOM again as * this can potentially lead to unwanted forced reflows. * * @docsCategory RxVirtualFor * @docsPage RxVirtualFor * @publicApi */ class AutoSizeVirtualScrollStrategy extends RxVirtualScrollStrategy { constructor() { super(...arguments); this.defaults = inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, { optional: true, }); /** * @description * The amount of items to render upfront in scroll direction */ this.runwayItems = this.defaults?.runwayItems ?? 10; /** * @description * The amount of items to render upfront in reverse scroll direction */ this.runwayItemsOpposite = this.defaults?.runwayItemsOpposite ?? 2; /** * @description * The default size of the items being rendered. The autosized strategy will assume * this size for items it doesn't know yet. For the smoothest experience, * you provide the mean size of all items being rendered - if possible of course. * * As soon as rxVirtualFor is able to also render actual tombstone items, this * will be the size of a tombstone item being rendered before the actual item * is inserted into its position. */ this.tombstoneSize = this.defaults?.itemSize ?? 50; /** * @description * When enabled, the autosized scroll strategy attaches a `ResizeObserver` * to every view within the given renderedRange. If your views receive * dimension changes that are not caused by list updates, this is a way to * still track height changes. This also applies to resize events of the whole * document. */ this.withResizeObserver = true; /** * @description * When enabled, the scroll strategy stops removing views from the viewport, * instead it only adds views. This setting can be changed on the fly. Views will be added in both directions * according to the user interactions. */ this.appendOnly = false; /** * @description * When enabled, the autosized scroll strategy removes css styles that * prevent the scrollbar from being in sync with the input device. * Use with caution, as this can lead to extremely weird scroll behavior * on chromium based browsers when the rendered views differ * in dimensions too much or change dimensions heavily. */ this.withSyncScrollbar = false; /** * @description * If this flag is true, the virtual scroll strategy maintains the scrolled item when new data * is prepended to the list. This is very useful when implementing a reversed infinite scroller, that prepends * data instead of appending it */ this.keepScrolledIndexOnPrepend = false; /** @internal */ this.viewport = null; /** @internal */ this.viewRepeater = null; /** @internal */ this._contentSize$ = new ReplaySubject(1); /** @internal */ this.contentSize$ = this._contentSize$.asObservable(); /** @internal */ this._contentSize = 0; /** @internal */ this._renderedRange$ = new Subject(); /** @internal */ this.renderedRange$ = this._renderedRange$.asObservable(); /** @internal */ this._renderedRange = { start: 0, end: 0 }; /** @internal */ this.positionedRange = { start: 0, end: 0 }; /** @internal */ this._scrolledIndex$ = new ReplaySubject(1); /** @internal */ this.scrolledIndex$ = this._scrolledIndex$.pipe(distinctUntilChanged()); /** * @internal * The action used to kick off the scroll process */ this.scrollToTrigger$ = new Subject(); /** @internal */ this._scrolledIndex = 0; /** * is set, when scrollToIndex is called * @internal * */ this._scrollToIndex = null; /** @internal */ this.containerSize = 0; /** @internal */ this.contentLength = 0; /** @internal */ this._virtualItems = []; /** @internal */ this.scrollTop = 0; /** @internal */ this.scrollTopWithOutOffset = 0; /** @internal */ this.scrollTopAfterOffset = 0; /** @internal */ this.viewportOffset = 0; /** @internal */ this.direction = 'down'; /** @internal */ this.anchorScrollTop = 0; /** @internal */ this.anchorItem = { index: 0, offset: 0, }; /** @internal */ this.lastScreenItem = { index: 0, offset: 0, }; /** @internal */ this.waitForScroll = false; /** @internal */ this.isStable$ = new ReplaySubject(1); /** @internal */ this.detached$ = new Subject(); /** @internal */ this.resizeObserver = inject(RxaResizeObserver, { self: true }); /** @internal */ this.recalculateRange$ = new Subject(); } /** @internal */ set contentSize(size) { this._contentSize = size; this._contentSize$.next(size); } get contentSize() { return this._contentSize; } /** @internal */ set renderedRange(range) { this._renderedRange = range; this._renderedRange$.next(range); } /** @internal */ get renderedRange() { return this._renderedRange; } /** @internal */ get scrolledIndex() { return this._scrolledIndex; } /** @internal */ set scrolledIndex(index) { this._scrolledIndex = index; this._scrolledIndex$.next(index); } /** @internal */ until$() { return (o$) => o$.pipe(takeUntil(this.detached$)); } /** @internal */ get extractSize() { return this.resizeObserverConfig?.extractSize ?? defaultSizeExtract; } /** @internal */ get isStable() { return this.isStable$.pipe(filter((w) => w)); } /** @internal */ ngOnChanges(changes) { if ((changes['runwayItemsOpposite'] && !changes['runwayItemsOpposite'].firstChange) || (changes['runwayItems'] && !changes['runwayItems'].firstChange)) { this.recalculateRange$.next(); } if (changes['withSyncScrollbar']) { this.updateScrollElementClass(); } } /** @internal */ ngOnDestroy() { this.detach(); } /** @internal */ attach(viewport, viewRepeater) { this.viewport = viewport; this.viewRepeater = viewRepeater; this.updateScrollElementClass(); this.maintainVirtualItems(); this.calcRenderedRange(); this.positionElements(); this.listenToScrollTrigger(); } /** @internal */ detach() { this.updateScrollElementClass(false); this.viewport = null; this.viewRepeater = null; this._virtualItems = []; this.resizeObserver.destroy(); this.detached$.next(); } scrollToIndex(index, behavior) { const _index = Math.min(Math.max(index, 0), Math.max(0, this.contentLength - 1)); if (_index !== this.scrolledIndex) { const scrollTop = this.calcInitialPosition(_index); this._scrollToIndex = _index; this.scrollToTrigger$.next({ scrollTop, behavior }); } } scrollTo(scrollTo, behavior) { this.waitForScroll = scrollTo !== this.scrollTop && this.contentSize > this.containerSize; if (this.waitForScroll) { this.isStable$.next(false); } this.viewport.scrollTo(this.viewportOffset + scrollTo, behavior); } /** * starts the subscriptions that maintain the virtualItems array on changes * to the underlying dataset * @internal */ maintainVirtualItems() { // reset virtual viewport when opposite orientation to the scroll direction // changes, as we have to expect dimension changes for all items when this // happens. This could also be configurable as it maybe costs performance this.viewport.containerRect$.pipe(map(({ width }) => width), distinctUntilChanged(), filter(() => this.renderedRange.end > 0 && this._virtualItems.length > 0), this.until$()).subscribe(() => { // reset because we have no idea how items will behave let i = 0; while (i < this.renderedRange.start) { this._virtualItems[i].cached = false; i++; } i = this.renderedRange.end; while (i < this.contentLength - 1) { this._virtualItems[i].cached = false; i++; } }); // synchronises the values with the virtual viewport we've built up // it might get costy when having > 100k elements, it's still faster than // the IterableDiffer approach, especially on move operations const itemCache = new Map(); const trackBy = this.viewRepeater._trackBy ?? ((i, item) => item); this.renderedRange$ .pipe(pairwise(), this.until$()) .subscribe(([oldRange, newRange]) => { let i = oldRange.start; if (i < this._virtualItems.length) { for (i; i < Math.min(this._virtualItems.length, oldRange.end); i++) { if (i < newRange.start || i >= newRange.end) { this._virtualItems[i].position = undefined; } } } }); this.viewRepeater.values$.pipe(this.until$(), tap((values) => { const dataArr = Array.isArray(values) ? values : values ? Array.from(values) : []; const existingIds = new Set(); let size = 0; const dataLength = dataArr.length; const virtualItems = new Array(dataLength); let anchorItemIndex = this.anchorItem.index; const keepScrolledIndexOnPrepend = this.keepScrolledIndexOnPrepend && dataArr.length > 0 && itemCache.size > 0; for (let i = 0; i < dataLength; i++) { const item = dataArr[i]; const id = trackBy(i, item); const cachedItem = itemCache.get(id); if (cachedItem === undefined) { // add virtualItems[i] = { size: 0 }; itemCache.set(id, { item: dataArr[i], index: i }); if (i <= anchorItemIndex) { anchorItemIndex++; } } else if (cachedItem.index !== i) { // move virtualItems[i] = this._virtualItems[cachedItem.index]; virtualItems[i].position = undefined; itemCache.set(id, { item: dataArr[i], index: i }); } else { // update // todo: properly determine update (Object.is?) virtualItems[i] = this._virtualItems[i]; // if index is not part of rendered range, remove cache if (!this.withResizeObserver || i < this.renderedRange.start || i >= this.renderedRange.end) { virtualItems[i].cached = false; } itemCache.set(id, { item: dataArr[i], index: i }); } existingIds.add(id); size += virtualItems[i].size || this.tombstoneSize; } this._virtualItems = virtualItems; // sync delete operations if (itemCache.size > dataLength) { itemCache.forEach((v, k) => { if (!existingIds.has(k)) { itemCache.delete(k); } }); } existingIds.clear(); this.contentLength = dataLength; if (keepScrolledIndexOnPrepend && this.anchorItem.index !== anchorItemIndex) { this.scrollToIndex(anchorItemIndex); } else if (dataLength === 0) { this.anchorItem = { index: 0, offset: 0, }; this._renderedRange = { start: 0, end: 0, }; this.scrollTo(0); this.scrollTop = this.anchorScrollTop = 0; } else if (dataLength < this._renderedRange.end) { this.anchorItem = this.calculateAnchoredItem({ index: dataLength, offset: 0, }, Math.max(-size, -calculateVisibleContainerSize(this.containerSize, this.scrollTopWithOutOffset, this.scrollTopAfterOffset))); this.calcAnchorScrollTop(); this._renderedRange = { start: Math.max(0, this.anchorItem.index - this.runwayItems), end: dataLength, }; this.scrollTo(size); this.scrollTop = this.anchorScrollTop; } this.contentSize = size; }), finalize(() => itemCache.clear())).subscribe(); } /** * listen to triggers that should change the renderedRange * @internal */ calcRenderedRange() { let removeScrollAnchorOnNextScroll = false; const onlyTriggerWhenStable = () => (o$) => o$.pipe(filter(() => this.renderedRange.end === 0 || (this.scrollTop === this.anchorScrollTop && this._scrollToIndex === null))); combineLatest([ this.viewport.containerRect$.pipe(map(({ height }) => { this.containerSize = height; return height; }), distinctUntilChanged(), onlyTriggerWhenStable()), this.viewport.elementScrolled$.pipe(startWith(void 0), tap(() => { this.viewportOffset = this.viewport.measureOffset(); const { scrollTop, scrollTopWithOutOffset, scrollTopAfterOffset } = parseScrollTopBoundaries(this.viewport.getScrollTop(), this.viewportOffset, this._contentSize, this.containerSize); this.direction = scrollTopWithOutOffset > this.scrollTopWithOutOffset ? 'down' : 'up'; this.scrollTopWithOutOffset = scrollTopWithOutOffset; this.scrollTopAfterOffset = scrollTopAfterOffset; this.scrollTop = scrollTop; if (removeScrollAnchorOnNextScroll) { this._scrollToIndex = null; removeScrollAnchorOnNextScroll = false; } else { removeScrollAnchorOnNextScroll = this._scrollToIndex !== null; } this.waitForScroll = false; })), this._contentSize$.pipe(distinctUntilChanged(), onlyTriggerWhenStable()), this.recalculateRange$.pipe(onlyTriggerWhenStable(), startWith(void 0)), ]) .pipe( // make sure to not over calculate things by coalescing all triggers to the next microtask coalesceWith(unpatchedMicroTask()), map(() => { const range = { start: 0, end: 0 }; const delta = this.scrollTop - this.anchorScrollTop; if (this.scrollTop === 0) { this.anchorItem = { index: 0, offset: 0 }; } else { this.anchorItem = this.calculateAnchoredItem(this.anchorItem, delta); } this.anchorScrollTop = this.scrollTop; this.scrolledIndex = this.anchorItem.index; this.lastScreenItem = this.calculateAnchoredItem(this.anchorItem, calculateVisibleContainerSize(this.containerSize, this.scrollTopWithOutOffset, this.scrollTopAfterOffset)); if (this.direction === 'up') { range.start = Math.max(0, this.anchorItem.index - this.runwayItems); range.end = Math.min(this.contentLength, this.lastScreenItem.index + this.runwayItemsOpposite); } else { range.start = Math.max(0, this.anchorItem.index - this.runwayItemsOpposite); range.end = Math.min(this.contentLength, this.lastScreenItem.index + this.runwayItems); } if (this.appendOnly) { range.start = Math.min(this._renderedRange.start, range.start); range.end = Math.max(this._renderedRange.end, range.end); } return range; })) .pipe(this.until$()) .subscribe((range) => { this.renderedRange = range; this.isStable$.next(!this.waitForScroll); }); } /** * position elements after they are created/updated/moved or their dimensions * change from other sources * @internal */ positionElements() { const viewsToObserve$ = new Subject(); const positionByIterableChange$ = this.viewRepeater.renderingStart$.pipe(switchMap((batchedUpdates) => { // initialIndex tells us what will be the first index to be change detected // if it's not the first one, we maybe have to adjust the position // of all items in the viewport before this index const initialIndex = batchedUpdates.size ? batchedUpdates.values().next().value + this.renderedRange.start : this.renderedRange.start; let position = 0; let scrollToAnchorPosition = null; return this.viewRepeater.viewRendered$.pipe(tap(({ view, index: viewIndex, item }) => { const itemIndex = view.context.index; // this most of the time causes a forced reflow per rendered view. // it doesn't sound good, but it's still way more stable than // having one large reflow in a microtask after the actual // scheduler tick. // Right here, we can insert work into the task which is currently // executed as part of the concurrent scheduler tick. // causing the forced reflow here, also makes it count for the // schedulers frame budget. This way we will always run with the // configured FPS. The only case where this is not true is when rendering 1 single view // already explodes the budget const [, sizeDiff] = this.updateElementSize(view, itemIndex); const virtualItem = this._virtualItems[itemIndex]; // before positioning the first view of this batch, calculate the // anchorScrollTop & initial position of the view if (itemIndex === initialIndex) { this.calcAnchorScrollTop(); position = this.calcInitialPosition(itemIndex); // if we receive a partial update and the current views position is // new, we can safely assume that all positions from views before the current // index are also off. We need to adjust them if (initialIndex > this.renderedRange.start && virtualItem.position !== position) { let beforePosition = position; let i = initialIndex - 1; while (i >= this.renderedRange.start) { const view = this.getViewRef(i - this.renderedRange.start); const virtualItem = this._virtualItems[i]; const element = this.getElement(view); beforePosition -= virtualItem.size; virtualItem.position = beforePosition; this.positionElement(element, beforePosition); i--; } } } else if (itemIndex < this.anchorItem.index && sizeDiff) { this.anchorScrollTop += sizeDiff; } const size = virtualItem.size; // position current element if we need to if (virtualItem.position !== position) { const element = this.getElement(view); this.positionElement(element, position); virtualItem.position = position; } if (this._scrollToIndex === itemIndex) { scrollToAnchorPosition = position; } position += size; // immediately activate the ResizeObserver after initial positioning viewsToObserve$.next(view); this.viewRenderCallback.next({ index: itemIndex, view, item, }); // after positioning the actual view, we also need to position all // views from the current index on until either the renderedRange.end // is hit or we hit an index that will anyway receive an update. // we can derive that information from the batchedUpdates index Set const { lastPositionedIndex: lastIndex, position: newPosition } = this.positionUnchangedViews({ viewIndex, itemIndex, batchedUpdates, position, }); position = newPosition; this.positionedRange.start = this.renderedRange.start; this.positionedRange.end = lastIndex + 1; }), coalesceWith(unpatchedMicroTask()), tap(() => { this.adjustContentSize(position); if (this._scrollToIndex === null) { this.maybeAdjustScrollPosition(); } else if (scrollToAnchorPosition != null) { if (scrollToAnchorPosition !== this.anchorScrollTop) { if (scrollToAnchorPosition > this.contentSize - this.containerSize) { // if the anchorItemPosition is larger than the maximum scrollPos, // we want to scroll until the bottom. // of course, we need to be sure all the items until the end are positioned // until we are sure that we need to scroll to the bottom if (this.renderedRange.end === this.positionedRange.end) { this._scrollToIndex = null; this.scrollTo(this.contentSize); } } else { this._scrollToIndex = null; this.scrollTo(scrollToAnchorPosition); } } else { this._scrollToIndex = null; this.maybeAdjustScrollPosition(); } } })); })); const positionByResizeObserver$ = viewsToObserve$.pipe(filter(() => this.withResizeObserver), groupBy((viewRef) => viewRef), mergeMap((o$) => o$.pipe(exhaustMap((viewRef) => this.observeViewSize$(viewRef)), tap(([index, viewIndex]) => { this.calcAnchorScrollTop(); let position = this.calcInitialPosition(index); let viewIdx = viewIndex; if (this._virtualItems[index].position !== position) { // we want to reposition the whole viewport, when the current position has changed while (viewIdx > 0) { viewIdx--; position -= this._virtualItems[this.getViewRef(viewIdx).context.index] .size; } } else { // we only need to reposition everything from the next viewIndex on viewIdx++; position += this._virtualItems[index].size; } // position all views from the specified viewIndex while (viewIdx < this.viewRepeater.viewContainer.length) { const view = this.getViewRef(viewIdx); const itemIndex = view.context.index; const virtualItem = this._virtualItems[itemIndex]; const element = this.getElement(view); this.updateElementSize(view, itemIndex); virtualItem.position = position; this.positionElement(element, position); position += virtualItem.size; viewIdx++; } this.maybeAdjustScrollPosition(); })))); merge(positionByIterableChange$, positionByResizeObserver$) .pipe(this.until$()) .subscribe(); } /** listen to API initiated scroll triggers (e.g. initialScrollIndex) */ listenToScrollTrigger() { this.scrollToTrigger$ .pipe(switchMap((scrollTo) => // wait until containerRect at least emitted once this.containerSize === 0 ? this.viewport.containerRect$.pipe(map(() => scrollTo), take(1)) : of(scrollTo)), this.until$()) .subscribe(({ scrollTop, behavior }) => { this.scrollTo(scrollTop, behavior); }); } /** @internal */ adjustContentSize(position) { let newContentSize = position; for (let i = this.positionedRange.end; i < this._virtualItems.length; i++) { newContentSize += this.getItemSize(i); } this.contentSize = newContentSize; } /** @internal */ observeViewSize$(viewRef) { const element = this.getElement(viewRef); return this.resizeObserver .observeElement(element, this.resizeObserverConfig?.options) .pipe(takeWhile((event) => event.target.isConnected && !!this._virtualItems[viewRef.context.index]), map((event) => { const index = viewRef.context.index; const size = Math.round(this.extractSize(event)); const diff = size - this._virtualItems[index].size; if (diff !== 0) { this._virtualItems[index].size = size; this._virtualItems[index].cached = true; this.contentSize += diff; return [index, this.viewRepeater.viewContainer.indexOf(viewRef)]; } return null; }), filter((diff) => diff !== null && diff[0] >= this.positionedRange.start && diff[0] < this.positionedRange.end), takeUntil(merge(this.viewRepeater.viewRendered$, this.viewRepeater.renderingStart$).pipe(tap(() => { // we need to clean up the position property for views // that fall out of the renderedRange. const index = viewRef.context.index; if (this._virtualItems[index] && (index < this.renderedRange.start || index >= this.renderedRange.end)) { this._virtualItems[index].position = undefined; } }), filter(() => this.viewRepeater.viewContainer.indexOf(viewRef) === -1)))); } /** * @internal * heavily inspired by * https://github.com/GoogleChromeLabs/ui-element-samples/blob/gh-pages/infinite-scroller/scripts/infinite-scroll.js */ calculateAnchoredItem(initialAnchor, delta) { if (delta === 0) return initialAnchor; delta += initialAnchor.offset; let i = initialAnchor.index; const items = this._virtualItems; if (delta < 0) { while (delta < 0 && i > 0) { delta += this.getItemSize(i - 1); i--; } } else { while (delta > 0 && i < items.length && this.getItemSize(i) <= delta) { delta -= this.getItemSize(i); i++; } } return { index: Math.min(i, items.length), offset: delta, }; } /** @internal */ positionUnchangedViews({ viewIndex, itemIndex, batchedUpdates, position, }) { let _viewIndex = viewIndex + 1; let index = itemIndex + 1; let lastPositionedIndex = itemIndex; while (!batchedUpdates.has(_viewIndex) && index < this.renderedRange.end) { const virtualItem = this._virtualItems[index]; if (position !== virtualItem.position) { const view = this.getViewRef(_viewIndex); const element = this.getElement(view); this.positionElement(element, position); virtualItem.position = position; } position += virtualItem.size; lastPositionedIndex = index; index++; _viewIndex++; } return { position, lastPositionedIndex }; } /** * Adjust the scroll position when the anchorScrollTop differs from * the actual scrollTop. * Trigger a range recalculation if there is empty space * * @internal */ maybeAdjustScrollPosition() { if (this.anchorScrollTop !== this.scrollTop) { this.scrollTo(this.anchorScrollTop); } } /** @internal */ calcAnchorScrollTop() { this.anchorScrollTop = 0; for (let i = 0; i < this.anchorItem.index; i++) { this.anchorScrollTop += this.getItemSize(i); } this.anchorScrollTop += this.anchorItem.offset; } /** @internal */ calcInitialPosition(start) { // Calculate position of starting node let pos = this.anchorScrollTop - this.anchorItem.offset; let i = this.anchorItem.index; while (i > start) { const itemSize = this.getItemSize(i - 1); pos -= itemSize; i--; } while (i < start) { const itemSize = this.getItemSize(i); pos += itemSize; i++; } return pos; } /** @internal */ getViewRef(index) { return (this.viewRepeater.viewContainer.get(index)); } /** @internal */ updateElementSize(view, index) { const oldSize = this.getItemSize(index); const isCached = this._virtualItems[index].cached; const size = isCached ? oldSize : this.getElementSize(this.getElement(view)); this._virtualItems[index].size = size; this._virtualItems[index].cached = true; return [size, size - oldSize]; } /** @internal */ getItemSize(index) { return this._virtualItems[index].size || this.tombstoneSize; } /** @internal */ getElementSize(element) { return element.offsetHeight; } /** @internal */ positionElement(element, scrollTop) { element.style.position = 'absolute'; element.style.transform = `translateY(${scrollTop}px)`; } /** @internal */ updateScrollElementClass(force = this.withSyncScrollbar) { const scrollElement = this.viewport?.getScrollElement?.(); if (!!scrollElement && scrollElement.classList.contains('rx-virtual-scroll-element')) { scrollElement.classList.toggle('rx-virtual-scroll-element--withSyncScrollbar', force); } } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: AutoSizeVirtualScrollStrategy, deps: null, target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.0.0", type: AutoSizeVirtualScrollStrategy, isStandalone: true, selector: "rx-virtual-scroll-viewport[autosize]", inputs: { runwayItems: "runwayItems", runwayItemsOpposite: "runwayItemsOpposite", tombstoneSize: "tombstoneSize", resizeObserverConfig: "resizeObserverConfig", withResizeObserver: ["withResizeObserver", "withResizeObserver", toBoolean], appendOnly: ["appendOnly", "appendOnly", toBoolean], withSyncScrollbar: ["withSyncScrollbar", "withSyncScrollbar", toBoolean], keepScrolledIndexOnPrepend: ["keepScrolledIndexOnPrepend", "keepScrolledIndexOnPrepend", toBoolean] }, providers: [ { provide: RxVirtualScrollStrategy, useExisting: AutoSizeVirtualScrollStrategy, }, RxaResizeObserver, ], usesInheritance: true, usesOnChanges: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: AutoSizeVirtualScrollStrategy, decorators: [{ type: Directive, args: [{ selector: 'rx-virtual-scroll-viewport[autosize]', providers: [ { provide: RxVirtualScrollStrategy, useExisting: AutoSizeVirtualScrollStrategy, }, RxaResizeObserver, ], standalone: true, }] }], propDecorators: { runwayItems: [{ type: Input }], runwayItemsOpposite: [{ type: Input }], tombstoneSize: [{ type: Input }], resizeObserverConfig: [{ type: Input }], withResizeObserver: [{ type: Input, args: [{ transform: toBoolean }] }], appendOnly: [{ type: Input, args: [{ transform: toBoolean }] }], withSyncScrollbar: [{ type: Input, args: [{ transform: toBoolean }] }], keepScrolledIndexOnPrepend: [{ type: Input, args: [{ transform: toBoolean }] }] } }); const defaultItemSize = () => DEFAULT_ITEM_SIZE; /** * @Directive DynamicSizeVirtualScrollStrategy * * @description * * The `DynamicSizeVirtualScrollStrategy` is very similar to the `AutosizeVirtualScrollStrategy`. * It positions items based on a function determining its size. * * @docsCategory RxVirtualFor * @docsPage RxVirtualFor * @publicApi */ class DynamicSizeVirtualScrollStrategy extends RxVirtualScrollStrategy { constructor() { super(...arguments); this.defaults = inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, { optional: true, }); /** * @description * The amount of items to render upfront in scroll direction */ this.runwayItems = this.defaults?.runwayItems ?? DEFAULT_RUNWAY_ITEMS; /** * @description * The amount of items to render upfront in reverse scroll direction */ this.runwayItemsOpposite = this.defaults?.runwayItemsOpposite ?? DEFAULT_RUNWAY_ITEMS_OPPOSITE; /** * @description * When enabled, the scroll strategy stops removing views from the viewport, * instead it only adds views. This setting can be changed on the fly. Views will be added in both directions * according to the user interactions. */ this.appendOnly = false; /** * @description * If this flag is true, the virtual scroll strategy maintains the scrolled item when new data * is prepended to the list. This is very useful when implementing a reversed infinite scroller, that prepends * data instead of appending it */ this.keepScrolledIndexOnPrepend = false; this._itemSizeFn = defaultItemSize; /** @internal */ this.waitForScroll = false; /** @internal */ this.isStable$ = new ReplaySubject(1); /** @internal */ this.viewport = null; /** @internal */ this.viewRepeater = null; /** @internal */ this._contentSize$ = new ReplaySubject(1); /** @internal */ this.contentSize$ = this._contentSize$.asObservable(); /** @internal */ this._contentSize = 0; /** @internal */ this._renderedRange$ = new ReplaySubject(1); /** @internal */ this.renderedRange$ = this._renderedRange$.asObservable(); /** @internal */ this._renderedRange = { start: 0, end: 0 }; /** @internal */ this._scrolledIndex$ = new ReplaySubject(1); /** @internal */ this.scrolledIndex$ = this._scrolledIndex$.pipe(distinctUntilChanged()); this._scrolledIndex = 0; /** @internal */ this.containerSize = 0; /** @internal */ this._virtualItems = []; /** @internal */ this.scrollTop = 0; /** @internal */ this.scrollTopWithOutOffset = 0; /** @internal */ this.scrollTopAfterOffset = 0; /** @internal */ this.viewportOffset = 0; /** @internal */ this.direction = 'down'; /** @internal */ this.anchorScrollTop = 0; /** @internal */ this.anchorItem = { index: 0, offset: 0, }; /** @internal */ this.lastScreenItem = { index: 0, offset: 0, }; /** @internal */ this.detached$ = new Subject(); /** @internal */ this.recalculateRange$ = new Subject(); } /** * @description * Function returning the size of an item */ set itemSize(fn) { if (fn) { this._itemSizeFn = fn; } } get itemSize() { return this._itemSizeFn; } /** @internal */ get isStable() { return this.isStable$.pipe(filter((w) => w)); } /** @internal */ set contentSize(size) { this._contentSize = size; this._contentSize$.next(size); } get contentSize() { return this._contentSize; } // range of items where size is known and doesn't need to be re-calculated /** @internal */ set renderedRange(range) { this._renderedRange = range; this._renderedRange$.next(range); } /** @internal */ get renderedRange() { return this._renderedRange; } /** @internal */ set scrolledIndex(index) { this._scrolledIndex = index; this._scrolledIndex$.next(index); } get scrolledIndex() { return this._scrolledIndex; } /** @internal */ get contentLength() { return this._virtualItems.length; } /** @internal */ until$() { return (o$) => o$.pipe(takeUntil(this.detached$)); } /** @internal */ ngOnChanges(changes) { if ((changes['runwayItemsOpposite'] && !changes['runwayItemsOpposite'].firstChange) || (changes['runwayItems'] && !changes['runwayItems'].firstChange)) { this.recalculateRange$.next(); } } /** @internal */ ngOnDestroy() { this.detach();