Skip to content

Commit 42afbf8

Browse files
JusterZhuclaude
andcommitted
feat(docs): add click-to-zoom modal with panzoom for mermaid flowcharts
Replace the basic scrollbar approach with a full interactive viewer: - Click any mermaid diagram to open a fullscreen modal overlay - Pan (drag) and zoom (scroll/+/- buttons) to freely explore - Toolbar with zoom in/out/reset/close buttons - Escape key to close, +/=/- keyboard shortcuts - Dark/light theme support - Smooth overlay animation Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4bb4aba commit 42afbf8

5 files changed

Lines changed: 306 additions & 54 deletions

File tree

website/package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

website/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@docusaurus/preset-classic": "3.1.1",
1919
"@docusaurus/theme-mermaid": "^3.1.1",
2020
"@mdx-js/react": "^3.0.0",
21+
"@panzoom/panzoom": "^4.6.2",
2122
"clsx": "^2.0.0",
2223
"prism-react-renderer": "^2.3.0",
2324
"react": "^18.0.0",

website/src/css/custom.css

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -636,60 +636,6 @@
636636
color: var(--ifm-color-primary);
637637
}
638638

639-
/* ════════════════════════════════════════════════════════════════
640-
MERMAID DIAGRAMS — horizontal scroll + zoom on hover
641-
════════════════════════════════════════════════════════════════ */
642-
643-
/* Wrapper around each mermaid SVG: enable horizontal scroll */
644-
.docusaurus-mermaid-container {
645-
overflow-x: auto;
646-
overflow-y: hidden;
647-
-webkit-overflow-scrolling: touch;
648-
margin: 1.5rem 0;
649-
border: 1px solid var(--ifm-color-emphasis-200);
650-
border-radius: var(--gu-radius);
651-
background: #fff;
652-
padding: 0.5rem;
653-
}
654-
655-
[data-theme='dark'] .docusaurus-mermaid-container {
656-
background: #1a1a2e;
657-
border-color: rgba(93, 173, 226, 0.15);
658-
}
659-
660-
/* Allow the SVG to keep its natural width (don't shrink to container) */
661-
.docusaurus-mermaid-container svg {
662-
width: auto !important;
663-
max-width: none !important;
664-
height: auto !important;
665-
min-width: 600px; /* Ensures diagrams don't get squashed too small */
666-
display: block;
667-
}
668-
669-
/* Zoom-on-hover: scale up slightly for readability */
670-
.docusaurus-mermaid-container:hover {
671-
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
672-
}
673-
[data-theme='dark'] .docusaurus-mermaid-container:hover {
674-
box-shadow: 0 2px 12px rgba(0,0,0,0.35);
675-
}
676-
677-
/* Make the scrollbar visible and styled */
678-
.docusaurus-mermaid-container::-webkit-scrollbar {
679-
height: 8px;
680-
}
681-
.docusaurus-mermaid-container::-webkit-scrollbar-track {
682-
background: var(--ifm-color-emphasis-100);
683-
border-radius: 4px;
684-
}
685-
.docusaurus-mermaid-container::-webkit-scrollbar-thumb {
686-
background: var(--ifm-color-emphasis-400);
687-
border-radius: 4px;
688-
}
689-
.docusaurus-mermaid-container::-webkit-scrollbar-thumb:hover {
690-
background: var(--ifm-color-emphasis-500);
691-
}
692-
693639
/* ════════════════════════════════════════════════════════════════
694640
PRINT-FRIENDLY
695641
════════════════════════════════════════════════════════════════ */

website/src/theme/Mermaid/index.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import React, { useEffect, useRef, useState, useCallback } from 'react';
2+
import ErrorBoundary from '@docusaurus/ErrorBoundary';
3+
import { ErrorBoundaryErrorMessageFallback } from '@docusaurus/theme-common';
4+
import {
5+
MermaidContainerClassName,
6+
useMermaidRenderResult,
7+
} from '@docusaurus/theme-mermaid/client';
8+
import panzoom from '@panzoom/panzoom';
9+
10+
import styles from './styles.module.css';
11+
12+
function MermaidRenderResult({ renderResult }) {
13+
const ref = useRef(null);
14+
const [modalOpen, setModalOpen] = useState(false);
15+
const modalRef = useRef(null);
16+
const zoomRef = useRef(null);
17+
const instanceRef = useRef(null);
18+
19+
useEffect(() => {
20+
const div = ref.current;
21+
renderResult.bindFunctions?.(div);
22+
}, [renderResult]);
23+
24+
// Handle click on the mermaid diagram to open modal
25+
const handleClick = useCallback(() => {
26+
setModalOpen(true);
27+
}, []);
28+
29+
// Initialize panzoom when modal opens
30+
useEffect(() => {
31+
if (!modalOpen || !modalRef.current) return;
32+
33+
const modalEl = modalRef.current;
34+
const svgEl = modalEl.querySelector('svg');
35+
if (!svgEl) return;
36+
37+
// Clean up any previous instance
38+
if (instanceRef.current) {
39+
instanceRef.current.destroy();
40+
}
41+
42+
const instance = panzoom(svgEl, {
43+
maxScale: 10,
44+
minScale: 0.3,
45+
step: 0.3,
46+
contain: 'outside',
47+
pinchAndPan: true,
48+
});
49+
instanceRef.current = instance;
50+
51+
// Reset zoom on open
52+
// Use a small delay to let the modal render finish
53+
const resetTimer = setTimeout(() => {
54+
instance.reset({ animate: false });
55+
}, 50);
56+
57+
// Keyboard handlers
58+
const handleKeyDown = (e) => {
59+
if (e.key === 'Escape') {
60+
setModalOpen(false);
61+
}
62+
// Zoom with +/-
63+
if (e.key === '+' || e.key === '=') {
64+
e.preventDefault();
65+
instance.zoomIn();
66+
}
67+
if (e.key === '-') {
68+
e.preventDefault();
69+
instance.zoomOut();
70+
}
71+
};
72+
73+
window.addEventListener('keydown', handleKeyDown);
74+
75+
return () => {
76+
clearTimeout(resetTimer);
77+
window.removeEventListener('keydown', handleKeyDown);
78+
if (instanceRef.current) {
79+
instanceRef.current.destroy();
80+
instanceRef.current = null;
81+
}
82+
};
83+
}, [modalOpen]);
84+
85+
return (
86+
<>
87+
<div
88+
ref={ref}
89+
className={`${MermaidContainerClassName} ${styles.container}`}
90+
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
91+
onClick={handleClick}
92+
role="button"
93+
tabIndex={0}
94+
onKeyDown={(e) => { if (e.key === 'Enter') handleClick(); }}
95+
title="Click to zoom"
96+
/>
97+
98+
{modalOpen && (
99+
<div
100+
className={styles.overlay}
101+
onClick={() => setModalOpen(false)}
102+
role="presentation"
103+
>
104+
<div className={styles.toolbar}>
105+
<button
106+
className={styles.toolBtn}
107+
onClick={(e) => {
108+
e.stopPropagation();
109+
instanceRef.current?.zoomIn();
110+
}}
111+
title="Zoom in (+)"
112+
>
113+
114+
</button>
115+
<button
116+
className={styles.toolBtn}
117+
onClick={(e) => {
118+
e.stopPropagation();
119+
instanceRef.current?.zoomOut();
120+
}}
121+
title="Zoom out (-)"
122+
>
123+
124+
</button>
125+
<button
126+
className={styles.toolBtn}
127+
onClick={(e) => {
128+
e.stopPropagation();
129+
instanceRef.current?.reset({ animate: true });
130+
}}
131+
title="Reset"
132+
>
133+
134+
</button>
135+
<button
136+
className={styles.toolBtn}
137+
onClick={(e) => {
138+
e.stopPropagation();
139+
setModalOpen(false);
140+
}}
141+
title="Close (Esc)"
142+
>
143+
144+
</button>
145+
<span className={styles.toolHint}>
146+
Scroll to zoom · Drag to pan
147+
</span>
148+
</div>
149+
<div
150+
className={styles.modalBody}
151+
onClick={(e) => e.stopPropagation()}
152+
role="presentation"
153+
>
154+
<div
155+
ref={modalRef}
156+
className={styles.zoomContainer}
157+
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
158+
/>
159+
</div>
160+
</div>
161+
)}
162+
</>
163+
);
164+
}
165+
166+
function MermaidRenderer({ value }) {
167+
const renderResult = useMermaidRenderResult({ text: value });
168+
if (renderResult === null) {
169+
return null;
170+
}
171+
return <MermaidRenderResult renderResult={renderResult} />;
172+
}
173+
174+
export default function Mermaid(props) {
175+
return (
176+
<ErrorBoundary
177+
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}
178+
>
179+
<MermaidRenderer {...props} />
180+
</ErrorBoundary>
181+
);
182+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/* ── Inline diagram (clickable) ─────────────────────────── */
2+
.container {
3+
max-width: 100%;
4+
cursor: zoom-in;
5+
transition: box-shadow 0.2s ease;
6+
}
7+
8+
.container > svg {
9+
max-width: 100%;
10+
}
11+
12+
.container:hover {
13+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
14+
}
15+
16+
[data-theme='dark'] .container:hover {
17+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
18+
}
19+
20+
/* ── Fullscreen modal overlay ─────────────────────────── */
21+
.overlay {
22+
position: fixed;
23+
inset: 0;
24+
z-index: 9999;
25+
background: rgba(0, 0, 0, 0.75);
26+
backdrop-filter: blur(4px);
27+
display: flex;
28+
flex-direction: column;
29+
align-items: center;
30+
justify-content: center;
31+
animation: overlayIn 0.2s ease;
32+
}
33+
34+
@keyframes overlayIn {
35+
from { opacity: 0; }
36+
to { opacity: 1; }
37+
}
38+
39+
/* ── Toolbar at top of overlay ─────────────────────────── */
40+
.toolbar {
41+
position: absolute;
42+
top: 16px;
43+
right: 16px;
44+
display: flex;
45+
align-items: center;
46+
gap: 6px;
47+
background: rgba(0, 0, 0, 0.6);
48+
backdrop-filter: blur(6px);
49+
border-radius: 8px;
50+
padding: 6px 10px;
51+
z-index: 10000;
52+
user-select: none;
53+
}
54+
55+
.toolBtn {
56+
display: inline-flex;
57+
align-items: center;
58+
justify-content: center;
59+
width: 32px;
60+
height: 32px;
61+
border: none;
62+
border-radius: 6px;
63+
background: transparent;
64+
color: #eee;
65+
font-size: 16px;
66+
cursor: pointer;
67+
transition: background 0.15s;
68+
}
69+
70+
.toolBtn:hover {
71+
background: rgba(255, 255, 255, 0.15);
72+
}
73+
74+
.toolBtn:active {
75+
background: rgba(255, 255, 255, 0.25);
76+
}
77+
78+
.toolHint {
79+
color: rgba(255, 255, 255, 0.5);
80+
font-size: 12px;
81+
margin-left: 6px;
82+
padding-left: 8px;
83+
border-left: 1px solid rgba(255, 255, 255, 0.15);
84+
}
85+
86+
/* ── Scrollable body area (for large diagrams) ─────────── */
87+
.modalBody {
88+
width: 90vw;
89+
height: 85vh;
90+
overflow: auto;
91+
border-radius: 12px;
92+
background: #fff;
93+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
94+
cursor: grab;
95+
}
96+
97+
.modalBody:active {
98+
cursor: grabbing;
99+
}
100+
101+
[data-theme='dark'] .modalBody {
102+
background: #1a1a2e;
103+
}
104+
105+
/* ── Panzoom container inside modal body ───────────────── */
106+
.zoomContainer {
107+
display: inline-block;
108+
padding: 32px;
109+
transform-origin: 0 0;
110+
touch-action: none;
111+
}
112+
113+
.zoomContainer svg {
114+
display: block;
115+
max-width: none;
116+
}

0 commit comments

Comments
 (0)