/
scroll.service.ts
261 lines (230 loc) Β· 9.36 KB
/
scroll.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import {DOCUMENT, Location, PlatformLocation, PopStateEvent, ViewportScroller} from '@angular/common';
import {Inject, Injectable, OnDestroy} from '@angular/core';
import {fromEvent, Subject} from 'rxjs';
import {debounceTime, takeUntil} from 'rxjs/operators';
type ScrollPosition = [number, number];
interface ScrollPositionPopStateEvent extends PopStateEvent {
// If there is history state, it should always include `scrollPosition`.
state?: {scrollPosition: ScrollPosition};
}
export const topMargin = 16;
/**
* A service that scrolls document elements into view
*/
@Injectable()
export class ScrollService implements OnDestroy {
private _topOffset: number|null;
private _topOfPageElement: Element;
private onDestroy = new Subject<void>();
private storage: Storage;
// The scroll position which has to be restored, after a `popstate` event.
poppedStateScrollPosition: ScrollPosition|null = null;
// Whether the browser supports the necessary features for manual scroll restoration.
supportManualScrollRestoration: boolean = !!window && ('scrollTo' in window) &&
('pageXOffset' in window) && ('pageYOffset' in window) && isScrollRestorationWritable();
// Offset from the top of the document to bottom of any static elements
// at the top (e.g. toolbar) + some margin
get topOffset() {
if (!this._topOffset) {
const toolbar = this.document.querySelector('.app-toolbar');
this._topOffset = (toolbar && toolbar.clientHeight || 0) + topMargin;
}
return this._topOffset!;
}
get topOfPageElement() {
if (!this._topOfPageElement) {
this._topOfPageElement = this.document.getElementById('top-of-page') || this.document.body;
}
return this._topOfPageElement;
}
constructor(
@Inject(DOCUMENT) private document: any, private platformLocation: PlatformLocation,
private viewportScroller: ViewportScroller, private location: Location) {
try {
this.storage = window.sessionStorage;
} catch {
// When cookies are disabled in the browser, even trying to access
// `window.sessionStorage` throws an error. Use a no-op storage.
this.storage = {
length: 0,
clear: () => undefined,
getItem: () => null,
key: () => null,
removeItem: () => undefined,
setItem: () => undefined
};
}
// On resize, the toolbar might change height, so "invalidate" the top offset.
fromEvent(window, 'resize')
.pipe(takeUntil(this.onDestroy))
.subscribe(() => this._topOffset = null);
fromEvent(window, 'scroll')
.pipe(debounceTime(250), takeUntil(this.onDestroy))
.subscribe(() => this.updateScrollPositionInHistory());
fromEvent(window, 'beforeunload')
.pipe(takeUntil(this.onDestroy))
.subscribe(() => this.updateScrollLocationHref());
// 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.
const locationSubscription = this.location.subscribe((event: ScrollPositionPopStateEvent) => {
// 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 {
// Navigating with the forward/back button, we have to remove the position from the
// session storage in order to avoid a race-condition.
this.removeStoredScrollInfo();
// The `popstate` event is always triggered by a browser action such as clicking the
// forward/back button. It can be followed by a `hashchange` event.
this.poppedStateScrollPosition = event.state ? event.state.scrollPosition : null;
}
});
this.onDestroy.subscribe(() => locationSubscription.unsubscribe());
}
// If this was not a reload, discard the stored scroll info.
if (window.location.href !== this.getStoredScrollLocationHref()) {
this.removeStoredScrollInfo();
}
}
ngOnDestroy() {
this.onDestroy.next();
}
/**
* Scroll to the element with id extracted from the current location hash fragment.
* Scroll to top if no hash.
* Don't scroll if hash not found.
*/
scroll() {
const hash = this.getCurrentHash();
const element: HTMLElement = hash ? this.document.getElementById(hash) : this.topOfPageElement;
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 (triggered by clicking the
// forward/back button), so we manage the scroll position.
this.scrollToPosition();
} else {
// The document was loaded as a result of one of the following cases:
// - Typing the URL in the address bar (direct navigation).
// - Clicking on a link.
// (If the location contains a hash, we have to wait for async layout.)
if (this.isLocationWithHash()) {
// Delay scrolling by the specified amount 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.
*/
scrollToElement(element: Element|null) {
if (element) {
element.scrollIntoView();
if (window && window.scrollBy) {
// Scroll as much as necessary to align the top of `element` at `topOffset`.
// (Usually, `.top` will be 0, except for cases where the element cannot be scrolled all the
// way to the top, because the viewport is larger than the height of the content after the
// element.)
window.scrollBy(0, element.getBoundingClientRect().top - this.topOffset);
// If we are very close to the top (<20px), then scroll all the way up.
// (This can happen if `element` is at the top of the page, but has a small top-margin.)
if (window.pageYOffset < 20) {
window.scrollBy(0, -window.pageYOffset);
}
}
}
}
/** Scroll to the top of the document. */
scrollToTop() {
this.scrollToElement(this.topOfPageElement);
}
scrollToPosition() {
if (this.poppedStateScrollPosition) {
this.viewportScroller.scrollToPosition(this.poppedStateScrollPosition);
this.poppedStateScrollPosition = null;
}
}
updateScrollLocationHref(): void {
this.storage.setItem('scrollLocationHref', window.location.href);
}
/**
* 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});
this.storage.setItem('scrollPosition', currentScrollPosition.join(','));
}
}
getStoredScrollLocationHref(): string|null {
const href = this.storage.getItem('scrollLocationHref');
return href || null;
}
getStoredScrollPosition(): ScrollPosition|null {
const position = this.storage.getItem('scrollPosition');
if (!position) {
return null;
}
const [x, y] = position.split(',');
return [+x, +y];
}
removeStoredScrollInfo() {
this.storage.removeItem('scrollLocationHref');
this.storage.removeItem('scrollPosition');
}
/**
* Check if the scroll position need to be manually fixed after popState event
*/
needToFixScrollPosition(): boolean {
return this.supportManualScrollRestoration && !!this.poppedStateScrollPosition;
}
/**
* Return the hash fragment from the `PlatformLocation`, minus the leading `#`.
*/
private getCurrentHash() {
return decodeURIComponent(this.platformLocation.hash.replace(/^#/, ''));
}
}
/**
* We need to check whether we can write to `history.scrollRestoration`
*
* We do this by checking the property descriptor of the property, but
* it might actually be defined on the `history` prototype not the instance.
*
* In this context "writable" means either than the property is a `writable`
* data file or a property that has a setter.
*/
function isScrollRestorationWritable() {
const scrollRestorationDescriptor =
Object.getOwnPropertyDescriptor(history, 'scrollRestoration') ||
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(history), 'scrollRestoration');
return scrollRestorationDescriptor !== undefined &&
!!(scrollRestorationDescriptor.writable || scrollRestorationDescriptor.set);
}