Compare commits

...

4 Commits

Author SHA1 Message Date
Viktor Renkema a7015bbf72 changeset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:03:57 +02:00
Viktor Renkema 3ed4fb070a Mark blocks, pages and titles as comment targets
Adds data attributes (block keys, table cells, page and page title) and
ignore markers for columns, annotations and reusable content, so a
commenting layer can resolve what was clicked. No visible change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:03:57 +02:00
Viktor Renkema a17ed29e35 Fix admin toolbar magnification, tooltips and truncation (#4697)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:01:14 +02:00
conico974 bb47dadfbe Change x-robots-tag header for search/assistant deep links (#4693) 2026-10-20 15:41:35 +02:00
41 changed files with 412 additions and 352 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix admin toolbar magnification, tooltip placement, title truncation and logo colour.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Mark blocks, pages and titles so comments can target them.
@@ -15,7 +15,6 @@ import {
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
@@ -137,14 +136,21 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
prefix={`#${changeRequest.number}`}
prefixLabel={`Change request #${changeRequest.number}`}
suffix={changeRequest.subject || 'Untitled'}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
<motion.span
layout="position"
title={author}
className="min-w-0 truncate"
>
by {author}
</motion.span>
</>
}
/>
@@ -339,12 +345,8 @@ function ToolbarActions(props: { children: React.ReactNode }) {
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
function EditPageButton(props: { href: string; siteId: string }) {
const { href, siteId } = props;
const pagePath = useCurrentPagePath();
return (
@@ -356,7 +358,6 @@ function EditPageButton(props: {
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
@@ -1,5 +1,6 @@
.svgLogo {
--logo-fill: var(--color-neutral-100);
/* The toolbar is always dark, so the logo is always light. */
--logo-fill: rgb(255 255 255 / 0.92);
--seg-A-color: #2782c4;
--seg-B-color: #43b7f2;
--seg-C-color: #8be2ff;
@@ -11,8 +11,14 @@ 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(styles.svgLogo, shouldAnimate ? undefined : styles.static)}
className={tcls(
'text-white/90',
styles.svgLogo,
shouldAnimate ? undefined : styles.static
)}
width="28"
height="28"
viewBox="0 0 28 28"
@@ -22,6 +28,7 @@ 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,8 +6,9 @@ import type { SVGProps } from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
import type { ToolbarButtonProps } from './Toolbar';
import { useToolbarSlot } from './Toolbar';
import type { MinimalChangedPage, MinimalChangedPages } from './types';
import { TOOLTIP_OFFSET } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind';
const STATUS_ICON_STYLES: Record<
@@ -42,12 +43,10 @@ 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;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { changedPages, motionValues } = props;
export function ChangedPagesButton(props: { changedPages: MinimalChangedPages | null }) {
const { changedPages } = props;
const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
if (!changedPages?.pages.length) {
return null;
@@ -59,30 +58,19 @@ export function ChangedPagesButton(props: {
}`;
const trigger = (
<button
<motion.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"
>
<motion.span
<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',
@@ -91,14 +79,18 @@ export function ChangedPagesButton(props: {
)}
>
<DiffIcon className="size-4 shrink-0" />
</motion.span>
</button>
</span>
</motion.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, type ToolbarButtonProps } from './Toolbar';
import { ToolbarButton, useToolbarSlot } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
import { tcls } from '@/lib/tailwind';
@@ -26,15 +26,11 @@ 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(props: HideToolbarButtonProps) {
const { motionValues } = props;
export function HideToolbarButton() {
const slot = useToolbarSlot();
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls();
@@ -114,11 +110,8 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
: null,
].filter(Boolean) as ArcMenuItem[];
const sharedMotionStyle = motionValues
? {
x: motionValues.x,
}
: undefined;
// The arc follows the button's push, but not its scale.
const sharedMotionStyle = slot ? { x: slot.x } : undefined;
return (
<ToolbarButton
@@ -136,7 +129,6 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
setOpen(true);
}
}}
motionValues={motionValues}
icon="gear"
>
{/* Expanding arc menu */}
@@ -1,16 +1,13 @@
'use client';
import React from 'react';
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import { ToolbarButton } from './Toolbar';
/**
* Button to refresh the page if the content has been updated.
*/
export function RefreshContentButton(props: {
refreshForUpdates: () => Promise<void>;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { refreshForUpdates, motionValues } = props;
export function RefreshContentButton(props: { refreshForUpdates: () => Promise<void> }) {
const { refreshForUpdates } = props;
const [loading, setLoading] = React.useState(false);
@@ -34,7 +31,6 @@ export function RefreshContentButton(props: {
refresh();
}}
disabled={loading}
motionValues={motionValues}
icon="rotate"
iconClassName={loading ? 'animate-spin' : undefined}
/>
@@ -1,12 +1,6 @@
'use client';
import {
AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { type MotionValue, motion, useReducedMotion, useSpring } from 'motion/react';
import React from 'react';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -15,7 +9,7 @@ import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { getCopyVariants, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect';
import { TOOLTIP_OFFSET, useMagnificationEffect } from './useMagnificationEffect';
import { tcls } from '@/lib/tailwind';
const DURATION_LOGO_APPEARANCE = 2000;
@@ -108,63 +102,93 @@ export function Toolbar(props: ToolbarProps) {
return (
<motion.div
data-testid="admin-toolbar"
className="fixed bottom-5 left-1/2 z-40 w-auto max-w-xl -translate-x-1/2 transform px-4"
// 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"
>
<ToolbarVisibilityHint show={showHint} />
<AnimatePresence mode="wait">
<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 */}
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
layoutDependency={minified}
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(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 */}
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{/* A layout child, so it's counter-scaled while the pill expands. */}
{!minified ? (
<motion.div
layout
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}}
layoutDependency={minified}
className="flex min-w-0 items-center"
>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
{children}
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
) : null}
</motion.div>
</motion.div>
);
}
export function ToolbarBody(props: { children: React.ReactNode }) {
return <div className="flex flex-col items-start px-3">{props.children}</div>;
// 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);
}
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
@@ -172,8 +196,8 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
const { buttonMotionValues } = useMagnificationEffect({
childrenCount: buttonChildren.length,
const { motionValues } = useMagnificationEffect({
slotCount: buttonChildren.length,
containerRef,
});
@@ -183,28 +207,48 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
variants={toolbarEasings.parent}
initial="hidden"
animate="show"
className="flex items-center gap-1 overflow-visible pl-4 pr-2"
// `relative`: the magnification measures slots by their `offsetLeft` in here.
className="relative flex items-center gap-1 overflow-visible pl-4 pr-2"
>
{buttonChildren.map((child, index) => {
const childEl = child as React.ReactElement;
const childKey = childEl.key ?? `toolbar-button-${index}`;
return (
<ToolbarButtonWrapper
key={childKey}
child={childEl}
rawMotionValues={buttonMotionValues[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;
})}
</motion.div>
);
}
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}
// Buttons can render null (e.g. no changed pages): don't leave a gap in the group.
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;
@@ -212,19 +256,10 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
}
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
const {
title,
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const { title, disabled, className, style, href, onClick, icon, iconClassName, children } =
props;
const reduceMotion = useReducedMotion();
const slot = useToolbarSlot();
const anchor = (
<motion.a
@@ -233,23 +268,11 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
reduceMotion || !slot
? style
: { scale: slot.scale, x: slot.x, transformOrigin: 'bottom center', ...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',
@@ -262,75 +285,68 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
className={tcls('size-3.5 shrink-0', iconClassName)}
/>
</motion.a>
);
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
<div className="relative" ref={ref}>
{children ? children : null}
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
{/* 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>
);
});
ToolbarButton.displayName = 'ToolbarButton';
function ToolbarButtonWrapper(props: {
child: React.ReactElement;
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
export function ToolbarTitle(props: {
prefix?: string;
/** What a short prefix stands for, e.g. "Change request #54" for "#54". */
prefixLabel?: string;
suffix: string;
}) {
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 }) {
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
);
return (
<div className="flex items-center gap-1 text-xs">
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
<ToolbarTitleSuffix title={props.suffix} />
</div>
<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>
);
}
function ToolbarTitlePrefix(props: { title: string }) {
// Kept whole: prefixes are short, and the suffix is the one to truncate.
return (
<motion.span
{...getCopyVariants(0)}
className="truncate font-medium text-neutral-1 dark:text-neutral-12"
className="shrink-0 whitespace-nowrap font-medium text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -341,7 +357,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
className="min-w-0 max-w-[28ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -352,7 +368,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 dark:text-neutral-12/80 inline-flex items-center gap-1 text-xxs"
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"
>
{props.subtitle}
</motion.span>
@@ -35,7 +35,8 @@ export function ToolbarDate(props: { value: string }) {
return (
<motion.div
className="inline-flex items-center gap-1"
// Never wraps: the longer formats push the author's name to truncate instead.
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -75,7 +76,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 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 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
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
@@ -1,4 +1,4 @@
import { type MotionValue, motionValue } from 'motion/react';
import { type MotionValue, motionValue, useReducedMotion } from 'motion/react';
import React from 'react';
interface MagnificationConfig {
@@ -16,40 +16,29 @@ 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: 32, // Standard button size for 32px (size-8) buttons
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
buttonSize: BUTTON_SIZE, // Standard button size for 32px (size-8) buttons
maxScale: MAX_SCALE, // 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
};
const resetMotionValues = (
motionValues: { scale: MotionValue<number>; x: MotionValue<number> }[]
) => {
type SlotMotionValues = { scale: MotionValue<number>; x: MotionValue<number> };
const resetMotionValues = (motionValues: SlotMotionValues[]) => {
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,
@@ -62,8 +51,7 @@ const calculateScale = (
if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge (X-axis only)
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdge = distanceFromEdgeX;
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
// Define the "heart" zone - 8px from center (16x16px total area)
const heartZoneRadius = 8;
@@ -94,24 +82,20 @@ const calculateScale = (
const calculateSpacing = (
buttonIndex: number,
buttonEffects: { scale: number; translateX: number }[],
positions: { left: number; width: number; top: number; height: number }[],
scales: number[],
centers: (number | null)[],
config: Required<MagnificationConfig>
) => {
const currentPos = positions[buttonIndex];
if (!currentPos) return 0;
const buttonCenterX = centers[buttonIndex];
if (buttonCenterX == null) return 0;
const buttonCenterX = currentPos.left + currentPos.width / 2;
let leftPush = 0;
let rightPush = 0;
buttonEffects.forEach((otherEffect, otherIndex) => {
if (otherIndex === buttonIndex) return;
scales.forEach((otherScale, otherIndex) => {
const otherCenterX = centers[otherIndex];
if (otherIndex === buttonIndex || otherCenterX == null) 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
@@ -120,7 +104,7 @@ const calculateSpacing = (
0,
1 - distanceBetween / (config.influenceRadius * 1.5)
);
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
const extraSpace = (otherScale - 1) * config.buttonSize;
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
if (otherCenterX < buttonCenterX) {
@@ -134,104 +118,88 @@ 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: {
childrenCount: number;
slotCount: number;
containerRef: React.RefObject<HTMLElement | null>;
config?: MagnificationConfig;
}) {
const { childrenCount, containerRef, config } = props;
const originalPositionsRef = React.useRef<
{ left: number; width: number; top: number; height: number }[]
>([]);
const { slotCount, containerRef, config } = props;
const reduceMotion = useReducedMotion();
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
// Create basic motion values that will be consumed by springs in the components
const buttonMotionValues = React.useMemo(() => {
return Array.from({ length: childrenCount }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
}, [childrenCount]);
// 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;
React.useEffect(() => {
const container = containerRef.current;
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})`
);
if (!container || reduceMotion) {
resetMotionValues(motionValues);
return;
}
const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from(
container.querySelectorAll('.toolbar-button')
) as HTMLElement[];
let frame = 0;
let pointer = { x: 0, y: 0 };
// 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);
// 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);
return;
}
// 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 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));
});
};
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
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);
};
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('pointerleave', onPointerLeave);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
cancelAnimationFrame(frame);
container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerleave', onPointerLeave);
};
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
}, [containerRef, finalConfig, motionValues, reduceMotion]);
return { buttonMotionValues };
return { motionValues };
}
@@ -21,13 +21,16 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
return (
<AnnotationPopover
body={
<Blocks
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style={['contents']}
/>
// The app doesn't allow comments inside annotation bodies.
<div data-comment-ignore="" className="contents">
<Blocks
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style={['contents']}
/>
</div>
}
>
{content}
@@ -31,6 +31,7 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
return (
<NotFoundRefHoverCard context={context}>
<BlockContentRefCard
data-block-key={block.key}
contentRef={block.data.ref}
resolved={fallback}
style={style}
@@ -39,7 +40,14 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
);
}
return <BlockContentRefCard contentRef={block.data.ref} resolved={resolved} style={style} />;
return (
<BlockContentRefCard
data-block-key={block.key}
contentRef={block.data.ref}
resolved={resolved}
style={style}
/>
);
}
function BlockContentRefCard(
@@ -34,6 +34,8 @@ export function Caption(
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
withFrame?: boolean;
/** Expose the block key for commenting; off for images nested in an images block. */
withBlockKey?: boolean;
} & DocumentContextProps
) {
const {
@@ -59,8 +61,11 @@ export function Caption(
],
style,
align = 'center',
withBlockKey = false,
} = props;
const blockKey = withBlockKey ? block.key : undefined;
const caption = getNodeFragmentByName(block, 'caption');
const captionParagraph = caption?.nodes[0];
@@ -69,11 +74,15 @@ export function Caption(
captionParagraph.type !== 'paragraph' ||
isNodeEmpty(captionParagraph)
) {
return <div className={tcls(style, wrapperStyle)}>{children}</div>;
return (
<div data-block-key={blockKey} className={tcls(style, wrapperStyle)}>
{children}
</div>
);
}
return (
<picture className={tcls('relative', style)}>
<picture data-block-key={blockKey} className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, captionAlignStyles[align])}>{children}</div>
<figcaption
className={tcls(
@@ -50,6 +50,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
<div
ref={ref}
id={codeId}
data-block-key={block.key}
aria-busy={ariaBusy}
className={tcls(
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
@@ -218,7 +218,12 @@ export function MermaidCodeBlock(
return (
<>
{/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */}
<div ref={rootRef} className={tcls('relative', style)} contentEditable={false} />
<div
ref={rootRef}
data-block-key={block.key}
className={tcls('relative', style)}
contentEditable={false}
/>
{diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null}
<Dialog.Root
open={isFullscreen}
@@ -37,6 +37,8 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
return (
<div
// The app doesn't allow comments inside columns.
data-comment-ignore=""
className={tcls(
'grid w-full @2xl:grid-flow-col @2xl:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] grid-cols-1 @2xl:grid-rows-1 gap-x-8 gap-y-4',
style
@@ -18,7 +18,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
}
return (
<Caption {...props}>
<Caption {...props} withBlockKey>
<Image
sources={{
light: {
@@ -32,7 +32,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
}
return (
<Caption {...props} withBorder>
<Caption {...props} withBorder withBlockKey>
{embed.type === 'rich' ? (
<>
<div
@@ -14,6 +14,8 @@ export function Details(props: {
contentIds?: string[];
open?: boolean;
className?: ClassValue;
/** Node key of the expandable, so comments can anchor to it. */
blockKey?: string;
}) {
const { children, id, className } = props;
@@ -46,6 +48,7 @@ export function Details(props: {
<details
ref={ref}
id={id}
data-block-key={props.blockKey}
open={props.open || openFromHash}
className={tcls(
className,
@@ -37,10 +37,12 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
return (
<Details
id={id}
blockKey={block.key}
open={context.mode === 'print' || isDefaultExpanded}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary
data-block-key={titleParagraph.key}
className={tcls(
'cursor-pointer',
'px-4',
@@ -40,7 +40,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
};
return (
<Caption {...props} withBorder>
<Caption {...props} withBorder withBlockKey>
<div className="flex flex-wrap items-center gap-5 px-5 py-3">
<div className="flex min-h-8 min-w-14 flex-col items-center justify-center gap-1 border-r border-tint-subtle pr-4">
{contentType === 'image' ? (
@@ -30,6 +30,7 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
<HeadingRevealWrapper
as={Tag}
id={id}
data-block-key={block.key}
className={tcls(
textStyle.textSize,
'heading',
@@ -15,6 +15,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
return (
<div
data-block-key={block.key}
className={tcls(
style,
'flex',
@@ -61,7 +61,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
}
return (
<div className={tcls(style)}>
<div data-block-key={block.key} className={tcls(style)}>
<pre>
Unexpected error with integration {block.data.integration}:{' '}
{initialResponse.error.message}
@@ -99,7 +99,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
};
return (
<div className={tcls(style)}>
<div data-block-key={block.key} className={tcls(style)}>
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
@@ -125,7 +125,14 @@ function getListItemDepth(input: {
function ListItemLI(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
const textStyle = getBlockTextStyle(props.block);
return <li className={tcls(textStyle.lineHeight, 'flex items-start')}>{props.children}</li>;
return (
<li
data-block-key={props.block.key}
className={tcls(textStyle.lineHeight, 'flex items-start')}
>
{props.children}
</li>
);
}
function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
@@ -11,13 +11,16 @@ const assetsUrl = getAssetURL('math');
export async function BlockMath(props: BlockProps<DocumentBlockMath>) {
const { block, style } = props;
// `MathFormula` only forwards `className`, so the comment anchor needs its own wrapper.
return (
<MathFormula
formula={block.data.formula}
inline={false}
className={tcls(style, 'overflow-x-auto')}
assetsUrl={assetsUrl}
/>
<div data-block-key={block.key} className={tcls(style)}>
<MathFormula
formula={block.data.formula}
inline={false}
className="overflow-x-auto"
assetsUrl={assetsUrl}
/>
</div>
);
}
@@ -9,9 +9,12 @@ import { tcls } from '@/lib/tailwind';
* Render an openapi block or an openapi-operation block.
*/
export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlock>) {
const { style } = props;
const { style, block } = props;
return (
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<div
data-block-key={block.key}
className={tcls('flex w-full min-w-0', style, 'max-w-full')}
>
<OpenAPIOperationBody {...props} />
</div>
);
@@ -9,9 +9,9 @@ import { tcls } from '@/lib/tailwind';
* Render an openapi-schemas block.
*/
export async function OpenAPISchemas(props: BlockProps<OpenAPISchemasBlock>) {
const { style } = props;
const { style, block } = props;
return (
<div className={tcls('flex w-full', style, 'max-w-full')}>
<div data-block-key={block.key} className={tcls('flex w-full', style, 'max-w-full')}>
<OpenAPISchemasBody {...props} />
</div>
);
@@ -9,9 +9,12 @@ import { tcls } from '@/lib/tailwind';
* Render an openapi block or an openapi-webhook block.
*/
export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
const { style } = props;
const { style, block } = props;
return (
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<div
data-block-key={block.key}
className={tcls('flex w-full min-w-0', style, 'max-w-full')}
>
<OpenAPIWebhookBody {...props} />
</div>
);
@@ -10,9 +10,12 @@ import { tcls } from '@/lib/tailwind';
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
const { context } = contextProps;
// Like the app, a list item owns the comments on its direct paragraph.
const isCommentTarget = ancestorBlocks.at(-1)?.type !== 'list-item';
const paragraph = (
<p
data-block-key={isCommentTarget ? block.key : undefined}
// Cover-aware contrast text applies only to the page body, not to documents
// rendered in overlays (search answers, AI chat) on a background-cover page.
data-cover-aware-text={context.isPageBody ? '' : undefined}
@@ -50,6 +50,9 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
nodes={reusableContentDocument.nodes}
document={reusableContentDocument}
ancestorBlocks={[...ancestorBlocks, block]}
// Its blocks carry keys of the reusable content's own document, which comments on the
// page can't anchor to. A class, not a wrapper, which would break the page's spacing.
blockStyle="comment-ignore"
context={{
...context,
contentContext: reusableContent.context,
@@ -32,7 +32,7 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
})();
return (
<div className={tcls('flex flex-row gap-4 md:gap-8', style)}>
<div data-block-key={block.key} className={tcls('flex flex-row gap-4 md:gap-8', style)}>
<div className="relative select-none">
<div
className={tcls(
@@ -219,6 +219,8 @@ function NativeViewGridBody(
colSpan={colSpan}
aria-rowspan={rowSpan}
aria-colspan={colSpan}
data-table-record={recordId}
data-table-column={column}
data-table-row-start={recordIndex}
data-table-row-end={
recordIndex + (cellMerge?.merge.rowSpan ?? 1) - 1
@@ -54,6 +54,8 @@ export function RecordRow(
<div
key={column}
role="cell"
data-table-record={record[0]}
data-table-column={column}
aria-colspan={cellMerge?.merge.colSpan}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
@@ -74,7 +74,11 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
}
>
<div className={tcls(style, 'flex flex-col gap-3')}>
<div
data-block-key={block.key}
data-comment-table=""
className={tcls(style, 'flex flex-col gap-3')}
>
{showSearch ? (
<TableSearchInput
selectColumns={selectColumns}
@@ -100,6 +100,8 @@ export async function PageBody(props: {
/>
) : null}
<main
// Comments on the whole page target anywhere in it that isn't a block.
data-comment-page=""
className={tcls(
'relative min-w-0 flex-1',
'break-anywhere', // Allow words to break if they are too long.
@@ -255,6 +255,8 @@ export async function PageHeader(props: {
<PageTags page={page} revision={revision} />
{page.layout.title ? (
<h1
// Comments on the whole page get their pin next to the title.
data-comment-page-title=""
data-cover-aware-text
data-over-cover
className={tcls(
@@ -9,7 +9,7 @@ import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from '.';
import { ToggleChevron } from './ToggleChevron';
import { Tooltip } from './Tooltip';
import { Tooltip, type TooltipProps } from './Tooltip';
import { type ClassValue, tcls } from '@/lib/tailwind';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
@@ -41,6 +41,8 @@ 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 */
@@ -72,6 +74,7 @@ export function DropdownMenu(props: {
const {
button,
buttonTooltip,
buttonTooltipProps,
children,
className,
openOnHover = false,
@@ -98,7 +101,7 @@ export function DropdownMenu(props: {
<DropdownMenuContext.Provider value={{ open, setOpen }}>
<Menu.Root modal={false} open={open} onOpenChange={setOpen}>
{buttonTooltip ? (
<Tooltip label={buttonTooltip} disableHoverablePopup>
<Tooltip label={buttonTooltip} disableHoverablePopup {...buttonTooltipProps}>
{trigger}
</Tooltip>
) : (
@@ -10,6 +10,8 @@ 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;
@@ -31,6 +33,7 @@ export function Tooltip(
side,
align,
sideOffset = 4,
anchor,
delay,
disabled,
disableHoverablePopup,
@@ -69,6 +72,7 @@ export function Tooltip(
side={side}
align={align}
sideOffset={sideOffset}
anchor={anchor}
collisionPadding={8}
>
<BaseTooltip.Popup
+1 -1
View File
@@ -579,7 +579,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// noindex search/assistant deep links, kept crawlable so Google sees the directive.
if (rewrittenURL.searchParams.has('ask') || rewrittenURL.searchParams.has('q')) {
response.headers.set('x-robots-tag', 'noindex');
response.headers.set('x-robots-tag', 'none');
}
// Allow cross-origin requests from the same parent domain as the site.