UNPKG

@rx-angular/template

Version:

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

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