@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
JavaScript
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();