mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
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:
co-authored by
Claude Fable 5
Mohamed Boudra
parent
d4841b68ff
commit
fa1f01ebbc
@@ -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")}`;
|
||||
|
||||
@@ -286,6 +286,7 @@ export const ar: TranslationResources = {
|
||||
zoomIn: "تكبير",
|
||||
zoomOut: "تصغير",
|
||||
resetZoom: "إعادة تعيين العرض",
|
||||
fullscreen: "ملء الشاشة",
|
||||
viewSource: "عرض المصدر",
|
||||
viewDiagram: "عرض المخطط",
|
||||
},
|
||||
|
||||
@@ -284,6 +284,7 @@ export const en = {
|
||||
zoomIn: "Zoom in",
|
||||
zoomOut: "Zoom out",
|
||||
resetZoom: "Reset view",
|
||||
fullscreen: "Fullscreen",
|
||||
viewSource: "View source",
|
||||
viewDiagram: "View diagram",
|
||||
},
|
||||
|
||||
@@ -289,6 +289,7 @@ export const es: TranslationResources = {
|
||||
zoomIn: "Acercar",
|
||||
zoomOut: "Alejar",
|
||||
resetZoom: "Restablecer vista",
|
||||
fullscreen: "Pantalla completa",
|
||||
viewSource: "Ver fuente",
|
||||
viewDiagram: "Ver diagrama",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -289,6 +289,7 @@ export const ja: TranslationResources = {
|
||||
zoomIn: "拡大",
|
||||
zoomOut: "縮小",
|
||||
resetZoom: "表示をリセット",
|
||||
fullscreen: "全画面表示",
|
||||
viewSource: "ソースを表示",
|
||||
viewDiagram: "図を表示",
|
||||
},
|
||||
|
||||
@@ -287,6 +287,7 @@ export const ko: TranslationResources = {
|
||||
zoomIn: "확대",
|
||||
zoomOut: "축소",
|
||||
resetZoom: "보기 초기화",
|
||||
fullscreen: "전체 화면",
|
||||
viewSource: "소스 보기",
|
||||
viewDiagram: "다이어그램 보기",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -288,6 +288,7 @@ export const ru: TranslationResources = {
|
||||
zoomIn: "Увеличить масштаб",
|
||||
zoomOut: "Уменьшить масштаб",
|
||||
resetZoom: "Сбросить масштаб",
|
||||
fullscreen: "Полноэкранный режим",
|
||||
viewSource: "Показать исходный код",
|
||||
viewDiagram: "Показать диаграмму",
|
||||
},
|
||||
|
||||
@@ -286,6 +286,7 @@ export const zhCN: TranslationResources = {
|
||||
zoomIn: "放大",
|
||||
zoomOut: "缩小",
|
||||
resetZoom: "重置视图",
|
||||
fullscreen: "全屏",
|
||||
viewSource: "查看源码",
|
||||
viewDiagram: "查看图表",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user