diff --git a/.changeset/mermaid-fullscreen-download.md b/.changeset/mermaid-fullscreen-download.md
new file mode 100644
index 00000000..98183958
--- /dev/null
+++ b/.changeset/mermaid-fullscreen-download.md
@@ -0,0 +1,5 @@
+---
+"streamdown": patch
+---
+
+fix(mermaid): render the download control inside the Mermaid fullscreen portal so it's reachable when the diagram is expanded
diff --git a/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx b/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx
index aa72ca8e..9942fa58 100644
--- a/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx
+++ b/packages/streamdown/__tests__/mermaid-fullscreen.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StreamdownContext } from "../index";
import { PluginContext } from "../lib/plugin-context";
+import type { DiagramPlugin, MermaidInstance } from "../lib/plugin-types";
// Must mock the Mermaid component that fullscreen-button imports
vi.mock("../lib/mermaid", () => ({
@@ -12,8 +13,23 @@ vi.mock("../lib/mermaid", () => ({
),
}));
-// Import after mock
+vi.mock("../lib/utils", async () => {
+ const actual = await vi.importActual("../lib/utils");
+ return {
+ ...actual,
+ save: vi.fn(),
+ };
+});
+
+vi.mock("../lib/mermaid/utils", () => ({
+ svgToPngBlob: vi
+ .fn()
+ .mockResolvedValue(new Blob(["png"], { type: "image/png" })),
+}));
+
import { MermaidFullscreenButton } from "../lib/mermaid/fullscreen-button";
+// Import after mocks
+import { save } from "../lib/utils";
describe("MermaidFullscreenButton", () => {
const defaultContext = {
@@ -23,9 +39,28 @@ describe("MermaidFullscreenButton", () => {
mode: "streaming" as const,
};
- const renderWithContext = (props: any, contextOverrides = {}) => {
+ const createMockPlugin = (
+ renderResult = { svg: "" }
+ ): DiagramPlugin => {
+ const mockInstance: MermaidInstance = {
+ initialize: vi.fn(),
+ render: vi.fn().mockResolvedValue(renderResult),
+ };
+ return {
+ name: "mermaid",
+ type: "diagram",
+ language: "mermaid",
+ getMermaid: vi.fn().mockReturnValue(mockInstance),
+ };
+ };
+
+ const renderWithContext = (
+ props: any,
+ contextOverrides = {},
+ plugin: DiagramPlugin | undefined = undefined
+ ) => {
return render(
-
+
@@ -246,4 +281,67 @@ describe("MermaidFullscreenButton", () => {
expect(mermaid?.textContent).toContain("graph TD; A-->B");
});
});
+
+ it("should render a working download button inside the fullscreen portal", async () => {
+ const plugin = createMockPlugin();
+ const { container } = renderWithContext(
+ { chart: "graph TD; A-->B" },
+ {},
+ plugin
+ );
+
+ const openBtn = container.querySelector('button[title="View fullscreen"]');
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(openBtn!);
+
+ await waitFor(() => {
+ expect(document.querySelector(".fixed.inset-0")).toBeTruthy();
+ });
+
+ const downloadBtn = document.querySelector(
+ 'button[title="Download diagram"]'
+ );
+ expect(downloadBtn).toBeTruthy();
+
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(downloadBtn!);
+
+ const pngOption = await waitFor(() =>
+ document.querySelector('button[title="Download diagram as PNG"]')
+ );
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(pngOption!);
+
+ await waitFor(() => {
+ expect(save).toHaveBeenCalledWith(
+ "diagram.png",
+ expect.any(Blob),
+ "image/png"
+ );
+ });
+
+ // Fullscreen should still be open — downloading must not close it.
+ expect(document.querySelector(".fixed.inset-0")).toBeTruthy();
+ });
+
+ it("should hide the fullscreen download button when the download control is disabled", async () => {
+ const plugin = createMockPlugin();
+ const { container } = renderWithContext(
+ { chart: "graph TD; A-->B" },
+ { controls: { mermaid: { download: false } } },
+ plugin
+ );
+
+ const openBtn = container.querySelector('button[title="View fullscreen"]');
+ // biome-ignore lint/style/noNonNullAssertion: test assertion
+ fireEvent.click(openBtn!);
+
+ await waitFor(() => {
+ expect(document.querySelector(".fixed.inset-0")).toBeTruthy();
+ });
+
+ expect(
+ document.querySelector('button[title="Download diagram"]')
+ ).toBeFalsy();
+ });
});
diff --git a/packages/streamdown/lib/mermaid/fullscreen-button.tsx b/packages/streamdown/lib/mermaid/fullscreen-button.tsx
index 316095da..53a906b3 100644
--- a/packages/streamdown/lib/mermaid/fullscreen-button.tsx
+++ b/packages/streamdown/lib/mermaid/fullscreen-button.tsx
@@ -7,6 +7,7 @@ import { useCn } from "../prefix-context";
import { lockBodyScroll, unlockBodyScroll } from "../scroll-lock";
import { useTranslations } from "../translations-context";
import { Mermaid } from ".";
+import { MermaidDownloadDropdown } from "./download-button";
type MermaidFullscreenButtonProps = ComponentProps<"button"> & {
chart: string;
@@ -42,6 +43,19 @@ export const MermaidFullscreenButton = ({
}
return mermaidCtl.panZoom !== false;
})();
+ const showDownload = (() => {
+ if (typeof controlsConfig === "boolean") {
+ return controlsConfig;
+ }
+ const mermaidCtl = controlsConfig.mermaid;
+ if (mermaidCtl === false) {
+ return false;
+ }
+ if (mermaidCtl === true || mermaidCtl === undefined) {
+ return true;
+ }
+ return mermaidCtl.download !== false;
+ })();
const handleToggle = () => {
setIsFullscreen(!isFullscreen);
@@ -107,16 +121,29 @@ export const MermaidFullscreenButton = ({
role="button"
tabIndex={0}
>
-
+ {showDownload ? (
+
+ ) : null}
+
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: "div with role=presentation is used for event propagation control" */}