@@ -9,7 +9,7 @@ import panzoom from '@panzoom/panzoom';
99
1010import styles from './styles.module.css' ;
1111
12- const PADDING = 80 ; // pixels of padding inside modal
12+ const PADDING = 60 ;
1313
1414function 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
232212function 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
240218export 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 ) ;
0 commit comments