Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: {
return (
<TranslateContext.Provider value={language}>
<TooltipProvider delayDuration={200}>
<HashProvider>
<NavigationStatusProvider>
<LoadingStateProvider>{children}</LoadingStateProvider>
</HashProvider>
</NavigationStatusProvider>
</TooltipProvider>
</TranslateContext.Provider>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -125,6 +126,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<SpaceLayoutServerContext {...props}>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
<NavigationLoader />
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
) : null}
Expand Down
83 changes: 71 additions & 12 deletions packages/gitbook/src/components/hooks/useHash.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,20 +30,62 @@ function getHash(): string | null {
return window.location.hash.slice(1);
}

export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [hash, setHash] = React.useState<string | null>(getHash);
const updateHashFromUrl = React.useCallback((href: string) => {
const [isNavigating, setIsNavigating] = React.useState(false);
const timeoutRef = React.useRef<number | null>(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 (
<NavigationStatusContext.Provider value={memoizedValue}>
{children}
</NavigationStatusContext.Provider>
);
return <HashContext.Provider value={memoizedValue}>{children}</HashContext.Provider>;
};

/**
Expand All @@ -45,8 +96,16 @@ export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ 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;
}
6 changes: 3 additions & 3 deletions packages/gitbook/src/components/primitives/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -81,7 +81,7 @@ export const Link = React.forwardRef(function Link(
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
if (!isExternal) {
updateHashFromUrl(href);
onNavigationClick(href);
}

if (insights) {
Expand Down
18 changes: 18 additions & 0 deletions packages/gitbook/src/components/primitives/NavigationLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { useIsNavigating } from '../hooks';

export const NavigationLoader = () => {
const isNavigating = useIsNavigating();

return (
<div
className={tcls(
'pointer-events-none fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden',
isNavigating ? 'block' : 'hidden animate-fade-out-slow'
)}
>
<div className={tcls('h-full w-full origin-left animate-crawl bg-primary-solid')} />
</div>
);
};
15 changes: 15 additions & 0 deletions packages/gitbook/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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)',
Expand Down