Skip to content

Commit 33ee373

Browse files
JusterZhuclaude
andcommitted
fix(docs): add retry backoff for SVG fit + scroll fallback in mermaid modal
Large mermaid diagrams like 'ClientStrategy Execution Flow' generate wide SVGs (2000+px). On open, fitToScreen sometimes reads 0 from viewBox because the DOM layout isn't done yet, bailing out early. This left the SVG at native size with overflow:hidden — invisible. Fixes: - Exponential backoff retry for fitToScreen (up to 8 attempts) - Fallback to width/height attributes then getBoundingClientRect - Changed modal body from overflow:hidden to overflow:auto for scroll fallback when panzoom hasn't fitted yet - Wrapped SVG in .svgWrapper for clean DOM interaction Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1c1ecf9 commit 33ee373

2 files changed

Lines changed: 118 additions & 145 deletions

File tree

website/src/theme/Mermaid/index.js

Lines changed: 103 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import panzoom from '@panzoom/panzoom';
99

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

12-
const PADDING = 80; // pixels of padding inside modal
12+
const PADDING = 60;
1313

1414
function MermaidRenderResult({ renderResult }) {
1515
const ref = useRef(null);
@@ -19,109 +19,115 @@ function MermaidRenderResult({ renderResult }) {
1919

2020
useEffect(() => {
2121
const div = ref.current;
22-
renderResult.bindFunctions?.(div);
22+
if (div) renderResult.bindFunctions?.(div);
2323
}, [renderResult]);
2424

2525
const handleClick = useCallback(() => {
2626
setModalOpen(true);
2727
}, []);
2828

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

33-
const modalEl = modalRef.current;
34-
const svgEl = modalEl.querySelector('svg');
35-
if (!svgEl) return;
33+
const body = modalRef.current;
34+
const svg = body.querySelector('svg');
35+
if (!svg) return;
3636

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

42-
const instance = panzoom(svgEl, {
43-
maxScale: 10,
42+
// 1. Create panzoom instance
43+
const instance = panzoom(svg, {
44+
maxScale: 12,
4445
minScale: 0.05,
45-
step: 0.15,
46+
step: 0.2,
4647
contain: false,
4748
pinchAndPan: true,
49+
// Let SVG keep its native size initially; we'll zoom below
50+
cursor: 'grab',
4851
});
4952
instanceRef.current = instance;
5053

51-
// Wheel zoom: forward wheel events from modal body to panzoom
52-
const handleWheel = (e) => {
53-
e.preventDefault();
54-
const delta = e.deltaY > 0 ? -0.3 : 0.3;
55-
// Calculate zoom around cursor position
56-
const rect = svgEl.getBoundingClientRect();
57-
const x = e.clientX - rect.left;
58-
const y = e.clientY - rect.top;
59-
instance.zoomWithWheel(e, {
60-
step: 0.3,
61-
minScale: 0.05,
62-
maxScale: 10,
63-
});
64-
};
65-
modalEl.addEventListener('wheel', handleWheel, { passive: false });
54+
// 2. Fit to screen – retry with exponential backoff
55+
let fitAttempts = 0;
56+
const maxAttempts = 8;
6657

67-
// Auto-fit: scale the SVG to fill the modal while keeping aspect ratio
6858
const fitToScreen = () => {
69-
const parent = modalRef.current;
70-
if (!parent) return;
71-
const svg = parent.querySelector('svg');
59+
const svg = body.querySelector('svg');
7260
if (!svg) return;
7361

74-
const containerW = parent.clientWidth;
75-
const containerH = parent.clientHeight;
76-
const svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width;
77-
const svgH = svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || svg.getBoundingClientRect().height;
62+
const w = body.clientWidth;
63+
const h = body.clientHeight;
64+
if (!w || !h) return;
65+
66+
// SVG native dimensions
67+
let svgW = svg.viewBox?.baseVal?.width;
68+
let svgH = svg.viewBox?.baseVal?.height;
69+
70+
// Fallback to width/height attributes
71+
if (!svgW) svgW = svg.width?.baseVal?.value;
72+
if (!svgH) svgH = svg.height?.baseVal?.value;
73+
74+
// Fallback to computed bounding rect
75+
if (!svgW || !svgH) {
76+
const rect = svg.getBoundingClientRect();
77+
svgW = rect.width;
78+
svgH = rect.height;
79+
}
80+
81+
// If still zero and we have retries left, try again
82+
if ((!svgW || !svgH) && fitAttempts < maxAttempts) {
83+
fitAttempts++;
84+
setTimeout(fitToScreen, 100 * fitAttempts);
85+
return;
86+
}
7887

7988
if (!svgW || !svgH) return;
8089

81-
const availableW = containerW - PADDING;
82-
const availableH = containerH - PADDING;
83-
const scale = Math.min(availableW / svgW, availableH / svgH, 3); // cap at 3x
90+
const availW = w - PADDING * 2;
91+
const availH = h - PADDING * 2;
92+
const scale = Math.min(availW / svgW, availH / svgH, 3);
8493

8594
if (scale > 0) {
8695
instance.zoom(scale, { animate: false });
87-
// Center the SVG
88-
const scaledW = svgW * scale;
89-
const scaledH = svgH * scale;
90-
const offsetX = (containerW - scaledW) / 2;
91-
const offsetY = (containerH - scaledH) / 2;
92-
instance.pan(offsetX, offsetY, { animate: false });
96+
instance.pan(
97+
(w - svgW * scale) / 2,
98+
(h - svgH * scale) / 2,
99+
{ animate: false }
100+
);
93101
}
94102
};
95103

96-
// Wait for layout, then fit
97-
const fitTimer = setTimeout(fitToScreen, 80);
104+
// Kick off fit with first attempt
105+
const fitTimer = setTimeout(fitToScreen, 100);
98106

99-
// Re-fit on resize
107+
// 3. Wheel zoom on the body
108+
const handleWheel = (e) => {
109+
e.preventDefault();
110+
const svgEl = body.querySelector('svg');
111+
if (!svgEl) return;
112+
instance.zoomWithWheel(e, { step: 0.3, minScale: 0.05, maxScale: 12 });
113+
};
114+
body.addEventListener('wheel', handleWheel, { passive: false });
115+
116+
// 4. Resize listener
100117
window.addEventListener('resize', fitToScreen);
101118

119+
// 5. Keyboard shortcuts
102120
const handleKeyDown = (e) => {
103-
if (e.key === 'Escape') {
104-
setModalOpen(false);
105-
}
106-
if (e.key === '+' || e.key === '=') {
107-
e.preventDefault();
108-
instance.zoomIn();
109-
}
110-
if (e.key === '-') {
111-
e.preventDefault();
112-
instance.zoomOut();
113-
}
114-
if (e.key === '0') {
115-
e.preventDefault();
116-
fitToScreen(); // reset to fit
117-
}
121+
if (e.key === 'Escape') { setModalOpen(false); return; }
122+
if (e.key === '+' || e.key === '=') { e.preventDefault(); instance.zoomIn(); }
123+
if (e.key === '-') { e.preventDefault(); instance.zoomOut(); }
124+
if (e.key === '0') { e.preventDefault(); fitToScreen(); }
118125
};
119-
120126
window.addEventListener('keydown', handleKeyDown);
121127

122128
return () => {
123129
clearTimeout(fitTimer);
124-
modalEl.removeEventListener('wheel', handleWheel);
130+
body.removeEventListener('wheel', handleWheel);
125131
window.removeEventListener('resize', fitToScreen);
126132
window.removeEventListener('keydown', handleKeyDown);
127133
if (instanceRef.current) {
@@ -133,6 +139,7 @@ function MermaidRenderResult({ renderResult }) {
133139

134140
return (
135141
<>
142+
{/* ── Inline diagram ─────────────────────────── */}
136143
<div
137144
ref={ref}
138145
className={`${MermaidContainerClassName} ${styles.container}`}
@@ -144,85 +151,58 @@ function MermaidRenderResult({ renderResult }) {
144151
title="Click to zoom"
145152
/>
146153

154+
{/* ── Fullscreen modal ─────────────────────────── */}
147155
{modalOpen && (
148156
<div
149157
className={styles.overlay}
150158
onClick={() => setModalOpen(false)}
151159
role="presentation"
152160
>
161+
{/* Toolbar */}
153162
<div className={styles.toolbar}>
154-
<button
155-
className={styles.toolBtn}
156-
onClick={(e) => {
157-
e.stopPropagation();
158-
instanceRef.current?.zoomIn();
159-
}}
160-
title="Zoom in (+)"
161-
>
162-
163-
</button>
164-
<button
165-
className={styles.toolBtn}
166-
onClick={(e) => {
167-
e.stopPropagation();
168-
instanceRef.current?.zoomOut();
169-
}}
170-
title="Zoom out (-)"
171-
>
172-
173-
</button>
174-
<button
175-
className={styles.toolBtn}
163+
<button className={styles.toolBtn}
164+
onClick={(e) => { e.stopPropagation(); instanceRef.current?.zoomIn(); }}
165+
title="Zoom in (+)"></button>
166+
<button className={styles.toolBtn}
167+
onClick={(e) => { e.stopPropagation(); instanceRef.current?.zoomOut(); }}
168+
title="Zoom out (-)"></button>
169+
<button className={styles.toolBtn}
176170
onClick={(e) => {
177171
e.stopPropagation();
178-
// Re-fit to screen
179-
const parent = modalRef.current;
180-
const svg = parent?.querySelector('svg');
181-
if (!svg || !instanceRef.current) return;
182-
const containerW = parent.clientWidth;
183-
const containerH = parent.clientHeight;
184-
const svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width;
185-
const svgH = svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || svg.getBoundingClientRect().height;
172+
const body = modalRef.current;
173+
if (!body || !instanceRef.current) return;
174+
const svg = body.querySelector('svg');
175+
if (!svg) return;
176+
const w = body.clientWidth, h = body.clientHeight;
177+
let svgW = svg.viewBox?.baseVal?.width || svg.width?.baseVal?.value || svg.getBoundingClientRect().width;
178+
let svgH = svg.viewBox?.baseVal?.height || svg.height?.baseVal?.value || svg.getBoundingClientRect().height;
186179
if (!svgW || !svgH) return;
187-
const availableW = containerW - PADDING;
188-
const availableH = containerH - PADDING;
189-
const scale = Math.min(availableW / svgW, availableH / svgH, 3);
180+
const availW = w - PADDING * 2, availH = h - PADDING * 2;
181+
const scale = Math.min(availW / svgW, availH / svgH, 3);
190182
if (scale > 0) {
191183
instanceRef.current.zoom(scale, { animate: true });
192-
const scaledW = svgW * scale;
193-
const scaledH = svgH * scale;
194-
instanceRef.current.pan(
195-
(containerW - scaledW) / 2,
196-
(containerH - scaledH) / 2,
197-
{ animate: true }
198-
);
184+
instanceRef.current.pan((w - svgW * scale) / 2, (h - svgH * scale) / 2, { animate: true });
199185
}
200186
}}
201-
title="Fit to screen (0)"
202-
>
203-
204-
</button>
205-
<button
206-
className={styles.toolBtn}
207-
onClick={(e) => {
208-
e.stopPropagation();
209-
setModalOpen(false);
210-
}}
211-
title="Close (Esc)"
212-
>
213-
214-
</button>
215-
<span className={styles.toolHint}>
216-
Scroll zoom · Drag pan · 0 fit
217-
</span>
187+
title="Fit to screen (0)"></button>
188+
<button className={styles.toolBtn}
189+
onClick={(e) => { e.stopPropagation(); setModalOpen(false); }}
190+
title="Close (Esc)"></button>
191+
<span className={styles.toolHint}>Scroll zoom · Drag pan · 0 fit</span>
218192
</div>
193+
194+
{/* Body – scrollable fallback so content is never hidden */}
219195
<div
220196
ref={modalRef}
221197
className={styles.modalBody}
222198
onClick={(e) => e.stopPropagation()}
223199
role="presentation"
224-
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
225-
/>
200+
>
201+
<div
202+
className={styles.svgWrapper}
203+
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
204+
/>
205+
</div>
226206
</div>
227207
)}
228208
</>
@@ -231,17 +211,14 @@ function MermaidRenderResult({ renderResult }) {
231211

232212
function MermaidRenderer({ value }) {
233213
const renderResult = useMermaidRenderResult({ text: value });
234-
if (renderResult === null) {
235-
return null;
236-
}
214+
if (renderResult === null) return null;
237215
return <MermaidRenderResult renderResult={renderResult} />;
238216
}
239217

240218
export default function Mermaid(props) {
241219
return (
242220
<ErrorBoundary
243-
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}
244-
>
221+
fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
245222
<MermaidRenderer {...props} />
246223
</ErrorBoundary>
247224
);

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

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
align-items: center;
2929
justify-content: center;
3030
animation: overlayIn 0.2s ease;
31-
/* Prevent body scroll when modal is open */
3231
overflow: hidden;
3332
touch-action: none;
3433
}
@@ -38,7 +37,7 @@
3837
to { opacity: 1; }
3938
}
4039

41-
/* ── Toolbar at top-right ──────────────────────────────── */
40+
/* ── Toolbar ──────────────────────────────────────────── */
4241
.toolbar {
4342
position: fixed;
4443
top: 12px;
@@ -86,35 +85,32 @@
8685
white-space: nowrap;
8786
}
8887

89-
/* ── Modal body = the fullscreen SVG container ──────────── */
88+
/* ── Modal body: fullscreen, scroll fallback ────────── */
9089
.modalBody {
9190
position: absolute;
92-
/* Leave room for toolbar */
9391
inset: 0;
94-
overflow: hidden;
92+
overflow: auto;
93+
/* Scroll fallback: user can always scroll if panzoom fails */
9594
cursor: grab;
96-
touch-action: none;
95+
touch-action: pan-x pan-y pinch-zoom;
96+
background: #f8f9fa;
9797
}
9898

9999
.modalBody:active {
100100
cursor: grabbing;
101101
}
102102

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 */
103+
[data-theme='dark'] .modalBody {
104+
background: #121220;
111105
}
112106

113-
/* Light background for the SVG canvas itself */
114-
.modalBody {
115-
background: #f8f9fa;
107+
/* ── SVG wrapper ─────────────────────────────────────── */
108+
.svgWrapper {
109+
display: inline-block;
110+
position: relative;
116111
}
117112

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

0 commit comments

Comments
 (0)