@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.
2,833 lines • 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();
}
/** @internal */
attach(viewport, viewRepeater) {
this.viewport = viewport;
this.viewRepeater = viewRepeater;
this.maintainVirtualItems();
this.calcRenderedRange();
this.positionElements();
}
/** @internal */
detach() {
this.viewport = null;
this.viewRepeater = null;
this._virtualItems = [];
this.detached$.next();
}
scrollToIndex(index, behavior) {
const _index = Math.min(Math.max(index, 0), this.contentLength - 1);
let scrollTo = 0;
for (let i = 0; i < _index; i++) {
scrollTo += this._virtualItems[i].size;
}
this.scrollTo(scrollTo, 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);
}
/** @internal */
maintainVirtualItems() {
const valueArray$ = this.viewRepeater.values$.pipe(map((values) => Array.isArray(values)
? values
: values != null
? Array.from(values)
: []), shareReplay({ bufferSize: 1, refCount: true }));
valueArray$.pipe(this.until$()).subscribe((dataArr) => {
const dataLength = dataArr.length;
if (!dataLength) {
this._virtualItems = [];
this.contentSize = 0;
this._renderedRange = {
start: 0,
end: 0,
};
this.anchorItem = {
index: 0,
offset: 0,
};
this.recalculateRange$.next();
}
else {
let shouldRecalculateRange = false;
let contentSize = 0;
for (let i = 0; i < dataArr.length; i++) {
const oldSize = this._virtualItems[i]?.size;
const newSize = this.itemSize(dataArr[i]);
contentSize += newSize;
if (oldSize === undefined || oldSize !== newSize) {
this._virtualItems[i] = { size: newSize };
if (!shouldRecalculateRange &&
(!this.contentSize ||
(i >= this.renderedRange.start && i < this.renderedRange.end))) {
shouldRecalculateRange = true;
}
}
}
if (dataLength < this._renderedRange.end) {
this.anchorItem = this.calculateAnchoredItem({
index: dataLength,
offset: 0,
}, -calculateVisibleContainerSize(this.containerSize, this.scrollTopWithOutOffset, this.scrollTopAfterOffset));
this._renderedRange.start = Math.max(0, this.anchorItem.index - this.runwayItems);
this._renderedRange.end = dataLength;
this.calcAnchorScrollTop();
this.scrollTo(contentSize);
this.scrollTop = this.anchorScrollTop;
}
this.contentSize = contentSize;
if (shouldRecalculateRange) {
this.recalculateRange$.next();
}
}
});
let valueCache = {};
/*
* when keepScrolledIndexOnPrepend is active, we need to listen to data changes and figure out what was appended
* before the last scrolledToItem
*/
valueArray$
.pipe(
// TODO: this might cause issues when turning on/off at runtime
filter(() => this.keepScrolledIndexOnPrepend), this.until$())
.subscribe((valueArray) => {
const trackBy = this.viewRepeater._trackBy;
let scrollTo = this.scrolledIndex;
const dataLength = valueArray.length;
const oldDataLength = Object.keys(valueCache).length;
if (oldDataLength > 0) {
let i = 0;
// check for each item from the last known scrolledIndex if it's an insert
for (i; i <= scrollTo && i < dataLength; i++) {
// item is not in the valueCache, so it was added
if (!valueCache[trackBy(i, valueArray[i])]) {
scrollTo++;
}
}
}
valueCache = {};
valueArray.forEach((v, i) => (valueCache[trackBy(i, v)] = v));
if (scrollTo !== this.scrolledIndex) {
this.scrollToIndex(scrollTo);
}
});
}
/** @internal */
calcRenderedRange() {
combineLatest([
this.viewport.containerRect$.pipe(map(({ height }) => {
this.containerSize = height;
return height;
}), distinctUntilChanged()),
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;
this.waitForScroll = false;
})),
this._contentSize$.pipe(distinctUntilChanged()),
this.recalculateRange$.pipe(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 length = this.contentLength;
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.scrolledIndex = this.anchorItem.index;
this.anchorScrollTop = this.scrollTop;
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(length, this.lastScreenItem.index + this.runwayItemsOpposite);
}
else {
range.start = Math.max(0, this.anchorItem.index - this.runwayItemsOpposite);
range.end = Math.min(length, 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);
});
}
/** @internal */
positionElements() {
this.viewRepeater.renderingStart$.pipe(switchMap((batchedUpdates) => {
const renderedRange = this.renderedRange;
const adjustIndexWith = renderedRange.start;
const initialIndex = batchedUpdates.size
? batchedUpdates.values().next().value + this.renderedRange.start
: this.renderedRange.start;
let position = this.calcInitialPosition(initialIndex);
return this.viewRepeater.viewRendered$.pipe(tap(({ view, index: viewIndex, item }) => {
const index = viewIndex + adjustIndexWith;
const size = this.getItemSize(index);
this.positionElement(this.getElement(view), position);
position += size;
this.viewRenderCallback.next({
index,
view,
item,
});
}));
}), this.until$()).subscribe();
}
/**
* @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 += items[i - 1].size;
i--;
}
}
else {
while (delta > 0 && i < items.length && items[i].size <= delta) {
delta -= items[i].size;
i++;
}
}
return {
index: Math.min(i, items.length),
offset: delta,
};
}
/** @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 */
calcAnchorScrollTop() {
this.anchorScrollTop = 0;
for (let i = 0; i < this.anchorItem.index; i++) {
this.anchorScrollTop += this.getItemSize(i);
}
this.anchorScrollTop += this.anchorItem.offset;
}
/** @internal */
getItemSize(index) {
return this._virtualItems[index].size;
}
/** @internal */
positionElement(element, scrollTop) {
element.style.position = 'absolute';
element.style.transform = `translateY(${scrollTop}px)`;
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: DynamicSizeVirtualScrollStrategy, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
/** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.0.0", type: DynamicSizeVirtualScrollStrategy, isStandalone: true, selector: "rx-virtual-scroll-viewport[dynamic]", inputs: { runwayItems: "runwayItems", runwayItemsOpposite: "runwayItemsOpposite", appendOnly: ["appendOnly", "appendOnly", toBoolean], keepScrolledIndexOnPrepend: ["keepScrolledIndexOnPrepend", "keepScrolledIndexOnPrepend", toBoolean], itemSize: ["dynamic", "itemSize"] }, providers: [
{
provide: RxVirtualScrollStrategy,
useExisting: DynamicSizeVirtualScrollStrategy,
},
], usesInheritance: true, usesOnChanges: true, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: DynamicSizeVirtualScrollStrategy, decorators: [{
type: Directive,
args: [{
selector: 'rx-virtual-scroll-viewport[dynamic]',
providers: [
{
provide: RxVirtualScrollStrategy,
useExisting: DynamicSizeVirtualScrollStrategy,
},
],
standalone: true,
}]
}], propDecorators: { runwayItems: [{
type: Input
}], runwayItemsOpposite: [{
type: Input
}], appendOnly: [{
type: Input,
args: [{ transform: toBoolean }]
}], keepScrolledIndexOnPrepend: [{
type: Input,
args: [{ transform: toBoolean }]
}], itemSize: [{
type: Input,
args: ['dynamic']
}] } });
/**
* @Directive FixedSizeVirtualScrollStrategy
*
* @description
*
* The `FixedSizeVirtualScrollStrategy` provides a very performant way of rendering
* items of a given size. It is comparable to \@angular/cdk `FixedSizeVirtualScrollStrategy`, but
* with a high performant layouting technique.
*
* @docsCategory RxVirtualFor
* @docsPage RxVirtualFor
* @publicApi
*/
class FixedSizeVirtualScrollStrategy extends RxVirtualScrollStrategy {
constructor() {
super(...arguments);
this.defaults = inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, {
optional: true,
});
this._itemSize = DEFAULT_ITEM_SIZE;
/**
* @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
* 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
* 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.runwayStateChanged$ = new Subject();
this.viewport = null;
this.viewRepeater = null;
this._scrolledIndex$ = new ReplaySubject(1);
this.scrolledIndex$ = this._scrolledIndex$.pipe(distinctUntilChanged());
this._scrolledIndex = 0;
this._contentSize$ = new ReplaySubject(1);
this.contentSize$ = this._contentSize$.asObservable();
this._contentSize = 0;
this._renderedRange$ = new ReplaySubject(1);
this.renderedRange$ = this._renderedRange$.asObservable();
this._renderedRange = { start: 0, end: 0 };
this.scrollTop = 0;
/** @internal */
this.scrollTopWithOutOffset = 0;
/** @internal */
this.scrollTopAfterOffset = 0;
/** @internal */
this.viewportOffset = 0;
/** @internal */
this.containerSize = 0;
this.direction = 'down';
this.detached$ = new Subject();
}
/**
* @description
* The size of the items in the virtually scrolled list
*/
set itemSize(itemSize) {
if (typeof itemSize === 'number') {
this._itemSize = itemSize;
}
}
get itemSize() {
return this._itemSize;
}
set scrolledIndex(index) {
this._scrolledIndex = index;
this._scrolledIndex$.next(index);
}
get scrolledIndex() {
return this._scrolledIndex;
}
set contentSize(size) {
this._contentSize = size;
this._contentSize$.next(size);
}
set renderedRange(range) {
this._renderedRange = range;
this._renderedRange$.next(range);
}
get renderedRange() {
return this._renderedRange;
}
/** @internal */
ngOnChanges(changes) {
if ((changes['runwayItemsOpposite'] &&
!changes['runwayItemsOpposite'].firstChange) ||
(changes['runwayItems'] && !changes['runwayItems'].firstChange)) {
this.runwayStateChanged$.next();
}
}
ngOnDestroy() {
this.detach();
}
attach(viewport, viewRepeater) {
this.viewport = viewport;
this.viewRepeater = viewRepeater;
this.calcRenderedRange();
this.positionElements();
}
detach() {
this.viewport = null;
this.viewRepeater = null;
this.detached$.next();
}
positionElements() {
this.viewRepeater.renderingStart$.pipe(switchMap(() => {
const start = this.renderedRange.start;
return this.viewRepeater.viewRendered$.pipe(tap(({ view, index, item }) => {
this._setViewPosition(view, (index + start) * this.itemSize);
this.viewRenderCallback.next({
view,
item,
index,
});
}));
}), this.untilDetached$()).subscribe();
}
calcRenderedRange() {
const valueArray$ = this.viewRepeater.values$.pipe(map((values) => Array.isArray(values)
? values
: values != null
? Array.from(values)
: []), shareReplay({ bufferSize: 1, refCount: true }));
/*
* when keepScrolledIndexOnPrepend is active, we need to listen to data changes and figure out what was appended
* before the last scrolledToItem
*/
let valueCache = {};
valueArray$
.pipe(
// TODO: this might cause issues when turning on/off
filter(() => this.keepScrolledIndexOnPrepend), this.untilDetached$())
.subscribe((valueArray) => {
const trackBy = this.viewRepeater._trackBy;
let scrollTo = this.scrolledIndex;
const dataLength = valueArray.length;
const oldDataLength = Object.keys(valueCache).length;
if (oldDataLength > 0) {
let i = 0;
// check for each item from the last known scrolledIndex if it's an insert
for (i; i <= scrollTo && i < dataLength; i++) {
// item is not in the valueCache, so it was added
if (!valueCache[trackBy(i, valueArray[i])]) {
scrollTo++;
}
}
}
valueCache = {};
valueArray.forEach((v, i) => (valueCache[trackBy(i, v)] = v));
if (scrollTo !== this.scrolledIndex) {
this.scrollToIndex(scrollTo);
}
});
const dataLengthChanged$ = valueArray$.pipe(map((values) => values.length), distinctUntilChanged(), tap((dataLength) => (this.contentSize = dataLength * this.itemSize)));
const onScroll$ = this.viewport.elementScrolled$.pipe(coalesceWith(unpatchedAnimationFrameTick()), 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;
}));
combineLatest([
dataLengthChanged$,
this.viewport.containerRect$.pipe(map(({ height }) => {
this.containerSize = height;
return height;
}), distinctUntilChanged()),
onScroll$,
this.runwayStateChanged$.pipe(startWith(void 0)),
])
.pipe(map(([length]) => {
const containerSize = calculateVisibleContainerSize(this.containerSize, this.scrollTopWithOutOffset, this.scrollTopAfterOffset);
const range = { start: 0, end: 0 };
if (this.direction === 'up') {
range.start = Math.floor(Math.max(0, this.scrollTop - this.runwayItems * this.itemSize) /
this.itemSize);
range.end = Math.min(length, Math.ceil((this.scrollTop +
containerSize +
this.runwayItemsOpposite * this.itemSize) /
this.itemSize));
}
else {
range.start = Math.floor(Math.max(0, this.scrollTop - this.runwayItemsOpposite * this.itemSize) / this.itemSize);
range.end = Math.min(length, Math.ceil((this.scrollTop +
containerSize +
this.runwayItems * this.itemSize) /
this.itemSize));
}
if (this.appendOnly) {
range.start = Math.min(this._renderedRange.start, range.start);
range.end = Math.max(this._renderedRange.end, range.end);
}
this.scrolledIndex = Math.floor(this.scrollTop / this.itemSize);
return range;
}), distinctUntilChanged(({ start: prevStart, end: prevEnd }, { start, end }) => prevStart === start && prevEnd === end), this.untilDetached$())
.subscribe((range) => (this.renderedRange = range));
}
scrollToIndex(index, behavior) {
const scrollTop = this.itemSize * index;
this.viewport.scrollTo(this.viewportOffset + scrollTop, behavior);
}
untilDetached$() {
return (o$) => o$.pipe(takeUntil(this.detached$));
}
_setViewPosition(view, scrollTop) {
const element = this.getElement(view);
element.style.position = 'absolute';
element.style.transform = `translateY(${scrollTop}px)`;
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: FixedSizeVirtualScrollStrategy, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
/** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.0.0", type: FixedSizeVirtualScrollStrategy, isStandalone: true, selector: "rx-virtual-scroll-viewport[itemSize]", inputs: { itemSize: "itemSize", appendOnly: ["appendOnly", "appendOnly", toBoolean], runwayItems: "runwayItems", runwayItemsOpposite: "runwayItemsOpposite", keepScrolledIndexOnPrepend: ["keepScrolledIndexOnPrepend", "keepScrolledIndexOnPrepend", toBoolean] }, providers: [
{
provide: RxVirtualScrollStrategy,
useExisting: FixedSizeVirtualScrollStrategy,
},
], usesInheritance: true, usesOnChanges: true, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: FixedSizeVirtualScrollStrategy, decorators: [{
type: Directive,
args: [{
selector: 'rx-virtual-scroll-viewport[itemSize]',
providers: [
{
provide: RxVirtualScrollStrategy,
useExisting: FixedSizeVirtualScrollStrategy,
},
],
standalone: true,
}]
}], propDecorators: { itemSize: [{
type: Input
}], appendOnly: [{
type: Input,
args: [{ transform: toBoolean }]
}], runwayItems: [{
type: Input
}], runwayItemsOpposite: [{
type: Input
}], keepScrolledIndexOnPrepend: [{
type: Input,
args: [{ transform: toBoolean }]
}] } });
/**
* @internal
*
* Factory that returns a `ListTemplateManager` for the passed params.
*
* @param templateSettings
*/
function createVirtualListTemplateManager({ viewContainerRef, templateRef, createViewContext, updateViewContext, templateCacheSize, }) {
let _viewCache = [];
let itemCount = 0;
return {
getListChanges,
setItemCount: (count) => (itemCount = count),
detach: () => {
for (let i = 0; i < _viewCache.length; i++) {
_viewCache[i].destroy();
}
_viewCache = [];
},
};
function _updateView(item, index, count, contextIndex) {
const view = viewContainerRef.get(index);
updateViewContext(item, view, {
count,
index: contextIndex,
});
view.detectChanges();
return view;
}
/**
* Inserts a view for a new item, either from the cache or by creating a new
* one.
*/
function _insertView(value, count, adjustIndexWith, currentIndex) {
currentIndex = currentIndex ?? viewContainerRef.length;
const contextIndex = currentIndex + adjustIndexWith;
const cachedView = _insertViewFromCache(currentIndex);
if (cachedView) {
updateViewContext(value, cachedView, {
count,
index: contextIndex,
});
cachedView.detectChanges();
return [currentIndex, cachedView];
}
const context = createViewContext(value, {
count,
index: contextIndex,
});
const view = viewContainerRef.createEmbeddedView(templateRef, context, currentIndex);
view.detectChanges();
return [currentIndex, view];
}
/** Detaches the view at the given index and inserts into the view cache. */
function _detachAndCacheView(index) {
const detachedView = viewContainerRef.detach(index);
_maybeCacheView(detachedView);
detachedView.detectChanges();
}
/** Moves view at the previous index to the current index. */
function _moveView(value, adjustedPreviousIndex, currentIndex, count, contextIndex) {
const oldView = viewContainerRef.get(adjustedPreviousIndex);
const view = (viewContainerRef.move(oldView, currentIndex));
updateViewContext(value, view, {
count,
index: contextIndex,
});
view.detectChanges();
return view;
}
/**
* Cache the given detached view. If the cache is full, the view will be
* destroyed.
*/
function _maybeCacheView(view) {
if (_viewCache.length < templateCacheSize) {
_viewCache.push(view);
return true;
}
else {
const index = viewContainerRef.indexOf(view);
// The host component could remove views from the container outside of
// the view repeater. It's unlikely this will occur, but just in case,
// destroy the view on its own, otherwise destroy it through the
// container to ensure that all the references are removed.
if (index === -1) {
view.destroy();
}
else {
viewContainerRef.remove(index);
}
return false;
}
}
/** Inserts a recycled view from the cache at the given index. */
function _insertViewFromCache(index) {
const cachedView = _viewCache.pop();
if (cachedView) {
return viewContainerRef.insert(cachedView, index);
}
return null;
}
/**
* @internal
*/
function getListChanges(changes, items, count, adjustIndexWith) {
const changedIdxs = new Set();
const listChanges = [];
let notifyParent = false;
let appendedAtEnd = 0;
const otherMovedIds = [];
changes.forEachOperation(({ item, previousIndex }, adjustedPreviousIndex, currentIndex) => {
if (previousIndex == null) {
// insert
const index = currentIndex === null ? undefined : currentIndex;
listChanges.push([
index ?? items.length + appendedAtEnd,
() => {
const [insertedIndex, view] = _insertView(item, count, adjustIndexWith, index);
return {
view,
index: insertedIndex,
item,
};
},
]);
if (index === undefined) {
appendedAtEnd++;
}
changedIdxs.add(item);
notifyParent = true;
}
else if (currentIndex == null) {
// remove
listChanges.push([
adjustedPreviousIndex,
() => {
_detachAndCacheView(adjustedPreviousIndex ?? undefined);
return { item };
},
true,
]);
notifyParent = true;
}
else if (adjustedPreviousIndex !== null) {
// move
listChanges.push([
currentIndex,
() => {
const view = _moveView(item, adjustedPreviousIndex, currentIndex, count, currentIndex + adjustIndexWith);
return {
view,
index: currentIndex,
item,
};
},
]);
otherMovedIds.push(adjustedPreviousIndex);
changedIdxs.add(item);
notifyParent = true;
}
});
changes.forEachIdentityChange(({ item, currentIndex }) => {
if (currentIndex != null && !changedIdxs.has(item)) {
listChanges.push([
currentIndex,
() => {
const view = _updateView(item, currentIndex, count, currentIndex + adjustIndexWith);
return {
view,
index: currentIndex,
item,
};
},
]);
changedIdxs.add(item);
}
});
for (let i = 0; i < otherMovedIds.length; i++) {
const itemIndex = otherMovedIds[i];
const item = items[itemIndex];
if (item && !changedIdxs.has(item)) {
changedIdxs.add(item);
listChanges.push([
itemIndex,
() => maybeUpdateView(itemIndex, count, itemIndex + adjustIndexWith, item),
]);
}
}
if (changedIdxs.size < items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!changedIdxs.has(item)) {
listChanges.push([
i,
() => maybeUpdateView(i, count, i + adjustIndexWith, item),
]);
}
}
}
return [listChanges, notifyParent];
}
function maybeUpdateView(viewIndex, count, itemIndex, item) {
const view = viewContainerRef.get(viewIndex);
if (view.context.count !== count || view.context.index !== itemIndex) {
return {
view: _updateView(item, viewIndex, count, itemIndex),
index: viewIndex,
item,
};
}
return {
index: viewIndex,
view,
item,
};
}
}
const NG_DEV_MODE$1 = typeof ngDevMode === 'undefined' || !!ngDevMode;
/**
* @Directive RxVirtualFor
*
* @description
*
* The `*rxVirtualFor` structural directive provides a convenient and performant
* way for rendering huge lists of items. It brings all the benefits `rxFor` does,
* and implements virtual rendering.
*
* Instead of rendering every item provided, rxVirtualFor only renders what is
* currently visible to the user, thus providing excellent runtime performance
* for huge sets of data.
*
* The technique to render items is comparable to the on used by twitter and
* explained in very much detail by @DasSurma in his blog post about the [complexities
* of infinite scrollers](https://developer.chrome.com/blog/infinite-scroller/).
*
* "Each recycling of a DOM element would normally relayout the entire runway which
* would bring us well below our target of 60 frames per second.
* To avoid this, we are taking the burden of layout onto ourselves and use
* absolutely positioned elements with transforms." (@DasSurma)
*
* ## API
* The API is a combination of \@rx-angular/template/for &
* \@angular/cdk `*cdkVirtualFor`.
* * trackBy: `(index: number, item: T) => any` | `keyof T`
* * strategy: `string` | `Observable<string>`
* * parent: `boolean`;
* * renderCallback: `Subject<T[]>`
* * viewCache: `number`
* * (Injected) scrollStrategy: `RxVirtualScrollStrategy<T, U>`
* * provides itself as RxVirtualViewRepeater for RxVirtualViewPortComponent to operate
*
* ## Features
* * Push based architecture
* * Comprehensive set of context variables
* * Opt-out of `NgZone` with `patchZone`
* * Notify when rendering of child templates is finished (`renderCallback`)
* * Super efficient layouting with css transformations
* * Define a viewCache in order to re-use views instead of re-creating them
* * Configurable RxVirtualScrollStrategy<T, U> providing the core logic to calculate the viewRange and position DOM
* Nodes
*
* ### Context Variables
*
* The following context variables are available for each template:
*
* - $implicit: `T` // the default variable accessed by `let val`
* - item$: `Observable<T>` // the same value as $implicit, but as `Observable`
* - index: `number` // current index of the item
* - count: `number` // count of all items in the list
* - first: `boolean` // true if the item is the first in the list
* - last: `boolean` // true if the item is the last in the list
* - even: `boolean` // true if the item has on even index (index % 2 === 0)
* - odd: `boolean` // the opposite of even
* - index$: `Observable<number>` // index as `Observable`
* - count$: `Observable<number>` // count as `Observable`
* - first$: `Observable<boolean>` // first as `Observable`
* - last$: `Observable<boolean>` // last as `Observable`
* - even$: `Observable<boolean>` // even as `Observable`
* - odd$: `Observable<boolean>` // odd as `Observable`
* - select: `(keys: (keyof T)[], distinctByMap) => Observable<Partial<T>>`
* // returns a selection function which
* // accepts an array of properties to pluck out of every list item. The function returns the selected properties of
* // the current list item as distinct `Observable` key-value-pair. See the example below:
*
* This example showcases the `select` view-context function used for deeply nested lists.
*
* ```html
* <rx-virtual-scroll-viewport>
* <div
* autosized
* *rxVirtualFor="let hero of heroes$; trackBy: trackItem; let select = select;">
* <div>
* <strong>{{ hero.name }}</strong></br>
* Defeated enemies:
* </div>
* <span *rxFor="let enemy of select(['defeatedEnemies']); trackBy: trackEnemy;">
* {{ enemy.name }}
* </span>
* </div>
* </rx-virtual-scroll-viewport>
* ```
*
* ### Using the context variables
*
* ```html
* <rx-virtual-scroll-viewport>
* <div
* *rxVirtualFor="
* let item of observableItems$;
* let count = count;
* let index = index;
* let first = first;
* let last = last;
* let even = even;
* let odd = odd;
* trackBy: trackItem;
* "
* >
* <div>{{ count }}</div>
* <div>{{ index }}</div>
* <div>{{ item }}</div>
* <div>{{ first }}</div>
* <div>{{ last }}</div>
* <div>{{ even }}</div>
* <div>{{ odd }}</div>
* </div>
* </rx-virtual-scroll-viewport>
* ```
*
* @docsCategory RxVirtualFor
* @docsPage RxVirtualFor
* @publicApi
*/
class RxVirtualFor {
/**
* @description
* The iterable input
*
* @example
* <rx-virtual-scroll-viewport>
* <app-hero *rxVirtualFor="heroes$; let hero"
* [hero]="hero"></app-hero>
* </rx-virtual-scroll-viewport>
*
* @param potentialSignalOrObservable
*/
set rxVirtualForOf(potentialSignalOrObservable) {
if (isSignal(potentialSignalOrObservable)) {
this.staticValue = undefined;
this.renderStatic = false;
this.observables$.next(toObservable(potentialSignalOrObservable, { injector: this.injector }));
}
else if (!isObservable(potentialSignalOrObservable)) {
this.staticValue = potentialSignalOrObservable;
this.renderStatic = true;
}
else {
this.staticValue = undefined;
this.renderStatic = false;
this.observables$.next(potentialSignalOrObservable);
}
}
set rxVirtualForTemplate(value) {
this._template = value;
}
/**
* @description
* The rendering strategy to be used to render updates to the DOM.
* Use it to dynamically manage your rendering strategy. You can switch the strategy
* imperatively (with a string) or by binding an Observable.
* The default strategy is `'normal'` if not configured otherwise.
*
* @example
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-hero
* autosized
* *rxVirtualFor="let hero of heroes$; strategy: strategy"
* [hero]="hero"></app-hero>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* strategy = 'low';
* }
*
* // OR
*
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-hero
* autosized
* *rxVirtualFor="let hero of heroes$; strategy: strategy$"
* [hero]="hero"></app-hero>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* strategy$ = new BehaviorSubject('immediate');
* }
*
* @param strategyName
* @see {@link strategies}
*/
set strategy(strategyName) {
this.strategyHandler.next(strategyName);
}
/*@Input('rxVirtualForTombstone') tombstone: TemplateRef<
RxVirtualForViewContext<T>
> | null = null;*/
/**
* @description
* A function or key that defines how to track changes for items in the provided
* iterable data.
*
* When items are added, moved, or removed in the iterable,
* the directive must re-render the appropriate DOM nodes.
* To minimize operations on the DOM, only nodes that have changed
* are re-rendered.
*
* By default, `rxVirtualFor` assumes that the object instance identifies
* the node in the iterable (equality check `===`).
* When a function or key is supplied, `rxVirtualFor` uses the result to identify the item node.
*
* @example
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-list-item
* *rxVirtualFor="
* let item of items$;
* trackBy: 'id';
* "
* autosized
* [item]="item"
* >
* </app-list-item>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* items$ = itemService.getItems();
* }
*
* // OR
*
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-list-item
* *rxVirtualFor="
* let item of items$;
* trackBy: trackItem;
* "
* autosized
* [item]="item"
* >
* </app-list-item>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* items$ = itemService.getItems();
* trackItem = (idx, item) => item.id;
* }
*
* @param trackByFnOrKey
*/
set trackBy(trackByFnOrKey) {
if (NG_DEV_MODE$1 &&
trackByFnOrKey != null &&
typeof trackByFnOrKey !== 'string' &&
typeof trackByFnOrKey !== 'symbol' &&
typeof trackByFnOrKey !== 'function') {
throw new Error(`trackBy must be typeof function or keyof T, but received ${JSON.stringify(trackByFnOrKey)}.`);
}
if (trackByFnOrKey == null) {
this._trackBy = null;
}
else {
this._trackBy =
typeof trackByFnOrKey !== 'function'
? (i, a) => a[trackByFnOrKey]
: trackByFnOrKey;
}
}
/**
* @description
* A `Subject` which emits whenever `*rxVirtualFor` finished rendering a
* set of changes to the view.
* This enables developers to perform actions exactly at the timing when the
* updates passed are rendered to the DOM.
* The `renderCallback` is useful in situations where you rely on specific DOM
* properties like the `height` of a table after all items got rendered.
* It is also possible to use the renderCallback in order to determine if a
* view should be visible or not. This way developers can hide a list as
* long as it has not finished rendering.
*
* The result of the `renderCallback` will contain the currently rendered set
* of items in the iterable.
*
* @example
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-list-item
* *rxVirtualFor="
* let item of items$;
* trackBy: trackItem;
* renderCallback: itemsRendered;
* "
* autosized
* [item]="item"
* >
* </app-list-item>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* items$: Observable<Item[]> = itemService.getItems();
* trackItem = (idx, item) => item.id;
* // this emits whenever rxVirtualFor finished rendering changes
* itemsRendered = new Subject<Item[]>();
* }
*
* @param renderCallback
*/
set renderCallback(renderCallback) {
this._renderCallback = renderCallback;
}
/** @internal */
get template() {
return this._template || this.templateRef;
}
/** @internal */
static ngTemplateContextGuard(dir, ctx) {
return true;
}
constructor(templateRef) {
this.templateRef = templateRef;
this.scrollStrategy = inject((RxVirtualScrollStrategy));
this.iterableDiffers = inject(IterableDiffers);
this.cdRef = inject(ChangeDetectorRef);
this.ngZone = inject(NgZone);
/** @internal */
this.injector = inject(Injector);
this.viewContainer = inject(ViewContainerRef);
this.strategyProvider = inject(RxStrategyProvider);
this.errorHandler = inject(ErrorHandler);
this.defaults = inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, {
optional: true,
});
/** @internal */
this.partiallyFinished = false;
/** @internal */
this.renderStatic = false;
/** @internal */
this.strategyHandler = strategyHandling(this.strategyProvider.primaryStrategy, this.strategyProvider.strategies);
/**
* @description
* Controls the amount if views held in cache for later re-use when a user is
* scrolling the list. If this is set to 0, `rxVirtualFor` won't cache any view,
* thus destroying & re-creating very often on scroll events.
*/
this.templateCacheSize = this.defaults?.templateCacheSize || DEFAULT_TEMPLATE_CACHE_SIZE;
/**
* @description
* If `parent` is set to `true` (default to `false`), `*rxVirtualFor` will
* automatically run change-detection for its parent component when its scheduled
* tasks are done in order to update any pending `@ContentChild` or `@ViewChild`
* relation to be updated according to the updated ViewContainer.
*
* @example
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-list-item
* *rxVirtualFor="
* let item of items$;
* trackBy: trackItem;
* parent: true;
* "
* [item]="item"
* autosized
* ></app-list-item>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* // those queries won't be in sync with what `rxVirtualFor` is rendering
* // when parent is set to false.
* \@ViewChildren(AppListItem) listItems: QueryList<AppListItem>;
*
* items$ = itemService.getItems();
* }
*
* @param renderParent
*
* @deprecated this flag will be dropped soon, as it is no longer required when using signal based view & content queries
*/
this.renderParent = false;
/**
* @description
* A flag to control whether `*rxVirtualFor` rendering happens within
* `NgZone` or not. The default value is set to `true` if not configured otherwise.
* If `patchZone` is set to `false` `*rxVirtualFor` will operate completely outside of `NgZone`.
*
* @example
* \@Component({
* selector: 'app-root',
* template: `
* <rx-virtual-scroll-viewport>
* <app-list-item
* *rxVirtualFor="
* let item of items$;
* trackBy: trackItem;
* patchZone: false;
* "
* [item]="item"
* autosized
* ></app-list-item>
* </rx-virtual-scroll-viewport>
* `
* })
* export class AppComponent {
* items$ = itemService.getItems();
* }
*
* @param patchZone
*/
this.patchZone = this.strategyProvider.config.patchZone;
/** @internal */
this.viewsRendered$ = new Subject();
/** @internal */
this.viewRendered$ = new Subject();
/** @internal */
this.renderingStart$ = new Subject();
/** @internal */
this.observables$ = new ReplaySubject(1);
/** @internal */
this.values$ = this.observables$.pipe(coerceObservableWith(), switchAll(), shareReplay({ bufferSize: 1, refCount: true }));
/** @internal */
this._destroy$ = new Subject();
/** @internal */
this._trackBy = null;
}
/** @internal */
ngOnInit() {
this.values$.pipe(takeUntil(this._destroy$)).subscribe((values) => {
this.values = values;
});
this.templateManager = createVirtualListTemplateManager({
viewContainerRef: this.viewContainer,
templateRef: this.template,
createViewContext: this.createViewContext.bind(this),
updateViewContext: this.updateViewContext.bind(this),
templateCacheSize: this.templateCacheSize,
});
// let the scroll strategy initialize before
Promise$1.resolve().then(() => {
this.render()
.pipe(takeUntil(this._destroy$))
.subscribe((v) => {
this._renderCallback?.next(v);
});
});
}
/** @internal */
ngDoCheck() {
if (this.renderStatic) {
this.observables$.next(this.staticValue);
}
}
/** @internal */
ngOnDestroy() {
this._destroy$.next();
this.templateManager.detach();
}
render() {
return combineLatest([
this.values$.pipe(map((values) => Array.isArray(values)
? values
: values != null
? Array.from(values)
: [])),
this.scrollStrategy.renderedRange$.pipe(distinctUntilChanged((oldRange, newRange) => oldRange.start === newRange.start && oldRange.end === newRange.end)),
this.strategyHandler.strategy$.pipe(distinctUntilChanged()),
]).pipe(switchMap(([items, range, strategy]) =>
// wait for scrollStrategy to be stable until computing new state
this.scrollStrategy.isStable.pipe(take(1),
// map iterable to latest diff
switchMap(() => {
const iterable = items.slice(range.start, range.end);
const differ = this.getDiffer(iterable);
let changes = null;
if (differ) {
if (this.partiallyFinished) {
const currentIterable = [];
for (let i = 0, ilen = this.viewContainer.length; i < ilen; i++) {
const viewRef = (this.viewContainer.get(i));
currentIterable[i] = viewRef.context.$implicit;
}
differ.diff(currentIterable);
}
changes = differ.diff(iterable);
}
if (!changes) {
return NEVER;
}
const listChanges = this.templateManager.getListChanges(changes, iterable, items.length, range.start);
const updates = listChanges[0].sort((a, b) => a[0] - b[0]);
const indicesToPosition = new Set();
const insertedOrRemoved = listChanges[1];
const work$ = updates.map(([index, work, removed]) => {
if (!removed) {
indicesToPosition.add(index);
}
return onStrategy(null, strategy, () => {
const update = work();
if (update.view) {
this.viewRendered$.next(update);
}
}, { ngZone: this.patchZone ? this.ngZone : undefined });
});
this.partiallyFinished = true;
const notifyParent = insertedOrRemoved && this.renderParent;
this.renderingStart$.next(indicesToPosition);
return combineLatest(
// emit after all changes are rendered
work$.length > 0 ? work$ : [of(iterable)]).pipe(tap(() => {
this.templateManager.setItemCount(items.length);
this.partiallyFinished = false;
const viewsRendered = [];
const end = this.viewContainer.length;
let i = 0;
for (i; i < end; i++) {
viewsRendered.push(this.viewContainer.get(i));
}
this.viewsRendered$.next(viewsRendered);
}), notifyParent
? switchMap((v) => concat(of(v), onStrategy(null, strategy, (_, work, options) => {
work(this.cdRef, options.scope);
}, {
ngZone: this.patchZone ? this.ngZone : undefined,
scope: this.cdRef.context || this.cdRef,
}).pipe(ignoreElements())))
: (o$) => o$, this.handleError(), map(() => iterable));
}))), this.handleError());
}
handleError() {
return (o$) => o$.pipe(catchError((err) => {
this.partiallyFinished = false;
this.errorHandler.handleError(err);
return of(null);
}));
}
getDiffer(values) {
if (this._differ) {
return this._differ;
}
return values
? (this._differ = this.iterableDiffers.find(values).create(this._trackBy))
: null;
}
/** @internal */
createViewContext(item, computedContext) {
return new RxVirtualForViewContext(item, this.values, computedContext);
}
/** @internal */
updateViewContext(item, view, computedContext) {
view.context.updateContext(computedContext);
view.context.$implicit = item;
view.context.rxVirtualForOf = this.values;
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualFor, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); }
/** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualFor, isStandalone: true, selector: "[rxVirtualFor][rxVirtualForOf]", inputs: { rxVirtualForOf: "rxVirtualForOf", rxVirtualForTemplate: "rxVirtualForTemplate", strategy: ["rxVirtualForStrategy", "strategy"], templateCacheSize: ["rxVirtualForTemplateCacheSize", "templateCacheSize"], renderParent: ["rxVirtualForParent", "renderParent"], patchZone: ["rxVirtualForPatchZone", "patchZone"], trackBy: ["rxVirtualForTrackBy", "trackBy"], renderCallback: ["rxVirtualForRenderCallback", "renderCallback"] }, providers: [{ provide: RxVirtualViewRepeater, useExisting: RxVirtualFor }], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualFor, decorators: [{
type: Directive,
args: [{
selector: '[rxVirtualFor][rxVirtualForOf]',
providers: [{ provide: RxVirtualViewRepeater, useExisting: RxVirtualFor }],
standalone: true,
}]
}], ctorParameters: () => [{ type: i0.TemplateRef }], propDecorators: { rxVirtualForOf: [{
type: Input
}], rxVirtualForTemplate: [{
type: Input
}], strategy: [{
type: Input,
args: ['rxVirtualForStrategy']
}], templateCacheSize: [{
type: Input,
args: ['rxVirtualForTemplateCacheSize']
}], renderParent: [{
type: Input,
args: ['rxVirtualForParent']
}], patchZone: [{
type: Input,
args: ['rxVirtualForPatchZone']
}], trackBy: [{
type: Input,
args: ['rxVirtualForTrackBy']
}], renderCallback: [{
type: Input,
args: ['rxVirtualForRenderCallback']
}] } });
class RxVirtualScrollElementDirective {
constructor() {
this.elementRef = inject(ElementRef);
this.elementScrolled$ = unpatchedScroll(this.elementRef.nativeElement);
}
getElementRef() {
return this.elementRef;
}
measureOffset() {
return this.elementRef.nativeElement.getBoundingClientRect().top;
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollElementDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
/** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualScrollElementDirective, isStandalone: true, selector: "[rxVirtualScrollElement]", host: { classAttribute: "rx-virtual-scroll-element" }, providers: [
{
provide: RxVirtualScrollElement,
useExisting: RxVirtualScrollElementDirective,
},
], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollElementDirective, decorators: [{
type: Directive,
args: [{
selector: '[rxVirtualScrollElement]',
providers: [
{
provide: RxVirtualScrollElement,
useExisting: RxVirtualScrollElementDirective,
},
],
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
host: {
class: 'rx-virtual-scroll-element',
},
standalone: true,
}]
}] });
function observeElementSize(element, config) {
const extractProp = config?.extract ?? ((entries) => entries[0].contentRect);
return new Observable((subscriber) => {
const observer = new ResizeObserver((entries) => {
subscriber.next(extractProp(entries));
});
observer.observe(element, config?.options);
return () => observer.disconnect();
});
}
const NG_DEV_MODE = typeof ngDevMode === 'undefined' || !!ngDevMode;
/**
* @Component RxVirtualScrollViewport
*
* @description
* Container component comparable to CdkVirtualScrollViewport acting as viewport
* for `*rxVirtualFor` to operate on.
*
* Its main purpose is to implement the `RxVirtualScrollViewport` interface
* as well as maintaining the scroll runways' height in order to give
* the provided `RxVirtualScrollStrategy` room to position items.
*
* Furthermore, it will gather and forward all events to the consumer of `rxVirtualFor`.
*
* @docsCategory RxVirtualFor
* @docsPage RxVirtualFor
* @publicApi
*/
class RxVirtualScrollViewportComponent {
/** @internal */
constructor() {
this.elementRef = inject((ElementRef));
this.scrollStrategy = inject((RxVirtualScrollStrategy), {
optional: true,
});
this.scrollElement = inject(RxVirtualScrollElement, { optional: true });
/**
* @description
*
* Sets the first view to be visible to the user.
* The viewport waits for the data to arrive and scrolls to the given index immediately.
*
* */
this.initialScrollIndex = 0;
this.elementScrolled$ = this.scrollElement?.elementScrolled$ ??
defer(() => unpatchedScroll(this.runway.nativeElement));
/** @internal */
this._containerRect$ = new ReplaySubject(1);
this.containerRect$ = this._containerRect$.asObservable();
/**
* @description
*
* The range to be rendered by `*rxVirtualFor`. This value is determined by the
* provided `RxVirtualScrollStrategy`. It gives the user information about the
* range of items being actually rendered to the DOM.
* Note this value updates before the `renderCallback` kicks in, thus it is only
* in sync with the DOM when the next `renderCallback` emitted an event.
*/
this.viewRange = this.scrollStrategy.renderedRange$;
/**
* @description
*
* The index of the currently scrolled item. The scrolled item is the topmost
* item actually being visible to the user.
*/
this.scrolledIndexChange = this.scrollStrategy.scrolledIndex$;
/** @internal */
this.destroy$ = new Subject();
if (NG_DEV_MODE && !this.scrollStrategy) {
throw Error('Error: rx-virtual-scroll-viewport requires an `RxVirtualScrollStrategy` to be set.');
}
observeElementSize(this.scrollElement?.getElementRef()?.nativeElement ??
this.elementRef.nativeElement, {
extract: (entries) => ({
height: Math.round(entries[0].contentRect.height),
width: Math.round(entries[0].contentRect.width),
}),
})
.pipe(distinctUntilChanged(({ height: prevHeight, width: prevWidth }, { height, width }) => prevHeight === height && prevWidth === width), takeUntil(this.destroy$))
.subscribe(this._containerRect$);
}
ngAfterViewInit() {
this.scrollStrategy.contentSize$
.pipe(distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((size) => {
this.updateContentSize(size);
});
if (this.initialScrollIndex != null && this.initialScrollIndex > 0) {
this.scrollStrategy.contentSize$
.pipe(filter((size) => size > 0), take(1), takeUntil(this.destroy$))
.subscribe(() => {
this.scrollToIndex(this.initialScrollIndex);
});
}
}
/** @internal */
ngAfterContentInit() {
if (ngDevMode && !this.viewRepeater) {
throw Error('Error: rx-virtual-scroll-viewport requires a `RxVirtualViewRepeater` to be provided.');
}
this.scrollStrategy.attach(this, this.viewRepeater);
}
/** @internal */
ngOnDestroy() {
this.destroy$.next();
this.scrollStrategy.detach();
}
getScrollElement() {
return (this.scrollElement?.getElementRef()?.nativeElement ??
this.runway.nativeElement);
}
getScrollTop() {
return this.getScrollElement().scrollTop;
}
scrollTo(position, behavior) {
// TODO: implement more complex scroll scenarios
this.getScrollElement().scrollTo({ top: position, behavior: behavior });
}
scrollToIndex(index, behavior) {
this.scrollStrategy.scrollToIndex(index, behavior);
}
measureOffset() {
if (this.scrollElement) {
const scrollableOffset = this.scrollElement.measureOffset();
const rect = this.elementRef.nativeElement.getBoundingClientRect();
return this.getScrollTop() + (rect.top - scrollableOffset);
}
else {
return 0;
}
}
updateContentSize(size) {
if (this.scrollElement) {
this.elementRef.nativeElement.style.height = `${size}px`;
}
else {
this.scrollSentinel.nativeElement.style.transform = `translate(0, ${size - 1}px)`;
}
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
/** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualScrollViewportComponent, isStandalone: true, selector: "rx-virtual-scroll-viewport", inputs: { initialScrollIndex: "initialScrollIndex" }, outputs: { viewRange: "viewRange", scrolledIndexChange: "scrolledIndexChange" }, host: { classAttribute: "rx-virtual-scroll-viewport" }, providers: [
{
provide: RxVirtualScrollViewport,
useExisting: RxVirtualScrollViewportComponent,
},
], queries: [{ propertyName: "viewRepeater", first: true, predicate: RxVirtualViewRepeater, descendants: true }], viewQueries: [{ propertyName: "scrollSentinel", first: true, predicate: ["sentinel"], descendants: true }, { propertyName: "runway", first: true, predicate: ["runway"], descendants: true, static: true }], ngImport: i0, template: `
<div
#runway
class="rx-virtual-scroll__runway"
[class.rx-virtual-scroll-element]="!scrollElement"
>
<div
#sentinel
class="rx-virtual-scroll__sentinel"
*ngIf="!this.scrollElement"
></div>
<ng-content></ng-content>
</div>
`, isInline: true, styles: [".rx-virtual-scroll-viewport{display:block;width:100%;height:100%;box-sizing:border-box;contain:strict}.rx-virtual-scroll-viewport .rx-virtual-scroll__runway{contain:strict;width:100%;position:absolute;top:0;bottom:0}.rx-virtual-scroll-viewport .rx-virtual-scroll__runway>*{position:absolute}.rx-virtual-scroll-viewport .rx-virtual-scroll__sentinel{width:1px;height:1px;contain:strict;position:absolute;will-change:transform}.rx-virtual-scroll-element{contain:strict;overflow:auto;-webkit-overflow-scrolling:touch}.rx-virtual-scroll-element:not(.rx-virtual-scroll-element:is(.rx-virtual-scroll-element--withSyncScrollbar)){transform:translateZ(0);will-change:scroll-position}\n"], dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollViewportComponent, decorators: [{
type: Component,
args: [{ selector: 'rx-virtual-scroll-viewport', template: `
<div
#runway
class="rx-virtual-scroll__runway"
[class.rx-virtual-scroll-element]="!scrollElement"
>
<div
#sentinel
class="rx-virtual-scroll__sentinel"
*ngIf="!this.scrollElement"
></div>
<ng-content></ng-content>
</div>
`, providers: [
{
provide: RxVirtualScrollViewport,
useExisting: RxVirtualScrollViewportComponent,
},
], encapsulation: ViewEncapsulation.None, host: {
class: 'rx-virtual-scroll-viewport',
}, changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgIf], styles: [".rx-virtual-scroll-viewport{display:block;width:100%;height:100%;box-sizing:border-box;contain:strict}.rx-virtual-scroll-viewport .rx-virtual-scroll__runway{contain:strict;width:100%;position:absolute;top:0;bottom:0}.rx-virtual-scroll-viewport .rx-virtual-scroll__runway>*{position:absolute}.rx-virtual-scroll-viewport .rx-virtual-scroll__sentinel{width:1px;height:1px;contain:strict;position:absolute;will-change:transform}.rx-virtual-scroll-element{contain:strict;overflow:auto;-webkit-overflow-scrolling:touch}.rx-virtual-scroll-element:not(.rx-virtual-scroll-element:is(.rx-virtual-scroll-element--withSyncScrollbar)){transform:translateZ(0);will-change:scroll-position}\n"] }]
}], ctorParameters: () => [], propDecorators: { initialScrollIndex: [{
type: Input
}], scrollSentinel: [{
type: ViewChild,
args: ['sentinel']
}], runway: [{
type: ViewChild,
args: ['runway', { static: true }]
}], viewRepeater: [{
type: ContentChild,
args: [RxVirtualViewRepeater]
}], viewRange: [{
type: Output
}], scrolledIndexChange: [{
type: Output
}] } });
class RxVirtualScrollWindowDirective {
constructor() {
this.document = inject(DOCUMENT);
this.elementRef = new ElementRef(this.document.documentElement);
this.elementScrolled$ = unpatchedScroll(this.document);
}
getElementRef() {
return this.elementRef;
}
measureOffset() {
return 0;
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollWindowDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
/** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxVirtualScrollWindowDirective, isStandalone: true, selector: "rx-virtual-scroll-viewport[scrollWindow]", providers: [
{
provide: RxVirtualScrollElement,
useExisting: RxVirtualScrollWindowDirective,
},
], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxVirtualScrollWindowDirective, decorators: [{
type: Directive,
args: [{
selector: 'rx-virtual-scroll-viewport[scrollWindow]',
providers: [
{
provide: RxVirtualScrollElement,
useExisting: RxVirtualScrollWindowDirective,
},
],
standalone: true,
}]
}] });
/**
* Generated bundle index. Do not edit.
*/
export { AutoSizeVirtualScrollStrategy, DynamicSizeVirtualScrollStrategy, FixedSizeVirtualScrollStrategy, RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS_FACTORY, RxVirtualFor, RxVirtualForViewContext, RxVirtualScrollElement, RxVirtualScrollElementDirective, RxVirtualScrollStrategy, RxVirtualScrollViewport, RxVirtualScrollViewportComponent, RxVirtualScrollWindowDirective, RxVirtualViewRepeater };
//# sourceMappingURL=template-experimental-virtual-scrolling.mjs.map