mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-20 12:19:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd9179db11 | |||
| 90f6a3cc89 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix admin toolbar magnification, tooltip placement, title truncation and logo colour.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user