feat(docs-infra): saves the scroll position before the change of location (#28037)

Issue #27916, #17308

PR Close #28037
This commit is contained in:
WilliamKoza 2019-01-10 08:18:30 +01:00 committed by Jason Aden
parent 67a41d8bff
commit 3414316fc8
4 changed files with 294 additions and 41 deletions

View File

@ -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>(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>(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
}));
});

View File

@ -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;

View File

@ -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();
}));
});
});

View File

@ -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(/^#/, ''));
}
}