]> BookStack Code Mirror - hacks/blob - content/mermaid-viewer/head.html
feat(mermaid-viewer): Improve theme integration and styling
[hacks] / content / mermaid-viewer / head.html
1 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
2     integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
3     crossorigin="anonymous" referrerpolicy="no-referrer" />
4 <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
5 <script type="module">
6     // Detect if BookStack's dark mode is enabled
7     const isDarkMode = document.documentElement.classList.contains('dark-mode');
8
9     // Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
10     mermaid.initialize({
11         startOnLoad: false,
12         securityLevel: 'loose',
13         theme: isDarkMode ? 'dark' : 'default'
14     });
15
16     // Zoom Level Configuration
17     const ZOOM_LEVEL_MIN = 0.5;
18     const ZOOM_LEVEL_MAX = 2.0;
19     const ZOOM_LEVEL_INCREMENT = 0.1;
20     const DEFAULT_ZOOM_SCALE = 1.0;
21
22     const DRAG_THRESHOLD_PIXELS = 3;
23     const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
24
25     const CSS_CLASSES = {
26         CONTAINER: 'mermaid-container',
27         VIEWPORT: 'mermaid-viewport',
28         CONTENT: 'mermaid-content',
29         DIAGRAM: 'mermaid-diagram',
30         CONTROLS: 'mermaid-controls',
31         ZOOM_CONTROLS: 'mermaid-zoom-controls',
32         INTERACTION_ENABLED: 'interaction-enabled',
33         DRAGGING: 'dragging',
34         ZOOMING: 'zooming',
35         LOCK_ICON: 'fa fa-lock',
36         UNLOCK_ICON: 'fa fa-unlock',
37         INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state
38         INTERACTIVE_PAN: 'interactive-pan',     // Class for 'grabbing' cursor state
39         BUTTON_BASE: 'mermaid-viewer-button-base' // Base class for all viewer buttons
40     };
41
42     class InteractiveMermaidViewer {
43         constructor(container, mermaidCode) {
44             this.container = container;
45             this.mermaidCode = mermaidCode;
46             this.scale = 1.0;
47             this.translateX = 0;
48             this.translateY = 0;
49             this.isDragging = false;
50             this.dragStarted = false;
51             this.startX = 0;
52             this.startY = 0;
53
54             const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
55             this.zoomLevels = Array.from(
56                 { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
57                 (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
58             );
59
60             this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
61             if (this.currentZoomIndex === -1) {
62                 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
63             }
64             this.interactionEnabled = false;
65             this.initialContentOffset = { x: 0, y: 0 };
66
67             // Cache DOM elements
68             this.toggleInteractionBtn = null;
69             this.copyCodeBtn = null;
70             this.zoomInBtn = null;
71             this.zoomOutBtn = null;
72             this.zoomResetBtn = null;
73
74             // Bind event handlers for proper addition and removal
75             this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
76             this.boundMouseUpHandler = this.handleMouseUp.bind(this);
77             this.boundToggleInteraction = this.toggleInteraction.bind(this);
78             this.boundCopyCode = this.copyCode.bind(this);
79             this.boundZoomIn = () => {
80                 const { clientX, clientY } = this._getViewportCenterClientCoords();
81                 this.zoom(1, clientX, clientY);
82             };
83             this.boundZoomOut = () => {
84                 const { clientX, clientY } = this._getViewportCenterClientCoords();
85                 this.zoom(-1, clientX, clientY);
86             };
87             this.boundResetZoom = this.resetZoom.bind(this);
88             this.boundHandleWheel = this.handleWheel.bind(this);
89             this.boundHandleMouseDown = this.handleMouseDown.bind(this);
90             this.boundPreventDefault = e => e.preventDefault();
91             this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
92
93             this.setupViewer();
94             this.setupEventListeners();
95         }
96
97         setupViewer() {
98             this.container.innerHTML = `
99                 <div class="${CSS_CLASSES.CONTROLS}">
100                     <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn toggle-interaction" title="Toggle interaction">
101                         <i class="${CSS_CLASSES.LOCK_ICON}" aria-hidden="true"></i>
102                     </div>
103                     <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn" title="Copy code">
104                         <i class="fa fa-copy" aria-hidden="true"></i>
105                     </div>
106                 </div>
107                 <div class="${CSS_CLASSES.ZOOM_CONTROLS}">
108                     <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-in" title="Zoom in"><i class="fa fa-search-plus" aria-hidden="true"></i></div>
109                     <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-out" title="Zoom out"><i class="fa fa-search-minus" aria-hidden="true"></i></div>
110                     <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-reset" title="Reset"><i class="fa fa-refresh" aria-hidden="true"></i></div>
111                 </div>
112                 <div class="${CSS_CLASSES.VIEWPORT}">
113                     <div class="${CSS_CLASSES.CONTENT}">
114                         <div class="${CSS_CLASSES.DIAGRAM}">${this.mermaidCode}</div>
115                     </div>
116                 </div>
117             `;
118             this.viewport = this.container.querySelector(`.${CSS_CLASSES.VIEWPORT}`);
119             this.content = this.container.querySelector(`.${CSS_CLASSES.CONTENT}`);
120             this.diagram = this.container.querySelector(`.${CSS_CLASSES.DIAGRAM}`);
121
122             // Cache control elements
123             this.toggleInteractionBtn = this.container.querySelector('.toggle-interaction');
124             this.copyCodeBtn = this.container.querySelector('.mermaid-btn:not(.toggle-interaction)');
125             this.zoomInBtn = this.container.querySelector('.zoom-in');
126             this.zoomOutBtn = this.container.querySelector('.zoom-out');
127             this.zoomResetBtn = this.container.querySelector('.zoom-reset');
128
129             // Function to render the diagram and perform post-render setup
130             const renderAndSetup = () => {
131                 mermaid.run({ nodes: [this.diagram] }).then(() => {
132                     this.adjustContainerHeight();
133                     this.calculateInitialOffset();
134                     this.centerDiagram();
135                 }).catch(error => {
136                     console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
137                     this.diagram.innerHTML = `<p style="color: red; padding: 10px;">Error rendering diagram. Check console.</p>`;
138                 });
139             };
140
141             // Check if Font Awesome is loaded before rendering
142             // This checks for the 'Font Awesome 6 Free' font family, which is common.
143             // Adjust if your Font Awesome version uses a different family name for its core icons.
144             if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
145                 renderAndSetup();
146             } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
147                 document.fonts.ready.then(renderAndSetup).catch(err => {
148                     renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
149                 });
150             } else {
151                 renderAndSetup();
152             }
153         }
154
155         adjustContainerHeight() {
156             const svgElement = this.content.querySelector('svg');
157             if (svgElement) {
158                 // Ensure the viewport takes up the height of the rendered SVG
159                 this.viewport.style.height = '100%';
160             }
161         }
162
163         calculateInitialOffset() {
164             const originalTransform = this.content.style.transform;
165             this.content.style.transform = '';
166             const contentRect = this.content.getBoundingClientRect();
167             const viewportRect = this.viewport.getBoundingClientRect();
168             this.initialContentOffset.x = contentRect.left - viewportRect.left;
169             this.initialContentOffset.y = contentRect.top - viewportRect.top;
170             this.content.style.transform = originalTransform;
171         }
172
173         _getViewportCenterClientCoords() {
174             const viewportRect = this.viewport.getBoundingClientRect();
175             return {
176                 clientX: viewportRect.left + viewportRect.width / 2,
177                 clientY: viewportRect.top + viewportRect.height / 2,
178             };
179         }
180
181         setupEventListeners() {
182             this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction);
183             this.copyCodeBtn.addEventListener('click', this.boundCopyCode);
184             this.zoomInBtn.addEventListener('click', this.boundZoomIn);
185             this.zoomOutBtn.addEventListener('click', this.boundZoomOut);
186             this.zoomResetBtn.addEventListener('click', this.boundResetZoom);
187
188             this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false });
189             this.viewport.addEventListener('mousedown', this.boundHandleMouseDown);
190
191             // Listen on document for mousemove to handle dragging outside viewport
192             document.addEventListener('mousemove', this.boundMouseMoveHandler);
193             // Listen on window for mouseup to ensure drag ends even if mouse is released outside
194             window.addEventListener('mouseup', this.boundMouseUpHandler, true); // Use capture phase
195
196             this.viewport.addEventListener('contextmenu', this.boundPreventDefault);
197             this.viewport.addEventListener('selectstart', this.boundPreventSelect);
198         }
199
200         toggleInteraction() {
201             this.interactionEnabled = !this.interactionEnabled;
202             const icon = this.toggleInteractionBtn.querySelector('i');
203             this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
204
205             if (this.interactionEnabled) {
206                 icon.className = CSS_CLASSES.UNLOCK_ICON;
207                 this.toggleInteractionBtn.title = 'Disable manual interaction';
208                 this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
209                 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
210                 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
211             } else {
212                 icon.className = CSS_CLASSES.LOCK_ICON;
213                 this.toggleInteractionBtn.title = 'Enable manual interaction';
214                 this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
215                 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
216                 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
217                 this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
218                 this.dragStarted = false;
219                 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
220             }
221         }
222
223         updateTransform() {
224             this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
225         }
226
227         handleWheel(e) {
228             if (!this.interactionEnabled) return;
229             // Prevent default browser scroll/zoom behavior when wheeling over the diagram
230             e.preventDefault();
231             this.content.classList.add(CSS_CLASSES.ZOOMING);
232             const clientX = e.clientX;
233             const clientY = e.clientY;
234             if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
235             else this.zoom(1, clientX, clientY);
236             setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
237         }
238
239         handleMouseDown(e) {
240             if (!this.interactionEnabled || e.button !== 0) return;
241             e.preventDefault();
242             this.isDragging = true;
243             this.dragStarted = false;
244             this.startX = e.clientX;
245             this.startY = e.clientY;
246             this.dragBaseTranslateX = this.translateX;
247             this.dragBaseTranslateY = this.translateY;
248             this.viewport.classList.add(CSS_CLASSES.DRAGGING);
249             this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
250             this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
251             this.content.classList.remove(CSS_CLASSES.ZOOMING);
252         }
253
254         handleMouseMove(e) {
255             if (!this.isDragging) return;
256             // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
257             const deltaX = e.clientX - this.startX;
258             const deltaY = e.clientY - this.startY;
259             if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
260                 this.dragStarted = true;
261             }
262             if (this.dragStarted) {
263                 e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
264                 this.translateX = this.dragBaseTranslateX + deltaX;
265                 this.translateY = this.dragBaseTranslateY + deltaY;
266                 this.updateTransform();
267             }
268         }
269
270         handleMouseUp() {
271             if (this.isDragging) {
272                 this.isDragging = false;
273                 this.dragStarted = false;
274                 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
275                 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
276                 if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
277                     this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
278                 }
279             }
280             this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
281         }
282
283         centerDiagram() {
284             const svgElement = this.content.querySelector('svg');
285             if (svgElement) {
286                 const viewportRect = this.viewport.getBoundingClientRect();
287                 const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
288                 const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
289
290                 const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
291                 const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
292
293                 this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
294                 this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
295
296                 // Initial centering constraints; may need adjustment for very large diagrams.
297                 this.translateX = Math.max(0, this.translateX);
298                 this.translateY = Math.max(0, this.translateY);
299
300                 this.updateTransform();
301             }
302         }
303
304         zoom(direction, clientX, clientY) {
305             this.content.classList.add(CSS_CLASSES.ZOOMING);
306             const oldScale = this.scale;
307             let newZoomIndex = this.currentZoomIndex + direction;
308
309             if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
310                 this.currentZoomIndex = newZoomIndex;
311                 const newScale = this.zoomLevels[this.currentZoomIndex];
312
313                 const viewportRect = this.viewport.getBoundingClientRect();
314                 const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
315                 const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
316
317                 this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
318                 this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
319                 this.scale = newScale;
320                 this.updateTransform();
321             }
322             setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
323         }
324
325         resetZoom() {
326             this.content.classList.add(CSS_CLASSES.ZOOMING);
327             this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
328             if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
329                 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
330             }
331             this.scale = this.zoomLevels[this.currentZoomIndex];
332             // Use requestAnimationFrame to ensure layout is stable before centering
333             requestAnimationFrame(() => {
334                 this.centerDiagram();
335                 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
336             });
337         }
338
339         async copyCode() {
340             try {
341                 await navigator.clipboard.writeText(this.mermaidCode);
342                 this.showNotification('Copied!');
343             } catch (_error) {
344                 // Fallback for older browsers or if clipboard API fails
345                 const textArea = document.createElement('textarea');
346                 textArea.value = this.mermaidCode;
347                 // Style to make it invisible
348                 textArea.style.position = 'fixed';
349                 textArea.style.top = '-9999px';
350                 textArea.style.left = '-9999px';
351                 document.body.appendChild(textArea);
352                 textArea.select();
353                 try {
354                     document.execCommand('copy');
355                     this.showNotification('Copied!');
356                 } catch (copyError) {
357                     console.error('Fallback copy failed:', copyError);
358                     this.showNotification('Copy failed.', true); // Error
359                 }
360                 document.body.removeChild(textArea);
361             }
362         }
363
364         showNotification(message, isError = false) {
365             if (window.$events) {
366                 const eventName = isError ? 'error' : 'success';
367                 window.$events.emit(eventName, message);
368             } else {
369                 // Fallback for if the event system is not available
370                 console.warn('BookStack event system not found, falling back to console log for notification.');
371                 if (isError) {
372                     console.error(message);
373                 } else {
374                     console.log(message);
375                 }
376             }
377         }
378
379         destroy() {
380             // Remove event listeners specific to this instance
381             this.toggleInteractionBtn.removeEventListener('click', this.boundToggleInteraction);
382             this.copyCodeBtn.removeEventListener('click', this.boundCopyCode);
383             this.zoomInBtn.removeEventListener('click', this.boundZoomIn);
384             this.zoomOutBtn.removeEventListener('click', this.boundZoomOut);
385             this.zoomResetBtn.removeEventListener('click', this.boundResetZoom);
386
387             this.viewport.removeEventListener('wheel', this.boundHandleWheel, { passive: false });
388             this.viewport.removeEventListener('mousedown', this.boundHandleMouseDown);
389             this.viewport.removeEventListener('contextmenu', this.boundPreventDefault);
390             this.viewport.removeEventListener('selectstart', this.boundPreventSelect);
391
392             document.removeEventListener('mousemove', this.boundMouseMoveHandler);
393             window.removeEventListener('mouseup', this.boundMouseUpHandler, true);
394
395             this.container.innerHTML = ''; // Clear the container's content
396         }
397     }
398
399     const mermaidViewers = [];
400     function initializeMermaidViewers() {
401         // Adjust the selector if your CMS wraps mermaid code blocks differently
402         const codeBlocks = document.querySelectorAll('pre code.language-mermaid');
403         for (const codeBlock of codeBlocks) {
404             // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
405             if (codeBlock.dataset.mermaidViewerInitialized) continue;
406
407             const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
408             const container = document.createElement('div');
409             container.className = CSS_CLASSES.CONTAINER;
410
411             const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
412
413             // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
414             if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
415
416             replaceTarget.after(container);
417             replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
418
419             const viewer = new InteractiveMermaidViewer(container, mermaidCode);
420             mermaidViewers.push(viewer);
421             codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
422         }
423     }
424
425     // Initialize on DOMContentLoaded
426     if (document.readyState === 'loading') {
427         document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
428     } else {
429         // DOMContentLoaded has already fired
430         initializeMermaidViewers();
431     }
432
433     // Re-center diagrams on window load, as images/fonts inside SVG might affect size
434     window.addEventListener('load', () => {
435         mermaidViewers.forEach(viewer => {
436             // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
437             setTimeout(() => viewer.centerDiagram(), 100);
438         });
439     });
440
441     // Optional: If your CMS dynamically adds content, you might need a way to re-run initialization
442     // For example, using a MutationObserver or a custom event.
443     // document.addEventListener('myCMSContentLoaded', () => initializeMermaidViewers());
444
445 </script>
446 <style>
447     /* Use BookStack's CSS variables for seamless theme integration */
448     .mermaid-container {
449         background: var(--color-bg-alt);
450         border: 1px solid #d0d7de;
451         border-radius: 6px;
452         position: relative;
453         margin: 20px 0;
454     }
455
456     .mermaid-viewport {
457         height: 100%;
458         /* This will now be 100% of the dynamically set container height */
459         overflow: hidden;
460         /* Keep this for panning/zooming when content exceeds viewport */
461         cursor: auto;
462         /* Default to normal system cursor */
463     }
464
465     /* Ensure viewport cursor is auto when locked, even if active.
466            The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */
467     .mermaid-viewport:not(.interaction-enabled):active {
468         cursor: auto;
469     }
470
471     /* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */
472     .mermaid-viewport.interactive-hover {
473         cursor: grab;
474     }
475
476     /* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */
477     .mermaid-viewport.interactive-pan {
478         cursor: grabbing !important;
479     }
480
481     .mermaid-content {
482         transform-origin: 0 0;
483         /* Allow text selection by default (when interaction is locked) */
484         user-select: auto;
485         /* or 'text' */
486         will-change: transform;
487     }
488
489     /* Disable text selection ONLY when interaction is enabled on the viewport */
490     .mermaid-viewport.interaction-enabled .mermaid-content {
491         user-select: none;
492     }
493
494     /* SVG elements inherit cursor from the viewport when interaction is enabled. */
495     .mermaid-viewport.interaction-enabled .mermaid-content svg,
496     .mermaid-viewport.interaction-enabled .mermaid-content svg * {
497         cursor: inherit !important;
498         /* Force inheritance from the viewport's cursor */
499     }
500
501     .mermaid-content.zooming {
502         transition: transform 0.2s ease;
503     }
504
505     .mermaid-controls {
506         position: absolute;
507         top: 10px;
508         right: 10px;
509         display: flex;
510         gap: 5px;
511         z-index: 10;
512     }
513
514     .mermaid-viewer-button-base {
515         border: 1px solid #d0d7de;
516         border-radius: 6px;
517         cursor: pointer;
518         display: flex;
519         align-items: center;
520         justify-content: center;
521         user-select: none;
522         background: var(--color-bg);
523         width: 32px;
524         height: 32px;
525         color: var(--color-text);
526     }
527
528     .mermaid-viewer-button-base:hover {
529         background: #f6f8fa;
530     }
531
532     /* Override for pure white icons in dark mode */
533     .dark-mode .mermaid-viewer-button-base {
534         color: #fff;
535     }
536
537     .mermaid-zoom-controls {
538         position: absolute;
539         bottom: 10px;
540         left: 10px;
541         display: flex;
542         flex-direction: column;
543         gap: 5px;
544         z-index: 10;
545     }
546 </style>