diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 977586566b..118b475ce6 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -5,6 +5,7 @@ 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 { @@ -17,48 +18,108 @@ import { ToolbarSubtitle, ToolbarTitle, } from './Toolbar'; -import type { AdminToolbarClientProps } from './types'; +import { + type ToolbarControlsContextValue, + ToolbarControlsProvider, +} from './ToolbarControlsContext'; +import type { AdminToolbarClientProps, AdminToolbarContext } from './types'; +import { useToolbarVisibility } from './utils'; export function AdminToolbarClient(props: AdminToolbarClientProps) { - const { context } = props; + const { context, onPersistentClose, onSessionClose, onToggleMinify } = props; + const { + minified, + setMinified, + shouldAutoExpand, + hidden, + minimize, + closeSession, + closePersistent, + } = useToolbarVisibility({ + onPersistentClose, + onSessionClose, + onToggleMinify, + }); + const visitorSession = useVisitorSession(); + const toolbarControls: ToolbarControlsContextValue = { + minimize, + closeSession, + closePersistent, + shouldAutoExpand, + }; + + if (hidden) { + return null; + } + // If there is a change request, show the change request toolbar if (context.changeRequest) { return ( - - - - - + + + ); } // 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 ( - - - - - + + + ); } // If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar if (visitorSession?.organizationId === context.organizationId) { return ( - - - - - + + + ); } + + return null; } -function ChangeRequestToolbar(props: AdminToolbarClientProps) { - const { context } = props; +/** + * Reusable wrapper that provides tooling and containers that are used by all types of toolbar views. + */ +export function ToolbarControlsWrapper( + props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }> +) { + const { children, value } = props; + return ( + + + {children} + + + ); +} + +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'); @@ -71,11 +132,11 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { }); return ( - + - + {/* Refresh to retrieve latest changes */} {updated ? : null} + + {/* Edit in GitBook */} + + {/* Comment in app */} - - {/* Edit in GitBook */} - - + ); } -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'); @@ -145,7 +207,7 @@ function RevisionToolbar(props: AdminToolbarClientProps) { const gitProvider = isGitHub ? 'GitHub' : 'GitLab'; return ( - + - + {/* Open commit in Git client */} - + ); } -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 ( - + - + @@ -230,9 +296,14 @@ 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 */} - - + ); } +function ToolbarActions(props: { children: React.ReactNode }) { + const { children } = props; + + return ( + + {children} + + + ); +} + function EditPageButton(props: { href: string; siteId: string; diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css index 404b957aa9..dc2938ef10 100644 --- a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -11,9 +11,15 @@ vector-effect: non-scaling-stroke; } -:global(html.dark) .svgLogo { - --logo-fill: var(--color-neutral-800); - --trace-color: #dedfe3; +.static .trace { + animation: none; + fill: var(--logo-fill); + stroke: none; +} + +.static .seg { + animation: none; + opacity: 0; } /* Base segment animation */ @@ -176,4 +182,4 @@ fill: var(--logo-fill); stroke:none; } -} \ No newline at end of file +} diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx index f7a7c2790a..c5e2ac8e95 100644 --- a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx @@ -2,10 +2,16 @@ import { tcls } from '@/lib/tailwind'; import type React from 'react'; import styles from './AnimatedLogo.module.css'; -export const AnimatedLogo: React.FC = () => { +interface AnimatedLogoProps { + shouldAnimate?: boolean; +} + +export const AnimatedLogo: React.FC = (props) => { + const { shouldAnimate = true } = props; + return ( (null); + const buttonRef = useRef(null); + + const handleClickOutsideArcMenu = (event: Event) => { + // Don't close the arc if we are 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(() => { + if (!open) return; + + const handleScroll = () => setOpen(false); + window.addEventListener('scroll', handleScroll, { passive: true }); + + return () => window.removeEventListener('scroll', handleScroll); + }, [open]); + + const items = [ + 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; + + const sharedMotionStyle = motionValues + ? { + x: motionValues.x, + } + : undefined; + + return ( + { + setOpen((v) => !v); + }} + motionValues={motionValues} + icon="eye-slash" + > + {/* Expanding arc menu */} + {open && ( + +
+ {items.map((item, index) => ( + { + setOpen(false); + item.onClick?.(); + }} + /> + ))} +
+ + )} + + ); +} + +type ArcMenuItem = { + id: string; + icon: IconName; + label: string; + description: string; + onClick?: () => void; +}; + +type ArcToolbarButtonProps = Pick & { + index: number; + staggerIndex?: number; + disabled?: boolean; + className?: string; + iconClassName?: string; +}; + +export function ArcToolbarButton(props: ArcToolbarButtonProps) { + const { + index, + staggerIndex = index, + label, + disabled, + className, + onClick = () => {}, + icon, + iconClassName, + } = props; + + const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`; + + // Calculate rotation based on position along the arc + const calculateRotation = () => { + return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG; + }; + + const itemRotation = calculateRotation(); + + return ( +
+ +
+ ); +} 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 0000000000..72b1ddd325 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.module.css @@ -0,0 +1,40 @@ +.arcMenu { + --arc-width: 505px; + --arc-height: 400px; + --arc-radius: 34%; + --start-distance: -240px; + --spread-distance: 45px; +} + +.arcMenuPath { + bottom: calc(var(--arc-height) / -2); + width: var(--arc-width); + height: var(--arc-height); + border-radius: var(--arc-radius); + transform: translate(0%, 0%); +} + +.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); + transform-origin: center left; + offset-path: border-box; + offset-anchor: 0% 0%; + offset-rotate: auto var(--rotation-offset, 0deg); +} + +@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 38f765df92..d64f4f2b1c 100644 --- a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx @@ -1,27 +1,39 @@ '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 { useToolbarControls } from './ToolbarControlsContext'; 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; const DELAY_BETWEEN_LOGO_AND_CONTENT = 100; interface ToolbarProps { - children: React.ReactNode; label: React.ReactNode; + children: React.ReactNode; + minified: boolean; + onMinifiedChange: (value: boolean) => void; } export function Toolbar(props: ToolbarProps) { - const { children, label } = props; - const [minified, setMinified] = React.useState(true); - const [showToolbarControls, setShowToolbarControls] = React.useState(false); + const { children, label, minified, onMinifiedChange } = props; + const controls = useToolbarControls(); const [isReady, setIsReady] = React.useState(false); + const autoExpandTriggeredRef = React.useRef(false); + + const shouldAutoExpand = Boolean(controls?.shouldAutoExpand); + const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand); // Wait for page to be ready, then show the toolbar React.useEffect(() => { @@ -37,16 +49,41 @@ export function Toolbar(props: ToolbarProps) { } }, []); - // After toolbar appears, wait then show the full content + // 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); + if (!isReady || autoExpandTriggeredRef.current) { + return; + } + + if (!shouldAutoExpand) { + // When we already know the toolbar should stay expanded (e.g. the user previously + // opened it this session) we short-circuit the auto-expand animation and immediately + // render the expanded state without replaying the logo animation. + autoExpandTriggeredRef.current = true; + setShouldAnimateLogo(false); + return; + } + + autoExpandTriggeredRef.current = true; + + // On a fresh session we let the toolbar appear in its compact form, play the logo + // animation, and only then expand the toolbar. The timeout mirrors the duration of the + // logo animation so both transitions feel connected. + const expandAfterTimeout = setTimeout(() => { + setShouldAnimateLogo(false); + onMinifiedChange(false); + }, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT); + + return () => clearTimeout(expandAfterTimeout); + }, [isReady, onMinifiedChange, shouldAutoExpand]); - return () => clearTimeout(expandAfterTimeout); + React.useEffect(() => { + if (!minified) { + // Any manual expansion should stop the logo animation so the icon stays in its + // “settled” state once the toolbar is open. + setShouldAnimateLogo(false); } - }, [isReady]); + }, [minified]); // Don't render anything until page is ready if (!isReady) { @@ -55,16 +92,13 @@ export function Toolbar(props: ToolbarProps) { 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); + setShouldAnimateLogo(false); + onMinifiedChange(false); } }} layout @@ -78,37 +112,22 @@ 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.80)_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(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]', + 'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]' )} - 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 && ( - - )} @@ -139,12 +158,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 +180,27 @@ export interface ToolbarButtonProps extends Omit((props, ref) => { + const { + title, + disabled, + motionValues, + className, + style, + href, + onClick, + icon, + iconClassName, + children, + } = props; const reduceMotion = useReducedMotion(); return ( - + + {children ? children : null} - + ); +}); + +ToolbarButton.displayName = 'ToolbarButton'; + +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 +313,7 @@ function ToolbarTitlePrefix(props: { title: string }) { return ( {props.title} @@ -252,7 +324,7 @@ function ToolbarTitleSuffix(props: { title: string }) { return ( {props.title} @@ -263,36 +335,9 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) { return ( {props.subtitle} ); } - -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/ToolbarControlsContext.tsx b/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx new file mode 100644 index 0000000000..185c04d41b --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx @@ -0,0 +1,27 @@ +'use client'; +import React from 'react'; + +export interface ToolbarControlsContextValue { + minimize: () => void; + closeSession?: () => void; + closePersistent?: () => void; + shouldAutoExpand?: boolean; +} + +const ToolbarControlsContext = React.createContext(null); + +/* + * Provides reusable state setters (mainly for hiding/showing the toolbar) for the toolbar controls propagated through to the children + */ +export function ToolbarControlsProvider( + props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }> +) { + const { children, value } = props; + return ( + {children} + ); +} + +export function useToolbarControls() { + return React.useContext(ToolbarControlsContext); +} diff --git a/packages/gitbook/src/components/AdminToolbar/index.ts b/packages/gitbook/src/components/AdminToolbar/index.ts index 703ad78e1a..490e4b6aea 100644 --- a/packages/gitbook/src/components/AdminToolbar/index.ts +++ b/packages/gitbook/src/components/AdminToolbar/index.ts @@ -3,3 +3,4 @@ export * from './AdminToolbarClient'; export * from './IframeWrapper'; export * from './Toolbar'; export * from './transitions'; +export * from './utils'; diff --git a/packages/gitbook/src/components/AdminToolbar/types.ts b/packages/gitbook/src/components/AdminToolbar/types.ts index 4d1b445848..f3ec3fed61 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/useMagnificationEffect.ts b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts index f9cdc693f4..7bf7e3e955 100644 --- a/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts +++ b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts @@ -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; - x: MotionValue; -} - interface MagnificationConfig { /** Size of each button in pixels - used for spacing calculations */ buttonSize?: number; @@ -30,14 +25,9 @@ const defaultConfig: Required = { 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: ButtonMotionValues[]) => { +const resetMotionValues = ( + motionValues: Array<{ scale: MotionValue; x: MotionValue }> +) => { motionValues.forEach(({ scale, x }) => { scale.set(1); x.set(0); @@ -62,23 +52,18 @@ const captureButtonPositions = (buttons: HTMLElement[]) => { const calculateScale = ( mouseX: number, - mouseY: number, buttonCenterX: number, - buttonCenterY: number, 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 +140,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 +167,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') @@ -211,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 }; }); diff --git a/packages/gitbook/src/components/AdminToolbar/utils.ts b/packages/gitbook/src/components/AdminToolbar/utils.ts new file mode 100644 index 0000000000..9778d5b452 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/utils.ts @@ -0,0 +1,154 @@ +import React from 'react'; + +import { + getLocalStorageItem, + getSessionStorageItem, + removeSessionStorageItem, + setLocalStorageItem, + setSessionStorageItem, +} from '@/lib/browser/local-storage'; + +const STORAGE_KEY = 'gitbook_toolbar_closed'; +const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed'; +const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified'; + +type SessionHideReason = 'session' | 'persistent'; + +/** + * Read the current session hide state. We store whether the toolbar was hidden “for session” or + * “persistently” so we can distinguish between a temporary dismissal and a user preference. + */ +export const getSessionHideState = (): { hidden: boolean; reason?: SessionHideReason } => { + const value = getSessionStorageItem(SESSION_STORAGE_KEY, null); + if (value === 'session' || value === 'persistent') { + return { hidden: true, reason: value }; + } + return { hidden: false, reason: undefined }; +}; + +/** + * Persist the session hide state. Passing `false` clears the stored preference entirely. + */ +export const setSessionHidden = (value: boolean, reason?: SessionHideReason) => { + if (value && reason) { + setSessionStorageItem(SESSION_STORAGE_KEY, reason); + } else { + removeSessionStorageItem(SESSION_STORAGE_KEY); + } +}; + +/** + * Retrieve the last minified state from session storage. If no value was ever stored we return + * `undefined`, which signals that the toolbar should auto-expand once the logo animation finishes. + */ +export const getStoredMinified = (): boolean | undefined => { + const stored = getSessionStorageItem(SESSION_MINIFIED_KEY, null); + if (stored === null || stored === undefined) { + removeSessionStorageItem(SESSION_MINIFIED_KEY); + return undefined; + } + return stored; +}; + +/** + * Persist the current minified state for the ongoing session. + */ +export const setStoredMinified = (value: boolean) => { + setSessionStorageItem(SESSION_MINIFIED_KEY, value); +}; + +interface UseToolbarVisibilityOptions { + onPersistentClose?: () => void; + onSessionClose?: () => void; + onToggleMinify?: () => void; +} + +export function useToolbarVisibility(options: UseToolbarVisibilityOptions = {}) { + const { onPersistentClose, onSessionClose, onToggleMinify } = options; + + const [initialStoredMinified] = React.useState(() => + typeof window !== 'undefined' ? getStoredMinified() : undefined + ); + const shouldAutoExpand = initialStoredMinified === undefined; + + const [minified, setMinifiedState] = React.useState(() => + initialStoredMinified !== undefined ? initialStoredMinified : true + ); + + const [persistentHidden, setPersistentHidden] = React.useState(() => + getLocalStorageItem(STORAGE_KEY, false) + ); + + const [sessionReason, setSessionReason] = React.useState(() => { + const state = getSessionHideState(); + return state.hidden ? state.reason : undefined; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const handleStorage = () => { + setPersistentHidden(getLocalStorageItem(STORAGE_KEY, false)); + const state = getSessionHideState(); + setSessionReason(state.hidden ? state.reason : undefined); + }; + + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, []); + + React.useEffect(() => { + if (persistentHidden) { + if (sessionReason !== 'persistent') { + // Sync the session flag so we honour the persistent preference and avoid flashes when + // opening new tabs in the same browsing session. + setSessionHidden(true, 'persistent'); + setSessionReason('persistent'); + } + return; + } + + if (sessionReason === 'persistent') { + // If the persistent preference was cleared we also clear the session flag so the toolbar + // can reappear immediately without requiring a full reload. + setSessionHidden(false); + setSessionReason(undefined); + } + }, [persistentHidden, sessionReason]); + + const setMinified = (value: boolean) => { + setMinifiedState(value); + setStoredMinified(value); + onToggleMinify?.(); + }; + + const minimize = () => setMinified(true); + + const closeSession = () => { + setSessionHidden(true, 'session'); + setSessionReason('session'); + onSessionClose?.(); + }; + + const closePersistent = () => { + setLocalStorageItem(STORAGE_KEY, true); + setPersistentHidden(true); + setSessionHidden(true, 'persistent'); + setSessionReason('persistent'); + onPersistentClose?.(); + }; + + const hidden = persistentHidden || sessionReason === 'session'; + + return { + minified, + setMinified, + shouldAutoExpand, + hidden, + minimize, + closeSession, + closePersistent, + }; +} diff --git a/packages/gitbook/src/lib/browser/local-storage.ts b/packages/gitbook/src/lib/browser/local-storage.ts index 9b06759f8a..03b6ba4c32 100644 --- a/packages/gitbook/src/lib/browser/local-storage.ts +++ b/packages/gitbook/src/lib/browser/local-storage.ts @@ -1,13 +1,13 @@ import { checkIsSecurityError } from './security-error'; /** - * Get an item from local storage safely. + * Reusable function to read from any kind of Storage */ -export function getLocalStorageItem(key: string, defaultValue: T): T { +function readStorage(storage: Storage | undefined, key: string, defaultValue: T): T { try { - if (typeof localStorage !== 'undefined' && localStorage && 'getItem' in localStorage) { - const stored = localStorage.getItem(key); - return stored ? (JSON.parse(stored) as T) : defaultValue; + if (storage && 'getItem' in storage) { + const stored = storage.getItem(key); + return stored !== null ? (JSON.parse(stored) as T) : defaultValue; } return defaultValue; } catch (error) { @@ -19,12 +19,12 @@ export function getLocalStorageItem(key: string, defaultValue: T): T { } /** - * Set an item in local storage safely. + * Reusable function to write to any kind of Storage */ -export function setLocalStorageItem(key: string, value: unknown) { +function writeStorage(storage: Storage | undefined, key: string, value: unknown) { try { - if (typeof localStorage !== 'undefined' && localStorage && 'setItem' in localStorage) { - localStorage.setItem(key, JSON.stringify(value)); + if (storage && 'setItem' in storage) { + storage.setItem(key, JSON.stringify(value)); } } catch (error) { if (checkIsSecurityError(error)) { @@ -33,3 +33,59 @@ export function setLocalStorageItem(key: string, value: unknown) { throw error; } } + +function removeStorage(storage: Storage | undefined, key: string) { + try { + if (storage && 'removeItem' in storage) { + storage.removeItem(key); + } + } catch (error) { + if (checkIsSecurityError(error)) { + return; + } + throw error; + } +} + +/** + * Get an item from local storage safely. + */ +export function getLocalStorageItem(key: string, defaultValue: T): T { + return readStorage( + typeof localStorage !== 'undefined' ? localStorage : undefined, + key, + defaultValue + ); +} + +/** + * Set an item in local storage safely. + */ +export function setLocalStorageItem(key: string, value: unknown) { + writeStorage(typeof localStorage !== 'undefined' ? localStorage : undefined, key, value); +} + +/** + * Get an item from session storage safely. + */ +export function getSessionStorageItem(key: string, defaultValue: T): T { + return readStorage( + typeof sessionStorage !== 'undefined' ? sessionStorage : undefined, + key, + defaultValue + ); +} + +/** + * Set an item in session storage safely. + */ +export function setSessionStorageItem(key: string, value: unknown) { + writeStorage(typeof sessionStorage !== 'undefined' ? sessionStorage : undefined, key, value); +} + +/** + * Remove an item from session storage safely. + */ +export function removeSessionStorageItem(key: string) { + removeStorage(typeof sessionStorage !== 'undefined' ? sessionStorage : undefined, key); +}