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
} }
function isInternalLink(el: HTMLAnchorElement | URL) {
return el.hostname === location.hostname
}
function isAnchorLink(el: HTMLAnchorElement | URL) {
return el.hash.length > 0
}
function compareLocationChange(
{ url: a }: LocationChange, { url: b }: LocationChange
) {
return a.href === b.href
}
// instant loading
if (config.feature.instant) {
/* Disable automatic scroll restoration, as it doesn't work nicely */
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 return NEVER
}), }),
shareReplay(1) distinctUntilChanged(),
map<string, LocationChange>(href => ({ url: new URL(href) })),
share()
) )
: NEVER
// the location might change, but popstate might not be triggered which is /* Intercept internal links to dispatch */
// the case when we hit the back button on the same page. scroll to top. const dispatch$ = internal$
// location$ .pipe(
// .pipe( filter(({ url }) => !isAnchorLink(url)),
// bufferCount(2, 1) share()
// ) )
// .subscribe(x => {
// console.log(x) /* 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}`)
// }) // })
// deploy new location - can be written as instant$.subscribe(location$) // dispatch$.subscribe(({ url }) => {
instant$.subscribe(url => { // console.log(`Push ${url}`)
console.log(`Load ${url}`) // })
location$.next(url)
popstate$.subscribe(({ url }) => {
console.log(`Pop ${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
// to exactly know when the content was loaded. then go to top.
instant$
.pipe(
sample(document$),
withLatestFrom(document$),
)
.subscribe(([url, { 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)
}
}
// TODO: this doesnt work as expected
const { hash } = new URL(url)
if (hash) {
const el = getElement(hash)
if (typeof el !== "undefined") {
el.scrollIntoView()
return
}
} else {
// console.log("scroll to top")
setViewportOffset({ y: 0 })
}
})
/* ----------------------------------------------------------------------- */ /* ----------------------------------------------------------------------- */

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)