Compare commits

..

2 Commits

Author SHA1 Message Date
Tomek Gargula dd9179db11 changeset 2026-10-20 13:06:14 +02:00
Tomek Gargula 90f6a3cc89 Adjust tab panel and tab button padding 2026-10-20 13:06:10 +02:00
14 changed files with 328 additions and 312 deletions
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix admin toolbar magnification, tooltip placement, title truncation and logo colour.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Adjust tab panel and tab button padding so tabbed content has more breathing room.
@@ -15,6 +15,7 @@ import {
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
@@ -136,21 +137,14 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`#${changeRequest.number}`}
prefixLabel={`Change request #${changeRequest.number}`}
suffix={changeRequest.subject || 'Untitled'}
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span
layout="position"
title={author}
className="min-w-0 truncate"
>
by {author}
</motion.span>
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
@@ -345,8 +339,12 @@ function ToolbarActions(props: { children: React.ReactNode }) {
);
}
function EditPageButton(props: { href: string; siteId: string }) {
const { href, siteId } = props;
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
@@ -358,6 +356,7 @@ function EditPageButton(props: { href: string; siteId: string }) {
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
@@ -1,6 +1,5 @@
.svgLogo {
/* The toolbar is always dark, so the logo is always light. */
--logo-fill: rgb(255 255 255 / 0.92);
--logo-fill: var(--color-neutral-100);
--seg-A-color: #2782c4;
--seg-B-color: #43b7f2;
--seg-C-color: #8be2ff;
@@ -11,14 +11,8 @@ export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
const { shouldAnimate = true } = props;
return (
// Light even without the CSS module, which loads with the lazy toolbar chunk and can be
// missing (e.g. after a hot reload): an SVG path without a fill renders black.
<svg
className={tcls(
'text-white/90',
styles.svgLogo,
shouldAnimate ? undefined : styles.static
)}
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)}
width="28"
height="28"
viewBox="0 0 28 28"
@@ -28,7 +22,6 @@ export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
<path
d={dPath}
transform="translate(14,14) scale(0.4)"
fill="currentColor"
strokeWidth={3}
pathLength={1}
strokeLinecap="round"
@@ -6,9 +6,8 @@ import type { SVGProps } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
import { useToolbarSlot } from './Toolbar';
import type { ToolbarButtonProps } from './Toolbar';
import type { MinimalChangedPage, MinimalChangedPages } from './types';
import { TOOLTIP_OFFSET } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind';
const STATUS_ICON_STYLES: Record<
@@ -43,10 +42,12 @@ const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center
/**
* A button that opens a popover with quick links to the _changed_ pages.
*/
export function ChangedPagesButton(props: { changedPages: MinimalChangedPages | null }) {
const { changedPages } = props;
export function ChangedPagesButton(props: {
changedPages: MinimalChangedPages | null;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { changedPages, motionValues } = props;
const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
if (!changedPages?.pages.length) {
return null;
@@ -58,19 +59,30 @@ export function ChangedPagesButton(props: { changedPages: MinimalChangedPages |
}`;
const trigger = (
<motion.button
<button
type="button"
aria-label="View changed pages"
style={
reduceMotion || !slot
? undefined
: { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center' }
}
className="relative size-8 cursor-pointer border-0 bg-transparent p-0"
>
<span
<motion.span
aria-hidden="true"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
@@ -79,18 +91,14 @@ export function ChangedPagesButton(props: { changedPages: MinimalChangedPages |
)}
>
<DiffIcon className="size-4 shrink-0" />
</span>
</motion.button>
</motion.span>
</button>
);
return (
<DropdownMenu
button={trigger}
buttonTooltip="View changed pages"
// Like the other toolbar buttons: against the unscaled slot, with room to magnify.
buttonTooltipProps={
slot ? { anchor: slot.anchorRef, sideOffset: TOOLTIP_OFFSET } : undefined
}
side="top"
align="end"
sideOffset={12}
@@ -5,7 +5,7 @@ import { useOnClickOutside } from 'usehooks-ts';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { ToolbarButton, useToolbarSlot } from './Toolbar';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
import { tcls } from '@/lib/tailwind';
@@ -26,11 +26,15 @@ const ARC_PARAMS = {
offsetAnchorY: 40,
} as const;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
}
/**
* Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions.
*/
export function HideToolbarButton() {
const slot = useToolbarSlot();
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls();
@@ -110,8 +114,11 @@ export function HideToolbarButton() {
: null,
].filter(Boolean) as ArcMenuItem[];
// The arc follows the button's push, but not its scale.
const sharedMotionStyle = slot ? { x: slot.x } : undefined;
const sharedMotionStyle = motionValues
? {
x: motionValues.x,
}
: undefined;
return (
<ToolbarButton
@@ -129,6 +136,7 @@ export function HideToolbarButton() {
setOpen(true);
}
}}
motionValues={motionValues}
icon="gear"
>
{/* Expanding arc menu */}
@@ -1,13 +1,16 @@
'use client';
import React from 'react';
import { ToolbarButton } from './Toolbar';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshContentButton(props: { refreshForUpdates: () => Promise<void> }) {
const { refreshForUpdates } = props;
export function RefreshContentButton(props: {
refreshForUpdates: () => Promise<void>;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { refreshForUpdates, motionValues } = props;
const [loading, setLoading] = React.useState(false);
@@ -31,6 +34,7 @@ export function RefreshContentButton(props: { refreshForUpdates: () => Promise<v
refresh();
}}
disabled={loading}
motionValues={motionValues}
icon="rotate"
iconClassName={loading ? 'animate-spin' : undefined}
/>
@@ -1,6 +1,12 @@
'use client';
import { type MotionValue, motion, useReducedMotion, useSpring } from 'motion/react';
import React from 'react';
import {
AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -9,7 +15,7 @@ import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { getCopyVariants, toolbarEasings } from './transitions';
import { TOOLTIP_OFFSET, useMagnificationEffect } from './useMagnificationEffect';
import { useMagnificationEffect } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind';
const DURATION_LOGO_APPEARANCE = 2000;
@@ -102,93 +108,63 @@ export function Toolbar(props: ToolbarProps) {
return (
<motion.div
data-testid="admin-toolbar"
// Centered with `inset-x-0 mx-auto`: at `left-1/2` it could only grow to half the viewport,
// which pushed the logo out of the pill.
className="fixed inset-x-0 bottom-5 z-40 mx-auto w-fit max-w-xl px-4"
className="fixed bottom-5 left-1/2 z-40 w-auto max-w-xl -translate-x-1/2 transform px-4"
>
<ToolbarVisibilityHint show={showHint} />
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
// Only minifying and expanding animate: content width changes (e.g. the date
// format) would otherwise run a bouncy scale animation that stretches the content.
layout
layoutDependency={minified}
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
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={PILL_STYLE}
>
{/* Logo — double-click to minimize */}
<AnimatePresence mode="wait">
<motion.div
layout
layoutDependency={minified}
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
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={PILL_STYLE}
>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{/* A layout child, so it's counter-scaled while the pill expands. */}
{!minified ? (
{/* Logo — double-click to minimize */}
<motion.div
layout
layoutDependency={minified}
className="flex min-w-0 items-center"
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}}
>
{children}
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
) : null}
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
export function ToolbarBody(props: { children: React.ReactNode }) {
// Shrinks so a long title truncates, but keeps room for the longest date format.
return <div className="flex min-w-40 flex-col items-start px-3">{props.children}</div>;
}
const MAGNIFICATION_SPRING = { stiffness: 400, damping: 30 };
/** A button's magnification, and the unscaled box to place its tooltip against. */
type ToolbarSlot = {
scale: MotionValue<number>;
x: MotionValue<number>;
anchorRef: React.RefObject<HTMLDivElement | null>;
};
const ToolbarSlotContext = React.createContext<ToolbarSlot | null>(null);
/** The magnification of the toolbar button group slot this is rendered in, if any. */
export function useToolbarSlot() {
return React.useContext(ToolbarSlotContext);
return <div className="flex flex-col items-start px-3">{props.children}</div>;
}
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
@@ -196,8 +172,8 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
const { motionValues } = useMagnificationEffect({
slotCount: buttonChildren.length,
const { buttonMotionValues } = useMagnificationEffect({
childrenCount: buttonChildren.length,
containerRef,
});
@@ -207,51 +183,28 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
variants={toolbarEasings.parent}
initial="hidden"
animate="show"
// `relative`: the magnification measures slots by their `offsetLeft` in here.
className="relative flex items-center gap-1 overflow-visible pl-4 pr-2"
className="flex items-center gap-1 overflow-visible pl-4 pr-2"
>
{buttonChildren.map((child, index) => {
const values = motionValues[index];
return values ? (
<ToolbarSlot
key={React.isValidElement(child) && child.key ? child.key : index}
scale={values.scale}
x={values.x}
>
{child}
</ToolbarSlot>
) : null;
const childEl = child as React.ReactElement;
const childKey = childEl.key ?? `toolbar-button-${index}`;
return (
<ToolbarButtonWrapper
key={childKey}
child={childEl}
rawMotionValues={buttonMotionValues[index]}
/>
);
})}
</motion.div>
);
}
/**
* One button of a group: staggers in, and smooths its magnification for the button inside.
* Collapses when the button renders nothing, so it leaves no gap.
*/
function ToolbarSlot(props: {
scale: MotionValue<number>;
x: MotionValue<number>;
children: React.ReactNode;
}) {
const anchorRef = React.useRef<HTMLDivElement>(null);
const scale = useSpring(props.scale, MAGNIFICATION_SPRING);
const x = useSpring(props.x, MAGNIFICATION_SPRING);
const slot = React.useMemo(() => ({ scale, x, anchorRef }), [scale, x]);
return (
<motion.div
ref={anchorRef}
variants={toolbarEasings.staggeringChild}
className="relative empty:hidden"
>
<ToolbarSlotContext.Provider value={slot}>{props.children}</ToolbarSlotContext.Provider>
</motion.div>
);
}
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
motionValues?: {
scale: MotionValue<number>;
x: MotionValue<number>;
};
icon: IconName;
iconClassName?: string;
title?: React.ReactNode;
@@ -259,10 +212,19 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
}
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
const { title, disabled, className, style, href, onClick, icon, iconClassName, children } =
props;
const {
title,
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
const anchor = (
<motion.a
@@ -271,11 +233,23 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion || !slot
? style
: { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center', ...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',
@@ -288,68 +262,75 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0', iconClassName)}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<div className="relative" ref={ref}>
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
{/* Anchored to the unscaled slot with room for full magnification, so the gap doesn't
depend on how far the spring has grown when the tooltip opens. */}
{title ? (
<Tooltip
label={title}
anchor={slot?.anchorRef}
sideOffset={slot ? TOOLTIP_OFFSET : 4}
>
{anchor}
</Tooltip>
) : (
anchor
)}
</div>
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
);
});
ToolbarButton.displayName = 'ToolbarButton';
export function ToolbarTitle(props: {
prefix?: string;
/** What a short prefix stands for, e.g. "Change request #54" for "#54". */
prefixLabel?: string;
suffix: string;
function ToolbarButtonWrapper(props: {
child: React.ReactElement;
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
}) {
const { prefix, prefixLabel, suffix } = props;
// One tooltip for the whole title: the short prefix spelled out, and the full, often
// truncated, subject.
const label = prefixLabel ? (
<span className="flex flex-col">
<span className="opacity-70 text-xxs">{prefixLabel}</span>
<span className="font-medium">{suffix}</span>
</span>
) : prefix ? (
`${prefix}${suffix}`
) : (
suffix
);
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,
};
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
return null;
}
return React.cloneElement(child, {
motionValues,
});
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<Tooltip label={label}>
<div className="flex min-w-0 max-w-full items-center gap-1 text-xs">
{prefix ? <ToolbarTitlePrefix title={prefix} /> : null}
<ToolbarTitleSuffix title={suffix} />
</div>
</Tooltip>
<div className="flex items-center gap-1 text-xs">
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
<ToolbarTitleSuffix title={props.suffix} />
</div>
);
}
function ToolbarTitlePrefix(props: { title: string }) {
// Kept whole: prefixes are short, and the suffix is the one to truncate.
return (
<motion.span
{...getCopyVariants(0)}
className="shrink-0 whitespace-nowrap font-medium text-neutral-1 dark:text-neutral-12"
className="truncate font-medium text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -360,7 +341,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="min-w-0 max-w-[28ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -371,7 +352,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 dark:text-neutral-12/80 flex min-w-0 max-w-full items-center gap-1 whitespace-nowrap text-xxs"
className="text-neutral-1/80 dark:text-neutral-12/80 inline-flex items-center gap-1 text-xxs"
>
{props.subtitle}
</motion.span>
@@ -35,8 +35,7 @@ export function ToolbarDate(props: { value: string }) {
return (
<motion.div
// Never wraps: the longer formats push the author's name to truncate instead.
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap"
className="inline-flex items-center gap-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -76,7 +75,7 @@ export function ToolbarDate(props: { value: string }) {
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="relative inline-flex cursor-pointer select-none overflow-hidden whitespace-nowrap font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
@@ -1,4 +1,4 @@
import { type MotionValue, motionValue, useReducedMotion } from 'motion/react';
import { type MotionValue, motionValue } from 'motion/react';
import React from 'react';
interface MagnificationConfig {
@@ -16,29 +16,40 @@ interface MagnificationConfig {
padding?: number;
}
export const BUTTON_SIZE = 32;
export const MAX_SCALE = 1.25;
/** Tooltip gap above a slot, with room for the button fully magnified (it grows from the bottom). */
export const TOOLTIP_OFFSET = 4 + BUTTON_SIZE * (MAX_SCALE - 1);
const defaultConfig: Required<MagnificationConfig> = {
buttonSize: BUTTON_SIZE, // Standard button size for 32px (size-8) buttons
maxScale: MAX_SCALE, // 25% scale increase - noticeable but not overwhelming
buttonSize: 32, // Standard button size for 32px (size-8) buttons
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
influenceRadius: 80, // ~2.5 button widths of influence
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
padding: 10, // Small buffer zone around container edges
};
type SlotMotionValues = { scale: MotionValue<number>; x: MotionValue<number> };
const resetMotionValues = (motionValues: SlotMotionValues[]) => {
const resetMotionValues = (
motionValues: { scale: MotionValue<number>; x: MotionValue<number> }[]
) => {
motionValues.forEach(({ scale, x }) => {
scale.set(1);
x.set(0);
});
};
const captureButtonPositions = (buttons: HTMLElement[]) => {
// Reset transforms to get original positions
buttons.forEach((button) => {
button.style.transform = '';
});
return buttons.map((button) => {
const rect = button.getBoundingClientRect();
return {
left: rect.left,
width: rect.width,
top: rect.top,
height: rect.height,
};
});
};
const calculateScale = (
mouseX: number,
buttonCenterX: number,
@@ -51,7 +62,8 @@ const calculateScale = (
if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge (X-axis only)
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdge = distanceFromEdgeX;
// Define the "heart" zone - 8px from center (16x16px total area)
const heartZoneRadius = 8;
@@ -82,20 +94,24 @@ const calculateScale = (
const calculateSpacing = (
buttonIndex: number,
scales: number[],
centers: (number | null)[],
buttonEffects: { scale: number; translateX: number }[],
positions: { left: number; width: number; top: number; height: number }[],
config: Required<MagnificationConfig>
) => {
const buttonCenterX = centers[buttonIndex];
if (buttonCenterX == null) return 0;
const currentPos = positions[buttonIndex];
if (!currentPos) return 0;
const buttonCenterX = currentPos.left + currentPos.width / 2;
let leftPush = 0;
let rightPush = 0;
scales.forEach((otherScale, otherIndex) => {
const otherCenterX = centers[otherIndex];
if (otherIndex === buttonIndex || otherCenterX == null) return;
buttonEffects.forEach((otherEffect, otherIndex) => {
if (otherIndex === buttonIndex) return;
const otherPos = positions[otherIndex];
if (!otherPos) return;
const otherCenterX = otherPos.left + otherPos.width / 2;
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
// Only apply influence if close enough
@@ -104,7 +120,7 @@ const calculateSpacing = (
0,
1 - distanceBetween / (config.influenceRadius * 1.5)
);
const extraSpace = (otherScale - 1) * config.buttonSize;
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
if (otherCenterX < buttonCenterX) {
@@ -118,88 +134,104 @@ const calculateSpacing = (
return rightPush - leftPush;
};
/**
* Dock-like magnification of the slots (direct children) of `containerRef`. Returns raw motion
* values per slot index, meant to be smoothed with springs by the slots.
*/
export function useMagnificationEffect(props: {
slotCount: number;
childrenCount: number;
containerRef: React.RefObject<HTMLElement | null>;
config?: MagnificationConfig;
}) {
const { slotCount, containerRef, config } = props;
const reduceMotion = useReducedMotion();
const { childrenCount, containerRef, config } = props;
const originalPositionsRef = React.useRef<
{ left: number; width: number; top: number; height: number }[]
>([]);
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
// Stable across renders, grown as slots are added, so existing springs keep their source.
const motionValuesRef = React.useRef<SlotMotionValues[]>([]);
while (motionValuesRef.current.length < slotCount) {
motionValuesRef.current.push({ scale: motionValue(1), x: motionValue(0) });
}
const motionValues = motionValuesRef.current;
// 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 || reduceMotion) {
resetMotionValues(motionValues);
if (!container) return;
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
if (buttons.length !== childrenCount) {
console.error(
`Button count (${buttons.length}) does not match children count (${childrenCount})`
);
return;
}
let frame = 0;
let pointer = { x: 0, y: 0 };
const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from(
container.querySelectorAll('.toolbar-button')
) as HTMLElement[];
// Measured every frame from layout positions (`offsetLeft`), which the slots' transforms
// don't affect, so nothing goes stale when the toolbar resizes, re-centers or animates in.
const update = () => {
frame = 0;
const rect = container.getBoundingClientRect();
const inRange =
pointer.x >= rect.left - finalConfig.padding &&
pointer.x <= rect.right + finalConfig.padding &&
pointer.y >= rect.top - finalConfig.padding &&
pointer.y <= rect.bottom + finalConfig.padding;
if (!inRange) {
resetMotionValues(motionValues);
// Capture positions if needed
if (originalPositionsRef.current.length !== buttons.length) {
originalPositionsRef.current = captureButtonPositions(buttons);
}
const containerRect = container.getBoundingClientRect();
const { clientX: mouseX, clientY: mouseY } = event;
// Check if mouse is in range
const isInRange =
mouseX >= containerRect.left - finalConfig.padding &&
mouseX <= containerRect.right + finalConfig.padding &&
mouseY >= containerRect.top - finalConfig.padding &&
mouseY <= containerRect.bottom + finalConfig.padding;
if (!isInRange) {
resetMotionValues(buttonMotionValues);
return;
}
const slots = Array.from(container.children) as HTMLElement[];
// Empty or hidden slots take no space and don't magnify.
const centers = slots.map((slot) =>
slot.offsetWidth > 0 ? rect.left + slot.offsetLeft + slot.offsetWidth / 2 : null
);
const scales = centers.map((center) =>
center == null ? 1 : calculateScale(pointer.x, center, rect, finalConfig)
);
motionValues.forEach((values, index) => {
values.scale.set(scales[index] ?? 1);
values.x.set(calculateSpacing(index, scales, centers, finalConfig));
// Calculate scales for all buttons
const buttonEffects = buttons.map((_, index) => {
const pos = originalPositionsRef.current[index];
if (!pos) return { scale: 1, translateX: 0 };
const buttonCenterX = pos.left + pos.width / 2;
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
return { scale, translateX: 0 };
});
// Calculate spacing for all buttons
buttonEffects.forEach((effect, index) => {
effect.translateX = calculateSpacing(
index,
buttonEffects,
originalPositionsRef.current,
finalConfig
);
});
// Update motion values
buttonEffects.forEach((effect, index) => {
const motionValue = buttonMotionValues[index];
if (motionValue) {
motionValue.scale.set(effect.scale);
motionValue.x.set(effect.translateX);
}
});
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== 'mouse') {
return;
}
pointer = { x: event.clientX, y: event.clientY };
if (!frame) {
frame = requestAnimationFrame(update);
}
};
const onPointerLeave = () => {
cancelAnimationFrame(frame);
frame = 0;
resetMotionValues(motionValues);
};
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('pointerleave', onPointerLeave);
return () => {
cancelAnimationFrame(frame);
container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerleave', onPointerLeave);
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [containerRef, finalConfig, motionValues, reduceMotion]);
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
return { motionValues };
return { buttonMotionValues };
}
@@ -130,7 +130,7 @@ const TabPanel = memo(function TabPanel(props: {
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4">{tab.body}</div>
<div className="px-8 py-6">{tab.body}</div>
</div>
);
});
@@ -317,7 +317,7 @@ function TabButton(
{...rest}
type="button"
className={tcls(
'relative inline-flex max-w-full items-center gap-1.5 px-3.5 py-2 font-medium text-sm transition-[color]',
'relative inline-flex max-w-full items-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-[color]',
props.className
)}
/>
@@ -9,7 +9,7 @@ import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from '.';
import { ToggleChevron } from './ToggleChevron';
import { Tooltip, type TooltipProps } from './Tooltip';
import { Tooltip } from './Tooltip';
import { type ClassValue, tcls } from '@/lib/tailwind';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
@@ -41,8 +41,6 @@ export function DropdownMenu(props: {
button: React.ReactElement<Record<string, unknown>>;
/** Tooltip label for the button */
buttonTooltip?: React.ReactNode;
/** Placement of the button's tooltip. */
buttonTooltipProps?: Pick<TooltipProps, 'side' | 'sideOffset' | 'anchor'>;
/** Content of the dropdown */
children: React.ReactNode;
/** Custom styles */
@@ -74,7 +72,6 @@ export function DropdownMenu(props: {
const {
button,
buttonTooltip,
buttonTooltipProps,
children,
className,
openOnHover = false,
@@ -101,7 +98,7 @@ export function DropdownMenu(props: {
<DropdownMenuContext.Provider value={{ open, setOpen }}>
<Menu.Root modal={false} open={open} onOpenChange={setOpen}>
{buttonTooltip ? (
<Tooltip label={buttonTooltip} disableHoverablePopup {...buttonTooltipProps}>
<Tooltip label={buttonTooltip} disableHoverablePopup>
{trigger}
</Tooltip>
) : (
@@ -10,8 +10,6 @@ export type TooltipProps = {
side?: BaseTooltip.Positioner.Props['side'];
align?: BaseTooltip.Positioner.Props['align'];
sideOffset?: number;
/** Positions against this element instead of the trigger, e.g. when the trigger is transformed. */
anchor?: BaseTooltip.Positioner.Props['anchor'];
delay?: number;
disabled?: boolean;
disableHoverablePopup?: boolean;
@@ -33,7 +31,6 @@ export function Tooltip(
side,
align,
sideOffset = 4,
anchor,
delay,
disabled,
disableHoverablePopup,
@@ -72,7 +69,6 @@ export function Tooltip(
side={side}
align={align}
sideOffset={sideOffset}
anchor={anchor}
collisionPadding={8}
>
<BaseTooltip.Popup