Skip to content

Commit

Permalink
RND-2147: automatically scroll TOC (#2478)
Browse files Browse the repository at this point in the history
  • Loading branch information
BrettJephson committed Sep 24, 2024
1 parent 73892f4 commit 042b850
Show file tree
Hide file tree
Showing 4 changed files with 86 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-hats-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Automatically scroll to active item in TOC
70 changes: 70 additions & 0 deletions packages/gitbook/src/components/TableOfContents/TOCScroller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import React from 'react';

import { ClassValue, tcls } from '@/lib/tailwind';

const TOCScrollContainerRefContext = React.createContext<React.RefObject<HTMLDivElement> | 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<HTMLDivElement>();

return (
<TOCScrollContainerRefContext.Provider value={scrollContainerRef}>
<div ref={scrollContainerRef} className={tcls(className)}>
{children}
</div>
</TOCScrollContainerRefContext.Provider>
);
}

// 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<HTMLAnchorElement>;
}) {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -55,7 +56,7 @@ export function TableOfContents(props: {
)}
>
{header ? header : null}
<div
<TOCScrollContainer
className={tcls(
withHeaderOffset ? 'pt-4' : ['pt-4', 'lg:pt-0'],
'hidden',
Expand Down Expand Up @@ -87,7 +88,7 @@ export function TableOfContents(props: {
{customization.trademark.enabled ? (
<Trademark space={space} customization={customization} />
) : null}
</div>
</TOCScrollContainer>
</aside>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
Expand All @@ -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<HTMLAnchorElement>();
useScrollToActiveTOCItem({ linkRef, isActive });

return (
<div>
<Link
ref={linkRef}
href={href}
aria-selected={isActive}
className={tcls(
Expand Down

0 comments on commit 042b850

Please sign in to comment.