Refactored instant loading setup

This commit is contained in:
squidfunk
2020-03-28 17:40:46 +01:00
parent 4d370fe903
commit edc9d6fc61
11 changed files with 114 additions and 116 deletions

View File

@@ -49,6 +49,7 @@ export function getLocationHash(): string {
export function setLocationHash(hash: string): void {
const el = document.createElement("a")
el.href = hash
el.addEventListener("click", ev => ev.stopPropagation())
el.click()
}

View File

@@ -20,7 +20,8 @@
* IN THE SOFTWARE.
*/
// TODO: remove this after we finished refactoring
// DISCLAIMER: this file is still WIP. There're some refactoring opportunities
// which must be tackled after we gathered some feedback on v5.
// tslint:disable
import "../stylesheets/main.scss"
@@ -32,8 +33,6 @@ import {
combineLatest,
animationFrameScheduler,
fromEvent,
of,
NEVER,
from
} from "rxjs"
import { ajax } from "rxjs/ajax"
@@ -46,9 +45,7 @@ import {
observeOn,
take,
shareReplay,
share,
pluck,
skip
pluck
} from "rxjs/operators"
import {
@@ -61,7 +58,6 @@ import {
watchLocationHash,
watchViewport,
isLocalLocation,
isAnchorLocation,
setLocationHash,
watchLocationBase
} from "browser"
@@ -255,7 +251,6 @@ export function initialize(config: unknown) {
)
)
const worker = setupSearchWorker(config.search.worker, {
base$, index$
})
@@ -299,10 +294,9 @@ export function initialize(config: unknown) {
tap(() => setToggle("search", false)),
delay(125), // ensure that it runs after the body scroll reset...
)
.subscribe(hash => setLocationHash(`#${hash}`)) // TODO: must be unified
.subscribe(hash => setLocationHash(`#${hash}`))
// Scroll lock // document -> document$ => { body } !?
// put into search...
// TODO: scroll restoration must be centralized
combineLatest([
watchToggle("search"),
tablet$,
@@ -313,7 +307,7 @@ export function initialize(config: unknown) {
const active = toggle && !tablet
return document$
.pipe(
delay(active ? 400 : 100), // TOOD: directly combine this with the hash!
delay(active ? 400 : 100),
observeOn(animationFrameScheduler),
tap(({ body }) => active
? setScrollLock(body, y)
@@ -326,68 +320,40 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */
/* Intercept internal link clicks */
const link$ = fromEvent<MouseEvent>(document.body, "click")
/* Always close drawer on click */
fromEvent<MouseEvent>(document.body, "click")
.pipe(
filter(ev => !(ev.metaKey || ev.ctrlKey)),
switchMap(ev => {
filter(ev => {
if (ev.target instanceof HTMLElement) {
const el = ev.target.closest("a") // TODO: abstract as link click?
if (el && isLocalLocation(el)) {
if (!isAnchorLocation(el) && config.features.includes("instant"))
ev.preventDefault()
return of(el)
return true
}
}
return NEVER
}),
share()
return false
})
)
.subscribe(() => {
setToggle("drawer", false)
})
/* Always close drawer on click */
link$.subscribe(() => {
setToggle("drawer", false)
})
/* Hack: ensure that page loads restore scroll offset */
fromEvent(window, "beforeunload")
.subscribe(() => {
history.scrollRestoration = "auto"
})
// instant loading
if (config.features.includes("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"]` // reduce style computations
])
for (const el of getElements<HTMLLinkElement>(selector))
el.href = el.href
setupInstantLoading({
document$, link$, location$, viewport$
})
}
/* Enable instant loading, if not on file:// protocol */
if (config.features.includes("instant") && location.protocol !== "file:")
setupInstantLoading({ document$, location$, viewport$ })
/* ----------------------------------------------------------------------- */
// if we use a single tab outside of search, unhide all permalinks.
// TODO: experimental. necessary!?
/* Unhide permalinks on first tab */
keyboard$
.pipe(
filter(key => key.mode === "global" && ["Tab"].includes(key.type)),
filter(key => key.mode === "global" && key.type === "Tab"),
take(1)
)
.subscribe(() => {
for (const link of getElements(".headerlink"))
link.style.visibility = "visible"
})
.subscribe(() => {
for (const link of getElements(".headerlink"))
link.style.visibility = "visible"
})
/* ----------------------------------------------------------------------- */
@@ -395,6 +361,7 @@ export function initialize(config: unknown) {
/* Browser observables */
document$,
location$,
viewport$,
/* Component observables */
@@ -406,7 +373,7 @@ export function initialize(config: unknown) {
tabs$,
toc$,
/* Integation observables */
/* Integration observables */
clipboard$,
keyboard$,
dialog$

View File

@@ -25,7 +25,7 @@ import { NEVER, Observable, Subject, fromEventPattern } from "rxjs"
import { mapTo, share, tap } from "rxjs/operators"
import { getElements } from "browser"
import { renderClipboard } from "templates"
import { renderClipboardButton } from "templates"
import { translate } from "utilities"
/* ----------------------------------------------------------------------------
@@ -66,7 +66,7 @@ export function setupClipboard(
blocks.forEach((block, index) => {
const parent = block.parentElement!
parent.id = `__code_${index}`
parent.insertBefore(renderClipboard(parent.id), block)
parent.insertBefore(renderClipboardButton(parent.id), block)
})
})

View File

@@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { NEVER, Observable, Subject, fromEvent, merge } from "rxjs"
import { NEVER, Observable, Subject, fromEvent, merge, of } from "rxjs"
import { ajax } from "rxjs//ajax"
import {
bufferCount,
@@ -43,9 +43,11 @@ import {
ViewportOffset,
getElement,
isAnchorLocation,
isLocalLocation,
replaceElement,
setLocation,
setLocationHash,
setToggle,
setViewportOffset
} from "browser"
@@ -68,9 +70,8 @@ interface State {
*/
interface SetupOptions {
document$: Subject<Document> /* Document subject */
viewport$: Observable<Viewport> /* Viewport observable */
link$: Observable<HTMLAnchorElement> /* Internal link observable */
location$: Subject<URL> /* Location subject */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
@@ -97,19 +98,51 @@ interface SetupOptions {
* location change is dispatched regularly.
*
* @param options - Options
*
* @return TODO: return type?
*/
export function setupInstantLoading(
{ document$, viewport$, link$, location$ }: SetupOptions
) { // TODO: add return type
const state$ = link$
{ document$, viewport$, location$ }: SetupOptions
): void {
/* Disable automatic scroll restoration */
if ("scrollRestoration" in history)
history.scrollRestoration = "manual"
/* Hack: ensure that reloads restore viewport offset */
fromEvent(window, "beforeunload")
.subscribe(() => {
history.scrollRestoration = "auto"
})
/* Hack: ensure absolute favicon link to omit 404s on document switch */
const favicon = getElement<HTMLLinkElement>(`link[rel="shortcut icon"]`)
if (typeof favicon !== "undefined")
favicon.href = favicon.href // tslint:disable-line no-self-assignment
/* Intercept link clicks and convert to state change */
const state$ = 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 && isLocalLocation(el)) {
if (!isAnchorLocation(el))
ev.preventDefault()
return of(el)
}
}
return NEVER
}),
map(el => ({ url: new URL(el.href) })),
share<State>()
)
/* Intercept internal links to dispatch */
/* Always close search on link click */
state$.subscribe(() => {
setToggle("search", false)
})
/* Filter state changes to dispatch */
const push$ = state$
.pipe(
distinctUntilChanged((prev, next) => prev.url.href === next.url.href),
@@ -121,11 +154,11 @@ export function setupInstantLoading(
const pop$ = fromEvent<PopStateEvent>(window, "popstate")
.pipe(
filter(ev => ev.state !== null),
map<PopStateEvent, State>(ev => ({
map(ev => ({
url: new URL(location.href),
offset: ev.state
})),
share()
share<State>()
)
/* Emit location change */
@@ -135,13 +168,11 @@ export function setupInstantLoading(
)
.subscribe(location$)
const dom = new DOMParser()
/* Fetch document on location change */
const ajax$ = location$
.pipe(
distinctUntilKeyChanged("pathname"),
skip(1),
/* Fetch document */
switchMap(url => ajax({
url: url.href,
responseType: "text",
@@ -154,9 +185,9 @@ export function setupInstantLoading(
})
)
)
// share()
)
/* Set new location as soon as the document was fetched */
push$
.pipe(
sample(ajax$)
@@ -165,55 +196,30 @@ export function setupInstantLoading(
history.pushState({}, "", url.toString())
})
/* Parse and emit document */
const dom = new DOMParser()
ajax$
.pipe(
map(({ response }) => {
return dom.parseFromString(response, "text/html")
})
map(({ response }) => dom.parseFromString(response, "text/html"))
)
.subscribe(document$)
/* History: debounce update of viewport offset */
viewport$
.pipe(
debounceTime(250),
distinctUntilKeyChanged("offset")
)
.subscribe(({ offset }) => {
history.replaceState(offset, "")
})
/* Apply viewport offset from history */
merge(state$, pop$)
.pipe(
bufferCount(2, 1),
filter(([prev, next]) => {
return prev.url.pathname === next.url.pathname
&& !isAnchorLocation(next.url)
}),
map(([, state]) => state)
)
.subscribe(({ offset }) => {
setViewportOffset(offset || { y: 0 })
})
/* Intercept actual instant loading */
/* Intercept instant loading */
const instant$ = merge(push$, pop$)
.pipe(
sample(document$)
)
// TODO: from here on, everything is beta.... ###############################
// TODO: this must be combined with search scroll restoration on mobile
instant$.subscribe(({ url, offset }) => {
if (url.hash && !offset) {
// console.log("set hash!")
setLocationHash(url.hash) // must delay, if search is open!
setLocationHash(url.hash)
} else {
setViewportOffset(offset || { y: 0 })
}
})
/* Replace document metadata */
instant$
.pipe(
withLatestFrom(document$)
@@ -238,4 +244,28 @@ export function setupInstantLoading(
}
}
})
/* Debounce update of viewport offset */
viewport$
.pipe(
debounceTime(250),
distinctUntilKeyChanged("offset")
)
.subscribe(({ offset }) => {
history.replaceState(offset, "")
})
/* Set viewport offset from history */
merge(state$, pop$)
.pipe(
bufferCount(2, 1),
filter(([prev, next]) => {
return prev.url.pathname === next.url.pathname
&& !isAnchorLocation(next.url)
}),
map(([, state]) => state)
)
.subscribe(({ offset }) => {
setViewportOffset(offset || { y: 0 })
})
}

View File

@@ -53,14 +53,14 @@ const path =
*
* @return Element
*/
export function renderClipboard(
export function renderClipboardButton(
id: string
) {
return (
<button
class={css.container}
title={translate("clipboard.copy")}
data-clipboard-target={`#${id} code`}
data-clipboard-target={`#${id} > code`}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d={path}></path>