mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
wip
This commit is contained in:
Vendored
+3
-1
@@ -17,5 +17,7 @@
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
},
|
||||
"chatgpt.openOnStartup": true,
|
||||
"chatgpt.commentCodeLensEnabled": false
|
||||
}
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user