UNPKG

ngx-page-scroll-core

Version:

Animated scrolling functionality for angular written in pure typescript

500 lines (492 loc) 25.9 kB
import * as i0 from '@angular/core'; import { InjectionToken, Inject, Injectable, NgModule } from '@angular/core'; /** * Represents a scrolling action */ class PageScrollInstance { /** * Private constructor, requires the properties assumed to be the bare minimum. * Use the factory methods to create instances: * {@link PageScrollService#create} */ constructor(pageScrollOptions) { /** * These properties will be set/manipulated if the scroll animation starts */ /* The initial value of the scrollTop or scrollLeft position when the animation starts */ this.startScrollPosition = 0; /* Whether an interrupt listener is attached to the body or not */ this.interruptListenersAttached = false; /* References to the timer instance that is used to perform the scroll animation to be able to clear it on animation end*/ this.timer = null; if (!pageScrollOptions.scrollViews || pageScrollOptions.scrollViews.length === 0) { pageScrollOptions.scrollViews = [ pageScrollOptions.document.documentElement, pageScrollOptions.document.body, pageScrollOptions.document.body.parentNode, ]; this.isInlineScrolling = false; } else { this.isInlineScrolling = true; } this.pageScrollOptions = pageScrollOptions; } static getScrollingTargetPosition(pageScrollOptions, scrollTargetElement) { const body = pageScrollOptions.document.body; const docEl = pageScrollOptions.document.documentElement; const windowPageYOffset = pageScrollOptions.document.defaultView && pageScrollOptions.document.defaultView.pageYOffset || undefined; const windowPageXOffset = pageScrollOptions.document.defaultView && pageScrollOptions.document.defaultView.pageXOffset || undefined; const scrollTop = windowPageYOffset || docEl.scrollTop || body.scrollTop; const scrollLeft = windowPageXOffset || docEl.scrollLeft || body.scrollLeft; const clientTop = docEl.clientTop || body.clientTop || 0; const clientLeft = docEl.clientLeft || body.clientLeft || 0; if (scrollTargetElement === undefined || scrollTargetElement === null) { // No element found, so return the current position to not cause any change in scroll position return { top: scrollTop, left: scrollLeft }; } const box = scrollTargetElement.getBoundingClientRect(); const top = box.top + scrollTop - clientTop; const left = box.left + scrollLeft - clientLeft; return { top: Math.round(top), left: Math.round(left) }; } static getInlineScrollingTargetPosition(pageScrollOptions, scrollTargetElement) { const position = { top: scrollTargetElement.offsetTop, left: scrollTargetElement.offsetLeft }; if (pageScrollOptions.advancedInlineOffsetCalculation && pageScrollOptions.scrollViews.length === 1) { const accumulatedParentsPos = { top: 0, left: 0 }; // not named window to make sure we're not getting the global window variable by accident const theWindow = scrollTargetElement.ownerDocument.defaultView; let parentFound = false; // Start parent is the immediate parent let parent = scrollTargetElement.parentElement; // Iterate upwards all parents while (!parentFound && parent !== undefined && parent !== null) { if (theWindow.getComputedStyle(parent).getPropertyValue('position') === 'relative') { accumulatedParentsPos.top += parent.offsetTop; accumulatedParentsPos.left += parent.offsetLeft; } // Next iteration parent = parent.parentElement; parentFound = parent === pageScrollOptions.scrollViews[0]; } if (parentFound) { // Only use the results if we found the parent, otherwise we accumulated too much anyway position.top += accumulatedParentsPos.top; position.left += accumulatedParentsPos.left; } else { /* TODO Uncomment if (PageScrollConfig._logLevel >= 2 || (PageScrollConfig._logLevel >= 1 && isDevMode())) { console.warn('Unable to find nested scrolling targets parent!'); }*/ } } return position; } getScrollPropertyValue(scrollingView) { if (!this.pageScrollOptions.verticalScrolling) { return scrollingView.scrollLeft; } return scrollingView.scrollTop; } getScrollClientPropertyValue(scrollingView) { if (!this.pageScrollOptions.verticalScrolling) { return scrollingView.clientWidth; } return scrollingView.clientHeight; } /** * Extract the exact location of the scrollTarget element. * * Extract the scrollTarget HTMLElement from the given PageScrollTarget object. The latter one may be * a string like "#heading2", then this method returns the corresponding DOM element for that id. * */ extractScrollTargetPosition() { const scrollTargetElement = this.getScrollTargetElement(); if (scrollTargetElement === null || scrollTargetElement === undefined) { // Scroll target not found return { top: NaN, left: NaN }; } if (this.isInlineScrolling) { return PageScrollInstance.getInlineScrollingTargetPosition(this.pageScrollOptions, scrollTargetElement); } return PageScrollInstance.getScrollingTargetPosition(this.pageScrollOptions, scrollTargetElement); } /** * Get the top offset of the scroll animation. * This automatically takes the offset location of the scrolling container/scrolling view * into account (for nested/inline scrolling). */ getCurrentOffset() { return this.pageScrollOptions.scrollOffset; } /** * Sets the "scrollTop" or "scrollLeft" property for all scrollViews to the provided value * @return true if at least for one ScrollTopSource the scrollTop/scrollLeft value could be set and it kept the new value. * false if it failed for all ScrollViews, meaning that we should stop the animation * (probably because we're at the end of the scrolling region) */ setScrollPosition(position) { // Set the new scrollTop/scrollLeft to all scrollViews elements return this.pageScrollOptions.scrollViews.reduce((oneAlreadyWorked, scrollingView) => { const startScrollPropertyValue = this.getScrollPropertyValue(scrollingView); if (scrollingView && startScrollPropertyValue !== undefined && startScrollPropertyValue !== null) { const scrollDistance = Math.abs(startScrollPropertyValue - position); // The movement we need to perform is less than 2px // This we consider a small movement which some browser may not perform when // changing the scrollTop/scrollLeft property // Thus in this cases we do not stop the scroll animation, although setting the // scrollTop/scrollLeft value "fails" const isSmallMovement = scrollDistance < this.pageScrollOptions._minScrollDistance; if (!this.pageScrollOptions.verticalScrolling) { scrollingView.scrollLeft = position; } else { scrollingView.scrollTop = position; } // Return true if setting the new scrollTop/scrollLeft value worked // We consider that it worked if the new scrollTop/scrollLeft value is closer to the // desired scrollTop/scrollLeft than before (it might not be exactly the value we // set due to dpi or rounding irregularities) if (isSmallMovement || scrollDistance > Math.abs(this.getScrollPropertyValue(scrollingView) - position)) { return true; } } return oneAlreadyWorked; }, false); } /** * Trigger firing a animation finish event * @param value Whether the animation finished at the target (true) or got interrupted (false) */ fireEvent(value) { if (this.pageScrollOptions.scrollFinishListener) { this.pageScrollOptions.scrollFinishListener.emit(value); } } /** * Attach the interrupt listeners to the PageScrollInstance body. The given interruptReporter * will be called if any of the attached events is fired. * * Possibly attached interruptListeners are automatically removed from the body before the new one will be attached. */ attachInterruptListeners(interruptReporter) { if (this.interruptListenersAttached) { // Detach possibly existing listeners first this.detachInterruptListeners(); } this.interruptListener = (event) => { interruptReporter.report(event, this); }; this.pageScrollOptions.interruptEvents.forEach((event) => this.pageScrollOptions.document.body.addEventListener(event, this.interruptListener)); this.interruptListenersAttached = true; } /** * Remove event listeners from the body and stop listening for events that might be treated as "animation * interrupt" events. */ detachInterruptListeners() { this.pageScrollOptions.interruptEvents.forEach((event) => this.pageScrollOptions.document.body.removeEventListener(event, this.interruptListener)); this.interruptListenersAttached = false; } getScrollTargetElement() { if (typeof this.pageScrollOptions.scrollTarget === 'string') { const targetSelector = this.pageScrollOptions.scrollTarget; if (targetSelector.match(/^#[^\s]+$/g) !== null) { // It's an id selector and a valid id, as it does not contain any white space characters return this.pageScrollOptions.document.getElementById(targetSelector.substr(1)); } return this.pageScrollOptions.document.querySelector(targetSelector); } return this.pageScrollOptions.scrollTarget; } } const NGXPS_CONFIG = new InjectionToken(typeof ngDevMode !== 'undefined' && ngDevMode ? 'ngxps_config' : ''); const defaultPageScrollConfig = { _interval: 10, _minScrollDistance: 2, _logLevel: 1, namespace: 'default', verticalScrolling: true, duration: 1250, scrollOffset: 0, advancedInlineOffsetCalculation: false, interruptEvents: ['mousedown', 'wheel', 'DOMMouseScroll', 'mousewheel', 'keyup', 'touchmove'], interruptKeys: [' ', 'Escape', 'Tab', 'Enter', 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'ArrowDown'], interruptible: true, scrollInView: true, easingLogic: (t, b, c, d) => { // Linear easing return c * t / d + b; }, }; class PageScrollService { stopInternal(interrupted, pageScrollInstance) { const index = this.runningInstances.indexOf(pageScrollInstance); if (index >= 0) { this.runningInstances.splice(index, 1); } if (pageScrollInstance.interruptListenersAttached) { pageScrollInstance.detachInterruptListeners(); } if (pageScrollInstance.timer) { // Clear/Stop the timer clearInterval(pageScrollInstance.timer); // Clear the reference to this timer pageScrollInstance.timer = undefined; pageScrollInstance.fireEvent(!interrupted); return true; } return false; } create(options) { return new PageScrollInstance({ ...this.config, ...options }); } /** * Start a scroll animation. All properties of the animation are stored in the given {@link PageScrollInstance} object. * * This is the core functionality of the whole library. */ // tslint:disable-next-line:cyclomatic-complexity start(pageScrollInstance) { // Merge the default options in the pageScrollInstance options pageScrollInstance.pageScrollOptions = { ...this.config, ...pageScrollInstance.pageScrollOptions }; // Stop all possibly running scroll animations in the same namespace this.stopAll(pageScrollInstance.pageScrollOptions.namespace); if (pageScrollInstance.pageScrollOptions.scrollViews === null || pageScrollInstance.pageScrollOptions.scrollViews.length === 0) { // No scrollViews specified, thus we can't animate anything if (typeof ngDevMode !== 'undefined' && ngDevMode) { if (this.config._logLevel >= 2 || this.config._logLevel >= 1) { console.warn('No scrollViews specified, thus ngx-page-scroll does not know which DOM elements to scroll'); } } return; } let startScrollPositionFound = false; let scrollRange = pageScrollInstance.getScrollClientPropertyValue(pageScrollInstance.pageScrollOptions.scrollViews[0]); // Reset start scroll position to 0. If any of the scrollViews has a different one, it will be extracted next pageScrollInstance.startScrollPosition = 0; // Get the start scroll position from the scrollViews (e.g. if the user already scrolled down the content) pageScrollInstance.pageScrollOptions.scrollViews.forEach(scrollingView => { if (scrollingView === undefined || scrollingView === null) { return; } // Get the scrollTop or scrollLeft value of the first scrollingView that returns a value for its "scrollTop" // or "scrollLeft" property that is not undefined and unequal to 0 const scrollPosition = pageScrollInstance.getScrollPropertyValue(scrollingView); if (!startScrollPositionFound && scrollPosition) { // We found a scrollingView that does not have scrollTop or scrollLeft 0 // Return the scroll position value, as this will be our startScrollPosition pageScrollInstance.startScrollPosition = scrollPosition; startScrollPositionFound = true; // Remember te scrollRange of this scrollingView scrollRange = pageScrollInstance.getScrollClientPropertyValue(scrollingView); } }); const pageScrollOffset = pageScrollInstance.getCurrentOffset(); // Calculate the target position that the scroll animation should go to const scrollTargetPosition = pageScrollInstance.extractScrollTargetPosition(); pageScrollInstance.targetScrollPosition = Math.round((pageScrollInstance.pageScrollOptions.verticalScrolling ? scrollTargetPosition.top : scrollTargetPosition.left) - pageScrollOffset); // Calculate the distance we need to go in total pageScrollInstance.distanceToScroll = pageScrollInstance.targetScrollPosition - pageScrollInstance.startScrollPosition; if (isNaN(pageScrollInstance.distanceToScroll)) { // We weren't able to find the target position, maybe the element does not exist? if (typeof ngDevMode !== 'undefined' && ngDevMode) { if (this.config._logLevel >= 2 || this.config._logLevel >= 1) { console.log('Scrolling not possible, as we can\'t find the specified target'); } } pageScrollInstance.fireEvent(false); return; } // We're at the final destination already // OR we need to scroll down but are already at the end // OR we need to scroll up but are at the top already const allReadyAtDestination = Math.abs(pageScrollInstance.distanceToScroll) < pageScrollInstance.pageScrollOptions._minScrollDistance; // Check how long we need to scroll if a speed option is given // Default executionDuration is the specified duration pageScrollInstance.executionDuration = pageScrollInstance.pageScrollOptions.duration; // Maybe we need to pay attention to the speed option? if ((pageScrollInstance.pageScrollOptions.speed !== undefined && pageScrollInstance.pageScrollOptions.speed !== null) && (pageScrollInstance.pageScrollOptions.duration === undefined || pageScrollInstance.pageScrollOptions.duration === null)) { // Speed option is set and no duration => calculate duration based on speed and scroll distance pageScrollInstance.executionDuration = Math.abs(pageScrollInstance.distanceToScroll) / pageScrollInstance.pageScrollOptions.speed * 1000; } // We should go there directly, as our "animation" would have one big step // only anyway and this way we save the interval stuff const tooShortInterval = pageScrollInstance.executionDuration <= pageScrollInstance.pageScrollOptions._interval; if (allReadyAtDestination || tooShortInterval) { if (this.config._logLevel >= 2 || this.config._logLevel >= 1) { if (typeof ngDevMode !== 'undefined' && ngDevMode) { if (allReadyAtDestination) { console.log('Scrolling not possible, as we can\'t get any closer to the destination'); } else { console.log('Scroll duration shorter that interval length, jumping to target'); } } } pageScrollInstance.setScrollPosition(pageScrollInstance.targetScrollPosition); pageScrollInstance.fireEvent(true); return; } if (!pageScrollInstance.pageScrollOptions.scrollInView) { const alreadyInView = pageScrollInstance.targetScrollPosition > pageScrollInstance.startScrollPosition && pageScrollInstance.targetScrollPosition <= pageScrollInstance.startScrollPosition + scrollRange; if (alreadyInView) { if (typeof ngDevMode !== 'undefined' && ngDevMode) { if (this.config._logLevel >= 2 || this.config._logLevel >= 1) { console.log('Not scrolling, as target already in view'); } } pageScrollInstance.fireEvent(true); return; } } // Register the interrupt listeners if we want an interruptible scroll animation if (pageScrollInstance.pageScrollOptions.interruptible) { pageScrollInstance.attachInterruptListeners(this.onInterrupted); } // Let's get started, get the start time... pageScrollInstance.startTime = new Date().getTime(); // .. and calculate the end time (when we need to finish at last) pageScrollInstance.endTime = pageScrollInstance.startTime + pageScrollInstance.executionDuration; pageScrollInstance.timer = setInterval((instance) => { // Take the current time const currentTime = new Date().getTime(); // Determine the new scroll position let newScrollPosition; let stopNow = false; if (instance.endTime <= currentTime) { // We're over the time already, so go the targetScrollPosition (aka destination) newScrollPosition = instance.targetScrollPosition; stopNow = true; } else { // Calculate the scroll position based on the current time using the easing function newScrollPosition = Math.round(instance.pageScrollOptions.easingLogic(currentTime - instance.startTime, instance.startScrollPosition, instance.distanceToScroll, instance.executionDuration)); } if (typeof ngDevMode !== 'undefined' && ngDevMode) { if (this.config._logLevel >= 5) { console.warn('Scroll Position: ' + newScrollPosition); } } // Set the new scrollPosition to all scrollViews elements if (!instance.setScrollPosition(newScrollPosition)) { // Setting the new scrollTop/scrollLeft value failed for all ScrollViews // early stop the scroll animation to save resources stopNow = true; } // At the end do the internal stop maintenance and fire the pageScrollFinish event // (otherwise the event might arrive at "too early") if (stopNow) { this.stopInternal(false, instance); } }, this.config._interval, pageScrollInstance); // Register the instance as running one this.runningInstances.push(pageScrollInstance); } scroll(options) { this.start(this.create(options)); } /** * Stop all running scroll animations. Optionally limit to stop only the ones of specific namespace. */ stopAll(namespace) { if (this.runningInstances.length > 0) { let stoppedSome = false; for (let i = 0; i < this.runningInstances.length; ++i) { const pageScrollInstance = this.runningInstances[i]; if (!namespace || pageScrollInstance.pageScrollOptions.namespace === namespace) { stoppedSome = true; this.stopInternal(true, pageScrollInstance); // Decrease the counter, as we removed an item from the array we iterate over i--; } } return stoppedSome; } return false; } stop(pageScrollInstance) { return this.stopInternal(true, pageScrollInstance); } constructor(customConfig) { this.runningInstances = []; this.onInterrupted = { report: (event, pageScrollInstance) => { if (!pageScrollInstance.pageScrollOptions.interruptible) { // Non-interruptible anyway, so do not stop anything return; } let shouldStop = true; if (event.type === 'keyup') { // Only stop if specific keys have been pressed, for all others don't stop anything if (this.config.interruptKeys.indexOf(event.key) === -1) { // The pressed key is not in the list of interrupting keys shouldStop = false; } } else if (event.type === 'mousedown') { // For mousedown events we only stop the scroll animation of the mouse has // been clicked inside the scrolling container if (!pageScrollInstance.pageScrollOptions.scrollViews.some(scrollingView => scrollingView.contains(event.target))) { // Mouse clicked an element which is not inside any of the the scrolling containers shouldStop = false; } } if (shouldStop) { this.stopAll(pageScrollInstance.pageScrollOptions.namespace); } }, }; this.config = { ...defaultPageScrollConfig, ...customConfig }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.2", ngImport: i0, type: PageScrollService, deps: [{ token: NGXPS_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.2", ngImport: i0, type: PageScrollService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.2", ngImport: i0, type: PageScrollService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [NGXPS_CONFIG] }] }] }); class NgxPageScrollCoreModule { static forRoot(config) { return { ngModule: NgxPageScrollCoreModule, providers: [PageScrollService, { provide: NGXPS_CONFIG, useValue: config }], }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.2", ngImport: i0, type: NgxPageScrollCoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.2.2", ngImport: i0, type: NgxPageScrollCoreModule }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.2.2", ngImport: i0, type: NgxPageScrollCoreModule, providers: [ PageScrollService, { provide: NGXPS_CONFIG, useValue: {} }, ] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.2", ngImport: i0, type: NgxPageScrollCoreModule, decorators: [{ type: NgModule, args: [{ providers: [ PageScrollService, { provide: NGXPS_CONFIG, useValue: {} }, ], }] }] }); /* * Public API Surface of ngx-page-scroll-core */ /** * Generated bundle index. Do not edit. */ export { NGXPS_CONFIG, NgxPageScrollCoreModule, PageScrollInstance, PageScrollService, defaultPageScrollConfig }; //# sourceMappingURL=ngx-page-scroll-core.mjs.map