]> BookStack Code Mirror - hacks/commitdiff
Reviewed mermaid viewer hack main
authorDan Brown <redacted>
Fri, 27 Jun 2025 13:54:59 +0000 (14:54 +0100)
committerDan Brown <redacted>
Fri, 27 Jun 2025 13:54:59 +0000 (14:54 +0100)
Modified with the changes:
- Split out files to make use of the public files system, and so it's
  not a massive chunk of head content loaded on all pages.
- Added a little extra size control during load to reduce bouncing.
- Updated code block targeting just to avoid potention editor conflicts.
- Removed some non-existing CSS variable usages.
- Tweaked hack wording/guidance a little.

content/mermaid-viewer/head.html [deleted file]
content/mermaid-viewer/index.md
content/mermaid-viewer/layouts/parts/base-body-start.blade.php [new file with mode: 0644]
content/mermaid-viewer/public/mermaid-viewer.css [new file with mode: 0644]
content/mermaid-viewer/public/mermaid-viewer.js [new file with mode: 0644]

diff --git a/content/mermaid-viewer/head.html b/content/mermaid-viewer/head.html
deleted file mode 100644 (file)
index 7785707..0000000
+++ /dev/null
@@ -1,562 +0,0 @@
-<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://cdnjs.cloudflare.com/ajax/libs/mermaid/11.7.0/mermaid.min.js"
-    integrity="sha512-ecc+vlmmc1f51s2l/AeIC552wULnv9Q8bYJ4FbODxsL6jGrFoLaKnGkN5JUZNH6LBjkAYy9Q4fKqyTuFUIvvFA=="
-    crossorigin="anonymous" referrerpolicy="no-referrer"></script>
-<script type="module">
-    // Detect if BookStack's dark mode is enabled
-    const isDarkMode = document.documentElement.classList.contains('dark-mode');
-
-    // Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
-    mermaid.initialize({
-        startOnLoad: false,
-        securityLevel: 'loose',
-        theme: isDarkMode ? 'dark' : '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 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
-    };
-
-    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;
-
-            // Use an AbortController for robust event listener cleanup.
-            this.abortController = new AbortController();
-
-            // Bind event handlers for proper addition and removal
-            this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
-            this.boundMouseUpHandler = this.handleMouseUp.bind(this);
-            this.boundToggleInteraction = this.toggleInteraction.bind(this);
-            this.boundCopyCode = this.copyCode.bind(this);
-            this.boundZoomIn = this.handleZoomClick.bind(this, 1);
-            this.boundZoomOut = this.handleZoomClick.bind(this, -1);
-            this.boundResetZoom = this.resetZoom.bind(this);
-            this.boundHandleWheel = this.handleWheel.bind(this);
-            this.boundHandleMouseDown = this.handleMouseDown.bind(this);
-            this.boundPreventDefault = e => e.preventDefault();
-            this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
-
-            this.setupViewer();
-            this.setupEventListeners();
-        }
-
-        /**
-         * Creates the DOM structure for the viewer programmatically.
-         * This is safer and more maintainable than using innerHTML with a large template string.
-         */
-        setupViewer() {
-            const createButton = (title, iconClass, ...extraClasses) => {
-                const button = document.createElement('div');
-                button.setAttribute('role', 'button');
-                button.setAttribute('tabindex', '0');
-                button.className = `${CSS_CLASSES.BUTTON_BASE} ${extraClasses.join(' ')}`;
-                button.title = title;
-                const icon = document.createElement('i');
-                icon.className = iconClass;
-                icon.setAttribute('aria-hidden', 'true');
-                button.append(icon);
-                return button;
-            };
-
-            const controls = document.createElement('div');
-            controls.className = CSS_CLASSES.CONTROLS;
-            this.toggleInteractionBtn = createButton('Toggle interaction', CSS_CLASSES.LOCK_ICON, 'mermaid-btn', 'toggle-interaction');
-            this.copyCodeBtn = createButton('Copy code', 'fa fa-copy', 'mermaid-btn');
-            controls.append(this.toggleInteractionBtn, this.copyCodeBtn);
-
-            const zoomControls = document.createElement('div');
-            zoomControls.className = CSS_CLASSES.ZOOM_CONTROLS;
-            this.zoomInBtn = createButton('Zoom in', 'fa fa-search-plus', 'mermaid-zoom-btn', 'zoom-in');
-            this.zoomOutBtn = createButton('Zoom out', 'fa fa-search-minus', 'mermaid-zoom-btn', 'zoom-out');
-            this.zoomResetBtn = createButton('Reset', 'fa fa-refresh', 'mermaid-zoom-btn', 'zoom-reset');
-            zoomControls.append(this.zoomInBtn, this.zoomOutBtn, this.zoomResetBtn);
-
-            this.diagram = document.createElement('div');
-            this.diagram.className = CSS_CLASSES.DIAGRAM;
-            // Use textContent for security, preventing any potential HTML injection.
-            // Mermaid will parse the text content safely.
-            this.diagram.textContent = this.mermaidCode;
-
-            this.content = document.createElement('div');
-            this.content.className = CSS_CLASSES.CONTENT;
-            this.content.append(this.diagram);
-
-            this.viewport = document.createElement('div');
-            this.viewport.className = CSS_CLASSES.VIEWPORT;
-            this.viewport.append(this.content);
-
-            // Clear the container and append the new structure
-            this.container.innerHTML = '';
-            this.container.append(controls, zoomControls, this.viewport);
-
-            // 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);
-                    // Use BookStack's negative color variable and provide a clearer message for debugging.
-                    this.diagram.innerHTML = `<p style="color: var(--color-neg); padding: 10px;">Error rendering diagram. Check browser console for details.</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() {
-            const { signal } = this.abortController;
-
-            this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction, { signal });
-            this.copyCodeBtn.addEventListener('click', this.boundCopyCode, { signal });
-            this.zoomInBtn.addEventListener('click', this.boundZoomIn, { signal });
-            this.zoomOutBtn.addEventListener('click', this.boundZoomOut, { signal });
-            this.zoomResetBtn.addEventListener('click', this.boundResetZoom, { signal });
-
-            this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false, signal });
-            this.viewport.addEventListener('mousedown', this.boundHandleMouseDown, { signal });
-
-            // Listen on document for mousemove to handle dragging outside viewport
-            document.addEventListener('mousemove', this.boundMouseMoveHandler, { signal });
-            // Listen on window for mouseup to ensure drag ends even if mouse is released outside
-            window.addEventListener('mouseup', this.boundMouseUpHandler, { signal, capture: true });
-
-            this.viewport.addEventListener('contextmenu', this.boundPreventDefault, { signal });
-            this.viewport.addEventListener('selectstart', this.boundPreventSelect, { signal });
-        }
-
-        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})`;
-        }
-
-        handleZoomClick(direction) {
-            const { clientX, clientY } = this._getViewportCenterClientCoords();
-            this.zoom(direction, clientX, clientY);
-        }
-
-        handleWheel(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);
-        }
-
-        handleMouseDown(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);
-        }
-
-        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
-                console.error('Clipboard API copy failed, attempting fallback:', error);
-                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) {
-            if (window.$events) {
-                const eventName = isError ? 'error' : 'success';
-                window.$events.emit(eventName, message);
-            } else {
-                // Fallback for if the event system is not available
-                console.warn('BookStack event system not found, falling back to console log for notification.');
-                if (isError) {
-                    console.error(message);
-                } else {
-                    console.log(message);
-                }
-            }
-        }
-
-        destroy() {
-            // Abort all listeners attached with this controller's signal.
-            this.abortController.abort();
-            this.container.innerHTML = ''; // Clear the container's content
-        }
-    }
-
-    const mermaidViewers = [];
-    function initializeMermaidViewers() {
-        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();
-    }
-
-    const recenterAllViewers = () => mermaidViewers.forEach(viewer => viewer.centerDiagram());
-
-    // Re-center diagrams on window load and window resize, as images/fonts inside SVG might affect size
-    window.addEventListener('load', () => {
-        // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
-        setTimeout(recenterAllViewers, 100);
-    });
-
-    window.addEventListener('resize', recenterAllViewers);
-
-</script>
-<style>
-    /* Use BookStack's CSS variables for seamless theme integration */
-    .mermaid-container {
-        background: var(--color-bg-alt);
-        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 #C0C0C0;
-        border-radius: 6px;
-        cursor: pointer;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        user-select: none;
-        width: 32px;
-        height: 32px;
-        color: var(--color-text);
-        /* The above color is overridden in dark mode below to ensure visibility */
-    }
-
-    .mermaid-viewer-button-base:hover {
-        background: #C8C8C8;
-    }
-
-    .dark-mode .mermaid-viewer-button-base {
-        background: #282828;
-        border: 1px solid #444444;
-        color: #FFFFFF;
-        /* Explicitly set to white for dark mode icons */
-    }
-
-    .dark-mode .mermaid-viewer-button-base:hover {
-        background: #383838;
-    }
-
-    .mermaid-zoom-controls {
-        position: absolute;
-        bottom: 10px;
-        left: 10px;
-        display: flex;
-        flex-direction: column;
-        gap: 5px;
-        z-index: 10;
-    }
-</style>
\ No newline at end of file
index 754fdb4345b2589af224b5fa9102902cbc2a6b3f..3001c89796403f4a786254b1121ae7cacdecf717 100644 (file)
@@ -1,13 +1,12 @@
 +++
-title = "Mermaid viewer"
+title = "Mermaid Viewer"
 author = "@Alexander-Wilms"
-date = 2024-07-16T00:00:00Z
-updated = 2024-07-16T00:00:00Z
-tested = "v25.05"
+date = 2025-06-27T00:00:00Z
+updated = 2025-06-27T00:00:00Z
+tested = "v25.05.1"
 +++
 
-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:
+This hack enables interactive Mermaid diagrams to be rendered within a page on BookStack. The Mermaid diagram code itself can be written & edited using either BookStack's WYSIWYG editor, by creating a code block and assigning it the language "mermaid", or via the Markdown editor using standard `mermaid` code fences like so:
 
 ````markdown
 ```mermaid
@@ -20,7 +19,8 @@ flowchart TD
 ```
 ````
 
-The viewer provides the following functionalities:
+On page view, the hack will replace these "mermaid" code blocks with a feature-rich viewer. 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.
@@ -31,10 +31,16 @@ The viewer provides the following functionalities:
 #### 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`.
+- This loads both the Mermaid JavaScript library and Font Awesome icons from external Cloudflare based CDN URLs.
+- Diagrams will not be rendered when using system export options.
 - 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.
+- Since the viewer is rendered after page load, it may result in extra visual jumping of page content during page load.
 
 #### Code
 
-{{<hack file="head.html" type="head">}}
+This hack makes use of [publicly accessible files](https://github.com/BookStackApp/BookStack/blob/development/dev/docs/visual-theme-system.md#publicly-accessible-files) via the visual theme system so that the required files are only loaded in when potentially required:
+
+{{<hack file="layouts/parts/base-body-start.blade.php" type="visual">}}
+{{<hack file="public/mermaid-viewer.css" type="visual">}}
+{{<hack file="public/mermaid-viewer.js" type="visual">}}
diff --git a/content/mermaid-viewer/layouts/parts/base-body-start.blade.php b/content/mermaid-viewer/layouts/parts/base-body-start.blade.php
new file mode 100644 (file)
index 0000000..aa7a266
--- /dev/null
@@ -0,0 +1,13 @@
+{{-- Only include on page related views --}}
+@if(request()->fullUrlIs('*/page/*'))
+    {{--External Requirements--}}
+    <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://cdnjs.cloudflare.com/ajax/libs/mermaid/11.7.0/mermaid.min.js"
+            integrity="sha512-ecc+vlmmc1f51s2l/AeIC552wULnv9Q8bYJ4FbODxsL6jGrFoLaKnGkN5JUZNH6LBjkAYy9Q4fKqyTuFUIvvFA=="
+            crossorigin="anonymous" defer referrerpolicy="no-referrer" nonce="{{ $cspNonce ?? '' }}"></script>
+    {{--Use files from theme folder--}}
+    <link rel="stylesheet" href="{{ url('/theme/' . \BookStack\Facades\Theme::getTheme() . '/mermaid-viewer.css') }}">
+    <script src="{{ url('/theme/' . \BookStack\Facades\Theme::getTheme() . '/mermaid-viewer.js') }}" type="module" nonce="{{ $cspNonce ?? '' }}"></script>
+@endif
\ No newline at end of file
diff --git a/content/mermaid-viewer/public/mermaid-viewer.css b/content/mermaid-viewer/public/mermaid-viewer.css
new file mode 100644 (file)
index 0000000..1838c18
--- /dev/null
@@ -0,0 +1,103 @@
+/* Use BookStack's CSS variables for seamless theme integration */
+.mermaid-container {
+    border: 1px solid #d0d7de;
+    border-radius: 6px;
+    position: relative;
+    margin: 20px 0;
+    overflow: hidden;
+}
+
+.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 #C0C0C0;
+    border-radius: 6px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    user-select: none;
+    width: 32px;
+    height: 32px;
+}
+
+.mermaid-viewer-button-base:hover {
+    background: #C8C8C8;
+}
+
+.dark-mode .mermaid-viewer-button-base {
+    background: #282828;
+    border: 1px solid #444444;
+    color: #FFFFFF;
+    /* Explicitly set to white for dark mode icons */
+}
+
+.dark-mode .mermaid-viewer-button-base:hover {
+    background: #383838;
+}
+
+.mermaid-zoom-controls {
+    position: absolute;
+    bottom: 10px;
+    left: 10px;
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+    z-index: 10;
+}
\ No newline at end of file
diff --git a/content/mermaid-viewer/public/mermaid-viewer.js b/content/mermaid-viewer/public/mermaid-viewer.js
new file mode 100644 (file)
index 0000000..564f124
--- /dev/null
@@ -0,0 +1,452 @@
+// Detect if BookStack's dark mode is enabled
+const isDarkMode = document.documentElement.classList.contains('dark-mode');
+
+// Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
+mermaid.initialize({
+    startOnLoad: false,
+    securityLevel: 'loose',
+    theme: isDarkMode ? 'dark' : '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 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
+};
+
+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;
+
+        // Use an AbortController for robust event listener cleanup.
+        this.abortController = new AbortController();
+
+        // Bind event handlers for proper addition and removal
+        this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
+        this.boundMouseUpHandler = this.handleMouseUp.bind(this);
+        this.boundToggleInteraction = this.toggleInteraction.bind(this);
+        this.boundCopyCode = this.copyCode.bind(this);
+        this.boundZoomIn = this.handleZoomClick.bind(this, 1);
+        this.boundZoomOut = this.handleZoomClick.bind(this, -1);
+        this.boundResetZoom = this.resetZoom.bind(this);
+        this.boundHandleWheel = this.handleWheel.bind(this);
+        this.boundHandleMouseDown = this.handleMouseDown.bind(this);
+        this.boundPreventDefault = e => e.preventDefault();
+        this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
+
+        this.setupViewer();
+        this.setupEventListeners();
+    }
+
+    /**
+     * Creates the DOM structure for the viewer programmatically.
+     * This is safer and more maintainable than using innerHTML with a large template string.
+     */
+    setupViewer() {
+        const createButton = (title, iconClass, ...extraClasses) => {
+            const button = document.createElement('button');
+            button.type = 'button';
+            button.className = `${CSS_CLASSES.BUTTON_BASE} ${extraClasses.join(' ')}`;
+            button.title = title;
+            const icon = document.createElement('i');
+            icon.className = iconClass;
+            icon.setAttribute('aria-hidden', 'true');
+            button.append(icon);
+            return button;
+        };
+
+        const controls = document.createElement('div');
+        controls.className = CSS_CLASSES.CONTROLS;
+        this.toggleInteractionBtn = createButton('Toggle interaction', CSS_CLASSES.LOCK_ICON, 'mermaid-btn', 'toggle-interaction');
+        this.copyCodeBtn = createButton('Copy code', 'fa fa-copy', 'mermaid-btn');
+        controls.append(this.toggleInteractionBtn, this.copyCodeBtn);
+
+        const zoomControls = document.createElement('div');
+        zoomControls.className = CSS_CLASSES.ZOOM_CONTROLS;
+        this.zoomInBtn = createButton('Zoom in', 'fa fa-search-plus', 'mermaid-zoom-btn', 'zoom-in');
+        this.zoomOutBtn = createButton('Zoom out', 'fa fa-search-minus', 'mermaid-zoom-btn', 'zoom-out');
+        this.zoomResetBtn = createButton('Reset', 'fa fa-refresh', 'mermaid-zoom-btn', 'zoom-reset');
+        zoomControls.append(this.zoomInBtn, this.zoomOutBtn, this.zoomResetBtn);
+
+        this.diagram = document.createElement('div');
+        this.diagram.className = CSS_CLASSES.DIAGRAM;
+        // Use textContent for security, preventing any potential HTML injection.
+        // Mermaid will parse the text content safely.
+        this.diagram.textContent = this.mermaidCode;
+
+        this.content = document.createElement('div');
+        this.content.className = CSS_CLASSES.CONTENT;
+        this.content.append(this.diagram);
+
+        this.viewport = document.createElement('div');
+        this.viewport.className = CSS_CLASSES.VIEWPORT;
+        this.viewport.append(this.content);
+
+        // Clear the container and append the new structure
+        this.container.innerHTML = '';
+        this.container.append(controls, zoomControls, this.viewport);
+
+        // 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);
+                // Use BookStack's negative color variable and provide a clearer message for debugging.
+                this.diagram.innerHTML = `<p style="color: var(--color-neg); padding: 10px;">Error rendering diagram. Check browser console for details.</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%';
+        }
+
+        // Remove any set height on the container once the viewer has had a chance to render
+        window.requestAnimationFrame(() => {
+            this.container.style.removeProperty('height');
+        });
+    }
+
+    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() {
+        const { signal } = this.abortController;
+
+        this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction, { signal });
+        this.copyCodeBtn.addEventListener('click', this.boundCopyCode, { signal });
+        this.zoomInBtn.addEventListener('click', this.boundZoomIn, { signal });
+        this.zoomOutBtn.addEventListener('click', this.boundZoomOut, { signal });
+        this.zoomResetBtn.addEventListener('click', this.boundResetZoom, { signal });
+
+        this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false, signal });
+        this.viewport.addEventListener('mousedown', this.boundHandleMouseDown, { signal });
+
+        // Listen on document for mousemove to handle dragging outside viewport
+        document.addEventListener('mousemove', this.boundMouseMoveHandler, { signal });
+        // Listen on window for mouseup to ensure drag ends even if mouse is released outside
+        window.addEventListener('mouseup', this.boundMouseUpHandler, { signal, capture: true });
+
+        this.viewport.addEventListener('contextmenu', this.boundPreventDefault, { signal });
+        this.viewport.addEventListener('selectstart', this.boundPreventSelect, { signal });
+    }
+
+    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})`;
+    }
+
+    handleZoomClick(direction) {
+        const { clientX, clientY } = this._getViewportCenterClientCoords();
+        this.zoom(direction, clientX, clientY);
+    }
+
+    handleWheel(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);
+    }
+
+    handleMouseDown(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);
+    }
+
+    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
+            console.error('Clipboard API copy failed, attempting fallback:', error);
+            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) {
+        if (window.$events) {
+            const eventName = isError ? 'error' : 'success';
+            window.$events.emit(eventName, message);
+        } else {
+            // Fallback for if the event system is not available
+            console.warn('BookStack event system not found, falling back to console log for notification.');
+            if (isError) {
+                console.error(message);
+            } else {
+                console.log(message);
+            }
+        }
+    }
+
+    destroy() {
+        // Abort all listeners attached with this controller's signal.
+        this.abortController.abort();
+        this.container.innerHTML = ''; // Clear the container's content
+    }
+}
+
+const mermaidViewers = [];
+function initializeMermaidViewers() {
+    const codeBlocks = document.querySelectorAll('.content-wrap > .page-content 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;
+        const targetBounds = replaceTarget.getBoundingClientRect();
+
+        // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
+        if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
+
+        container.style.height = `${targetBounds.height}px`;
+        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();
+}
+
+const recenterAllViewers = () => mermaidViewers.forEach(viewer => viewer.centerDiagram());
+
+// Re-center diagrams on window load and window resize, as images/fonts inside SVG might affect size
+window.addEventListener('load', () => {
+    // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
+    setTimeout(recenterAllViewers, 100);
+});
+
+window.addEventListener('resize', recenterAllViewers);
\ No newline at end of file