feat(docs-infra): saves the scroll position before the change of location (#28037)
Issue #27916, #17308 PR Close #28037
This commit is contained in:
parent
67a41d8bff
commit
3414316fc8
|
@ -169,6 +169,13 @@ describe('AppComponent', () => {
|
||||||
|
|
||||||
expect(component.tocMaxHeight).toMatch(/^\d+\.\d{2}$/);
|
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', () => {
|
describe('SideNav', () => {
|
||||||
|
@ -461,11 +468,15 @@ describe('AppComponent', () => {
|
||||||
let scrollService: ScrollService;
|
let scrollService: ScrollService;
|
||||||
let scrollSpy: jasmine.Spy;
|
let scrollSpy: jasmine.Spy;
|
||||||
let scrollToTopSpy: jasmine.Spy;
|
let scrollToTopSpy: jasmine.Spy;
|
||||||
|
let scrollAfterRenderSpy: jasmine.Spy;
|
||||||
|
let removeStoredScrollPositionSpy: jasmine.Spy;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
scrollService = fixture.debugElement.injector.get<ScrollService>(ScrollService);
|
scrollService = fixture.debugElement.injector.get<ScrollService>(ScrollService);
|
||||||
scrollSpy = spyOn(scrollService, 'scroll');
|
scrollSpy = spyOn(scrollService, 'scroll');
|
||||||
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
|
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
|
||||||
|
scrollAfterRenderSpy = spyOn(scrollService, 'scrollAfterRender');
|
||||||
|
removeStoredScrollPositionSpy = spyOn(scrollService, 'removeStoredScrollPosition');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not scroll immediately when the docId (path) changes', () => {
|
it('should not scroll immediately when the docId (path) changes', () => {
|
||||||
|
@ -510,33 +521,24 @@ describe('AppComponent', () => {
|
||||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should scroll to top when call `onDocRemoved` directly', () => {
|
it('should call `removeStoredScrollPosition` when call `onDocRemoved` directly', () => {
|
||||||
scrollToTopSpy.calls.reset();
|
|
||||||
|
|
||||||
component.onDocRemoved();
|
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();
|
component.onDocInserted();
|
||||||
expect(scrollSpy).not.toHaveBeenCalled();
|
expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay);
|
||||||
|
|
||||||
tick(scrollDelay);
|
|
||||||
expect(scrollSpy).toHaveBeenCalled();
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should scroll (via `onDocInserted`) when finish navigating to a new doc', fakeAsync(() => {
|
it('should call `scrollAfterRender` (via `onDocInserted`) when navigate to a new Doc', fakeAsync(() => {
|
||||||
expect(scrollToTopSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
locationService.go('guide/pipes');
|
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`
|
fixture.detectChanges(); // triggers the event that calls `onDocInserted`
|
||||||
|
|
||||||
expect(scrollToTopSpy).toHaveBeenCalled();
|
expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay);
|
||||||
expect(scrollSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
tick(scrollDelay);
|
tick(500); // there are other outstanding timers in the AppComponent that are not relevant
|
||||||
expect(scrollSpy).toHaveBeenCalled();
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -127,7 +127,7 @@ export class AppComponent implements OnInit {
|
||||||
}
|
}
|
||||||
if (path === this.currentPath) {
|
if (path === this.currentPath) {
|
||||||
// scroll only if on same page (most likely a change to the hash)
|
// scroll only if on same page (most likely a change to the hash)
|
||||||
this.autoScroll();
|
this.scrollService.scroll();
|
||||||
} else {
|
} else {
|
||||||
// don't scroll; leave that to `onDocRendered`
|
// don't scroll; leave that to `onDocRendered`
|
||||||
this.currentPath = path;
|
this.currentPath = path;
|
||||||
|
@ -187,11 +187,6 @@ export class AppComponent implements OnInit {
|
||||||
.subscribe(() => this.updateShell());
|
.subscribe(() => this.updateShell());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to the anchor in the hash fragment or top of doc.
|
|
||||||
autoScroll() {
|
|
||||||
this.scrollService.scroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDocReady() {
|
onDocReady() {
|
||||||
// About to transition to new view.
|
// About to transition to new view.
|
||||||
this.isTransitioning = true;
|
this.isTransitioning = true;
|
||||||
|
@ -204,9 +199,7 @@ export class AppComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
onDocRemoved() {
|
onDocRemoved() {
|
||||||
// The previous document has been removed.
|
this.scrollService.removeStoredScrollPosition();
|
||||||
// Scroll to top to restore a clean visual state for the new document.
|
|
||||||
this.scrollService.scrollToTop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDocInserted() {
|
onDocInserted() {
|
||||||
|
@ -216,9 +209,8 @@ export class AppComponent implements OnInit {
|
||||||
// (e.g. sidenav, host classes) needs to happen asynchronously.
|
// (e.g. sidenav, host classes) needs to happen asynchronously.
|
||||||
setTimeout(() => this.updateShell());
|
setTimeout(() => this.updateShell());
|
||||||
|
|
||||||
// Scroll 500ms after the new document has been inserted into the doc-viewer.
|
// Scroll the good position depending on the context
|
||||||
// The delay is to allow time for async layout to complete.
|
this.scrollService.scrollAfterRender(500);
|
||||||
setTimeout(() => this.autoScroll(), 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDocRendered() {
|
onDocRendered() {
|
||||||
|
@ -256,7 +248,6 @@ export class AppComponent implements OnInit {
|
||||||
|
|
||||||
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
||||||
onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean {
|
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"
|
// 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))) {
|
if (!this.searchElements.some(element => element.nativeElement.contains(eventTarget))) {
|
||||||
this.hideSearchResults();
|
this.hideSearchResults();
|
||||||
|
@ -348,6 +339,9 @@ export class AppComponent implements OnInit {
|
||||||
// Dynamically change height of table of contents container
|
// Dynamically change height of table of contents container
|
||||||
@HostListener('window:scroll')
|
@HostListener('window:scroll')
|
||||||
onScroll() {
|
onScroll() {
|
||||||
|
|
||||||
|
this.scrollService.updateScrollPositionInHistory();
|
||||||
|
|
||||||
if (!this.tocMaxHeightOffset) {
|
if (!this.tocMaxHeightOffset) {
|
||||||
// Must wait until `mat-toolbar` is measurable.
|
// Must wait until `mat-toolbar` is measurable.
|
||||||
const el = this.hostElement.nativeElement as Element;
|
const el = this.hostElement.nativeElement as Element;
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { ReflectiveInjector } from '@angular/core';
|
import { ReflectiveInjector } from '@angular/core';
|
||||||
import { PlatformLocation } from '@angular/common';
|
import { Location, LocationStrategy, PlatformLocation, ViewportScroller } from '@angular/common';
|
||||||
import { DOCUMENT } 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';
|
import { ScrollService, topMargin } from './scroll.service';
|
||||||
|
|
||||||
|
@ -8,8 +10,9 @@ describe('ScrollService', () => {
|
||||||
const topOfPageElem = {} as Element;
|
const topOfPageElem = {} as Element;
|
||||||
let injector: ReflectiveInjector;
|
let injector: ReflectiveInjector;
|
||||||
let document: MockDocument;
|
let document: MockDocument;
|
||||||
let location: MockPlatformLocation;
|
let platformLocation: MockPlatformLocation;
|
||||||
let scrollService: ScrollService;
|
let scrollService: ScrollService;
|
||||||
|
let location: SpyLocation;
|
||||||
|
|
||||||
class MockPlatformLocation {
|
class MockPlatformLocation {
|
||||||
hash: string;
|
hash: string;
|
||||||
|
@ -27,6 +30,11 @@ describe('ScrollService', () => {
|
||||||
scrollIntoView = jasmine.createSpy('Element scrollIntoView');
|
scrollIntoView = jasmine.createSpy('Element scrollIntoView');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewportScrollerStub = jasmine.createSpyObj(
|
||||||
|
'viewportScroller',
|
||||||
|
['getScrollPosition', 'scrollToPosition']);
|
||||||
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(window, 'scrollBy');
|
spyOn(window, 'scrollBy');
|
||||||
});
|
});
|
||||||
|
@ -34,12 +42,24 @@ describe('ScrollService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
injector = ReflectiveInjector.resolveAndCreate([
|
injector = ReflectiveInjector.resolveAndCreate([
|
||||||
ScrollService,
|
ScrollService,
|
||||||
|
{ provide: Location, useClass: SpyLocation },
|
||||||
{ provide: DOCUMENT, useClass: MockDocument },
|
{ 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);
|
document = injector.get(DOCUMENT);
|
||||||
scrollService = injector.get(ScrollService);
|
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', () => {
|
describe('#topOffset', () => {
|
||||||
|
@ -107,7 +127,7 @@ describe('ScrollService', () => {
|
||||||
|
|
||||||
describe('#scroll', () => {
|
describe('#scroll', () => {
|
||||||
it('should scroll to the top if there is no hash', () => {
|
it('should scroll to the top if there is no hash', () => {
|
||||||
location.hash = '';
|
platformLocation.hash = '';
|
||||||
|
|
||||||
const topOfPage = new MockElement();
|
const topOfPage = new MockElement();
|
||||||
document.getElementById.and
|
document.getElementById.and
|
||||||
|
@ -118,7 +138,7 @@ describe('ScrollService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not scroll if the hash does not match an element id', () => {
|
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);
|
document.getElementById.and.returnValue(null);
|
||||||
|
|
||||||
scrollService.scroll();
|
scrollService.scroll();
|
||||||
|
@ -128,7 +148,7 @@ describe('ScrollService', () => {
|
||||||
|
|
||||||
it('should scroll to the element whose id matches the hash', () => {
|
it('should scroll to the element whose id matches the hash', () => {
|
||||||
const element = new MockElement();
|
const element = new MockElement();
|
||||||
location.hash = 'some-id';
|
platformLocation.hash = 'some-id';
|
||||||
document.getElementById.and.returnValue(element);
|
document.getElementById.and.returnValue(element);
|
||||||
|
|
||||||
scrollService.scroll();
|
scrollService.scroll();
|
||||||
|
@ -139,7 +159,7 @@ describe('ScrollService', () => {
|
||||||
|
|
||||||
it('should scroll to the element whose id matches the hash with encoded characters', () => {
|
it('should scroll to the element whose id matches the hash with encoded characters', () => {
|
||||||
const element = new MockElement();
|
const element = new MockElement();
|
||||||
location.hash = '%F0%9F%91%8D'; // 👍
|
platformLocation.hash = '%F0%9F%91%8D'; // 👍
|
||||||
document.getElementById.and.returnValue(element);
|
document.getElementById.and.returnValue(element);
|
||||||
|
|
||||||
scrollService.scroll();
|
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();
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Injectable, Inject } from '@angular/core';
|
import { Injectable, Inject } from '@angular/core';
|
||||||
import { PlatformLocation } from '@angular/common';
|
import { Location, PlatformLocation, ViewportScroller } from '@angular/common';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { fromEvent } from 'rxjs';
|
import { fromEvent } from 'rxjs';
|
||||||
|
|
||||||
|
@ -13,6 +13,13 @@ export class ScrollService {
|
||||||
private _topOffset: number | null;
|
private _topOffset: number | null;
|
||||||
private _topOfPageElement: Element;
|
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
|
// Offset from the top of the document to bottom of any static elements
|
||||||
// at the top (e.g. toolbar) + some margin
|
// at the top (e.g. toolbar) + some margin
|
||||||
get topOffset() {
|
get topOffset() {
|
||||||
|
@ -32,9 +39,37 @@ export class ScrollService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DOCUMENT) private document: any,
|
@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.
|
// On resize, the toolbar might change height, so "invalidate" the top offset.
|
||||||
fromEvent(window, 'resize').subscribe(() => this._topOffset = null);
|
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);
|
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.
|
* Scroll to the element.
|
||||||
* Don't scroll if no element.
|
* Don't scroll if no element.
|
||||||
|
@ -79,10 +152,42 @@ export class ScrollService {
|
||||||
this.scrollToElement(this.topOfPageElement);
|
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 `#`.
|
* Return the hash fragment from the `PlatformLocation`, minus the leading `#`.
|
||||||
*/
|
*/
|
||||||
private getCurrentHash() {
|
private getCurrentHash() {
|
||||||
return decodeURIComponent(this.location.hash.replace(/^#/, ''));
|
return decodeURIComponent(this.platformLocation.hash.replace(/^#/, ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue