ngx-page-scroll-core
Version:
Animated scrolling functionality for angular written in pure typescript
500 lines (492 loc) • 25.9 kB
JavaScript
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