ngx-infinite-scroller
Version:
Infinite bidirectional scroll directive for Angular 11
408 lines (395 loc) • 14.9 kB
JavaScript
import { Injectable, EventEmitter, Directive, Inject, PLATFORM_ID, ElementRef, Renderer2, Input, Output, NgModule } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Subject, fromEvent, zip } from 'rxjs';
import { filter, tap, takeWhile, map, pairwise, debounceTime, skipWhile } from 'rxjs/operators';
import isNumber from 'is-number';
class DirectiveStateService {
get scrollTop() {
return this._el.nativeElement.scrollTop;
}
get scrollHeight() {
return this._el.nativeElement.scrollHeight;
}
get clientHeight() {
return this._el.nativeElement.clientHeight;
}
get initMode() {
return this._initMode;
}
set initMode(initMode) {
this._initMode = initMode;
}
get scrollStreamActive() {
return this._scrollStreamActive;
}
set scrollStreamActive(active) {
this._scrollStreamActive = active;
}
get previousScrollPositionpUpdated() {
return this._previousScrollPositionpUpdated;
}
set previousScrollPositionpUpdated(previousScrollPositionpUpdated) {
this._previousScrollPositionpUpdated = previousScrollPositionpUpdated;
}
get previousScrollTop() {
return this._previousScrollTop;
}
get previousScrollHeight() {
return this._previousScrollHeight;
}
setup(params) {
this._el = params.el;
this._initMode = params.initMode;
this._scrollStreamActive = params.scrollStreamActive;
this._previousScrollPositionpUpdated = params.previousScrollPositionpUpdated;
this.updatePreviousScrollTop();
this.updatePreviousScrollHeight();
}
updatePreviousScrollTop() {
this._previousScrollTop = this._el.nativeElement.scrollTop;
}
updatePreviousScrollHeight() {
this._previousScrollHeight = this._el.nativeElement.scrollHeight;
}
}
DirectiveStateService.decorators = [
{ type: Injectable }
];
var InitialScrollPosition;
(function (InitialScrollPosition) {
InitialScrollPosition["DEFAULT"] = "DEFAULT";
InitialScrollPosition["TOP"] = "TOP";
InitialScrollPosition["MIDDLE"] = "MIDDLE";
InitialScrollPosition["BOTTOM"] = "BOTTOM";
})(InitialScrollPosition || (InitialScrollPosition = {}));
class DirectiveContext {
constructor() { }
}
class StrategyBase {
constructor(directive, state) {
this.directive = directive;
this.state = state;
}
wasScrolledDown(prevPos, currentPos) {
return prevPos.scrollTop < currentPos.scrollTop;
}
wasScrolledUp(prevPos, currentPos) {
return !this.wasScrolledDown(prevPos, currentPos);
}
isScrollDownEnough(pos, scrollPositionTrigger) {
return ((pos.scrollTop + pos.clientHeight) / pos.scrollHeight) > (scrollPositionTrigger / 100);
}
isScrollUpEnough(pos, scrollPositionTrigger) {
return (pos.scrollTop / pos.scrollHeight) < (scrollPositionTrigger / 100);
}
getInitialScrollPositionValue(defaultScrollPosition) {
const { initialScrollPosition } = this.directive;
if (isNumber(initialScrollPosition)) {
return Number(initialScrollPosition);
}
const initialScrollPositions = this.getInitialScrollPositions();
if (initialScrollPosition === InitialScrollPosition.DEFAULT) {
return initialScrollPositions[defaultScrollPosition];
}
return initialScrollPositions[initialScrollPosition];
}
getInitialScrollPositions() {
const { scrollHeight, clientHeight } = this.state;
return {
[InitialScrollPosition.TOP]: 0,
[InitialScrollPosition.MIDDLE]: scrollHeight / 2 - clientHeight / 2,
[InitialScrollPosition.BOTTOM]: scrollHeight,
};
}
}
class ScrollingToTop extends StrategyBase {
constructor(directive, state) {
super(directive, state);
}
scrollDirectionChanged(scrollPairChanged) {
return scrollPairChanged.pipe(filter((scrollPositions) => {
return super.wasScrolledUp(scrollPositions[0], scrollPositions[1]);
}));
}
scrollRequestZoneChanged(scrollDirectionChanged) {
return scrollDirectionChanged.pipe(filter((scrollPositions) => {
return super.isScrollUpEnough(scrollPositions[1], this.directive.scrollUpPercentilePositionTrigger);
}));
}
askForUpdate() {
this.directive.onScrollUp.next();
}
setInitialScrollPosition() {
const initialScrollPositionValue = super.getInitialScrollPositionValue(InitialScrollPosition.BOTTOM);
this.directive.scrollTo(initialScrollPositionValue);
}
setPreviousScrollPosition() {
const prevScrollPosition = this.state.previousScrollTop +
(this.state.scrollHeight - this.state.previousScrollHeight);
this.directive.scrollTo(prevScrollPosition);
}
}
class ScrollingToBottom extends StrategyBase {
constructor(directive, state) {
super(directive, state);
}
scrollDirectionChanged(scrollPairChanged) {
return scrollPairChanged.pipe(filter((scrollPositions) => {
return super.wasScrolledDown(scrollPositions[0], scrollPositions[1]);
}));
}
scrollRequestZoneChanged(scrollDirectionChanged) {
return scrollDirectionChanged.pipe(filter((scrollPositions) => {
return super.isScrollDownEnough(scrollPositions[1], this.directive.scrollDownPercentilePositionTrigger);
}));
}
askForUpdate() {
this.directive.onScrollDown.next();
}
setInitialScrollPosition() {
const initialScrollPositionValue = super.getInitialScrollPositionValue(InitialScrollPosition.TOP);
this.directive.scrollTo(initialScrollPositionValue);
}
setPreviousScrollPosition() {
const prevScrollPosition = this.state.previousScrollTop;
this.directive.scrollTo(prevScrollPosition);
}
}
class ScrollingToBoth extends StrategyBase {
constructor(directive, state) {
super(directive, state);
}
scrollDirectionChanged(scrollPairChanged) {
return scrollPairChanged;
}
scrollRequestZoneChanged(scrollDirectionChanged) {
return scrollDirectionChanged.pipe(filter((scrollPositions) => {
return (super.isScrollUpEnough(scrollPositions[1], this.directive.scrollUpPercentilePositionTrigger) || super.isScrollDownEnough(scrollPositions[1], this.directive.scrollDownPercentilePositionTrigger));
}), tap((scrollPositions) => {
this.scrolledUp = super.wasScrolledUp(scrollPositions[0], scrollPositions[1]);
}));
}
askForUpdate() {
if (this.scrolledUp) {
this.directive.onScrollUp.next();
}
else {
this.directive.onScrollDown.next();
}
}
setInitialScrollPosition() {
const initialScrollPositionValue = super.getInitialScrollPositionValue(InitialScrollPosition.MIDDLE);
this.directive.scrollTo(initialScrollPositionValue);
}
setPreviousScrollPosition() {
let prevScrollPosition;
if (this.scrolledUp) {
prevScrollPosition = this.state.previousScrollTop +
(this.state.scrollHeight - this.state.previousScrollHeight);
}
else {
prevScrollPosition = this.state.previousScrollTop;
}
this.directive.scrollTo(prevScrollPosition);
}
}
class ScrollHeightListener {
constructor(directive, state) {
this.directive = directive;
this.state = state;
this.DEFAULT_REQUEST_TIMEOUT = 30000;
}
start() {
this.listener = window.requestAnimationFrame(this.listen.bind(this));
if (!this.httpRequestTimeout) {
this.httpRequestTimeout = setTimeout(() => {
this.stopIfRequestTimeout();
}, this.DEFAULT_REQUEST_TIMEOUT);
}
}
stop() {
window.cancelAnimationFrame(this.listener);
clearTimeout(this.httpRequestTimeout);
this.httpRequestTimeout = null;
}
listen() {
if (this.state.previousScrollHeight !== this.state.scrollHeight) {
this.stop();
this.directive.onScrollbarHeightChanged();
}
else {
this.start();
}
}
stopIfRequestTimeout() {
if (!this.state.previousScrollPositionpUpdated) {
this.stop();
}
}
}
class NgxInfiniteScrollerDirective extends DirectiveContext {
constructor(platformId, el, renderer, state) {
super();
this.platformId = platformId;
this.el = el;
this.renderer = renderer;
this.state = state;
this.strategy = 'scrollingToBottom';
this.initialScrollPosition = InitialScrollPosition.DEFAULT;
this.scrollbarAnimationInterval = 100;
this.scrollDebounceTimeAfterScrollHeightChanged = 50;
this.scrollDebounceTimeAfterDOMMutationOnInit = 1000;
this.scrollUpPercentilePositionTrigger = 2;
this.scrollDownPercentilePositionTrigger = 98;
this.onScrollUp = new EventEmitter();
this.onScrollDown = new EventEmitter();
this.scrollHeightChanged = new Subject();
this.domMutationEmitter = new Subject();
this.isBrowser = isPlatformBrowser(platformId);
this.state.setup({
el: el,
initMode: true,
scrollStreamActive: true,
previousScrollPositionpUpdated: false
});
}
get scrollPairChanged() {
if (this.scrollChanged) {
return this.scrollChanged.pipe(takeWhile(() => this.state.scrollStreamActive), map((e) => {
return {
scrollHeight: e.target.scrollHeight,
scrollTop: e.target.scrollTop,
clientHeight: e.target.clientHeight,
};
}), pairwise(), debounceTime(this.scrollbarAnimationInterval));
}
}
get scrollDirectionChanged() {
return this.scrollingStrategy.scrollDirectionChanged(this.scrollPairChanged);
}
get scrollRequestZoneChanged() {
return this.scrollingStrategy.scrollRequestZoneChanged(this.scrollDirectionChanged).pipe(tap(() => {
this.state.updatePreviousScrollTop();
this.state.updatePreviousScrollHeight();
this.state.previousScrollPositionpUpdated = false;
this.scrollHeightListener.start();
}));
}
ngOnInit() {
this.useStrategy();
this.useScrollHeightListener();
this.registerScrollEventHandler();
this.registerMutationObserver();
this.registerInitialScrollPostionHandler();
this.registerPreviousScrollPositionHandler();
}
ngAfterViewInit() {
this.registerScrollSpy();
}
ngOnDestroy() {
this.unregisterMutationObserver();
}
scrollTo(position) {
this.state.scrollStreamActive = false;
this.renderer.setProperty(this.el.nativeElement, 'scrollTop', position);
this.state.scrollStreamActive = true;
}
onScrollbarHeightChanged() {
this.scrollHeightChanged.next();
}
registerScrollEventHandler() {
this.scrollChanged = fromEvent(this.el.nativeElement, 'scroll');
}
registerMutationObserver() {
if (this.isBrowser) {
this.domMutationObserver = new MutationObserver((mutations) => {
this.domMutationEmitter.next(mutations);
});
const config = { attributes: true, childList: true, characterData: true };
this.domMutationObserver.observe(this.el.nativeElement, config);
}
}
registerInitialScrollPostionHandler() {
this.domMutationEmitter.pipe(takeWhile(() => this.state.initMode), debounceTime(this.scrollDebounceTimeAfterDOMMutationOnInit)).subscribe(() => {
this.scrollingStrategy.setInitialScrollPosition();
this.state.initMode = false;
});
}
registerPreviousScrollPositionHandler() {
zip(this.scrollRequestZoneChanged, this.scrollHeightChanged).pipe(skipWhile(() => this.state.initMode), debounceTime(this.scrollDebounceTimeAfterScrollHeightChanged)).subscribe(() => {
this.scrollingStrategy.setPreviousScrollPosition();
this.state.previousScrollPositionpUpdated = true;
});
}
registerScrollSpy() {
this.scrollRequestZoneChanged.subscribe(() => {
this.scrollingStrategy.askForUpdate();
});
}
unregisterMutationObserver() {
if (this.domMutationObserver) {
this.domMutationObserver.disconnect();
}
}
useStrategy() {
switch (this.strategy) {
case 'scrollingToBoth':
this.scrollingStrategy = new ScrollingToBoth(this, this.state);
break;
case 'scrollingToTop':
this.scrollingStrategy = new ScrollingToTop(this, this.state);
break;
case 'scrollingToBottom':
default:
this.scrollingStrategy = new ScrollingToBottom(this, this.state);
break;
}
}
useScrollHeightListener() {
this.scrollHeightListener = new ScrollHeightListener(this, this.state);
}
}
NgxInfiniteScrollerDirective.decorators = [
{ type: Directive, args: [{
selector: '[ngxInfiniteScroller]'
},] }
];
NgxInfiniteScrollerDirective.ctorParameters = () => [
{ type: undefined, decorators: [{ type: Inject, args: [PLATFORM_ID,] }] },
{ type: ElementRef },
{ type: Renderer2 },
{ type: DirectiveStateService }
];
NgxInfiniteScrollerDirective.propDecorators = {
strategy: [{ type: Input }],
initialScrollPosition: [{ type: Input }],
scrollbarAnimationInterval: [{ type: Input }],
scrollDebounceTimeAfterScrollHeightChanged: [{ type: Input }],
scrollDebounceTimeAfterDOMMutationOnInit: [{ type: Input }],
scrollUpPercentilePositionTrigger: [{ type: Input }],
scrollDownPercentilePositionTrigger: [{ type: Input }],
onScrollUp: [{ type: Output }],
onScrollDown: [{ type: Output }]
};
class NgxInfiniteScrollerModule {
}
NgxInfiniteScrollerModule.decorators = [
{ type: NgModule, args: [{
declarations: [
NgxInfiniteScrollerDirective
],
imports: [],
exports: [
NgxInfiniteScrollerDirective
],
providers: [
DirectiveStateService
],
bootstrap: []
},] }
];
/**
* Generated bundle index. Do not edit.
*/
export { NgxInfiniteScrollerModule, NgxInfiniteScrollerDirective as ɵa, DirectiveContext as ɵb, DirectiveStateService as ɵc };
//# sourceMappingURL=ngx-infinite-scroller.js.map