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