import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Platform } from '@angular/cdk/platform'; import { fromEvent, Subject } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; import PerfectScrollbar from 'perfect-scrollbar'; import * as _ from 'lodash'; import { ScrollbarGeometry, ScrollbarPosition } from '@treo/directives/scrollbar/scrollbar.interfaces'; // ----------------------------------------------------------------------------------------------------- // Wrapper directive for the Perfect Scrollbar: https://github.com/mdbootstrap/perfect-scrollbar // Based on https://github.com/zefoy/ngx-perfect-scrollbar // ----------------------------------------------------------------------------------------------------- @Directive({ selector: '[treoScrollbar]', exportAs: 'treoScrollbar' }) export class TreoScrollbarDirective implements OnInit, OnDestroy { isMobile: boolean; ps: PerfectScrollbar | any; // Private private _animation: number | null; private _enabled: boolean; private _options: any; private _unsubscribeAll: Subject; /** * Constructor * * @param {ElementRef} _elementRef * @param {Platform} _platform * @param {Router} _router */ constructor( private _elementRef: ElementRef, private _platform: Platform, private _router: Router ) { // Set the private defaults this._animation = null; this._options = {}; this._unsubscribeAll = new Subject(); // Set the defaults this.enabled = true; this.isMobile = false; } // ----------------------------------------------------------------------------------------------------- // @ Accessors // ----------------------------------------------------------------------------------------------------- /** * Scrollbar options * * @param value */ @Input() set treoScrollbarOptions(value: any) { // Merge the options this._options = _.merge({}, this._options, value); // Destroy and re-init the PerfectScrollbar to update its options setTimeout(() => { this._destroy(); }); setTimeout(() => { this._init(); }); } get treoScrollbarOptions(): any { // Return the options return this._options; } /** * Is enabled * * @param value */ @Input('treoScrollbar') set enabled(value: boolean | '') { // If the value is an empty string, interpret it as 'true' if ( value === '' ) { value = true; } // If the value is the same, return... if ( this._enabled === value ) { return; } // Store the value this._enabled = value; // If enabled... if ( this.enabled ) { // Init the directive this._init(); } else { // Otherwise destroy it this._destroy(); } } get enabled(): boolean | '' { // Return the enabled status return this._enabled; } /** * Getter for _elementRef */ get elementRef(): ElementRef { return this._elementRef; } // ----------------------------------------------------------------------------------------------------- // @ Lifecycle hooks // ----------------------------------------------------------------------------------------------------- /** * On init */ ngOnInit(): void { // Subscribe to window resize event fromEvent(window, 'resize') .pipe( takeUntil(this._unsubscribeAll), debounceTime(150) ) .subscribe(() => { // Update the PerfectScrollbar this.update(); }); } /** * On destroy */ ngOnDestroy(): void { this._destroy(); // Unsubscribe from all subscriptions this._unsubscribeAll.next(); this._unsubscribeAll.complete(); } // ----------------------------------------------------------------------------------------------------- // @ Private methods // ----------------------------------------------------------------------------------------------------- /** * Initialize * * @private */ private _init(): void { // Return, if already initialized if ( this.ps ) { return; } // Check if is mobile if ( this._platform.ANDROID || this._platform.IOS ) { this.isMobile = true; } // Return if it's mobile or the platform is not a browser if ( this.isMobile || !this._platform.isBrowser ) { // Silently set the enabled to false this._enabled = false; return; } // Initialize the PerfectScrollbar this.ps = new PerfectScrollbar(this._elementRef.nativeElement, {...this.treoScrollbarOptions}); } /** * Destroy * * @private */ private _destroy(): void { if ( !this.ps ) { return; } // Destroy the PerfectScrollbar this.ps.destroy(); // Clean up this.ps = null; } // ----------------------------------------------------------------------------------------------------- // @ Public methods // ----------------------------------------------------------------------------------------------------- /** * Update the scrollbar */ update(): void { if ( !this.ps ) { return; } // Update the PerfectScrollbar this.ps.update(); } /** * Destroy the scrollbar */ destroy(): void { this.ngOnDestroy(); } /** * Returns the geometry of the scrollable element * * @param prefix */ geometry(prefix: string = 'scroll'): ScrollbarGeometry { const scrollbarGeometry = new ScrollbarGeometry( this._elementRef.nativeElement[prefix + 'Left'], this._elementRef.nativeElement[prefix + 'Top'], this._elementRef.nativeElement[prefix + 'Width'], this._elementRef.nativeElement[prefix + 'Height']); return scrollbarGeometry; } /** * Returns the position of the scrollable element * * @param absolute */ position(absolute: boolean = false): ScrollbarPosition { let scrollbarPosition; if ( !absolute && this.ps ) { scrollbarPosition = new ScrollbarPosition( this.ps.reach.x || 0, this.ps.reach.y || 0 ); } else { scrollbarPosition = new ScrollbarPosition( this._elementRef.nativeElement.scrollLeft, this._elementRef.nativeElement.scrollTop ); } return scrollbarPosition; } /** * Scroll to * * @param x * @param y * @param speed */ scrollTo(x: number, y?: number, speed?: number): void { if ( y == null && speed == null ) { this.animateScrolling('scrollTop', x, speed); } else { if ( x != null ) { this.animateScrolling('scrollLeft', x, speed); } if ( y != null ) { this.animateScrolling('scrollTop', y, speed); } } } /** * Scroll to X * * @param {number} x * @param {number} speed */ scrollToX(x: number, speed?: number): void { this.animateScrolling('scrollLeft', x, speed); } /** * Scroll to Y * * @param {number} y * @param {number} speed */ scrollToY(y: number, speed?: number): void { this.animateScrolling('scrollTop', y, speed); } /** * Scroll to top * * @param {number} offset * @param {number} speed */ scrollToTop(offset: number = 0, speed?: number): void { this.animateScrolling('scrollTop', offset, speed); } /** * Scroll to bottom * * @param {number} offset * @param {number} speed */ scrollToBottom(offset: number = 0, speed?: number): void { const top = this._elementRef.nativeElement.scrollHeight - this._elementRef.nativeElement.clientHeight; this.animateScrolling('scrollTop', top - offset, speed); } /** * Scroll to left * * @param {number} offset * @param {number} speed */ scrollToLeft(offset: number = 0, speed?: number): void { this.animateScrolling('scrollLeft', offset, speed); } /** * Scroll to right * * @param {number} offset * @param {number} speed */ scrollToRight(offset: number = 0, speed?: number): void { const left = this._elementRef.nativeElement.scrollWidth - this._elementRef.nativeElement.clientWidth; this.animateScrolling('scrollLeft', left - offset, speed); } /** * Scroll to element * * @param {string} qs * @param {number} offset * @param {boolean} ignoreVisible If true, scrollToElement won't happen if element is already inside the current viewport * @param {number} speed */ scrollToElement(qs: string, offset: number = 0, ignoreVisible: boolean = false, speed?: number): void { const element = this._elementRef.nativeElement.querySelector(qs); if ( !element ) { return; } const elementPos = element.getBoundingClientRect(); const scrollerPos = this._elementRef.nativeElement.getBoundingClientRect(); if ( this._elementRef.nativeElement.classList.contains('ps--active-x') ) { if ( ignoreVisible && elementPos.right <= (scrollerPos.right - Math.abs(offset)) ) { return; } const currentPos = this._elementRef.nativeElement['scrollLeft']; const position = elementPos.left - scrollerPos.left + currentPos; this.animateScrolling('scrollLeft', position + offset, speed); } if ( this._elementRef.nativeElement.classList.contains('ps--active-y') ) { if ( ignoreVisible && elementPos.bottom <= (scrollerPos.bottom - Math.abs(offset)) ) { return; } const currentPos = this._elementRef.nativeElement['scrollTop']; const position = elementPos.top - scrollerPos.top + currentPos; this.animateScrolling('scrollTop', position + offset, speed); } } /** * Animate scrolling * * @param target * @param value * @param speed */ animateScrolling(target: string, value: number, speed?: number): void { if ( this._animation ) { window.cancelAnimationFrame(this._animation); this._animation = null; } if ( !speed || typeof window === 'undefined' ) { this._elementRef.nativeElement[target] = value; } else if ( value !== this._elementRef.nativeElement[target] ) { let newValue = 0; let scrollCount = 0; let oldTimestamp = performance.now(); let oldValue = this._elementRef.nativeElement[target]; const cosParameter = (oldValue - value) / 2; const step = (newTimestamp: number) => { scrollCount += Math.PI / (speed / (newTimestamp - oldTimestamp)); newValue = Math.round(value + cosParameter + cosParameter * Math.cos(scrollCount)); // Only continue animation if scroll position has not changed if ( this._elementRef.nativeElement[target] === oldValue ) { if ( scrollCount >= Math.PI ) { this.animateScrolling(target, value, 0); } else { this._elementRef.nativeElement[target] = newValue; // On a zoomed out page the resulting offset may differ oldValue = this._elementRef.nativeElement[target]; oldTimestamp = newTimestamp; this._animation = window.requestAnimationFrame(step); } } }; window.requestAnimationFrame(step); } } }