mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 02:53:29 +00:00
Full screen view for Mermaid diagrams (#4275)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user