Skip to content

Commit 3654470

Browse files
authored
refactor(theme): improve robustness and readability of outline component (#3368)
1 parent 017395f commit 3654470

File tree

1 file changed

+59
-46
lines changed

1 file changed

+59
-46
lines changed

Diff for: src/client/theme-default/composables/outline.ts

+59-46
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import type { Header } from '../../shared'
44
import { useAside } from './aside'
55
import { throttleAndDebounce } from '../support/utils'
66

7-
// magic number to avoid repeated retrieval
8-
const PAGE_OFFSET = 71
7+
// cached list of anchor elements from resolveHeaders
8+
const resolvedHeaders: { element: HTMLHeadElement; link: string }[] = []
99

1010
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
11+
element: HTMLHeadElement
1112
children?: MenuItem[]
1213
}
1314

@@ -29,6 +30,7 @@ export function getHeaders(range: DefaultTheme.Config['outline']) {
2930
.map((el) => {
3031
const level = Number(el.tagName[1])
3132
return {
33+
element: el as HTMLHeadElement,
3234
title: serializeHeader(el),
3335
link: '#' + el.id,
3436
level
@@ -78,6 +80,12 @@ export function resolveHeaders(
7880
: levelsRange
7981

8082
headers = headers.filter((h) => h.level >= high && h.level <= low)
83+
// clear previous caches
84+
resolvedHeaders.length = 0
85+
// update global header list for active link rendering
86+
for (const { element, link } of headers) {
87+
resolvedHeaders.push({ element, link })
88+
}
8189

8290
const ret: MenuItem[] = []
8391
outer: for (let i = 0; i < headers.length; i++) {
@@ -128,40 +136,55 @@ export function useActiveAnchor(
128136
return
129137
}
130138

131-
const links = [].slice.call(
132-
container.value.querySelectorAll('.outline-link')
133-
) as HTMLAnchorElement[]
134-
135-
const anchors = [].slice
136-
.call(document.querySelectorAll('.content .header-anchor'))
137-
.filter((anchor: HTMLAnchorElement) => {
138-
return links.some((link) => {
139-
return link.hash === anchor.hash && anchor.offsetParent !== null
140-
})
141-
}) as HTMLAnchorElement[]
139+
// pixel offset, start of main content
140+
const offsetDocTop = (() => {
141+
const container =
142+
document.querySelector('#VPContent .VPDoc')?.firstElementChild
143+
if (container) return getAbsoluteTop(container as HTMLElement)
144+
else return 78
145+
})()
142146

143147
const scrollY = window.scrollY
144148
const innerHeight = window.innerHeight
145149
const offsetHeight = document.body.offsetHeight
146150
const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1
147151

148-
// page bottom - highlight last one
149-
if (anchors.length && isBottom) {
150-
activateLink(anchors[anchors.length - 1].hash)
152+
// resolvedHeaders may be repositioned, hidden or fix positioned
153+
const headers = resolvedHeaders
154+
.map(({ element, link }) => ({
155+
link,
156+
top: getAbsoluteTop(element)
157+
}))
158+
.filter(({ top }) => !Number.isNaN(top))
159+
.sort((a, b) => a.top - b.top)
160+
161+
// no headers available for active link
162+
if (!headers.length) {
163+
activateLink(null)
151164
return
152165
}
153166

154-
for (let i = 0; i < anchors.length; i++) {
155-
const anchor = anchors[i]
156-
const nextAnchor = anchors[i + 1]
167+
// page top
168+
if (scrollY < 1) {
169+
activateLink(null)
170+
return
171+
}
157172

158-
const [isActive, hash] = isAnchorActive(i, anchor, nextAnchor)
173+
// page bottom - highlight last link
174+
if (isBottom) {
175+
activateLink(headers[headers.length - 1].link)
176+
return
177+
}
159178

160-
if (isActive) {
161-
activateLink(hash)
162-
return
179+
// find the last header above the top of viewport
180+
let activeLink: string | null = null
181+
for (const { link, top } of headers) {
182+
if (top > scrollY + offsetDocTop) {
183+
break
163184
}
185+
activeLink = link
164186
}
187+
activateLink(activeLink)
165188
}
166189

167190
function activateLink(hash: string | null) {
@@ -190,28 +213,18 @@ export function useActiveAnchor(
190213
}
191214
}
192215

193-
function getAnchorTop(anchor: HTMLAnchorElement): number {
194-
return anchor.parentElement!.offsetTop - PAGE_OFFSET
195-
}
196-
197-
function isAnchorActive(
198-
index: number,
199-
anchor: HTMLAnchorElement,
200-
nextAnchor: HTMLAnchorElement | undefined
201-
): [boolean, string | null] {
202-
const scrollTop = window.scrollY
203-
204-
if (index === 0 && scrollTop === 0) {
205-
return [true, null]
206-
}
207-
208-
if (scrollTop < getAnchorTop(anchor)) {
209-
return [false, null]
210-
}
211-
212-
if (!nextAnchor || scrollTop < getAnchorTop(nextAnchor)) {
213-
return [true, anchor.hash]
216+
function getAbsoluteTop(element: HTMLElement): number {
217+
let offsetTop = 0
218+
while (element !== document.body) {
219+
if (element === null) {
220+
// child element is:
221+
// - not attached to the DOM (display: none)
222+
// - set to fixed position (not scrollable)
223+
// - body or html element (null offsetParent)
224+
return NaN
225+
}
226+
offsetTop += element.offsetTop
227+
element = element.offsetParent as HTMLElement
214228
}
215-
216-
return [false, null]
229+
return offsetTop
217230
}

0 commit comments

Comments
 (0)