diff --git a/.changeset/lovely-hats-peel.md b/.changeset/lovely-hats-peel.md new file mode 100644 index 000000000..92051b6f2 --- /dev/null +++ b/.changeset/lovely-hats-peel.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Automatically scroll to active item in TOC diff --git a/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx b/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx new file mode 100644 index 000000000..24382fbf3 --- /dev/null +++ b/packages/gitbook/src/components/TableOfContents/TOCScroller.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; + +import { ClassValue, tcls } from '@/lib/tailwind'; + +const TOCScrollContainerRefContext = React.createContext | null>( + null, +); + +function useTOCScrollContainerRefContext() { + const ctx = React.useContext(TOCScrollContainerRefContext); + if (!ctx) { + throw new Error('Context `TOCScrollContainerRefContext` must be used within Provider'); + } + return ctx; +} + +export function TOCScrollContainer(props: { children: React.ReactNode; className?: ClassValue }) { + const { children, className } = props; + const scrollContainerRef = React.createRef(); + + return ( + +
+ {children} +
+
+ ); +} + +// Offset to scroll the table of contents item by. +const TOC_ITEM_OFFSET = 200; + +/** + * Scrolls the table of contents container to the page item when it becomes active + */ +export function useScrollToActiveTOCItem(props: { + isActive: boolean; + linkRef: React.RefObject; +}) { + const { isActive, linkRef } = props; + const scrollContainerRef = useTOCScrollContainerRefContext(); + const isScrolled = React.useRef(false); + React.useLayoutEffect(() => { + if (!isActive) { + isScrolled.current = false; + return; + } + if (isScrolled.current) { + return; + } + const tocItem = linkRef.current; + const tocContainer = scrollContainerRef.current; + if (!tocItem || !tocContainer || !isOutOfView(tocItem, tocContainer)) { + return; + } + tocContainer?.scrollTo({ + top: tocItem.offsetTop - TOC_ITEM_OFFSET, + }); + isScrolled.current = true; + }, [isActive, linkRef, scrollContainerRef]); +} + +function isOutOfView(tocItem: HTMLElement, tocContainer: HTMLElement) { + const tocItemTop = tocItem.offsetTop; + const containerTop = tocContainer.scrollTop; + const containerBottom = containerTop + tocContainer.clientHeight; + return tocItemTop < containerTop || tocItemTop > containerBottom; +} diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 4fec88bac..58dcd07e6 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -13,6 +13,7 @@ import { ContentRefContext } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; import { PagesList } from './PagesList'; +import { TOCScrollContainer } from './TOCScroller'; import { Trademark } from './Trademark'; export function TableOfContents(props: { @@ -55,7 +56,7 @@ export function TableOfContents(props: { )} > {header ? header : null} -
) : null} -
+ ); } diff --git a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx index 6610291b3..c04679a5b 100644 --- a/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/ToggleableLinkItem.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { tcls } from '@/lib/tailwind'; +import { useScrollToActiveTOCItem } from './TOCScroller'; +import { useIsMounted } from '../hooks'; import { Link } from '../primitives'; const show = { @@ -46,6 +48,7 @@ export function ToggleableLinkItem(props: { const [scope, animate] = useAnimate(); const [isVisible, setIsVisible] = React.useState(hasActiveDescendant); + const isMounted = useIsMounted(); // Update the visibility of the children, if we are navigating to a descendant. React.useEffect(() => { @@ -59,10 +62,9 @@ export function ToggleableLinkItem(props: { // Animate the visibility of the children // only after the initial state. React.useEffect(() => { - if (!mountedRef.current || !hasDescendants) { + if (!isMounted || !hasDescendants) { return; } - try { animate(scope.current, isVisible ? show : hide, { duration: 0.1, @@ -84,17 +86,15 @@ export function ToggleableLinkItem(props: { // The selector can crash in some browsers, we ignore it as the animation is not critical. console.error(error); } - }, [isVisible, hasDescendants, animate, scope]); + }, [isVisible, isMounted, hasDescendants, animate, scope]); - // Track if the component is mounted. - const mountedRef = React.useRef(false); - React.useEffect(() => { - mountedRef.current = true; - }, []); + const linkRef = React.createRef(); + useScrollToActiveTOCItem({ linkRef, isActive }); return (