Add buttons to hide toolbar

This commit is contained in:
Viktor Renkema
2025-10-03 11:47:21 +02:00
parent 174e7f43a2
commit bddd458446
12 changed files with 1622 additions and 2798 deletions
+1 -3
View File
@@ -17,7 +17,5 @@
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"chatgpt.openOnStartup": true,
"chatgpt.commentCodeLensEnabled": false
}
}
+1312 -2261
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -5,7 +5,6 @@
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"tweakpane": "^4.0.4",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.2.15",
@@ -1,12 +1,11 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig } from 'motion/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { DateRelative, Tooltip } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -20,50 +19,58 @@ import {
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import type { AdminToolbarClientProps } from './types';
import { ToolbarControlsProvider } from './ToolbarControlsContext';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context } = props;
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
const [minified, setMinified] = React.useState(true);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const visitorSession = useVisitorSession();
const [sessionClosed, setSessionClosed] = React.useState(false);
const [shouldHide, setShouldHide] = React.useState(false);
React.useEffect(() => {
const uiParam = searchParams?.get('ui');
const STORAGE_KEY = 'gitbook_toolbar_closed';
if (uiParam === 'true' || uiParam === '1') {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
try {
const params = new URLSearchParams(searchParams?.toString() || '');
params.delete('ui');
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
} catch {}
setShouldHide(false);
return;
}
if (uiParam === 'false' || uiParam === '0') {
setShouldHide(true);
return;
}
try {
const hidden = !!localStorage.getItem(STORAGE_KEY);
setShouldHide(hidden);
} catch {
setShouldHide(false);
}
}, [pathname, router, searchParams]);
}, []);
const handleSessionClose = React.useCallback(() => {
setSessionClosed(true);
onSessionClose?.();
}, [onSessionClose]);
const handlePersistentClose = React.useCallback(() => {
try {
localStorage.setItem('gitbook_toolbar_closed', '1');
} catch {
console.error('Failed to close toolbar using local storage');
}
setSessionClosed(true);
onPersistentClose?.();
}, [onPersistentClose]);
const handleMinifiedChange = React.useCallback(
(value: boolean) => {
setMinified(value);
onToggleMinify?.();
},
[onToggleMinify]
);
const toolbarControls = React.useMemo(
() => ({
minimize: () => handleMinifiedChange(true),
closeSession: handleSessionClose,
closePersistent: handlePersistentClose,
}),
[handleMinifiedChange, handleSessionClose, handlePersistentClose]
);
if (shouldHide || sessionClosed) {
return null;
@@ -72,49 +79,63 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<ChangeRequestToolbar context={context} />
</MotionConfig>
</IframeWrapper>
<ToolbarControlsProvider value={toolbarControls}>
<IframeWrapper>
<MotionConfig reducedMotion="user">
<ChangeRequestToolbar
context={context}
minified={minified}
onMinifiedChange={handleMinifiedChange}
/>
</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<RevisionToolbar context={context} />
</MotionConfig>
</IframeWrapper>
<ToolbarControlsProvider value={toolbarControls}>
<IframeWrapper>
<MotionConfig reducedMotion="user">
<RevisionToolbar
context={context}
minified={minified}
onMinifiedChange={handleMinifiedChange}
/>
</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitorSession?.organizationId === context.organizationId) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<AuthenticatedUserToolbar
context={context}
onSessionClose={() => setSessionClosed(true)}
onPersistentClose={() => {
try {
localStorage.setItem('gitbook_toolbar_closed', '1');
} catch {}
setSessionClosed(true);
}}
onToggleMinify={() => setMinified((prev) => !prev)}
/>
</MotionConfig>
</IframeWrapper>
<ToolbarControlsProvider value={toolbarControls}>
<IframeWrapper>
<MotionConfig reducedMotion="user">
<AuthenticatedUserToolbar
context={context}
minified={minified}
onMinifiedChange={handleMinifiedChange}
/>
</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
}
function ChangeRequestToolbar(props: AdminToolbarClientProps) {
const { context } = props;
interface ToolbarViewProps {
context: AdminToolbarContext;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
function ChangeRequestToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
@@ -127,7 +148,7 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
});
return (
<Toolbar label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -144,7 +165,7 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
<ToolbarSeparator />
<ToolbarButtonGroup>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -185,13 +206,13 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
})}
icon="code-pull-request"
/>
</ToolbarButtonGroup>
</ToolbarActions>
</Toolbar>
);
}
function RevisionToolbar(props: AdminToolbarClientProps) {
const { context } = props;
function RevisionToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
@@ -202,19 +223,21 @@ function RevisionToolbar(props: AdminToolbarClientProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar label="Site preview">
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Tooltip label="Site preview">
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
</Tooltip>
<ToolbarSeparator />
<ToolbarButtonGroup>
<ToolbarActions>
{/* Open commit in Git client */}
<ToolbarButton
title={
@@ -262,32 +285,34 @@ function RevisionToolbar(props: AdminToolbarClientProps) {
})}
icon="code-commit"
/>
</ToolbarButtonGroup>
</ToolbarActions>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
const { context } = props;
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar label="Only visible to your GitBook organization">
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Tooltip label="Site preview">
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
</Tooltip>
<ToolbarSeparator />
<ToolbarButtonGroup>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -326,24 +351,22 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
})}
icon="chart-simple"
/>
{/* Toolbar settings */}
<HideToolbarButton
onSessionClose={() => {
props.onSessionClose?.();
}}
onPersistentClose={() => {
props.onPersistentClose?.();
}}
onMinify={() => {
props.onToggleMinify?.();
}}
/>
</ToolbarButtonGroup>
</ToolbarActions>
</Toolbar>
);
}
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
@@ -1,9 +1,9 @@
.svgLogo {
--logo-fill: var(--color-neutral-100);
--logo-fill: var(--color-neutral-900);
--seg-A-color: #2782c4;
--seg-B-color: #43b7f2;
--seg-C-color: #8be2ff;
--trace-color: #46474c;
--trace-color: #dedfe3;
--T: 2s;
@@ -12,8 +12,8 @@
}
:global(html.dark) .svgLogo {
--logo-fill: var(--color-neutral-800);
--trace-color: #dedfe3;
--logo-fill: var(--color-neutral-100);
--trace-color: #46474c;
}
/* Base segment animation */
@@ -1,146 +0,0 @@
'use client';
import React from 'react';
import type { FanConfig } from './useFanConfig';
const STYLE_ID = 'fan-config-pane-tweakpane';
const TWEAKPANE_CSS_URL = 'https://cdn.jsdelivr.net/npm/tweakpane@4.0.5/dist/tweakpane.min.css';
export function FanConfigPane(props: {
config: FanConfig;
setConfig: React.Dispatch<React.SetStateAction<FanConfig>>;
visible?: boolean;
}) {
const { config, setConfig, visible = false } = props;
const hostRef = React.useRef<HTMLDivElement | null>(null);
const paneRef = React.useRef<any>(null);
const paneStateRef = React.useRef<FanConfig | null>(null);
const cssLoadedRef = React.useRef<boolean>(false);
// Load tweakpane CSS on demand (only once)
React.useEffect(() => {
if (!visible || cssLoadedRef.current) return;
if (document.getElementById(STYLE_ID)) {
cssLoadedRef.current = true;
return;
}
const link = document.createElement('link');
link.id = STYLE_ID;
link.rel = 'stylesheet';
link.href = TWEAKPANE_CSS_URL;
document.head.appendChild(link);
cssLoadedRef.current = true;
}, [visible]);
// Initialise tweakpane when visible
React.useEffect(() => {
if (!visible || !hostRef.current || paneRef.current) return;
let disposed = false;
const setup = async () => {
const mod: any = await import('tweakpane');
if (disposed) return;
const PaneCtor = mod.Pane ?? mod.default?.Pane ?? mod;
// Create a clean object with values for Tweakpane
const paneValues = {
arcWidth: Number(config.arcWidth) || 220,
arcHeight: Number(config.arcHeight) || 380,
arcRadius: Number(config.arcRadius) || 220,
startOffset: Number(config.startOffset) || 220,
spread: Number(config.spread) || 380,
rotationOffsetDeg: Number(config.rotationOffsetDeg) || 0,
staggerMs: Number(config.staggerMs) || 90,
speed: Number(config.speed) || 1,
debug: Boolean(config.debug),
};
paneStateRef.current = paneValues;
const pane = new PaneCtor({ container: hostRef.current, title: 'Fan Config' });
paneRef.current = pane;
const sliderBindings: Array<{
key: keyof FanConfig;
options: any;
}> = [
{ key: 'arcWidth', options: { label: 'Ellipse width', min: 80, max: 600, step: 5 } },
{ key: 'arcHeight', options: { label: 'Ellipse height', min: 80, max: 600, step: 5 } },
{ key: 'arcRadius', options: { label: 'Ellipse radius', min: 0, max: 50, step: 5 } },
{ key: 'startOffset', options: { label: 'Start offset', min: -600, max: 600, step: 5 } },
{ key: 'spread', options: { label: 'Spread', min: -200, max: 200, step: 5 } },
{
key: 'rotationOffsetDeg',
options: { label: 'Rotation offset (deg)', min: -90, max: 90, step: 1 },
},
{ key: 'staggerMs', options: { label: 'Stagger (ms)', min: 0, max: 250, step: 5 } },
{ key: 'speed', options: { label: 'Speed', min: 0.25, max: 3, step: 0.05 } },
];
sliderBindings.forEach(({ key, options }) => {
const binding = pane.addBinding(paneValues, key as string, options);
binding.on('change', (ev: any) => {
if (paneStateRef.current) {
paneStateRef.current[key] = ev.value;
}
setConfig((prev) => ({ ...prev, [key]: ev.value }));
});
});
const debugBinding = pane.addBinding(paneValues, 'debug', { label: 'Debug path' });
debugBinding.on('change', (ev: any) => {
if (paneStateRef.current) {
paneStateRef.current.debug = ev.value;
}
setConfig((prev) => ({ ...prev, debug: ev.value }));
});
};
setup();
return () => {
disposed = true;
if (paneRef.current) {
try {
paneRef.current.dispose();
} catch {}
paneRef.current = null;
paneStateRef.current = null;
}
};
}, [visible, setConfig]);
// Synchronise pane when config is updated externally
React.useEffect(() => {
if (!paneRef.current || !paneStateRef.current) return;
let refresh = false;
const state = paneStateRef.current;
(Object.keys(config) as Array<keyof FanConfig>).forEach((key) => {
if (state[key] !== config[key]) {
state[key] = config[key];
refresh = true;
}
});
if (refresh) {
try {
paneRef.current.refresh();
} catch {}
}
}, [config]);
if (!visible) return null;
return (
<div
style={{
position: 'fixed',
right: 400,
bottom: 12,
zIndex: 99999,
background: 'rgba(20,20,20,.85)',
borderRadius: 8,
padding: 6,
color: 'white',
}}
>
<div ref={hostRef} />
</div>
);
}
@@ -2,88 +2,93 @@
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { motion } from 'motion/react';
import React from 'react';
import { FanConfigPane } from './FanConfigPane';
import React, { useRef } from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { type FanConfig, useFanConfig } from './useFanConfig';
import { useToolbarControls } from './ToolbarControlsContext';
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
onSessionClose?: () => void; // hides for current session
onPersistentClose?: () => void; // stores preference in browser
onMinify?: () => void; // just minimize the toolbar
}
/**
* Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions.
*/
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues, onSessionClose, onPersistentClose, onMinify } = props;
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [config, setConfig] = useFanConfig();
const [showPane, setShowPane] = React.useState(false);
const controls = useToolbarControls();
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const handleClickOutsideArcMenu = (event: Event) => {
// Don't close the arc if we are clicking clicking on the button itself
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
setOpen(false);
};
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
React.useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.get('fan') === '1') setShowPane(true);
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'f') setShowPane((v) => !v);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
if (!open) return;
const items = [
{
id: 'persistent-close',
icon: 'eye-slash',
label: 'Close until enabled again',
description: 'Preference stored in the browser, clear your cache to reset',
onClick: () => onPersistentClose?.(),
},
{
id: 'session-close',
icon: 'x',
label: 'Close for one session',
description: 'Reopens next time you visit the site',
onClick: () => onSessionClose?.(),
},
{
id: 'minimize',
icon: 'minus',
label: 'Minimize',
description: 'Reduces the toolbar to its minimum size',
onClick: () => onMinify?.(),
},
];
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
// Create a stable fallback motion value
// const fallbackScale = useMotionValue(1);
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
// // Always call useTransform to avoid hook order issues
// const arcMagnificationScale = useTransform(
// motionValues?.scale || fallbackScale,
// (val) => val * 0.8
// );
const items = React.useMemo(
() =>
[
controls?.minimize
? {
id: 'minimize',
icon: 'minus',
label: 'Minimize',
onClick: controls.minimize,
}
: null,
controls?.closeSession
? {
id: 'session-close',
icon: 'xmark',
label: 'Close for one session',
onClick: controls.closeSession,
}
: null,
controls?.closePersistent
? {
id: 'persistent-close',
icon: 'ban',
label: "Don't show again",
onClick: controls.closePersistent,
}
: null,
].filter(Boolean) as Array<ArcMenuItem>,
[controls]
);
const sharedMotionStyle = motionValues
? {
// We also apply the magnification effect to the arc menu. However, we reduce the scaling factor to 0.8 so it's less intense.
// scale: arcMagnificationScale,
// scale: motionValues?.scale,
x: motionValues.x,
transformOrigin: 'bottom center',
}
: undefined;
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick={() => {
setOpen((v) => !v);
}}
motionValues={motionValues}
@@ -92,80 +97,68 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
{/* Expanding arc menu */}
{open && (
<motion.div
className={styles.arcMenu}
style={
{
...sharedMotionStyle,
'--start-distance': `${config.startOffset}px`,
'--spread-distance': `${config.spread}px`,
} as React.CSSProperties
}
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
>
<div
className={styles.arcMenuPath}
style={
{
'--arc-width': `${config.arcWidth}px`,
'--arc-height': `${config.arcHeight}px`,
'--arc-radius': `${config.arcRadius}%`,
width: `${config.arcWidth}px`,
height: `${config.arcHeight}px`,
border: config.debug ? '3px dashed red' : 'none',
} as React.CSSProperties
}
className={tcls(
'pointer-events-none absolute left-0 overflow-visible',
styles.arcMenuPath
)}
ref={ref}
>
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
config={config}
key={item.icon}
title={item.label}
{...item}
onClick={() => {
setOpen(false);
item.onClick?.();
}}
icon={item.icon as IconName}
/>
))}
</div>
</motion.div>
)}
{/* </div> */}
<FanConfigPane config={config} setConfig={setConfig} visible={showPane} />
</ToolbarButton>
);
}
export function ArcToolbarButton(
props: ToolbarButtonProps & {
index: number;
staggerIndex?: number;
config: FanConfig;
}
) {
type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
onClick?: () => void;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
disabled?: boolean;
className?: string;
iconClassName?: string;
};
export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
title,
label,
disabled,
className,
onClick,
onClick = () => {},
icon,
iconClassName,
config,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
const baseRotation = 95; // Starting rotation for index 0
const rotationStep = 18; // Degrees to subtract per index
// Simple linear decrease
return baseRotation - index * rotationStep;
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = calculateRotation();
@@ -174,17 +167,14 @@ export function ArcToolbarButton(
<div className="pointer-events-none">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
onClick={() => {
onClick();
}}
style={
{
'--target-offset-distance': targetOffset,
'--start-distance': `${config.startOffset}px`,
'--arc-duration': `${config.speed}s`,
'--arc-delay': `${(staggerIndex ?? 0) * config.staggerMs}ms`,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
@@ -194,63 +184,53 @@ export function ArcToolbarButton(
}
className={tcls(
'group',
'pointer-events-auto',
'absolute',
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex',
'w-8',
'h-8',
'shrink-0',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'border',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'shadow-1xs',
// Button background
'bg-neutral-800/90 hover:bg-neutral-800 dark:bg-neutral-900/80 dark:hover:bg-neutral-900',
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'hover:scale-105'
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgb(255_255_255_/_40%)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
boxShadow: 'rgba(255, 255, 255, 0.15) 0px 1px 1px 0px inset',
outline: 'unset',
border: '1px solid rgba(0, 0, 0, 0.06)',
boxShadow: 'rgba(255, 255, 255, 0.15) 0px 1px 1px 0px inset',
}}
>
<div
className="group-hover:-rotate-3 flex items-center justify-center rounded-full p-1 group-hover:scale-105"
style={{ background: 'linear-gradient(rgb(50, 52, 56), rgb(51, 53, 57))' }}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0', iconClassName)}
/>
</div>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg bg-neutral-800/90 px-3 py-1 font-normal text-neutral-3 text-sm transition-transform hover:bg-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-3 dark:hover:bg-neutral-900',
'group-hover:rotate-2 group-hover:scale-105 group-hover:bg-neutral-900 group-hover:text-neutral-1'
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{title}
{label}
</span>
</button>
</div>
@@ -1,19 +1,17 @@
.arcMenu {
position: absolute;
inset: 0;
pointer-events: none;
--arc-width: 505px;
--arc-height: 400px;
--arc-radius: 34%;
--start-distance: -240px;
--spread-distance: 45px;
}
.arcMenuPath {
position: absolute;
bottom: calc(var(--arc-height) / -2);
left: 0;
width: var(--arc-width);
height: var(--arc-height);
border-radius: var(--arc-radius);
transform: translate(0%, 0%);
pointer-events: none;
overflow: visible;
}
.arcMenuItem {
@@ -22,18 +20,10 @@
animation-fill-mode: forwards;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
position: absolute;
top: 0;
left: 0;
transform-origin: center left;
opacity: 0;
offset-path: border-box;
offset-anchor: 0% 0%;
offset-rotate: auto var(--rotation-offset, 0deg);
pointer-events: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
@keyframes hide-toolbar-arc-enter {
@@ -20,14 +20,12 @@ const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
interface ToolbarProps {
children: React.ReactNode;
label: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, label } = props;
const [minified, setMinified] = React.useState(true);
const [closed, setClosed] = React.useState(false);
const [showToolbarControls, setShowToolbarControls] = React.useState(false);
const { children, minified, onMinifiedChange } = props;
const [isReady, setIsReady] = React.useState(false);
// Wait for page to be ready, then show the toolbar
@@ -46,31 +44,29 @@ export function Toolbar(props: ToolbarProps) {
// After toolbar appears, wait then show the full content
React.useEffect(() => {
if (isReady) {
const expandAfterTimeout = setTimeout(() => {
setMinified(false);
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
return () => clearTimeout(expandAfterTimeout);
if (!isReady) {
return;
}
}, [isReady]);
const expandAfterTimeout = setTimeout(() => {
onMinifiedChange(false);
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
return () => clearTimeout(expandAfterTimeout);
}, [isReady, onMinifiedChange]);
// Don't render anything until page is ready
if (!isReady || closed) {
if (!isReady) {
return null;
}
return (
<motion.div
onMouseEnter={() => setShowToolbarControls(true)}
onMouseLeave={() => setShowToolbarControls(false)}
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setMinified((prev) => !prev);
onMinifiedChange(false);
}
}}
layout
@@ -84,11 +80,10 @@ export function Toolbar(props: ToolbarProps) {
'min-w-12',
'h-12',
'py-2',
'border-tint-1/3',
'backdrop-blur-sm',
'origin-center',
'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.89)_100%)]',
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(255,255,255,0)_0%,rgba(255,255,255,0.2)_100%)]'
)}
initial={{
scale: 1,
@@ -97,9 +92,6 @@ export function Toolbar(props: ToolbarProps) {
animate={{
scale: 1,
opacity: 1,
boxShadow: minified
? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)'
: '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)',
}}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
@@ -165,7 +157,7 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
children?: React.ReactNode;
}
export function ToolbarButton(props: ToolbarButtonProps) {
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
const {
title,
disabled,
@@ -181,7 +173,7 @@ export function ToolbarButton(props: ToolbarButtonProps) {
const reduceMotion = useReducedMotion();
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative">
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
<Tooltip label={title}>
<motion.a
@@ -200,7 +192,6 @@ export function ToolbarButton(props: ToolbarButtonProps) {
...style,
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
boxShadow: 'rgba(255, 255, 255, 0.15) 0px 1px 1px 0px inset',
outline: 'unset',
border: '1px solid rgba(0, 0, 0, 0.06)',
}
}
@@ -219,35 +210,30 @@ export function ToolbarButton(props: ToolbarButtonProps) {
'gap-1',
'text-sm',
'rounded-full',
'border-neutral-500',
'outline-neutral-800',
'outline-1',
'border',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'shadow-1xs'
disabled ? 'cursor-not-allowed opacity-50' : ''
)}
>
<div
className="flex items-center justify-center rounded-full p-1"
style={{ background: 'linear-gradient(rgb(50, 52, 56), rgb(51, 53, 57))' }}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-4', iconClassName)}
/>
</div>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
</motion.div>
);
}
});
ToolbarButton.displayName = 'ToolbarButton';
function ToolbarButtonWrapper(props: {
child: React.ReactElement;
@@ -300,10 +286,7 @@ export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
function ToolbarTitlePrefix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(0)}
className="truncate font-medium text-neutral-3 dark:text-neutral-2"
>
<motion.span {...getCopyVariants(0)} className="truncate font-medium text-neutral-12">
{props.title}
</motion.span>
);
@@ -311,10 +294,7 @@ function ToolbarTitlePrefix(props: { title: string }) {
function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[20ch] truncate text-neutral-3 dark:text-neutral-2"
>
<motion.span {...getCopyVariants(1)} className="max-w-[20ch] truncate text-neutral-12">
{props.title}
</motion.span>
);
@@ -322,10 +302,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-7 text-xxs dark:text-neutral-2"
>
<motion.span {...getCopyVariants(1)} className="text-neutral-12/90 text-xxs">
{props.subtitle}
</motion.span>
);
@@ -0,0 +1,21 @@
'use client';
import React from 'react';
export interface ToolbarControlsContextValue {
minimize: () => void;
closeSession?: () => void;
closePersistent?: () => void;
}
const ToolbarControlsContext = React.createContext<ToolbarControlsContextValue | null>(null);
export function ToolbarControlsProvider(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return <ToolbarControlsContext.Provider value={value}>{children}</ToolbarControlsContext.Provider>;
}
export function useToolbarControls() {
return React.useContext(ToolbarControlsContext);
}
@@ -1,47 +0,0 @@
'use client';
import React from 'react';
export type FanConfig = {
arcWidth: number;
arcHeight: number;
arcRadius: number;
spread: number;
startOffset: number;
rotationOffsetDeg: number;
staggerMs: number;
speed: number; // 1 = baseline, >1 faster, <1 slower
debug: boolean;
};
export const defaultFanConfig: FanConfig = {
arcWidth: 505,
arcHeight: 400,
arcRadius: 35,
spread: 45,
startOffset: -250,
rotationOffsetDeg: 80,
staggerMs: 80,
speed: 0.4,
debug: false,
};
const STORAGE_KEY = 'gitbook_toolbar_fan_config';
export function useFanConfig(): [FanConfig, React.Dispatch<React.SetStateAction<FanConfig>>] {
const [config, setConfig] = React.useState<FanConfig>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? { ...defaultFanConfig, ...JSON.parse(raw) } : defaultFanConfig;
} catch {
return defaultFanConfig;
}
});
React.useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {}
}, [config]);
return [config, setConfig];
}
@@ -1,11 +1,6 @@
import { type MotionValue, motionValue } from 'framer-motion';
import { type MotionValue, motionValue } from 'motion/react';
import React from 'react';
interface ButtonMotionValues {
scale: MotionValue<number>;
x: MotionValue<number>;
}
interface MagnificationConfig {
/** Size of each button in pixels - used for spacing calculations */
buttonSize?: number;
@@ -30,13 +25,6 @@ const defaultConfig: Required<MagnificationConfig> = {
padding: 10, // Small buffer zone around container edges
};
// Helper functions for cleaner code
const createMotionValues = (count: number): ButtonMotionValues[] =>
Array.from({ length: count }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
const resetMotionValues = (
motionValues: Array<{ scale: MotionValue<number>; x: MotionValue<number> }>
) => {
@@ -64,9 +52,7 @@ const captureButtonPositions = (buttons: HTMLElement[]) => {
const calculateScale = (
mouseX: number,
mouseY: number,
buttonCenterX: number,
buttonCenterY: number,
containerRect: DOMRect,
config: Required<MagnificationConfig>
) => {
@@ -212,15 +198,7 @@ export function useMagnificationEffect(props: {
if (!pos) return { scale: 1, translateX: 0 };
const buttonCenterX = pos.left + pos.width / 2;
const buttonCenterY = pos.top + pos.height / 2;
const scale = calculateScale(
mouseX,
mouseY,
buttonCenterX,
buttonCenterY,
containerRect,
finalConfig
);
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
return { scale, translateX: 0 };
});