Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mermaid-fullscreen-download.md
Original file line number Diff line number Diff line change
@@ -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
104 changes: 101 additions & 3 deletions packages/streamdown/__tests__/mermaid-fullscreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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 = {
Expand All @@ -23,9 +39,28 @@ describe("MermaidFullscreenButton", () => {
mode: "streaming" as const,
};

const renderWithContext = (props: any, contextOverrides = {}) => {
const createMockPlugin = (
renderResult = { svg: "<svg><text>Chart</text></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(
<PluginContext.Provider value={{}}>
<PluginContext.Provider value={plugin ? { mermaid: plugin } : {}}>
<StreamdownContext.Provider
value={{ ...defaultContext, ...contextOverrides }}
>
Expand Down Expand Up @@ -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();
});
});
41 changes: 34 additions & 7 deletions packages/streamdown/lib/mermaid/fullscreen-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -107,16 +121,29 @@ export const MermaidFullscreenButton = ({
role="button"
tabIndex={0}
>
<button
{/* biome-ignore lint/a11y/noStaticElementInteractions: "div with role=presentation is used for event propagation control" */}
<div
className={cn(
"absolute top-4 right-4 z-10 rounded-md p-2 text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
"absolute top-4 right-4 z-10 flex items-center gap-1"
)}
onClick={handleToggle}
title={t.exitFullscreen}
type="button"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<XIcon size={20} />
</button>
{showDownload ? (
<MermaidDownloadDropdown chart={chart} config={config} />
) : null}
<button
className={cn(
"rounded-md p-2 text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
)}
onClick={handleToggle}
title={t.exitFullscreen}
type="button"
>
<XIcon size={20} />
</button>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: "div with role=presentation is used for event propagation control" */}
<div
className={cn("flex size-full items-center justify-center p-4")}
Expand Down