diff --git a/src/client/theme-default/composables/outline.ts b/src/client/theme-default/composables/outline.ts index 59ee7447..8ec4f3e5 100644 --- a/src/client/theme-default/composables/outline.ts +++ b/src/client/theme-default/composables/outline.ts @@ -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 & { + 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 }