1 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
2 integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
3 crossorigin="anonymous" referrerpolicy="no-referrer" />
4 <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
6 // Detect if BookStack's dark mode is enabled
7 const isDarkMode = document.documentElement.classList.contains('dark-mode');
9 // Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
12 securityLevel: 'loose',
13 theme: isDarkMode ? 'dark' : 'default'
16 // Zoom Level Configuration
17 const ZOOM_LEVEL_MIN = 0.5;
18 const ZOOM_LEVEL_MAX = 2.0;
19 const ZOOM_LEVEL_INCREMENT = 0.1;
20 const DEFAULT_ZOOM_SCALE = 1.0;
22 const DRAG_THRESHOLD_PIXELS = 3;
23 const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
26 CONTAINER: 'mermaid-container',
27 VIEWPORT: 'mermaid-viewport',
28 CONTENT: 'mermaid-content',
29 DIAGRAM: 'mermaid-diagram',
30 CONTROLS: 'mermaid-controls',
31 ZOOM_CONTROLS: 'mermaid-zoom-controls',
32 INTERACTION_ENABLED: 'interaction-enabled',
35 LOCK_ICON: 'fa fa-lock',
36 UNLOCK_ICON: 'fa fa-unlock',
37 INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state
38 INTERACTIVE_PAN: 'interactive-pan', // Class for 'grabbing' cursor state
39 BUTTON_BASE: 'mermaid-viewer-button-base' // Base class for all viewer buttons
42 class InteractiveMermaidViewer {
43 constructor(container, mermaidCode) {
44 this.container = container;
45 this.mermaidCode = mermaidCode;
49 this.isDragging = false;
50 this.dragStarted = false;
54 const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
55 this.zoomLevels = Array.from(
56 { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
57 (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
60 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
61 if (this.currentZoomIndex === -1) {
62 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
64 this.interactionEnabled = false;
65 this.initialContentOffset = { x: 0, y: 0 };
68 this.toggleInteractionBtn = null;
69 this.copyCodeBtn = null;
70 this.zoomInBtn = null;
71 this.zoomOutBtn = null;
72 this.zoomResetBtn = null;
74 // Bind event handlers for proper addition and removal
75 this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
76 this.boundMouseUpHandler = this.handleMouseUp.bind(this);
77 this.boundToggleInteraction = this.toggleInteraction.bind(this);
78 this.boundCopyCode = this.copyCode.bind(this);
79 this.boundZoomIn = () => {
80 const { clientX, clientY } = this._getViewportCenterClientCoords();
81 this.zoom(1, clientX, clientY);
83 this.boundZoomOut = () => {
84 const { clientX, clientY } = this._getViewportCenterClientCoords();
85 this.zoom(-1, clientX, clientY);
87 this.boundResetZoom = this.resetZoom.bind(this);
88 this.boundHandleWheel = this.handleWheel.bind(this);
89 this.boundHandleMouseDown = this.handleMouseDown.bind(this);
90 this.boundPreventDefault = e => e.preventDefault();
91 this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
94 this.setupEventListeners();
98 this.container.innerHTML = `
99 <div class="${CSS_CLASSES.CONTROLS}">
100 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn toggle-interaction" title="Toggle interaction">
101 <i class="${CSS_CLASSES.LOCK_ICON}" aria-hidden="true"></i>
103 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn" title="Copy code">
104 <i class="fa fa-copy" aria-hidden="true"></i>
107 <div class="${CSS_CLASSES.ZOOM_CONTROLS}">
108 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-in" title="Zoom in"><i class="fa fa-search-plus" aria-hidden="true"></i></div>
109 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-out" title="Zoom out"><i class="fa fa-search-minus" aria-hidden="true"></i></div>
110 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-reset" title="Reset"><i class="fa fa-refresh" aria-hidden="true"></i></div>
112 <div class="${CSS_CLASSES.VIEWPORT}">
113 <div class="${CSS_CLASSES.CONTENT}">
114 <div class="${CSS_CLASSES.DIAGRAM}">${this.mermaidCode}</div>
118 this.viewport = this.container.querySelector(`.${CSS_CLASSES.VIEWPORT}`);
119 this.content = this.container.querySelector(`.${CSS_CLASSES.CONTENT}`);
120 this.diagram = this.container.querySelector(`.${CSS_CLASSES.DIAGRAM}`);
122 // Cache control elements
123 this.toggleInteractionBtn = this.container.querySelector('.toggle-interaction');
124 this.copyCodeBtn = this.container.querySelector('.mermaid-btn:not(.toggle-interaction)');
125 this.zoomInBtn = this.container.querySelector('.zoom-in');
126 this.zoomOutBtn = this.container.querySelector('.zoom-out');
127 this.zoomResetBtn = this.container.querySelector('.zoom-reset');
129 // Function to render the diagram and perform post-render setup
130 const renderAndSetup = () => {
131 mermaid.run({ nodes: [this.diagram] }).then(() => {
132 this.adjustContainerHeight();
133 this.calculateInitialOffset();
134 this.centerDiagram();
136 console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
137 this.diagram.innerHTML = `<p style="color: red; padding: 10px;">Error rendering diagram. Check console.</p>`;
141 // Check if Font Awesome is loaded before rendering
142 // This checks for the 'Font Awesome 6 Free' font family, which is common.
143 // Adjust if your Font Awesome version uses a different family name for its core icons.
144 if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
146 } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
147 document.fonts.ready.then(renderAndSetup).catch(err => {
148 renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
155 adjustContainerHeight() {
156 const svgElement = this.content.querySelector('svg');
158 // Ensure the viewport takes up the height of the rendered SVG
159 this.viewport.style.height = '100%';
163 calculateInitialOffset() {
164 const originalTransform = this.content.style.transform;
165 this.content.style.transform = '';
166 const contentRect = this.content.getBoundingClientRect();
167 const viewportRect = this.viewport.getBoundingClientRect();
168 this.initialContentOffset.x = contentRect.left - viewportRect.left;
169 this.initialContentOffset.y = contentRect.top - viewportRect.top;
170 this.content.style.transform = originalTransform;
173 _getViewportCenterClientCoords() {
174 const viewportRect = this.viewport.getBoundingClientRect();
176 clientX: viewportRect.left + viewportRect.width / 2,
177 clientY: viewportRect.top + viewportRect.height / 2,
181 setupEventListeners() {
182 this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction);
183 this.copyCodeBtn.addEventListener('click', this.boundCopyCode);
184 this.zoomInBtn.addEventListener('click', this.boundZoomIn);
185 this.zoomOutBtn.addEventListener('click', this.boundZoomOut);
186 this.zoomResetBtn.addEventListener('click', this.boundResetZoom);
188 this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false });
189 this.viewport.addEventListener('mousedown', this.boundHandleMouseDown);
191 // Listen on document for mousemove to handle dragging outside viewport
192 document.addEventListener('mousemove', this.boundMouseMoveHandler);
193 // Listen on window for mouseup to ensure drag ends even if mouse is released outside
194 window.addEventListener('mouseup', this.boundMouseUpHandler, true); // Use capture phase
196 this.viewport.addEventListener('contextmenu', this.boundPreventDefault);
197 this.viewport.addEventListener('selectstart', this.boundPreventSelect);
200 toggleInteraction() {
201 this.interactionEnabled = !this.interactionEnabled;
202 const icon = this.toggleInteractionBtn.querySelector('i');
203 this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
205 if (this.interactionEnabled) {
206 icon.className = CSS_CLASSES.UNLOCK_ICON;
207 this.toggleInteractionBtn.title = 'Disable manual interaction';
208 this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
209 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
210 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
212 icon.className = CSS_CLASSES.LOCK_ICON;
213 this.toggleInteractionBtn.title = 'Enable manual interaction';
214 this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
215 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
216 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
217 this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
218 this.dragStarted = false;
219 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
224 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
228 if (!this.interactionEnabled) return;
229 // Prevent default browser scroll/zoom behavior when wheeling over the diagram
231 this.content.classList.add(CSS_CLASSES.ZOOMING);
232 const clientX = e.clientX;
233 const clientY = e.clientY;
234 if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
235 else this.zoom(1, clientX, clientY);
236 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
240 if (!this.interactionEnabled || e.button !== 0) return;
242 this.isDragging = true;
243 this.dragStarted = false;
244 this.startX = e.clientX;
245 this.startY = e.clientY;
246 this.dragBaseTranslateX = this.translateX;
247 this.dragBaseTranslateY = this.translateY;
248 this.viewport.classList.add(CSS_CLASSES.DRAGGING);
249 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
250 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
251 this.content.classList.remove(CSS_CLASSES.ZOOMING);
255 if (!this.isDragging) return;
256 // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
257 const deltaX = e.clientX - this.startX;
258 const deltaY = e.clientY - this.startY;
259 if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
260 this.dragStarted = true;
262 if (this.dragStarted) {
263 e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
264 this.translateX = this.dragBaseTranslateX + deltaX;
265 this.translateY = this.dragBaseTranslateY + deltaY;
266 this.updateTransform();
271 if (this.isDragging) {
272 this.isDragging = false;
273 this.dragStarted = false;
274 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
275 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
276 if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
277 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
280 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
284 const svgElement = this.content.querySelector('svg');
286 const viewportRect = this.viewport.getBoundingClientRect();
287 const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
288 const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
290 const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
291 const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
293 this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
294 this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
296 // Initial centering constraints; may need adjustment for very large diagrams.
297 this.translateX = Math.max(0, this.translateX);
298 this.translateY = Math.max(0, this.translateY);
300 this.updateTransform();
304 zoom(direction, clientX, clientY) {
305 this.content.classList.add(CSS_CLASSES.ZOOMING);
306 const oldScale = this.scale;
307 let newZoomIndex = this.currentZoomIndex + direction;
309 if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
310 this.currentZoomIndex = newZoomIndex;
311 const newScale = this.zoomLevels[this.currentZoomIndex];
313 const viewportRect = this.viewport.getBoundingClientRect();
314 const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
315 const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
317 this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
318 this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
319 this.scale = newScale;
320 this.updateTransform();
322 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
326 this.content.classList.add(CSS_CLASSES.ZOOMING);
327 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
328 if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
329 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
331 this.scale = this.zoomLevels[this.currentZoomIndex];
332 // Use requestAnimationFrame to ensure layout is stable before centering
333 requestAnimationFrame(() => {
334 this.centerDiagram();
335 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
341 await navigator.clipboard.writeText(this.mermaidCode);
342 this.showNotification('Copied!');
344 // Fallback for older browsers or if clipboard API fails
345 const textArea = document.createElement('textarea');
346 textArea.value = this.mermaidCode;
347 // Style to make it invisible
348 textArea.style.position = 'fixed';
349 textArea.style.top = '-9999px';
350 textArea.style.left = '-9999px';
351 document.body.appendChild(textArea);
354 document.execCommand('copy');
355 this.showNotification('Copied!');
356 } catch (copyError) {
357 console.error('Fallback copy failed:', copyError);
358 this.showNotification('Copy failed.', true); // Error
360 document.body.removeChild(textArea);
364 showNotification(message, isError = false) {
365 if (window.$events) {
366 const eventName = isError ? 'error' : 'success';
367 window.$events.emit(eventName, message);
369 // Fallback for if the event system is not available
370 console.warn('BookStack event system not found, falling back to console log for notification.');
372 console.error(message);
374 console.log(message);
380 // Remove event listeners specific to this instance
381 this.toggleInteractionBtn.removeEventListener('click', this.boundToggleInteraction);
382 this.copyCodeBtn.removeEventListener('click', this.boundCopyCode);
383 this.zoomInBtn.removeEventListener('click', this.boundZoomIn);
384 this.zoomOutBtn.removeEventListener('click', this.boundZoomOut);
385 this.zoomResetBtn.removeEventListener('click', this.boundResetZoom);
387 this.viewport.removeEventListener('wheel', this.boundHandleWheel, { passive: false });
388 this.viewport.removeEventListener('mousedown', this.boundHandleMouseDown);
389 this.viewport.removeEventListener('contextmenu', this.boundPreventDefault);
390 this.viewport.removeEventListener('selectstart', this.boundPreventSelect);
392 document.removeEventListener('mousemove', this.boundMouseMoveHandler);
393 window.removeEventListener('mouseup', this.boundMouseUpHandler, true);
395 this.container.innerHTML = ''; // Clear the container's content
399 const mermaidViewers = [];
400 function initializeMermaidViewers() {
401 // Adjust the selector if your CMS wraps mermaid code blocks differently
402 const codeBlocks = document.querySelectorAll('pre code.language-mermaid');
403 for (const codeBlock of codeBlocks) {
404 // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
405 if (codeBlock.dataset.mermaidViewerInitialized) continue;
407 const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
408 const container = document.createElement('div');
409 container.className = CSS_CLASSES.CONTAINER;
411 const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
413 // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
414 if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
416 replaceTarget.after(container);
417 replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
419 const viewer = new InteractiveMermaidViewer(container, mermaidCode);
420 mermaidViewers.push(viewer);
421 codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
425 // Initialize on DOMContentLoaded
426 if (document.readyState === 'loading') {
427 document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
429 // DOMContentLoaded has already fired
430 initializeMermaidViewers();
433 // Re-center diagrams on window load, as images/fonts inside SVG might affect size
434 window.addEventListener('load', () => {
435 mermaidViewers.forEach(viewer => {
436 // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
437 setTimeout(() => viewer.centerDiagram(), 100);
441 // Optional: If your CMS dynamically adds content, you might need a way to re-run initialization
442 // For example, using a MutationObserver or a custom event.
443 // document.addEventListener('myCMSContentLoaded', () => initializeMermaidViewers());
447 /* Use BookStack's CSS variables for seamless theme integration */
449 background: var(--color-bg-alt);
450 border: 1px solid #d0d7de;
458 /* This will now be 100% of the dynamically set container height */
460 /* Keep this for panning/zooming when content exceeds viewport */
462 /* Default to normal system cursor */
465 /* Ensure viewport cursor is auto when locked, even if active.
466 The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */
467 .mermaid-viewport:not(.interaction-enabled):active {
471 /* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */
472 .mermaid-viewport.interactive-hover {
476 /* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */
477 .mermaid-viewport.interactive-pan {
478 cursor: grabbing !important;
482 transform-origin: 0 0;
483 /* Allow text selection by default (when interaction is locked) */
486 will-change: transform;
489 /* Disable text selection ONLY when interaction is enabled on the viewport */
490 .mermaid-viewport.interaction-enabled .mermaid-content {
494 /* SVG elements inherit cursor from the viewport when interaction is enabled. */
495 .mermaid-viewport.interaction-enabled .mermaid-content svg,
496 .mermaid-viewport.interaction-enabled .mermaid-content svg * {
497 cursor: inherit !important;
498 /* Force inheritance from the viewport's cursor */
501 .mermaid-content.zooming {
502 transition: transform 0.2s ease;
514 .mermaid-viewer-button-base {
515 border: 1px solid #d0d7de;
520 justify-content: center;
522 background: var(--color-bg);
525 color: var(--color-text);
528 .mermaid-viewer-button-base:hover {
532 .dark-mode .mermaid-viewer-button-base:hover {
533 background: var(--color-bg-alt);
536 /* Override for pure white icons in dark mode */
537 .dark-mode .mermaid-viewer-button-base {
541 .mermaid-zoom-controls {
546 flex-direction: column;