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" />
5 <script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.7.0/mermaid.min.js"
6 integrity="sha512-ecc+vlmmc1f51s2l/AeIC552wULnv9Q8bYJ4FbODxsL6jGrFoLaKnGkN5JUZNH6LBjkAYy9Q4fKqyTuFUIvvFA=="
7 crossorigin="anonymous" referrerpolicy="no-referrer"></script>
9 // Detect if BookStack's dark mode is enabled
10 const isDarkMode = document.documentElement.classList.contains('dark-mode');
12 // Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
15 securityLevel: 'loose',
16 theme: isDarkMode ? 'dark' : 'default'
19 // Zoom Level Configuration
20 const ZOOM_LEVEL_MIN = 0.5;
21 const ZOOM_LEVEL_MAX = 2.0;
22 const ZOOM_LEVEL_INCREMENT = 0.1;
23 const DEFAULT_ZOOM_SCALE = 1.0;
25 const DRAG_THRESHOLD_PIXELS = 3;
26 const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
29 CONTAINER: 'mermaid-container',
30 VIEWPORT: 'mermaid-viewport',
31 CONTENT: 'mermaid-content',
32 DIAGRAM: 'mermaid-diagram',
33 CONTROLS: 'mermaid-controls',
34 ZOOM_CONTROLS: 'mermaid-zoom-controls',
35 INTERACTION_ENABLED: 'interaction-enabled',
38 LOCK_ICON: 'fa fa-lock',
39 UNLOCK_ICON: 'fa fa-unlock',
40 INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state
41 INTERACTIVE_PAN: 'interactive-pan', // Class for 'grabbing' cursor state
42 BUTTON_BASE: 'mermaid-viewer-button-base' // Base class for all viewer buttons
45 class InteractiveMermaidViewer {
46 constructor(container, mermaidCode) {
47 this.container = container;
48 this.mermaidCode = mermaidCode;
52 this.isDragging = false;
53 this.dragStarted = false;
57 const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
58 this.zoomLevels = Array.from(
59 { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
60 (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
63 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
64 if (this.currentZoomIndex === -1) {
65 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
67 this.interactionEnabled = false;
68 this.initialContentOffset = { x: 0, y: 0 };
71 this.toggleInteractionBtn = null;
72 this.copyCodeBtn = null;
73 this.zoomInBtn = null;
74 this.zoomOutBtn = null;
75 this.zoomResetBtn = null;
77 // Bind event handlers for proper addition and removal
78 this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
79 this.boundMouseUpHandler = this.handleMouseUp.bind(this);
80 this.boundToggleInteraction = this.toggleInteraction.bind(this);
81 this.boundCopyCode = this.copyCode.bind(this);
82 this.boundZoomIn = () => {
83 const { clientX, clientY } = this._getViewportCenterClientCoords();
84 this.zoom(1, clientX, clientY);
86 this.boundZoomOut = () => {
87 const { clientX, clientY } = this._getViewportCenterClientCoords();
88 this.zoom(-1, clientX, clientY);
90 this.boundResetZoom = this.resetZoom.bind(this);
91 this.boundHandleWheel = this.handleWheel.bind(this);
92 this.boundHandleMouseDown = this.handleMouseDown.bind(this);
93 this.boundPreventDefault = e => e.preventDefault();
94 this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
97 this.setupEventListeners();
101 * Creates the DOM structure for the viewer programmatically.
102 * This is safer and more maintainable than using innerHTML with a large template string.
105 const createButton = (title, iconClass, ...extraClasses) => {
106 const button = document.createElement('div');
107 button.className = `${CSS_CLASSES.BUTTON_BASE} ${extraClasses.join(' ')}`;
108 button.title = title;
109 const icon = document.createElement('i');
110 icon.className = iconClass;
111 icon.setAttribute('aria-hidden', 'true');
116 const controls = document.createElement('div');
117 controls.className = CSS_CLASSES.CONTROLS;
118 this.toggleInteractionBtn = createButton('Toggle interaction', CSS_CLASSES.LOCK_ICON, 'mermaid-btn', 'toggle-interaction');
119 this.copyCodeBtn = createButton('Copy code', 'fa fa-copy', 'mermaid-btn');
120 controls.append(this.toggleInteractionBtn, this.copyCodeBtn);
122 const zoomControls = document.createElement('div');
123 zoomControls.className = CSS_CLASSES.ZOOM_CONTROLS;
124 this.zoomInBtn = createButton('Zoom in', 'fa fa-search-plus', 'mermaid-zoom-btn', 'zoom-in');
125 this.zoomOutBtn = createButton('Zoom out', 'fa fa-search-minus', 'mermaid-zoom-btn', 'zoom-out');
126 this.zoomResetBtn = createButton('Reset', 'fa fa-refresh', 'mermaid-zoom-btn', 'zoom-reset');
127 zoomControls.append(this.zoomInBtn, this.zoomOutBtn, this.zoomResetBtn);
129 this.diagram = document.createElement('div');
130 this.diagram.className = CSS_CLASSES.DIAGRAM;
131 // Use textContent for security, preventing any potential HTML injection.
132 // Mermaid will parse the text content safely.
133 this.diagram.textContent = this.mermaidCode;
135 this.content = document.createElement('div');
136 this.content.className = CSS_CLASSES.CONTENT;
137 this.content.append(this.diagram);
139 this.viewport = document.createElement('div');
140 this.viewport.className = CSS_CLASSES.VIEWPORT;
141 this.viewport.append(this.content);
143 // Clear the container and append the new structure
144 this.container.innerHTML = '';
145 this.container.append(controls, zoomControls, this.viewport);
147 // Function to render the diagram and perform post-render setup
148 const renderAndSetup = () => {
149 mermaid.run({ nodes: [this.diagram] }).then(() => {
150 this.adjustContainerHeight();
151 this.calculateInitialOffset();
152 this.centerDiagram();
154 console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
155 this.diagram.innerHTML = `<p style="color: red; padding: 10px;">Error rendering diagram. Check console.</p>`;
159 // Check if Font Awesome is loaded before rendering
160 // This checks for the 'Font Awesome 6 Free' font family, which is common.
161 // Adjust if your Font Awesome version uses a different family name for its core icons.
162 if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
164 } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
165 document.fonts.ready.then(renderAndSetup).catch(err => {
166 renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
173 adjustContainerHeight() {
174 const svgElement = this.content.querySelector('svg');
176 // Ensure the viewport takes up the height of the rendered SVG
177 this.viewport.style.height = '100%';
181 calculateInitialOffset() {
182 const originalTransform = this.content.style.transform;
183 this.content.style.transform = '';
184 const contentRect = this.content.getBoundingClientRect();
185 const viewportRect = this.viewport.getBoundingClientRect();
186 this.initialContentOffset.x = contentRect.left - viewportRect.left;
187 this.initialContentOffset.y = contentRect.top - viewportRect.top;
188 this.content.style.transform = originalTransform;
191 _getViewportCenterClientCoords() {
192 const viewportRect = this.viewport.getBoundingClientRect();
194 clientX: viewportRect.left + viewportRect.width / 2,
195 clientY: viewportRect.top + viewportRect.height / 2,
199 setupEventListeners() {
200 this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction);
201 this.copyCodeBtn.addEventListener('click', this.boundCopyCode);
202 this.zoomInBtn.addEventListener('click', this.boundZoomIn);
203 this.zoomOutBtn.addEventListener('click', this.boundZoomOut);
204 this.zoomResetBtn.addEventListener('click', this.boundResetZoom);
206 this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false });
207 this.viewport.addEventListener('mousedown', this.boundHandleMouseDown);
209 // Listen on document for mousemove to handle dragging outside viewport
210 document.addEventListener('mousemove', this.boundMouseMoveHandler);
211 // Listen on window for mouseup to ensure drag ends even if mouse is released outside
212 window.addEventListener('mouseup', this.boundMouseUpHandler, true); // Use capture phase
214 this.viewport.addEventListener('contextmenu', this.boundPreventDefault);
215 this.viewport.addEventListener('selectstart', this.boundPreventSelect);
218 toggleInteraction() {
219 this.interactionEnabled = !this.interactionEnabled;
220 const icon = this.toggleInteractionBtn.querySelector('i');
221 this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
223 if (this.interactionEnabled) {
224 icon.className = CSS_CLASSES.UNLOCK_ICON;
225 this.toggleInteractionBtn.title = 'Disable manual interaction';
226 this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
227 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
228 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
230 icon.className = CSS_CLASSES.LOCK_ICON;
231 this.toggleInteractionBtn.title = 'Enable manual interaction';
232 this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
233 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
234 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
235 this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
236 this.dragStarted = false;
237 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
242 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
246 if (!this.interactionEnabled) return;
247 // Prevent default browser scroll/zoom behavior when wheeling over the diagram
249 this.content.classList.add(CSS_CLASSES.ZOOMING);
250 const clientX = e.clientX;
251 const clientY = e.clientY;
252 if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
253 else this.zoom(1, clientX, clientY);
254 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
258 if (!this.interactionEnabled || e.button !== 0) return;
260 this.isDragging = true;
261 this.dragStarted = false;
262 this.startX = e.clientX;
263 this.startY = e.clientY;
264 this.dragBaseTranslateX = this.translateX;
265 this.dragBaseTranslateY = this.translateY;
266 this.viewport.classList.add(CSS_CLASSES.DRAGGING);
267 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
268 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
269 this.content.classList.remove(CSS_CLASSES.ZOOMING);
273 if (!this.isDragging) return;
274 // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
275 const deltaX = e.clientX - this.startX;
276 const deltaY = e.clientY - this.startY;
277 if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
278 this.dragStarted = true;
280 if (this.dragStarted) {
281 e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
282 this.translateX = this.dragBaseTranslateX + deltaX;
283 this.translateY = this.dragBaseTranslateY + deltaY;
284 this.updateTransform();
289 if (this.isDragging) {
290 this.isDragging = false;
291 this.dragStarted = false;
292 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
293 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
294 if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
295 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
298 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
302 const svgElement = this.content.querySelector('svg');
304 const viewportRect = this.viewport.getBoundingClientRect();
305 const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
306 const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
308 const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
309 const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
311 this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
312 this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
314 // Initial centering constraints; may need adjustment for very large diagrams.
315 this.translateX = Math.max(0, this.translateX);
316 this.translateY = Math.max(0, this.translateY);
318 this.updateTransform();
322 zoom(direction, clientX, clientY) {
323 this.content.classList.add(CSS_CLASSES.ZOOMING);
324 const oldScale = this.scale;
325 let newZoomIndex = this.currentZoomIndex + direction;
327 if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
328 this.currentZoomIndex = newZoomIndex;
329 const newScale = this.zoomLevels[this.currentZoomIndex];
331 const viewportRect = this.viewport.getBoundingClientRect();
332 const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
333 const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
335 this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
336 this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
337 this.scale = newScale;
338 this.updateTransform();
340 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
344 this.content.classList.add(CSS_CLASSES.ZOOMING);
345 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
346 if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
347 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
349 this.scale = this.zoomLevels[this.currentZoomIndex];
350 // Use requestAnimationFrame to ensure layout is stable before centering
351 requestAnimationFrame(() => {
352 this.centerDiagram();
353 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
359 await navigator.clipboard.writeText(this.mermaidCode);
360 this.showNotification('Copied!');
362 // Fallback for older browsers or if clipboard API fails
363 const textArea = document.createElement('textarea');
364 textArea.value = this.mermaidCode;
365 // Style to make it invisible
366 textArea.style.position = 'fixed';
367 textArea.style.top = '-9999px';
368 textArea.style.left = '-9999px';
369 document.body.appendChild(textArea);
372 document.execCommand('copy');
373 this.showNotification('Copied!');
374 } catch (copyError) {
375 console.error('Fallback copy failed:', copyError);
376 this.showNotification('Copy failed.', true); // Error
378 document.body.removeChild(textArea);
382 showNotification(message, isError = false) {
383 if (window.$events) {
384 const eventName = isError ? 'error' : 'success';
385 window.$events.emit(eventName, message);
387 // Fallback for if the event system is not available
388 console.warn('BookStack event system not found, falling back to console log for notification.');
390 console.error(message);
392 console.log(message);
398 // Remove event listeners specific to this instance
399 this.toggleInteractionBtn.removeEventListener('click', this.boundToggleInteraction);
400 this.copyCodeBtn.removeEventListener('click', this.boundCopyCode);
401 this.zoomInBtn.removeEventListener('click', this.boundZoomIn);
402 this.zoomOutBtn.removeEventListener('click', this.boundZoomOut);
403 this.zoomResetBtn.removeEventListener('click', this.boundResetZoom);
405 this.viewport.removeEventListener('wheel', this.boundHandleWheel, { passive: false });
406 this.viewport.removeEventListener('mousedown', this.boundHandleMouseDown);
407 this.viewport.removeEventListener('contextmenu', this.boundPreventDefault);
408 this.viewport.removeEventListener('selectstart', this.boundPreventSelect);
410 document.removeEventListener('mousemove', this.boundMouseMoveHandler);
411 window.removeEventListener('mouseup', this.boundMouseUpHandler, true);
413 this.container.innerHTML = ''; // Clear the container's content
417 const mermaidViewers = [];
418 function initializeMermaidViewers() {
419 // Adjust the selector if your CMS wraps mermaid code blocks differently
420 const codeBlocks = document.querySelectorAll('pre code.language-mermaid');
421 for (const codeBlock of codeBlocks) {
422 // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
423 if (codeBlock.dataset.mermaidViewerInitialized) continue;
425 const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
426 const container = document.createElement('div');
427 container.className = CSS_CLASSES.CONTAINER;
429 const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
431 // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
432 if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
434 replaceTarget.after(container);
435 replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
437 const viewer = new InteractiveMermaidViewer(container, mermaidCode);
438 mermaidViewers.push(viewer);
439 codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
443 // Initialize on DOMContentLoaded
444 if (document.readyState === 'loading') {
445 document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
447 // DOMContentLoaded has already fired
448 initializeMermaidViewers();
451 // Re-center diagrams on window load, as images/fonts inside SVG might affect size
452 window.addEventListener('load', () => {
453 mermaidViewers.forEach(viewer => {
454 // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
455 setTimeout(() => viewer.centerDiagram(), 100);
459 // Optional: If your CMS dynamically adds content, you might need a way to re-run initialization
460 // For example, using a MutationObserver or a custom event.
461 // document.addEventListener('myCMSContentLoaded', () => initializeMermaidViewers());
465 /* Use BookStack's CSS variables for seamless theme integration */
467 background: var(--color-bg-alt);
468 border: 1px solid #d0d7de;
476 /* This will now be 100% of the dynamically set container height */
478 /* Keep this for panning/zooming when content exceeds viewport */
480 /* Default to normal system cursor */
483 /* Ensure viewport cursor is auto when locked, even if active.
484 The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */
485 .mermaid-viewport:not(.interaction-enabled):active {
489 /* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */
490 .mermaid-viewport.interactive-hover {
494 /* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */
495 .mermaid-viewport.interactive-pan {
496 cursor: grabbing !important;
500 transform-origin: 0 0;
501 /* Allow text selection by default (when interaction is locked) */
504 will-change: transform;
507 /* Disable text selection ONLY when interaction is enabled on the viewport */
508 .mermaid-viewport.interaction-enabled .mermaid-content {
512 /* SVG elements inherit cursor from the viewport when interaction is enabled. */
513 .mermaid-viewport.interaction-enabled .mermaid-content svg,
514 .mermaid-viewport.interaction-enabled .mermaid-content svg * {
515 cursor: inherit !important;
516 /* Force inheritance from the viewport's cursor */
519 .mermaid-content.zooming {
520 transition: transform 0.2s ease;
532 .mermaid-viewer-button-base {
533 border: 1px solid #d0d7de;
538 justify-content: center;
540 background: var(--color-bg);
543 color: var(--color-text);
546 .mermaid-viewer-button-base:hover {
550 .dark-mode .mermaid-viewer-button-base:hover {
551 background: var(--color-bg-alt);
554 /* Override for pure white icons in dark mode */
555 .dark-mode .mermaid-viewer-button-base {
559 .mermaid-zoom-controls {
564 flex-direction: column;