feat(app): add fullscreen viewer for Mermaid diagrams on web (#4107)

* feat(app): add fullscreen viewer for Mermaid diagrams on web

Add a fullscreen action to the diagram toolbar that opens the diagram in
a modal ZoomableViewport with free zoom/pan, closable via the close
button, Escape, or clicking outside the diagram. The sandboxed iframe
runtime moves into a shared MermaidIframeRuntime so the inline host and
the fullscreen viewer drive the same renderer.

Fixes uncovered while verifying the feature end to end:

- The runtime measured the rendered SVG with getBoundingClientRect(),
  which depends on the iframe size the host derives from that very
  measurement. Each streaming re-render shrank the reported size by the
  diagram box padding, ratcheting diagrams down to a few pixels. Measure
  the viewBox instead, which is the diagram's own size, and let the SVG
  fill whatever frame the host provides.
- ZoomableViewport's root is flex: 1, so as a flex item its 0% basis
  overrode the inline diagram box height and the box collapsed, scaling
  the diagram tiny and clipping the toolbar unclickable. Size the box
  from its height and keep a minimum tall enough for the toolbar.
- react-native-web's Modal focus trap focuses the first descendant that
  takes focus, which was the diagram iframe; every keystroke then landed
  in the sandboxed frame and Escape never reached the window listener.
  Mark the presentational iframe inert and focus the viewer's content
  layer on mount.

Closes #4106

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(app): exercise Mermaid fullscreen interactions

Cover zoom, pan, outside-click dismissal, and streaming layout through the real browser path. Remove the mocked DOM test that duplicated those behaviors without exercising the viewport or iframe runtime.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Jiawen Geng
2026-09-02 16:10:26 +02:00
committed by GitHub
co-authored by Claude Fable 5 Mohamed Boudra
parent d4841b68ff
commit fa1f01ebbc
18 changed files with 528 additions and 107 deletions
@@ -54,6 +54,11 @@ const html = `<!doctype html>
margin: 0;
padding: 0;
background: transparent;
height: 100%;
}
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
#diagram {
height: 100%;
}
#diagram svg {
display: block;
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import type { DiagramColorScheme } from "./render-model";
import { useMermaidRenderModel } from "./use-render-model";
interface MermaidFullscreenViewerProps {
source: string;
colorScheme: DiagramColorScheme;
onClose: () => void;
}
// The diagram is already in the render cache, so the viewport knows its size before the
// fullscreen iframe has re-rendered it.
const FALLBACK_SIZE = { width: 240, height: 240 };
const VIEWER_FIT = { padding: SPACING[6] };
export function MermaidFullscreenViewer({
source,
colorScheme,
onClose,
}: MermaidFullscreenViewerProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source,
phase: "complete",
colorScheme,
});
const handleRendered = useCallback(
(message: MermaidRenderedMessage) => {
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
useEffect(() => {
function handleKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [onClose]);
// The Modal focus trap walks descendants and focuses the first one that takes it. Focus the
// content layer ourselves (this effect runs after the trap's) so keystrokes stay in this
// document instead of disappearing into a focused diagram iframe.
const contentLayerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
contentLayerRef.current?.focus();
}, []);
const visible = state.visible;
const contentSize = useMemo(
() => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE),
[visible],
);
const actions = useMemo(
() => [
{
icon: X,
label: t("common.actions.close"),
onPress: onClose,
testID: "mermaid-fullscreen-close",
},
],
[onClose, t],
);
const contentLayerStyle = useMemo(
() => [
styles.contentLayer,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
},
],
[insets.bottom, insets.left, insets.right, insets.top],
);
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<WindowChromeRootRegion corners="both">
<View style={styles.root}>
<View style={styles.backdrop} />
<View style={contentLayerStyle}>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */}
<div ref={contentLayerRef} style={focusLayerDomStyle} tabIndex={-1}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={contentSize}
fit={VIEWER_FIT}
minScale={1}
onPressOutsideContent={onClose}
style={styles.viewport}
testID="mermaid-fullscreen-viewport"
wheelActivation="always"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
</View>
</View>
</WindowChromeRootRegion>
</Modal>
);
}
const focusLayerDomStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
outline: "none",
};
const styles = StyleSheet.create((theme) => ({
root: { flex: 1, minHeight: 0, minWidth: 0 },
backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 },
contentLayer: { position: "absolute", inset: 0 },
viewport: { flex: 1 },
}));
@@ -1,104 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
import { Code, Workflow } from "lucide-react-native";
import { Code, Maximize2, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
height: React.CSSProperties["height"];
onRendered: (message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => void;
onRenderFailed: (revision: number) => void;
}
function MermaidIframeRuntime({
request,
height,
onRendered,
onRenderFailed,
}: MermaidIframeRuntimeProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const iframeStyle = useMemo<React.CSSProperties>(
() => ({
display: "block",
width: "100%",
height,
border: 0,
pointerEvents: "none",
background: "transparent",
}),
[height],
);
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) return;
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive: false,
};
target.postMessage(message, "*");
}, []);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = parseMermaidRuntimeMessage(event.data);
if (!message) return;
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
}
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onRenderFailed, onRendered, sendRequest]);
return (
<iframe
ref={iframeRef}
title=""
aria-hidden
sandbox="allow-scripts"
srcDoc={mermaidRuntimeHtml}
tabIndex={-1}
style={iframeStyle}
/>
);
}
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
@@ -118,16 +31,13 @@ function MermaidFenceHostImpl({
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const [showSource, setShowSource] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const showSourcePress = useCallback(() => setShowSource(true), []);
const showDiagramPress = useCallback(() => setShowSource(false), []);
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
(message: MermaidRenderedMessage) => {
setHasRuntimeContent(true);
rendered({
revision: message.revision,
@@ -149,8 +59,14 @@ function MermaidFenceHostImpl({
label: t("message.diagram.viewSource"),
onPress: showSourcePress,
},
{
icon: Maximize2,
label: t("message.diagram.fullscreen"),
onPress: openFullscreen,
testID: "mermaid-fullscreen",
},
],
[showSourcePress, t],
[openFullscreen, showSourcePress, t],
);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
@@ -162,7 +78,11 @@ function MermaidFenceHostImpl({
return { container: [margins, sourceContainerStyle], text };
}, [textStyle]);
const diagramStyle = useMemo(
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
const diagramSize = useMemo(
@@ -212,18 +132,40 @@ function MermaidFenceHostImpl({
>
<MermaidIframeRuntime
request={request}
height="100%"
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
colorScheme={visible.colorScheme}
onClose={closeFullscreen}
/>
) : null}
</>
);
}
const MEASURING_SIZE = { width: 240, height: 240 };
/**
* The toolbar overlays the top of the box (8px offset + 32px compact buttons) and the box clips
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const sourceContainerStyle: ViewStyle = { position: "relative" };
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
* replaces the height below. In the markdown column there is no free space to grow into, so the
* box collapses and the diagram is scaled down to fit a couple of pixels. Size it from the height.
*/
const containerStyle: ViewStyle = {
flexBasis: "auto",
flexGrow: 0,
flexShrink: 0,
overflow: "hidden",
position: "relative",
};
const measuringStyle: ViewStyle = {
position: "absolute",
left: 0,
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef } from "react";
import type { DiagramColorScheme, MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
export interface MermaidRenderedMessage {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
height: number;
width: number;
}
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
onRendered: (message: MermaidRenderedMessage) => void;
onRenderFailed: (revision: number) => void;
}
/** Sandboxed Mermaid renderer. Sizing and gestures belong to the surrounding viewport. */
export function MermaidIframeRuntime({
request,
onRendered,
onRenderFailed,
}: MermaidIframeRuntimeProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) return;
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive: false,
};
target.postMessage(message, "*");
}, []);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = parseMermaidRuntimeMessage(event.data);
if (!message) return;
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
}
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onRenderFailed, onRendered, sendRequest]);
// `inert` (not just tabIndex) because the Modal focus trap focuses descendants
// programmatically; a focused iframe swallows every keystroke, including Escape.
return (
<iframe
ref={iframeRef}
title=""
aria-hidden
inert
sandbox="allow-scripts"
srcDoc={mermaidRuntimeHtml}
tabIndex={-1}
style={iframeStyle}
/>
);
}
const iframeStyle: React.CSSProperties = {
display: "block",
width: "100%",
height: "100%",
border: 0,
pointerEvents: "none",
background: "transparent",
};
@@ -59,6 +59,41 @@ let pendingRender: MermaidRuntimeRenderMessage | null = null;
let isRendering = false;
let isDrainScheduled = false;
interface DiagramSize {
height: number;
width: number;
}
/**
* Mermaid emits `width="100%"` with an inline `max-width` in pixels, so the diagram renders at
* its natural width and never fills a larger box. The host sizes this frame, so let the diagram
* follow the frame in both directions instead.
*/
function stretchToFrame(svg: SVGSVGElement): void {
svg.style.maxWidth = "100%";
svg.style.maxHeight = "100%";
svg.style.width = "100%";
svg.style.height = "100%";
}
/**
* The reported size is the host's content size, and the host feeds it back as this frame's size.
* Measuring the rendered box would make that a loop: every re-render lands in a frame the last
* measurement shrank by the container's padding, so a streaming diagram ratchets down to nothing.
* The viewBox is the diagram's own size and doesn't move.
*/
function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSize {
const viewBox = svg?.viewBox.baseVal;
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { height: Math.ceil(viewBox.height), width: Math.ceil(viewBox.width) };
}
const rect = svg?.getBoundingClientRect();
return {
height: Math.ceil(rect?.height ?? host.scrollHeight),
width: Math.ceil(rect?.width ?? host.scrollWidth),
};
}
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
@@ -72,14 +107,18 @@ async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
return;
}
host.innerHTML = svg;
const rect = host.querySelector("svg")?.getBoundingClientRect();
const element = host.querySelector("svg");
if (element) {
stretchToFrame(element);
}
const size = measureDiagram(host, element);
sendToHost({
type: "rendered",
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
height: Math.ceil(rect?.height ?? host.scrollHeight),
width: Math.ceil(rect?.width ?? host.scrollWidth),
height: size.height,
width: size.width,
});
} catch {
if (message.revision === latestRevision) {
File diff suppressed because one or more lines are too long
@@ -29,9 +29,13 @@ function waitForRuntimeMessage(
});
}
async function mountRuntime(): Promise<HTMLIFrameElement> {
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
if (size) {
frame.style.width = `${size.width}px`;
frame.style.height = `${size.height}px`;
}
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
frame.srcdoc = mermaidRuntimeHtml;
document.body.append(frame);
@@ -40,6 +44,13 @@ async function mountRuntime(): Promise<HTMLIFrameElement> {
return frame;
}
function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } {
if (message.type !== "rendered") {
throw new Error(`Expected a rendered diagram, got ${message.type}`);
}
return { height: message.height, width: message.width };
}
function render(
frame: HTMLIFrameElement,
input: { revision: number; source: string },
@@ -85,6 +96,22 @@ describe("Mermaid sandbox runtime", () => {
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
});
/**
* The host sizes this frame from the reported size, so a size that depends on the frame feeds
* back: every re-render measures inside a frame the previous measurement already shrank by the
* container's padding, and a streaming diagram ratchets down to a few pixels.
*/
it("reports the same size whatever frame the host gives it", async () => {
const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]";
const narrowFrame = await mountRuntime({ height: 60, width: 60 });
const wideFrame = await mountRuntime({ height: 600, width: 900 });
const narrow = await render(narrowFrame, { revision: 1, source });
const wide = await render(wideFrame, { revision: 1, source });
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
+1
View File
@@ -286,6 +286,7 @@ export const ar: TranslationResources = {
zoomIn: "تكبير",
zoomOut: "تصغير",
resetZoom: "إعادة تعيين العرض",
fullscreen: "ملء الشاشة",
viewSource: "عرض المصدر",
viewDiagram: "عرض المخطط",
},
+1
View File
@@ -284,6 +284,7 @@ export const en = {
zoomIn: "Zoom in",
zoomOut: "Zoom out",
resetZoom: "Reset view",
fullscreen: "Fullscreen",
viewSource: "View source",
viewDiagram: "View diagram",
},
+1
View File
@@ -289,6 +289,7 @@ export const es: TranslationResources = {
zoomIn: "Acercar",
zoomOut: "Alejar",
resetZoom: "Restablecer vista",
fullscreen: "Pantalla completa",
viewSource: "Ver fuente",
viewDiagram: "Ver diagrama",
},
+1
View File
@@ -290,6 +290,7 @@ export const fr: TranslationResources = {
zoomIn: "Zoomer",
zoomOut: "Dézoomer",
resetZoom: "Réinitialiser la vue",
fullscreen: "Plein écran",
viewSource: "Afficher la source",
viewDiagram: "Afficher le diagramme",
},
+1
View File
@@ -289,6 +289,7 @@ export const ja: TranslationResources = {
zoomIn: "拡大",
zoomOut: "縮小",
resetZoom: "表示をリセット",
fullscreen: "全画面表示",
viewSource: "ソースを表示",
viewDiagram: "図を表示",
},
+1
View File
@@ -287,6 +287,7 @@ export const ko: TranslationResources = {
zoomIn: "확대",
zoomOut: "축소",
resetZoom: "보기 초기화",
fullscreen: "전체 화면",
viewSource: "소스 보기",
viewDiagram: "다이어그램 보기",
},
+1
View File
@@ -289,6 +289,7 @@ export const ptBR: TranslationResources = {
zoomIn: "Aproximar",
zoomOut: "Afastar",
resetZoom: "Redefinir visualização",
fullscreen: "Tela cheia",
viewSource: "Ver fonte",
viewDiagram: "Ver diagrama",
},
+1
View File
@@ -288,6 +288,7 @@ export const ru: TranslationResources = {
zoomIn: "Увеличить масштаб",
zoomOut: "Уменьшить масштаб",
resetZoom: "Сбросить масштаб",
fullscreen: "Полноэкранный режим",
viewSource: "Показать исходный код",
viewDiagram: "Показать диаграмму",
},
+1
View File
@@ -286,6 +286,7 @@ export const zhCN: TranslationResources = {
zoomIn: "放大",
zoomOut: "缩小",
resetZoom: "重置视图",
fullscreen: "全屏",
viewSource: "查看源码",
viewDiagram: "查看图表",
},