1 // Detect if BookStack's dark mode is enabled
2 const isDarkMode = document.documentElement.classList.contains('dark-mode');
4 // Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
7 securityLevel: 'loose',
8 theme: isDarkMode ? 'dark' : 'default'
11 // Zoom Level Configuration
12 const ZOOM_LEVEL_MIN = 0.5;
13 const ZOOM_LEVEL_MAX = 2.0;
14 const ZOOM_LEVEL_INCREMENT = 0.1;
15 const DEFAULT_ZOOM_SCALE = 1.0;
17 const DRAG_THRESHOLD_PIXELS = 3;
18 const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
21 CONTAINER: 'mermaid-container',
22 VIEWPORT: 'mermaid-viewport',
23 CONTENT: 'mermaid-content',
24 DIAGRAM: 'mermaid-diagram',
25 CONTROLS: 'mermaid-controls',
26 ZOOM_CONTROLS: 'mermaid-zoom-controls',
27 INTERACTION_ENABLED: 'interaction-enabled',
30 LOCK_ICON: 'fa fa-lock',
31 UNLOCK_ICON: 'fa fa-unlock',
32 INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state
33 INTERACTIVE_PAN: 'interactive-pan', // Class for 'grabbing' cursor state
34 BUTTON_BASE: 'mermaid-viewer-button-base' // Base class for all viewer buttons
37 class InteractiveMermaidViewer {
38 constructor(container, mermaidCode) {
39 this.container = container;
40 this.mermaidCode = mermaidCode;
44 this.isDragging = false;
45 this.dragStarted = false;
49 const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
50 this.zoomLevels = Array.from(
51 { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
52 (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
55 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
56 if (this.currentZoomIndex === -1) {
57 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
59 this.interactionEnabled = false;
60 this.initialContentOffset = { x: 0, y: 0 };
63 this.toggleInteractionBtn = null;
64 this.copyCodeBtn = null;
65 this.zoomInBtn = null;
66 this.zoomOutBtn = null;
67 this.zoomResetBtn = null;
69 // Use an AbortController for robust event listener cleanup.
70 this.abortController = new AbortController();
72 // Bind event handlers for proper addition and removal
73 this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
74 this.boundMouseUpHandler = this.handleMouseUp.bind(this);
75 this.boundToggleInteraction = this.toggleInteraction.bind(this);
76 this.boundCopyCode = this.copyCode.bind(this);
77 this.boundZoomIn = this.handleZoomClick.bind(this, 1);
78 this.boundZoomOut = this.handleZoomClick.bind(this, -1);
79 this.boundResetZoom = this.resetZoom.bind(this);
80 this.boundHandleWheel = this.handleWheel.bind(this);
81 this.boundHandleMouseDown = this.handleMouseDown.bind(this);
82 this.boundPreventDefault = e => e.preventDefault();
83 this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
86 this.setupEventListeners();
90 * Creates the DOM structure for the viewer programmatically.
91 * This is safer and more maintainable than using innerHTML with a large template string.
94 const createButton = (title, iconClass, ...extraClasses) => {
95 const button = document.createElement('button');
96 button.type = 'button';
97 button.className = `${CSS_CLASSES.BUTTON_BASE} ${extraClasses.join(' ')}`;
99 const icon = document.createElement('i');
100 icon.className = iconClass;
101 icon.setAttribute('aria-hidden', 'true');
106 const controls = document.createElement('div');
107 controls.className = CSS_CLASSES.CONTROLS;
108 this.toggleInteractionBtn = createButton('Toggle interaction', CSS_CLASSES.LOCK_ICON, 'mermaid-btn', 'toggle-interaction');
109 this.copyCodeBtn = createButton('Copy code', 'fa fa-copy', 'mermaid-btn');
110 controls.append(this.toggleInteractionBtn, this.copyCodeBtn);
112 const zoomControls = document.createElement('div');
113 zoomControls.className = CSS_CLASSES.ZOOM_CONTROLS;
114 this.zoomInBtn = createButton('Zoom in', 'fa fa-search-plus', 'mermaid-zoom-btn', 'zoom-in');
115 this.zoomOutBtn = createButton('Zoom out', 'fa fa-search-minus', 'mermaid-zoom-btn', 'zoom-out');
116 this.zoomResetBtn = createButton('Reset', 'fa fa-refresh', 'mermaid-zoom-btn', 'zoom-reset');
117 zoomControls.append(this.zoomInBtn, this.zoomOutBtn, this.zoomResetBtn);
119 this.diagram = document.createElement('div');
120 this.diagram.className = CSS_CLASSES.DIAGRAM;
121 // Use textContent for security, preventing any potential HTML injection.
122 // Mermaid will parse the text content safely.
123 this.diagram.textContent = this.mermaidCode;
125 this.content = document.createElement('div');
126 this.content.className = CSS_CLASSES.CONTENT;
127 this.content.append(this.diagram);
129 this.viewport = document.createElement('div');
130 this.viewport.className = CSS_CLASSES.VIEWPORT;
131 this.viewport.append(this.content);
133 // Clear the container and append the new structure
134 this.container.innerHTML = '';
135 this.container.append(controls, zoomControls, this.viewport);
137 // Function to render the diagram and perform post-render setup
138 const renderAndSetup = () => {
139 mermaid.run({ nodes: [this.diagram] }).then(() => {
140 this.adjustContainerHeight();
141 this.calculateInitialOffset();
142 this.centerDiagram();
144 console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
145 // Use BookStack's negative color variable and provide a clearer message for debugging.
146 this.diagram.innerHTML = `<p style="color: var(--color-neg); padding: 10px;">Error rendering diagram. Check browser console for details.</p>`;
150 // Check if Font Awesome is loaded before rendering
151 // This checks for the 'Font Awesome 6 Free' font family, which is common.
152 // Adjust if your Font Awesome version uses a different family name for its core icons.
153 if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
155 } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
156 document.fonts.ready.then(renderAndSetup).catch(err => {
157 renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
164 adjustContainerHeight() {
165 const svgElement = this.content.querySelector('svg');
167 // Ensure the viewport takes up the height of the rendered SVG
168 this.viewport.style.height = '100%';
171 // Remove any set height on the container once the viewer has had a chance to render
172 window.requestAnimationFrame(() => {
173 this.container.style.removeProperty('height');
177 calculateInitialOffset() {
178 const originalTransform = this.content.style.transform;
179 this.content.style.transform = '';
180 const contentRect = this.content.getBoundingClientRect();
181 const viewportRect = this.viewport.getBoundingClientRect();
182 this.initialContentOffset.x = contentRect.left - viewportRect.left;
183 this.initialContentOffset.y = contentRect.top - viewportRect.top;
184 this.content.style.transform = originalTransform;
187 _getViewportCenterClientCoords() {
188 const viewportRect = this.viewport.getBoundingClientRect();
190 clientX: viewportRect.left + viewportRect.width / 2,
191 clientY: viewportRect.top + viewportRect.height / 2,
195 setupEventListeners() {
196 const { signal } = this.abortController;
198 this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction, { signal });
199 this.copyCodeBtn.addEventListener('click', this.boundCopyCode, { signal });
200 this.zoomInBtn.addEventListener('click', this.boundZoomIn, { signal });
201 this.zoomOutBtn.addEventListener('click', this.boundZoomOut, { signal });
202 this.zoomResetBtn.addEventListener('click', this.boundResetZoom, { signal });
204 this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false, signal });
205 this.viewport.addEventListener('mousedown', this.boundHandleMouseDown, { signal });
207 // Listen on document for mousemove to handle dragging outside viewport
208 document.addEventListener('mousemove', this.boundMouseMoveHandler, { signal });
209 // Listen on window for mouseup to ensure drag ends even if mouse is released outside
210 window.addEventListener('mouseup', this.boundMouseUpHandler, { signal, capture: true });
212 this.viewport.addEventListener('contextmenu', this.boundPreventDefault, { signal });
213 this.viewport.addEventListener('selectstart', this.boundPreventSelect, { signal });
216 toggleInteraction() {
217 this.interactionEnabled = !this.interactionEnabled;
218 const icon = this.toggleInteractionBtn.querySelector('i');
219 this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
221 if (this.interactionEnabled) {
222 icon.className = CSS_CLASSES.UNLOCK_ICON;
223 this.toggleInteractionBtn.title = 'Disable manual interaction';
224 this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
225 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
226 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
228 icon.className = CSS_CLASSES.LOCK_ICON;
229 this.toggleInteractionBtn.title = 'Enable manual interaction';
230 this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
231 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
232 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
233 this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
234 this.dragStarted = false;
235 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
240 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
243 handleZoomClick(direction) {
244 const { clientX, clientY } = this._getViewportCenterClientCoords();
245 this.zoom(direction, clientX, clientY);
249 if (!this.interactionEnabled) return;
250 // Prevent default browser scroll/zoom behavior when wheeling over the diagram
252 this.content.classList.add(CSS_CLASSES.ZOOMING);
253 const clientX = e.clientX;
254 const clientY = e.clientY;
255 if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
256 else this.zoom(1, clientX, clientY);
257 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
261 if (!this.interactionEnabled || e.button !== 0) return;
263 this.isDragging = true;
264 this.dragStarted = false;
265 this.startX = e.clientX;
266 this.startY = e.clientY;
267 this.dragBaseTranslateX = this.translateX;
268 this.dragBaseTranslateY = this.translateY;
269 this.viewport.classList.add(CSS_CLASSES.DRAGGING);
270 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
271 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
272 this.content.classList.remove(CSS_CLASSES.ZOOMING);
276 if (!this.isDragging) return;
277 // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
278 const deltaX = e.clientX - this.startX;
279 const deltaY = e.clientY - this.startY;
280 if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
281 this.dragStarted = true;
283 if (this.dragStarted) {
284 e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
285 this.translateX = this.dragBaseTranslateX + deltaX;
286 this.translateY = this.dragBaseTranslateY + deltaY;
287 this.updateTransform();
292 if (this.isDragging) {
293 this.isDragging = false;
294 this.dragStarted = false;
295 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
296 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
297 if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
298 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
301 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
305 const svgElement = this.content.querySelector('svg');
307 const viewportRect = this.viewport.getBoundingClientRect();
308 const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
309 const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
311 const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
312 const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
314 this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
315 this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
317 // Initial centering constraints; may need adjustment for very large diagrams.
318 this.translateX = Math.max(0, this.translateX);
319 this.translateY = Math.max(0, this.translateY);
321 this.updateTransform();
325 zoom(direction, clientX, clientY) {
326 this.content.classList.add(CSS_CLASSES.ZOOMING);
327 const oldScale = this.scale;
328 let newZoomIndex = this.currentZoomIndex + direction;
330 if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
331 this.currentZoomIndex = newZoomIndex;
332 const newScale = this.zoomLevels[this.currentZoomIndex];
334 const viewportRect = this.viewport.getBoundingClientRect();
335 const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
336 const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
338 this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
339 this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
340 this.scale = newScale;
341 this.updateTransform();
343 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
347 this.content.classList.add(CSS_CLASSES.ZOOMING);
348 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
349 if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
350 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
352 this.scale = this.zoomLevels[this.currentZoomIndex];
353 // Use requestAnimationFrame to ensure layout is stable before centering
354 requestAnimationFrame(() => {
355 this.centerDiagram();
356 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
362 await navigator.clipboard.writeText(this.mermaidCode);
363 this.showNotification('Copied!');
365 // Fallback for older browsers or if clipboard API fails
366 console.error('Clipboard API copy failed, attempting fallback:', error);
367 const textArea = document.createElement('textarea');
368 textArea.value = this.mermaidCode;
369 // Style to make it invisible
370 textArea.style.position = 'fixed';
371 textArea.style.top = '-9999px';
372 textArea.style.left = '-9999px';
373 document.body.appendChild(textArea);
376 document.execCommand('copy');
377 this.showNotification('Copied!');
378 } catch (copyError) {
379 console.error('Fallback copy failed:', copyError);
380 this.showNotification('Copy failed.', true); // Error
382 document.body.removeChild(textArea);
386 showNotification(message, isError = false) {
387 if (window.$events) {
388 const eventName = isError ? 'error' : 'success';
389 window.$events.emit(eventName, message);
391 // Fallback for if the event system is not available
392 console.warn('BookStack event system not found, falling back to console log for notification.');
394 console.error(message);
396 console.log(message);
402 // Abort all listeners attached with this controller's signal.
403 this.abortController.abort();
404 this.container.innerHTML = ''; // Clear the container's content
408 const mermaidViewers = [];
409 function initializeMermaidViewers() {
410 const codeBlocks = document.querySelectorAll('.content-wrap > .page-content pre code.language-mermaid');
411 for (const codeBlock of codeBlocks) {
412 // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
413 if (codeBlock.dataset.mermaidViewerInitialized) continue;
415 const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
416 const container = document.createElement('div');
417 container.className = CSS_CLASSES.CONTAINER;
420 const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
421 const targetBounds = replaceTarget.getBoundingClientRect();
423 // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
424 if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
426 container.style.height = `${targetBounds.height}px`;
427 replaceTarget.after(container);
428 replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
430 const viewer = new InteractiveMermaidViewer(container, mermaidCode);
431 mermaidViewers.push(viewer);
432 codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
436 // Initialize on DOMContentLoaded
437 if (document.readyState === 'loading') {
438 document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
440 // DOMContentLoaded has already fired
441 initializeMermaidViewers();
444 const recenterAllViewers = () => mermaidViewers.forEach(viewer => viewer.centerDiagram());
446 // Re-center diagrams on window load and window resize, as images/fonts inside SVG might affect size
447 window.addEventListener('load', () => {
448 // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
449 setTimeout(recenterAllViewers, 100);
452 window.addEventListener('resize', recenterAllViewers);