quartz-research-note/quartz/components/scripts/spa.inline.ts

180 lines
5.0 KiB
TypeScript
Raw Normal View History

2023-06-10 02:58:58 +00:00
import micromorph from "micromorph"
2024-01-05 08:29:34 +00:00
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
2023-06-10 02:58:58 +00:00
// adapted from `micromorph`
// https://github.com/natemoo-re/micromorph
const NODE_TYPE_ELEMENT = 1
2023-07-23 00:27:41 +00:00
let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element =>
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
2023-06-10 02:58:58 +00:00
const isLocalUrl = (href: string) => {
try {
const url = new URL(href)
if (window.location.origin === url.origin) {
return true
}
2023-08-17 07:55:52 +00:00
} catch (e) {}
2023-06-10 02:58:58 +00:00
return false
}
const isSamePage = (url: URL): boolean => {
const sameOrigin = url.origin === window.location.origin
const samePath = url.pathname === window.location.pathname
return sameOrigin && samePath
}
2023-07-23 00:27:41 +00:00
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
2023-06-10 02:58:58 +00:00
if (!isElement(target)) return
if (target.attributes.getNamedItem("target")?.value === "_blank") return
2023-06-10 02:58:58 +00:00
const a = target.closest("a")
if (!a) return
2023-07-23 00:27:41 +00:00
if ("routerIgnore" in a.dataset) return
2023-06-10 02:58:58 +00:00
const { href } = a
if (!isLocalUrl(href)) return
2023-07-23 00:27:41 +00:00
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
2023-06-10 02:58:58 +00:00
}
function notifyNav(url: FullSlug) {
2023-06-18 17:47:07 +00:00
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
2023-06-17 02:41:59 +00:00
document.dispatchEvent(event)
}
2023-06-10 02:58:58 +00:00
let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser()
const contents = await fetch(`${url}`)
2024-01-05 08:29:34 +00:00
.then((res) => {
const contentType = res.headers.get("content-type")
if (contentType?.startsWith("text/html")) {
return res.text()
} else {
window.location.assign(url)
}
})
2023-06-10 02:58:58 +00:00
.catch(() => {
window.location.assign(url)
})
2023-07-23 00:27:41 +00:00
if (!contents) return
2023-06-10 02:58:58 +00:00
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, url)
2023-06-10 02:58:58 +00:00
let title = html.querySelector("title")?.textContent
if (title) {
document.title = title
} else {
2023-07-23 00:27:41 +00:00
const h1 = document.querySelector("h1")
2023-06-10 02:58:58 +00:00
title = h1?.innerText ?? h1?.textContent ?? url.pathname
}
if (announcer.textContent !== title) {
announcer.textContent = title
}
2023-07-23 00:27:41 +00:00
announcer.dataset.persist = ""
2023-06-10 02:58:58 +00:00
html.body.appendChild(announcer)
// morph body
2023-06-10 02:58:58 +00:00
micromorph(document.body, html.body)
// scroll into place and add history
if (!isBack) {
if (url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
} else {
window.scrollTo({ top: 0 })
}
}
2023-07-23 00:27:41 +00:00
// now, patch head
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
elementsToAdd.forEach((el) => document.head.appendChild(el))
2023-06-10 02:58:58 +00:00
// delay setting the url until now
// at this point everything is loaded so changing the url should resolve to the correct addresses
if (!isBack) {
history.pushState({}, "", url)
}
notifyNav(getFullSlug(window))
2023-06-10 02:58:58 +00:00
delete announcer.dataset.persist
}
2023-06-18 17:47:07 +00:00
window.spaNavigate = navigate
2023-06-10 02:58:58 +00:00
function createRouter() {
if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => {
const { url } = getOpts(event) ?? {}
// dont hijack behaviour, just let browser act normally
2023-10-08 16:15:06 +00:00
if (!url || event.ctrlKey || event.metaKey) return
2023-06-10 02:58:58 +00:00
event.preventDefault()
if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
2024-01-05 08:29:34 +00:00
history.pushState({}, "", url)
return
}
2023-06-10 02:58:58 +00:00
try {
navigate(url, false)
} catch (e) {
window.location.assign(url)
}
})
window.addEventListener("popstate", (event) => {
const { url } = getOpts(event) ?? {}
if (window.location.hash && window.location.pathname === url?.pathname) return
2023-06-10 02:58:58 +00:00
try {
navigate(new URL(window.location.toString()), true)
} catch (e) {
window.location.reload()
}
return
})
}
2023-06-17 19:07:40 +00:00
2023-07-23 00:27:41 +00:00
return new (class Router {
2023-07-13 07:19:35 +00:00
go(pathname: RelativeURL) {
2023-06-10 02:58:58 +00:00
const url = new URL(pathname, window.location.toString())
return navigate(url, false)
}
back() {
return window.history.back()
}
forward() {
return window.history.forward()
}
2023-07-23 00:27:41 +00:00
})()
2023-06-10 02:58:58 +00:00
}
createRouter()
notifyNav(getFullSlug(window))
2023-06-10 02:58:58 +00:00
2023-07-23 00:27:41 +00:00
if (!customElements.get("route-announcer")) {
2023-06-10 02:58:58 +00:00
const attrs = {
2023-07-23 00:27:41 +00:00
"aria-live": "assertive",
"aria-atomic": "true",
style:
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
2023-06-10 02:58:58 +00:00
}
2023-07-23 00:27:41 +00:00
customElements.define(
"route-announcer",
class RouteAnnouncer extends HTMLElement {
constructor() {
super()
2023-06-10 02:58:58 +00:00
}
2023-07-23 00:27:41 +00:00
connectedCallback() {
for (const [key, value] of Object.entries(attrs)) {
this.setAttribute(key, value)
}
}
},
)
2023-06-10 02:58:58 +00:00
}