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
10 changes: 10 additions & 0 deletions .changeset/tidy-aliens-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"streamdown": minor
---

Fix Mermaid diagrams so text is readable and diagrams auto-fit container.
- Normalize SVG to remove responsive shrinking
- Extract intrinsic size from viewBox
- Add width-and-height auto-fit in PanZoom
- Preserve user zoom/pan after initial fit
- Add tests for SVG utilities and auto-fit behavior
50 changes: 49 additions & 1 deletion packages/streamdown/__tests__/mermaid-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { svgToPngBlob } from "../lib/mermaid/utils";
import {
getMermaidSvgSize,
normalizeMermaidInlineSvg,
svgToPngBlob,
} from "../lib/mermaid/utils";

const BASE64_SVG_DATA_URL_REGEX = /^data:image\/svg\+xml;base64,/;

Expand Down Expand Up @@ -133,3 +137,47 @@ describe("svgToPngBlob", () => {
expect(mockImage.src).toMatch(BASE64_SVG_DATA_URL_REGEX);
});
});

describe("normalizeMermaidInlineSvg", () => {
it("should preserve source when no SVG element exists", () => {
const input = "<div>not svg</div>";
expect(normalizeMermaidInlineSvg(input)).toBe(input);
});

it("should preserve source when viewBox is missing", () => {
const input = '<svg width="100%" height="100%"></svg>';
expect(normalizeMermaidInlineSvg(input)).toBe(input);
});

it("should normalize width/height/maxWidth from viewBox", () => {
const input =
'<svg width="100%" style="max-width: 3000px;" viewBox="0 0 3000 800"><text>Test</text></svg>';

const output = normalizeMermaidInlineSvg(input);
const doc = new DOMParser().parseFromString(output, "image/svg+xml");
const svg = doc.querySelector("svg");

expect(svg).toBeTruthy();
expect(svg?.getAttribute("width")).toBe("3000");
expect(svg?.getAttribute("height")).toBe("800");
const style = svg?.getAttribute("style") ?? "";
expect(style).toContain("width:3000px");
expect(style).toContain("height:800px");
expect(style).toContain("max-width:none");
});
});

describe("getMermaidSvgSize", () => {
it("should return null when svg is missing", () => {
expect(getMermaidSvgSize("<div></div>")).toBeNull();
});

it("should return null when viewBox is missing", () => {
expect(getMermaidSvgSize('<svg width="100%"></svg>')).toBeNull();
});

it("should parse width and height from viewBox", () => {
const size = getMermaidSvgSize('<svg viewBox="0 0 3400 1200"></svg>');
expect(size).toEqual({ height: 1200, width: 3400 });
});
});
28 changes: 27 additions & 1 deletion packages/streamdown/__tests__/pan-zoom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { PanZoom } from "../lib/mermaid/pan-zoom";

describe("PanZoom", () => {
Expand Down Expand Up @@ -295,4 +295,30 @@ describe("PanZoom", () => {
// Check the actual style property, not the attribute string
expect(content?.style.touchAction).toBe("none");
});

it("should auto-fit large content to width and height", async () => {
const widthSpy = vi
.spyOn(HTMLElement.prototype, "clientWidth", "get")
.mockReturnValue(500);
const heightSpy = vi
.spyOn(HTMLElement.prototype, "clientHeight", "get")
.mockReturnValue(250);

try {
const { container } = render(
<PanZoom contentSize={{ height: 1000, width: 1000 }} isAutoFit={true}>
<div>Content</div>
</PanZoom>
);

await waitFor(() => {
const content = container.querySelector('[role="application"]');
const transform = content?.getAttribute("style") ?? "";
expect(transform).toContain("scale(0.25)");
});
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
});
14 changes: 12 additions & 2 deletions packages/streamdown/lib/mermaid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { StreamdownContext } from "../../index";
import { useMermaidPlugin } from "../plugin-context";
import { useCn } from "../prefix-context";
import { PanZoom } from "./pan-zoom";
import { getMermaidSvgSize, normalizeMermaidInlineSvg } from "./utils";

interface MermaidProps {
chart: string;
Expand All @@ -25,6 +26,9 @@ export const Mermaid = ({
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [svgContent, setSvgContent] = useState<string>("");
const [svgSize, setSvgSize] = useState<{ height: number; width: number } | null>(
null
);
const [lastValidSvg, setLastValidSvg] = useState<string>("");
const [retryCount, setRetryCount] = useState(0);
const { mermaid: mermaidContext } = useContext(StreamdownContext);
Expand Down Expand Up @@ -67,10 +71,13 @@ export const Mermaid = ({
const uniqueId = `mermaid-${Math.abs(chartHash)}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;

const { svg } = await mermaid.render(uniqueId, chart);
const size = getMermaidSvgSize(svg);
const normalizedSvg = fullscreen ? svg : normalizeMermaidInlineSvg(svg);

// Update both current and last valid SVG
setSvgContent(svg);
setLastValidSvg(svg);
setSvgContent(normalizedSvg);
setSvgSize(size);
setLastValidSvg(normalizedSvg);
} catch (err) {
// Silently fail and keep the last valid SVG
// Don't update svgContent here - just keep what we have
Expand Down Expand Up @@ -170,7 +177,10 @@ export const Mermaid = ({
fullscreen ? "size-full overflow-hidden" : "overflow-hidden",
className
)}
contentSize={svgSize}
fitKey={chart}
fullscreen={fullscreen}
isAutoFit={true}
maxZoom={3}
minZoom={0.5}
showControls={showControls}
Expand Down
77 changes: 72 additions & 5 deletions packages/streamdown/lib/mermaid/pan-zoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { useCn } from "../prefix-context";
interface PanZoomProps {
children: ReactNode;
className?: string;
contentSize?: { height: number; width: number } | null;
fitKey?: string;
fullscreen?: boolean;
initialZoom?: number;
isAutoFit?: boolean;
maxZoom?: number;
minZoom?: number;
showControls?: boolean;
Expand All @@ -17,31 +20,41 @@ interface PanZoomProps {
export const PanZoom = ({
children,
className,
contentSize,
fitKey,
minZoom = 0.5,
maxZoom = 3,
zoomStep = 0.1,
showControls = true,
initialZoom = 1,
isAutoFit = false,
fullscreen = false,
}: PanZoomProps) => {
const { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } = useIcons();
const cn = useCn();
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [baseZoom, setBaseZoom] = useState(initialZoom);
const [effectiveMinZoom, setEffectiveMinZoom] = useState(minZoom);
const [zoom, setZoom] = useState(initialZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [hasUserInteracted, setHasUserInteracted] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [panStartPosition, setPanStartPosition] = useState({ x: 0, y: 0 });

const handleZoom = useCallback(
(delta: number) => {
setZoom((prevZoom) => {
const newZoom = Math.max(minZoom, Math.min(maxZoom, prevZoom + delta));
const newZoom = Math.max(
effectiveMinZoom,
Math.min(maxZoom, prevZoom + delta)
);
return newZoom;
});
setHasUserInteracted(true);
},
[minZoom, maxZoom]
[effectiveMinZoom, maxZoom]
);

const handleZoomIn = useCallback(() => {
Expand All @@ -53,9 +66,10 @@ export const PanZoom = ({
}, [handleZoom, zoomStep]);

const handleReset = useCallback(() => {
setZoom(initialZoom);
setZoom(baseZoom);
setPan({ x: 0, y: 0 });
}, [initialZoom]);
setHasUserInteracted(false);
}, [baseZoom]);

const handleWheel = useCallback(
(e: WheelEvent) => {
Expand All @@ -73,6 +87,7 @@ export const PanZoom = ({
return;
}
setIsPanning(true);
setHasUserInteracted(true);
setPanStart({ x: e.clientX, y: e.clientY });
setPanStartPosition(pan);
// Capture the pointer to track it even outside the element
Expand Down Expand Up @@ -110,6 +125,58 @@ export const PanZoom = ({
}
}, []);

useEffect(() => {
setEffectiveMinZoom(minZoom);
if (!isAutoFit) {
setBaseZoom(initialZoom);
setZoom(initialZoom);
}
}, [initialZoom, isAutoFit, minZoom]);

useEffect(() => {
if (!isAutoFit || !contentSize) {
return;
}

const container = containerRef.current;
if (!container) {
return;
}

const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;

if (!(containerWidth > 0 && containerHeight > 0)) {
return;
}

const fitZoom = Math.min(
containerWidth / contentSize.width,
containerHeight / contentSize.height,
1
);

if (!(fitZoom > 0) || Number.isNaN(fitZoom)) {
return;
}

setBaseZoom(fitZoom);
setEffectiveMinZoom(Math.min(minZoom, fitZoom));

if (!hasUserInteracted) {
setZoom(fitZoom);
setPan({ x: 0, y: 0 });
}
}, [contentSize, hasUserInteracted, isAutoFit, minZoom]);

useEffect(() => {
if (!isAutoFit) {
return;
}

setHasUserInteracted(false);
}, [fitKey, isAutoFit]);

useEffect(() => {
const container = containerRef.current;
/* v8 ignore next */
Expand Down Expand Up @@ -181,7 +248,7 @@ export const PanZoom = ({
className={cn(
"flex items-center justify-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
)}
disabled={zoom <= minZoom}
disabled={zoom <= effectiveMinZoom}
onClick={handleZoomOut}
title="Zoom out"
type="button"
Expand Down
Loading
Loading