使用 IntersectionObserver 实现目录
发布于 2021-04-28
约 4 分钟
介绍
使用 Intersection Observer API 和 scroll-padding
实现一个文章目录,能自动跟随高亮目录标题,其中 scroll-padding 用在当点击目录时,使标题滚到合适的可视区域。
现状
IntersectionObserver
的 callback 执行时机:
- target 元素在上边界离开和进入可视区域
- target 元素在下边界离开和进入可视区域
- 初始化或快速滚动时,多个 target 同时在可视区域
- 非鼠标滚动交互造成的滚动
- ……
因此为了确定什么时候高亮哪一个目录标题,设定几个 case。
case 设计
前提:把上边界作为基线,threshold
设为 1,root 是 document
- case1: 向上滚动,到基线,变成不相交,active 当前标题
- case2: 向下滚动,到基线,变成相交,active 上一个标题
- case3: 初始化,
heading.getboundingClientRect().top
离基线最近的 active 标题 - case4: 点击目录,跳到 heading,直接 active,跳过 observe 处理
- caseX: 其他不处理
代码实现
scroll-padding-top
设置上边界和 const baseLine = 156 // 上边界的基线位置
const distanceActionToCase1 = baseLine - 6 // scroll-padding-top,点击目录跳转后的标题位置视为 case1
const init = () => {
document.documentElement.style.scrollPaddingTop = `${distanceActionToCase1}px`
}
用前后的 scrollTop
判断滚动方向
const observer = new IntersectionObserver(
(entries) => {
let prevScollTop = document.documentElement.scrollTop
const isScrollDown = prevScollTop > document.documentElement.scrollTop
const isScrollUp = !isScrollDown
prevScollTop = document.documentElement.scrollTop
// ...
},
{
threshold: [1],
root: document,
rootMargin: `-${baseLine}px 0px 0px 0px`, // 设置 rootMargin
}
)
处理 case3
比较 IntersectionObserverEntry.boundingClientRect.top
和 baseLine
的距离,确定哪个 heading
离基线最近
const observer = new IntersectionObserver(
(entries) => {
// ...
let willActiveHeading: Element | null = null
let isInit = true
entries.forEach((entry) => {
const { top } = entry.boundingClientRect
if (isInit) {
if (
Math.abs(top - baseLine) <=
Math.abs(initClosestHeading.boundingClientRect.top - baseLine)
) {
initClosestHeading = entry
}
return
}
// ...
})
if (isInit) {
const { hash } = location
if (hash) {
;(
document.querySelector(`.toc a[href='${hash}']`) as HTMLAnchorElement
).click()
} else {
initClosestHeading.target.dispatchEvent(new CustomEvent('active'))
}
isInit = false
}
}
// ...
)
处理 case1 和 case2
const observer = new IntersectionObserver(
(entries) => {
// ...
let willActiveHeading: Element | null = null
entries.forEach((entry) => {
if (entry.isIntersecting && isScrollDown) {
willActiveHeading = entry.target
} else if (!entry.isIntersecting && isScrollUp) {
willActiveHeading = entry.target
}
})
}
// ...
)
处理 case4
在点击跳转过程中,依然会触发 observer 的 callback
const article = document.querySelector('.markdown-body')
const allHeadings = article?.querySelectorAll('h1,h2,h3,h4')
allHeadings?.forEach((heading, index) => {
let a = findToc(heading)
a?.addEventListener('click', () => {
tocJumpTo = heading
tocJumpTo.dispatchEvent(
new CustomEvent('active', {
detail: 'cur',
})
)
prevScollTop = document.documentElement.scrollTop
})
heading.addEventListener('active', (e) => {
toc.forEach((t) => t.classList.remove('active-heading'))
if ((e as CustomEvent).detail === 'prev' && index !== 0) {
const prevA = findToc(allHeadings[index - 1])
prevA?.classList.add('active-heading')
return
}
a?.classList.add('active-heading')
})
observer?.observe(heading)
})
完整代码(React) https://github.com/marsk6/marsk6.github.io/blob/master/src/components/Toc.tsx
实现效果
本博客 (Hea的web博客) 所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!