diff --git a/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx b/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx index 68a95dde55..1ac89b8176 100644 --- a/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx +++ b/packages/gitbook/src/components/RootLayout/RootLayoutClientContexts.tsx @@ -5,7 +5,7 @@ import type React from 'react'; import { TranslateContext } from '@/intl/client'; import type { TranslationLanguage } from '@/intl/translations'; import { TooltipProvider } from '@radix-ui/react-tooltip'; -import { HashProvider } from '../hooks'; +import { NavigationStatusProvider } from '../hooks'; import { LoadingStateProvider } from '../primitives/LoadingStateProvider'; /** @@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: { return ( - + {children} - + ); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index a9be339da8..f1376f1a7b 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -25,6 +25,7 @@ import { InsightsProvider, VisitorSessionProvider } from '../Insights'; import { SearchContainer } from '../Search'; import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { CurrentContentProvider } from '../hooks'; +import { NavigationLoader } from '../primitives/NavigationLoader'; import { SpaceLayoutContextProvider } from './SpaceLayoutContext'; type SpaceLayoutProps = { @@ -125,6 +126,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
+ {customization.ai?.mode === CustomizationAIMode.Assistant ? ( ) : null} diff --git a/packages/gitbook/src/components/hooks/useHash.tsx b/packages/gitbook/src/components/hooks/useHash.tsx index efcb8249a2..1c3957ab2e 100644 --- a/packages/gitbook/src/components/hooks/useHash.tsx +++ b/packages/gitbook/src/components/hooks/useHash.tsx @@ -1,17 +1,26 @@ 'use client'; + +import { usePathname } from 'next/navigation'; import React from 'react'; -export const HashContext = React.createContext<{ +export const NavigationStatusContext = React.createContext<{ hash: string | null; /** - * Updates the hash value from the URL provided here. - * It will then be used by the `useHash` hook. + * Updates the navigation state from the URL provided here. * URL can be relative or absolute. */ - updateHashFromUrl: (href: string) => void; + onNavigationClick: (href: string) => void; + /** + * Indicates if a link has been clicked recently. + * Becomes true after a click and resets to false when pathname changes. + * It is debounced to avoid flickering on fast navigations. + * Debounce time is 400ms (= doherty threshold for responsiveness). + */ + isNavigating: boolean; }>({ hash: null, - updateHashFromUrl: () => {}, + onNavigationClick: () => {}, + isNavigating: false, }); function getHash(): string | null { @@ -21,20 +30,62 @@ function getHash(): string | null { return window.location.hash.slice(1); } -export const HashProvider: React.FC> = ({ children }) => { +export const NavigationStatusProvider: React.FC = ({ children }) => { const [hash, setHash] = React.useState(getHash); - const updateHashFromUrl = React.useCallback((href: string) => { + const [isNavigating, setIsNavigating] = React.useState(false); + const timeoutRef = React.useRef(null); + const pathname = usePathname(); + const pathnameRef = React.useRef(pathname); + + // Reset isNavigating when pathname changes + React.useEffect(() => { + if (pathnameRef.current !== pathname) { + setIsNavigating(false); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + pathnameRef.current = pathname; + } + }, [pathname]); + + // Cleanup timeout on unmount + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const onNavigationClick = React.useCallback((href: string) => { const url = new URL( href, typeof window !== 'undefined' ? window.location.origin : 'http://localhost' ); setHash(url.hash.slice(1)); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (pathnameRef.current !== url.pathname) { + timeoutRef.current = window.setTimeout(() => { + setIsNavigating(true); + timeoutRef.current = null; + return; + }, 400); // 400ms timeout - doherty threshold for responsiveness + } }, []); + const memoizedValue = React.useMemo( - () => ({ hash, updateHashFromUrl }), - [hash, updateHashFromUrl] + () => ({ hash, onNavigationClick, isNavigating }), + [hash, onNavigationClick, isNavigating] + ); + return ( + + {children} + ); - return {children}; }; /** @@ -45,8 +96,16 @@ export const HashProvider: React.FC> = ({ children } * Since we have a single Link component that handles all links, we can use a context to share the hash. */ export function useHash() { - // const params = useParams(); - const { hash } = React.useContext(HashContext); + const { hash } = React.useContext(NavigationStatusContext); return hash; } + +/** + * Hook to get the current navigation state. + * @returns True if a navigation has been triggered recently. False otherwise, it also resets to false when the navigation is complete. + */ +export function useIsNavigating() { + const { isNavigating: hasBeenClicked } = React.useContext(NavigationStatusContext); + return hasBeenClicked; +} diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index c09180173b..722b7fdb80 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { tcls } from '@/lib/tailwind'; import { SiteExternalLinksTarget } from '@gitbook/api'; import { type TrackEventInput, useTrackEvent } from '../Insights'; -import { HashContext } from '../hooks'; +import { NavigationStatusContext } from '../hooks'; import { isExternalLink } from '../utils/link'; import { type DesignTokenName, useClassnames } from './StyleProvider'; @@ -72,7 +72,7 @@ export const Link = React.forwardRef(function Link( ) { const { href, prefetch, children, insights, classNames, className, ...domProps } = props; const { externalLinksTarget } = React.useContext(LinkSettingsContext); - const { updateHashFromUrl } = React.useContext(HashContext); + const { onNavigationClick } = React.useContext(NavigationStatusContext); const trackEvent = useTrackEvent(); const forwardedClassNames = useClassnames(classNames || []); const isExternal = isExternalLink(href); @@ -81,7 +81,7 @@ export const Link = React.forwardRef(function Link( const onClick = (event: React.MouseEvent) => { const isExternalWithOrigin = isExternalLink(href, window.location.origin); if (!isExternal) { - updateHashFromUrl(href); + onNavigationClick(href); } if (insights) { diff --git a/packages/gitbook/src/components/primitives/NavigationLoader.tsx b/packages/gitbook/src/components/primitives/NavigationLoader.tsx new file mode 100644 index 0000000000..eca15e2fda --- /dev/null +++ b/packages/gitbook/src/components/primitives/NavigationLoader.tsx @@ -0,0 +1,18 @@ +'use client'; +import { tcls } from '@/lib/tailwind'; +import { useIsNavigating } from '../hooks'; + +export const NavigationLoader = () => { + const isNavigating = useIsNavigating(); + + return ( +
+
+
+ ); +}; diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 87d42fdeaf..c6b075ef20 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -329,6 +329,7 @@ const config: Config = { exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both', heightIn: 'heightIn 200ms ease both', + crawl: 'crawl 2s ease-in-out infinite', }, keyframes: { bounceSmall: { @@ -498,6 +499,20 @@ const config: Config = { from: { height: '0' }, to: { height: 'max-content' }, }, + crawl: { + '0%': { + scale: '0 1', + translate: '0 0', + }, + '40%': { + scale: '1 1', + translate: '100% 0', + }, + '100%': { + scale: '0 1', + translate: '100% 0', + }, + }, }, boxShadow: { thinbottom: '0px 1px 0px rgba(0, 0, 0, 0.05)',