From 3414316fc8367751d596d3426a027a21bfd7cd18 Mon Sep 17 00:00:00 2001 From: WilliamKoza Date: Thu, 10 Jan 2019 08:18:30 +0100 Subject: [PATCH] feat(docs-infra): saves the scroll position before the change of location (#28037) Issue #27916, #17308 PR Close #28037 --- aio/src/app/app.component.spec.ts | 36 ++--- aio/src/app/app.component.ts | 20 +-- aio/src/app/shared/scroll.service.spec.ts | 168 ++++++++++++++++++++-- aio/src/app/shared/scroll.service.ts | 111 +++++++++++++- 4 files changed, 294 insertions(+), 41 deletions(-) diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index 8e9d621a2d..a3313e3f2b 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -169,6 +169,13 @@ describe('AppComponent', () => { expect(component.tocMaxHeight).toMatch(/^\d+\.\d{2}$/); }); + + it('should update `scrollService.updateScrollPositonInHistory()`', () => { + const scrollService = fixture.debugElement.injector.get(ScrollService); + spyOn(scrollService, 'updateScrollPositionInHistory'); + component.onScroll(); + expect(scrollService.updateScrollPositionInHistory).toHaveBeenCalled(); + }); }); describe('SideNav', () => { @@ -461,11 +468,15 @@ describe('AppComponent', () => { let scrollService: ScrollService; let scrollSpy: jasmine.Spy; let scrollToTopSpy: jasmine.Spy; + let scrollAfterRenderSpy: jasmine.Spy; + let removeStoredScrollPositionSpy: jasmine.Spy; beforeEach(() => { scrollService = fixture.debugElement.injector.get(ScrollService); scrollSpy = spyOn(scrollService, 'scroll'); scrollToTopSpy = spyOn(scrollService, 'scrollToTop'); + scrollAfterRenderSpy = spyOn(scrollService, 'scrollAfterRender'); + removeStoredScrollPositionSpy = spyOn(scrollService, 'removeStoredScrollPosition'); }); it('should not scroll immediately when the docId (path) changes', () => { @@ -510,33 +521,24 @@ describe('AppComponent', () => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); - it('should scroll to top when call `onDocRemoved` directly', () => { - scrollToTopSpy.calls.reset(); - + it('should call `removeStoredScrollPosition` when call `onDocRemoved` directly', () => { component.onDocRemoved(); - expect(scrollToTopSpy).toHaveBeenCalled(); + expect(removeStoredScrollPositionSpy).toHaveBeenCalled(); }); - it('should scroll after a delay when call `onDocInserted` directly', fakeAsync(() => { + it('should call `scrollAfterRender` when call `onDocInserted` directly', (() => { component.onDocInserted(); - expect(scrollSpy).not.toHaveBeenCalled(); - - tick(scrollDelay); - expect(scrollSpy).toHaveBeenCalled(); + expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay); })); - it('should scroll (via `onDocInserted`) when finish navigating to a new doc', fakeAsync(() => { - expect(scrollToTopSpy).not.toHaveBeenCalled(); - + it('should call `scrollAfterRender` (via `onDocInserted`) when navigate to a new Doc', fakeAsync(() => { locationService.go('guide/pipes'); - tick(1); // triggers the HTTP response for the document + tick(1); // triggers the HTTP response for the document fixture.detectChanges(); // triggers the event that calls `onDocInserted` - expect(scrollToTopSpy).toHaveBeenCalled(); - expect(scrollSpy).not.toHaveBeenCalled(); + expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay); - tick(scrollDelay); - expect(scrollSpy).toHaveBeenCalled(); + tick(500); // there are other outstanding timers in the AppComponent that are not relevant })); }); diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 65c4855200..47845f4184 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -127,7 +127,7 @@ export class AppComponent implements OnInit { } if (path === this.currentPath) { // scroll only if on same page (most likely a change to the hash) - this.autoScroll(); + this.scrollService.scroll(); } else { // don't scroll; leave that to `onDocRendered` this.currentPath = path; @@ -187,11 +187,6 @@ export class AppComponent implements OnInit { .subscribe(() => this.updateShell()); } - // Scroll to the anchor in the hash fragment or top of doc. - autoScroll() { - this.scrollService.scroll(); - } - onDocReady() { // About to transition to new view. this.isTransitioning = true; @@ -204,9 +199,7 @@ export class AppComponent implements OnInit { } onDocRemoved() { - // The previous document has been removed. - // Scroll to top to restore a clean visual state for the new document. - this.scrollService.scrollToTop(); + this.scrollService.removeStoredScrollPosition(); } onDocInserted() { @@ -216,9 +209,8 @@ export class AppComponent implements OnInit { // (e.g. sidenav, host classes) needs to happen asynchronously. setTimeout(() => this.updateShell()); - // Scroll 500ms after the new document has been inserted into the doc-viewer. - // The delay is to allow time for async layout to complete. - setTimeout(() => this.autoScroll(), 500); + // Scroll the good position depending on the context + this.scrollService.scrollAfterRender(500); } onDocRendered() { @@ -256,7 +248,6 @@ export class AppComponent implements OnInit { @HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey']) onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean { - // Hide the search results if we clicked outside both the "search box" and the "search results" if (!this.searchElements.some(element => element.nativeElement.contains(eventTarget))) { this.hideSearchResults(); @@ -348,6 +339,9 @@ export class AppComponent implements OnInit { // Dynamically change height of table of contents container @HostListener('window:scroll') onScroll() { + + this.scrollService.updateScrollPositionInHistory(); + if (!this.tocMaxHeightOffset) { // Must wait until `mat-toolbar` is measurable. const el = this.hostElement.nativeElement as Element; diff --git a/aio/src/app/shared/scroll.service.spec.ts b/aio/src/app/shared/scroll.service.spec.ts index 90ff2d3cb5..fff5c0ea7b 100644 --- a/aio/src/app/shared/scroll.service.spec.ts +++ b/aio/src/app/shared/scroll.service.spec.ts @@ -1,6 +1,8 @@ import { ReflectiveInjector } from '@angular/core'; -import { PlatformLocation } from '@angular/common'; +import { Location, LocationStrategy, PlatformLocation, ViewportScroller } from '@angular/common'; import { DOCUMENT } from '@angular/common'; +import { MockLocationStrategy, SpyLocation } from '@angular/common/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; import { ScrollService, topMargin } from './scroll.service'; @@ -8,8 +10,9 @@ describe('ScrollService', () => { const topOfPageElem = {} as Element; let injector: ReflectiveInjector; let document: MockDocument; - let location: MockPlatformLocation; + let platformLocation: MockPlatformLocation; let scrollService: ScrollService; + let location: SpyLocation; class MockPlatformLocation { hash: string; @@ -27,6 +30,11 @@ describe('ScrollService', () => { scrollIntoView = jasmine.createSpy('Element scrollIntoView'); } + const viewportScrollerStub = jasmine.createSpyObj( + 'viewportScroller', + ['getScrollPosition', 'scrollToPosition']); + + beforeEach(() => { spyOn(window, 'scrollBy'); }); @@ -34,12 +42,24 @@ describe('ScrollService', () => { beforeEach(() => { injector = ReflectiveInjector.resolveAndCreate([ ScrollService, + { provide: Location, useClass: SpyLocation }, { provide: DOCUMENT, useClass: MockDocument }, - { provide: PlatformLocation, useClass: MockPlatformLocation } + { provide: PlatformLocation, useClass: MockPlatformLocation }, + { provide: ViewportScroller, useValue: viewportScrollerStub }, + { provide: LocationStrategy, useClass: MockLocationStrategy } ]); - location = injector.get(PlatformLocation); + platformLocation = injector.get(PlatformLocation); document = injector.get(DOCUMENT); scrollService = injector.get(ScrollService); + location = injector.get(Location); + }); + + it('should set `scrollRestoration` to `manual` if supported', () => { + if (scrollService.supportManualScrollRestoration) { + expect(window.history.scrollRestoration).toBe('manual'); + } else { + expect(window.history.scrollRestoration).toBeUndefined(); + } }); describe('#topOffset', () => { @@ -107,7 +127,7 @@ describe('ScrollService', () => { describe('#scroll', () => { it('should scroll to the top if there is no hash', () => { - location.hash = ''; + platformLocation.hash = ''; const topOfPage = new MockElement(); document.getElementById.and @@ -118,7 +138,7 @@ describe('ScrollService', () => { }); it('should not scroll if the hash does not match an element id', () => { - location.hash = 'not-found'; + platformLocation.hash = 'not-found'; document.getElementById.and.returnValue(null); scrollService.scroll(); @@ -128,7 +148,7 @@ describe('ScrollService', () => { it('should scroll to the element whose id matches the hash', () => { const element = new MockElement(); - location.hash = 'some-id'; + platformLocation.hash = 'some-id'; document.getElementById.and.returnValue(element); scrollService.scroll(); @@ -139,7 +159,7 @@ describe('ScrollService', () => { it('should scroll to the element whose id matches the hash with encoded characters', () => { const element = new MockElement(); - location.hash = '%F0%9F%91%8D'; // 👍 + platformLocation.hash = '%F0%9F%91%8D'; // 👍 document.getElementById.and.returnValue(element); scrollService.scroll(); @@ -210,4 +230,136 @@ describe('ScrollService', () => { }); }); + describe('#isLocationWithHash', () => { + it('should return true when the location has a hash', () => { + platformLocation.hash = 'anchor'; + expect(scrollService.isLocationWithHash()).toBe(true); + }); + + it('should return false when the location has no hash', () => { + platformLocation.hash = ''; + expect(scrollService.isLocationWithHash()).toBe(false); + }); + }); + + describe('#needToFixScrollPosition', async() => { + it('should return true when popState event was fired after a back navigation if the browser supports ' + + 'scrollRestoration`. Otherwise, needToFixScrollPosition() returns false', () => { + + if (scrollService.supportManualScrollRestoration) { + location.go('/initial-url1'); + // We simulate a scroll down + location.replaceState('/initial-url1', 'hack', {scrollPosition: [2000, 0]}); + location.go('/initial-url2'); + location.back(); + + expect(scrollService.popStateFired).toBe(true); + expect(scrollService.scrollPosition).toEqual([2000, 0]); + expect(scrollService.needToFixScrollPosition()).toBe(true); + } else { + location.go('/initial-url1'); + location.go('/initial-url2'); + location.back(); + + expect(scrollService.popStateFired).toBe(false); // popStateFired is always false + expect(scrollService.scrollPosition).toEqual([0, 0]); // scrollPosition always equals [0, 0] + expect(scrollService.needToFixScrollPosition()).toBe(false); + } + + }); + + it('should return true when popState event was fired after a forward navigation if the browser supports ' + + 'scrollRestoration`. Otherwise, needToFixScrollPosition() returns false', () => { + + if (scrollService.supportManualScrollRestoration) { + location.go('/initial-url1'); + location.go('/initial-url2'); + // We simulate a scroll down + location.replaceState('/initial-url1', 'hack', {scrollPosition: [2000, 0]}); + + location.back(); + scrollService.popStateFired = false; + scrollService.scrollPosition = [0, 0]; + location.forward(); + + expect(scrollService.popStateFired).toBe(true); + expect(scrollService.scrollPosition).toEqual([2000, 0]); + expect(scrollService.needToFixScrollPosition()).toBe(true); + } else { + location.go('/initial-url1'); + location.go('/initial-url2'); + location.back(); + location.forward(); + + expect(scrollService.popStateFired).toBe(false); // popStateFired is always false + expect(scrollService.scrollPosition).toEqual([0, 0]); // scrollPosition always equals [0, 0] + expect(scrollService.needToFixScrollPosition()).toBe(false); + } + + }); + }); + + describe('#scrollAfterRender', async() => { + + let scrollSpy: jasmine.Spy; + let scrollToTopSpy: jasmine.Spy; + let needToFixScrollPositionSpy: jasmine.Spy; + let scrollToPosition: jasmine.Spy; + let isLocationWithHashSpy: jasmine.Spy; + let getStoredScrollPositionSpy: jasmine.Spy; + const scrollDelay = 500; + + beforeEach(() => { + scrollSpy = spyOn(scrollService, 'scroll'); + scrollToTopSpy = spyOn(scrollService, 'scrollToTop'); + scrollToPosition = spyOn(scrollService, 'scrollToPosition'); + needToFixScrollPositionSpy = spyOn(scrollService, 'needToFixScrollPosition'); + getStoredScrollPositionSpy = spyOn(scrollService, 'getStoredScrollPosition'); + isLocationWithHashSpy = spyOn(scrollService, 'isLocationWithHash'); + }); + + + it('should call `scroll` when we navigate to a location with anchor', fakeAsync(() => { + needToFixScrollPositionSpy.and.returnValue(false); + getStoredScrollPositionSpy.and.returnValue(null); + isLocationWithHashSpy.and.returnValue(true); + + scrollService.scrollAfterRender(scrollDelay); + + expect(scrollSpy).not.toHaveBeenCalled(); + tick(scrollDelay); + expect(scrollSpy).toHaveBeenCalled(); + })); + + it('should call `scrollToTop` when we navigate to a location without anchor', fakeAsync(() => { + needToFixScrollPositionSpy.and.returnValue(false); + getStoredScrollPositionSpy.and.returnValue(null); + isLocationWithHashSpy.and.returnValue(false); + + scrollService.scrollAfterRender(scrollDelay); + + expect(scrollToTopSpy).toHaveBeenCalled(); + tick(scrollDelay); + expect(scrollSpy).not.toHaveBeenCalled(); + })); + + it('should call `viewportScroller.scrollToPosition` when we reload a page', fakeAsync(() => { + getStoredScrollPositionSpy.and.returnValue([0, 1000]); + + scrollService.scrollAfterRender(scrollDelay); + + expect(viewportScrollerStub.scrollToPosition).toHaveBeenCalled(); + expect(getStoredScrollPositionSpy).toHaveBeenCalled(); + })); + + it('should call `scrollToPosition` after a popState', fakeAsync(() => { + needToFixScrollPositionSpy.and.returnValue(true); + getStoredScrollPositionSpy.and.returnValue(null); + scrollService.scrollAfterRender(scrollDelay); + expect(scrollToPosition).toHaveBeenCalled(); + tick(scrollDelay); + expect(scrollSpy).not.toHaveBeenCalled(); + expect(scrollToTopSpy).not.toHaveBeenCalled(); + })); + }); }); diff --git a/aio/src/app/shared/scroll.service.ts b/aio/src/app/shared/scroll.service.ts index 22ae8ef8e2..4478ead0a7 100644 --- a/aio/src/app/shared/scroll.service.ts +++ b/aio/src/app/shared/scroll.service.ts @@ -1,5 +1,5 @@ import { Injectable, Inject } from '@angular/core'; -import { PlatformLocation } from '@angular/common'; +import { Location, PlatformLocation, ViewportScroller } from '@angular/common'; import { DOCUMENT } from '@angular/common'; import { fromEvent } from 'rxjs'; @@ -13,6 +13,13 @@ export class ScrollService { private _topOffset: number | null; private _topOfPageElement: Element; + // true when popState event has been fired. + popStateFired = false; + // scroll position which has to be restored after the popState event + scrollPosition: [number, number] = [0, 0]; + // true when the browser supports `scrollTo`, `scrollX`, `scrollY` and `scrollRestoration` + supportManualScrollRestoration: boolean; + // Offset from the top of the document to bottom of any static elements // at the top (e.g. toolbar) + some margin get topOffset() { @@ -32,9 +39,37 @@ export class ScrollService { constructor( @Inject(DOCUMENT) private document: any, - private location: PlatformLocation) { + private platformLocation: PlatformLocation, + private viewportScroller: ViewportScroller, + private location: Location) { // On resize, the toolbar might change height, so "invalidate" the top offset. fromEvent(window, 'resize').subscribe(() => this._topOffset = null); + + try { + this.supportManualScrollRestoration = !!window && !!window.scrollTo && 'scrollX' in window + && 'scrollY' in window && !!history && !!history.scrollRestoration; + } catch { + this.supportManualScrollRestoration = false; + } + + // Change scroll restoration strategy to `manual` if it's supported + if (this.supportManualScrollRestoration) { + history.scrollRestoration = 'manual'; + // we have to detect forward and back navigation thanks to popState event + this.location.subscribe(event => { + // the type is `hashchange` when the fragment identifier of the URL has changed. It allows us to go to position + // just before a click on an anchor + if (event.type === 'hashchange') { + this.scrollToPosition(); + } else { + // The popstate event is always triggered by doing a browser action such as a click on the back or forward button. + // It can be follow by a event of type `hashchange`. + this.popStateFired = true; + // we always should have a scrollPosition in our state history + this.scrollPosition = event.state ? event.state['scrollPosition'] : null; + } + }); + } } /** @@ -50,6 +85,44 @@ export class ScrollService { this.scrollToElement(element); } + /** + * test if the current location has a hash + */ + isLocationWithHash(): boolean { + return !!this.getCurrentHash(); + } + + /** + * When we load a document, we have to scroll to the correct position depending on whether this is a new location, + * a back/forward in the history, or a refresh + * @param delay before we scroll to the good position + */ + scrollAfterRender(delay: number) { + // If we do rendering following a refresh, we use the scroll position from the storage. + const storedScrollPosition = this.getStoredScrollPosition(); + if (storedScrollPosition) { + this.viewportScroller.scrollToPosition(storedScrollPosition); + } else { + if (this.needToFixScrollPosition()) { + // The document was reloaded following a popState `event` (called by the forward/back button), so we manage + // the scroll position + this.scrollToPosition(); + } else { + // The document was loaded either of the following cases: a direct navigation via typing the URL in the + // address bar or a click on a link. If the location contains a hash, we have to wait for async + // layout. + if (this.isLocationWithHash()) { + // Scroll 500ms after the new document has been inserted into the doc-viewer. + // The delay is to allow time for async layout to complete. + setTimeout(() => this.scroll(), delay); + } else { + // If the location doesn't contain a hash, we scroll to the top of the page. + this.scrollToTop(); + } + } + } + } + /** * Scroll to the element. * Don't scroll if no element. @@ -79,10 +152,42 @@ export class ScrollService { this.scrollToElement(this.topOfPageElement); } + scrollToPosition() { + this.viewportScroller.scrollToPosition(this.scrollPosition); + this.popStateFired = false; + } + + /** + * Update the state with scroll position into history. + */ + updateScrollPositionInHistory() { + if (this.supportManualScrollRestoration) { + const currentScrollPosition = this.viewportScroller.getScrollPosition(); + this.location.replaceState(this.location.path(true), undefined, {scrollPosition: currentScrollPosition}); + window.sessionStorage.setItem('scrollPosition', currentScrollPosition.toString()); + } + } + + getStoredScrollPosition(): [number, number] | null { + const position = window.sessionStorage.getItem('scrollPosition'); + return position ? JSON.parse('[' + position + ']') : null; + } + + removeStoredScrollPosition() { + window.sessionStorage.removeItem('scrollPosition'); + } + + /** + * Check if the scroll position need to be manually fixed after popState event + */ + needToFixScrollPosition(): boolean { + return this.popStateFired && this.scrollPosition && this.supportManualScrollRestoration; + } + /** * Return the hash fragment from the `PlatformLocation`, minus the leading `#`. */ private getCurrentHash() { - return decodeURIComponent(this.location.hash.replace(/^#/, '')); + return decodeURIComponent(this.platformLocation.hash.replace(/^#/, '')); } }