UNPKG

ngx-scroll-position-restoration

Version:
663 lines (653 loc) 32 kB
import { InjectionToken, Injectable, NgZone, Inject, PLATFORM_ID, Directive, ElementRef, NgModule } from '@angular/core'; import { isPlatformServer } from '@angular/common'; import { NavigationStart, NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { finder } from '@medv/finder'; const WINDOW_SELECTOR = '__window-selector__'; /** * DomUtils * * I provide a unified interface for dealing with scroll offsets across different types of targets (elements vs. windows). */ /** * I get the scroll-top of the given target in the active DOM. */ function getScrollTop(target) { if (target instanceof Window) { return window.scrollY; } else { return target.scrollTop; } } /** * I return the CSS selector for the given target. * ___ * NOTE: The generated selector is intended to be consumed by this class only - it may not produce a valid CSS selector. */ function getSelector(target) { // NOTE: I am breaking this apart because TypeScript was having trouble dealing // with type-guard. I believe this is part of this bug: // -- // https://github.com/Microsoft/TypeScript/issues/7271#issuecomment-360123191 if (target instanceof Window) { return WINDOW_SELECTOR; } else { // If the given element is not part of the active document, there's no way for us // to calculate a selector for it. if (!document.body.contains(target)) { return null; } return finder(target); } } /** * I get the scrollable target for the given 'scroll' event. * ___ * NOTE: If you want to ignore (ie, not reinstate the scroll) of a particular type of DOM element, return NULL from this method. */ function getTargetFromScrollEvent(event) { const node = event.target; if (node instanceof HTMLDocument) { return window; } else if (node instanceof Element) { return node; } return null; } /** * I attempt to scroll the given target to the given scrollTop and return the resultant value presented by the target. * @param target * @param scrollTop * @returns resultant scroll top. */ function scrollTo(target, scrollTop) { if (target instanceof Window) { target.scrollTo(0, scrollTop); return target.scrollY; } else if (target instanceof Element) { target.scrollTop = scrollTop; return target.scrollTop; } return null; } /** * I return the target accessible at the given CSS selector. */ function select(selector) { if (selector === WINDOW_SELECTOR) { return window; } else { return document.querySelector(selector); } } /** * Source: * - https://www.bennadel.com/blog/3534-restoring-and-resetting-the-scroll-position-using-the-navigationstart-event-in-angular-7-0-4.htm * - http://bennadel.github.io/JavaScript-Demos/demos/router-retain-scroll-polyfill-angular7/ * - https://github.com/bennadel/JavaScript-Demos/tree/master/demos/router-retain-scroll-polyfill-angular7 */ const NGX_SCROLL_POSITION_RESTORATION_CONFIG_INJECTION_TOKEN = new InjectionToken('ngx_scroll_position_restoration_config_injection_token'); class NgxScrollPositionRestorationService { constructor(router, zone, platformId, config) { this.router = router; this.zone = zone; this.platformId = platformId; this.config = config; this.applyStateToDomTimer = 0; this.currentPageState = {}; this.lastNavigationStartAt = 0; this.navigationIDs = []; this.pageStates = {}; this.scrolledElements = new Set(); this.maximumNumberOfCachedPageStates = 20; this.serviceDestroyed$ = new Subject(); } /** * Initialize NgxScrollPositionRestorationService. */ initialize() { if (isPlatformServer(this.platformId)) { return; } this.setupScrollBinding(); // this.setupRouterBinding(); // I bind to the router events and perform to primary actions: // -- // NAVIGATION START: When the user is about to navigate away from the current view, // I inspect the current DOM state and commit any scrolled-element offsets to the // in-memory cache of the page state (scroll events were recorded during the lifetime // of the current router state). // -- // NAVIGATION END: When the user completes a navigation to a new view, I check to see // if the new view is really the restoration of a previously cached page state; and, // if so, I try to reinstate the old scrolled-element offsets in the rendered DOM. this.router.events.pipe(takeUntil(this.serviceDestroyed$)).subscribe((event) => { // Filter navigation event streams to the appropriate event handlers. if (event instanceof NavigationStart) { this.handleNavigationStart(event); } else if (event instanceof NavigationEnd) { this.handleNavigationEnd(); } }); // Since we're going to be implementing a custom scroll retention algorithm, // let's disable the one that is provided by the browser. This will keep our // polyfill the source of truth. this.disableBrowserDefaultScrollRestoration(); } ngOnDestroy() { this.serviceDestroyed$.next(); this.serviceDestroyed$.complete(); } clearSavedWindowScrollTopInLastNavigation() { const lastNavigationId = this.navigationIDs[this.navigationIDs.length - 1]; if (lastNavigationId) { if (this.config.debug && this.pageStates[lastNavigationId][WINDOW_SELECTOR]) { console.log('Navigation in a "secondary" router-outlet - Remove window scroll position from recorded scroll positions.'); } delete (this.pageStates[lastNavigationId][WINDOW_SELECTOR]); } } /** * I attempt to apply the given page-state to the rendered DOM. I will continue to poll the document until all states have been reinstated; or, until the poll duration has been exceeded; or, until a subsequent navigation takes place. */ applyPageStateToDom(pageState) { if (this.config.debug) { this.debugPageState(pageState, 'Attempting to reapply scroll positions after a popstate navigation (backward or forward).'); } if (this.objectIsEmpty(pageState)) { return; } // Let's create a copy of the page state so that we can safely delete keys from // it as we successfully apply them to the rendered DOM. const pendingPageState = Object.assign({}, pageState); // Setup the scroll retention timer outside of the Angular Zone so that it // doesn't trigger any additional change-detection digests. this.zone.runOutsideAngular(() => { const startedAt = Date.now(); this.applyStateToDomTimer = setInterval(() => { for (const selector in pendingPageState) { const target = select(selector); // If the target element doesn't exist in the DOM yet, it // could be an indication of asynchronous loading and // rendering. Move onto the next selector while we still // have time. if (!target) { continue; } // If the element in question has been scrolled (by the user) // while we're attempting to reinstate the previous scroll // offsets, then ignore this state - the user's action should // take precedence. if (this.scrolledElements.has(target)) { delete (pendingPageState[selector]); // Otherwise, let's try to restore the scroll for the target. } else { const scrollTop = pendingPageState[selector]; const resultantScrollTop = scrollTo(target, scrollTop); // If the attempt to restore the element to its previous // offset resulted in a match, then stop tracking this // element. Otherwise, we'll continue to try and scroll // it in the subsequent tick. // -- // NOTE: We continue to try and update it because the // target element may exist in the DOM but also be // loading asynchronous data that is required for the // previous scroll offset. if (resultantScrollTop === scrollTop) { delete (pendingPageState[selector]); } } } // If there are no more elements to scroll or, we've exceeded our // poll duration, then stop watching the DOM. if (this.objectIsEmpty(pendingPageState) || ((Date.now() - startedAt) >= this.config.pollDuration)) { clearTimeout(this.applyStateToDomTimer); if (this.config.debug) { if (this.objectIsEmpty(pendingPageState)) { console.log('%c Successfully reapplied all recorded scroll positions to the DOM.', 'color: #2ecc71'); } else { console.warn(`Could not reapply following recorded scroll positions to the DOM after a poll duration of: ${this.config.pollDuration} milliseconds:`); this.debugPageState(pendingPageState); } } } }, this.config.pollCadence); }); } /** * I get the page state from the given set of nodes. This extracts the CSS selectors and offsets from the recorded elements. */ getPageStateFromNodes(nodes) { const pageState = {}; nodes.forEach(target => { // Generate a CSS selector from the given target. // -- // TODO: Right now, this algorithm creates the selector by walking up the // DOM tree and using the simulated encapsulation attributes. But, it // would be cool to have a configuration option that tells this algorithm // to look for a specific id-prefix or attribute or something. This would // require the developer to provide those; but it would be optimal. const selector = getSelector(target); // If the given Target is no longer part of the active DOM, the selector // will be null. if (selector) { pageState[selector] = getScrollTop(target); } }); return pageState; } /** * I determine if the given object is empty (ie, has no keys). */ objectIsEmpty(object) { for (const key in object) { return false; } return true; } // The goal of the NavigationStart event is to take changes that have been made // to the current DOM and store them in the render-state tree so they can be // reinstated at a future date. handleNavigationStart(event) { this.lastNavigationStartAt = Date.now(); // Get the navigation ID and the restored navigation ID for use in the // NavigationEnd event handler. this.navigationID = event.id; /** * Maybe in future update @todo: use ngx-navigation-trigger here, like: * (event.restoredState && this.whenShouldScrollPositionBeRestored.has(this.navigationTrigger)) */ this.restoredNavigationID = event.restoredState ? event.restoredState.navigationId : null; // If the user is navigating away from the current view, kill any timers that // may be trying to reinstate a page-state. clearTimeout(this.applyStateToDomTimer); // Before we navigate away from the current page state, let's commit any // scroll-elements to the current page state. Object.assign(this.currentPageState, this.getPageStateFromNodes(this.scrolledElements)); this.scrolledElements.clear(); if (this.config.debug) { this.debugPageState(this.currentPageState, 'Recorded scroll positions.'); } } ; // The primary goal of the NavigationEnd event is to reinstate a cached page // state in the event that the navigation is restoring a previously rendered page // as the result of a popstate event (ex, the user hit the Back or Forward // buttons). handleNavigationEnd() { const previousPageState = this.currentPageState; // Now that we know the navigation was successful, let's start and store a // new page state to track future scrolling. this.currentPageState = this.pageStates[this.navigationID] = {}; // While we are going to track elements that will be scrolled during the // current page rendering, it is possible that there are elements that were // scrolled during a prior page rendering that still exist on the page, but // were not scrolled recently (such as a secondary router-outlet). As such, // let's look at the previous page state and 'pull forward' any state that // still pertains to the current page. if (!this.restoredNavigationID) { for (const selector in previousPageState) { const target = select(selector); // Only pull the selector forward if it corresponds to an element // that still exists in the rendered page. if (!target) { continue; } // Only pull the selector forward if the target is still at the same // offset after the navigation has taken place. In other words, if // the offset has somehow changed in between the NavigationStart and // NavigationEnd events, then ignore it. To be honest, this really // only applies to the WINDOW, which can change in offset due to the // change in what the Router is actively rendering in the DOM. if (getScrollTop(target) !== previousPageState[selector]) { continue; } this.currentPageState[selector] = previousPageState[selector]; if (this.config.debug) { console.group('Pulling scroll position from previous page state in current page state.'); console.log({ selector, scrollPosition: this.currentPageState[selector] }); console.groupEnd(); } } // If we're restoring a previous page state AND we have that previous page // state cached in-memory, let's copy the previous state and then restore the // offsets in the DOM. } else if (this.restoredNavigationID && this.pageStates[this.restoredNavigationID]) { // NOTE: We're copying the offsets from the restored state into the // current state instead of just swapping the references because these // navigations are different in the Router history. Since each navigation // - imperative or popstate - gets a unique ID, we never truly 'go back' // in history; the Router only 'goes forward', with the notion that we're // recreating a previous state sometimes. this.applyPageStateToDom(Object.assign(this.currentPageState, this.pageStates[this.restoredNavigationID])); } // Keep track of the navigation event so we can limit the size of our // in-memory page state cache. this.navigationIDs.push(this.navigationID); // Trim the oldest page states as we go so that the in-memory cache doesn't // grow, unbounded. while (this.navigationIDs.length > this.maximumNumberOfCachedPageStates) { delete (this.pageStates[this.navigationIDs.shift()]); } } ; /** * I bind to the scroll event and keep track of any elements that are scrolled in the rendered document. */ setupScrollBinding() { /** * Maybe @todo: You should try to find a way to get scrollable (scrolled) elements only during NavigationStart. * Advantages: * - Better performance: no need to listen to the scroll event the whole time. * - Some elements might be added to the `scrolledElements` are not part of the DOM any more. * Disavantages: * - during NavigationStart scrollable elements that are maybe present after the intialization of page (before any user-interactions that can remove them) might be not part DOM any more. * */ // Add scroll-binding outside of the Angular Zone so it doesn't trigger any // additional change-detection digests. this.zone.runOutsideAngular(() => { // When navigating, the browser emits some scroll events as the DOM // (Document Object Model) changes shape in a way that forces the various // scroll offsets to change. Since these scroll events are not indicative // of a user's actual scrolling intent, we're going to ignore them. This // needs to be done on both sides of the navigation event (for reasons // that are not fully obvious or logical -- basically, the window's // scroll changes at a time that is not easy to tap into). Ignoring these // scroll events is important because the polyfilly stops trying to // reinstate a scroll-offset if it sees that the given element has // already been scrolled during the current rendering. const scrollBufferWindow = 100; let target; window.addEventListener('scroll', event => { // If the scroll event happens immediately following a // navigation event, then ignore it - it is likely a scroll that // was forced by the browser's native behavior. if ((Date.now() - this.lastNavigationStartAt) < scrollBufferWindow) { return; } // The target will return NULL for elements that have irrelevant // scroll behaviors (like textarea inputs). As such, we have to // check to see if the domUtils returned anything. target = getTargetFromScrollEvent(event); if (target) { this.scrolledElements.add(target); } }, // We have to use the CAPTURING phase. Scroll events DO NOT BUBBLE. // As such, if we want to listen for all scroll events in the // document, we have to use the capturing phase (as the event travels // down through the DOM tree). true); }); } debugPageState(pageState, message) { if (this.objectIsEmpty(pageState)) { return; } console.group(message || ''); for (const [selector, scrollPosition] of Object.entries(pageState)) { console.log({ selector, scrollPosition }); } console.groupEnd(); } /** * Disable browser default scroll restoration. * * Documentation: * - https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration */ disableBrowserDefaultScrollRestoration() { if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; } } } NgxScrollPositionRestorationService.decorators = [ { type: Injectable } ]; NgxScrollPositionRestorationService.ctorParameters = () => [ { type: Router }, { type: NgZone }, { type: String, decorators: [{ type: Inject, args: [PLATFORM_ID,] }] }, { type: undefined, decorators: [{ type: Inject, args: [NGX_SCROLL_POSITION_RESTORATION_CONFIG_INJECTION_TOKEN,] }] } ]; /** * Source: * - https://www.bennadel.com/blog/3534-restoring-and-resetting-the-scroll-position-using-the-navigationstart-event-in-angular-7-0-4.htm * - http://bennadel.github.io/JavaScript-Demos/demos/router-retain-scroll-polyfill-angular7/ * - https://github.com/bennadel/JavaScript-Demos/tree/master/demos/router-retain-scroll-polyfill-angular7 */ const ANGULAR_DEFAULT_ROUTER_OUTLET_NAME = 'primary'; /** * I co-opt the <router-outlet> element selector so that I can tap into the life-cycle of the core RouterOutlet directive. * * REASON: When the user clicks on a link, it's quite hard to differentiate between a primary navigation, which should probably scroll the user back to the top of the viewport; and, something like a tabbed-navigation, which should probably keep the user's scroll around the offset associated with the tab. As such, we are going to rely on the inherent scroll-position of the view as the router-outlet target is pulled out of the DOM. * PS: Keep in mind in Angular per default scroll position is maintained on navigation. */ class CustomRouterOutletDirective { constructor(elementRef, router, routerOutlet, ngxScrollPositionRestorationService, platformId, config) { this.elementRef = elementRef; this.router = router; this.routerOutlet = routerOutlet; this.ngxScrollPositionRestorationService = ngxScrollPositionRestorationService; this.platformId = platformId; this.config = config; this.recordedScrollPositions = []; this.directiveDestroyed$ = new Subject(); } ngOnInit() { if (isPlatformServer(this.platformId)) { return; } this.routerOutlet.activateEvents.pipe(takeUntil(this.directiveDestroyed$)).subscribe(() => this.handleActivateEvent()); this.routerOutlet.deactivateEvents.pipe(takeUntil(this.directiveDestroyed$)).subscribe(() => this.handleDectivateEvent()); this.router.events.pipe(takeUntil(this.directiveDestroyed$)).subscribe((event) => this.handleNavigationEvent(event)); } ngOnDestroy() { this.directiveDestroyed$.next(); this.directiveDestroyed$.complete(); } /** * Called when a router-outlet component has been rendered. */ handleActivateEvent() { var _a; const currentRouterOutletName = this.routerOutlet.activatedRoute.outlet; // A Check because there is no `router.getCurrentNavigation` function in Angular 6. const currentNavigation = typeof this.router.getCurrentNavigation === 'function' ? this.router.getCurrentNavigation() : null; if (currentRouterOutletName !== ANGULAR_DEFAULT_ROUTER_OUTLET_NAME && !((_a = currentNavigation === null || currentNavigation === void 0 ? void 0 : currentNavigation.extras) === null || _a === void 0 ? void 0 : _a.skipLocationChange)) { this.ngxScrollPositionRestorationService.clearSavedWindowScrollTopInLastNavigation(); } const isRootRouterOutlet = this.isRootRouterOutlet(this.routerOutlet.activatedRoute); if (isRootRouterOutlet && this.navigationTrigger === 'imperative' && this.routerOutlet.activatedRoute.outlet === ANGULAR_DEFAULT_ROUTER_OUTLET_NAME) { scrollTo(window, 0); if (this.config.debug) { console.log('Imperative navigation: scrolled to the top (scrollTop = 0) of the window.'); } } else { // At this point, the View-in-question has been mounted in the DOM (Document // Object Model). We can now walk back up the DOM and make sure that the // previously-recorded offsets (in the last 'deactivate' event) are being applied // to the ancestral elements. This will prevent the browser's native desire to // auto-scroll-down a document once the view has been injected. Essentially, this // ensures that we scroll back to the 'expected top' as the user clicks through // the application. if (this.config.debug) { console.group(`router-outlet ("${this.elementRef.nativeElement.getAttribute('name') || ANGULAR_DEFAULT_ROUTER_OUTLET_NAME}") - Reapply recorded scroll positions.`); console.log(this.recordedScrollPositions.slice()); console.groupEnd(); } if (this.recordedScrollPositions.length === 0) { return; } for (const { elementSelector, scrollPosition } of this.recordedScrollPositions) { if (elementSelector) { const element = select(elementSelector); if (element) { scrollTo(element, scrollPosition); } } } this.recordedScrollPositions = []; } } /** * Called when a router-outlet component has been destroyed from the DOM. This means, at this point, the scroll position of the scrollable element containing the router-outlet component should be `0` (@todo: (BUG) but this seems not to work in Angular@13.1.1: component is not destroyed at this point). */ handleDectivateEvent() { // At this point, the View-in-question has already been removed from the // document. Let's walk up the DOM (Document Object Model) and record the scroll // position of all scrollable elements. This will give us a sense of what the DOM // should look like after the next View is injected. let node = this.elementRef.nativeElement.parentNode; while (node && node.tagName !== 'BODY') { // If this is an "Element" node, capture its offset. if (node.nodeType === 1) { const scrollTop = getScrollTop(node); const elementSelector = getSelector(node); this.recordedScrollPositions.push({ elementSelector, target: node, scrollPosition: scrollTop }); } node = node.parentNode; } if (this.config.debug) { console.group(`router-outlet ("${this.elementRef.nativeElement.getAttribute('name') || ANGULAR_DEFAULT_ROUTER_OUTLET_NAME}") - Recorded scroll positions.`); console.log(this.recordedScrollPositions.slice()); console.groupEnd(); } } /** * I get called whenever a router event is raised. */ handleNavigationEvent(event) { if (event instanceof NavigationStart) { this.navigationTrigger = event.navigationTrigger; } // The 'offsets' are only meant to be used across a single navigation. As such, // let's clear out the offsets at the end of each navigation in order to ensure // that old offsets don't accidentally get applied to a future view mounted by // the current router-outlet. if (event instanceof NavigationEnd) { this.recordedScrollPositions = []; } } /** * Is root "primary" (or any secondary) router-outet. */ isRootRouterOutlet(actvitedRoute) { var _a, _b; const currentComponent = actvitedRoute.component; const parentChildren = (_b = (_a = actvitedRoute.parent) === null || _a === void 0 ? void 0 : _a.routeConfig) === null || _b === void 0 ? void 0 : _b.children; if (!Array.isArray(parentChildren)) { return true; } for (const route of parentChildren) { if (route.component === currentComponent) { return false; } } return true; // Alternative: solution 02 (but not valid for secondary router-outlet) // if (actvitedRoute.parent?.component) { // return false; // } else { // return true; // } } } CustomRouterOutletDirective.decorators = [ { type: Directive, args: [{ selector: 'router-outlet' },] } ]; CustomRouterOutletDirective.ctorParameters = () => [ { type: ElementRef }, { type: Router }, { type: RouterOutlet }, { type: NgxScrollPositionRestorationService }, { type: String, decorators: [{ type: Inject, args: [PLATFORM_ID,] }] }, { type: undefined, decorators: [{ type: Inject, args: [NGX_SCROLL_POSITION_RESTORATION_CONFIG_INJECTION_TOKEN,] }] } ]; /** * Source: * - https://www.bennadel.com/blog/3534-restoring-and-resetting-the-scroll-position-using-the-navigationstart-event-in-angular-7-0-4.htm * - http://bennadel.github.io/JavaScript-Demos/demos/router-retain-scroll-polyfill-angular7/ * - https://github.com/bennadel/JavaScript-Demos/tree/master/demos/router-retain-scroll-polyfill-angular7 */ const defaultNgxScrollPositionRestorationConfig = { pollDuration: 3000, pollCadence: 50, debug: false }; class NgxScrollPositionRestorationModule { constructor(ngxScrollPositionRestorationService) { if (!NgxScrollPositionRestorationModule.serviceInitialized) { ngxScrollPositionRestorationService.initialize(); NgxScrollPositionRestorationModule.serviceInitialized = true; } } static forRoot(config) { return ({ ngModule: NgxScrollPositionRestorationModule, providers: [ { provide: NGX_SCROLL_POSITION_RESTORATION_CONFIG_INJECTION_TOKEN, useValue: Object.assign(defaultNgxScrollPositionRestorationConfig, config) } ] }); } } /** * Since NgxScrollPositionRestorationModule can be imported in child modules, it is needed to track if the ngxScrollPositionRestorationService has been already initialized to avoid duplicate calls of the `initialize` method. */ NgxScrollPositionRestorationModule.serviceInitialized = false; NgxScrollPositionRestorationModule.decorators = [ { type: NgModule, args: [{ declarations: [ CustomRouterOutletDirective ], exports: [ CustomRouterOutletDirective ], providers: [ NgxScrollPositionRestorationService ] },] } ]; NgxScrollPositionRestorationModule.ctorParameters = () => [ { type: NgxScrollPositionRestorationService } ]; /* * Public API Surface of ngx-scroll-position-restoration */ /** * Generated bundle index. Do not edit. */ export { CustomRouterOutletDirective, NgxScrollPositionRestorationModule, NGX_SCROLL_POSITION_RESTORATION_CONFIG_INJECTION_TOKEN as ɵa, NgxScrollPositionRestorationService as ɵb, defaultNgxScrollPositionRestorationConfig as ɵc }; //# sourceMappingURL=ngx-scroll-position-restoration.js.map