mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-20 19:34:05 -06:00
- Add scrollToElementWithBuffer and highlightElement utility functions to util.ts - Implement header-aware scroll positioning to prevent content hiding behind fixed headers - Add temporary highlight effect for scrolled-to elements to improve user experience - Refactor SPA and popover scripts to use shared utility functions (DRY principle) - Support configurable buffer space and highlight duration for different use cases Fixes content being obscured by page header when navigating to hash anchors. Enhances UX with visual feedback showing which heading was targeted.
220 lines
5.9 KiB
TypeScript
220 lines
5.9 KiB
TypeScript
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
|
import { fetchCanonical, scrollToElementWithBuffer } from "./util"
|
|
|
|
import micromorph from "micromorph"
|
|
|
|
// adapted from `micromorph`
|
|
// https://github.com/natemoo-re/micromorph
|
|
const NODE_TYPE_ELEMENT = 1
|
|
let announcer = document.createElement("route-announcer")
|
|
const isElement = (target: EventTarget | null): target is Element =>
|
|
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
|
|
const isLocalUrl = (href: string) => {
|
|
try {
|
|
const url = new URL(href)
|
|
if (window.location.origin === url.origin) {
|
|
return true
|
|
}
|
|
} catch (e) {}
|
|
return false
|
|
}
|
|
|
|
const isSamePage = (url: URL): boolean => {
|
|
const sameOrigin = url.origin === window.location.origin
|
|
const samePath = url.pathname === window.location.pathname
|
|
return sameOrigin && samePath
|
|
}
|
|
|
|
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
|
|
if (!isElement(target)) return
|
|
if (target.attributes.getNamedItem("target")?.value === "_blank") return
|
|
const a = target.closest("a")
|
|
if (!a) return
|
|
if ("routerIgnore" in a.dataset) return
|
|
const { href } = a
|
|
if (!isLocalUrl(href)) return
|
|
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
|
|
}
|
|
|
|
function notifyNav(url: FullSlug) {
|
|
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
|
document.dispatchEvent(event)
|
|
}
|
|
|
|
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
|
window.addCleanup = (fn) => cleanupFns.add(fn)
|
|
|
|
function startLoading() {
|
|
const loadingBar = document.createElement("div")
|
|
loadingBar.className = "navigation-progress"
|
|
loadingBar.style.width = "0"
|
|
if (!document.body.contains(loadingBar)) {
|
|
document.body.appendChild(loadingBar)
|
|
}
|
|
|
|
setTimeout(() => {
|
|
loadingBar.style.width = "80%"
|
|
}, 100)
|
|
}
|
|
|
|
let isNavigating = false
|
|
let p: DOMParser
|
|
async function _navigate(url: URL, isBack: boolean = false) {
|
|
isNavigating = true
|
|
startLoading()
|
|
p = p || new DOMParser()
|
|
const contents = await fetchCanonical(url)
|
|
.then((res) => {
|
|
const contentType = res.headers.get("content-type")
|
|
if (contentType?.startsWith("text/html")) {
|
|
return res.text()
|
|
} else {
|
|
window.location.assign(url)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
window.location.assign(url)
|
|
})
|
|
|
|
if (!contents) return
|
|
|
|
// notify about to nav
|
|
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
|
|
document.dispatchEvent(event)
|
|
|
|
// cleanup old
|
|
cleanupFns.forEach((fn) => fn())
|
|
cleanupFns.clear()
|
|
|
|
const html = p.parseFromString(contents, "text/html")
|
|
normalizeRelativeURLs(html, url)
|
|
|
|
let title = html.querySelector("title")?.textContent
|
|
if (title) {
|
|
document.title = title
|
|
} else {
|
|
const h1 = document.querySelector("h1")
|
|
title = h1?.innerText ?? h1?.textContent ?? url.pathname
|
|
}
|
|
if (announcer.textContent !== title) {
|
|
announcer.textContent = title
|
|
}
|
|
announcer.dataset.persist = ""
|
|
html.body.appendChild(announcer)
|
|
|
|
// morph body
|
|
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)))
|
|
if (el) {
|
|
scrollToElementWithBuffer(el)
|
|
}
|
|
} else {
|
|
window.scrollTo({ top: 0 })
|
|
}
|
|
}
|
|
|
|
// now, patch head, re-executing scripts
|
|
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))
|
|
|
|
// 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))
|
|
delete announcer.dataset.persist
|
|
}
|
|
|
|
async function navigate(url: URL, isBack: boolean = false) {
|
|
if (isNavigating) return
|
|
isNavigating = true
|
|
try {
|
|
await _navigate(url, isBack)
|
|
} catch (e) {
|
|
console.error(e)
|
|
window.location.assign(url)
|
|
} finally {
|
|
isNavigating = false
|
|
}
|
|
}
|
|
|
|
window.spaNavigate = navigate
|
|
|
|
function createRouter() {
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("click", async (event) => {
|
|
const { url } = getOpts(event) ?? {}
|
|
// dont hijack behaviour, just let browser act normally
|
|
if (!url || event.ctrlKey || event.metaKey) return
|
|
event.preventDefault()
|
|
|
|
if (isSamePage(url) && url.hash) {
|
|
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
|
if (el) {
|
|
scrollToElementWithBuffer(el)
|
|
}
|
|
history.pushState({}, "", url)
|
|
return
|
|
}
|
|
|
|
navigate(url, false)
|
|
})
|
|
|
|
window.addEventListener("popstate", (event) => {
|
|
const { url } = getOpts(event) ?? {}
|
|
if (window.location.hash && window.location.pathname === url?.pathname) return
|
|
navigate(new URL(window.location.toString()), true)
|
|
return
|
|
})
|
|
}
|
|
|
|
return new (class Router {
|
|
go(pathname: RelativeURL) {
|
|
const url = new URL(pathname, window.location.toString())
|
|
return navigate(url, false)
|
|
}
|
|
|
|
back() {
|
|
return window.history.back()
|
|
}
|
|
|
|
forward() {
|
|
return window.history.forward()
|
|
}
|
|
})()
|
|
}
|
|
|
|
createRouter()
|
|
notifyNav(getFullSlug(window))
|
|
|
|
if (!customElements.get("route-announcer")) {
|
|
const attrs = {
|
|
"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",
|
|
}
|
|
|
|
customElements.define(
|
|
"route-announcer",
|
|
class RouteAnnouncer extends HTMLElement {
|
|
constructor() {
|
|
super()
|
|
}
|
|
connectedCallback() {
|
|
for (const [key, value] of Object.entries(attrs)) {
|
|
this.setAttribute(key, value)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|