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