From 5d409ae4ad56c4203a0cc76dbfd5286879fb5998 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Fri, 3 Oct 2025 11:55:19 +0200 Subject: [PATCH 1/5] Add buttons to hide toolbar --- .../AdminToolbar/AdminToolbarClient.tsx | 228 +++++++++++---- .../AdminToolbar/AnimatedLogo.module.css | 8 +- .../AdminToolbar/HideToolbarButton.tsx | 238 ++++++++++++++++ .../AdminToolbar/Toolbar.module.css | 40 +++ .../src/components/AdminToolbar/Toolbar.tsx | 267 +++++++++--------- .../AdminToolbar/ToolbarControlsContext.tsx | 26 ++ .../src/components/AdminToolbar/types.ts | 3 + .../AdminToolbar/useMagnificationEffect.ts | 55 ++-- 8 files changed, 631 insertions(+), 234 deletions(-) create mode 100644 packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx create mode 100644 packages/gitbook/src/components/AdminToolbar/Toolbar.module.css create mode 100644 packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 977586566b..c696ef3ec3 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -1,10 +1,12 @@ 'use client'; import { Icon } from '@gitbook/icons'; import { MotionConfig } from 'motion/react'; +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'; import { @@ -17,48 +19,130 @@ import { ToolbarSubtitle, ToolbarTitle, } from './Toolbar'; -import type { AdminToolbarClientProps } from './types'; +import { + type ToolbarControlsContextValue, + 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 visitorSession = useVisitorSession(); + const [sessionClosed, setSessionClosed] = React.useState(false); + const [shouldHide, setShouldHide] = React.useState(false); + + React.useEffect(() => { + const STORAGE_KEY = 'gitbook_toolbar_closed'; + + try { + const hidden = !!localStorage.getItem(STORAGE_KEY); + setShouldHide(hidden); + } catch { + setShouldHide(false); + } + }, []); + + 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; + } // 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 ( - - - - - + + + ); } } -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 +155,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,19 +230,21 @@ function RevisionToolbar(props: AdminToolbarClientProps) { const gitProvider = isGitHub ? 'GitHub' : 'GitLab'; return ( - - - - - Created - - } - /> - + + + + + + Created + + } + /> + + - + {/* 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 ( - - - - - Updated - - } - /> - + + + + + + Updated + + } + /> + + - + {/* 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..a9f883db15 100644 --- a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -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 */ diff --git a/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx new file mode 100644 index 0000000000..a686147594 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx @@ -0,0 +1,238 @@ +'use client'; +import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName, IconStyle } from '@gitbook/icons'; +import { motion } from 'motion/react'; +import React, { useRef } from 'react'; +import { useOnClickOutside } from 'usehooks-ts'; +import { ToolbarButton, type ToolbarButtonProps } from './Toolbar'; +import styles from './Toolbar.module.css'; +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']; +} + +/** + * Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions. + */ +export function HideToolbarButton(props: HideToolbarButtonProps) { + const { motionValues } = props; + const [open, setOpen] = React.useState(false); + const controls = useToolbarControls(); + + const ref = useRef(null); + const buttonRef = useRef(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(() => { + if (!open) return; + + const handleScroll = () => setOpen(false); + window.addEventListener('scroll', handleScroll, { passive: true }); + + return () => window.removeEventListener('scroll', handleScroll); + }, [open]); + + 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, + [controls] + ); + + 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..6765890784 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; @@ -14,13 +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 [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 @@ -39,14 +44,16 @@ 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) { @@ -54,65 +61,43 @@ 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); - } - }} - 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 && ( - - )} + + + { + if (minified) { + onMinifiedChange(false); + } + }} + 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', + 'backdrop-blur-sm', + 'origin-center', + '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%)]' + )} + 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 +124,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 +146,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}
); @@ -239,10 +278,7 @@ export function ToolbarTitle(props: { prefix: string; suffix: string }) { function ToolbarTitlePrefix(props: { title: string }) { return ( - + {props.title} ); @@ -250,10 +286,7 @@ function ToolbarTitlePrefix(props: { title: string }) { function ToolbarTitleSuffix(props: { title: string }) { return ( - + {props.title} ); @@ -261,38 +294,8 @@ function ToolbarTitleSuffix(props: { title: string }) { 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..57fcda92bf --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx @@ -0,0 +1,26 @@ +'use client'; +import React from 'react'; + +export interface ToolbarControlsContextValue { + minimize: () => void; + closeSession?: () => void; + closePersistent?: () => void; +} + +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/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 }; }); From a74ae25052000b1c1d40a0cc4f7f0014fc687d74 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Fri, 3 Oct 2025 12:52:16 +0200 Subject: [PATCH 2/5] Fixes --- .../AdminToolbar/AdminToolbarClient.tsx | 56 +++++------ .../AdminToolbar/AnimatedLogo.module.css | 9 +- .../AdminToolbar/HideToolbarButton.tsx | 4 +- .../src/components/AdminToolbar/Toolbar.tsx | 96 +++++++++++-------- 4 files changed, 85 insertions(+), 80 deletions(-) diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index c696ef3ec3..01aebcf2bb 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { useCheckForContentUpdate } from '../AutoRefreshContent'; import { useVisitorSession } from '../Insights'; import { useCurrentPagePath } from '../hooks'; -import { DateRelative, Tooltip } from '../primitives'; +import { DateRelative } from '../primitives'; import { HideToolbarButton } from './HideToolbarButton'; import { IframeWrapper } from './IframeWrapper'; import { RefreshContentButton } from './RefreshContentButton'; @@ -155,7 +155,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) { }); return ( - + - - - - - Created - - } - /> - - + + + + + Created + + } + /> + {/* Open commit in Git client */} @@ -305,19 +303,21 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) { }); return ( - - - - - - Updated - - } - /> - - + + + + + Updated + + } + /> + {/* Refresh to retrieve latest changes */} diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css index a9f883db15..a755b340f3 100644 --- a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -1,9 +1,9 @@ .svgLogo { - --logo-fill: var(--color-neutral-900); + --logo-fill: var(--color-neutral-100); --seg-A-color: #2782c4; --seg-B-color: #43b7f2; --seg-C-color: #8be2ff; - --trace-color: #dedfe3; + --trace-color: #46474c; --T: 2s; @@ -11,11 +11,6 @@ vector-effect: non-scaling-stroke; } -:global(html.dark) .svgLogo { - --logo-fill: var(--color-neutral-100); - --trace-color: #46474c; -} - /* Base segment animation */ .seg { opacity: 0; diff --git a/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx index a686147594..639d19c706 100644 --- a/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx +++ b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx @@ -208,12 +208,10 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) { '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%)]' + 'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]' )} style={{ background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))', - border: '1px solid rgba(0, 0, 0, 0.06)', - boxShadow: 'rgba(255, 255, 255, 0.15) 0px 1px 1px 0px inset', }} > void; } export function Toolbar(props: ToolbarProps) { - const { children, minified, onMinifiedChange } = props; + const { children, label, minified, onMinifiedChange } = props; const [isReady, setIsReady] = React.useState(false); // Wait for page to be ready, then show the toolbar @@ -61,43 +62,46 @@ export function Toolbar(props: ToolbarProps) { } return ( - - - { - if (minified) { - onMinifiedChange(false); - } - }} - 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', - 'backdrop-blur-sm', - 'origin-center', - '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%)]' - )} - style={{ - borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions - }} - > - {/* Logo with stroke segments animation in blue-tints */} - - + + + + { + if (minified) { + onMinifiedChange(false); + } + }} + 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', + 'backdrop-blur-sm', + 'origin-center', + '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%)]' + )} + 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 ? children : null} - - - + + + ); } @@ -182,7 +186,6 @@ export const ToolbarButton = React.forwardRef @@ -278,7 +281,10 @@ export function ToolbarTitle(props: { prefix?: string; suffix: string }) { function ToolbarTitlePrefix(props: { title: string }) { return ( - + {props.title} ); @@ -286,7 +292,10 @@ function ToolbarTitlePrefix(props: { title: string }) { function ToolbarTitleSuffix(props: { title: string }) { return ( - + {props.title} ); @@ -294,7 +303,10 @@ function ToolbarTitleSuffix(props: { title: string }) { export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) { return ( - + {props.subtitle} ); From ef53ff949b2050f962f6f84f47e194acf6a7f774 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Tue, 7 Oct 2025 09:01:23 +0200 Subject: [PATCH 3/5] Feedback fixes --- .../AdminToolbar/AdminToolbarClient.tsx | 43 +++++--------- .../AdminToolbar/HideToolbarButton.tsx | 58 +++++++++---------- 2 files changed, 42 insertions(+), 59 deletions(-) diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 01aebcf2bb..89bf79020a 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -2,6 +2,7 @@ import { Icon } from '@gitbook/icons'; import { MotionConfig } from 'motion/react'; import React from 'react'; +import { getLocalStorageItem, setLocalStorageItem } from '../../lib/browser/local-storage'; import { useCheckForContentUpdate } from '../AutoRefreshContent'; import { useVisitorSession } from '../Insights'; import { useCurrentPagePath } from '../hooks'; @@ -25,6 +26,8 @@ import { } from './ToolbarControlsContext'; import type { AdminToolbarClientProps, AdminToolbarContext } from './types'; +const STORAGE_KEY = 'gitbook_toolbar_closed'; + export function AdminToolbarClient(props: AdminToolbarClientProps) { const { context, onPersistentClose, onSessionClose, onToggleMinify } = props; const [minified, setMinified] = React.useState(true); @@ -33,14 +36,8 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { const [shouldHide, setShouldHide] = React.useState(false); React.useEffect(() => { - const STORAGE_KEY = 'gitbook_toolbar_closed'; - - try { - const hidden = !!localStorage.getItem(STORAGE_KEY); - setShouldHide(hidden); - } catch { - setShouldHide(false); - } + const hidden = getLocalStorageItem(STORAGE_KEY, false); + setShouldHide(hidden); }, []); const handleSessionClose = React.useCallback(() => { @@ -49,31 +46,21 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { }, [onSessionClose]); const handlePersistentClose = React.useCallback(() => { - try { - localStorage.setItem('gitbook_toolbar_closed', '1'); - } catch { - console.error('Failed to close toolbar using local storage'); - } + setLocalStorageItem(STORAGE_KEY, true); setSessionClosed(true); onPersistentClose?.(); }, [onPersistentClose]); - const handleMinifiedChange = React.useCallback( - (value: boolean) => { - setMinified(value); - onToggleMinify?.(); - }, - [onToggleMinify] - ); + const handleMinifiedChange = (value: boolean) => { + setMinified(value); + onToggleMinify?.(); + }; - const toolbarControls = React.useMemo( - () => ({ - minimize: () => handleMinifiedChange(true), - closeSession: handleSessionClose, - closePersistent: handlePersistentClose, - }), - [handleMinifiedChange, handleSessionClose, handlePersistentClose] - ); + const toolbarControls = { + minimize: () => handleMinifiedChange(true), + closeSession: handleSessionClose, + closePersistent: handlePersistentClose, + }; if (shouldHide || sessionClosed) { return null; diff --git a/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx index 639d19c706..40fc9ed1e8 100644 --- a/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx +++ b/packages/gitbook/src/components/AdminToolbar/HideToolbarButton.tsx @@ -29,7 +29,7 @@ export function HideToolbarButton(props: HideToolbarButtonProps) { const buttonRef = useRef(null); const handleClickOutsideArcMenu = (event: Event) => { - // Don't close the arc if we are clicking clicking on the button itself + // Don't close the arc if we are clicking on the button itself if (buttonRef.current?.contains(event.target as Node)) { return; } @@ -47,36 +47,32 @@ export function HideToolbarButton(props: HideToolbarButtonProps) { return () => window.removeEventListener('scroll', handleScroll); }, [open]); - 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, - [controls] - ); + 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 ? { From c6f60d64a1dd43b5f0b5b6c3f8e71597172b1c09 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Wed, 8 Oct 2025 08:58:10 +0200 Subject: [PATCH 4/5] Improvements to hiding states --- .../AdminToolbar/AdminToolbarClient.tsx | 62 +++---- .../AdminToolbar/AnimatedLogo.module.css | 13 +- .../components/AdminToolbar/AnimatedLogo.tsx | 10 +- .../src/components/AdminToolbar/Toolbar.tsx | 38 ++++- .../AdminToolbar/ToolbarControlsContext.tsx | 1 + .../src/components/AdminToolbar/index.ts | 1 + .../src/components/AdminToolbar/utils.ts | 154 ++++++++++++++++++ .../gitbook/src/lib/browser/local-storage.ts | 74 ++++++++- 8 files changed, 301 insertions(+), 52 deletions(-) create mode 100644 packages/gitbook/src/components/AdminToolbar/utils.ts diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 89bf79020a..118b475ce6 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -1,8 +1,6 @@ 'use client'; import { Icon } from '@gitbook/icons'; import { MotionConfig } from 'motion/react'; -import React from 'react'; -import { getLocalStorageItem, setLocalStorageItem } from '../../lib/browser/local-storage'; import { useCheckForContentUpdate } from '../AutoRefreshContent'; import { useVisitorSession } from '../Insights'; import { useCurrentPagePath } from '../hooks'; @@ -25,44 +23,34 @@ import { ToolbarControlsProvider, } from './ToolbarControlsContext'; import type { AdminToolbarClientProps, AdminToolbarContext } from './types'; - -const STORAGE_KEY = 'gitbook_toolbar_closed'; +import { useToolbarVisibility } from './utils'; export function AdminToolbarClient(props: AdminToolbarClientProps) { const { context, onPersistentClose, onSessionClose, onToggleMinify } = props; - const [minified, setMinified] = React.useState(true); + const { + minified, + setMinified, + shouldAutoExpand, + hidden, + minimize, + closeSession, + closePersistent, + } = useToolbarVisibility({ + onPersistentClose, + onSessionClose, + onToggleMinify, + }); + const visitorSession = useVisitorSession(); - const [sessionClosed, setSessionClosed] = React.useState(false); - const [shouldHide, setShouldHide] = React.useState(false); - - React.useEffect(() => { - const hidden = getLocalStorageItem(STORAGE_KEY, false); - setShouldHide(hidden); - }, []); - - const handleSessionClose = React.useCallback(() => { - setSessionClosed(true); - onSessionClose?.(); - }, [onSessionClose]); - - const handlePersistentClose = React.useCallback(() => { - setLocalStorageItem(STORAGE_KEY, true); - setSessionClosed(true); - onPersistentClose?.(); - }, [onPersistentClose]); - - const handleMinifiedChange = (value: boolean) => { - setMinified(value); - onToggleMinify?.(); - }; - const toolbarControls = { - minimize: () => handleMinifiedChange(true), - closeSession: handleSessionClose, - closePersistent: handlePersistentClose, + const toolbarControls: ToolbarControlsContextValue = { + minimize, + closeSession, + closePersistent, + shouldAutoExpand, }; - if (shouldHide || sessionClosed) { + if (hidden) { return null; } @@ -73,7 +61,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { ); @@ -86,7 +74,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { ); @@ -99,11 +87,13 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { ); } + + return null; } /** diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css index a755b340f3..dc2938ef10 100644 --- a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -11,6 +11,17 @@ vector-effect: non-scaling-stroke; } +.static .trace { + animation: none; + fill: var(--logo-fill); + stroke: none; +} + +.static .seg { + animation: none; + opacity: 0; +} + /* Base segment animation */ .seg { opacity: 0; @@ -171,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 ( { @@ -43,18 +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) { + 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]); + }, [isReady, onMinifiedChange, shouldAutoExpand]); + + 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); + } + }, [minified]); // Don't render anything until page is ready if (!isReady) { @@ -68,6 +97,7 @@ export function Toolbar(props: ToolbarProps) { { if (minified) { + setShouldAnimateLogo(false); onMinifiedChange(false); } }} @@ -94,7 +124,7 @@ export function Toolbar(props: ToolbarProps) { > {/* Logo with stroke segments animation in blue-tints */} - + {!minified ? children : null} diff --git a/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx b/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx index 57fcda92bf..185c04d41b 100644 --- a/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx +++ b/packages/gitbook/src/components/AdminToolbar/ToolbarControlsContext.tsx @@ -5,6 +5,7 @@ export interface ToolbarControlsContextValue { minimize: () => void; closeSession?: () => void; closePersistent?: () => void; + shouldAutoExpand?: boolean; } const ToolbarControlsContext = React.createContext(null); 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/utils.ts b/packages/gitbook/src/components/AdminToolbar/utils.ts new file mode 100644 index 0000000000..b90dfb1969 --- /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'; + +export 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 || (!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); +} From cc5615b03ded48e9ab7cb0db226949b434277e0e Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Wed, 8 Oct 2025 09:30:25 +0200 Subject: [PATCH 5/5] Fixes --- packages/gitbook/src/components/AdminToolbar/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/AdminToolbar/utils.ts b/packages/gitbook/src/components/AdminToolbar/utils.ts index b90dfb1969..9778d5b452 100644 --- a/packages/gitbook/src/components/AdminToolbar/utils.ts +++ b/packages/gitbook/src/components/AdminToolbar/utils.ts @@ -8,7 +8,7 @@ import { setSessionStorageItem, } from '@/lib/browser/local-storage'; -export const STORAGE_KEY = 'gitbook_toolbar_closed'; +const STORAGE_KEY = 'gitbook_toolbar_closed'; const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed'; const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified'; @@ -140,7 +140,7 @@ export function useToolbarVisibility(options: UseToolbarVisibilityOptions = {}) onPersistentClose?.(); }; - const hidden = persistentHidden || (!persistentHidden && sessionReason === 'session'); + const hidden = persistentHidden || sessionReason === 'session'; return { minified,