Improved scroll restoration implementation

This commit is contained in:
squidfunk 2020-02-22 13:35:26 +01:00
parent a49b1421e7
commit c362179234
10 changed files with 211 additions and 164 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"assets/javascripts/bundle.js": "assets/javascripts/bundle.95ab87dc.min.js", "assets/javascripts/bundle.js": "assets/javascripts/bundle.dcf1ce56.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.95ab87dc.min.js.map", "assets/javascripts/bundle.js.map": "assets/javascripts/bundle.dcf1ce56.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.926ffd9e.min.js", "assets/javascripts/worker/search.js": "assets/javascripts/worker/search.926ffd9e.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.926ffd9e.min.js.map", "assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.926ffd9e.min.js.map",
"assets/stylesheets/app-palette.scss": "assets/stylesheets/app-palette.3f90c815.min.css", "assets/stylesheets/app-palette.scss": "assets/stylesheets/app-palette.3f90c815.min.css",

View File

@ -190,7 +190,7 @@
{% endblock %} {% endblock %}
</div> </div>
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/bundle.95ab87dc.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.dcf1ce56.min.js' | url }}"></script>
{%- set translations = {} -%} {%- set translations = {} -%}
{%- for key in [ {%- for key in [
"clipboard.copy", "clipboard.copy",

View File

@ -45,7 +45,13 @@ import {
take, take,
mapTo, mapTo,
shareReplay, shareReplay,
sample sample,
share,
map,
pluck,
debounceTime,
distinctUntilKeyChanged,
distinctUntilChanged
} from "rxjs/operators" } from "rxjs/operators"
import { import {
@ -61,7 +67,8 @@ import {
setupToggles, setupToggles,
useToggle, useToggle,
getElement, getElement,
setViewportOffset setViewportOffset,
ViewportOffset
} from "./observables" } from "./observables"
import { setupSearchWorker } from "./workers" import { setupSearchWorker } from "./workers"
@ -300,139 +307,177 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */ /* ----------------------------------------------------------------------- */
// instant loading /**
const instant$ = config.feature.instant ? document$ // TODO: just use document$ and take(1) * Location change
.pipe( */
take(1), // only initial load interface LocationChange {
switchMap(({ body }) => fromEvent(body, "click")), url: URL // TODO: use URL!?
withLatestFrom(viewport$), data?: ViewportOffset
switchMap(([ev, { offset }]) => {
if (ev.target && ev.target instanceof HTMLElement) {
const link = ev.target.closest("a")
if (link) {
if (/(:\/\/|^#)/.test(link.getAttribute("href")!) === false) {
ev.preventDefault()
// we must copy the value, or weird stuff will happen
// remember scroll position!
const href = link.href
history.replaceState(offset, document.title)
history.pushState({}, "", href)
return of(href) // anchor.href
}
}
}
return NEVER
}),
shareReplay(1)
)
: NEVER
// the location might change, but popstate might not be triggered which is
// the case when we hit the back button on the same page. scroll to top.
// location$
// .pipe(
// bufferCount(2, 1)
// )
// .subscribe(x => {
// console.log(x)
// })
// deploy new location - can be written as instant$.subscribe(location$)
instant$.subscribe(url => {
console.log(`Load ${url}`)
location$.next(url)
})
if ("scrollRestoration" in history)
history.scrollRestoration = "manual"
const pop$ = fromEvent<PopStateEvent>(window, "popstate")
.pipe(
shareReplay(1) // TODO: share() should be enough
)
pop$
.subscribe(() => location$.next(getLocation()))
pop$
.pipe(
sample(document$),
withLatestFrom(document$),
)
.subscribe(([ev, { title, head }]) => {
document.title = title
// replace meta tags
for (const selector of [
"link[rel=canonical]",
"meta[name=author]",
"meta[name=description]"
]) {
const next = getElement(selector, head)
const prev = getElement(selector, document.head)
if (
typeof next !== "undefined" &&
typeof prev !== "undefined"
) {
prev.replaceWith(next)
}
}
console.log(ev)
if (ev.state)
setViewportOffset(ev.state)
})
// make links absolute, so they remain stable
for (const selector of [
"link[rel='shortcut icon']",
"link[rel='stylesheet']"
]) {
for (const el of getElements<HTMLLinkElement>(selector))
el.href = el.href
} }
// if a new url is deployed via instant loading, switch to document observable function isInternalLink(el: HTMLAnchorElement | URL) {
// to exactly know when the content was loaded. then go to top. return el.hostname === location.hostname
instant$ }
.pipe(
sample(document$),
withLatestFrom(document$),
)
.subscribe(([url, { title, head }]) => {
document.title = title
// replace meta tags function isAnchorLink(el: HTMLAnchorElement | URL) {
for (const selector of [ return el.hash.length > 0
"link[rel=canonical]", }
"meta[name=author]",
"meta[name=description]"
]) {
const next = getElement(selector, head)
const prev = getElement(selector, document.head)
if (
typeof next !== "undefined" &&
typeof prev !== "undefined"
) {
prev.replaceWith(next)
}
}
// TODO: this doesnt work as expected function compareLocationChange(
const { hash } = new URL(url) { url: a }: LocationChange, { url: b }: LocationChange
if (hash) { ) {
const el = getElement(hash) return a.href === b.href
if (typeof el !== "undefined") { }
el.scrollIntoView()
return // instant loading
} if (config.feature.instant) {
} else {
// console.log("scroll to top") /* Disable automatic scroll restoration, as it doesn't work nicely */
setViewportOffset({ y: 0 }) if ("scrollRestoration" in history)
} history.scrollRestoration = "manual"
/* Resolve relative links for stability */
for (const selector of [
`link[rel="shortcut icon"]`,
`link[rel="stylesheet"]`
])
for (const el of getElements<HTMLLinkElement>(selector))
el.href = el.href
/* Intercept internal link clicks */
const internal$ = fromEvent<MouseEvent>(document.body, "click")
.pipe(
filter(ev => !(ev.metaKey || ev.ctrlKey)),
switchMap(ev => {
if (ev.target instanceof HTMLElement) {
const el = ev.target.closest("a")
if (el && isInternalLink(el)) {
if (!isAnchorLink(el))
ev.preventDefault()
return of(el.href)
}
}
return NEVER
}),
distinctUntilChanged(),
map<string, LocationChange>(href => ({ url: new URL(href) })),
share()
)
/* Intercept internal links to dispatch */
const dispatch$ = internal$
.pipe(
filter(({ url }) => !isAnchorLink(url)),
share()
)
/* Intercept popstate events (history back and forward) */
const popstate$ = fromEvent<PopStateEvent>(window, "popstate")
.pipe(
map<PopStateEvent, LocationChange>(ev => ({
url: new URL(getLocation()),
data: ev.state
})),
share()
)
/* Emit location change */
merge(dispatch$, popstate$)
.pipe(
pluck("url")
)
.subscribe(location$)
/* Add dispatched link to history */
internal$
.pipe(
distinctUntilChanged(compareLocationChange),
filter(({ url }) => !isAnchorLink(url))
)
.subscribe(({ url }) => {
// console.log(`History.Push ${url}`)
history.pushState({}, "", url.toString())
})
/* Persist viewport offset in history before hash change */
viewport$
.pipe(
debounceTime(250),
distinctUntilKeyChanged("offset"),
)
.subscribe(({ offset }) => {
console.log("Update", offset)
history.replaceState(offset, "")
})
// /* Edge case: go back from anchor to same
// // // TODO: better to just replace the state when this is encountered.
// // pop$
// // .pipe(
// // filter(({ href }) => !/#/.test(href)) // TODO: kind of sucks
// // )
// // .subscribe(({ data }) => {
// // // console.log("Detected", data) // detects too much...
// // setViewportOffset(data || { y: 0 }) // TOOD: must wait for document sample!
// // })
/* */
merge(dispatch$, popstate$)
.pipe(
sample(document$),
withLatestFrom(document$),
)
.subscribe(([{ url: href, data }, { title, head }]) => {
console.log("Done", href.href, data)
// setDocumentTitle
document.title = title
// replace meta tags
for (const selector of [
`link[rel="canonical"]`,
`meta[name="author"]`,
`meta[name="description"]`
]) {
const next = getElement(selector, head)
const prev = getElement(selector, document.head)
if (
typeof next !== "undefined" &&
typeof prev !== "undefined"
) {
prev.replaceWith(next)
}
}
// // TODO: this doesnt work as expected
// if (!data) {
// const { hash } = new URL(href)
// if (hash) {
// const el = getElement(hash)
// if (typeof el !== "undefined") {
// el.scrollIntoView()
// return
// }
// }
// }
// console.log(ev)
// if (!data)
setViewportOffset(data || { y: 0 }) // push state!
})
// internal$.subscribe(({ url }) => {
// console.log(`Internal ${url}`)
// })
// dispatch$.subscribe(({ url }) => {
// console.log(`Push ${url}`)
// })
popstate$.subscribe(({ url }) => {
console.log(`Pop ${url}`)
}) })
}
/* ----------------------------------------------------------------------- */ /* ----------------------------------------------------------------------- */

View File

@ -33,7 +33,7 @@ import { watchDocumentSwitch } from "../switch"
* Watch options * Watch options
*/ */
interface WatchOptions { interface WatchOptions {
location$?: Observable<string> /* Location observable */ location$?: Observable<URL> /* Location observable */
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------

View File

@ -25,6 +25,7 @@ import { ajax } from "rxjs/ajax"
import { import {
catchError, catchError,
distinctUntilChanged, distinctUntilChanged,
filter,
map, map,
pluck, pluck,
share, share,
@ -43,7 +44,7 @@ import { getLocation, setLocation } from "../../location"
* Watch options * Watch options
*/ */
interface WatchOptions { interface WatchOptions {
location$: Observable<string> /* Location observable */ location$: Observable<URL> /* Location observable */
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@ -69,8 +70,9 @@ export function watchDocumentSwitch(
): Observable<Document> { ): Observable<Document> {
return location$ return location$
.pipe( .pipe(
startWith(getLocation()), startWith(location), // TODO: getLocation should return URL or Location
map(url => url.replace(/#[^#]*$/, "")), filter(url => url.hash.length === 0), // use isAnchorLink
map(url => url.href),
distinctUntilChanged(), distinctUntilChanged(),
skip(1), skip(1),

View File

@ -52,8 +52,8 @@ export function setLocation(value: string): void {
* *
* @return Location subject * @return Location subject
*/ */
export function watchLocation(): Subject<string> { export function watchLocation(): Subject<URL> {
const location$ = new Subject<string>() const location$ = new Subject<URL>()
// fromEvent<PopStateEvent>(window, "popstate") // fromEvent<PopStateEvent>(window, "popstate")
// .pipe( // .pipe(
// map(getLocation) // map(getLocation)