Skip to content
Merged
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
60 changes: 60 additions & 0 deletions packages/ui/components/MermaidBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'bun:test';
import { normalizeMermaidSvgMarkup } from './mermaidSvg';

describe('normalizeMermaidSvgMarkup', () => {
test('replaces natural max-width with max-width:none', () => {
const input = '<svg style="max-width: 85.125px;" viewBox="0 0 346 278" width="100%"><g/></svg>';
const expected =
'<svg style="max-width: none" viewBox="0 0 346 278" width="100%" preserveAspectRatio="xMidYMid meet" height="100%"><g/></svg>';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});

test('adds preserveAspectRatio when missing', () => {
const input = '<svg viewBox="0 0 1 1"></svg>';
const expected =
'<svg viewBox="0 0 1 1" style="max-width: none" preserveAspectRatio="xMidYMid meet" height="100%"></svg>';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});

test('adds height=100% when missing', () => {
const input = '<svg viewBox="0 0 1 1" preserveAspectRatio="none"></svg>';
const expected =
'<svg viewBox="0 0 1 1" preserveAspectRatio="none" style="max-width: none" height="100%"></svg>';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});

test('preserves existing preserveAspectRatio and height', () => {
const input = '<svg viewBox="0 0 1 1" preserveAspectRatio="none" height="200"></svg>';
const expected =
'<svg viewBox="0 0 1 1" preserveAspectRatio="none" height="200" style="max-width: none"></svg>';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});

test('injects style attribute when mermaid omits one', () => {
const input = '<svg viewBox="0 0 1 1" width="100%"></svg>';
const expected =
'<svg viewBox="0 0 1 1" width="100%" style="max-width: none" preserveAspectRatio="xMidYMid meet" height="100%"></svg>';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});

test('only normalizes the root <svg> tag', () => {
const input =
'<svg style="max-width: 100px;" viewBox="0 0 1 1"><defs><marker><svg viewBox="0 0 5 5"/></marker></defs></svg>';
const expected =
'<svg style="max-width: none" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" height="100%"><defs><marker><svg viewBox="0 0 5 5"/></marker></defs></svg>';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});

test('leaves non-svg input unchanged', () => {
const input = 'plain text';
const expected = 'plain text';

expect(normalizeMermaidSvgMarkup(input)).toBe(expected);
});
});
16 changes: 13 additions & 3 deletions packages/ui/components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import mermaid from 'mermaid';
import type { Block } from '../types';
import { normalizeMermaidSvgMarkup } from './mermaidSvg';

mermaid.initialize({
startOnLoad: false,
Expand Down Expand Up @@ -127,7 +128,7 @@ function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox
/**
* Renders a mermaid diagram block with zoom controls.
*/
export const MermaidBlock: React.FC<{ block: Block }> = ({ block }) => {
const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
const containerRef = useRef<HTMLDivElement>(null);
const expandedOverlayRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState('');
Expand Down Expand Up @@ -191,8 +192,9 @@ export const MermaidBlock: React.FC<{ block: Block }> = ({ block }) => {
const id = `mermaid-${block.id}`;
const { svg: renderedSvg } = await mermaid.render(id, block.content);
if (!cancelled) {
naturalBoundsRef.current = parseViewBoxFromMarkup(renderedSvg);
setSvg(renderedSvg);
const normalizedSvg = normalizeMermaidSvgMarkup(renderedSvg);
naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg);
setSvg(normalizedSvg);
setError(null);
}
} catch (err) {
Expand Down Expand Up @@ -528,6 +530,7 @@ export const MermaidBlock: React.FC<{ block: Block }> = ({ block }) => {
const diagramBody = (
<div
ref={containerRef}
data-pinpoint-ignore=""
className={`rounded-xl bg-muted/30 border border-border/30 overflow-hidden select-none cursor-grab ${isExpanded ? 'h-full min-h-0' : 'h-[min(65vh,36rem)] min-h-[20rem]'}`}
dangerouslySetInnerHTML={{ __html: svg }}
onMouseDown={handleMouseDown}
Expand Down Expand Up @@ -567,3 +570,10 @@ export const MermaidBlock: React.FC<{ block: Block }> = ({ block }) => {
</>
);
};

export const MermaidBlock = React.memo(
MermaidBlockImpl,
(prev, next) =>
prev.block.id === next.block.id &&
prev.block.content === next.block.content,
);
33 changes: 33 additions & 0 deletions packages/ui/components/mermaidSvg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Pure SVG-markup helpers for MermaidBlock. Kept free of React and the
// mermaid library so they can be unit-tested without loading mermaid's
// browser-only `initialize()` (which throws in headless test environments).

// Bake sizing attrs into the SVG markup so they survive repeated
// dangerouslySetInnerHTML re-injection — imperative setAttribute gets wiped.
export function normalizeMermaidSvgMarkup(markup: string): string {
return markup.replace(/<svg\b([^>]*)>/i, (_match, attrs: string) => {
let next = attrs;

if (/\bstyle\s*=\s*"/i.test(next)) {
next = next.replace(/\bstyle\s*=\s*"([^"]*)"/i, (_m, styleVal: string) => {
const rules = styleVal
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0 && !/^max-width\s*:/i.test(s));
rules.push('max-width: none');
return `style="${rules.join('; ')}"`;
});
} else {
next += ' style="max-width: none"';
}

if (!/\bpreserveAspectRatio\s*=/i.test(next)) {
next += ' preserveAspectRatio="xMidYMid meet"';
}
if (!/\bheight\s*=/i.test(next)) {
next += ' height="100%"';
}

return `<svg${next}>`;
});
}
Loading