Permalink
Browse files

feat(router): implement scrolling restoration service (#20030)

For documentation, see `RouterModule.scrollPositionRestoration`

Fixes #13636 #10929 #7791 #6595

PR Close #20030
  • Loading branch information...
vsavkin authored and mhevery committed May 17, 2018
1 parent 1b253e1 commit 49c5234c6817ceae02b8bacb30adae99c45a49a9
@@ -25,3 +25,4 @@ export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCase
export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index';
export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id';
export {VERSION} from './version';
export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller';
@@ -0,0 +1,183 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {defineInjectable, inject} from '@angular/core';
import {DOCUMENT} from './dom_tokens';
/**
* @whatItDoes Manages the scroll position.
*/
export abstract class ViewportScroller {
// De-sugared tree-shakable injection
// See #23917
/** @nocollapse */
static ngInjectableDef = defineInjectable(
{providedIn: 'root', factory: () => new BrowserViewportScroller(inject(DOCUMENT), window)});
/**
* @whatItDoes Configures the top offset used when scrolling to an anchor.
*
* When given a tuple with two number, the service will always use the numbers.
* When given a function, the service will invoke the function every time it restores scroll
* position.
*/
abstract setOffset(offset: [number, number]|(() => [number, number])): void;
/**
* @whatItDoes Returns the current scroll position.
*/
abstract getScrollPosition(): [number, number];
/**
* @whatItDoes Sets the scroll position.
*/
abstract scrollToPosition(position: [number, number]): void;
/**
* @whatItDoes Scrolls to the provided anchor.
*/
abstract scrollToAnchor(anchor: string): void;
/**
* @whatItDoes Disables automatic scroll restoration provided by the browser.
* See also [window.history.scrollRestoration
* info](https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration)
*/
abstract setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void;
}
/**
* @whatItDoes Manages the scroll position.
*/
export class BrowserViewportScroller implements ViewportScroller {
private offset: () => [number, number] = () => [0, 0];
constructor(private document: any, private window: any) {}
/**
* @whatItDoes Configures the top offset used when scrolling to an anchor.
*
* * When given a number, the service will always use the number.
* * When given a function, the service will invoke the function every time it restores scroll
* position.
*/
setOffset(offset: [number, number]|(() => [number, number])): void {
if (Array.isArray(offset)) {
this.offset = () => offset;
} else {
this.offset = offset;
}
}
/**
* @whatItDoes Returns the current scroll position.
*/
getScrollPosition(): [number, number] {
if (this.supportScrollRestoration()) {
return [this.window.scrollX, this.window.scrollY];
} else {
return [0, 0];
}
}
/**
* @whatItDoes Sets the scroll position.
*/
scrollToPosition(position: [number, number]): void {
if (this.supportScrollRestoration()) {
this.window.scrollTo(position[0], position[1]);
}
}
/**
* @whatItDoes Scrolls to the provided anchor.
*/
scrollToAnchor(anchor: string): void {
if (this.supportScrollRestoration()) {
const elSelectedById = this.document.querySelector(`#${anchor}`);
if (elSelectedById) {
this.scrollToElement(elSelectedById);
return;
}
const elSelectedByName = this.document.querySelector(`[name='${anchor}']`);
if (elSelectedByName) {
this.scrollToElement(elSelectedByName);
return;
}
}
}
/**
* @whatItDoes Disables automatic scroll restoration provided by the browser.
*/
setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {
if (this.supportScrollRestoration()) {
const history = this.window.history;
if (history && history.scrollRestoration) {
history.scrollRestoration = scrollRestoration;
}
}
}
private scrollToElement(el: any): void {
const rect = el.getBoundingClientRect();
const left = rect.left + this.window.pageXOffset;
const top = rect.top + this.window.pageYOffset;
const offset = this.offset();
this.window.scrollTo(left - offset[0], top - offset[1]);
}
/**
* We only support scroll restoration when we can get a hold of window.
* This means that we do not support this behavior when running in a web worker.
*
* Lifting this restriction right now would require more changes in the dom adapter.
* Since webworkers aren't widely used, we will lift it once RouterScroller is
* battle-tested.
*/
private supportScrollRestoration(): boolean {
try {
return !!this.window && !!this.window.scrollTo;
} catch (e) {
return false;
}
}
}
/**
* @whatItDoes Provides an empty implementation of the viewport scroller. This will
* live in @angular/common as it will be used by both platform-server and platform-webworker.
*/
export class NullViewportScroller implements ViewportScroller {
/**
* @whatItDoes empty implementation
*/
setOffset(offset: [number, number]|(() => [number, number])): void {}
/**
* @whatItDoes empty implementation
*/
getScrollPosition(): [number, number] { return [0, 0]; }
/**
* @whatItDoes empty implementation
*/
scrollToPosition(position: [number, number]): void {}
/**
* @whatItDoes empty implementation
*/
scrollToAnchor(anchor: string): void {}
/**
* @whatItDoes empty implementation
*/
setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {}
}
@@ -7,7 +7,7 @@
*/
importAnimationEngine} from '@angular/animations/browser';
import {PlatformLocation, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common';
import {PlatformLocation, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common';
import {HttpClientModule} from '@angular/common/http';
import {Injectable, InjectionToken, Injector, NgModule, NgZone, Optional, PLATFORM_ID, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactory2, RootRenderer, StaticProvider, Testability, createPlatformFactory, isDevMode, platformCore, ɵALLOW_MULTIPLE_PLATFORMS as ALLOW_MULTIPLE_PLATFORMS} from '@angular/core';
import {HttpModule} from '@angular/http';
@@ -74,6 +74,7 @@ export const SERVER_RENDER_PROVIDERS: Provider[] = [
SERVER_RENDER_PROVIDERS,
SERVER_HTTP_PROVIDERS,
{provide: Testability, useValue: null},
{provide: ViewportScroller, useClass: NullViewportScroller},
],
})
export class ServerModule {
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {CommonModule, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common';
import {CommonModule, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common';
import {APP_INITIALIZER, ApplicationModule, ErrorHandler, NgModule, NgZone, PLATFORM_ID, PlatformRef, RendererFactory2, RootRenderer, StaticProvider, createPlatformFactory, platformCore} from '@angular/core';
import {DOCUMENT, ɵBROWSER_SANITIZATION_PROVIDERS as BROWSER_SANITIZATION_PROVIDERS} from '@angular/platform-browser';
@@ -71,6 +71,7 @@ export function setupWebWorker(): void {
{provide: ErrorHandler, useFactory: errorHandler, deps: []},
{provide: MessageBus, useFactory: createMessageBus, deps: [NgZone]},
{provide: APP_INITIALIZER, useValue: setupWebWorker, multi: true},
{provide: ViewportScroller, useClass: NullViewportScroller, deps: []},
],
exports: [
CommonModule,
@@ -401,6 +401,28 @@ export class ActivationEnd {
}
}
/**
* @description
*
* Represents a scrolling event.
*/
export class Scroll {
constructor(
/** @docsNotRequired */
readonly routerEvent: NavigationEnd,
/** @docsNotRequired */
readonly position: [number, number]|null,
/** @docsNotRequired */
readonly anchor: string|null) {}
toString(): string {
const pos = this.position ? `${this.position[0]}, ${this.position[1]}` : null;
return `Scroll(anchor: '${this.anchor}', position: '${pos}')`;
}
}
/**
* @description
*
@@ -423,8 +445,9 @@ export class ActivationEnd {
* - `NavigationEnd`,
* - `NavigationCancel`,
* - `NavigationError`
* - `Scroll`
*
*
*/
export type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart |
ChildActivationEnd | ActivationStart | ActivationEnd;
ChildActivationEnd | ActivationStart | ActivationEnd | Scroll;
@@ -11,7 +11,7 @@ export {Data, LoadChildren, LoadChildrenCallback, ResolveData, Route, Routes, Ru
export {RouterLink, RouterLinkWithHref} from './directives/router_link';
export {RouterLinkActive} from './directives/router_link_active';
export {RouterOutlet} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized} from './events';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces';
export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {NavigationExtras, Router} from './router';
@@ -543,7 +543,6 @@ export class Router {
rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null,
extras: NavigationExtras): Promise<boolean> {
const lastNavigation = this.navigations.value;
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
// and that navigation results in 'replaceState' that leads to the same URL,
// we should skip those.
Oops, something went wrong.

0 comments on commit 49c5234

Please sign in to comment.