Skip to content

Commit a7bd26d

Browse files
committed
feat(Mermaid) add pan and zoom via svg-pan-zoom
1 parent d75aee2 commit a7bd26d

File tree

3 files changed

+277
-186
lines changed

3 files changed

+277
-186
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: 118 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,94 +2,137 @@
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-
};
28-
const mermaidBlocks =
29-
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
11+
const mermaidBlocks = document.querySelectorAll<HTMLDivElement>('.language-mermaid');
12+
if (mermaidBlocks.length === 0) return;
13+
14+
const escapeHTML = (str: string) =>
15+
str.replace(/[&<>"']/g, (match) => ({
16+
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
17+
}[match] || match));
18+
3419
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));
20+
21+
// Create light and dark versions
22+
mermaidBlocks.forEach(block => {
23+
const code = block.textContent ?? '';
24+
block.innerHTML = escapeHTML(code);
25+
block.style.backgroundColor = 'transparent';
26+
block.classList.add('light');
27+
28+
const parentCodeTabs = block.closest('.code-tabs-wrapper');
29+
const darkBlock = block.cloneNode(true) as HTMLDivElement;
30+
darkBlock.classList.replace('light', 'dark');
5231

53-
const darkModeBlock = lightModeblock.cloneNode(true) as HTMLPreElement;
54-
darkModeBlock.classList.add('dark');
55-
darkModeBlock.classList.remove('light');
56-
parentCodeTabs?.appendChild(darkModeBlock);
32+
if (parentCodeTabs) {
33+
parentCodeTabs.innerHTML = '';
34+
parentCodeTabs.append(block, darkBlock);
35+
} else {
36+
const wrapper = document.createElement('div');
37+
wrapper.className = 'mermaid-theme-wrapper';
38+
block.parentNode?.insertBefore(wrapper, block);
39+
wrapper.append(block, darkBlock);
40+
}
5741
});
58-
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.light')});
5942

43+
// Render both themes
44+
mermaid.initialize({startOnLoad: false, theme: 'default'});
45+
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.light')});
46+
6047
mermaid.initialize({startOnLoad: false, theme: 'dark'});
61-
await mermaid
62-
.run({nodes: document.querySelectorAll('.language-mermaid.dark')})
63-
.then(() => setDoneRendering(true));
48+
await mermaid.run({nodes: document.querySelectorAll('.language-mermaid.dark')});
49+
50+
// Initialize pan/zoom
51+
await initializePanZoom();
52+
setDoneRendering(true);
53+
};
54+
55+
const initializePanZoom = async () => {
56+
const svgPanZoom = (await import('svg-pan-zoom')).default;
57+
58+
document.querySelectorAll('.language-mermaid svg').forEach(svg => {
59+
const svgElement = svg as SVGSVGElement;
60+
const container = svgElement.closest('.language-mermaid') as HTMLElement;
61+
const isVisible = window.getComputedStyle(container).display !== 'none';
62+
63+
if (isVisible) {
64+
const rect = svgElement.getBoundingClientRect();
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+
} else {
81+
svgElement.dataset.needsPanZoom = 'true';
82+
}
83+
});
6484
};
85+
6586
renderMermaid();
6687
}, []);
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
88+
89+
// Initialize pan/zoom for newly visible SVGs on theme change
90+
useEffect(() => {
91+
if (!isDoneRendering) return;
92+
93+
const initializeDelayedPanZoom = async () => {
94+
const svgPanZoom = (await import('svg-pan-zoom')).default;
95+
96+
document.querySelectorAll('.language-mermaid svg[data-needs-pan-zoom="true"]')
97+
.forEach(svg => {
98+
const svgElement = svg as SVGSVGElement;
99+
const container = svgElement.closest('.language-mermaid') as HTMLElement;
100+
const isVisible = window.getComputedStyle(container).display !== 'none';
101+
102+
if (isVisible) {
103+
const rect = svgElement.getBoundingClientRect();
104+
if (rect.width > 0 && rect.height > 0) {
105+
svgElement.setAttribute('width', rect.width.toString());
106+
svgElement.setAttribute('height', rect.height.toString());
107+
}
108+
109+
svgPanZoom(svgElement, {
110+
zoomEnabled: true,
111+
panEnabled: true,
112+
controlIconsEnabled: true,
113+
fit: true,
114+
center: true,
115+
minZoom: 0.1,
116+
maxZoom: 10,
117+
zoomScaleSensitivity: 0.2,
118+
});
119+
120+
svgElement.removeAttribute('data-needs-pan-zoom');
121+
}
122+
});
123+
};
124+
125+
setTimeout(initializeDelayedPanZoom, 50);
126+
}, [theme, isDoneRendering]);
127+
70128
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-
}
129+
<style>
130+
{`
131+
.language-mermaid.light { display: ${theme === 'dark' ? 'none' : 'block'}; }
132+
.language-mermaid.dark { display: ${theme === 'dark' ? 'block' : 'none'}; }
133+
.dark .language-mermaid.light { display: none; }
134+
.dark .language-mermaid.dark { display: block; }
91135
`}
92-
</style>
93-
)
136+
</style>
94137
) : null;
95-
}
138+
}

0 commit comments

Comments
 (0)