UNPKG

@nicky-lenaers/ngx-scroll-to

Version:

A simple Angular 4+ plugin enabling you to smooth scroll to any element on your page and enhance scroll-based features in your app.

585 lines (576 loc) 22 kB
import * as i0 from '@angular/core'; import { ElementRef, PLATFORM_ID, Injectable, Inject, Directive, Input, NgModule } from '@angular/core'; import { isPlatformBrowser, DOCUMENT } from '@angular/common'; import { ReplaySubject, throwError } from 'rxjs'; /** Default values for Component Input */ const DEFAULTS = { target: null, action: 'click', duration: 650, easing: 'easeInOutQuad', offset: 0, offsetMap: new Map() }; /** Easing Colleciton */ const EASING = { easeInQuad: (time) => { return time * time; }, easeOutQuad: (time) => { return time * (2 - time); }, easeInOutQuad: (time) => { return time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time; }, easeInCubic: (time) => { return time * time * time; }, easeOutCubic: (time) => { return (--time) * time * time + 1; }, easeInOutCubic: (time) => { return time < 0.5 ? 4 * time * time * time : (time - 1) * (2 * time - 2) * (2 * time - 2) + 1; }, easeInQuart: (time) => { return time * time * time * time; }, easeOutQuart: (time) => { return 1 - (--time) * time * time * time; }, easeInOutQuart: (time) => { return time < 0.5 ? 8 * time * time * time * time : 1 - 8 * (--time) * time * time * time; }, easeInQuint: (time) => { return time * time * time * time * time; }, easeOutQuint: (time) => { return 1 + (--time) * time * time * time * time; }, easeInOutQuint: (time) => { return time < 0.5 ? 16 * time * time * time * time * time : 1 + 16 * (--time) * time * time * time * time; }, easeOutElastic: (time) => { return Math.pow(2, -10 * time) * Math.sin((time - 1 / 4) * (2 * Math.PI) / 1) + 1; } }; /** * Set of allowed events as triggers * for the Animation to start. */ const EVENTS = [ 'click', 'mouseenter', 'mouseover', 'mousedown', 'mouseup', 'dblclick', 'contextmenu', 'wheel', 'mouseleave', 'mouseout' ]; /** * Strip hash (#) from value. * * @param value The given string value * @returns The stripped string value */ function stripHash(value) { return value.substring(0, 1) === '#' ? value.substring(1) : value; } /** * Test if a given value is a string. * * @param value The given value * @returns Whether the given value is a string */ function isString(value) { return typeof value === 'string' || value instanceof String; } /** * Test if a given Element is the Window. * * @param container The given Element * @returns Whether the given Element is Window */ function isWindow(container) { return container === window; } /** * Test if a given value is of type ElementRef. * * @param value The given value * @returns Whether the given value is a number */ function isElementRef(value) { return value instanceof ElementRef; } /** * Whether or not the given value is a Native Element. * * @param value The given value * @returns Whether or not the value is a Native Element */ function isNativeElement(value) { return value instanceof HTMLElement; } /** * Test if a given value is type number. * * @param value The given value * @returns Whether the given value is a number */ function isNumber(value) { return !isNaN(parseFloat(value)) && isFinite(value); } /** Scroll To Animation */ class ScrollToAnimation { /** * Class Constructor. * * @param container The Container * @param listenerTarget The Element that listens for DOM Events * @param isWindow Whether or not the listener is the Window * @param to Position to scroll to * @param options Additional options for scrolling * @param isBrowser Whether or not execution runs in the browser * (as opposed to the server) */ constructor(container, listenerTarget, isWindow, to, options, isBrowser) { this.container = container; this.listenerTarget = listenerTarget; this.isWindow = isWindow; this.to = to; this.options = options; this.isBrowser = isBrowser; /** Recursively loop over the Scroll Animation */ this.loop = () => { this.timeLapsed += this.tick; this.percentage = (this.timeLapsed / this.options.duration); this.percentage = (this.percentage > 1) ? 1 : this.percentage; // Position Update this.position = this.startPosition + ((this.startPosition - this.to <= 0 ? 1 : -1) * this.distance * EASING[this.options.easing](this.percentage)); if (this.lastPosition !== null && this.position === this.lastPosition) { this.stop(); } else { this.source$.next(this.position); this.isWindow ? this.listenerTarget.scrollTo(0, Math.floor(this.position)) : this.container.scrollTop = Math.floor(this.position); this.lastPosition = this.position; } }; this.tick = 16; this.interval = null; this.lastPosition = null; this.timeLapsed = 0; this.windowScrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; if (!this.container) { this.startPosition = this.windowScrollTop; } else { this.startPosition = this.isWindow ? this.windowScrollTop : this.container.scrollTop; } // Correction for Starting Position of nested HTML Elements if (this.container && !this.isWindow) { this.to = this.to - this.container.getBoundingClientRect().top + this.startPosition; } // Set Distance const directionalDistance = this.startPosition - this.to; this.distance = this.container ? Math.abs(this.startPosition - this.to) : this.to; this.mappedOffset = this.options.offset; // Set offset from Offset Map if (this.isBrowser) { this.options .offsetMap .forEach((value, key) => this.mappedOffset = window.innerWidth > key ? value : this.mappedOffset); } this.distance += this.mappedOffset * (directionalDistance <= 0 ? 1 : -1); this.source$ = new ReplaySubject(); } /** * Start the new Scroll Animation. * * @returns Observable containing a number */ start() { clearInterval(this.interval); this.interval = setInterval(this.loop, this.tick); return this.source$.asObservable(); } /** * Stop the current Scroll Animation Loop. * * @param force Force to stop the Animation Loop * @returns Void */ stop() { clearInterval(this.interval); this.interval = null; this.source$.complete(); } } /** * The Scroll To Service handles starting, interrupting * and ending the actual Scroll Animation. It provides * some utilities to find the proper HTML Element on a * given page to setup Event Listeners and calculate * distances for the Animation. */ class ScrollToService { /** * Construct and setup required paratemeters. * * @param document A Reference to the Document * @param platformId Angular Platform ID */ constructor(document, platformId) { this.document = document; this.platformId = platformId; this.interruptiveEvents = ['mousewheel', 'DOMMouseScroll', 'touchstart']; } /** * Target an Element to scroll to. Notice that the `TimeOut` decorator * ensures the executing to take place in the next Angular lifecycle. * This allows for scrolling to elements that are e.g. initially hidden * by means of `*ngIf`, but ought to be scrolled to eventually. * * @todo type 'any' in Observable should become custom type like 'ScrollToEvent' (base class), see issue comment: * - https://github.com/nicky-lenaers/ngx-scroll-to/issues/10#issuecomment-317198481 * * @param options Configuration Object * @returns Observable */ scrollTo(options) { if (!isPlatformBrowser(this.platformId)) { return new ReplaySubject().asObservable(); } return this.start(options); } /** * Start a new Animation. * * @todo Emit proper events from subscription * * @param options Configuration Object * @returns Observable */ start(options) { // Merge config with default values const mergedConfigOptions = Object.assign(Object.assign({}, DEFAULTS), options); if (this.animation) { this.animation.stop(); } const targetNode = this.getNode(mergedConfigOptions.target); if (mergedConfigOptions.target && !targetNode) { return throwError(() => new Error('Unable to find Target Element')); } const container = this.getContainer(mergedConfigOptions, targetNode); if (mergedConfigOptions.container && !container) { return throwError(() => new Error('Unable to find Container Element')); } const listenerTarget = this.getListenerTarget(container) || window; let to = container ? container.getBoundingClientRect().top : 0; if (targetNode) { to = isWindow(listenerTarget) ? window.scrollY + targetNode.getBoundingClientRect().top : targetNode.getBoundingClientRect().top; } // Create Animation this.animation = new ScrollToAnimation(container, listenerTarget, isWindow(listenerTarget), to, mergedConfigOptions, isPlatformBrowser(this.platformId)); const onInterrupt = () => this.animation.stop(); this.addInterruptiveEventListeners(listenerTarget, onInterrupt); // Start Animation const animation$ = this.animation.start(); this.subscribeToAnimation(animation$, listenerTarget, onInterrupt); return animation$; } /** * Subscribe to the events emitted from the Scrolling * Animation. Events might be used for e.g. unsubscribing * once finished. * * @param animation$ The Animation Observable * @param listenerTarget The Listener Target for events * @param onInterrupt The handler for Interruptive Events * @returns Void */ subscribeToAnimation(animation$, listenerTarget, onInterrupt) { const subscription = animation$ .subscribe({ complete: () => { this.removeInterruptiveEventListeners(this.interruptiveEvents, listenerTarget, onInterrupt); subscription.unsubscribe(); } }); } /** * Get the container HTML Element in which * the scrolling should happen. * * @param options The Merged Configuration Object * @param targetNode the targeted HTMLElement */ getContainer(options, targetNode) { let container = null; if (options.container) { container = this.getNode(options.container, true); } else if (targetNode) { container = this.getFirstScrollableParent(targetNode); } return container; } /** * Add listeners for the Animation Interruptive Events * to the Listener Target. * * @param events List of events to listen to * @param listenerTarget Target to attach the listener on * @param handler Handler for when the listener fires * @returns Void */ addInterruptiveEventListeners(listenerTarget, handler) { if (!listenerTarget) { listenerTarget = window; } this.interruptiveEvents .forEach(event => listenerTarget .addEventListener(event, handler, this.supportPassive() ? { passive: true } : false)); } /** * Feature-detect support for passive event listeners. * * @returns Whether or not passive event listeners are supported */ supportPassive() { let supportsPassive = false; try { const opts = Object.defineProperty({}, 'passive', { get: () => { supportsPassive = true; } }); window.addEventListener('testPassive', null, opts); window.removeEventListener('testPassive', null, opts); } catch (e) { } return supportsPassive; } /** * Remove listeners for the Animation Interrupt Event from * the Listener Target. Specifying the correct handler prevents * memory leaks and makes the allocated memory available for * Garbage Collection. * * @param events List of Interruptive Events to remove * @param listenerTarget Target to attach the listener on * @param handler Handler for when the listener fires * @returns Void */ removeInterruptiveEventListeners(events, listenerTarget, handler) { if (!listenerTarget) { listenerTarget = window; } events.forEach(event => listenerTarget.removeEventListener(event, handler)); } /** * Find the first scrollable parent Node of a given * Element. The DOM Tree gets searched upwards * to find this first scrollable parent. Parents might * be ignored by CSS styles applied to the HTML Element. * * @param nativeElement The Element to search the DOM Tree upwards from * @returns The first scrollable parent HTML Element */ getFirstScrollableParent(nativeElement) { let style = window.getComputedStyle(nativeElement); const overflowRegex = /(auto|scroll|overlay)/; if (style.position === 'fixed') { return null; } let parent = nativeElement; while (parent.parentElement) { parent = parent.parentElement; style = window.getComputedStyle(parent); if (style.position === 'absolute' || style.overflow === 'hidden' || style.overflowY === 'hidden') { continue; } if (overflowRegex.test(style.overflow + style.overflowY) || parent.tagName === 'BODY') { return parent; } } return null; } /** * Get the Target Node to scroll to. * * @param id The given ID of the node, either a string or * an element reference * @param allowBodyTag Indicate whether or not the Document Body is * considered a valid Target Node * @returns The Target Node to scroll to */ getNode(id, allowBodyTag = false) { let targetNode; if (isString(id)) { if (allowBodyTag && (id === 'body' || id === 'BODY')) { targetNode = this.document.body; } else { targetNode = this.document.getElementById(stripHash(id)); } } else if (isNumber(id)) { targetNode = this.document.getElementById(String(id)); } else if (isElementRef(id)) { targetNode = id.nativeElement; } else if (isNativeElement(id)) { targetNode = id; } return targetNode; } /** * Retrieve the Listener target. This Listener Target is used * to attach Event Listeners on. In case of the target being * the Document Body, we need the actual `window` to listen * for events. * * @param container The HTML Container element * @returns The Listener Target to attach events on */ getListenerTarget(container) { if (!container) { return null; } return this.isDocumentBody(container) ? window : container; } /** * Test if a given HTML Element is the Document Body. * * @param element The given HTML Element * @returns Whether or not the Element is the * Document Body Element */ isDocumentBody(element) { return element.tagName.toUpperCase() === 'BODY'; } } ScrollToService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToService, deps: [{ token: DOCUMENT }, { token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable }); ScrollToService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToService }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToService, decorators: [{ type: Injectable }], ctorParameters: function () { return [{ type: undefined, decorators: [{ type: Inject, args: [DOCUMENT] }] }, { type: undefined, decorators: [{ type: Inject, args: [PLATFORM_ID] }] }]; } }); class ScrollToDirective { constructor(elementRef, scrollToService, renderer2) { this.elementRef = elementRef; this.scrollToService = scrollToService; this.renderer2 = renderer2; this.ngxScrollTo = DEFAULTS.target; this.ngxScrollToEvent = DEFAULTS.action; this.ngxScrollToDuration = DEFAULTS.duration; this.ngxScrollToEasing = DEFAULTS.easing; this.ngxScrollToOffset = DEFAULTS.offset; this.ngxScrollToOffsetMap = DEFAULTS.offsetMap; } /** * Angular Lifecycle Hook - After View Init * * @todo Implement Subscription for Events * * @returns void */ ngAfterViewInit() { // Test Event Support if (EVENTS.indexOf(this.ngxScrollToEvent) === -1) { throw new Error(`Unsupported Event '${this.ngxScrollToEvent}'`); } // Listen for the trigger... this.renderer2.listen(this.elementRef.nativeElement, this.ngxScrollToEvent, (event) => { this.options = { target: this.ngxScrollTo, duration: this.ngxScrollToDuration, easing: this.ngxScrollToEasing, offset: this.ngxScrollToOffset, offsetMap: this.ngxScrollToOffsetMap }; this.scrollToService.scrollTo(this.options); }); } } ScrollToDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToDirective, deps: [{ token: i0.ElementRef }, { token: ScrollToService }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Directive }); ScrollToDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.2.7", type: ScrollToDirective, selector: "[ngxScrollTo]", inputs: { ngxScrollTo: "ngxScrollTo", ngxScrollToEvent: "ngxScrollToEvent", ngxScrollToDuration: "ngxScrollToDuration", ngxScrollToEasing: "ngxScrollToEasing", ngxScrollToOffset: "ngxScrollToOffset", ngxScrollToOffsetMap: "ngxScrollToOffsetMap" }, ngImport: i0 }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToDirective, decorators: [{ type: Directive, args: [{ selector: '[ngxScrollTo]' }] }], ctorParameters: function () { return [{ type: i0.ElementRef }, { type: ScrollToService }, { type: i0.Renderer2 }]; }, propDecorators: { ngxScrollTo: [{ type: Input }], ngxScrollToEvent: [{ type: Input }], ngxScrollToDuration: [{ type: Input }], ngxScrollToEasing: [{ type: Input }], ngxScrollToOffset: [{ type: Input }], ngxScrollToOffsetMap: [{ type: Input }] } }); /** Scroll To Module */ class ScrollToModule { /** * Guaranteed singletons for provided Services across App. * * @return An Angular Module with Providers */ static forRoot() { return { ngModule: ScrollToModule, providers: [ ScrollToService ] }; } } ScrollToModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); ScrollToModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "14.2.7", ngImport: i0, type: ScrollToModule, declarations: [ScrollToDirective], exports: [ScrollToDirective] }); ScrollToModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToModule }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.2.7", ngImport: i0, type: ScrollToModule, decorators: [{ type: NgModule, args: [{ declarations: [ ScrollToDirective ], exports: [ ScrollToDirective ] }] }] }); /* * Public API Surface of ngx-scroll-to */ /** * Generated bundle index. Do not edit. */ export { ScrollToDirective, ScrollToModule, ScrollToService }; //# sourceMappingURL=nicky-lenaers-ngx-scroll-to.mjs.map