Render TOCScrollContent only once

This commit is contained in:
Nolann Biron
2025-06-09 18:03:50 +02:00
parent 7b6dff7a9d
commit eef6d2f391
5 changed files with 131 additions and 75 deletions
@@ -5,15 +5,13 @@ import React from 'react';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
import { SearchButton, SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents';
import { TOCScrollContent, TableOfContents } from '@/components/TableOfContents';
import { CONTAINER_STYLE } from '@/components/layout';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { tcls } from '@/lib/tailwind';
import { MobileMenuSheet } from '@/components/MobileMenu';
import { TOCScrollContent } from '@/components/TableOfContents/TOCScrollContent';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
@@ -68,27 +66,6 @@ export function SpaceLayout(props: {
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} />
<MobileMenuSheet>
<TOCScrollContent
innerHeader={
isMultiVariants ? (
<SpacesDropdown
withPortal={false}
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex',
'h-8'
)}
/>
) : null
}
context={context}
/>
</MobileMenuSheet>
<div className="scroll-nojump">
<div
className={tcls(
@@ -104,7 +81,6 @@ export function SpaceLayout(props: {
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
@@ -122,48 +98,59 @@ export function SpaceLayout(props: {
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled
? 'search_or_ask'
: 'search'
)}
...
</span>
</SearchButton>
</React.Suspense>
</div>
)}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(context, sections)}
/>
)}
{isMultiVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex',
'mb-2'
>
<TOCScrollContent
context={context}
innerHeader={
!withTopHeader || isMultiVariants ? (
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled
? 'search_or_ask'
: 'search'
)}
...
</span>
</SearchButton>
</React.Suspense>
</div>
)}
/>
)}
</>
}
/>
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(
context,
sections
)}
/>
)}
{isMultiVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls(
'w-full',
'page-no-toc:hidden',
'site-header-none:page-no-toc:flex',
'mb-2',
// Set the height to match the close button of the mobile menu sheet
'max-lg:h-8'
)}
/>
)}
</>
) : null
}
/>
</TableOfContents>
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
</div>
@@ -40,8 +40,8 @@ export function TOCScrollContent(props: {
<TOCScrollContainer // The scrollview inside the sidebar
className={tcls(
'flex flex-grow flex-col p-2',
innerHeader ? 'mt-0 lg:mt-2' : 'mt-8',
customization.trademark.enabled && 'pb-20',
innerHeader && 'mt-0 lg:mt-2',
customization.trademark.enabled && 'pb-[4.5rem]',
'gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
@@ -1,16 +1,23 @@
import type { GitBookSiteContext } from '@v2/lib/context';
'use client';
import type React from 'react';
import { TOCScrollContent } from '@/components/TableOfContents/TOCScrollContent';
import { MobileMenuSheet } from '@/components/MobileMenu';
import { useIsMobile } from '@/hooks/useIsMobile';
import { tcls } from '@/lib/tailwind';
import { TableOfContentsScript } from './TableOfContentsScript';
export function TableOfContents(props: {
context: GitBookSiteContext;
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list
children: React.ReactNode;
}) {
const { innerHeader, context, header } = props;
const { header, children } = props;
const isMobile = useIsMobile();
// If the screen is mobile, we use the mobile menu sheet to display the table of contents.
if (isMobile) {
return <MobileMenuSheet>{children}</MobileMenuSheet>;
}
return (
<>
@@ -65,7 +72,7 @@ export function TableOfContents(props: {
)}
>
{header && header}
<TOCScrollContent context={context} innerHeader={innerHeader} />
{children}
</aside>
<TableOfContentsScript />
</>
@@ -2,3 +2,4 @@ export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark';
export { TOCScrollContent } from './TOCScrollContent';
+61
View File
@@ -0,0 +1,61 @@
import { useSyncExternalStore } from 'react';
/**
* Hook to check if the screen is mobile
* @default breakpoint => 1024
*/
export function useIsMobile(breakpoint = 1024) {
const store = getMediaQueryStore(breakpoint);
return useSyncExternalStore(
(cb) => {
store.subscribers.add(cb);
return () => store.subscribers.delete(cb);
},
() => store.isMatch,
() => false
);
}
type MediaQueryStore = {
/** Latest match result */
isMatch: boolean;
/** The native MediaQueryList object */
mediaQueryList: MediaQueryList;
/** React subscribers that need re-rendering on change */
subscribers: Set<() => void>;
};
const mediaQueryStores: Record<string, MediaQueryStore> = {};
/**
* getMediaQueryStore("(max-width: 1024px)")
* Returns a singleton store for that query,
* creating it (and its listener) the first time.
*/
function getMediaQueryStore(breakpoint: number): MediaQueryStore {
// If the store already exists, return it
if (mediaQueryStores[breakpoint]) return mediaQueryStores[breakpoint];
const queryString = `(max-width: ${breakpoint - 0.1}px)`;
const mqList =
typeof window !== 'undefined' ? window.matchMedia(queryString) : ({} as MediaQueryList);
const store: MediaQueryStore = {
isMatch: typeof window !== 'undefined' ? mqList.matches : false,
mediaQueryList: mqList,
subscribers: new Set(),
};
const update = () => {
store.isMatch = mqList.matches;
store.subscribers.forEach((cb) => cb());
};
if (mqList.addEventListener) mqList.addEventListener('change', update);
// For Safari < 14
else if (mqList.addListener) mqList.addListener(update);
mediaQueryStores[breakpoint] = store;
return store;
}