From fa1f01ebbcce84b3e8e27ef4f2b3473d5d7b5a33 Mon Sep 17 00:00:00 2001 From: Jiawen Geng Date: Wed, 2 Sep 2026 22:10:26 +0800 Subject: [PATCH] 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 * 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 Co-authored-by: Mohamed Boudra --- .../e2e/browser/mermaid-fullscreen.spec.ts | 74 +++++++++ packages/app/e2e/support/helpers/diagram.ts | 93 +++++++++++ .../markdown/fence/mermaid/build-runtime.mjs | 5 + .../fence/mermaid/fullscreen-viewer.web.tsx | 140 +++++++++++++++++ .../markdown/fence/mermaid/host.web.tsx | 146 ++++++------------ .../fence/mermaid/iframe-runtime.web.tsx | 92 +++++++++++ .../markdown/fence/mermaid/runtime/entry.ts | 45 +++++- .../fence/mermaid/runtime/html.gen.ts | 2 +- .../mermaid/runtime/runtime.browser.test.ts | 29 +++- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + 18 files changed, 528 insertions(+), 107 deletions(-) create mode 100644 packages/app/e2e/browser/mermaid-fullscreen.spec.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/iframe-runtime.web.tsx diff --git a/packages/app/e2e/browser/mermaid-fullscreen.spec.ts b/packages/app/e2e/browser/mermaid-fullscreen.spec.ts new file mode 100644 index 0000000000..72b05ed83e --- /dev/null +++ b/packages/app/e2e/browser/mermaid-fullscreen.spec.ts @@ -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(); + } +}); diff --git a/packages/app/e2e/support/helpers/diagram.ts b/packages/app/e2e/support/helpers/diagram.ts index f320f40a6f..bfa91b36e6 100644 --- a/packages/app/e2e/support/helpers/diagram.ts +++ b/packages/app/e2e/support/helpers/diagram.ts @@ -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 { 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 { + 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 { + await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover(); + await page.getByTestId("mermaid-fullscreen-close").click(); +} + +export async function zoomAndPanFullscreenDiagram(page: Page): Promise { + 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 { + 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 { + 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 { + await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0); +} diff --git a/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs index e4f61b8899..6ed821ceac 100644 --- a/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs +++ b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs @@ -54,6 +54,11 @@ const 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; diff --git a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx new file mode 100644 index 0000000000..8b68ae61cb --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx @@ -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(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 ( + + + + + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */} +
+ + + +
+
+
+
+
+ ); +} + +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 }, +})); diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx index f99b5e6630..e79922ec43 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx @@ -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(null); - const driverRef = useRef(null); - driverRef.current ??= new MermaidRuntimeRequestDriver(); - const iframeStyle = useMemo( - () => ({ - 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 ( - `},\"putIntoIFrame\"),n$e=v((t,e,r,n,i)=>{let a=t.append(\"div\");a.attr(\"id\",r),n&&a.attr(\"style\",n);let s=a.append(\"svg\").attr(\"id\",e).attr(\"width\",\"100%\").attr(\"xmlns\",gNt);return i&&s.attr(\"xmlns:xlink\",i),s.append(\"g\"),t},\"appendDivSvgG\");function VK(t,e){return t.append(\"iframe\").attr(\"id\",e).attr(\"style\",\"width: 100%; height: 100%;\").attr(\"sandbox\",\"\")}v(VK,\"sandboxedIframe\");var INt=v((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},\"removeExistingElements\"),MNt=v(async function(t,e,r){lM();let n=qK(e);e=n.code;let i=It();ne.debug(i),e.length>(i?.maxTextSize??hNt)&&(e=dNt);let a=`#${t}`,s=\"i\"+t,o=\"#\"+s,l=\"d\"+t,u=\"#\"+l,h=v(()=>{let _=tt(f?o:u).node();_&&\"remove\"in _&&_.remove()},\"removeTempElements\"),d=tt(document.body),f=i.securityLevel===fNt,p=i.securityLevel===pNt,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=\"\"),f){let R=VK(tt(r),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(r);n$e(d,t,l,`font-family: ${g}`,mNt)}else{if(INt(document,t,l,s),f){let R=VK(tt(document.body),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(\"body\");n$e(d,t,l)}let m,y;try{m=await GK.fromText(e,{title:n.title})}catch(R){if(i.suppressErrorRendering)throw h(),R;m=await GK.fromText(\"error\"),y=R}let x=d.select(u).node(),b=m.type,T=x.firstChild,k=T.firstChild,C=m.renderer.getClasses?.(e,m),w=_Nt(i,b,C,a),S=document.createElement(\"style\");S.innerHTML=w,T.insertBefore(S,k);try{await m.renderer.draw(e,t,\"11.16.0\",m)}catch(R){throw i.suppressErrorRendering?h():$Mt.draw(e,t,\"11.16.0\"),R}let A=d.select(`${u} svg`),L=m.db.getAccTitle?.(),M=m.db.getAccDescription?.();K$e(b,A,L,M),d.select(`[id=\"${t}\"]`).selectAll(\"foreignobject > *\").attr(\"xmlns\",yNt);let P=d.select(u).node().innerHTML;if(ne.debug(\"config.arrowMarkerAbsolute\",i.arrowMarkerAbsolute),P=RNt(P,f,yl(i.arrowMarkerAbsolute)),f){let R=d.select(u+\" svg\").node();P=LNt(P,R)}else p||(P=to.sanitize(P,{ADD_TAGS:kNt,ADD_ATTR:SNt,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(sNt(),y)throw y;return h(),{diagramType:b,svg:P,bindFunctions:m.db.bindFunctions}},\"render\");function X$e(t={}){let e=Jn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),NJ(e),e?.theme&&e.theme in qc?e.themeVariables=qc[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=qc.default.getThemeVariables(e.themeVariables));let r=typeof e==\"object\"?DJ(e):AD();rC(r.logLevel),lM()}v(X$e,\"initialize\");var j$e=v((t,e={})=>{let{code:r}=WK(t);return GK.fromText(r,e)},\"getDiagramFromText\");function K$e(t,e,r,n){W$e(e,t),q$e(e,r,n,e.attr(\"id\"))}v(K$e,\"addA11yInfo\");var Im=Object.freeze({render:MNt,parse:Y$e,getDiagramFromText:j$e,initialize:X$e,getConfig:It,setConfig:_D,getSiteConfig:AD,updateSiteConfig:OJ,reset:v(()=>{CC()},\"reset\"),globalReset:v(()=>{CC(Gp)},\"globalReset\"),defaultConfig:Gp});rC(It().logLevel);CC(It());var DNt=v((t,e,r)=>{ne.warn(t),fA(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},\"handleError\"),Z$e=v(async function(t={querySelector:\".mermaid\"}){try{await NNt(t)}catch(e){if(fA(e)&&ne.error(e.str),wd.parseError&&wd.parseError(e),!t.suppressErrors)throw ne.error(\"Use the suppressErrors option to suppress these errors\"),e}},\"run\"),NNt=v(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:\".mermaid\"}){let n=Im.getConfig();ne.debug(`${t?\"\":\"No \"}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error(\"Nodes and querySelector are both undefined\");ne.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(ne.debug(\"Start On Load: \"+n?.startOnLoad),Im.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new Zt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,o=[];for(let l of Array.from(i)){if(ne.info(\"Rendering diagram: \"+l.id),l.getAttribute(\"data-processed\"))continue;l.setAttribute(\"data-processed\",\"true\");let u=`mermaid-${a.next()}`;s=l.innerHTML,s=CA(Zt.entityDecode(s)).trim().replace(//gi,\"
\");let h=Zt.detectInit(s);h&&ne.debug(\"Detected early reinit: \",h);try{let{svg:d,bindFunctions:f}=await tFe(u,s,l);l.innerHTML=d,t&&await t(u),f&&f(l)}catch(d){DNt(d,o,wd.parseError)}}if(o.length>0)throw o[0]},\"runThrowsErrors\"),Q$e=v(function(t){Im.initialize(t)},\"initialize\"),ONt=v(async function(t,e,r){ne.warn(\"mermaid.init is deprecated. Please use run instead.\"),t&&Q$e(t);let n={postRenderCallback:r,querySelector:\".mermaid\"};typeof e==\"string\"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await Z$e(n)},\"init\"),PNt=v(async(t,{lazyLoad:e=!0}={})=>{lM(),c5(...t),e===!1&&await iNt()},\"registerExternalDiagrams\"),J$e=v(function(){if(wd.startOnLoad){let{startOnLoad:t}=Im.getConfig();t&&wd.run().catch(e=>ne.error(\"Mermaid failed to initialize\",e))}},\"contentLoaded\");typeof document<\"u\"&&window.addEventListener(\"load\",J$e,!1);var BNt=v(function(t){wd.parseError=t},\"setParseErrorHandler\"),oM=[],zK=!1,eFe=v(async()=>{if(!zK){for(zK=!0;oM.length>0;){let t=oM.shift();if(t)try{await t()}catch(e){ne.error(\"Error executing queue\",e)}}zK=!1}},\"executeQueue\"),$Nt=v(async(t,e)=>new Promise((r,n)=>{let i=v(()=>new Promise((a,s)=>{Im.parse(t,e).then(o=>{a(o),r(o)},o=>{ne.error(\"Error parsing\",o),wd.parseError?.(o),s(o),n(o)})}),\"performCall\");oM.push(i),eFe().catch(n)}),\"parse\"),tFe=v((t,e,r)=>new Promise((n,i)=>{let a=v(()=>new Promise((s,o)=>{Im.render(t,e,r).then(l=>{s(l),n(l)},l=>{ne.error(\"Error parsing\",l),wd.parseError?.(l),o(l),i(l)})}),\"performCall\");oM.push(a),eFe().catch(i)}),\"render\"),FNt=v(()=>Object.keys(Bd).map(t=>({id:t})),\"getRegisteredDiagramsMetadata\"),wd={startOnLoad:!0,mermaidAPI:Im,parse:$Nt,render:tFe,init:ONt,run:Z$e,registerExternalDiagrams:PNt,registerLayoutLoaders:_G,initialize:Q$e,parseError:void 0,contentLoaded:J$e,setParseErrorHandler:BNt,detectType:wC,registerIconPacks:qy,getRegisteredDiagramsMetadata:FNt},UK=wd;function zNt(t){return typeof t==\"object\"&&t!==null}function GNt(t){return t===\"light\"||t===\"dark\"}function rFe(t){return!zNt(t)||t.type!==\"render\"||typeof t.revision!=\"number\"||!Number.isInteger(t.revision)||typeof t.source!=\"string\"||!GNt(t.colorScheme)||typeof t.interactive!=\"boolean\"?null:{type:\"render\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,interactive:t.interactive}}function YK(t){window.ReactNativeWebView?.postMessage?.(JSON.stringify(t)),window.parent!==window&&window.parent.postMessage(t,\"*\")}function VNt(t){UK.initialize({startOnLoad:!1,securityLevel:\"strict\",suppressErrorRendering:!0,theme:t===\"dark\"?\"dark\":\"default\",secure:[\"secure\",\"securityLevel\",\"startOnLoad\",\"maxTextSize\",\"suppressErrorRendering\",\"maxEdges\",\"theme\",\"themeVariables\",\"themeCSS\"],flowchart:{htmlLabels:!1},class:{htmlLabels:!1}})}function WNt(t){document.querySelector('meta[name=\"viewport\"]')?.setAttribute(\"content\",t?\"width=device-width, initial-scale=1, maximum-scale=8\":\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\")}var XK=0,cM=null,uM=!1,HK=!1;async function qNt(t){try{VNt(t.colorScheme);let{svg:e}=await UK.render(`paseo-mermaid-${t.revision}`,t.source);if(t.revision!==XK)return;WNt(t.interactive);let r=document.getElementById(\"diagram\");if(!r)return;r.innerHTML=e;let n=r.querySelector(\"svg\")?.getBoundingClientRect();YK({type:\"rendered\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,height:Math.ceil(n?.height??r.scrollHeight),width:Math.ceil(n?.width??r.scrollWidth)})}catch{t.revision===XK&&YK({type:\"renderError\",revision:t.revision})}}async function UNt(){if(!uM){uM=!0;try{for(;cM;){let t=cM;cM=null,await qNt(t)}}finally{uM=!1}}}function nFe(t){let e=rFe(t);e&&(XK=e.revision,cM=e,!(uM||HK)&&(HK=!0,window.setTimeout(()=>{HK=!1,UNt()},0)))}window.__PASEO_MERMAID_RUNTIME_RECEIVE__=nFe;window.addEventListener(\"message\",t=>{t.source===window.parent&&nFe(t.data)});YK({type:\"bridgeReady\"});})();\n/*! Bundled license information:\n\ndompurify/dist/purify.es.mjs:\n (*! @license DOMPurify 3.4.12 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.12/LICENSE *)\n\nmermaid/dist/chunks/mermaid.core/chunk-ZIRB5QZD.mjs:\n (*! Bundled license information:\n \n js-yaml/dist/js-yaml.mjs:\n (*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT *)\n *)\n\nlodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" --repo lodash/lodash#4.18.1 -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n\ncytoscape/dist/cytoscape.esm.mjs:\n (*!\n Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable\n Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)\n Licensed under The MIT License (http://opensource.org/licenses/MIT)\n *)\n (*!\n Event object based on jQuery events, MIT license\n \n https://jquery.org/license/\n https://tldrlegal.com/license/mit-license\n https://github.com/jquery/jquery/blob/master/src/event.js\n *)\n (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n\n@mermaid-js/parser/dist/chunks/mermaid-parser.core/chunk-KEIR6QF5.mjs:\n (*! Bundled license information:\n \n lodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n *)\n\nmermaid/dist/mermaid.core.mjs:\n (*! Check if previously processed *)\n (*!\n * Wait for document loaded before starting the execution\n *)\n*/\n\n \n"; +export const mermaidRuntimeHtml = "\n\n \n \n \n \n \n \n \n
\n \n \n"; diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts index 1dda832eb3..7ffbbc79db 100644 --- a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts @@ -29,9 +29,13 @@ function waitForRuntimeMessage( }); } -async function mountRuntime(): Promise { +async function mountRuntime(size?: { width: number; height: number }): Promise { 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 { 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")}`; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ae99bfdb58..86a43b05e7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -286,6 +286,7 @@ export const ar: TranslationResources = { zoomIn: "تكبير", zoomOut: "تصغير", resetZoom: "إعادة تعيين العرض", + fullscreen: "ملء الشاشة", viewSource: "عرض المصدر", viewDiagram: "عرض المخطط", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5c79d4d7ea..302958c56d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -284,6 +284,7 @@ export const en = { zoomIn: "Zoom in", zoomOut: "Zoom out", resetZoom: "Reset view", + fullscreen: "Fullscreen", viewSource: "View source", viewDiagram: "View diagram", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 5e797c9982..5e4ae46cb7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -289,6 +289,7 @@ export const es: TranslationResources = { zoomIn: "Acercar", zoomOut: "Alejar", resetZoom: "Restablecer vista", + fullscreen: "Pantalla completa", viewSource: "Ver fuente", viewDiagram: "Ver diagrama", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5ea0b3a6ab..64d0bb210c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 3025c03119..4bdda2ef55 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -289,6 +289,7 @@ export const ja: TranslationResources = { zoomIn: "拡大", zoomOut: "縮小", resetZoom: "表示をリセット", + fullscreen: "全画面表示", viewSource: "ソースを表示", viewDiagram: "図を表示", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 631bdfa84d..b61d77d0f7 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -287,6 +287,7 @@ export const ko: TranslationResources = { zoomIn: "확대", zoomOut: "축소", resetZoom: "보기 초기화", + fullscreen: "전체 화면", viewSource: "소스 보기", viewDiagram: "다이어그램 보기", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d823baee7c..f38a01e15b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 607f2641a4..3b928784be 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -288,6 +288,7 @@ export const ru: TranslationResources = { zoomIn: "Увеличить масштаб", zoomOut: "Уменьшить масштаб", resetZoom: "Сбросить масштаб", + fullscreen: "Полноэкранный режим", viewSource: "Показать исходный код", viewDiagram: "Показать диаграмму", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 096639587d..afc06872ae 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -286,6 +286,7 @@ export const zhCN: TranslationResources = { zoomIn: "放大", zoomOut: "缩小", resetZoom: "重置视图", + fullscreen: "全屏", viewSource: "查看源码", viewDiagram: "查看图表", },