]> BookStack Code Mirror - hacks/commitdiff
feat: Add interactive Mermaid diagram viewer
authorAlexander Wilms <redacted>
Sat, 14 Jun 2025 16:28:54 +0000 (18:28 +0200)
committerAlexander-Wilms <redacted>
Tue, 24 Jun 2025 19:03:48 +0000 (21:03 +0200)
content/mermaid-viewer/head.html [new file with mode: 0644]
content/mermaid-viewer/index.md [new file with mode: 0644]

diff --git a/content/mermaid-viewer/head.html b/content/mermaid-viewer/head.html
new file mode 100644 (file)
index 0000000..2470231
--- /dev/null
@@ -0,0 +1,555 @@
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
+    integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
+    crossorigin="anonymous" referrerpolicy="no-referrer" />
+<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
+<script type="module">
+    mermaid.initialize({
+        startOnLoad: false,
+        securityLevel: 'loose',
+        theme: 'default'
+    });
+
+    // Zoom Level Configuration
+    const ZOOM_LEVEL_MIN = 0.5;
+    const ZOOM_LEVEL_MAX = 2.0;
+    const ZOOM_LEVEL_INCREMENT = 0.1;
+    const DEFAULT_ZOOM_SCALE = 1.0;
+
+    const DRAG_THRESHOLD_PIXELS = 3;
+    const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
+    const NOTIFICATION_DISPLAY_TIMEOUT_MS = 2000;
+
+    const CSS_CLASSES = {
+        CONTAINER: 'mermaid-container',
+        VIEWPORT: 'mermaid-viewport',
+        CONTENT: 'mermaid-content',
+        DIAGRAM: 'mermaid-diagram',
+        CONTROLS: 'mermaid-controls',
+        ZOOM_CONTROLS: 'mermaid-zoom-controls',
+        INTERACTION_ENABLED: 'interaction-enabled',
+        DRAGGING: 'dragging',
+        ZOOMING: 'zooming',
+        LOCK_ICON: 'fa fa-lock',
+        UNLOCK_ICON: 'fa fa-unlock',
+        INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state
+        INTERACTIVE_PAN: 'interactive-pan',     // Class for 'grabbing' cursor state
+        BUTTON_BASE: 'mermaid-viewer-button-base', // Base class for all viewer buttons
+        NOTIFICATION: 'mermaid-notification', // Keep existing notification classes
+        NOTIFICATION_SHOW: 'show' // Keep existing notification classes
+    };
+
+    class InteractiveMermaidViewer {
+        constructor(container, mermaidCode) {
+            this.container = container;
+            this.mermaidCode = mermaidCode;
+            this.scale = 1.0;
+            this.translateX = 0;
+            this.translateY = 0;
+            this.isDragging = false;
+            this.dragStarted = false;
+            this.startX = 0;
+            this.startY = 0;
+
+            const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
+            this.zoomLevels = Array.from(
+                { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
+                (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
+            );
+
+            this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
+            if (this.currentZoomIndex === -1) {
+                this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
+            }
+            this.interactionEnabled = false;
+            this.initialContentOffset = { x: 0, y: 0 };
+
+            // Cache DOM elements
+            this.toggleInteractionBtn = null;
+            this.copyCodeBtn = null;
+            this.zoomInBtn = null;
+            this.zoomOutBtn = null;
+            this.zoomResetBtn = null;
+
+            this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
+            this.boundMouseUpHandler = this.handleMouseUp.bind(this);
+            this.setupViewer();
+            this.setupEventListeners();
+        }
+
+        setupViewer() {
+            this.container.innerHTML = `
+                <div class="${CSS_CLASSES.CONTROLS}">
+                    <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn toggle-interaction" title="Toggle interaction">
+                        <i class="${CSS_CLASSES.LOCK_ICON}" aria-hidden="true"></i>
+                    </div>
+                    <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn" title="Copy code">
+                        <i class="fa fa-copy" aria-hidden="true"></i>
+                    </div>
+                </div>
+                <div class="${CSS_CLASSES.ZOOM_CONTROLS}">
+                    <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>
+                    <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>
+                    <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-reset" title="Reset"><i class="fa fa-refresh" aria-hidden="true"></i></div>
+                </div>
+                <div class="${CSS_CLASSES.VIEWPORT}">
+                    <div class="${CSS_CLASSES.CONTENT}">
+                        <div class="${CSS_CLASSES.DIAGRAM}">${this.mermaidCode}</div>
+                    </div>
+                </div>
+            `;
+            this.viewport = this.container.querySelector(`.${CSS_CLASSES.VIEWPORT}`);
+            this.content = this.container.querySelector(`.${CSS_CLASSES.CONTENT}`);
+            this.diagram = this.container.querySelector(`.${CSS_CLASSES.DIAGRAM}`);
+
+            // Cache control elements
+            this.toggleInteractionBtn = this.container.querySelector('.toggle-interaction');
+            this.copyCodeBtn = this.container.querySelector('.mermaid-btn:not(.toggle-interaction)');
+            this.zoomInBtn = this.container.querySelector('.zoom-in');
+            this.zoomOutBtn = this.container.querySelector('.zoom-out');
+            this.zoomResetBtn = this.container.querySelector('.zoom-reset');
+
+            // Function to render the diagram and perform post-render setup
+            const renderAndSetup = () => {
+                mermaid.run({ nodes: [this.diagram] }).then(() => {
+                    this.adjustContainerHeight();
+                    this.calculateInitialOffset();
+                    this.centerDiagram();
+                }).catch(error => {
+                    console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
+                    this.diagram.innerHTML = `<p style="color: red; padding: 10px;">Error rendering diagram. Check console.</p>`;
+                });
+            };
+
+            // Check if Font Awesome is loaded before rendering
+            // This checks for the 'Font Awesome 6 Free' font family, which is common.
+            // Adjust if your Font Awesome version uses a different family name for its core icons.
+            if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
+                renderAndSetup();
+            } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
+                document.fonts.ready.then(renderAndSetup).catch(err => {
+                    renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
+                });
+            } else {
+                renderAndSetup();
+            }
+        }
+
+        adjustContainerHeight() {
+            const svgElement = this.content.querySelector('svg');
+            if (svgElement) {
+                // Ensure the viewport takes up the height of the rendered SVG
+                this.viewport.style.height = '100%';
+            }
+        }
+
+        calculateInitialOffset() {
+            const originalTransform = this.content.style.transform;
+            this.content.style.transform = '';
+            const contentRect = this.content.getBoundingClientRect();
+            const viewportRect = this.viewport.getBoundingClientRect();
+            this.initialContentOffset.x = contentRect.left - viewportRect.left;
+            this.initialContentOffset.y = contentRect.top - viewportRect.top;
+            this.content.style.transform = originalTransform;
+        }
+
+        _getViewportCenterClientCoords() {
+            const viewportRect = this.viewport.getBoundingClientRect();
+            return {
+                clientX: viewportRect.left + viewportRect.width / 2,
+                clientY: viewportRect.top + viewportRect.height / 2,
+            };
+        }
+
+        setupEventListeners() {
+            this.toggleInteractionBtn.addEventListener('click', () => this.toggleInteraction());
+            this.copyCodeBtn.addEventListener('click', () => this.copyCode());
+            this.zoomInBtn.addEventListener('click', () => {
+                const { clientX, clientY } = this._getViewportCenterClientCoords();
+                this.zoom(1, clientX, clientY);
+            });
+            this.zoomOutBtn.addEventListener('click', () => {
+                const { clientX, clientY } = this._getViewportCenterClientCoords();
+                this.zoom(-1, clientX, clientY);
+            });
+            this.zoomResetBtn.addEventListener('click', () => this.resetZoom());
+
+            this.viewport.addEventListener('wheel', (e) => {
+                if (!this.interactionEnabled) return;
+                // Prevent default browser scroll/zoom behavior when wheeling over the diagram
+                e.preventDefault();
+                this.content.classList.add(CSS_CLASSES.ZOOMING);
+                const clientX = e.clientX;
+                const clientY = e.clientY;
+                if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
+                else this.zoom(1, clientX, clientY);
+                setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
+            }, { passive: false });
+
+            this.viewport.addEventListener('mousedown', (e) => {
+                if (!this.interactionEnabled || e.button !== 0) return;
+                e.preventDefault();
+                this.isDragging = true;
+                this.dragStarted = false;
+                this.startX = e.clientX;
+                this.startY = e.clientY;
+                this.dragBaseTranslateX = this.translateX;
+                this.dragBaseTranslateY = this.translateY;
+                this.viewport.classList.add(CSS_CLASSES.DRAGGING);
+                this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
+                this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
+                this.content.classList.remove(CSS_CLASSES.ZOOMING);
+            });
+
+            // Listen on document for mousemove to handle dragging outside viewport
+            document.addEventListener('mousemove', this.boundMouseMoveHandler);
+            // Listen on window for mouseup to ensure drag ends even if mouse is released outside
+            window.addEventListener('mouseup', this.boundMouseUpHandler, true); // Use capture phase
+
+            this.viewport.addEventListener('contextmenu', (e) => e.preventDefault());
+            this.viewport.addEventListener('selectstart', (e) => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); });
+        }
+
+        toggleInteraction() {
+            this.interactionEnabled = !this.interactionEnabled;
+            const icon = this.toggleInteractionBtn.querySelector('i');
+            this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
+
+            if (this.interactionEnabled) {
+                icon.className = CSS_CLASSES.UNLOCK_ICON;
+                this.toggleInteractionBtn.title = 'Disable manual interaction';
+                this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
+                this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
+                this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
+            } else {
+                icon.className = CSS_CLASSES.LOCK_ICON;
+                this.toggleInteractionBtn.title = 'Enable manual interaction';
+                this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
+                this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
+                this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
+                this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
+                this.dragStarted = false;
+                this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
+            }
+        }
+
+        updateTransform() {
+            this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
+        }
+
+        handleMouseMove(e) {
+            if (!this.isDragging) return;
+            // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
+            const deltaX = e.clientX - this.startX;
+            const deltaY = e.clientY - this.startY;
+            if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
+                this.dragStarted = true;
+            }
+            if (this.dragStarted) {
+                e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
+                this.translateX = this.dragBaseTranslateX + deltaX;
+                this.translateY = this.dragBaseTranslateY + deltaY;
+                this.updateTransform();
+            }
+        }
+
+        handleMouseUp() {
+            if (this.isDragging) {
+                this.isDragging = false;
+                this.dragStarted = false;
+                this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
+                this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
+                if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
+                    this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
+                }
+            }
+            this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
+        }
+
+        centerDiagram() {
+            const svgElement = this.content.querySelector('svg');
+            if (svgElement) {
+                const viewportRect = this.viewport.getBoundingClientRect();
+                const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
+                const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
+
+                const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
+                const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
+
+                this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
+                this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
+
+                // Initial centering constraints; may need adjustment for very large diagrams.
+                this.translateX = Math.max(0, this.translateX);
+                this.translateY = Math.max(0, this.translateY);
+
+                this.updateTransform();
+            }
+        }
+
+        zoom(direction, clientX, clientY) {
+            this.content.classList.add(CSS_CLASSES.ZOOMING);
+            const oldScale = this.scale;
+            let newZoomIndex = this.currentZoomIndex + direction;
+
+            if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
+                this.currentZoomIndex = newZoomIndex;
+                const newScale = this.zoomLevels[this.currentZoomIndex];
+
+                const viewportRect = this.viewport.getBoundingClientRect();
+                const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
+                const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
+
+                this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
+                this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
+                this.scale = newScale;
+                this.updateTransform();
+            }
+            setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
+        }
+
+        resetZoom() {
+            this.content.classList.add(CSS_CLASSES.ZOOMING);
+            this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
+            if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
+                this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
+            }
+            this.scale = this.zoomLevels[this.currentZoomIndex];
+            // Use requestAnimationFrame to ensure layout is stable before centering
+            requestAnimationFrame(() => {
+                this.centerDiagram();
+                setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
+            });
+        }
+
+        async copyCode() {
+            try {
+                await navigator.clipboard.writeText(this.mermaidCode);
+                this.showNotification('Copied!');
+            } catch (_error) {
+                // Fallback for older browsers or if clipboard API fails
+                const textArea = document.createElement('textarea');
+                textArea.value = this.mermaidCode;
+                // Style to make it invisible
+                textArea.style.position = 'fixed';
+                textArea.style.top = '-9999px';
+                textArea.style.left = '-9999px';
+                document.body.appendChild(textArea);
+                textArea.select();
+                try {
+                    document.execCommand('copy');
+                    this.showNotification('Copied!');
+                } catch (copyError) {
+                    console.error('Fallback copy failed:', copyError);
+                    this.showNotification('Copy failed.', true); // Error
+                }
+                document.body.removeChild(textArea);
+            }
+        }
+
+        showNotification(message, isError = false) {
+            let notification = document.querySelector(`.${CSS_CLASSES.NOTIFICATION}`);
+            if (!notification) {
+                notification = document.createElement('div');
+                notification.className = CSS_CLASSES.NOTIFICATION;
+                document.body.appendChild(notification);
+            }
+            notification.innerHTML = `<i class="fa ${isError ? 'fa-times-circle' : 'fa-check'}"></i> ${message}`;
+            notification.style.background = isError ? '#dc3545' : '#28a745'; // Red for error, green for success
+            // Ensure it's visible before triggering transition
+            notification.style.transform = 'translateX(400px)'; // Reset if previously shown
+            requestAnimationFrame(() => { // Allow repaint
+                notification.classList.add(CSS_CLASSES.NOTIFICATION_SHOW);
+            });
+
+            // Clear any existing timeout to prevent premature hiding if clicked again
+            if (this.notificationTimeout) {
+                clearTimeout(this.notificationTimeout);
+            }
+            this.notificationTimeout = setTimeout(() => {
+                notification.classList.remove(CSS_CLASSES.NOTIFICATION_SHOW);
+                this.notificationTimeout = null;
+            }, NOTIFICATION_DISPLAY_TIMEOUT_MS);
+        }
+
+        destroy() {
+            // Remove event listeners specific to this instance
+            this.toggleInteractionBtn.removeEventListener('click', this.toggleInteraction); // Need to ensure this is the same function reference
+            this.copyCodeBtn.removeEventListener('click', this.copyCode);
+            this.zoomInBtn.removeEventListener('click', this.zoom); // These would need bound versions or careful handling
+            this.zoomOutBtn.removeEventListener('click', this.zoom);
+            this.zoomResetBtn.removeEventListener('click', this.resetZoom);
+
+            this.viewport.removeEventListener('wheel', this.handleWheel); // Assuming handleWheel is the actual handler
+            this.viewport.removeEventListener('mousedown', this.handleMouseDown); // Assuming handleMouseDown
+            this.viewport.removeEventListener('contextmenu', this.handleContextMenu);
+            this.viewport.removeEventListener('selectstart', this.handleSelectStart);
+
+            document.removeEventListener('mousemove', this.boundMouseMoveHandler);
+            window.removeEventListener('mouseup', this.boundMouseUpHandler, true);
+
+            if (this.notificationTimeout) {
+                clearTimeout(this.notificationTimeout);
+            }
+            this.container.innerHTML = ''; // Clear the container's content
+        }
+    }
+
+    const mermaidViewers = [];
+    function initializeMermaidViewers() {
+        // Adjust the selector if your CMS wraps mermaid code blocks differently
+        const codeBlocks = document.querySelectorAll('pre code.language-mermaid');
+        for (const codeBlock of codeBlocks) {
+            // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
+            if (codeBlock.dataset.mermaidViewerInitialized) continue;
+
+            const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
+            const container = document.createElement('div');
+            container.className = CSS_CLASSES.CONTAINER;
+
+            const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
+
+            // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
+            if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
+
+            replaceTarget.after(container);
+            replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
+
+            const viewer = new InteractiveMermaidViewer(container, mermaidCode);
+            mermaidViewers.push(viewer);
+            codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
+        }
+    }
+
+    // Initialize on DOMContentLoaded
+    if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
+    } else {
+        // DOMContentLoaded has already fired
+        initializeMermaidViewers();
+    }
+
+    // Re-center diagrams on window load, as images/fonts inside SVG might affect size
+    window.addEventListener('load', () => {
+        mermaidViewers.forEach(viewer => {
+            // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
+            setTimeout(() => viewer.centerDiagram(), 100);
+        });
+    });
+
+    // Optional: If your CMS dynamically adds content, you might need a way to re-run initialization
+    // For example, using a MutationObserver or a custom event.
+    // document.addEventListener('myCMSContentLoaded', () => initializeMermaidViewers());
+
+</script>
+<style>
+    .mermaid-container {
+        background: white;
+        border: 1px solid #d0d7de;
+        border-radius: 6px;
+        position: relative;
+        margin: 20px 0;
+    }
+
+    .mermaid-viewport {
+        height: 100%;
+        /* This will now be 100% of the dynamically set container height */
+        overflow: hidden;
+        /* Keep this for panning/zooming when content exceeds viewport */
+        cursor: auto;
+        /* Default to normal system cursor */
+    }
+
+    /* Ensure viewport cursor is auto when locked, even if active.
+           The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */
+    .mermaid-viewport:not(.interaction-enabled):active {
+        cursor: auto;
+    }
+
+    /* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */
+    .mermaid-viewport.interactive-hover {
+        cursor: grab;
+    }
+
+    /* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */
+    .mermaid-viewport.interactive-pan {
+        cursor: grabbing !important;
+    }
+
+    .mermaid-content {
+        transform-origin: 0 0;
+        /* Allow text selection by default (when interaction is locked) */
+        user-select: auto;
+        /* or 'text' */
+        will-change: transform;
+    }
+
+    /* Disable text selection ONLY when interaction is enabled on the viewport */
+    .mermaid-viewport.interaction-enabled .mermaid-content {
+        user-select: none;
+    }
+
+    /* SVG elements inherit cursor from the viewport when interaction is enabled. */
+    .mermaid-viewport.interaction-enabled .mermaid-content svg,
+    .mermaid-viewport.interaction-enabled .mermaid-content svg * {
+        cursor: inherit !important;
+        /* Force inheritance from the viewport's cursor */
+    }
+
+    .mermaid-content.zooming {
+        transition: transform 0.2s ease;
+    }
+
+    .mermaid-controls {
+        position: absolute;
+        top: 10px;
+        right: 10px;
+        display: flex;
+        gap: 5px;
+        z-index: 10;
+    }
+
+    .mermaid-viewer-button-base {
+        border: 1px solid #d0d7de;
+        border-radius: 6px;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        user-select: none;
+        background: rgba(255, 255, 255, 0.9);
+        width: 32px;
+        height: 32px;
+        color: #24292f;
+    }
+
+    .mermaid-viewer-button-base:hover {
+        background: #f6f8fa;
+    }
+
+    .mermaid-zoom-controls {
+        position: absolute;
+        bottom: 10px;
+        left: 10px;
+        display: flex;
+        flex-direction: column;
+        gap: 5px;
+        z-index: 10;
+    }
+
+    .mermaid-notification {
+        position: fixed;
+        top: 20px;
+        right: 20px;
+        background: #28a745;
+        color: white;
+        padding: 12px 16px;
+        border-radius: 6px;
+        transform: translateX(400px);
+        transition: transform 0.3s ease;
+        z-index: 1000;
+    }
+
+    .mermaid-notification.show {
+        transform: translateX(0);
+    }
+</style>
\ No newline at end of file
diff --git a/content/mermaid-viewer/index.md b/content/mermaid-viewer/index.md
new file mode 100644 (file)
index 0000000..754fdb4
--- /dev/null
@@ -0,0 +1,40 @@
++++
+title = "Mermaid viewer"
+author = "@Alexander-Wilms"
+date = 2024-07-16T00:00:00Z
+updated = 2024-07-16T00:00:00Z
+tested = "v25.05"
++++
+
+This hack enables interactive Mermaid diagrams to be rendered within a page on BookStack. The Mermaid code itself can be written and edited using either BookStack's WYSIWYG editor by creating a code block and assigning it the language "mermaid" or the Markdown editor (using standard ` ```mermaid ... ``` ` code fences).
+It automatically detects `pre code` blocks with the class `language-mermaid` and replaces them with a feature-rich viewer:
+
+````markdown
+```mermaid
+flowchart TD
+    A[Christmas] -->|Get money| B(Go shopping)
+    B --> C{Let me think}
+    C -->|One| D[Laptop]
+    C -->|Two| E[iPhone]
+    C -->|Three| F[fa:fa-car Car]
+```
+````
+
+The viewer provides the following functionalities:
+- **Pan and Zoom:** Pan the diagram by clicking and dragging, zoom using the mouse wheel or use dedicated buttons for both.
+- **Interaction Toggle:** Toggle manual interaction (pan/zoom) on or off using a lock/unlock button so it doesn't interfere with scrolling the page.
+- **Reset Zoom:** Reset the diagram to its default zoom level and centered position.
+- **Copy Code:** Copy the underlying Mermaid code to the clipboard.
+- **Font Awesome Icons:** Use Font Awesome icons directly within the Mermaid diagram syntax.
+- **Text selection:** When interaction is disabled, text can be selected and copied.
+
+#### Considerations
+
+- This relies on JavaScript to parse and render content on page load.
+- This loads the Mermaid.js library from `cdn.jsdelivr.net` and Font Awesome icons from `cdnjs.cloudflare.com`.
+- There is no live preview of the *interactive viewer features* while editing the page content.
+- The viewer injects its own CSS for styling. While designed to be self-contained, there's a minor possibility of style interactions with highly customized BookStack themes.
+
+#### Code
+
+{{<hack file="head.html" type="head">}}