+++ /dev/null
-<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
+++
-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
```
````
-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.
#### 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">}}
--- /dev/null
+{{-- 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
--- /dev/null
+/* 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
--- /dev/null
+// 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