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
@@ -0,0 +1,74 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
expectFullscreenDiagramClosed,
openDiagramFullscreen,
requestDiagram,
waitForDiagramTurnToComplete,
zoomAndPanFullscreenDiagram,
} from "../support/helpers/diagram";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const MERMAID_DIAGRAM = [
"```mermaid",
"flowchart LR",
" Start --> Middle",
" Middle --> Review",
" Review --> Ship",
"```",
].join("\n");
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-fullscreen-",
title: "Fullscreen Mermaid",
featureValues: {
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await test.step("Open the conversation and wait for the rendered diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
});
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
await zoomAndPanFullscreenDiagram(page);
});
await test.step("The close action returns to the conversation", async () => {
await closeDiagramFullscreen(page);
await expectFullscreenDiagramClosed(page);
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Escape closes the fullscreen diagram", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Ship"]);
await page.keyboard.press("Escape");
await expectFullscreenDiagramClosed(page);
});
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Ship"]);
await closeDiagramFullscreenFromOutside(page);
await expectFullscreenDiagramClosed(page);
});
} finally {
await agent.cleanup();
}
});
@@ -33,6 +33,12 @@ export async function expectDiagramRemainsRenderedWhileStreaming(
const completed = completion.then(() => true);
for (;;) {
expect(await diagram.isVisible()).toBe(true);
const box = await diagram.boundingBox();
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
expect(
box?.height,
"the inline diagram should remain tall enough to read",
).toBeGreaterThanOrEqual(56);
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
if (didComplete) return completion;
}
@@ -49,3 +55,90 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
export async function reloadConversation(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
}
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
function diagramSvg(page: Page, viewportTestId: string) {
return page
.getByTestId(`${viewportTestId}-canvas`)
.last()
.locator("iframe")
.contentFrame()
.locator("#diagram svg");
}
/**
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
* step left the cursor inside the viewport.
*/
export async function openDiagramFullscreen(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await page.getByTestId("mermaid-viewport-canvas").last().hover();
await page.getByTestId("mermaid-fullscreen").last().click();
}
export async function closeDiagramFullscreen(page: Page): Promise<void> {
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
await page.getByTestId("mermaid-fullscreen-close").click();
}
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
const initial = await svg.boundingBox();
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
await page.mouse.wheel(0, -100);
const zoomed = await svg.boundingBox();
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
const viewport = await canvas.boundingBox();
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
await page.mouse.down();
await page.mouse.move(
viewport!.x + viewport!.width / 2 - 120,
viewport!.y + viewport!.height / 2,
);
await page.mouse.up();
const panned = await svg.boundingBox();
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
}
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
const viewport = await canvas.boundingBox();
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
const x = viewport!.x + viewport!.width / 2;
const y =
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
? viewport!.y + viewport!.height - 4
: viewport!.y + 4;
await page.mouse.click(x, y);
}
export async function expectFullscreenDiagram(
page: Page,
labels: readonly string[],
): Promise<void> {
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
await expect(viewport).toBeVisible({ timeout: 30_000 });
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
await expect(svg).toBeVisible({ timeout: 30_000 });
for (const label of labels) {
await expect(svg).toContainText(label);
}
}
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
@@ -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: "查看图表",
},