Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mermaid-fullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view.
5 changes: 5 additions & 0 deletions .changeset/tooltip-non-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger.
3 changes: 2 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"p-retry": "^8.0.0",
"quick-lru": "^7.0.1",
"react": "19.2.4",
"react-aria": "^3.44.0",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,51 @@
'use client';

import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import type { RenderResult } from 'mermaid';
import { FocusScope, usePreventScroll } from 'react-aria';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';

/** Duration of the fullscreen dialog enter/exit animation, must match `animate-blur-in/out`. */
const DIALOG_ANIMATION_MS = 200;

/**
* Used to render a Mermaid diagram from a CodeBlock.
*/
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, mode, style } = props;
const source = getPlainCodeBlock(block);
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
// A stable container that holds the diagram subtree. We portal the diagram into it and
// only ever move this plain node between the inline slot and the dialog — never the
// React-managed subtree itself — so React stays in control and panzoom/SVG are preserved.
const diagramHostRef = useRef<HTMLDivElement | null>(null);
if (diagramHostRef.current === null && typeof document !== 'undefined') {
const host = document.createElement('div');
// `display: contents` so the host adds no box of its own (the diagram becomes a
// direct flex child of the dialog panel and can fill it).
host.style.display = 'contents';
diagramHostRef.current = host;
}
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// `isFullscreen` is the open intent; `isExiting` keeps the dialog mounted while it
// animates closed. `isPresent` is true whenever the diagram lives in the dialog.
const [isFullscreen, setIsFullscreen] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const isPresent = isFullscreen || isExiting;
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
Expand Down Expand Up @@ -96,37 +118,168 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
};
}, [source, id, darkMode, shouldRender]);

// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
usePreventScroll({ isDisabled: !isPresent });
Comment thread
gregberge marked this conversation as resolved.

const openFullscreen = useCallback(() => {
// Reserve the inline slot's current height before the diagram is detached, so the
// page layout does not jump. Measured here while still inline and un-restyled.
const root = rootRef.current;
if (root) {
root.style.minHeight = `${root.offsetHeight}px`;
}
setIsExiting(false);
setIsFullscreen(true);
// Re-center the diagram for the larger view.
panZoom?.reset();
}, [panZoom]);

const closeFullscreen = useCallback(() => {
setIsFullscreen(false);
setIsExiting(true);
}, []);

// Keep the dialog mounted until the exit animation finishes, then unmount it.
useEffect(() => {
if (!isExiting) {
return;
}

const timer = window.setTimeout(() => {
setIsExiting(false);
panZoom?.reset();
}, DIALOG_ANIMATION_MS);
return () => window.clearTimeout(timer);
}, [isExiting, panZoom]);

// Allow Escape to close the dialog.
useEffect(() => {
if (!isFullscreen) {
return;
}

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeFullscreen();
}
};

document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isFullscreen, closeFullscreen]);

// Keep the diagram host in the inline slot on mount (and whenever it isn't in the dialog).
useLayoutEffect(() => {
const host = diagramHostRef.current;
const root = rootRef.current;
if (host && root && !host.parentNode) {
root.appendChild(host);
}
}, []);

// Move the diagram host into the dialog panel (and back) as the panel mounts/unmounts.
// Done in the panel's ref callback so it happens during commit, before FocusScope reads
// focus. The inline slot's reserved height (set in openFullscreen) is cleared on return.
const setPanel = useCallback((panel: HTMLDivElement | null) => {
panelRef.current = panel;
const host = diagramHostRef.current;
const root = rootRef.current;
if (!host) {
return;
}

if (panel) {
panel.appendChild(host);
} else if (root) {
root.appendChild(host);
root.style.minHeight = '';
}
}, []);

if (error) {
return <ClientCodeBlock {...props} />;
}

return (
// The live diagram subtree. It is portaled into a stable host that moves between the
// inline slot and the dialog, so its markup must not depend on where it currently lives.
const diagram = (
<div
ref={rootRef}
className={tcls('group/mermaid relative', style)}
contentEditable={false}
className={tcls(
'group/mermaid relative',
isPresent ? 'flex h-full w-full flex-col' : null
)}
>
<div
ref={wrapperRef}
className={
className={tcls(
isLoading
? 'invisible absolute inset-x-0 overflow-hidden'
: 'cursor-grab overflow-hidden active:cursor-grabbing'
}
: 'cursor-grab overflow-hidden active:cursor-grabbing',
isPresent && !isLoading ? 'flex-1' : null
)}
>
<div
ref={diagramRef}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
className={tcls(
'overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full',
isPresent
? 'flex h-full items-center justify-center [&_svg]:max-h-full'
: null
)}
/>
</div>
{isLoading ? (
<div className="flex h-24 items-center justify-center text-tint">
<Loading className="h-8 w-8" />
</div>
) : null}
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
{!isLoading && panZoom ? (
<MermaidPanZoomControls
panZoom={panZoom}
isFullscreen={isPresent}
onToggleFullscreen={isFullscreen ? closeFullscreen : openFullscreen}
/>
) : null}
</div>
);

return (
<>
{/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */}
<div ref={rootRef} className={tcls('relative', style)} contentEditable={false} />
{diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null}
{isPresent
? createPortal(
<FocusScope contain restoreFocus>
{/* Backdrop: dims and blurs the page, closes on click. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */}
<div
aria-hidden="true"
className={tcls(
'fixed inset-0 z-40 bg-tint-base/3 backdrop-blur-md dark:bg-tint-base/6',
isFullscreen ? 'animate-fade-in' : 'animate-fade-out'
)}
onClick={closeFullscreen}
/>
{/* Centered panel. The padding area lets clicks fall through to the backdrop. */}
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center p-3 sm:p-5 lg:p-8">
<div
ref={setPanel}
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram"
className={tcls(
'pointer-events-auto relative flex h-full w-full max-w-[110rem] flex-col overflow-hidden rounded-2xl border border-tint-subtle bg-tint-base shadow-2xl',
isFullscreen ? 'animate-blur-in' : 'animate-blur-out'
)}
/>
</div>
</FocusScope>,
document.body
)
: null}
</>
);
}

async function renderMermaidDiagram(args: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,55 @@
import type { PanzoomObject } from '@panzoom/panzoom';

import { Button } from '@/components/primitives';

const PAN_STEP = 50;
import { tcls } from '@/lib/tailwind';

/**
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
* Zoom and fullscreen controls for mermaid diagrams, grouped into a single vertical
* toolbar positioned in the bottom-right corner. Panning is done by dragging the diagram.
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
export function MermaidPanZoomControls(props: {
panZoom: PanzoomObject;
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const { panZoom, isFullscreen, onToggleFullscreen } = props;
const btnProps = {
variant: 'secondary' as const,
size: 'xsmall' as const,
iconOnly: true,
className: tcls('p-1 [&_svg]:size-3.5 opacity-90'),
// Non-interactive tooltips: the Tooltip primitive makes their popper wrapper
// pointer-transparent so it can't steal the hover that reveals these controls.
tooltipProps: {
rootProps: { disableHoverableContent: true },
},
};

return (
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
{/* Row 1: empty, pan up, zoom in */}
<div />
<div
className={tcls(
'absolute right-2 bottom-2 z-10 flex flex-col items-center gap-0.5 rounded-lg transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none',
// Keep the controls always visible in fullscreen, otherwise only reveal on hover/focus.
isFullscreen ? 'opacity-100' : 'opacity-0'
)}
>
<Button {...btnProps} icon="plus" label="Zoom in" onClick={() => panZoom.zoomIn()} />
<Button
{...btnProps}
icon="chevron-up"
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
icon="arrows-to-dot"
label="Reset view"
onClick={() => panZoom.reset()}
/>
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
{/* Row 2: pan left, reset, pan right */}
<Button
{...btnProps}
icon="chevron-left"
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
<Button
{...btnProps}
icon="chevron-right"
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button {...btnProps} icon="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />

<div className="my-0.5 h-px w-4 bg-tint-subtle" />

<Button
{...btnProps}
icon="chevron-down"
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
icon={isFullscreen ? 'compress' : 'expand'}
label={isFullscreen ? 'Exit full page' : 'View in full page'}
onClick={onToggleFullscreen}
/>
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
</div>
);
}
11 changes: 11 additions & 0 deletions packages/gitbook/src/components/RootLayout/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@
}
}

/*
Radix renders tooltip content inside a portaled `[data-radix-popper-content-wrapper]` attached
to <body>. A non-interactive tooltip (Tooltip with `disableHoverableContent`) marks its content
with `data-non-interactive`; without this rule the pointer-interactive wrapper could sit over
the trigger and steal its hover (e.g. breaking hover-revealed controls). We can't style the
wrapper from React, so let pointer events pass through it for these tooltips only.
*/
[data-radix-popper-content-wrapper]:has([data-non-interactive]) {
pointer-events: none;
}

@utility linear-mask-gradient {
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
Expand Down
2 changes: 1 addition & 1 deletion packages/gitbook/src/components/primitives/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export const Button = React.forwardRef<
}}
label={label}
triggerProps={{ disabled, ...tooltipProps?.triggerProps }}
contentProps={{ ...tooltipProps?.contentProps }}
contentProps={tooltipProps?.contentProps}
>
{button}
</Tooltip>
Expand Down
Loading
Loading