|
1 | 1 | 'use client';
|
2 | 2 |
|
3 |
| -import React from 'react'; |
| 3 | +import React, { |
| 4 | + useCallback, |
| 5 | + useEffect, |
| 6 | + useMemo, |
| 7 | + useRef, |
| 8 | + type ComponentPropsWithoutRef, |
| 9 | +} from 'react'; |
| 10 | +import { assert } from 'ts-essentials'; |
4 | 11 |
|
5 |
| -import { type ClassValue, tcls } from '@/lib/tailwind'; |
| 12 | +interface TOCScrollContainerContextType { |
| 13 | + onContainerMount: (listener: (element: HTMLDivElement) => void) => () => void; |
| 14 | +} |
6 | 15 |
|
7 |
| -const TOCScrollContainerRefContext = React.createContext<React.RefObject<HTMLDivElement> | null>( |
8 |
| - null |
9 |
| -); |
| 16 | +const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null); |
10 | 17 |
|
11 |
| -function useTOCScrollContainerRefContext() { |
12 |
| - const ctx = React.useContext(TOCScrollContainerRefContext); |
13 |
| - if (!ctx) { |
14 |
| - throw new Error('Context `TOCScrollContainerRefContext` must be used within Provider'); |
15 |
| - } |
| 18 | +function useTOCScrollContainerContext() { |
| 19 | + const ctx = React.useContext(TOCScrollContainerContext); |
| 20 | + assert(ctx); |
16 | 21 | return ctx;
|
17 | 22 | }
|
18 | 23 |
|
19 |
| -export function TOCScrollContainer(props: { |
20 |
| - children: React.ReactNode; |
21 |
| - className?: ClassValue; |
22 |
| - style?: React.CSSProperties; |
23 |
| -}) { |
24 |
| - const { children, className, style } = props; |
25 |
| - const scrollContainerRef = React.createRef<HTMLDivElement>(); |
| 24 | +/** |
| 25 | + * Table of contents scroll container. |
| 26 | + */ |
| 27 | +export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) { |
| 28 | + const ref = useRef<HTMLDivElement>(null); |
| 29 | + const listeners = useRef<((element: HTMLDivElement) => void)[]>([]); |
| 30 | + const onContainerMount: TOCScrollContainerContextType['onContainerMount'] = useCallback( |
| 31 | + (listener) => { |
| 32 | + if (ref.current) { |
| 33 | + listener(ref.current); |
| 34 | + return () => {}; |
| 35 | + } |
| 36 | + listeners.current.push(listener); |
| 37 | + return () => { |
| 38 | + listeners.current = listeners.current.filter((l) => l !== listener); |
| 39 | + }; |
| 40 | + }, |
| 41 | + [] |
| 42 | + ); |
| 43 | + const value: TOCScrollContainerContextType = useMemo( |
| 44 | + () => ({ onContainerMount }), |
| 45 | + [onContainerMount] |
| 46 | + ); |
| 47 | + useEffect(() => { |
| 48 | + const element = ref.current; |
| 49 | + if (!element) { |
| 50 | + return; |
| 51 | + } |
| 52 | + listeners.current.forEach((listener) => listener(element)); |
| 53 | + return () => { |
| 54 | + listeners.current = []; |
| 55 | + }; |
| 56 | + }, []); |
26 | 57 |
|
27 | 58 | return (
|
28 |
| - <TOCScrollContainerRefContext.Provider value={scrollContainerRef}> |
29 |
| - <div |
30 |
| - ref={scrollContainerRef} |
31 |
| - data-testid="toc-scroll-container" |
32 |
| - className={tcls(className)} |
33 |
| - style={style} |
34 |
| - > |
35 |
| - {children} |
36 |
| - </div> |
37 |
| - </TOCScrollContainerRefContext.Provider> |
| 59 | + <TOCScrollContainerContext.Provider value={value}> |
| 60 | + <div ref={ref} data-testid="toc-scroll-container" {...props} /> |
| 61 | + </TOCScrollContainerContext.Provider> |
38 | 62 | );
|
39 | 63 | }
|
40 | 64 |
|
41 | 65 | // Offset to scroll the table of contents item by.
|
42 | 66 | const TOC_ITEM_OFFSET = 100;
|
43 | 67 |
|
44 | 68 | /**
|
45 |
| - * Scrolls the table of contents container to the page item when it becomes active |
| 69 | + * Scrolls the table of contents container to the page item when it's initially active. |
46 | 70 | */
|
47 | 71 | export function useScrollToActiveTOCItem(props: {
|
| 72 | + anchorRef: React.RefObject<HTMLAnchorElement>; |
48 | 73 | isActive: boolean;
|
49 |
| - linkRef: React.RefObject<HTMLAnchorElement>; |
50 | 74 | }) {
|
51 |
| - const { isActive, linkRef } = props; |
52 |
| - const scrollContainerRef = useTOCScrollContainerRefContext(); |
53 |
| - const isScrolled = React.useRef(false); |
54 |
| - React.useLayoutEffect(() => { |
55 |
| - if (!isActive) { |
56 |
| - isScrolled.current = false; |
57 |
| - return; |
58 |
| - } |
59 |
| - if (isScrolled.current) { |
60 |
| - return; |
61 |
| - } |
62 |
| - const tocItem = linkRef.current; |
63 |
| - const tocContainer = scrollContainerRef.current; |
64 |
| - if (!tocItem || !tocContainer || !isOutOfView(tocItem, tocContainer)) { |
65 |
| - return; |
| 75 | + const { isActive, anchorRef } = props; |
| 76 | + const isInitialActiveRef = useRef(isActive); |
| 77 | + const { onContainerMount } = useTOCScrollContainerContext(); |
| 78 | + useEffect(() => { |
| 79 | + const anchor = anchorRef.current; |
| 80 | + if (isInitialActiveRef.current && anchor) { |
| 81 | + return onContainerMount((container) => { |
| 82 | + if (isOutOfView(anchor, container)) { |
| 83 | + container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET }); |
| 84 | + } |
| 85 | + }); |
66 | 86 | }
|
67 |
| - tocContainer?.scrollTo({ |
68 |
| - top: tocItem.offsetTop - TOC_ITEM_OFFSET, |
69 |
| - }); |
70 |
| - isScrolled.current = true; |
71 |
| - }, [isActive, linkRef, scrollContainerRef]); |
| 87 | + }, [onContainerMount, anchorRef]); |
72 | 88 | }
|
73 | 89 |
|
74 |
| -function isOutOfView(tocItem: HTMLElement, tocContainer: HTMLElement) { |
75 |
| - const tocItemTop = tocItem.offsetTop; |
76 |
| - const containerTop = tocContainer.scrollTop; |
77 |
| - const containerBottom = containerTop + tocContainer.clientHeight; |
| 90 | +function isOutOfView(element: HTMLElement, container: HTMLElement) { |
| 91 | + const tocItemTop = element.offsetTop; |
| 92 | + const containerTop = container.scrollTop; |
| 93 | + const containerBottom = containerTop + container.clientHeight; |
78 | 94 | return (
|
79 | 95 | tocItemTop < containerTop + TOC_ITEM_OFFSET ||
|
80 | 96 | tocItemTop > containerBottom - TOC_ITEM_OFFSET
|
|
0 commit comments