Skip to content

Commit a14a18d

Browse files
feat(Mermaid) add pan and zoom via svg-pan-zoom (#14967)
Closes #13749 Example: https://develop-docs-7b5gl6fix.sentry.dev/self-hosted/data-flow/ | Before | After | |--------|--------| | <img width="3488" height="2202" alt="CleanShot 2025-09-17 at 16 59 32@2x" src="https://github.com/user-attachments/assets/e29bf1a5-bb9f-4408-a8d3-d294cb0b6ffb" /> | ![mermaid-pan-zoom](https://github.com/user-attachments/assets/c9b8cbcd-75b2-4499-a42d-c1556b90af93) | --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent b3d73c7 commit a14a18d

File tree

3 files changed

+233
-182
lines changed

3 files changed

+233
-182
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"js-yaml": "^4.1.0",
7272
"match-sorter": "^6.3.4",
7373
"mdx-bundler": "^10.0.1",
74-
"mermaid": "^11.4.0",
74+
"mermaid": "^11.11.0",
7575
"micromark": "^4.0.0",
7676
"next": "15.1.7",
7777
"next-mdx-remote": "^4.4.1",
@@ -106,6 +106,7 @@
106106
"search-insights": "^2.17.2",
107107
"server-only": "^0.0.1",
108108
"sharp": "^0.33.4",
109+
"svg-pan-zoom": "^3.6.2",
109110
"tailwindcss-scoped-preflight": "^3.0.4",
110111
"textarea-markdown-editor": "^1.0.4",
111112
"unified": "^11.0.5",
@@ -149,4 +150,4 @@
149150
"node": "22.16.0",
150151
"yarn": "1.22.22"
151152
}
152-
}
153+
}

src/components/mermaid.tsx

Lines changed: 74 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,94 +2,97 @@
22
import {useEffect, useState} from 'react';
33
import {useTheme} from 'next-themes';
44

5-
/**
6-
* we target ```mermaid``` code blocks after they have been highlighted (not ideal),
7-
* then we strip the code from the html elements used for highlighting
8-
* then we render the mermaid chart both in light and dark modes
9-
* CSS takes care of showing the right one depending on the theme
10-
*/
115
export default function Mermaid() {
126
const [isDoneRendering, setDoneRendering] = useState(false);
137
const {resolvedTheme: theme} = useTheme();
8+
149
useEffect(() => {
1510
const renderMermaid = async () => {
16-
const escapeHTML = (str: string) => {
17-
return str.replace(/[&<>"']/g, function (match) {
18-
const escapeMap = {
19-
'&': '&amp;',
20-
'<': '&lt;',
21-
'>': '&gt;',
22-
'"': '&quot;',
23-
"'": '&#39;',
24-
};
25-
return escapeMap[match];
26-
});
27-
};
2811
const mermaidBlocks =
2912
document.querySelectorAll<HTMLDivElement>('.language-mermaid');
30-
if (mermaidBlocks.length === 0) {
31-
return;
32-
}
33-
// we have to dig like this as the nomral import doesn't work
13+
if (mermaidBlocks.length === 0) return;
14+
15+
const escapeHTML = (str: string) =>
16+
str.replace(
17+
/[&<>"']/g,
18+
match =>
19+
({
20+
'&': '&amp;',
21+
'<': '&lt;',
22+
'>': '&gt;',
23+
'"': '&quot;',
24+
"'": '&#39;',
25+
})[match] || match
26+
);
27+
3428
const {default: mermaid} = await import('mermaid/dist/mermaid.esm.min.mjs');
35-
mermaid.initialize({startOnLoad: false});
36-
mermaidBlocks.forEach(lightModeblock => {
37-
// get rid of code highlighting
38-
const code = lightModeblock.textContent ?? '';
39-
lightModeblock.innerHTML = escapeHTML(code);
40-
// force transparent background
41-
lightModeblock.style.backgroundColor = 'transparent';
42-
lightModeblock.classList.add('light');
43-
const parentCodeTabs = lightModeblock.closest('.code-tabs-wrapper');
44-
if (!parentCodeTabs) {
45-
// eslint-disable-next-line no-console
46-
console.error('Mermaid code block was not wrapped in a code tab');
47-
return;
48-
}
49-
// empty the container
50-
parentCodeTabs.innerHTML = '';
51-
parentCodeTabs.appendChild(lightModeblock.cloneNode(true));
29+
const svgPanZoom = (await import('svg-pan-zoom')).default;
30+
31+
// Create light and dark versions
32+
mermaidBlocks.forEach(block => {
33+
const code = block.textContent ?? '';
34+
block.innerHTML = escapeHTML(code);
35+
block.style.backgroundColor = 'transparent';
36+
block.classList.add('light');
5237

53-
const darkModeBlock = lightModeblock.cloneNode(true) as HTMLPreElement;
54-
darkModeBlock.classList.add('dark');
55-
darkModeBlock.classList.remove('light');
56-
parentCodeTabs?.appendChild(darkModeBlock);
38+
const parentCodeTabs = block.closest('.code-tabs-wrapper');
39+
const darkBlock = block.cloneNode(true) as HTMLDivElement;
40+
darkBlock.classList.replace('light', 'dark');
41+
42+
if (parentCodeTabs) {
43+
parentCodeTabs.innerHTML = '';
44+
parentCodeTabs.append(block, darkBlock);
45+
} else {
46+
const wrapper = document.createElement('div');
47+
wrapper.className = 'mermaid-theme-wrapper';
48+
block.parentNode?.insertBefore(wrapper, block);
49+
wrapper.append(block, darkBlock);
50+
}
5751
});
52+
53+
// Render both themes
54+
mermaid.initialize({startOnLoad: false, theme: 'default'});
5855
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.light')});
5956

6057
mermaid.initialize({startOnLoad: false, theme: 'dark'});
61-
await mermaid
62-
.run({nodes: document.querySelectorAll('.language-mermaid.dark')})
63-
.then(() => setDoneRendering(true));
58+
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.dark')});
59+
60+
// Initialize pan/zoom for all SVGs (including hidden ones)
61+
document.querySelectorAll('.language-mermaid svg').forEach(svg => {
62+
const svgElement = svg as SVGSVGElement;
63+
const rect = svgElement.getBoundingClientRect();
64+
65+
if (rect.width > 0 && rect.height > 0) {
66+
svgElement.setAttribute('width', rect.width.toString());
67+
svgElement.setAttribute('height', rect.height.toString());
68+
}
69+
70+
svgPanZoom(svgElement, {
71+
zoomEnabled: true,
72+
panEnabled: true,
73+
controlIconsEnabled: true,
74+
fit: true,
75+
center: true,
76+
minZoom: 0.1,
77+
maxZoom: 10,
78+
zoomScaleSensitivity: 0.2,
79+
});
80+
});
81+
82+
setDoneRendering(true);
6483
};
84+
6585
renderMermaid();
6686
}, []);
67-
// we have to wait for mermaid.js to finish rendering both light and dark charts
68-
// before we hide one of them depending on the theme
69-
// this is necessary because mermaid.js relies on the DOM for calculations
87+
7088
return isDoneRendering ? (
71-
theme === 'dark' ? (
72-
<style>
73-
{`
74-
.dark .language-mermaid {
75-
display: none;
76-
}
77-
.dark .language-mermaid.dark {
78-
display: block;
79-
}
80-
`}
81-
</style>
82-
) : (
83-
<style>
84-
{`
85-
.language-mermaid.light {
86-
display: block;
87-
}
88-
.language-mermaid.dark {
89-
display: none;
90-
}
89+
<style>
90+
{`
91+
.language-mermaid.light { display: ${theme === 'dark' ? 'none' : 'block'}; }
92+
.language-mermaid.dark { display: ${theme === 'dark' ? 'block' : 'none'}; }
93+
.dark .language-mermaid.light { display: none; }
94+
.dark .language-mermaid.dark { display: block; }
9195
`}
92-
</style>
93-
)
96+
</style>
9497
) : null;
9598
}

0 commit comments

Comments
 (0)