mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 02:53:29 +00:00
Add navigation loader and rename context provider (#3642)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io> Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
This commit is contained in:
@@ -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 (
|
||||
<TranslateContext.Provider value={language}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<HashProvider>
|
||||
<NavigationStatusProvider>
|
||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
||||
</HashProvider>
|
||||
</NavigationStatusProvider>
|
||||
</TooltipProvider>
|
||||
</TranslateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
<SpaceLayoutServerContext {...props}>
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
|
||||
<NavigationLoader />
|
||||
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
|
||||
<AIChat trademark={customization.trademark.enabled} />
|
||||
) : null}
|
||||
|
||||
@@ -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<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>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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,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<HTMLAnchorElement>) => {
|
||||
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
|
||||
if (!isExternal) {
|
||||
updateHashFromUrl(href);
|
||||
onNavigationClick(href);
|
||||
}
|
||||
|
||||
if (insights) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user