Compare commits

...

2 Commits

Author SHA1 Message Date
Viktor Renkema c5d1bbfafe wip 2026-02-12 09:40:27 +01:00
Viktor Renkema dac5e9d444 Fix logo sizing bug during navigation 2026-01-21 20:25:17 +01:00
8 changed files with 975 additions and 203 deletions
@@ -4,7 +4,6 @@ import { MotionConfig } from 'motion/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';
@@ -22,6 +21,7 @@ import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
@@ -132,7 +132,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -141,7 +141,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle
subtitle={
<>
<DateRelative value={changeRequest.updatedAt} /> by {author}
<ToolbarDate value={changeRequest.updatedAt} /> by {author}
</>
}
/>
@@ -207,13 +207,13 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
Created <ToolbarDate value={revision.createdAt} />
</>
}
/>
@@ -280,20 +280,10 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
@@ -305,13 +295,13 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open site in GitBook */}
<ToolbarButton
title="Open site in GitBook"
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="gears"
icon="folder-gear"
/>
{/* Customize in GitBook */}
@@ -361,13 +351,13 @@ function EditPageButton(props: {
return (
<ToolbarButton
title="Edit in GitBook"
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pencil"
icon="pen-to-square"
motionValues={motionValues}
/>
);
@@ -8,10 +8,20 @@ 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;
const ARC_PARAMS = {
arcWidth: 505,
arcHeight: 400,
arcRadius: 34,
startDistance: -240,
spreadDistance: 45,
fromDistance: -286,
fromSpread: 0,
durationSeconds: 0.6,
staggerMs: 80,
baseRotationDeg: 95,
rotationStepDeg: 18,
offsetAnchorY: 40,
} as const;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
@@ -23,30 +33,75 @@ interface HideToolbarButtonProps {
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const [shouldMirror, setShouldMirror] = React.useState(false);
const [shouldFlipVertical, setShouldFlipVertical] = React.useState(false);
const controls = useToolbarControls();
const closingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const close = React.useCallback(() => {
if (!open || closing) return;
setClosing(true);
// Clear any existing timeout
if (closingTimeoutRef.current) {
clearTimeout(closingTimeoutRef.current);
}
// Wait for the exit animation to complete before unmounting
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
closingTimeoutRef.current = setTimeout(() => {
setOpen(false);
setClosing(false);
closingTimeoutRef.current = null;
}, totalDuration);
}, [open, closing]);
// Clean up timeout on unmount
React.useEffect(() => {
return () => {
if (closingTimeoutRef.current) {
clearTimeout(closingTimeoutRef.current);
}
};
}, []);
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);
close();
};
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
// Measure button position to decide arc direction when opening
React.useEffect(() => {
if (open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const distanceFromRight = window.innerWidth - rect.right;
setShouldMirror(distanceFromRight < 250);
setShouldFlipVertical(rect.top < 250);
}
}, [open]);
// Close arc menu on scroll or resize
React.useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
const handleClose = () => close();
window.addEventListener('scroll', handleClose, { passive: true });
window.addEventListener('resize', handleClose, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
return () => {
window.removeEventListener('scroll', handleClose);
window.removeEventListener('resize', handleClose);
};
}, [open, close]);
const items = [
controls?.minimize
@@ -84,36 +139,80 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
title={open ? undefined : 'Hide toolbar'}
className={
open || closing
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
: undefined
}
onClick={() => {
setOpen((v) => !v);
if (open || closing) {
close();
} else {
setOpen(true);
}
}}
motionValues={motionValues}
icon="eye-slash"
icon="gear"
>
{/* Expanding arc menu */}
{open && (
{(open || closing) && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
style={
{
...sharedMotionStyle,
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
'--start-distance': `${ARC_PARAMS.startDistance}px`,
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
} as React.CSSProperties
}
>
<div
className={tcls(
'pointer-events-none absolute left-0 overflow-visible',
'pointer-events-none absolute overflow-visible',
shouldMirror ? '' : 'left-0',
styles.arcMenuPath
)}
style={
{
...(shouldMirror
? {
'--arc-mirror-offset': '-345px',
'--spread-distance': '37px',
right: 'var(--arc-mirror-offset)',
}
: undefined),
...(shouldFlipVertical
? {
bottom: 'auto',
top: 'calc(var(--arc-height) / -2)',
transform: 'scaleY(-1)',
}
: undefined),
} as React.CSSProperties
}
ref={ref}
>
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
key={item.icon}
staggerIndex={closing ? index : items.length - 1 - index}
key={item.id}
mirrored={shouldMirror}
flippedVertical={shouldFlipVertical}
closing={closing}
{...item}
onClick={() => {
setOpen(false);
item.onClick?.();
}}
onClick={
item.isLabel
? undefined
: () => {
close();
item.onClick?.();
}
}
/>
))}
</div>
@@ -127,13 +226,17 @@ type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
description?: string;
onClick?: () => void;
isLabel?: boolean;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick' | 'isLabel'> & {
index: number;
staggerIndex?: number;
mirrored?: boolean;
flippedVertical?: boolean;
closing?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
@@ -143,40 +246,41 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
mirrored = false,
flippedVertical = false,
closing = false,
label,
disabled,
className,
onClick = () => {},
onClick,
icon,
iconClassName,
isLabel = false,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
const itemRotation = calculateRotation();
const Tag = isLabel ? 'div' : 'button';
return (
<div className="pointer-events-none">
<button
type="button"
onClick={() => {
onClick();
}}
<Tag
{...(Tag === 'button' ? { type: 'button' as const } : {})}
onClick={onClick ? () => onClick() : undefined}
style={
{
'--from-offset-distance': fromOffset,
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
offsetDistance: fromOffset,
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
offsetRotate: 'auto 90deg',
} as React.CSSProperties
}
className={tcls(
@@ -185,49 +289,61 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
mirrored ? 'flex-row-reverse' : '',
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'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-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
className="flex items-center gap-2"
style={flippedVertical ? { transform: 'scaleY(-1)' } : undefined}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
{isLabel ? (
<div className="flex items-center gap-2 rounded-lg bg-[rgba(79,139,255,0.1)] px-2 py-1 text-[#4F8BFF] backdrop-blur-sm">
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className="size-3.5 shrink-0"
/>
<span className="whitespace-nowrap font-normal text-sm">{label}</span>
</div>
) : (
<>
<div
className={tcls(
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls(
'size-3.5 shrink-0 group-hover:scale-110',
iconClassName
)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
)}
>
{label}
</span>
</>
)}
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</Tag>
</div>
);
}
@@ -17,7 +17,7 @@
.arcMenuItem {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: forwards;
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
@@ -28,13 +28,101 @@
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
to {
offset-distance: var(--target-offset-distance);
transform: scale(1);
opacity: 1;
offset-rotate: auto var(--rotation-offset);
}
}
@keyframes hide-toolbar-arc-exit {
from {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
}
to {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
}
.arcMenuItemExit {
animation-name: hide-toolbar-arc-exit;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.3s);
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);
}
/* ── Arc label fade ── */
@keyframes arc-label-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes arc-label-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.arcLabelFadeIn {
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
}
.arcLabelFadeOut {
animation: arc-label-fade-out 0.2s ease-out forwards;
}
/* ── Glass effect for the toolbar pill ── */
.glassLayer {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
z-index: -1;
isolation: isolate;
}
.glassLayer::before {
content: '';
position: absolute;
inset: -50%;
pointer-events: none;
-webkit-backdrop-filter: blur(9px);
backdrop-filter: blur(9px);
filter: url(#glass-distortion);
}
.glassLayer::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: rgba(39, 39, 39, 0.55);
box-shadow:
inset 2px 2px 1px rgba(255, 255, 255, 0.08),
inset -1px -1px 1px rgba(255, 255, 255, 0.06);
}
:global(.dark) .glassLayer::after {
background: rgba(30, 30, 30, 0.6);
box-shadow:
inset 2px 2px 1px rgba(255, 255, 255, 0.05),
inset -1px -1px 1px rgba(255, 255, 255, 0.04);
}
@media (prefers-reduced-motion: reduce) {
.glassLayer::before {
filter: none;
}
}
@@ -2,39 +2,71 @@
import {
AnimatePresence,
type MotionValue,
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import {
getStoredPosition,
getVisibilityHintDismissed,
setStoredPosition,
setVisibilityHintDismissed,
} from './utils';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { Tooltip } from '../primitives';
// import styles from './Toolbar.module.css';
import { getCopyVariants, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect';
const DEBUG = true;
const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
const ToolbarDraggingContext = React.createContext(false);
interface ToolbarProps {
label: React.ReactNode;
children: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, label, minified, onMinifiedChange } = props;
const { children, minified, onMinifiedChange } = props;
const controls = useToolbarControls();
const [isReady, setIsReady] = React.useState(false);
const autoExpandTriggeredRef = React.useRef(false);
const constraintsRef = React.useRef<HTMLDivElement>(null);
const innerRef = React.useRef<HTMLDivElement>(null);
const isDraggingRef = React.useRef(false);
const [isDragging, setIsDragging] = React.useState(false);
const prevWidthRef = React.useRef<number | undefined>(undefined);
const [hintDismissed, setHintDismissed] = React.useState(() =>
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
);
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
// Restore saved drag position (synchronous read — no flash)
const storedPos = React.useMemo(
() => (typeof window !== 'undefined' ? getStoredPosition() : null),
[]
);
const x = useMotionValue(storedPos?.x ?? 0);
const y = useMotionValue(storedPos?.y ?? 0);
const savePosition = React.useCallback(() => {
setStoredPosition({ x: x.get(), y: y.get() });
}, [x, y]);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
@@ -80,58 +112,432 @@ export function Toolbar(props: ToolbarProps) {
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.
// "settled" state once the toolbar is open.
setShouldAnimateLogo(false);
}
}, [minified]);
// Detect pinning state based on actual viewport position
const debugRef = React.useRef<HTMLDivElement>(null);
const debugLeftEdgeRef = React.useRef<HTMLDivElement>(null);
const debugRightEdgeRef = React.useRef<HTMLDivElement>(null);
const debugArrowRef = React.useRef<HTMLDivElement>(null);
const lastDecisionRef = React.useRef<'left' | 'right' | 'center'>('center');
const computePinState = React.useCallback(
(overrideWidth?: number) => {
if (!innerRef.current) return 'center' as const;
const rect = innerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const edgeThreshold = 80; // px from viewport edge
// When overrideWidth is provided (e.g. prevWidth), reconstruct where the
// edges WERE before the resize. The center stays the same because x hasn't
// been compensated yet and flexbox centering is width-independent.
let left = rect.left;
let right = rect.right;
if (overrideWidth !== undefined) {
const center = (rect.left + rect.right) / 2;
left = center - overrideWidth / 2;
right = center + overrideWidth / 2;
}
const pin =
right > viewportWidth - edgeThreshold
? ('right' as const)
: left < edgeThreshold
? ('left' as const)
: ('center' as const);
// Update debug overlays directly via DOM to avoid React re-render lag
if (DEBUG) {
// Text overlay
if (debugRef.current) {
const el = debugRef.current;
el.textContent = '';
const decisionColor = lastDecisionRef.current === 'center' ? '#facc15' : '#4ade80';
const liveColor = pin === 'center' ? '#facc15' : '#4ade80';
const preInfo =
overrideWidth !== undefined
? ` | pre: L${Math.round(left)} R${Math.round(right)}`
: '';
el.innerHTML = `decision: <span style="color:${decisionColor}">${lastDecisionRef.current}</span> | live: <span style="color:${liveColor}">${pin}</span> | x: ${Math.round(x.get())} y: ${Math.round(y.get())} | rect: L${Math.round(rect.left)} R${Math.round(rect.right)} (vw: ${viewportWidth})${preInfo}`;
}
// Edge anchor indicators on the toolbar pill (track live state)
if (debugLeftEdgeRef.current) {
debugLeftEdgeRef.current.style.backgroundColor =
pin === 'left' ? '#4ade80' : '#ffffff20';
debugLeftEdgeRef.current.style.boxShadow =
pin === 'left' ? '0 0 8px #4ade80' : 'none';
}
if (debugRightEdgeRef.current) {
debugRightEdgeRef.current.style.backgroundColor =
pin === 'right' ? '#4ade80' : '#ffffff20';
debugRightEdgeRef.current.style.boxShadow =
pin === 'right' ? '0 0 8px #4ade80' : 'none';
}
// Arrow showing expansion direction (tracks live state so it
// updates as you drag, showing what WOULD happen on click)
if (debugArrowRef.current) {
debugArrowRef.current.textContent =
pin === 'left'
? '→'
: pin === 'right'
? '←'
: '↔';
debugArrowRef.current.style.color =
pin === 'center' ? '#facc15' : '#4ade80';
}
}
return pin;
},
[x, y]
);
// Update debug display reactively when x/y change (direct DOM updates, no re-renders)
React.useEffect(() => {
if (!DEBUG) return;
const unsubX = x.on('change', () => computePinState());
const unsubY = y.on('change', () => computePinState());
// Initial computation
computePinState();
return () => {
unsubX();
unsubY();
};
}, [x, y, computePinState]);
// Compensate drag x position when the toolbar width changes (expand/collapse) so the
// pinned edge stays anchored. Uses viewport-aware edge detection instead of a fixed
// x-threshold so it works correctly at any viewport width.
React.useLayoutEffect(() => {
if (!innerRef.current) return;
const newWidth = innerRef.current.offsetWidth;
const prevWidth = prevWidthRef.current;
if (prevWidth !== undefined && prevWidth !== newWidth) {
const delta = newWidth - prevWidth;
const currentX = x.get();
const currentY = y.get();
const expanding = newWidth > prevWidth;
// Grab the raw post-resize rect for logging
const rawRect = innerRef.current.getBoundingClientRect();
const center = (rawRect.left + rawRect.right) / 2;
const preResizeLeft = center - prevWidth / 2;
const preResizeRight = center + prevWidth / 2;
const viewportWidth = window.innerWidth;
const edgeThreshold = 80;
// Use prevWidth to reconstruct where the edges WERE before the resize.
const pin = computePinState(prevWidth);
lastDecisionRef.current = pin;
// Refresh debug overlay so "decision" label updates immediately
computePinState(prevWidth);
let compensation = 0;
if (pin === 'right') {
compensation = -delta / 2;
} else if (pin === 'left') {
compensation = delta / 2;
}
// eslint-disable-next-line no-console -- temporary debug logging
console.warn(
`[Toolbar Pin] ${expanding ? 'EXPAND' : 'COLLAPSE'}\n` +
` Action: ${expanding ? 'minified → expanded' : 'expanded → minified'}\n` +
` Width: ${prevWidth}px → ${newWidth}px (delta: ${delta}px)\n` +
` Motion: x=${Math.round(currentX)}, y=${Math.round(currentY)}\n` +
` Viewport: ${viewportWidth}px, edgeThreshold: ${edgeThreshold}px\n` +
` Post-resize rect (raw): L=${Math.round(rawRect.left)} R=${Math.round(rawRect.right)}\n` +
` Pre-resize rect (reconstructed): L=${Math.round(preResizeLeft)} R=${Math.round(preResizeRight)}\n` +
` Center: ${Math.round(center)}\n` +
` Pin check: left=${Math.round(preResizeLeft)} < ${edgeThreshold}? ${preResizeLeft < edgeThreshold} | right=${Math.round(preResizeRight)} > ${viewportWidth - edgeThreshold}? ${preResizeRight > viewportWidth - edgeThreshold}\n` +
` → Decision: ${pin}\n` +
` → Compensation: ${compensation}px (x: ${Math.round(currentX)}${Math.round(currentX + compensation)})`
);
if (compensation !== 0) {
animate(x, currentX + compensation, {
type: 'spring',
stiffness: 200,
damping: 30,
mass: 1,
}).then(savePosition);
}
}
prevWidthRef.current = newWidth;
}, [minified, x, savePosition, computePinState]);
// Don't render anything until page is ready
if (!isReady) {
return null;
}
return (
<Tooltip label={label}>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
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 */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
<ToolbarDraggingContext.Provider value={isDragging}>
{/* Hidden SVG filter for glass distortion */}
<svg aria-hidden="true" className="pointer-events-none fixed size-0">
<defs>
<filter id="glass-distortion">
<feTurbulence
type="fractalNoise"
baseFrequency="0.016 0.012"
numOctaves={3}
seed={39}
result="noise"
/>
<feGaussianBlur in="noise" stdDeviation={6} result="softNoise" />
<feDisplacementMap
in="SourceGraphic"
in2="softNoise"
scale={160}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</defs>
</svg>
<div
ref={constraintsRef}
className="pointer-events-none fixed inset-2 z-40 flex items-end justify-center"
>
<motion.div
drag
dragConstraints={constraintsRef}
dragElastic={0.04}
dragTransition={{
power: 0.2,
timeConstant: 200,
bounceStiffness: 800,
bounceDamping: 60,
}}
style={{ x, y }}
onDragStart={() => {
isDraggingRef.current = true;
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
requestAnimationFrame(() => {
isDraggingRef.current = false;
});
}}
onDragTransitionEnd={savePosition}
className="pointer-events-auto relative w-auto max-w-xl cursor-grab active:cursor-grabbing"
>
{/* Visibility peek label — rendered as a sibling before the pill so it paints behind it */}
{!hintDismissed && (
<motion.div
initial={false}
animate={{
y: !minified && !isDragging ? 0 : 10,
opacity: !minified && !isDragging ? 1 : 0,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3"
style={{ paddingBlock: '2px' }}
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
Only visible to your GitBook organization
</span>
<button
type="button"
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
onClick={(e) => {
e.stopPropagation();
setVisibilityHintDismissed();
setHintDismissed(true);
}}
>
Dismiss
</button>
</motion.div>
)}
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
<AnimatePresence mode="wait">
<motion.div
ref={innerRef}
onClick={() => {
if (isDraggingRef.current) return;
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={
{
'--toolbar-bg': '#1f1d1b',
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
zIndex: 1, // Ensure pill stacks above the peek label sibling
} as React.CSSProperties
}
>
{/* Glass effect layer (disabled for now) */}
{/* <div className={styles.glassLayer} /> */}
{/* Debug: anchor edge indicators */}
{DEBUG && (
<>
<div
ref={debugLeftEdgeRef}
style={{
position: 'absolute',
left: -1,
top: '15%',
bottom: '15%',
width: 3,
borderRadius: 2,
backgroundColor: '#ffffff20',
transition: 'background-color 0.2s, box-shadow 0.2s',
zIndex: 10,
}}
/>
<div
ref={debugRightEdgeRef}
style={{
position: 'absolute',
right: -1,
top: '15%',
bottom: '15%',
width: 3,
borderRadius: 2,
backgroundColor: '#ffffff20',
transition: 'background-color 0.2s, box-shadow 0.2s',
zIndex: 10,
}}
/>
<div
ref={debugArrowRef}
style={{
position: 'absolute',
top: -20,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 16,
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 10,
}}
/>
</>
)}
{/* Logo — double-click to minimize without opening the arc menu */}
<motion.div
layout
onDoubleClick={(e) => {
if (isDraggingRef.current || minified) return;
e.stopPropagation();
onMinifiedChange(true);
}}
>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
{/* Debug overlay for pinning state — updated via DOM ref to avoid re-render lag */}
{DEBUG && (
<>
<div
ref={debugRef}
className="pointer-events-none whitespace-nowrap rounded bg-black/80 px-2 py-1 font-mono text-[10px] text-white"
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 9999,
}}
/>
{/* Viewport edge threshold zones */}
<div
style={{
position: 'fixed',
left: 0,
top: 0,
width: 80,
height: '100vh',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
borderRight: '1px dashed rgba(74, 222, 128, 0.3)',
pointerEvents: 'none',
zIndex: 39,
}}
>
<span
style={{
position: 'absolute',
bottom: 80,
right: 4,
fontSize: 9,
color: 'rgba(74, 222, 128, 0.5)',
writingMode: 'vertical-rl',
fontFamily: 'monospace',
}}
>
pin-left zone (80px)
</span>
</div>
<div
style={{
position: 'fixed',
right: 0,
top: 0,
width: 80,
height: '100vh',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
borderLeft: '1px dashed rgba(74, 222, 128, 0.3)',
pointerEvents: 'none',
zIndex: 39,
}}
>
<span
style={{
position: 'absolute',
bottom: 80,
left: 4,
fontSize: 9,
color: 'rgba(74, 222, 128, 0.5)',
writingMode: 'vertical-rl',
fontFamily: 'monospace',
}}
>
pin-right zone (80px)
</span>
</div>
</>
)}
</div>
</ToolbarDraggingContext.Provider>
);
}
@@ -197,63 +603,53 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
children,
} = props;
const reduceMotion = useReducedMotion();
const isDragging = React.useContext(ToolbarDraggingContext);
const anchor = (
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
{title && !isDragging ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
);
});
@@ -339,7 +735,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
@@ -0,0 +1,142 @@
'use client';
import React from 'react';
import { useLanguage } from '@/intl/client';
import { AnimatePresence, motion } from 'motion/react';
type DateFormat = 'relative' | 'weekday' | 'full';
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
/**
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
*/
export function ToolbarDate(props: { value: string }) {
const { value } = props;
const language = useLanguage();
const [now, setNow] = React.useState<number>(Date.now());
const [formatIndex, setFormatIndex] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(
() => {
setNow(Date.now());
},
30 * 60 * 1000
);
return () => {
clearInterval(interval);
};
}, []);
const date = new Date(value);
const format = DATE_FORMATS[formatIndex] as DateFormat;
const formatted = React.useMemo(() => {
return formatDateValue(format, language.locale, now, date);
}, [format, language.locale, now, date]);
return (
<div className="inline-flex items-center gap-1">
{/* Vertical dots indicating cycleable formats */}
<div className="flex flex-col items-center gap-[1px]">
{DATE_FORMATS.map((fmt, i) => (
<motion.span
key={fmt}
animate={{
opacity: i === formatIndex ? 1 : 0.3,
scale: i === formatIndex ? 1 : 0.75,
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="block size-[3px] rounded-full bg-current"
/>
))}
</div>
<time
data-visual-test="transparent"
suppressHydrationWarning={true}
dateTime={value}
onClick={(e) => {
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}
}}
className="relative inline-flex min-w-24 cursor-pointer overflow-hidden font-semibold transition-colors hover:text-white"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={formatIndex}
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
{formatted}
</motion.span>
</AnimatePresence>
</time>
</div>
);
}
function formatDateValue(format: DateFormat, locale: string, now: number, date: Date): string {
switch (format) {
case 'relative':
return formatRelative(locale, now - date.getTime());
case 'weekday':
return date.toLocaleDateString(locale, {
weekday: 'long',
month: 'short',
day: 'numeric',
year: 'numeric',
});
case 'full':
return date.toLocaleString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
}
function formatRelative(locale: string, diff: number) {
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
return `${days} days ago`;
}
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'long' });
if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / 60 / 1000);
return rtf.format(-minutes, 'minute');
}
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / 60 / 60 / 1000);
return rtf.format(-hours, 'hour');
}
if (diff < 30 * 24 * 60 * 60 * 1000) {
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
return rtf.format(-days, 'day');
}
if (diff < 365 * 24 * 60 * 60 * 1000) {
const months = Math.floor(diff / 30 / 24 / 60 / 60 / 1000);
return rtf.format(-months, 'month');
}
const years = Math.floor(diff / 365 / 24 / 60 / 60 / 1000);
return rtf.format(-years, 'year');
}
@@ -11,6 +11,8 @@ import {
const STORAGE_KEY = 'gitbook_toolbar_closed';
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
const SESSION_POSITION_KEY = 'gitbook_toolbar_position';
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
type SessionHideReason = 'session' | 'persistent';
@@ -57,6 +59,35 @@ export const setStoredMinified = (value: boolean) => {
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
};
/**
* Retrieve the last drag position from session storage. Returns `null` when no position has been
* stored, meaning the toolbar should use its default center-bottom placement.
*/
export const getStoredPosition = (): { x: number; y: number } | null => {
return getSessionStorageItem<{ x: number; y: number } | null>(SESSION_POSITION_KEY, null);
};
/**
* Persist the current drag position for the ongoing session.
*/
export const setStoredPosition = (position: { x: number; y: number }) => {
setSessionStorageItem(SESSION_POSITION_KEY, position);
};
/**
* Check whether the user has dismissed the "only you can see this" hint.
*/
export const getVisibilityHintDismissed = (): boolean => {
return getLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
};
/**
* Persist that the user dismissed the visibility hint.
*/
export const setVisibilityHintDismissed = () => {
setLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
};
interface UseToolbarVisibilityOptions {
onPersistentClose?: () => void;
onSessionClose?: () => void;
@@ -65,6 +65,8 @@ export function PageBody(props: {
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
).length > 0;
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
return (
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
<main
@@ -76,12 +78,10 @@ export function PageBody(props: {
'@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents && hasVisibleTOCItems
? 'page-has-toc'
: 'page-no-toc'
pageHasToc ? 'page-has-toc' : 'page-no-toc'
)}
>
<PreservePageLayout siteWidthWide={siteWidthWide} />
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -11,9 +11,10 @@ import * as React from 'react';
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
*
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
* It also preserves the page TOC state (page-has-toc/page-no-toc) to prevent logo sizing issues during navigation.
*/
export function PreservePageLayout(props: { siteWidthWide: boolean }) {
const { siteWidthWide } = props;
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
const { siteWidthWide, pageHasToc } = props;
React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions
@@ -28,7 +29,15 @@ export function PreservePageLayout(props: { siteWidthWide: boolean }) {
} else {
header.classList.remove('site-width-wide');
}
}, [siteWidthWide]);
if (pageHasToc) {
header.classList.add('page-has-toc');
header.classList.remove('page-no-toc');
} else {
header.classList.add('page-no-toc');
header.classList.remove('page-has-toc');
}
}, [siteWidthWide, pageHasToc]);
return null;
}