Merge pull request #5743 from squidfunk/fix/instant-loading

Improve support for instant loading with keyboard navigation
This commit is contained in:
Martin Donath 2023-09-24 17:18:27 +02:00 committed by GitHub
commit da57083874
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 156 additions and 85 deletions

View File

@ -23,5 +23,5 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'assets/javascripts/custom.4eda089e.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/custom.a4bbca43.min.js' | url }}"></script>
{% endblock %}

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

@ -250,7 +250,7 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.55099adf.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.726fbb30.min.js' | url }}"></script>
{% for script in config.extra_javascript %}
{{ script | script_tag }}
{% endfor %}

View File

@ -10,7 +10,7 @@
<nav class="md-footer__inner md-grid" aria-label="{{ lang.t('footer') }}" {{ hidden }}>
{% if page.previous_page %}
{% set direction = lang.t("footer.previous") %}
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}" rel="prev">
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}">
<div class="md-footer__button md-icon">
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
{% include ".icons/" ~ icon ~ ".svg" %}
@ -27,7 +27,7 @@
{% endif %}
{% if page.next_page %}
{% set direction = lang.t("footer.next") %}
<a href="{{ page.next_page.url | url }}" class="md-footer__link md-footer__link--next" aria-label="{{ direction }}: {{ page.next_page.title | e }}" rel="next">
<a href="{{ page.next_page.url | url }}" class="md-footer__link md-footer__link--next" aria-label="{{ direction }}: {{ page.next_page.title | e }}">
<div class="md-footer__title">
<span class="md-footer__direction">
{{ direction }}

View File

@ -38,7 +38,7 @@ export type Flag =
| "header.autohide" /* Hide header */
| "navigation.expand" /* Automatic expansion */
| "navigation.indexes" /* Section pages */
| "navigation.instant" /* Instant loading */
| "navigation.instant" /* Instant navigation */
| "navigation.sections" /* Section navigation */
| "navigation.tabs" /* Tabs navigation */
| "navigation.tabs.sticky" /* Tabs navigation (sticky) */

View File

@ -22,6 +22,9 @@
import { Subject } from "rxjs"
import { feature } from "~/_"
import { h } from "~/utilities"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -43,10 +46,31 @@ export function getLocation(): URL {
/**
* Set location
*
* @param url - URL to change to
* If instant navigation is enabled, this function creates a temporary anchor
* element, sets the `href` attribute, appends it to the body, clicks it, and
* then removes it again. The event will bubble up the DOM and trigger be
* intercepted by the instant loading business logic.
*
* Note that we must append and remove the anchor element, or the event will
* not bubble up the DOM, making it impossible to intercept it.
*
* @param url - URL to navigate to
* @param navigate - Force navigation
*/
export function setLocation(url: URL | HTMLLinkElement): void {
location.href = url.href
export function setLocation(
url: URL | HTMLLinkElement, navigate = false
): void {
if (feature("navigation.instant") && !navigate) {
const el = h("a", { href: url.href })
document.body.appendChild(el)
el.click()
el.remove()
// If we're not using instant navigation, and the page should not be reloaded
// just instruct the browser to navigate to the given URL
} else {
location.href = url.href
}
}
/* ------------------------------------------------------------------------- */

View File

@ -77,7 +77,7 @@ import {
import {
SearchIndex,
setupClipboardJS,
setupInstantLoading,
setupInstantNavigation,
setupVersionSelector
} from "./integrations"
import {
@ -143,9 +143,9 @@ const index$ = document.forms.namedItem("search")
const alert$ = new Subject<string>()
setupClipboardJS({ alert$ })
/* Set up instant loading, if enabled */
/* Set up instant navigation, if enabled */
if (feature("navigation.instant"))
setupInstantLoading({ location$, viewport$ })
setupInstantNavigation({ location$, viewport$ })
.subscribe(document$)
/* Set up version selector */

View File

@ -82,7 +82,7 @@ export function mountAnnounce(
if (!feature("announce.dismiss") || !el.childElementCount)
return EMPTY
/* Support instant loading - see https://t.ly/3FTme */
/* Support instant navigation - see https://t.ly/3FTme */
if (!el.hidden) {
const content = getElement(".md-typeset", el)
if (__md_hash(content.innerHTML) === __md_get("__announce"))

View File

@ -117,7 +117,7 @@ export function watchSearchQuery(
first(active => !active)
)
.subscribe(() => {
const url = new URL(location.href)
const url = getLocation()
url.searchParams.delete("q")
history.replaceState({}, "", `${url}`)
})

View File

@ -70,6 +70,48 @@ interface SetupOptions {
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Create a map of head elements for lookup and replacement
*
* @param head - Document head
*
* @returns Element map
*/
function lookup(head: HTMLHeadElement): Map<string, HTMLElement> {
const tags = new Map<string, HTMLElement>()
for (const el of getElements(":scope > *", head)) {
let html = el.outerHTML
// If the current element is a style sheet or script, we must resolve the
// URL relative to the current location and make it absolute, so it's easy
// to deduplicate it later on by comparing the outer HTML of tags. We must
// keep identical style sheets and scripts without replacing them.
for (const key of ["href", "src"]) {
const value = el.getAttribute(key)!
if (value === null)
continue
// Resolve URL relative to current location
const url = new URL(value, getLocation())
const ref = el.cloneNode() as HTMLElement
// Set resolved URL and retrieve HTML for deduplication
ref.setAttribute(key, `${url}`)
html = ref.outerHTML
}
// Index element in tag map
tags.set(html, el)
}
// Return tag map
return tags
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -84,7 +126,7 @@ interface SetupOptions {
*
* @returns Document observable
*/
export function setupInstantLoading(
export function setupInstantNavigation(
{ location$, viewport$ }: SetupOptions
): Observable<Document> {
const config = configuration()
@ -188,10 +230,10 @@ export function setupInstantLoading(
history.pushState(null, "", url)
})
// Emit URL that should be fetched via instant loading on location subject,
// which was passed into this function. The idea is that instant loading can
// be intercepted by other parts of the application, which can synchronously
// back up or restore state before instant loading happens.
// Emit URL that should be fetched via instant navigation on location subject,
// which was passed into this function. Instant navigation can be intercepted
// by other parts of the application, which can synchronously back up or
// restore state before instant navigation happens.
instant$.subscribe(location$)
// Fetch document - when fetching, we could use `responseType: document`, but
@ -201,7 +243,7 @@ export function setupInstantLoading(
// reason, we fall back to regular navigation and set the location explicitly,
// which will force-load the page. Furthermore, we must pre-warm the buffer
// for the duplicate check, or the first click on an anchor link will also
// trigger an instant loading event, which doesn't make sense.
// trigger an instant navigation event, which doesn't make sense.
const response$ = location$
.pipe(
startWith(getLocation()),
@ -210,7 +252,7 @@ export function setupInstantLoading(
switchMap(url => request(url)
.pipe(
catchError(() => {
setLocation(url)
setLocation(url, true)
return EMPTY
})
)
@ -218,22 +260,14 @@ export function setupInstantLoading(
)
// Initialize the DOM parser, parse the returned HTML, and replace selected
// meta tags and components before handing control down to the application
// components before handing control down to the application
const dom = new DOMParser()
const document$ = response$
.pipe(
switchMap(res => res.text()),
switchMap(res => {
const document = dom.parseFromString(res, "text/html")
const next = dom.parseFromString(res, "text/html")
for (const selector of [
// Meta tags
"title",
"link[rel=canonical]",
"meta[name=author]",
"meta[name=description]",
// Components
"[data-md-component=announce]",
"[data-md-component=container]",
"[data-md-component=header-topic]",
@ -245,7 +279,7 @@ export function setupInstantLoading(
: []
]) {
const source = getOptionalElement(selector)
const target = getOptionalElement(selector, document)
const target = getOptionalElement(selector, next)
if (
typeof source !== "undefined" &&
typeof target !== "undefined"
@ -254,13 +288,28 @@ export function setupInstantLoading(
}
}
// After meta tags and components were replaced, re-evaluate scripts
// Update meta tags
const source = lookup(document.head)
const target = lookup(next.head)
for (const [html, el] of target) {
if (source.has(html)) {
source.delete(html)
} else {
document.head.appendChild(el)
}
}
// Remove meta tags that are not present in the new document
for (const el of source.values())
el.remove()
// After components and meta tags were replaced, re-evaluate scripts
// that were provided by the author as part of Markdown files
const container = getComponentElement("container")
return concat(getElements("script", container))
.pipe(
switchMap(el => {
const script = document.createElement("script")
const script = next.createElement("script")
if (el.src) {
for (const name of el.getAttributeNames())
script.setAttribute(name, el.getAttribute(name)!)
@ -279,7 +328,7 @@ export function setupInstantLoading(
}
}),
ignoreElements(),
endWith(document)
endWith(next)
)
}),
share()

View File

@ -50,7 +50,7 @@ export type Sitemap = string[]
* Preprocess a list of URLs
*
* This function replaces the `site_url` in the sitemap with the actual base
* URL, to allow instant loading to work in occasions like Netlify previews.
* URL, to allow instant navigation to work in occasions like Netlify previews.
*
* @param urls - URLs
*

View File

@ -112,8 +112,8 @@ export function setupVersionSelector(
// find the same page, as we might have different deployments
// due to aliases. However, if we're outside the version
// selector, we must abort here, because we might otherwise
// interfere with instant loading. We need to refactor this
// at some point together with instant loading.
// interfere with instant navigation. We need to refactor this
// at some point together with instant navigation.
//
// See https://github.com/squidfunk/mkdocs-material/issues/4012
if (!ev.target.closest(".md-version")) {
@ -143,7 +143,7 @@ export function setupVersionSelector(
)
)
)
.subscribe(url => setLocation(url))
.subscribe(url => setLocation(url, true))
/* Render version selector and warning */
combineLatest([versions$, current$])
@ -152,7 +152,7 @@ export function setupVersionSelector(
topic.appendChild(renderVersionSelector(versions, current))
})
/* Integrate outdated version banner with instant loading */
/* Integrate outdated version banner with instant navigation */
document$.pipe(switchMap(() => current$))
.subscribe(current => {

View File

@ -42,7 +42,6 @@
href="{{ page.previous_page.url | url }}"
class="md-footer__link md-footer__link--prev"
aria-label="{{ direction }}: {{ page.previous_page.title | e }}"
rel="prev"
>
<div class="md-footer__button md-icon">
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
@ -66,7 +65,6 @@
href="{{ page.next_page.url | url }}"
class="md-footer__link md-footer__link--next"
aria-label="{{ direction }}: {{ page.next_page.title | e }}"
rel="next"
>
<div class="md-footer__title">
<span class="md-footer__direction">