mirror of https://github.com/vuejs/vitepress
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
235 lines
5.9 KiB
235 lines
5.9 KiB
import { getScrollOffset } from 'vitepress'
|
|
import type { DefaultTheme } from 'vitepress/theme'
|
|
import { onMounted, onUnmounted, onUpdated, type Ref } from 'vue'
|
|
import type { Header } from '../../shared'
|
|
import { throttleAndDebounce } from '../support/utils'
|
|
import { useAside } from './aside'
|
|
|
|
// cached list of anchor elements from resolveHeaders
|
|
const resolvedHeaders: { element: HTMLHeadElement; link: string }[] = []
|
|
|
|
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
|
|
element: HTMLHeadElement
|
|
children?: MenuItem[]
|
|
}
|
|
|
|
export function resolveTitle(theme: DefaultTheme.Config): string {
|
|
return (
|
|
(typeof theme.outline === 'object' &&
|
|
!Array.isArray(theme.outline) &&
|
|
theme.outline.label) ||
|
|
theme.outlineTitle ||
|
|
'On this page'
|
|
)
|
|
}
|
|
|
|
export function getHeaders(range: DefaultTheme.Config['outline']): MenuItem[] {
|
|
const headers = [
|
|
...document.querySelectorAll('.VPDoc :where(h1,h2,h3,h4,h5,h6)')
|
|
]
|
|
.filter((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
|
|
}
|
|
})
|
|
|
|
return resolveHeaders(headers, range)
|
|
}
|
|
|
|
function serializeHeader(h: Element): string {
|
|
let ret = ''
|
|
for (const node of h.childNodes) {
|
|
if (node.nodeType === 1) {
|
|
if (
|
|
(node as Element).classList.contains('VPBadge') ||
|
|
(node as Element).classList.contains('header-anchor') ||
|
|
(node as Element).classList.contains('footnote-ref') ||
|
|
(node as Element).classList.contains('ignore-header')
|
|
) {
|
|
continue
|
|
}
|
|
ret += node.textContent
|
|
} else if (node.nodeType === 3) {
|
|
ret += node.textContent
|
|
}
|
|
}
|
|
return ret.trim()
|
|
}
|
|
|
|
export function resolveHeaders(
|
|
headers: MenuItem[],
|
|
range?: DefaultTheme.Config['outline']
|
|
): MenuItem[] {
|
|
if (range === false) {
|
|
return []
|
|
}
|
|
|
|
const levelsRange =
|
|
(typeof range === 'object' && !Array.isArray(range)
|
|
? range.level
|
|
: range) || 2
|
|
|
|
const [high, low]: [number, number] =
|
|
typeof levelsRange === 'number'
|
|
? [levelsRange, levelsRange]
|
|
: levelsRange === 'deep'
|
|
? [2, 6]
|
|
: levelsRange
|
|
|
|
return buildTree(headers, high, low)
|
|
}
|
|
|
|
export function useActiveAnchor(
|
|
container: Ref<HTMLElement>,
|
|
marker: Ref<HTMLElement>
|
|
): void {
|
|
const { isAsideEnabled } = useAside()
|
|
|
|
const onScroll = throttleAndDebounce(setActiveLink, 100)
|
|
|
|
let prevActiveLink: HTMLAnchorElement | null = null
|
|
|
|
onMounted(() => {
|
|
requestAnimationFrame(setActiveLink)
|
|
window.addEventListener('scroll', onScroll)
|
|
})
|
|
|
|
onUpdated(() => {
|
|
// sidebar update means a route change
|
|
activateLink(location.hash)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('scroll', onScroll)
|
|
})
|
|
|
|
function setActiveLink() {
|
|
if (!isAsideEnabled.value) {
|
|
return
|
|
}
|
|
|
|
const scrollY = 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 + getScrollOffset() + 4) {
|
|
break
|
|
}
|
|
activeLink = link
|
|
}
|
|
activateLink(activeLink)
|
|
}
|
|
|
|
function activateLink(hash: string | null) {
|
|
if (prevActiveLink) {
|
|
prevActiveLink.classList.remove('active')
|
|
}
|
|
|
|
if (hash == null) {
|
|
prevActiveLink = null
|
|
} else {
|
|
prevActiveLink = container.value.querySelector(
|
|
`a[href="${decodeURIComponent(hash)}"]`
|
|
)
|
|
}
|
|
|
|
const activeLink = prevActiveLink
|
|
|
|
if (activeLink) {
|
|
activeLink.classList.add('active')
|
|
marker.value.style.top = activeLink.offsetTop + 39 + 'px'
|
|
marker.value.style.opacity = '1'
|
|
} else {
|
|
marker.value.style.top = '33px'
|
|
marker.value.style.opacity = '0'
|
|
}
|
|
}
|
|
}
|
|
|
|
function getAbsoluteTop(element: HTMLElement): number {
|
|
let offsetTop = 0
|
|
while (element !== document.body) {
|
|
if (element === null) {
|
|
// child element is:
|
|
// - not attached to the DOM (display: none)
|
|
// - set to fixed position (not scrollable)
|
|
// - body or html element (null offsetParent)
|
|
return NaN
|
|
}
|
|
offsetTop += element.offsetTop
|
|
element = element.offsetParent as HTMLElement
|
|
}
|
|
return offsetTop
|
|
}
|
|
|
|
function buildTree(data: MenuItem[], min: number, max: number): MenuItem[] {
|
|
resolvedHeaders.length = 0
|
|
|
|
const result: MenuItem[] = []
|
|
const stack: (MenuItem | { level: number; shouldIgnore: true })[] = []
|
|
|
|
data.forEach((item) => {
|
|
const node = { ...item, children: [] }
|
|
let parent = stack[stack.length - 1]
|
|
|
|
while (parent && parent.level >= node.level) {
|
|
stack.pop()
|
|
parent = stack[stack.length - 1]
|
|
}
|
|
|
|
if (
|
|
node.element.classList.contains('ignore-header') ||
|
|
(parent && 'shouldIgnore' in parent)
|
|
) {
|
|
stack.push({ level: node.level, shouldIgnore: true })
|
|
return
|
|
}
|
|
|
|
if (node.level > max || node.level < min) return
|
|
resolvedHeaders.push({ element: node.element, link: node.link })
|
|
|
|
if (parent) parent.children!.push(node)
|
|
else result.push(node)
|
|
|
|
stack.push(node)
|
|
})
|
|
|
|
return result
|
|
}
|