mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Add buttons to hide toolbar
This commit is contained in:
Vendored
+1
-3
@@ -17,7 +17,5 @@
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"chatgpt.openOnStartup": true,
|
||||
"chatgpt.commentCodeLensEnabled": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user