Skip to content

Commit f4570b1

Browse files
JusterZhuclaude
andauthored
fix(docs): center and auto-fit mermaid SVG to fullscreen on open (#137)
* 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> * fix(docs): center and auto-fit mermaid SVG to fullscreen on zoom Previously the modal body was only 90vw x 85vh with nested scrollable divs, and the SVG rendered at its tiny native size in the top-left corner. Fixes: - Modal now covers the entire viewport (100vw x 100vh) - On open, auto-scale SVG to fit available space using viewBox dimensions - SVG is centered both horizontally and vertically - Window resize also re-fits the SVG - Replaced the old 'reset' button with a 'fit to screen' button (0) - Keyboard shortcut 0 to re-fit Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a1fd22 commit f4570b1

3 files changed

Lines changed: 119 additions & 120 deletions

File tree

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: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,57 +9,84 @@ import panzoom from '@panzoom/panzoom';
99

1010
import styles from './styles.module.css';
1111

12+
const PADDING = 80; // pixels of padding inside modal
13+
1214
function MermaidRenderResult({ renderResult }) {
1315
const ref = useRef(null);
1416
const [modalOpen, setModalOpen] = useState(false);
1517
const modalRef = useRef(null);
16-
const zoomRef = useRef(null);
1718
const instanceRef = useRef(null);
1819

1920
useEffect(() => {
2021
const div = ref.current;
2122
renderResult.bindFunctions?.(div);
2223
}, [renderResult]);
2324

24-
// Handle click on the mermaid diagram to open modal
2525
const handleClick = useCallback(() => {
2626
setModalOpen(true);
2727
}, []);
2828

29-
// Initialize panzoom when modal opens
29+
// Initialize panzoom and auto-fit SVG when modal opens
3030
useEffect(() => {
3131
if (!modalOpen || !modalRef.current) return;
3232

3333
const modalEl = modalRef.current;
3434
const svgEl = modalEl.querySelector('svg');
3535
if (!svgEl) return;
3636

37-
// Clean up any previous instance
37+
// Clean up previous
3838
if (instanceRef.current) {
3939
instanceRef.current.destroy();
4040
}
4141

4242
const instance = panzoom(svgEl, {
4343
maxScale: 10,
44-
minScale: 0.3,
45-
step: 0.3,
44+
minScale: 0.1,
45+
step: 0.15,
4646
contain: 'outside',
4747
pinchAndPan: true,
4848
});
4949
instanceRef.current = instance;
5050

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);
51+
// Auto-fit: scale the SVG to fill the modal while keeping aspect ratio
52+
const fitToScreen = () => {
53+
const parent = modalRef.current;
54+
if (!parent) return;
55+
const svg = parent.querySelector('svg');
56+
if (!svg) return;
57+
58+
const containerW = parent.clientWidth;
59+
const containerH = parent.clientHeight;
60+
const svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width;
61+
const svgH = svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || svg.getBoundingClientRect().height;
62+
63+
if (!svgW || !svgH) return;
64+
65+
const availableW = containerW - PADDING;
66+
const availableH = containerH - PADDING;
67+
const scale = Math.min(availableW / svgW, availableH / svgH, 3); // cap at 3x
68+
69+
if (scale > 0) {
70+
instance.zoom(scale, { animate: false });
71+
// Center the SVG
72+
const scaledW = svgW * scale;
73+
const scaledH = svgH * scale;
74+
const offsetX = (containerW - scaledW) / 2;
75+
const offsetY = (containerH - scaledH) / 2;
76+
instance.pan(offsetX, offsetY, { animate: false });
77+
}
78+
};
79+
80+
// Wait for layout, then fit
81+
const fitTimer = setTimeout(fitToScreen, 80);
82+
83+
// Re-fit on resize
84+
window.addEventListener('resize', fitToScreen);
5685

57-
// Keyboard handlers
5886
const handleKeyDown = (e) => {
5987
if (e.key === 'Escape') {
6088
setModalOpen(false);
6189
}
62-
// Zoom with +/-
6390
if (e.key === '+' || e.key === '=') {
6491
e.preventDefault();
6592
instance.zoomIn();
@@ -68,12 +95,17 @@ function MermaidRenderResult({ renderResult }) {
6895
e.preventDefault();
6996
instance.zoomOut();
7097
}
98+
if (e.key === '0') {
99+
e.preventDefault();
100+
fitToScreen(); // reset to fit
101+
}
71102
};
72103

73104
window.addEventListener('keydown', handleKeyDown);
74105

75106
return () => {
76-
clearTimeout(resetTimer);
107+
clearTimeout(fitTimer);
108+
window.removeEventListener('resize', fitToScreen);
77109
window.removeEventListener('keydown', handleKeyDown);
78110
if (instanceRef.current) {
79111
instanceRef.current.destroy();
@@ -126,11 +158,32 @@ function MermaidRenderResult({ renderResult }) {
126158
className={styles.toolBtn}
127159
onClick={(e) => {
128160
e.stopPropagation();
129-
instanceRef.current?.reset({ animate: true });
161+
// Re-fit to screen
162+
const parent = modalRef.current;
163+
const svg = parent?.querySelector('svg');
164+
if (!svg || !instanceRef.current) return;
165+
const containerW = parent.clientWidth;
166+
const containerH = parent.clientHeight;
167+
const svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width;
168+
const svgH = svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || svg.getBoundingClientRect().height;
169+
if (!svgW || !svgH) return;
170+
const availableW = containerW - PADDING;
171+
const availableH = containerH - PADDING;
172+
const scale = Math.min(availableW / svgW, availableH / svgH, 3);
173+
if (scale > 0) {
174+
instanceRef.current.zoom(scale, { animate: true });
175+
const scaledW = svgW * scale;
176+
const scaledH = svgH * scale;
177+
instanceRef.current.pan(
178+
(containerW - scaledW) / 2,
179+
(containerH - scaledH) / 2,
180+
{ animate: true }
181+
);
182+
}
130183
}}
131-
title="Reset"
184+
title="Fit to screen (0)"
132185
>
133-
186+
134187
</button>
135188
<button
136189
className={styles.toolBtn}
@@ -143,20 +196,16 @@ function MermaidRenderResult({ renderResult }) {
143196
144197
</button>
145198
<span className={styles.toolHint}>
146-
Scroll to zoom · Drag to pan
199+
Scroll zoom · Drag pan · 0 fit
147200
</span>
148201
</div>
149202
<div
203+
ref={modalRef}
150204
className={styles.modalBody}
151205
onClick={(e) => e.stopPropagation()}
152206
role="presentation"
153-
>
154-
<div
155-
ref={modalRef}
156-
className={styles.zoomContainer}
157-
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
158-
/>
159-
</div>
207+
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
208+
/>
160209
</div>
161210
)}
162211
</>

website/src/theme/Mermaid/styles.module.css

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,95 +22,99 @@
2222
position: fixed;
2323
inset: 0;
2424
z-index: 9999;
25-
background: rgba(0, 0, 0, 0.75);
26-
backdrop-filter: blur(4px);
25+
background: rgba(0, 0, 0, 0.78);
26+
backdrop-filter: blur(6px);
2727
display: flex;
28-
flex-direction: column;
2928
align-items: center;
3029
justify-content: center;
3130
animation: overlayIn 0.2s ease;
31+
/* Prevent body scroll when modal is open */
32+
overflow: hidden;
33+
touch-action: none;
3234
}
3335

3436
@keyframes overlayIn {
3537
from { opacity: 0; }
3638
to { opacity: 1; }
3739
}
3840

39-
/* ── Toolbar at top of overlay ─────────────────────────── */
41+
/* ── Toolbar at top-right ──────────────────────────────── */
4042
.toolbar {
41-
position: absolute;
42-
top: 16px;
43-
right: 16px;
43+
position: fixed;
44+
top: 12px;
45+
right: 12px;
4446
display: flex;
4547
align-items: center;
46-
gap: 6px;
47-
background: rgba(0, 0, 0, 0.6);
48-
backdrop-filter: blur(6px);
49-
border-radius: 8px;
48+
gap: 4px;
49+
background: rgba(0, 0, 0, 0.55);
50+
backdrop-filter: blur(8px);
51+
border-radius: 10px;
5052
padding: 6px 10px;
51-
z-index: 10000;
53+
z-index: 10001;
5254
user-select: none;
5355
}
5456

5557
.toolBtn {
5658
display: inline-flex;
5759
align-items: center;
5860
justify-content: center;
59-
width: 32px;
60-
height: 32px;
61+
width: 34px;
62+
height: 34px;
6163
border: none;
62-
border-radius: 6px;
64+
border-radius: 8px;
6365
background: transparent;
64-
color: #eee;
65-
font-size: 16px;
66+
color: #ddd;
67+
font-size: 18px;
6668
cursor: pointer;
6769
transition: background 0.15s;
6870
}
6971

7072
.toolBtn:hover {
71-
background: rgba(255, 255, 255, 0.15);
73+
background: rgba(255, 255, 255, 0.18);
7274
}
7375

7476
.toolBtn:active {
75-
background: rgba(255, 255, 255, 0.25);
77+
background: rgba(255, 255, 255, 0.3);
7678
}
7779

7880
.toolHint {
79-
color: rgba(255, 255, 255, 0.5);
80-
font-size: 12px;
81-
margin-left: 6px;
82-
padding-left: 8px;
81+
color: rgba(255, 255, 255, 0.45);
82+
font-size: 11px;
83+
margin-left: 8px;
84+
padding-left: 10px;
8385
border-left: 1px solid rgba(255, 255, 255, 0.15);
86+
white-space: nowrap;
8487
}
8588

86-
/* ── Scrollable body area (for large diagrams) ─────────── */
89+
/* ── Modal body = the fullscreen SVG container ──────────── */
8790
.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);
91+
position: absolute;
92+
/* Leave room for toolbar */
93+
inset: 0;
94+
overflow: hidden;
9495
cursor: grab;
96+
touch-action: none;
9597
}
9698

9799
.modalBody:active {
98100
cursor: grabbing;
99101
}
100102

101-
[data-theme='dark'] .modalBody {
102-
background: #1a1a2e;
103+
/* The SVG gets transform-origin 0 0 by panzoom internally */
104+
.modalBody svg {
105+
position: absolute;
106+
top: 0;
107+
left: 0;
108+
display: block;
109+
transform-origin: 0 0;
110+
/* Panzoom handles the transform */
103111
}
104112

105-
/* ── Panzoom container inside modal body ───────────────── */
106-
.zoomContainer {
107-
display: inline-block;
108-
padding: 32px;
109-
transform-origin: 0 0;
110-
touch-action: none;
113+
/* Light background for the SVG canvas itself */
114+
.modalBody {
115+
background: #f8f9fa;
111116
}
112117

113-
.zoomContainer svg {
114-
display: block;
115-
max-width: none;
118+
[data-theme='dark'] .modalBody {
119+
background: #121220;
116120
}

0 commit comments

Comments
 (0)