339 lines
8.8 KiB
TypeScript
339 lines
8.8 KiB
TypeScript
import type { Ref } from 'vue'
|
|
import type { Router } from 'vuepress/client'
|
|
import type { ThemeOutline } from '../../shared/index.js'
|
|
import { useThrottleFn, watchDebounced } from '@vueuse/core'
|
|
import { onMounted, onUnmounted, onUpdated, ref } from 'vue'
|
|
import { onContentUpdated, useRouter } from 'vuepress/client'
|
|
import { useData } from './data.js'
|
|
import { useLayout } from './layout.js'
|
|
|
|
export interface Header {
|
|
/**
|
|
* The level of the header
|
|
*
|
|
* `1` to `6` for `<h1>` to `<h6>`
|
|
*/
|
|
level: number
|
|
/**
|
|
* The title of the header
|
|
*/
|
|
title: string
|
|
/**
|
|
* The slug of the header
|
|
*
|
|
* Typically the `id` attr of the header anchor
|
|
*/
|
|
slug: string
|
|
/**
|
|
* Link of the header
|
|
*
|
|
* Typically using `#${slug}` as the anchor hash
|
|
*/
|
|
link: string
|
|
/**
|
|
* The children of the header
|
|
*/
|
|
children: Header[]
|
|
}
|
|
|
|
// cached list of anchor elements from resolveHeaders
|
|
const resolvedHeaders: { element: HTMLHeadElement, link: string }[] = []
|
|
|
|
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
|
|
element: HTMLHeadElement
|
|
children?: MenuItem[]
|
|
lowLevel?: number
|
|
}
|
|
|
|
const headers = ref<MenuItem[]>([])
|
|
|
|
export function setupHeaders(): Ref<MenuItem[]> {
|
|
const { frontmatter, theme } = useData()
|
|
|
|
onContentUpdated(() => {
|
|
headers.value = getHeaders(frontmatter.value.outline ?? theme.value.outline)
|
|
})
|
|
|
|
return headers
|
|
}
|
|
|
|
export function useHeaders(): Ref<MenuItem[]> {
|
|
return headers
|
|
}
|
|
|
|
export function getHeaders(range?: ThemeOutline): MenuItem[] {
|
|
const heading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
|
|
const ignores = Array.from(document.querySelectorAll(
|
|
heading.map(h => `.vp-demo-wrapper ${h}`).join(','),
|
|
))
|
|
const headers = Array.from(
|
|
document.querySelectorAll(heading.map(h => `.vp-doc ${h}`).join(',')),
|
|
)
|
|
.filter(el => !ignores.includes(el) && el.id && el.hasChildNodes())
|
|
.map((el) => {
|
|
const level = Number(el.tagName[1])
|
|
return {
|
|
element: el as HTMLHeadElement,
|
|
title: serializeHeader(el),
|
|
link: `#${el.id}`,
|
|
level,
|
|
lowLevel: getLowLevel(el as HTMLHeadElement, level),
|
|
}
|
|
})
|
|
if (range === false)
|
|
return []
|
|
|
|
const [high, low] = getRange(range)
|
|
return resolveSubRangeHeader(resolveHeaders(headers, high), low)
|
|
}
|
|
|
|
function getRange(range?: Exclude<ThemeOutline, boolean>): readonly [number, number] {
|
|
const levelsRange = range || 2
|
|
// [high, low]
|
|
return typeof levelsRange === 'number'
|
|
? [levelsRange, levelsRange]
|
|
: levelsRange === 'deep'
|
|
? [2, 6]
|
|
: levelsRange
|
|
}
|
|
|
|
function getLowLevel(el: HTMLHeadElement, level: number): number | undefined {
|
|
if (!el.hasAttribute('data-outline') && !el.hasAttribute('outline'))
|
|
return
|
|
|
|
// only support
|
|
// data-outline="3" -> star, end -> [level, 3]
|
|
const str = (el.getAttribute('data-outline') || el.getAttribute('outline'))?.trim()
|
|
if (!str)
|
|
return
|
|
|
|
const num = Number(str)
|
|
if (!Number.isNaN(num) && num >= level)
|
|
return num
|
|
|
|
return undefined
|
|
}
|
|
|
|
function serializeHeader(h: Element): string {
|
|
// <hx><a href="#"><span>title</span></a></hx>
|
|
const anchor = h.firstChild
|
|
const el = anchor?.firstChild
|
|
let ret = ''
|
|
for (const node of Array.from(el?.childNodes ?? [])) {
|
|
if (node.nodeType === 1) {
|
|
if (
|
|
(node as Element).classList.contains('vp-badge')
|
|
|| (node as Element).classList.contains('ignore-header')
|
|
) {
|
|
continue
|
|
}
|
|
const clone = node.cloneNode(true)
|
|
clearHeaderNodeList(Array.from(clone.childNodes))
|
|
ret += clone.textContent
|
|
}
|
|
else if (node.nodeType === 3) {
|
|
ret += node.textContent
|
|
}
|
|
}
|
|
// maybe `<hx><a href="#"></a><a href="xxx"></a></hx>` or more
|
|
let next = anchor?.nextSibling
|
|
while (next) {
|
|
if (next.nodeType === 1 || next.nodeType === 3)
|
|
ret += next.textContent
|
|
|
|
next = next.nextSibling
|
|
}
|
|
return ret.trim()
|
|
}
|
|
|
|
function clearHeaderNodeList(list?: ChildNode[]) {
|
|
if (list?.length) {
|
|
for (const node of list) {
|
|
if (node.nodeType === 1) {
|
|
if ((node as Element).classList.contains('ignore-header')) {
|
|
node.remove()
|
|
}
|
|
else {
|
|
clearHeaderNodeList(Array.from(node.childNodes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveHeaders(headers: MenuItem[], high: number): MenuItem[] {
|
|
headers = headers.filter(h => h.level >= high)
|
|
// clear previous caches
|
|
resolvedHeaders.length = 0
|
|
// update global header list for active link rendering
|
|
for (const { element, link } of headers)
|
|
resolvedHeaders.push({ element, link })
|
|
|
|
const ret: MenuItem[] = []
|
|
// eslint-disable-next-line no-labels
|
|
outer: for (let i = 0; i < headers.length; i++) {
|
|
const cur = headers[i]
|
|
if (i === 0) {
|
|
ret.push(cur)
|
|
}
|
|
else {
|
|
for (let j = i - 1; j >= 0; j--) {
|
|
const prev = headers[j]
|
|
if (prev.level < cur.level) {
|
|
;(prev.children || (prev.children = [])).push(cur)
|
|
// eslint-disable-next-line no-labels
|
|
continue outer
|
|
}
|
|
}
|
|
ret.push(cur)
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
function resolveSubRangeHeader(headers: MenuItem[], low: number): MenuItem[] {
|
|
return headers.map((header) => {
|
|
if (header.children?.length) {
|
|
const current = header.lowLevel ? Math.max(header.lowLevel, low) : low
|
|
const children = header.children.filter(({ level }) => level <= current)
|
|
header.children = resolveSubRangeHeader(children, header.lowLevel || low)
|
|
}
|
|
return header
|
|
})
|
|
}
|
|
|
|
export function useActiveAnchor(container: Ref<HTMLElement | null>, marker: Ref<HTMLElement | null>): void {
|
|
const { isAsideEnabled } = useLayout()
|
|
const router = useRouter()
|
|
const routeHash = ref<string>(router.currentRoute.value.hash)
|
|
|
|
let prevActiveLink: HTMLAnchorElement | null = null
|
|
|
|
const setActiveLink = (): void => {
|
|
if (!isAsideEnabled.value)
|
|
return
|
|
|
|
const scrollY = Math.round(window.scrollY)
|
|
const innerHeight = window.innerHeight
|
|
const offsetHeight = document.body.offsetHeight
|
|
const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1
|
|
|
|
// resolvedHeaders may be repositioned, hidden or fix positioned
|
|
const headers = resolvedHeaders
|
|
.map(({ element, link }) => ({
|
|
link,
|
|
top: getAbsoluteTop(element),
|
|
}))
|
|
.filter(({ top }) => !Number.isNaN(top))
|
|
.sort((a, b) => a.top - b.top)
|
|
|
|
// no headers available for active link
|
|
if (!headers.length) {
|
|
activateLink(null)
|
|
return
|
|
}
|
|
|
|
// page top
|
|
if (scrollY < 1) {
|
|
activateLink(null)
|
|
return
|
|
}
|
|
|
|
// page bottom - highlight last link
|
|
if (isBottom) {
|
|
activateLink(headers[headers.length - 1].link)
|
|
return
|
|
}
|
|
|
|
// find the last header above the top of viewport
|
|
let activeLink: string | null = null
|
|
for (const { link, top } of headers) {
|
|
if (top > scrollY + 80)
|
|
break
|
|
|
|
activeLink = link
|
|
}
|
|
activateLink(activeLink)
|
|
}
|
|
|
|
function activateLink(hash: string | null): void {
|
|
routeHash.value = hash || ''
|
|
if (prevActiveLink)
|
|
prevActiveLink.classList.remove('active')
|
|
|
|
if (hash == null) {
|
|
prevActiveLink = null
|
|
}
|
|
else {
|
|
prevActiveLink = container.value?.querySelector(
|
|
`a[href="${decodeURIComponent(hash)}"]`,
|
|
) ?? null
|
|
}
|
|
|
|
const activeLink = prevActiveLink
|
|
|
|
if (activeLink) {
|
|
activeLink.classList.add('active')
|
|
if (marker.value) {
|
|
marker.value.style.top = `${activeLink.offsetTop + 39}px`
|
|
marker.value.style.opacity = '1'
|
|
}
|
|
}
|
|
else {
|
|
if (marker.value) {
|
|
marker.value.style.top = '33px'
|
|
marker.value.style.opacity = '0'
|
|
}
|
|
}
|
|
}
|
|
|
|
const onScroll = useThrottleFn(setActiveLink, 100)
|
|
|
|
watchDebounced(routeHash, () => {
|
|
updateHash(router, routeHash.value)
|
|
}, { debounce: 500 })
|
|
|
|
onMounted(() => {
|
|
setTimeout(() => {
|
|
setActiveLink()
|
|
window.addEventListener('scroll', onScroll)
|
|
}, 1000)
|
|
})
|
|
|
|
onUpdated(() => {
|
|
// sidebar update means a route change
|
|
activateLink(location.hash)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('scroll', onScroll)
|
|
})
|
|
}
|
|
|
|
function getAbsoluteTop(element: HTMLElement): number {
|
|
let offsetTop = 0
|
|
while (element && element !== document.body) {
|
|
if (window.getComputedStyle(element).position === 'fixed') {
|
|
return element.offsetTop
|
|
}
|
|
offsetTop += element.offsetTop
|
|
element = element.offsetParent as HTMLElement
|
|
}
|
|
return element ? offsetTop : Number.NaN
|
|
}
|
|
|
|
/**
|
|
* Update current hash and do not trigger `scrollBehavior`
|
|
*/
|
|
async function updateHash(router: Router, hash: string): Promise<void> {
|
|
const { path, query } = router.currentRoute.value
|
|
const { scrollBehavior } = router.options
|
|
|
|
// temporarily disable `scrollBehavior`
|
|
router.options.scrollBehavior = undefined
|
|
await router.replace({ path, query, hash })
|
|
// restore it after navigation
|
|
router.options.scrollBehavior = scrollBehavior
|
|
}
|