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