diff --git a/coverage/coverage.svg b/coverage/coverage.svg index db7d8a5..506419f 100644 --- a/coverage/coverage.svg +++ b/coverage/coverage.svg @@ -1 +1 @@ -coverage: 16%coverage16% \ No newline at end of file +coverage: 15%coverage15% \ No newline at end of file diff --git a/package.json b/package.json index 982c4cb..940e4b8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@lithiumjs/ngx-virtual-scroll", "description": "A fast virtual scrolling solution for Angular that natively supports items with unequal heights. Built with @lithiumjs/angular.", "repository": "https://github.com/lVlyke/lithium-ngx-virtual-scroll", - "version": "0.0.4", + "version": "0.0.5", "main": "index.js", "author": "Mychal Thompson ", "license": "MIT", @@ -14,9 +14,9 @@ "test": "node ./scripts/test.js" }, "peerDependencies": { - "@angular/core": "6.x.x - 11.x.x", - "@angular/common": "6.x.x - 11.x.x", - "@lithiumjs/angular": ">=5.0.0", + "@angular/core": "6.x.x - 12.x.x", + "@angular/common": "6.x.x - 12.x.x", + "@lithiumjs/angular": ">=7.0.0", "rxjs": "6.x.x" }, "devDependencies": { @@ -24,7 +24,7 @@ "@angular/compiler-cli": "^11.2.4", "@angular/core": "^11.2.4", "@angular/common": "^11.2.4", - "@lithiumjs/angular": "^6.0.0-beta.0", + "@lithiumjs/angular": "^7.0.0", "@types/jasmine": "^3.3.13", "detest-bdd": "^1.1.1", "fs-extra": "^8.1.0", diff --git a/src/components/virtual-scroll/virtual-scroll.component.ts b/src/components/virtual-scroll/virtual-scroll.component.ts index e0d91c8..a4c5221 100644 --- a/src/components/virtual-scroll/virtual-scroll.component.ts +++ b/src/components/virtual-scroll/virtual-scroll.component.ts @@ -6,22 +6,15 @@ import { ChangeDetectionStrategy, ChangeDetectorRef } from "@angular/core"; -import { LiComponent, StateEmitter, OnDestroy, AutoPush } from "@lithiumjs/angular"; -import { Subject, Observable, combineLatest, of, forkJoin, fromEvent, asyncScheduler } from "rxjs"; +import { OnDestroy, AutoPush, DeclareState, ComponentState, ComponentStateRef } from "@lithiumjs/angular"; +import { Observable, combineLatest, of, forkJoin, fromEvent, asyncScheduler } from "rxjs"; import { map, throttleTime, filter, mergeMap, take, delay, tap } from "rxjs/operators"; import { VirtualItem } from "../../directives/virtual-item.directive"; -export function DEFAULT_SCROLL_POSITION(): VirtualScroll.ScrollPosition { - return { x: 0, y: 0 }; -} - -export function EMPTY_ARRAY(): T[] { - return []; -} - @Component({ selector: "li-virtual-scroll", changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ComponentState.create(VirtualScroll)], template: ` @@ -39,104 +32,88 @@ export function EMPTY_ARRAY(): T[] { ` }) -export class VirtualScroll extends LiComponent { +export class VirtualScroll { private static readonly DEFAULT_BUFFER_LENGTH = 3; - private static readonly DEFAULT_SCROLL_THROTTLE_MS = 50; - - @ContentChild(VirtualItem) - public readonly virtualItem: VirtualItem; + private static readonly DEFAULT_SCROLL_THROTTLE_MS = 100; @Input() - public items: T[]; - - @StateEmitter({ propertyName: "items", initial: EMPTY_ARRAY }) - public readonly items$: Subject; + public items: T[] = []; @Input() - public bufferLength: number; - - @StateEmitter({ propertyName: "bufferLength", initialValue: VirtualScroll.DEFAULT_BUFFER_LENGTH }) - public readonly bufferLength$: Subject; + public bufferLength = VirtualScroll.DEFAULT_BUFFER_LENGTH; @Input() - public scrollContainer: HTMLElement; - - @StateEmitter({ propertyName: "scrollContainer" }) - public readonly scrollContainer$: Subject; + @DeclareState() + public scrollContainer?: HTMLElement; @Input() - public eventCapture: boolean; + public eventCapture = false; - @StateEmitter({ propertyName: "eventCapture", initialValue: false }) - public readonly eventCapture$: Subject; + @ContentChild(VirtualItem) + public virtualItem!: VirtualItem; - @StateEmitter({ initial: DEFAULT_SCROLL_POSITION }) - private readonly scrollPosition$: Subject; + @OnDestroy() + private readonly onDestroy$!: Observable; - @StateEmitter({ initial: EMPTY_ARRAY }) - private readonly renderedItems$: Subject[]>; + @DeclareState("renderedItems") + private _renderedItems: VirtualScroll.RenderedItem[] = []; - @StateEmitter({ initialValue: 0 }) - private readonly referenceWidth$: Subject; + @DeclareState("scrollPosition") + private _scrollPosition: VirtualScroll.ScrollPosition = { x: 0, y: 0 }; - @StateEmitter({ initialValue: 0 }) - private readonly referenceHeight$: Subject; + @DeclareState("referenceWidth") + private _referenceWidth = 0; - @OnDestroy() - private readonly onDestroy$: Observable; + @DeclareState("referenceWidth") + private _referenceHeight = 0; - private readonly listElement: HTMLElement; + private _listElement!: HTMLElement; constructor( private readonly cdRef: ChangeDetectorRef, + stateRef: ComponentStateRef>, { nativeElement: listElement }: ElementRef ) { - super(); - AutoPush.enable(this, cdRef); - this.listElement = listElement; - - this.scrollContainer$.next(listElement); + this.scrollContainer = this._listElement = listElement; - const scrollSubscription = combineLatest([ - this.scrollContainer$, - this.eventCapture$ - ]).pipe( + const scrollSubscription = combineLatest(stateRef.getAll( + "scrollContainer", + "eventCapture" + )).pipe( tap(([scrollContainer]) => this.applyScrollContainerStyles(scrollContainer === listElement)), - mergeMap(([scrollContainer, capture]) => fromEvent(scrollContainer, "scroll", { capture })) + mergeMap(([scrollContainer, capture]) => fromEvent(scrollContainer!, "scroll", { capture })) ).subscribe((scrollEvent) => { - this.scrollPosition$.next({ + this._scrollPosition = { x: (scrollEvent.target as HTMLElement).scrollLeft, y: (scrollEvent.target as HTMLElement).scrollTop - }); + }; }); this.onDestroy$.subscribe(() => scrollSubscription.unsubscribe()); - this.items$.pipe( + stateRef.get("items").pipe( map((items): VirtualScroll.RenderedItem[] => items.map((item) => ({ item, visible: false }))) - ).subscribe(this.renderedItems$); + ).subscribe(renderedItems => this._renderedItems = renderedItems); // Make the first element visible (TODO- always?) - this.renderedItems$.pipe( + stateRef.get("renderedItems").pipe( filter(renderedItems => renderedItems.length > 0), take(1) ).subscribe(renderedItems => renderedItems[0].visible = true); combineLatest([ - this.scrollPosition$.pipe(throttleTime( + stateRef.get("scrollPosition").pipe(throttleTime( VirtualScroll.DEFAULT_SCROLL_THROTTLE_MS, // TODO - Make customizable asyncScheduler, { leading: true, trailing: true } )), - this.renderedItems$, - this.scrollContainer$, - this.bufferLength$ + ...stateRef.getAll("renderedItems", "scrollContainer", "bufferLength") ]).pipe( filter(([, renderedItems]) => renderedItems.length > 0), delay(0), // Wait for DOM rendering to occur @@ -144,10 +121,10 @@ export class VirtualScroll extends LiComponent { const renderedBounds: VirtualScroll.Rect = { left: scrollPosition.x, top: scrollPosition.y, - right: scrollPosition.x + scrollContainer.clientWidth, - bottom: scrollPosition.y + scrollContainer.clientHeight + right: scrollPosition.x + scrollContainer!.clientWidth, + bottom: scrollPosition.y + scrollContainer!.clientHeight }; - const bufferLengthPx = (scrollContainer.clientHeight) * bufferLength; + const bufferLengthPx = (scrollContainer!.clientHeight) * bufferLength; const [bestRenderedIndex, renderedElement] = this.findBestOnScreenItem(renderedItems); renderedBounds.top -= bufferLengthPx; @@ -155,8 +132,8 @@ export class VirtualScroll extends LiComponent { if (renderedElement) { // TODO - this.referenceWidth$.next(renderedElement.clientWidth); - this.referenceHeight$.next(renderedElement.clientHeight); + this._referenceWidth = renderedElement.clientWidth; + this._referenceHeight = renderedElement.clientHeight; const offset = { x: renderedElement.offsetLeft, y: renderedElement.offsetTop }; const elementDimensions = { @@ -173,20 +150,20 @@ export class VirtualScroll extends LiComponent { delay(VirtualScroll.DEFAULT_SCROLL_THROTTLE_MS * 2), tap(() => { // Re-check the rendering status if there are no rendered items or if the scroll position changed quickly - if (this.listElement.scrollLeft !== scrollPosition.x - || this.listElement.scrollTop !== scrollPosition.y + if (this._listElement.scrollLeft !== scrollPosition.x + || this._listElement.scrollTop !== scrollPosition.y || this.getAllRenderedElements().length === 0) { - this.scrollPosition$.next({ x: this.listElement.scrollLeft, y: this.listElement.scrollTop }); + this._scrollPosition = { x: this._listElement.scrollLeft, y: this._listElement.scrollTop }; } }) ); - } else if (renderedItems.length > 0) { + } else if (renderedItems.length > 0 && this._referenceWidth > 0 && this._referenceHeight > 0) { // If there are no rendered items, walk the list from the beginning to find the rendered segment return this.walkList(renderedItems, renderedBounds, 0, 1, false, { left: 0, top: 0, - right: this.referenceWidth, - bottom: this.referenceHeight + right: this._referenceWidth, + bottom: this._referenceHeight }); } else { return of(null); @@ -195,14 +172,29 @@ export class VirtualScroll extends LiComponent { ).subscribe(); } + public get renderedItems(): VirtualScroll.RenderedItem[] { + return this._renderedItems; + } + + public get scrollPosition(): VirtualScroll.ScrollPosition { + return this._scrollPosition; + } + + public get referenceWidth(): number { + return this._referenceWidth; + } + + public get referenceHeight(): number { + return this._referenceHeight; + } + public checkScroll(scrollPosition?: VirtualScroll.ScrollPosition): void { - (scrollPosition ? of(scrollPosition) : this.scrollPosition$.pipe(take(1))) - .subscribe(scrollPosition => this.scrollPosition$.next(scrollPosition)); + this._scrollPosition = scrollPosition ?? this._scrollPosition; } private applyScrollContainerStyles(apply: boolean) { - this.listElement.style.overflowY = apply ? "scroll" : "initial"; - this.listElement.style.display = apply ? "block" : "initial"; + this._listElement.style.overflowY = apply ? "scroll" : "initial"; + this._listElement.style.display = apply ? "block" : "initial"; } private findBestOnScreenItem(renderedItems: VirtualScroll.RenderedItem[]): [number, HTMLElement] { @@ -211,7 +203,7 @@ export class VirtualScroll extends LiComponent { // Grab any rendered element (that is currently being rendered in the DOM) let bestRenderedIndex = minRenderedIndex; - let renderedElement: HTMLElement; + let renderedElement: HTMLElement | null; do { renderedElement = this.getRenderedElement(bestRenderedIndex); } while (!renderedElement && ++bestRenderedIndex < (maxRenderedIndex === -1 ? renderedItems.length : maxRenderedIndex)); @@ -224,15 +216,15 @@ export class VirtualScroll extends LiComponent { } } - return [bestRenderedIndex, renderedElement]; + return [bestRenderedIndex, renderedElement!]; } - private getRenderedElement(renderedIndex: number): HTMLElement { - return this.listElement.querySelector(`[data-li-virtual-index="${renderedIndex}"]`); + private getRenderedElement(renderedIndex: number): HTMLElement | null { + return this._listElement.querySelector(`[data-li-virtual-index="${renderedIndex}"]`); } private getAllRenderedElements(): NodeListOf { - return this.listElement.querySelectorAll(".li-virtual-item"); + return this._listElement.querySelectorAll(".li-virtual-item"); } private intersects(a: VirtualScroll.Rect, b: VirtualScroll.Rect): boolean { @@ -251,14 +243,12 @@ export class VirtualScroll extends LiComponent { const nextIndex = index + direction; // Stop walking the list if we hit an unrendered segment - if (stopOnUnrendered && !lastElementDimensions && !item?.visible) { + if (nextIndex >= renderedItems.length || (stopOnUnrendered && !lastElementDimensions && !item?.visible)) { return of(renderedItems); } - let renderedElement: HTMLElement; - const visible = item?.visible && (renderedElement = this.getRenderedElement(index)); - if (visible) { - + let renderedElement: HTMLElement | null; + if (item?.visible && (renderedElement = this.getRenderedElement(index))) { const offset = { x: renderedElement.offsetLeft, y: renderedElement.offsetTop }; // Update the element dimensions based on the current element @@ -274,7 +264,7 @@ export class VirtualScroll extends LiComponent { item.visible = false; this.cdRef.markForCheck(); } - } else { + } else if (lastElementDimensions) { const lastElementSize = { x: lastElementDimensions.right - lastElementDimensions.left, y: lastElementDimensions.bottom - lastElementDimensions.top, @@ -283,11 +273,11 @@ export class VirtualScroll extends LiComponent { const offsetY = lastElementSize.y * direction; // Walk to the next item in the list - if (lastElementDimensions.right + offsetX <= this.listElement.scrollWidth && lastElementDimensions.left + offsetX >= 0) { + if (lastElementDimensions.right + offsetX <= this._listElement.scrollWidth && lastElementDimensions.left + offsetX >= 0) { lastElementDimensions.left += offsetX; lastElementDimensions.right += offsetX; } else { - lastElementDimensions.left = direction > 0 ? 0 : this.listElement.scrollWidth; + lastElementDimensions.left = direction > 0 ? 0 : this._listElement.scrollWidth; lastElementDimensions.right = lastElementDimensions.left + offsetX; lastElementDimensions.top += offsetY; @@ -296,14 +286,13 @@ export class VirtualScroll extends LiComponent { // If the current item should be rendered, make it visible if (this.intersects(renderedBounds, lastElementDimensions)) { - item.visible = true; - this.cdRef.detectChanges(); - - // Wait for the DOM element to render, then continue walking the list - return of(null).pipe( - delay(0), - mergeMap(() => this.walkList(renderedItems, renderedBounds, index, direction, stopOnUnrendered, lastElementDimensions)) - ); + if (!!item) { + item.visible = true; + this.cdRef.markForCheck(); + } + + // Continue walking the list + return this.walkList(renderedItems, renderedBounds, nextIndex, direction, stopOnUnrendered, lastElementDimensions); } } diff --git a/src/directives/virtual-item.directive.ts b/src/directives/virtual-item.directive.ts index 8138dda..d7865e9 100644 --- a/src/directives/virtual-item.directive.ts +++ b/src/directives/virtual-item.directive.ts @@ -1,14 +1,11 @@ import { Directive, TemplateRef } from "@angular/core"; -import { LiComponent } from "@lithiumjs/angular"; @Directive({ selector: "[liVirtualItem]" }) -export class VirtualItem extends LiComponent { +export class VirtualItem { constructor( public readonly templateRef: TemplateRef - ) { - super(); - } + ) {} } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1672d29..4ee59ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitAny": true, @@ -29,6 +30,9 @@ "rewriteTsConfig": false }, "angularCompilerOptions": { - "skipTemplateCodegen": true + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "strictTemplates": true, + "fullTemplateTypeCheck": true } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c602fb9..a61fd25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -239,12 +239,12 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" -"@lithiumjs/angular@^6.0.0-beta.0": - version "6.0.0-beta.0" - resolved "https://registry.yarnpkg.com/@lithiumjs/angular/-/angular-6.0.0-beta.0.tgz#cb6592404649d4a351a14091f9ec887ac82ed982" - integrity sha512-WYkJAWbQ/Hit+Feru8didgHfvZGHWflp589sz5Jh8YocdGZNt7X+FKsutZKBezToV9IwZRtPeR7mshgHvA+Byg== +"@lithiumjs/angular@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@lithiumjs/angular/-/angular-7.0.0.tgz#8932f6ec29d4ebed7ecaf88243468fe374ba63e3" + integrity sha512-KHykfp163GidULSV5zT31KmKH5eAMZ2vF6n8uoJa/230p7qgA8ttv8Eh4njosBLd7X4/c7qKTWBYYmEX5dHPhQ== dependencies: - tslib "^2.0.0" + tslib "^2.1.0" "@rollup/plugin-commonjs@^17.0.0": version "17.1.0" @@ -2627,6 +2627,11 @@ tslib@^2.0.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" + integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + type-check@~0.3.2: version "0.3.2" resolved "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"