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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"js-yaml": "^4.1.0",
"match-sorter": "^6.3.4",
"mdx-bundler": "^10.0.1",
"mermaid": "^11.4.0",
"mermaid": "^11.11.0",
"micromark": "^4.0.0",
"next": "15.1.7",
"next-mdx-remote": "^4.4.1",
Expand Down Expand Up @@ -106,6 +106,7 @@
"search-insights": "^2.17.2",
"server-only": "^0.0.1",
"sharp": "^0.33.4",
"svg-pan-zoom": "^3.6.2",
"tailwindcss-scoped-preflight": "^3.0.4",
"textarea-markdown-editor": "^1.0.4",
"unified": "^11.0.5",
Expand Down Expand Up @@ -149,4 +150,4 @@
"node": "22.16.0",
"yarn": "1.22.22"
}
}
}
145 changes: 74 additions & 71 deletions src/components/mermaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,97 @@
import {useEffect, useState} from 'react';
import {useTheme} from 'next-themes';

/**
* we target ```mermaid``` code blocks after they have been highlighted (not ideal),
* then we strip the code from the html elements used for highlighting
* then we render the mermaid chart both in light and dark modes
* CSS takes care of showing the right one depending on the theme
*/
export default function Mermaid() {
const [isDoneRendering, setDoneRendering] = useState(false);
const {resolvedTheme: theme} = useTheme();

useEffect(() => {
const renderMermaid = async () => {
const escapeHTML = (str: string) => {
return str.replace(/[&<>"']/g, function (match) {
const escapeMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return escapeMap[match];
});
};
const mermaidBlocks =
document.querySelectorAll<HTMLDivElement>('.language-mermaid');
if (mermaidBlocks.length === 0) {
return;
}
// we have to dig like this as the nomral import doesn't work
if (mermaidBlocks.length === 0) return;

const escapeHTML = (str: string) =>
str.replace(
/[&<>"']/g,
match =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[match] || match
);

const {default: mermaid} = await import('mermaid/dist/mermaid.esm.min.mjs');
mermaid.initialize({startOnLoad: false});
mermaidBlocks.forEach(lightModeblock => {
// get rid of code highlighting
const code = lightModeblock.textContent ?? '';
lightModeblock.innerHTML = escapeHTML(code);
// force transparent background
lightModeblock.style.backgroundColor = 'transparent';
lightModeblock.classList.add('light');
const parentCodeTabs = lightModeblock.closest('.code-tabs-wrapper');
if (!parentCodeTabs) {
// eslint-disable-next-line no-console
console.error('Mermaid code block was not wrapped in a code tab');
return;
}
// empty the container
parentCodeTabs.innerHTML = '';
parentCodeTabs.appendChild(lightModeblock.cloneNode(true));
const svgPanZoom = (await import('svg-pan-zoom')).default;

// Create light and dark versions
mermaidBlocks.forEach(block => {
const code = block.textContent ?? '';
block.innerHTML = escapeHTML(code);
block.style.backgroundColor = 'transparent';
block.classList.add('light');

const darkModeBlock = lightModeblock.cloneNode(true) as HTMLPreElement;
darkModeBlock.classList.add('dark');
darkModeBlock.classList.remove('light');
parentCodeTabs?.appendChild(darkModeBlock);
const parentCodeTabs = block.closest('.code-tabs-wrapper');
const darkBlock = block.cloneNode(true) as HTMLDivElement;
darkBlock.classList.replace('light', 'dark');

if (parentCodeTabs) {
parentCodeTabs.innerHTML = '';
parentCodeTabs.append(block, darkBlock);
} else {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-theme-wrapper';
block.parentNode?.insertBefore(wrapper, block);
wrapper.append(block, darkBlock);
}
});

// Render both themes
mermaid.initialize({startOnLoad: false, theme: 'default'});
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.light')});

mermaid.initialize({startOnLoad: false, theme: 'dark'});
await mermaid
.run({nodes: document.querySelectorAll('.language-mermaid.dark')})
.then(() => setDoneRendering(true));
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.dark')});

// Initialize pan/zoom for all SVGs (including hidden ones)
document.querySelectorAll('.language-mermaid svg').forEach(svg => {
const svgElement = svg as SVGSVGElement;
const rect = svgElement.getBoundingClientRect();

if (rect.width > 0 && rect.height > 0) {
svgElement.setAttribute('width', rect.width.toString());
svgElement.setAttribute('height', rect.height.toString());
}

svgPanZoom(svgElement, {
zoomEnabled: true,
panEnabled: true,
controlIconsEnabled: true,
fit: true,
center: true,
minZoom: 0.1,
maxZoom: 10,
zoomScaleSensitivity: 0.2,
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SVG Initialization Fails on Hidden Elements

The svg-pan-zoom library initializes on all Mermaid SVG elements, including the one currently hidden by CSS for theme switching. Hidden SVGs have zero dimensions, which can cause svg-pan-zoom to initialize incorrectly, leading to broken pan/zoom functionality or conflicting controls when the theme changes.

Fix in Cursor Fix in Web


setDoneRendering(true);
};

renderMermaid();
}, []);
// we have to wait for mermaid.js to finish rendering both light and dark charts
// before we hide one of them depending on the theme
// this is necessary because mermaid.js relies on the DOM for calculations

return isDoneRendering ? (
theme === 'dark' ? (
<style>
{`
.dark .language-mermaid {
display: none;
}
.dark .language-mermaid.dark {
display: block;
}
`}
</style>
) : (
<style>
{`
.language-mermaid.light {
display: block;
}
.language-mermaid.dark {
display: none;
}
<style>
{`
.language-mermaid.light { display: ${theme === 'dark' ? 'none' : 'block'}; }
.language-mermaid.dark { display: ${theme === 'dark' ? 'block' : 'none'}; }
.dark .language-mermaid.light { display: none; }
.dark .language-mermaid.dark { display: block; }
`}
</style>
)
</style>
) : null;
}
Loading
Loading