|
|
|
@ -4,10 +4,11 @@ import type { Header } from '../../shared'
|
|
|
|
|
import { useAside } from './aside'
|
|
|
|
|
import { throttleAndDebounce } from '../support/utils'
|
|
|
|
|
|
|
|
|
|
// magic number to avoid repeated retrieval
|
|
|
|
|
const PAGE_OFFSET = 71
|
|
|
|
|
// cached list of anchor elements from resolveHeaders
|
|
|
|
|
const resolvedHeaders: { element: HTMLHeadElement; link: string }[] = []
|
|
|
|
|
|
|
|
|
|
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
|
|
|
|
|
element: HTMLHeadElement
|
|
|
|
|
children?: MenuItem[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -29,6 +30,7 @@ export function getHeaders(range: DefaultTheme.Config['outline']) {
|
|
|
|
|
.map((el) => {
|
|
|
|
|
const level = Number(el.tagName[1])
|
|
|
|
|
return {
|
|
|
|
|
element: el as HTMLHeadElement,
|
|
|
|
|
title: serializeHeader(el),
|
|
|
|
|
link: '#' + el.id,
|
|
|
|
|
level
|
|
|
|
@ -78,6 +80,12 @@ export function resolveHeaders(
|
|
|
|
|
: levelsRange
|
|
|
|
|
|
|
|
|
|
headers = headers.filter((h) => h.level >= high && h.level <= low)
|
|
|
|
|
// 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[] = []
|
|
|
|
|
outer: for (let i = 0; i < headers.length; i++) {
|
|
|
|
@ -128,40 +136,55 @@ export function useActiveAnchor(
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const links = [].slice.call(
|
|
|
|
|
container.value.querySelectorAll('.outline-link')
|
|
|
|
|
) as HTMLAnchorElement[]
|
|
|
|
|
|
|
|
|
|
const anchors = [].slice
|
|
|
|
|
.call(document.querySelectorAll('.content .header-anchor'))
|
|
|
|
|
.filter((anchor: HTMLAnchorElement) => {
|
|
|
|
|
return links.some((link) => {
|
|
|
|
|
return link.hash === anchor.hash && anchor.offsetParent !== null
|
|
|
|
|
})
|
|
|
|
|
}) as HTMLAnchorElement[]
|
|
|
|
|
// pixel offset, start of main content
|
|
|
|
|
const offsetDocTop = (() => {
|
|
|
|
|
const container =
|
|
|
|
|
document.querySelector('#VPContent .VPDoc')?.firstElementChild
|
|
|
|
|
if (container) return getAbsoluteTop(container as HTMLElement)
|
|
|
|
|
else return 78
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
const scrollY = window.scrollY
|
|
|
|
|
const innerHeight = window.innerHeight
|
|
|
|
|
const offsetHeight = document.body.offsetHeight
|
|
|
|
|
const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1
|
|
|
|
|
|
|
|
|
|
// page bottom - highlight last one
|
|
|
|
|
if (anchors.length && isBottom) {
|
|
|
|
|
activateLink(anchors[anchors.length - 1].hash)
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < anchors.length; i++) {
|
|
|
|
|
const anchor = anchors[i]
|
|
|
|
|
const nextAnchor = anchors[i + 1]
|
|
|
|
|
// page top
|
|
|
|
|
if (scrollY < 1) {
|
|
|
|
|
activateLink(null)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [isActive, hash] = isAnchorActive(i, anchor, nextAnchor)
|
|
|
|
|
// page bottom - highlight last link
|
|
|
|
|
if (isBottom) {
|
|
|
|
|
activateLink(headers[headers.length - 1].link)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isActive) {
|
|
|
|
|
activateLink(hash)
|
|
|
|
|
return
|
|
|
|
|
// find the last header above the top of viewport
|
|
|
|
|
let activeLink: string | null = null
|
|
|
|
|
for (const { link, top } of headers) {
|
|
|
|
|
if (top > scrollY + offsetDocTop) {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
activeLink = link
|
|
|
|
|
}
|
|
|
|
|
activateLink(activeLink)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activateLink(hash: string | null) {
|
|
|
|
@ -190,28 +213,18 @@ export function useActiveAnchor(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getAnchorTop(anchor: HTMLAnchorElement): number {
|
|
|
|
|
return anchor.parentElement!.offsetTop - PAGE_OFFSET
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAnchorActive(
|
|
|
|
|
index: number,
|
|
|
|
|
anchor: HTMLAnchorElement,
|
|
|
|
|
nextAnchor: HTMLAnchorElement | undefined
|
|
|
|
|
): [boolean, string | null] {
|
|
|
|
|
const scrollTop = window.scrollY
|
|
|
|
|
|
|
|
|
|
if (index === 0 && scrollTop === 0) {
|
|
|
|
|
return [true, null]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scrollTop < getAnchorTop(anchor)) {
|
|
|
|
|
return [false, null]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!nextAnchor || scrollTop < getAnchorTop(nextAnchor)) {
|
|
|
|
|
return [true, anchor.hash]
|
|
|
|
|
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 [false, null]
|
|
|
|
|
return offsetTop
|
|
|
|
|
}
|
|
|
|
|