This commit is contained in:
Viktor Renkema
2025-10-02 10:50:23 +02:00
parent f8c54b4d34
commit 174e7f43a2
11 changed files with 754 additions and 131 deletions
+3 -1
View File
@@ -17,5 +17,7 @@
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
},
"chatgpt.openOnStartup": true,
"chatgpt.commentCodeLensEnabled": false
}
+3
View File
@@ -7,6 +7,7 @@
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"tweakpane": "^4.0.4",
"vercel": "^39.3.0",
},
},
@@ -2884,6 +2885,8 @@
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OUHCV+ueXa3UzfZ4co/ueIHgeq9B2K48pZwIxKSm5VaLVuv8M13MhM7unukW09g++dpdrrE1w4IOVgxKZ0/exg=="],
"tweakpane": ["tweakpane@4.0.5", "", {}, "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
"type-fest": ["type-fest@4.40.0", "", {}, "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw=="],
+1
View File
@@ -5,6 +5,7 @@
"@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,10 +1,13 @@
'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 { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
@@ -21,7 +24,50 @@ import type { AdminToolbarClientProps } from './types';
export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context } = 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]);
if (shouldHide || sessionClosed) {
return null;
}
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
@@ -50,7 +96,17 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
return (
<IframeWrapper>
<MotionConfig reducedMotion="user">
<AuthenticatedUserToolbar context={context} />
<AuthenticatedUserToolbar
context={context}
onSessionClose={() => setSessionClosed(true)}
onPersistentClose={() => {
try {
localStorage.setItem('gitbook_toolbar_closed', '1');
} catch {}
setSessionClosed(true);
}}
onToggleMinify={() => setMinified((prev) => !prev)}
/>
</MotionConfig>
</IframeWrapper>
);
@@ -74,8 +130,8 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
<Toolbar label="Site preview">
<ToolbarBody>
<ToolbarTitle
prefix="Change request"
suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
@@ -91,6 +147,10 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
<ToolbarButtonGroup>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
@@ -125,9 +185,6 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
})}
icon="code-pull-request"
/>
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
</ToolbarButtonGroup>
</Toolbar>
);
@@ -220,7 +277,7 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
return (
<Toolbar label="Only visible to your GitBook organization">
<ToolbarBody>
<ToolbarTitle prefix="Site" suffix={context.site.title} />
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
@@ -233,6 +290,11 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
<ToolbarButtonGroup>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton
title="Open site in GitBook"
href={getToolbarHref({
@@ -240,8 +302,10 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
siteId: site.id,
buttonId: 'site',
})}
icon="gear"
icon="gears"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize in GitBook"
href={getToolbarHref({
@@ -251,6 +315,8 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
})}
icon="palette"
/>
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights in GitBook"
href={getToolbarHref({
@@ -260,7 +326,19 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
})}
icon="chart-simple"
/>
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Toolbar settings */}
<HideToolbarButton
onSessionClose={() => {
props.onSessionClose?.();
}}
onPersistentClose={() => {
props.onPersistentClose?.();
}}
onMinify={() => {
props.onToggleMinify?.();
}}
/>
</ToolbarButtonGroup>
</Toolbar>
);
@@ -0,0 +1,146 @@
'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>
);
}
@@ -0,0 +1,258 @@
'use client';
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 { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { type FanConfig, useFanConfig } from './useFanConfig';
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 [open, setOpen] = React.useState(false);
const [config, setConfig] = useFanConfig();
const [showPane, setShowPane] = React.useState(false);
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);
}, []);
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?.(),
},
];
// Create a stable fallback motion value
// const fallbackScale = useMotionValue(1);
// // Always call useTransform to avoid hook order issues
// const arcMagnificationScale = useTransform(
// motionValues?.scale || fallbackScale,
// (val) => val * 0.8
// );
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
title={open ? 'Hide options' : 'Hide toolbar'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen((v) => !v);
}}
motionValues={motionValues}
icon="eye-slash"
>
{/* Expanding arc menu */}
{open && (
<motion.div
className={styles.arcMenu}
style={
{
...sharedMotionStyle,
'--start-distance': `${config.startOffset}px`,
'--spread-distance': `${config.spread}px`,
} as React.CSSProperties
}
>
<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
}
>
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
config={config}
key={item.icon}
title={item.label}
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;
}
) {
const {
index,
staggerIndex = index,
title,
disabled,
className,
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;
};
const itemRotation = calculateRotation();
return (
<div className="pointer-events-none">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
}}
style={
{
'--target-offset-distance': targetOffset,
'--start-distance': `${config.startOffset}px`,
'--arc-duration': `${config.speed}s`,
'--arc-delay': `${(staggerIndex ?? 0) * config.staggerMs}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
} as React.CSSProperties
}
className={tcls(
'group',
'pointer-events-auto',
'absolute',
'top-0',
'left-0',
'w-40',
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',
disabled ? 'cursor-not-allowed opacity-50' : '',
'hover:scale-105'
)}
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)',
}}
>
<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>
</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'
)}
>
{title}
</span>
</button>
</div>
);
}
@@ -0,0 +1,50 @@
.arcMenu {
position: absolute;
inset: 0;
pointer-events: none;
}
.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 {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
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 {
from {
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
}
to {
offset-distance: var(--target-offset-distance);
transform: scale(1);
opacity: 1;
}
}
@@ -1,12 +1,18 @@
'use client';
import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
import {
AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { Tooltip } from '../primitives';
import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
import { getCopyVariants, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect';
const DURATION_LOGO_APPEARANCE = 2000;
@@ -20,6 +26,7 @@ interface ToolbarProps {
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 [isReady, setIsReady] = React.useState(false);
@@ -49,70 +56,64 @@ export function Toolbar(props: ToolbarProps) {
}, [isReady]);
// Don't render anything until page is ready
if (!isReady) {
if (!isReady || closed) {
return null;
}
return (
<Tooltip label={label}>
<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"
>
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setMinified((prev) => !prev);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'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.80)_100%)]',
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
)}
initial={{
scale: 1,
opacity: 1,
}}
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
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo />
</motion.div>
{!minified ? children : null}
{!minified && showToolbarControls && (
<MinifyButton setMinified={setMinified} />
)}
<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"
>
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setMinified((prev) => !prev);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'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%)]'
)}
initial={{
scale: 1,
opacity: 1,
}}
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
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo />
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
@@ -139,12 +140,15 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
>
{buttonChildren.map((child, index) => {
const motionValues = buttonMotionValues[index];
const childEl = child as React.ReactElement;
return React.cloneElement(childEl, {
key: index,
motionValues,
});
const childKey = childEl.key ?? `toolbar-button-${index}`;
return (
<ToolbarButtonWrapper
key={childKey}
child={childEl}
rawMotionValues={buttonMotionValues[index]}
/>
);
})}
</motion.div>
);
@@ -158,15 +162,27 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
icon: IconName;
iconClassName?: string;
title?: React.ReactNode;
children?: React.ReactNode;
}
export function ToolbarButton(props: ToolbarButtonProps) {
const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
props;
const {
title,
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const reduceMotion = useReducedMotion();
return (
<motion.div variants={toolbarEasings.staggeringChild}>
<motion.div variants={toolbarEasings.staggeringChild} className="relative">
{children ? children : null}
<Tooltip label={title}>
<motion.a
href={href}
@@ -182,6 +198,10 @@ export function ToolbarButton(props: ToolbarButtonProps) {
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...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)',
}
}
transition={{
@@ -209,29 +229,70 @@ export function ToolbarButton(props: ToolbarButtonProps) {
'cursor-pointer',
'transition-colors',
'size-8',
'bg-tint-1/3',
'hover:bg-tint-1/4',
'dark:bg-tint-3',
'dark:hover:bg-tint-1',
disabled ? 'cursor-not-allowed opacity-50' : '',
'shadow-1xs'
)}
>
<Icon icon={icon} className={tcls('size-4', iconClassName)} />
<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>
</motion.a>
</Tooltip>
</motion.div>
);
}
function ToolbarButtonWrapper(props: {
child: React.ReactElement;
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
}) {
const { child, rawMotionValues } = props;
// Convert the raw motion values to smooth spring easings
const springScale = useSpring(rawMotionValues?.scale.get() ?? 1, {
stiffness: 400,
damping: 30,
});
const springX = useSpring(rawMotionValues?.x.get() ?? 0, { stiffness: 400, damping: 30 });
// Sync springs with raw motion values
React.useEffect(() => {
if (!rawMotionValues) return;
const unsubScale = rawMotionValues.scale.on('change', (v) => springScale.set(v));
const unsubX = rawMotionValues.x.on('change', (v) => springX.set(v));
return () => {
unsubScale();
unsubX();
};
}, [rawMotionValues, springScale, springX]);
const motionValues = {
scale: springScale,
x: springX,
};
return React.cloneElement(child, {
motionValues,
});
}
export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />;
}
export function ToolbarTitle(props: { prefix: string; suffix: string }) {
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<div className="flex items-center gap-1 text-xs ">
<ToolbarTitlePrefix title={props.prefix} />
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
<ToolbarTitleSuffix title={props.suffix} />
</div>
);
@@ -241,7 +302,7 @@ function ToolbarTitlePrefix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(0)}
className="font-light text-neutral-7 dark:text-neutral-3"
className="truncate font-medium text-neutral-3 dark:text-neutral-2"
>
{props.title}
</motion.span>
@@ -252,7 +313,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
className="max-w-[20ch] truncate text-neutral-3 dark:text-neutral-2"
>
{props.title}
</motion.span>
@@ -269,30 +330,3 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
</motion.span>
);
}
function MinifyButton(props: { setMinified: (minified: boolean) => void }) {
return (
<Tooltip label="Minify">
<motion.div
{...minifyButtonAnimation}
transition={{
duration: 0.2,
}}
whileHover={{
scale: 1.05,
}}
onClick={(e) => {
e.stopPropagation();
props.setMinified(true);
}}
className={tcls(
'-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border',
'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600',
'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100'
)}
>
<Icon icon="minus" className="size-2 text-neutral-1 dark:text-neutral-9" />
</motion.div>
</Tooltip>
);
}
@@ -54,4 +54,7 @@ export type AdminToolbarContext = {
export interface AdminToolbarClientProps {
context: AdminToolbarContext;
onSessionClose?: () => void;
onPersistentClose?: () => void;
onToggleMinify?: () => void;
}
@@ -0,0 +1,47 @@
'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];
}
@@ -37,7 +37,9 @@ const createMotionValues = (count: number): ButtonMotionValues[] =>
x: motionValue(0),
}));
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
const resetMotionValues = (
motionValues: Array<{ scale: MotionValue<number>; x: MotionValue<number> }>
) => {
motionValues.forEach(({ scale, x }) => {
scale.set(1);
x.set(0);
@@ -68,17 +70,14 @@ const calculateScale = (
containerRect: DOMRect,
config: Required<MagnificationConfig>
) => {
// Calculate 2D distance from mouse to button center
const deltaX = mouseX - buttonCenterX;
const deltaY = mouseY - buttonCenterY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// Calculate only X-axis distance from mouse to button center
const distance = Math.abs(mouseX - buttonCenterX);
if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge
// Calculate distance from container edge (X-axis only)
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdgeY = Math.min(mouseY - containerRect.top, containerRect.bottom - mouseY);
const distanceFromEdge = Math.min(distanceFromEdgeX, distanceFromEdgeY);
const distanceFromEdge = distanceFromEdgeX;
// Define the "heart" zone - 8px from center (16x16px total area)
const heartZoneRadius = 8;
@@ -155,13 +154,20 @@ export function useMagnificationEffect(props: {
config?: MagnificationConfig;
}) {
const { childrenCount, containerRef, config } = props;
const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
const originalPositionsRef = React.useRef<
Array<{ left: number; width: number; top: number; height: number }>
>([]);
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
// Create basic motion values that will be consumed by springs in the components
const buttonMotionValues = React.useMemo(() => {
return Array.from({ length: childrenCount }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
}, [childrenCount]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -175,11 +181,6 @@ export function useMagnificationEffect(props: {
return;
}
// Initialize motion values if button count changed
if (buttonMotionValues.length !== buttons.length) {
setButtonMotionValues(createMotionValues(buttons.length));
}
const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from(
container.querySelectorAll('.toolbar-button')
@@ -233,7 +234,7 @@ export function useMagnificationEffect(props: {
);
});
// Update motion values
// Update motion values - springs in components will animate to these values
buttonEffects.forEach((effect, index) => {
const motionValue = buttonMotionValues[index];
if (motionValue) {