diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a86e5d5d..4e3c246e2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,7 @@ }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "chatgpt.openOnStartup": true, + "chatgpt.commentCodeLensEnabled": false } diff --git a/bun.lock b/bun.lock index 09d6bc50c..928c5f1cd 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 3cf554d86..b46c306aa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 977586566..6f60bc557 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -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 ( - + setSessionClosed(true)} + onPersistentClose={() => { + try { + localStorage.setItem('gitbook_toolbar_closed', '1'); + } catch {} + setSessionClosed(true); + }} + onToggleMinify={() => setMinified((prev) => !prev)} + /> ); @@ -74,8 +130,8 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { {/* Refresh to retrieve latest changes */} {updated ? : null} + + {/* Edit in GitBook */} + + {/* Comment in app */} - - {/* Edit in GitBook */} - ); @@ -220,7 +277,7 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) { return ( - + @@ -233,6 +290,11 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) { {/* Refresh to retrieve latest changes */} {updated ? : null} + + {/* Edit in GitBook */} + + + {/* Open site in GitBook */} + + {/* Customize in GitBook */} + + {/* Open insights in GitBook */} - + + {/* Toolbar settings */} + { + props.onSessionClose?.(); + }} + onPersistentClose={() => { + props.onPersistentClose?.(); + }} + onMinify={() => { + props.onToggleMinify?.(); + }} + /> ); diff --git a/packages/gitbook/src/components/AdminToolbar/FanConfigPane.tsx b/packages/gitbook/src/components/AdminToolbar/FanConfigPane.tsx new file mode 100644 index 000000000..b738ad2f5 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/FanConfigPane.tsx @@ -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>; + visible?: boolean; +}) { + const { config, setConfig, visible = false } = props; + const hostRef = React.useRef(null); + const paneRef = React.useRef(null); + const paneStateRef = React.useRef(null); + const cssLoadedRef = React.useRef(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).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 ( + + + + ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx new file mode 100644 index 000000000..0da3a2ae3 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx @@ -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 ( + { + e.preventDefault(); + e.stopPropagation(); + setOpen((v) => !v); + }} + motionValues={motionValues} + icon="eye-slash" + > + {/* Expanding arc menu */} + {open && ( + + + {items.map((item, index) => ( + { + setOpen(false); + item.onClick?.(); + }} + icon={item.icon as IconName} + /> + ))} + + + )} + + {/* */} + + + ); +} + +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 ( + + { + 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 + )} + > + + + + + + + {title} + + + + ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/Toolbar.module.css b/packages/gitbook/src/components/AdminToolbar/Toolbar.module.css new file mode 100644 index 000000000..4394fb362 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.module.css @@ -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; + } +} diff --git a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx index 38f765df9..b9235167f 100644 --- a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx @@ -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 ( - - 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" - > - - { - 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 */} - - - - - {!minified ? children : null} - - {!minified && showToolbarControls && ( - - )} + 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" + > + + { + 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 */} + + - - - + + {!minified ? children : null} + + + ); } @@ -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 ( + + ); })} ); @@ -158,15 +162,27 @@ export interface ToolbarButtonProps extends Omit + + {children ? children : null} - + + + ); } +function ToolbarButtonWrapper(props: { + child: React.ReactElement; + rawMotionValues?: { scale: MotionValue; x: MotionValue }; +}) { + 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 ; } -export function ToolbarTitle(props: { prefix: string; suffix: string }) { +export function ToolbarTitle(props: { prefix?: string; suffix: string }) { return ( - + {props.prefix ? : null} ); @@ -241,7 +302,7 @@ function ToolbarTitlePrefix(props: { title: string }) { return ( {props.title} @@ -252,7 +313,7 @@ function ToolbarTitleSuffix(props: { title: string }) { return ( {props.title} @@ -269,30 +330,3 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) { ); } - -function MinifyButton(props: { setMinified: (minified: boolean) => void }) { - return ( - - { - 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' - )} - > - - - - ); -} diff --git a/packages/gitbook/src/components/AdminToolbar/types.ts b/packages/gitbook/src/components/AdminToolbar/types.ts index 4d1b44584..f3ec3fed6 100644 --- a/packages/gitbook/src/components/AdminToolbar/types.ts +++ b/packages/gitbook/src/components/AdminToolbar/types.ts @@ -54,4 +54,7 @@ export type AdminToolbarContext = { export interface AdminToolbarClientProps { context: AdminToolbarContext; + onSessionClose?: () => void; + onPersistentClose?: () => void; + onToggleMinify?: () => void; } diff --git a/packages/gitbook/src/components/AdminToolbar/useFanConfig.tsx b/packages/gitbook/src/components/AdminToolbar/useFanConfig.tsx new file mode 100644 index 000000000..1b74ef7dd --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/useFanConfig.tsx @@ -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>] { + const [config, setConfig] = React.useState(() => { + 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]; +} diff --git a/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts index f9cdc693f..3e89fc255 100644 --- a/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts +++ b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts @@ -37,7 +37,9 @@ const createMotionValues = (count: number): ButtonMotionValues[] => x: motionValue(0), })); -const resetMotionValues = (motionValues: ButtonMotionValues[]) => { +const resetMotionValues = ( + motionValues: Array<{ scale: MotionValue; x: MotionValue }> +) => { motionValues.forEach(({ scale, x }) => { scale.set(1); x.set(0); @@ -68,17 +70,14 @@ const calculateScale = ( containerRect: DOMRect, config: Required ) => { - // 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([]); 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) {