Full screen view for Mermaid diagrams (#4275)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Greg Bergé
2026-05-28 09:17:21 +02:00
committed by GitHub
parent 257ca09a5e
commit b9bb535d52
9 changed files with 239 additions and 40 deletions
+5
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -175,6 +175,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",
+1
View File
@@ -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",
@@ -1,17 +1,22 @@
'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.
*/
@@ -19,11 +24,28 @@ 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();
@@ -96,27 +118,114 @@ 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 });
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 ? (
@@ -124,9 +233,53 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
<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: {
@@ -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="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />
<div className="my-0.5 h-px w-4 bg-tint-subtle" />
<Button
{...btnProps}
icon="chevron-left"
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
icon={isFullscreen ? 'compress' : 'expand'}
label={isFullscreen ? 'Exit full page' : 'View in full page'}
onClick={onToggleFullscreen}
/>
<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="chevron-down"
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
</div>
);
}
@@ -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));
@@ -219,7 +219,7 @@ export const Button = React.forwardRef<
}}
label={label}
triggerProps={{ disabled, ...tooltipProps?.triggerProps }}
contentProps={{ ...tooltipProps?.contentProps }}
contentProps={tooltipProps?.contentProps}
>
{button}
</Tooltip>
@@ -36,6 +36,24 @@ export function Tooltip(props: {
const [open, setOpen] = useState(false);
const [clicked, setClicked] = useState(false);
// When hoverable content is disabled, the content is purely informational: make it
// non-interactive so its (portaled) popper wrapper can't steal hover/clicks from the
// trigger. The `data-non-interactive` marker lets a scoped global rule (globals.css)
// set `pointer-events: none` on the wrapper, which we can't reach from React.
const nonInteractive = rootProps?.disableHoverableContent ?? false;
const resolvedContentProps = nonInteractive
? {
...contentProps,
'data-non-interactive': '',
style: {
...contentProps?.style,
// Forced last so the non-interactive guarantee can't be overridden.
pointerEvents: 'none' as const,
userSelect: 'none' as const,
},
}
: contentProps;
return (
<RadixTooltip.Root open={open || clicked} onOpenChange={setOpen} {...rootProps}>
<RadixTooltip.Trigger asChild onClick={() => setClicked(true)} {...triggerProps}>
@@ -50,7 +68,7 @@ export function Tooltip(props: {
className
)}
onPointerDownOutside={() => setClicked(false)}
{...contentProps}
{...resolvedContentProps}
>
{label}
{arrow && <RadixTooltip.Arrow {...arrowProps} />}