Reviewed mermaid viewer hack

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.
This commit is contained in:
Dan Brown 2025-06-27 14:54:59 +01:00
commit a7185d10f9
Signed by: danb
GPG key ID: 46D9F943C24A2EF9

File diff suppressed because it is too large Load diff

View 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">}}

View file

@ -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

View file

@ -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;
}

View file

@ -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);