toc-primitive.tsx115 lines · main
1'use client'
2
3import { mergeRefs, useAnchorObserver, useOnChange } from 'common'
4import { createContext, forwardRef, useContext, useMemo, useRef } from 'react'
5import type { AnchorHTMLAttributes, ReactNode, RefObject } from 'react'
6import scrollIntoView from 'scroll-into-view-if-needed'
7
8import type { TableOfContents } from './server/get-toc'
9
10export const ActiveAnchorContext = createContext<string[]>([])
11
12const ScrollContext = createContext<RefObject<HTMLElement | null>>({
13 current: null,
14})
15
16/**
17 * The estimated active heading ID
18 */
19export function useActiveAnchor(): string | undefined {
20 return useContext(ActiveAnchorContext).at(-1)
21}
22
23/**
24 * The id of visible anchors
25 */
26export function useActiveAnchors(): string[] {
27 return useContext(ActiveAnchorContext)
28}
29
30export interface AnchorProviderProps {
31 toc: TableOfContents
32 /**
33 * Only accept one active item at most
34 *
35 * @defaultValue true
36 */
37 single?: boolean
38 children?: ReactNode
39}
40
41export interface ScrollProviderProps {
42 /**
43 * Scroll into the view of container when active
44 */
45 containerRef: RefObject<HTMLElement | null>
46
47 children?: ReactNode
48}
49
50export function ScrollProvider({
51 containerRef,
52 children,
53}: ScrollProviderProps): React.ReactElement {
54 return <ScrollContext.Provider value={containerRef}>{children}</ScrollContext.Provider>
55}
56
57export function AnchorProvider({
58 toc,
59 single = true,
60 children,
61}: AnchorProviderProps): React.ReactElement {
62 const headings = useMemo(() => {
63 return toc.map((item) => item.url.split('#')[1])
64 }, [toc])
65
66 return (
67 <ActiveAnchorContext.Provider value={useAnchorObserver(headings, single)}>
68 {children}
69 </ActiveAnchorContext.Provider>
70 )
71}
72
73export interface TOCItemProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
74 href: string
75
76 onActiveChange?: (v: boolean) => void
77}
78
79export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
80 ({ onActiveChange, ...props }, ref) => {
81 const containerRef = useContext(ScrollContext)
82
83 const anchors = useActiveAnchors()
84 const anchorRef = useRef<HTMLAnchorElement>(null)
85 const mergedRef = mergeRefs(anchorRef, ref)
86
87 const isActive = anchors.includes(props.href.slice(1))
88
89 useOnChange(isActive, (v) => {
90 const element = anchorRef.current
91
92 if (!element) return
93
94 if (v && containerRef.current) {
95 scrollIntoView(element, {
96 behavior: 'smooth',
97 block: 'center',
98 inline: 'center',
99 scrollMode: 'always',
100 boundary: containerRef.current,
101 })
102 }
103
104 onActiveChange?.(v)
105 })
106
107 return (
108 <a ref={mergedRef} data-active={isActive} {...props}>
109 {props.children}
110 </a>
111 )
112 }
113)
114
115TOCItem.displayName = 'TOCItem'