useAnchorObserver.ts64 lines · main
1import { useEffect, useState } from 'react'
2
3/**
4 * Find the active heading of page
5 *
6 * It selects the top heading by default, and the last item when reached the bottom of page.
7 *
8 * @param watch - An array of element ids to watch
9 * @param single - only one active item at most
10 * @returns Active anchor
11 */
12export function useAnchorObserver(watch: string[], single: boolean): string[] {
13 const [activeAnchor, setActiveAnchor] = useState<string[]>([])
14
15 useEffect(() => {
16 let visible: string[] = []
17 const observer = new IntersectionObserver(
18 (entries) => {
19 for (const entry of entries) {
20 if (entry.isIntersecting && !visible.includes(entry.target.id)) {
21 visible = [...visible, entry.target.id]
22 } else if (!entry.isIntersecting && visible.includes(entry.target.id)) {
23 visible = visible.filter((v) => v !== entry.target.id)
24 }
25 }
26
27 if (visible.length > 0) setActiveAnchor(visible)
28 },
29 {
30 rootMargin: single ? '-80px 0% -70% 0%' : `-20px 0% -40% 0%`,
31 threshold: 1,
32 }
33 )
34
35 function onScroll(): void {
36 const element = document.scrollingElement
37 if (!element) return
38
39 if (element.scrollTop === 0 && single) setActiveAnchor(watch.slice(0, 1))
40 else if (element.scrollTop + element.clientHeight >= element.scrollHeight - 6) {
41 setActiveAnchor((active) => {
42 return active.length > 0 && !single
43 ? watch.slice(watch.indexOf(active[0]))
44 : watch.slice(-1)
45 })
46 }
47 }
48
49 for (const heading of watch) {
50 const element = document.getElementById(heading)
51
52 if (element) observer.observe(element)
53 }
54
55 onScroll()
56 window.addEventListener('scroll', onScroll)
57 return () => {
58 window.removeEventListener('scroll', onScroll)
59 observer.disconnect()
60 }
61 }, [single, watch])
62
63 return single ? activeAnchor.slice(0, 1) : activeAnchor
64}