Mermaid Diagram
A responsive Mermaid rendering surface with streaming and error states, repair hooks, and accessible pan and zoom both inline and in a fullscreen viewer.
app-patternpan
"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type HTMLAttributes,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
type Ref,
type RefObject,
} from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Maximize2, RotateCcw, X, ZoomIn, ZoomOut } from "lucide-react";
import type { MermaidConfig } from "mermaid";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export interface MermaidRepairRequest {
chart: string;
error: string;
}
export type MermaidDiagramClassNames = {
root?: string;
header?: string;
title?: string;
caption?: string;
viewport?: string;
toolbar?: string;
error?: string;
};
export type MermaidDiagramTool =
"zoom-in" | "zoom-out" | "reset" | "fullscreen";
export interface MermaidDiagramProps {
chart: string;
responsiveChart?: MermaidResponsiveChart;
title?: string;
caption?: string;
showCaption?: boolean;
meta?: string;
config?: MermaidConfig;
isIncomplete?: boolean;
/**
* Header buttons for the inline diagram; omit one to hide it. Zoom tools
* render in the given order and only while `inlineZoom` is on; the
* fullscreen button always sits last, and leaving it out removes the
* fullscreen viewer. The fullscreen toolbar always keeps every zoom tool.
*/
tools?: readonly MermaidDiagramTool[];
inlineZoom?: boolean;
wheelZoom?: boolean;
initialZoom?: number;
minZoom?: number;
maxZoom?: number;
zoomStep?: number;
className?: string;
classNames?: MermaidDiagramClassNames;
onRepair?: (request: MermaidRepairRequest) => void | Promise<void>;
onRenderError?: (error: Error) => void;
renderLoading?: () => ReactNode;
renderError?: (context: MermaidRepairRequest) => ReactNode;
ref?: Ref<HTMLElement>;
}
export type MermaidResponsiveChart = {
chart: string;
query?: string;
};
export type DiagramViewportControls = {
reset: () => void;
zoomIn: () => void;
zoomOut: () => void;
};
export interface DiagramViewportProps extends Omit<
HTMLAttributes<HTMLDivElement>,
"children"
> {
children: ReactNode;
controlsRef?: Ref<DiagramViewportControls>;
/**
* For viewports that sit inside a scrolling page: plain wheel and touch
* gestures scroll the page, while ctrl/⌘+wheel (and trackpad pinch) zoom and
* mouse or pen drags pan.
*/
embedded?: boolean;
initialZoom?: number;
interactive?: boolean;
maxZoom?: number;
minZoom?: number;
wheelZoom?: boolean;
zoomStep?: number;
ref?: Ref<HTMLDivElement>;
}
type MermaidRenderResult = {
svg: string;
};
type RenderState =
| { status: "empty" | "idle" }
| { status: "loading"; lastSvg?: string }
| { status: "success"; svg: string }
| { status: "error"; message: string };
type RenderSnapshot =
| { status: "success"; svg: string; requestKey: string }
| { status: "error"; message: string; requestKey: string };
type DiagramMeta = {
caption?: string;
title?: string;
};
type ViewState = {
scale: number;
x: number;
y: number;
};
type ViewportPoint = {
x: number;
y: number;
};
const DEFAULT_MERMAID_CONFIG: MermaidConfig = {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
maxEdges: 500,
maxTextSize: 50_000,
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
};
const DEFAULT_TOOLS: readonly MermaidDiagramTool[] = [
"zoom-in",
"zoom-out",
"reset",
"fullscreen",
];
const FULLSCREEN_TOOLS: readonly MermaidDiagramTool[] = [
"zoom-in",
"zoom-out",
"reset",
];
const DEFAULT_MIN_ZOOM = 0.5;
const DEFAULT_MAX_ZOOM = 4;
const DEFAULT_ZOOM_STEP = 0.25;
const DEFAULT_RESPONSIVE_CHART_QUERY = "(max-width: 640px)";
const EMPTY_MERMAID_CONFIG: MermaidConfig = {};
const WHEEL_ZOOM_INTENSITY = 0.002;
const PINCH_WHEEL_ZOOM_INTENSITY = 0.008;
const WHEEL_GESTURE_IDLE_MS = 160;
const KEYBOARD_PAN_STEP = 40;
const KEYBOARD_PAN_STEP_FAST = 120;
let renderQueue: Promise<void> = Promise.resolve();
let renderSequence = 0;
const subscribeToRootTheme = (callback: () => void) => {
if (typeof document === "undefined") return () => {};
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, {
attributeFilter: ["class", "data-theme"],
attributes: true,
});
return () => observer.disconnect();
};
function getResolvedMermaidTheme(): "dark" | "default" {
if (typeof document === "undefined") return "default";
const root = document.documentElement;
const isDark =
root.classList.contains("dark") ||
(root.dataset.theme?.toLowerCase().includes("dark") ?? false);
return isDark ? "dark" : "default";
}
function getServerMermaidTheme(): "dark" | "default" {
return "default";
}
function parseMermaidFenceMeta(meta?: string): DiagramMeta {
if (!meta) return {};
const result: DiagramMeta = {};
const attributePattern =
/(?:^|\s)(title|caption)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g;
let match = attributePattern.exec(meta);
while (match) {
const key = match[1];
const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
if ((key === "title" || key === "caption") && value) result[key] = value;
match = attributePattern.exec(meta);
}
return result;
}
// Charts can be as large as maxTextSize, so requests are identified by a hash
// instead of carrying the full source around in state comparisons.
function hashRenderInput(input: string) {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let index = 0; index < input.length; index += 1) {
const code = input.charCodeAt(index);
h1 = Math.imul(h1 ^ code, 2654435761);
h2 = Math.imul(h2 ^ code, 1597334677);
}
h1 =
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 =
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return `${(h2 >>> 0).toString(36)}${(h1 >>> 0).toString(36)}:${input.length}`;
}
function getRenderRequestKey(
chart: string,
config: MermaidConfig,
theme: string,
) {
try {
return hashRenderInput(JSON.stringify([chart, config, theme]));
} catch {
return hashRenderInput(`${chart}:${theme}`);
}
}
async function renderMermaid(
idBase: string,
chart: string,
config: MermaidConfig,
theme: "dark" | "default",
) {
const task = renderQueue
.catch(() => undefined)
.then(async () => {
const mermaid = (await import("mermaid")).default;
const renderId = `mermaid-diagram-${idBase}-${++renderSequence}`;
mermaid.initialize({
...DEFAULT_MERMAID_CONFIG,
...config,
theme: config.theme ?? theme,
maxEdges: Math.min(
config.maxEdges ?? DEFAULT_MERMAID_CONFIG.maxEdges!,
2_000,
),
maxTextSize: Math.min(
config.maxTextSize ?? DEFAULT_MERMAID_CONFIG.maxTextSize!,
100_000,
),
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
});
try {
const result = (await mermaid.render(
renderId,
chart,
)) as MermaidRenderResult;
return result.svg;
} catch (error) {
// Mermaid can leave its temporary render element in the document when
// parsing fails; remove it so failed renders never accumulate nodes.
document.getElementById(renderId)?.remove();
document.getElementById(`d${renderId}`)?.remove();
throw error;
}
});
renderQueue = task.then(
() => undefined,
() => undefined,
);
return task;
}
export function MermaidDiagram({
chart,
responsiveChart,
title: titleProp,
caption: captionProp,
showCaption = true,
meta,
config = EMPTY_MERMAID_CONFIG,
isIncomplete = false,
tools = DEFAULT_TOOLS,
inlineZoom = true,
wheelZoom = true,
initialZoom = 1,
minZoom = DEFAULT_MIN_ZOOM,
maxZoom = DEFAULT_MAX_ZOOM,
zoomStep = DEFAULT_ZOOM_STEP,
className,
classNames,
onRepair,
onRenderError,
renderLoading,
renderError,
ref,
}: MermaidDiagramProps) {
const parsedMeta = useMemo(() => parseMermaidFenceMeta(meta), [meta]);
const title = titleProp?.trim() || parsedMeta.title;
const caption = showCaption
? captionProp?.trim() || parsedMeta.caption
: undefined;
const useResponsiveChart = useMediaQuery(
responsiveChart?.query ?? DEFAULT_RESPONSIVE_CHART_QUERY,
Boolean(responsiveChart),
);
const selectedChart = useResponsiveChart
? (responsiveChart?.chart ?? chart)
: chart;
const normalizedChart = useMemo(
() => selectedChart.replace(/\n+$/u, ""),
[selectedChart],
);
const renderState = useMermaidRender(
normalizedChart,
config,
isIncomplete,
onRenderError,
);
if (isIncomplete) {
return (
<figure
ref={ref}
className={cn(
"my-4 border bg-card p-4 text-sm text-muted-foreground",
className,
classNames?.root,
)}
>
Mermaid diagram is still streaming…
</figure>
);
}
if (renderState.status === "empty") {
return (
<figure
ref={ref}
className={cn(
"my-4 border bg-card p-4 text-sm text-muted-foreground",
className,
classNames?.root,
)}
>
No Mermaid source to render.
</figure>
);
}
if (renderState.status === "error") {
const context = { chart: normalizedChart, error: renderState.message };
return renderError ? (
<>{renderError(context)}</>
) : (
<MermaidRenderError
ref={(node) => setRefValue(ref, node)}
chart={normalizedChart}
error={renderState.message}
onRepair={onRepair}
className={cn(className, classNames?.error)}
/>
);
}
const svgHtml =
renderState.status === "success"
? renderState.svg
: renderState.status === "loading"
? renderState.lastSvg
: undefined;
if (svgHtml) {
return (
<SvgViewer
ref={ref}
svgHtml={svgHtml}
isRefreshing={renderState.status === "loading"}
title={title}
caption={caption}
tools={tools}
inlineZoom={inlineZoom}
wheelZoom={wheelZoom}
initialZoom={initialZoom}
minZoom={minZoom}
maxZoom={maxZoom}
zoomStep={zoomStep}
viewportClassName={classNames?.viewport}
toolbarClassName={classNames?.toolbar}
className={cn(className, classNames?.root)}
headerClassName={classNames?.header}
titleClassName={classNames?.title}
captionClassName={classNames?.caption}
/>
);
}
return (
<MermaidFrame
ref={ref}
title={title}
caption={caption}
isLoading
className={cn(className, classNames?.root)}
headerClassName={classNames?.header}
titleClassName={classNames?.title}
captionClassName={classNames?.caption}
>
{renderLoading ? (
renderLoading()
) : (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
<span
aria-hidden="true"
className="mr-2 size-4 animate-spin rounded-full border border-muted-foreground/30 border-b-muted-foreground"
/>
Rendering diagram…
</div>
)}
</MermaidFrame>
);
}
function useMediaQuery(query: string, enabled: boolean) {
const subscribe = useCallback(
(callback: () => void) => {
if (!enabled || typeof window === "undefined") return () => {};
const mediaQuery = window.matchMedia(query);
mediaQuery.addEventListener("change", callback);
return () => mediaQuery.removeEventListener("change", callback);
},
[enabled, query],
);
const getSnapshot = useCallback(
() =>
enabled &&
typeof window !== "undefined" &&
window.matchMedia(query).matches,
[enabled, query],
);
const getServerSnapshot = useCallback(() => false, []);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
function useMermaidRender(
chart: string,
config: MermaidConfig,
isIncomplete: boolean,
onRenderError?: (error: Error) => void,
): RenderState {
const reactId = useId();
const idBase = useMemo(
() => reactId.replace(/[^a-zA-Z0-9_-]/g, ""),
[reactId],
);
const theme = useSyncExternalStore(
subscribeToRootTheme,
getResolvedMermaidTheme,
getServerMermaidTheme,
);
const [snapshot, setSnapshot] = useState<RenderSnapshot | null>(null);
const requestKey = getRenderRequestKey(chart, config, theme);
// Inline `config` objects and `onRenderError` callbacks get a new identity on
// every parent render; reading them through refs keeps them out of the render
// effect's dependencies so only content changes (via requestKey) re-render.
const configRef = useRef(config);
const onRenderErrorRef = useRef(onRenderError);
useEffect(() => {
configRef.current = config;
onRenderErrorRef.current = onRenderError;
});
useEffect(() => {
if (isIncomplete || !chart.trim()) return;
let active = true;
void renderMermaid(idBase, chart, configRef.current, theme).then(
(svg) => {
if (active) setSnapshot({ status: "success", svg, requestKey });
},
(caught: unknown) => {
if (!active) return;
const error =
caught instanceof Error
? caught
: new Error("Failed to render Mermaid diagram.");
onRenderErrorRef.current?.(error);
setSnapshot({
status: "error",
message: error.message.trim() || "Failed to render Mermaid diagram.",
requestKey,
});
},
);
return () => {
active = false;
};
}, [chart, idBase, isIncomplete, requestKey, theme]);
if (isIncomplete) return { status: "idle" };
if (!chart.trim()) return { status: "empty" };
if (snapshot?.requestKey === requestKey) {
return snapshot.status === "success"
? { status: "success", svg: snapshot.svg }
: { status: "error", message: snapshot.message };
}
// While a new request renders, keep the previous diagram on screen instead of
// flashing back to the loading state.
return {
status: "loading",
lastSvg: snapshot?.status === "success" ? snapshot.svg : undefined,
};
}
function MermaidFrame({
title,
caption,
isLoading,
children,
actions,
actionsClassName,
className,
headerClassName,
titleClassName,
captionClassName,
ref,
}: {
title?: string;
caption?: string;
isLoading: boolean;
children: ReactNode;
actions?: ReactNode;
actionsClassName?: string;
className?: string;
headerClassName?: string;
titleClassName?: string;
captionClassName?: string;
ref?: Ref<HTMLElement>;
}) {
return (
<figure
ref={ref}
className={cn(
"my-4 overflow-hidden rounded-xl border bg-card text-card-foreground",
className,
)}
>
<div
data-slot="mermaid-diagram-header"
className={cn(
"flex min-h-14 items-center justify-between gap-3 border-b bg-muted/45 px-3 py-2",
headerClassName,
)}
>
<div className="min-w-0">
<figcaption
data-slot="mermaid-diagram-title"
className={cn(
"truncate text-base font-medium text-foreground",
titleClassName,
)}
>
{title?.trim() || "Mermaid diagram"}
</figcaption>
{caption?.trim() ? (
<p
data-slot="mermaid-diagram-caption"
className={cn(
"truncate text-xs text-muted-foreground",
captionClassName,
)}
>
{caption}
</p>
) : null}
</div>
<div
className={cn("flex shrink-0 items-center gap-1", actionsClassName)}
>
{isLoading ? (
<span className="font-mono text-[10px] text-muted-foreground uppercase">
Loading
</span>
) : null}
{actions}
</div>
</div>
{children}
</figure>
);
}
function SvgViewer({
ref,
svgHtml,
isRefreshing,
title,
caption,
tools,
inlineZoom,
wheelZoom,
initialZoom,
minZoom,
maxZoom,
zoomStep,
viewportClassName,
toolbarClassName,
className,
headerClassName,
titleClassName,
captionClassName,
}: {
ref?: Ref<HTMLElement>;
svgHtml: string;
isRefreshing: boolean;
title?: string;
caption?: string;
tools: readonly MermaidDiagramTool[];
inlineZoom: boolean;
wheelZoom: boolean;
initialZoom: number;
minZoom: number;
maxZoom: number;
zoomStep: number;
viewportClassName?: string;
toolbarClassName?: string;
className?: string;
headerClassName?: string;
titleClassName?: string;
captionClassName?: string;
}) {
const [open, setOpen] = useState(false);
const [animateFullscreen, setAnimateFullscreen] = useState(true);
const controlsRef = useRef<DiagramViewportControls | null>(null);
const inlineControlsRef = useRef<DiagramViewportControls | null>(null);
// Fullscreen opens on the diagram itself: it is the content, it takes the
// pan/zoom keys, and focusing a toolbar button would pop its tooltip open,
// so the first Escape would close the tooltip instead of fullscreen.
const fullscreenViewportRef = useRef<HTMLDivElement | null>(null);
const label = title?.trim() ? `${title} diagram` : "Mermaid diagram";
const fullscreen = tools.includes("fullscreen");
const inlineTools = inlineZoom
? [...new Set(tools)].filter((tool) => tool !== "fullscreen")
: [];
useFullscreenDocumentScrollLock(open);
return (
<TooltipProvider delay={150}>
<MermaidFrame
ref={ref}
title={title}
caption={caption}
isLoading={isRefreshing}
className={className}
headerClassName={headerClassName}
titleClassName={titleClassName}
captionClassName={captionClassName}
actionsClassName={toolbarClassName}
actions={
<>
{inlineTools.length > 0 ? (
<ViewerToolbar
controlsRef={inlineControlsRef}
tools={inlineTools}
/>
) : null}
{fullscreen ? (
<Button
aria-label="View diagram fullscreen"
onClick={(event) => {
setAnimateFullscreen(event.detail > 0);
setOpen(true);
}}
variant="ghost"
size="icon"
>
<Maximize2 />
</Button>
) : null}
</>
}
>
<DiagramViewport
controlsRef={inlineControlsRef}
interactive={inlineZoom}
embedded
wheelZoom={wheelZoom}
initialZoom={initialZoom}
minZoom={minZoom}
maxZoom={maxZoom}
zoomStep={zoomStep}
className={cn("min-h-48", viewportClassName)}
>
<SvgMount svgHtml={svgHtml} ariaLabel={label} />
</DiagramViewport>
</MermaidFrame>
<DialogPrimitive.Root
open={open}
onOpenChange={(nextOpen, eventDetails) => {
if (!nextOpen && eventDetails.event instanceof window.KeyboardEvent) {
setAnimateFullscreen(false);
}
setOpen(nextOpen);
}}
modal="trap-focus"
>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
data-slot="mermaid-fullscreen-backdrop"
className="fixed inset-0 isolate z-50 bg-transparent"
/>
<DialogPrimitive.Popup
data-slot="mermaid-fullscreen"
initialFocus={fullscreenViewportRef}
className={cn(
"fixed inset-0 z-50 grid size-full grid-rows-[auto_minmax(0,1fr)] bg-popover text-sm text-popover-foreground opacity-100 outline-none",
animateFullscreen
? "transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:opacity-0 motion-reduce:duration-100"
: "transition-none",
)}
>
<div className="flex min-h-14 items-stretch justify-between border-b bg-card">
<div className="flex min-w-0 flex-1 flex-col justify-center px-4">
<DialogPrimitive.Title
className={cn(
"truncate text-base font-medium",
titleClassName,
)}
>
{title?.trim() || "Mermaid diagram"}
</DialogPrimitive.Title>
<DialogPrimitive.Description
className={cn(
"truncate text-xs text-muted-foreground",
captionClassName,
!caption && "sr-only",
)}
>
{caption?.trim() ||
"Interactive fullscreen diagram. Drag to pan and use the toolbar to zoom."}
</DialogPrimitive.Description>
</div>
<div className="flex shrink-0 items-stretch">
<ViewerToolbar
controlsRef={controlsRef}
tools={FULLSCREEN_TOOLS}
className={cn("px-3", toolbarClassName)}
/>
<Separator
orientation="vertical"
className="w-px bg-foreground/20"
/>
<DialogPrimitive.Close
render={
<Button
aria-label="Exit fullscreen"
className="size-14 rounded-none [&_svg:not([class*='size-'])]:size-6"
variant="ghost"
size="icon"
/>
}
>
<X />
</DialogPrimitive.Close>
</div>
</div>
<DiagramViewport
ref={fullscreenViewportRef}
controlsRef={controlsRef}
interactive
wheelZoom={wheelZoom}
initialZoom={initialZoom}
minZoom={minZoom}
maxZoom={maxZoom}
zoomStep={zoomStep}
className={cn("min-h-0", viewportClassName)}
>
<SvgMount svgHtml={svgHtml} ariaLabel={label} />
</DiagramViewport>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</TooltipProvider>
);
}
function useFullscreenDocumentScrollLock(open: boolean) {
useLayoutEffect(() => {
if (!open) return;
const root = document.documentElement;
const body = document.body;
const rootOverflow = root.style.overflow;
const bodyOverflow = body.style.overflow;
root.style.overflow = "hidden";
body.style.overflow = "hidden";
return () => {
root.style.overflow = rootOverflow;
body.style.overflow = bodyOverflow;
};
}, [open]);
}
function IconButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={label}
onClick={onClick}
variant="ghost"
size="icon"
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function ViewerToolbar({
controlsRef,
tools,
className,
}: {
controlsRef: RefObject<DiagramViewportControls | null>;
tools: readonly MermaidDiagramTool[];
className?: string;
}) {
const getControls = () => controlsRef.current;
return (
<div className={cn("flex shrink-0 items-center gap-1", className)}>
{tools.map((tool) => {
switch (tool) {
case "zoom-in":
return (
<IconButton
key={tool}
label="Zoom in"
onClick={() => getControls()?.zoomIn()}
>
<ZoomIn />
</IconButton>
);
case "zoom-out":
return (
<IconButton
key={tool}
label="Zoom out"
onClick={() => getControls()?.zoomOut()}
>
<ZoomOut />
</IconButton>
);
case "reset":
return (
<IconButton
key={tool}
label="Reset diagram view"
onClick={() => getControls()?.reset()}
>
<RotateCcw />
</IconButton>
);
default:
return null;
}
})}
</div>
);
}
function setRefValue<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) ref.current = value;
}
export function DiagramViewport({
children,
controlsRef,
embedded = false,
initialZoom = 1,
interactive = true,
maxZoom = DEFAULT_MAX_ZOOM,
minZoom = DEFAULT_MIN_ZOOM,
wheelZoom = true,
zoomStep = DEFAULT_ZOOM_STEP,
className,
ref,
onKeyDown,
onPointerCancel,
onPointerDown,
onPointerMove,
onPointerUp,
...props
}: DiagramViewportProps) {
const reducedMotion = useReducedMotion();
const clampScale = useCallback(
(scale: number) =>
Math.min(
Math.max(maxZoom, minZoom),
Math.max(Math.min(minZoom, maxZoom), scale),
),
[maxZoom, minZoom],
);
const initialView = useMemo<ViewState>(
() => ({ scale: clampScale(initialZoom), x: 0, y: 0 }),
[clampScale, initialZoom],
);
const [view, setView] = useState(initialView);
const [isGesturing, setIsGesturing] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef(view);
const pointersRef = useRef(new Map<number, ViewportPoint>());
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
x: number;
y: number;
} | null>(null);
const pinchRef = useRef<{ distance: number; midpoint: ViewportPoint } | null>(
null,
);
const pendingViewRef = useRef<ViewState | null>(null);
const frameRef = useRef<number | null>(null);
const wheelIdleTimerRef = useRef<number | null>(null);
const setViewportNode = useCallback(
(node: HTMLDivElement | null) => {
viewportRef.current = node;
setRefValue(ref, node);
},
[ref],
);
const commitView = useCallback((next: ViewState) => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
pendingViewRef.current = null;
viewRef.current = next;
setView(next);
}, []);
const scheduleView = useCallback((next: ViewState) => {
viewRef.current = next;
pendingViewRef.current = next;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingViewRef.current;
pendingViewRef.current = null;
if (pending) setView(pending);
});
}, []);
// Zooming toward a point keeps the content under that point stationary. The
// point is expressed relative to the viewport center, which is also the
// content's transform origin.
const zoomAtPoint = useCallback(
(nextScale: number, point?: ViewportPoint, immediate = true) => {
const current = viewRef.current;
const scale = clampScale(nextScale);
if (scale === current.scale) return;
const next = point
? {
scale,
x: point.x - (point.x - current.x) * (scale / current.scale),
y: point.y - (point.y - current.y) * (scale / current.scale),
}
: { ...current, scale };
if (immediate) commitView(next);
else scheduleView(next);
},
[clampScale, commitView, scheduleView],
);
const zoom = useCallback(
(direction: 1 | -1) => {
zoomAtPoint(viewRef.current.scale + zoomStep * direction);
},
[zoomAtPoint, zoomStep],
);
const reset = useCallback(
() => commitView(initialView),
[commitView, initialView],
);
const getViewportPoint = useCallback(
(clientX: number, clientY: number): ViewportPoint => {
const node = viewportRef.current;
if (!node) return { x: 0, y: 0 };
const rect = node.getBoundingClientRect();
return {
x: clientX - rect.left - rect.width / 2,
y: clientY - rect.top - rect.height / 2,
};
},
[],
);
useLayoutEffect(() => {
setRefValue(controlsRef, {
reset,
zoomIn: () => zoom(1),
zoomOut: () => zoom(-1),
});
return () => setRefValue(controlsRef, null);
}, [controlsRef, reset, zoom]);
// React attaches wheel listeners passively, so preventing page scroll while
// zooming requires a native non-passive listener.
useEffect(() => {
const node = viewportRef.current;
if (!node || !interactive || !wheelZoom) return;
const handleWheel = (event: WheelEvent) => {
// Inside a page, an unmodified wheel is the reader scrolling past the
// diagram, not asking to zoom it.
if (embedded && !event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const deltaY =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? event.deltaY * 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? event.deltaY * 120
: event.deltaY;
// Trackpad pinches arrive as ctrl+wheel and expect a stronger response.
const intensity = event.ctrlKey
? PINCH_WHEEL_ZOOM_INTENSITY
: WHEEL_ZOOM_INTENSITY;
setIsGesturing(true);
if (wheelIdleTimerRef.current !== null)
window.clearTimeout(wheelIdleTimerRef.current);
wheelIdleTimerRef.current = window.setTimeout(() => {
wheelIdleTimerRef.current = null;
if (pointersRef.current.size === 0) setIsGesturing(false);
}, WHEEL_GESTURE_IDLE_MS);
zoomAtPoint(
viewRef.current.scale * Math.exp(-deltaY * intensity),
getViewportPoint(event.clientX, event.clientY),
false,
);
};
node.addEventListener("wheel", handleWheel, { passive: false });
return () => node.removeEventListener("wheel", handleWheel);
}, [embedded, getViewportPoint, interactive, wheelZoom, zoomAtPoint]);
useEffect(
() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (wheelIdleTimerRef.current !== null)
window.clearTimeout(wheelIdleTimerRef.current);
},
[],
);
const startPinch = useCallback(() => {
const [first, second] = [...pointersRef.current.values()];
if (!first || !second) return;
dragRef.current = null;
pinchRef.current = {
distance: Math.hypot(second.x - first.x, second.y - first.y),
midpoint: getViewportPoint(
(first.x + second.x) / 2,
(first.y + second.y) / 2,
),
};
}, [getViewportPoint]);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
onPointerDown?.(event);
if (!interactive || event.defaultPrevented || event.button !== 0) return;
// Touch keeps scrolling the page; fullscreen is where fingers pan and pinch.
if (embedded && event.pointerType === "touch") return;
event.currentTarget.setPointerCapture(event.pointerId);
pointersRef.current.set(event.pointerId, {
x: event.clientX,
y: event.clientY,
});
setIsGesturing(true);
if (pointersRef.current.size >= 2) {
startPinch();
} else {
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
x: viewRef.current.x,
y: viewRef.current.y,
};
}
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
onPointerMove?.(event);
const tracked = pointersRef.current.get(event.pointerId);
if (!tracked) return;
tracked.x = event.clientX;
tracked.y = event.clientY;
const pinch = pinchRef.current;
if (pinch && pointersRef.current.size >= 2) {
const [first, second] = [...pointersRef.current.values()];
const distance = Math.hypot(second.x - first.x, second.y - first.y);
if (pinch.distance <= 0 || distance <= 0) return;
const midpoint = getViewportPoint(
(first.x + second.x) / 2,
(first.y + second.y) / 2,
);
const current = viewRef.current;
const scale = clampScale(current.scale * (distance / pinch.distance));
const ratio = scale / current.scale;
scheduleView({
scale,
x: midpoint.x - (pinch.midpoint.x - current.x) * ratio,
y: midpoint.y - (pinch.midpoint.y - current.y) * ratio,
});
pinch.distance = distance;
pinch.midpoint = midpoint;
return;
}
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
scheduleView({
...viewRef.current,
x: drag.x + event.clientX - drag.startX,
y: drag.y + event.clientY - drag.startY,
});
};
const finishPointer = (event: PointerEvent<HTMLDivElement>) => {
if (!pointersRef.current.delete(event.pointerId)) return;
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
if (pointersRef.current.size >= 2) {
startPinch();
return;
}
pinchRef.current = null;
if (pointersRef.current.size === 1) {
const [remaining] = [...pointersRef.current.entries()];
dragRef.current = {
pointerId: remaining[0],
startX: remaining[1].x,
startY: remaining[1].y,
x: viewRef.current.x,
y: viewRef.current.y,
};
return;
}
dragRef.current = null;
const pending = pendingViewRef.current;
if (pending) commitView(pending);
setIsGesturing(false);
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (
!interactive ||
event.defaultPrevented ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return;
const panStep = event.shiftKey ? KEYBOARD_PAN_STEP_FAST : KEYBOARD_PAN_STEP;
const current = viewRef.current;
switch (event.key) {
case "ArrowUp":
commitView({ ...current, y: current.y + panStep });
break;
case "ArrowDown":
commitView({ ...current, y: current.y - panStep });
break;
case "ArrowLeft":
commitView({ ...current, x: current.x + panStep });
break;
case "ArrowRight":
commitView({ ...current, x: current.x - panStep });
break;
case "+":
case "=":
zoom(1);
break;
case "-":
case "_":
zoom(-1);
break;
case "0":
reset();
break;
default:
return;
}
event.preventDefault();
};
return (
<div
role={interactive ? "group" : undefined}
aria-label={
!interactive
? undefined
: embedded
? "Diagram viewport. Drag or use arrow keys to pan, press plus and minus to zoom, 0 to reset."
: "Diagram viewport. Drag or use arrow keys to pan, pinch or press plus and minus to zoom, 0 to reset."
}
tabIndex={interactive ? 0 : undefined}
{...props}
ref={setViewportNode}
data-mermaid-viewport={
!interactive ? "static" : embedded ? "embedded" : "interactive"
}
className={cn(
"relative flex overflow-hidden bg-background",
interactive &&
"cursor-grab select-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:outline-none focus-visible:ring-inset active:cursor-grabbing",
interactive && !embedded ? "touch-none" : "touch-auto",
className,
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(event) => {
onPointerUp?.(event);
finishPointer(event);
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
finishPointer(event);
}}
onKeyDown={handleKeyDown}
>
<div
data-mermaid-viewport-content
className="flex min-h-full w-full origin-center items-center justify-center p-4"
style={{
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
transition:
isGesturing || reducedMotion ? "none" : "transform 120ms ease-out",
// A page can hold many inline diagrams; only promote those in motion.
willChange:
interactive && (!embedded || isGesturing) ? "transform" : undefined,
}}
>
{children}
</div>
</div>
);
}
function SvgMount({
svgHtml,
ariaLabel,
}: {
svgHtml: string;
ariaLabel: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const svg = ref.current?.querySelector("svg");
// Mermaid inlines a pixel max-width on the root svg, which beats the
// class-based sizing below; override it so the diagram fits the viewport.
if (svg instanceof SVGSVGElement) {
svg.style.maxWidth = "100%";
svg.style.maxHeight = "100%";
}
}, [svgHtml]);
return (
<div
ref={ref}
role="img"
aria-label={ariaLabel}
className="flex size-full items-center justify-center text-foreground [&_svg]:h-auto [&_svg]:max-h-full [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svgHtml }}
/>
);
}
function MermaidRenderError({
chart,
error,
onRepair,
className,
ref,
}: {
chart: string;
error: string;
onRepair?: (request: MermaidRepairRequest) => void | Promise<void>;
className?: string;
ref?: Ref<HTMLDivElement>;
}) {
const [isRepairing, setIsRepairing] = useState(false);
const [repairError, setRepairError] = useState<string | null>(null);
const repair = async () => {
if (!onRepair || isRepairing) return;
setIsRepairing(true);
setRepairError(null);
try {
await onRepair({ chart, error });
} catch (caught) {
setRepairError(
caught instanceof Error && caught.message.trim()
? caught.message
: "Could not repair this diagram.",
);
} finally {
setIsRepairing(false);
}
};
return (
<div
ref={ref}
className={cn(
"my-4 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-foreground",
className,
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-destructive">
Mermaid diagram could not render.
</p>
<p className="mt-1 wrap-break-word text-muted-foreground">{error}</p>
{repairError ? (
<p className="mt-2 text-xs wrap-break-word text-destructive">
{repairError}
</p>
) : null}
</div>
{onRepair ? (
<Button
disabled={isRepairing}
onClick={() => void repair()}
size="sm"
type="button"
variant="outline"
>
{isRepairing ? "Repairing…" : "Repair diagram"}
</Button>
) : null}
</div>
<details className="mt-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
View Mermaid source
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded-lg border bg-muted p-3 text-xs text-muted-foreground">
<code>{chart}</code>
</pre>
</details>
</div>
);
}