From cbaee7d047ebe0ec284cd48d0eefd5f07bc15152 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 26 Mar 2026 17:56:08 +0530 Subject: [PATCH 1/6] fix(mermaid): enhance SVG normalization for inline rendering --- packages/streamdown/lib/mermaid/utils.ts | 103 +++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/packages/streamdown/lib/mermaid/utils.ts b/packages/streamdown/lib/mermaid/utils.ts index 0242d786..5414fc49 100644 --- a/packages/streamdown/lib/mermaid/utils.ts +++ b/packages/streamdown/lib/mermaid/utils.ts @@ -1,3 +1,106 @@ +/** + * Normalize Mermaid SVG dimensions for inline rendering. + * Mermaid emits width="100%" with max-width style, which can shrink very wide + * diagrams until text becomes unreadable. + */ +export const getMermaidSvgSize = ( + svgString: string +): { height: number; width: number } | null => { + const svgTagMatch = svgString.match(/]*>/i); + if (!svgTagMatch) { + return null; + } + + const svgTag = svgTagMatch[0]; + const viewBoxMatch = svgTag.match(/\bviewBox=(['"])(.*?)\1/i); + const viewBox = viewBoxMatch?.[2]; + + if (!viewBox) { + return null; + } + + const values = viewBox + .trim() + .split(/[\s,]+/) + .map((value) => Number.parseFloat(value)); + + if (values.length < 4 || values.slice(0, 4).some(Number.isNaN)) { + return null; + } + + const width = values[2]; + const height = values[3]; + if (!(width > 0 && height > 0)) { + return null; + } + + return { height, width }; +}; + +/** + * Normalize Mermaid SVG dimensions for inline rendering. + * Mermaid emits width="100%" with max-width style, which can shrink very wide + * diagrams until text becomes unreadable. + */ +export const normalizeMermaidInlineSvg = (svgString: string): string => { + const svgTagMatch = svgString.match(/]*>/i); + if (!svgTagMatch) { + return svgString; + } + + try { + const svgTag = svgTagMatch[0]; + const size = getMermaidSvgSize(svgString); + if (!size) { + return svgString; + } + const { width, height } = size; + + let updatedSvgTag = svgTag + .replace(/\swidth=(['"]).*?\1/gi, "") + .replace(/\sheight=(['"]).*?\1/gi, ""); + + const styleMatch = updatedSvgTag.match(/\sstyle=(['"])(.*?)\1/i); + const sizeDeclarations = `width:${width}px;height:${height}px;max-width:none;`; + + if (styleMatch) { + const styleQuote = styleMatch[1]; + const styleValue = styleMatch[2]; + const filtered = styleValue + .split(";") + .map((decl) => decl.trim()) + .filter(Boolean) + .filter( + (decl) => + !/^width\s*:/i.test(decl) && + !/^height\s*:/i.test(decl) && + !/^max-width\s*:/i.test(decl) + ) + .join(";"); + + const mergedStyle = `${sizeDeclarations}${filtered ? `${filtered};` : ""}`; + updatedSvgTag = updatedSvgTag.replace( + /\sstyle=(['"])(.*?)\1/i, + ` style=${styleQuote}${mergedStyle}${styleQuote}` + ); + } else { + updatedSvgTag = updatedSvgTag.replace( + /^ Date: Thu, 26 Mar 2026 17:56:15 +0530 Subject: [PATCH 2/6] fix(pan-zoom): enhance zoom functionality and add auto-fit support --- packages/streamdown/lib/mermaid/pan-zoom.tsx | 77 ++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/streamdown/lib/mermaid/pan-zoom.tsx b/packages/streamdown/lib/mermaid/pan-zoom.tsx index c618f77b..5267c271 100644 --- a/packages/streamdown/lib/mermaid/pan-zoom.tsx +++ b/packages/streamdown/lib/mermaid/pan-zoom.tsx @@ -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; @@ -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(null); const contentRef = useRef(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(() => { @@ -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) => { @@ -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 @@ -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 */ @@ -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" From f1e5a7dba34718be4f1a6cc21a67d58b8ac9323a Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 26 Mar 2026 17:56:20 +0530 Subject: [PATCH 3/6] fix(mermaid): enhance SVG rendering with size normalization and auto-fit support --- packages/streamdown/lib/mermaid/index.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/streamdown/lib/mermaid/index.tsx b/packages/streamdown/lib/mermaid/index.tsx index 3f483863..c756fed5 100644 --- a/packages/streamdown/lib/mermaid/index.tsx +++ b/packages/streamdown/lib/mermaid/index.tsx @@ -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; @@ -25,6 +26,9 @@ export const Mermaid = ({ const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [svgContent, setSvgContent] = useState(""); + const [svgSize, setSvgSize] = useState<{ height: number; width: number } | null>( + null + ); const [lastValidSvg, setLastValidSvg] = useState(""); const [retryCount, setRetryCount] = useState(0); const { mermaid: mermaidContext } = useContext(StreamdownContext); @@ -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 @@ -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} From bd73cb245859c7f184a5e74893d61e28b9141095 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 26 Mar 2026 17:56:27 +0530 Subject: [PATCH 4/6] fix(mermaid): add tests for normalizeMermaidInlineSvg and getMermaidSvgSize functions --- .../__tests__/mermaid-utils.test.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/streamdown/__tests__/mermaid-utils.test.ts b/packages/streamdown/__tests__/mermaid-utils.test.ts index 3baae087..bc2fb909 100644 --- a/packages/streamdown/__tests__/mermaid-utils.test.ts +++ b/packages/streamdown/__tests__/mermaid-utils.test.ts @@ -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,/; @@ -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 = "
not svg
"; + expect(normalizeMermaidInlineSvg(input)).toBe(input); + }); + + it("should preserve source when viewBox is missing", () => { + const input = ''; + expect(normalizeMermaidInlineSvg(input)).toBe(input); + }); + + it("should normalize width/height/maxWidth from viewBox", () => { + const input = + 'Test'; + + 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("
")).toBeNull(); + }); + + it("should return null when viewBox is missing", () => { + expect(getMermaidSvgSize('')).toBeNull(); + }); + + it("should parse width and height from viewBox", () => { + const size = getMermaidSvgSize(''); + expect(size).toEqual({ height: 1200, width: 3400 }); + }); +}); From 8579ce5efe0fb9e69aa475dd745cccf14c7cd88b Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 26 Mar 2026 17:56:32 +0530 Subject: [PATCH 5/6] fix(pan-zoom): add test for auto-fitting large content to width and height --- .../streamdown/__tests__/pan-zoom.test.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/streamdown/__tests__/pan-zoom.test.tsx b/packages/streamdown/__tests__/pan-zoom.test.tsx index 30f3fef3..a5028149 100644 --- a/packages/streamdown/__tests__/pan-zoom.test.tsx +++ b/packages/streamdown/__tests__/pan-zoom.test.tsx @@ -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", () => { @@ -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( + +
Content
+
+ ); + + await waitFor(() => { + const content = container.querySelector('[role="application"]'); + const transform = content?.getAttribute("style") ?? ""; + expect(transform).toContain("scale(0.25)"); + }); + } finally { + widthSpy.mockRestore(); + heightSpy.mockRestore(); + } + }); }); From 976960e3d3dbb0adb29e358ee0189490a2698066 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Thu, 26 Mar 2026 18:16:45 +0530 Subject: [PATCH 6/6] changeset: adds changeset with description for the changes done, improve diagram readability and auto-fit functionality --- .changeset/tidy-aliens-swim.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/tidy-aliens-swim.md diff --git a/.changeset/tidy-aliens-swim.md b/.changeset/tidy-aliens-swim.md new file mode 100644 index 00000000..6861f4e1 --- /dev/null +++ b/.changeset/tidy-aliens-swim.md @@ -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