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>
8 securityLevel: 'loose',
12 // Zoom Level Configuration
13 const ZOOM_LEVEL_MIN = 0.5;
14 const ZOOM_LEVEL_MAX = 2.0;
15 const ZOOM_LEVEL_INCREMENT = 0.1;
16 const DEFAULT_ZOOM_SCALE = 1.0;
18 const DRAG_THRESHOLD_PIXELS = 3;
19 const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
20 const NOTIFICATION_DISPLAY_TIMEOUT_MS = 2000;
23 CONTAINER: 'mermaid-container',
24 VIEWPORT: 'mermaid-viewport',
25 CONTENT: 'mermaid-content',
26 DIAGRAM: 'mermaid-diagram',
27 CONTROLS: 'mermaid-controls',
28 ZOOM_CONTROLS: 'mermaid-zoom-controls',
29 INTERACTION_ENABLED: 'interaction-enabled',
32 LOCK_ICON: 'fa fa-lock',
33 UNLOCK_ICON: 'fa fa-unlock',
34 INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state
35 INTERACTIVE_PAN: 'interactive-pan', // Class for 'grabbing' cursor state
36 BUTTON_BASE: 'mermaid-viewer-button-base' // Base class for all viewer buttons
39 class InteractiveMermaidViewer {
40 constructor(container, mermaidCode) {
41 this.container = container;
42 this.mermaidCode = mermaidCode;
46 this.isDragging = false;
47 this.dragStarted = false;
51 const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
52 this.zoomLevels = Array.from(
53 { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
54 (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
57 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
58 if (this.currentZoomIndex === -1) {
59 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
61 this.interactionEnabled = false;
62 this.initialContentOffset = { x: 0, y: 0 };
65 this.toggleInteractionBtn = null;
66 this.copyCodeBtn = null;
67 this.zoomInBtn = null;
68 this.zoomOutBtn = null;
69 this.zoomResetBtn = null;
71 // Bind event handlers for proper addition and removal
72 this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
73 this.boundMouseUpHandler = this.handleMouseUp.bind(this);
74 this.boundToggleInteraction = this.toggleInteraction.bind(this);
75 this.boundCopyCode = this.copyCode.bind(this);
76 this.boundZoomIn = () => {
77 const { clientX, clientY } = this._getViewportCenterClientCoords();
78 this.zoom(1, clientX, clientY);
80 this.boundZoomOut = () => {
81 const { clientX, clientY } = this._getViewportCenterClientCoords();
82 this.zoom(-1, clientX, clientY);
84 this.boundResetZoom = this.resetZoom.bind(this);
85 this.boundHandleWheel = this.handleWheel.bind(this);
86 this.boundHandleMouseDown = this.handleMouseDown.bind(this);
87 this.boundPreventDefault = e => e.preventDefault();
88 this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); };
91 this.setupEventListeners();
95 this.container.innerHTML = `
96 <div class="${CSS_CLASSES.CONTROLS}">
97 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn toggle-interaction" title="Toggle interaction">
98 <i class="${CSS_CLASSES.LOCK_ICON}" aria-hidden="true"></i>
100 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn" title="Copy code">
101 <i class="fa fa-copy" aria-hidden="true"></i>
104 <div class="${CSS_CLASSES.ZOOM_CONTROLS}">
105 <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>
106 <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>
107 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-reset" title="Reset"><i class="fa fa-refresh" aria-hidden="true"></i></div>
109 <div class="${CSS_CLASSES.VIEWPORT}">
110 <div class="${CSS_CLASSES.CONTENT}">
111 <div class="${CSS_CLASSES.DIAGRAM}">${this.mermaidCode}</div>
115 this.viewport = this.container.querySelector(`.${CSS_CLASSES.VIEWPORT}`);
116 this.content = this.container.querySelector(`.${CSS_CLASSES.CONTENT}`);
117 this.diagram = this.container.querySelector(`.${CSS_CLASSES.DIAGRAM}`);
119 // Cache control elements
120 this.toggleInteractionBtn = this.container.querySelector('.toggle-interaction');
121 this.copyCodeBtn = this.container.querySelector('.mermaid-btn:not(.toggle-interaction)');
122 this.zoomInBtn = this.container.querySelector('.zoom-in');
123 this.zoomOutBtn = this.container.querySelector('.zoom-out');
124 this.zoomResetBtn = this.container.querySelector('.zoom-reset');
126 // Function to render the diagram and perform post-render setup
127 const renderAndSetup = () => {
128 mermaid.run({ nodes: [this.diagram] }).then(() => {
129 this.adjustContainerHeight();
130 this.calculateInitialOffset();
131 this.centerDiagram();
133 console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
134 this.diagram.innerHTML = `<p style="color: red; padding: 10px;">Error rendering diagram. Check console.</p>`;
138 // Check if Font Awesome is loaded before rendering
139 // This checks for the 'Font Awesome 6 Free' font family, which is common.
140 // Adjust if your Font Awesome version uses a different family name for its core icons.
141 if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
143 } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
144 document.fonts.ready.then(renderAndSetup).catch(err => {
145 renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
152 adjustContainerHeight() {
153 const svgElement = this.content.querySelector('svg');
155 // Ensure the viewport takes up the height of the rendered SVG
156 this.viewport.style.height = '100%';
160 calculateInitialOffset() {
161 const originalTransform = this.content.style.transform;
162 this.content.style.transform = '';
163 const contentRect = this.content.getBoundingClientRect();
164 const viewportRect = this.viewport.getBoundingClientRect();
165 this.initialContentOffset.x = contentRect.left - viewportRect.left;
166 this.initialContentOffset.y = contentRect.top - viewportRect.top;
167 this.content.style.transform = originalTransform;
170 _getViewportCenterClientCoords() {
171 const viewportRect = this.viewport.getBoundingClientRect();
173 clientX: viewportRect.left + viewportRect.width / 2,
174 clientY: viewportRect.top + viewportRect.height / 2,
178 setupEventListeners() {
179 this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction);
180 this.copyCodeBtn.addEventListener('click', this.boundCopyCode);
181 this.zoomInBtn.addEventListener('click', this.boundZoomIn);
182 this.zoomOutBtn.addEventListener('click', this.boundZoomOut);
183 this.zoomResetBtn.addEventListener('click', this.boundResetZoom);
185 this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false });
186 this.viewport.addEventListener('mousedown', this.boundHandleMouseDown);
188 // Listen on document for mousemove to handle dragging outside viewport
189 document.addEventListener('mousemove', this.boundMouseMoveHandler);
190 // Listen on window for mouseup to ensure drag ends even if mouse is released outside
191 window.addEventListener('mouseup', this.boundMouseUpHandler, true); // Use capture phase
193 this.viewport.addEventListener('contextmenu', this.boundPreventDefault);
194 this.viewport.addEventListener('selectstart', this.boundPreventSelect);
197 toggleInteraction() {
198 this.interactionEnabled = !this.interactionEnabled;
199 const icon = this.toggleInteractionBtn.querySelector('i');
200 this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
202 if (this.interactionEnabled) {
203 icon.className = CSS_CLASSES.UNLOCK_ICON;
204 this.toggleInteractionBtn.title = 'Disable manual interaction';
205 this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
206 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
207 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
209 icon.className = CSS_CLASSES.LOCK_ICON;
210 this.toggleInteractionBtn.title = 'Enable manual interaction';
211 this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
212 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
213 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
214 this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
215 this.dragStarted = false;
216 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
221 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
225 if (!this.interactionEnabled) return;
226 // Prevent default browser scroll/zoom behavior when wheeling over the diagram
228 this.content.classList.add(CSS_CLASSES.ZOOMING);
229 const clientX = e.clientX;
230 const clientY = e.clientY;
231 if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
232 else this.zoom(1, clientX, clientY);
233 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
237 if (!this.interactionEnabled || e.button !== 0) return;
239 this.isDragging = true;
240 this.dragStarted = false;
241 this.startX = e.clientX;
242 this.startY = e.clientY;
243 this.dragBaseTranslateX = this.translateX;
244 this.dragBaseTranslateY = this.translateY;
245 this.viewport.classList.add(CSS_CLASSES.DRAGGING);
246 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
247 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
248 this.content.classList.remove(CSS_CLASSES.ZOOMING);
252 if (!this.isDragging) return;
253 // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
254 const deltaX = e.clientX - this.startX;
255 const deltaY = e.clientY - this.startY;
256 if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
257 this.dragStarted = true;
259 if (this.dragStarted) {
260 e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
261 this.translateX = this.dragBaseTranslateX + deltaX;
262 this.translateY = this.dragBaseTranslateY + deltaY;
263 this.updateTransform();
268 if (this.isDragging) {
269 this.isDragging = false;
270 this.dragStarted = false;
271 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
272 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
273 if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
274 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
277 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
281 const svgElement = this.content.querySelector('svg');
283 const viewportRect = this.viewport.getBoundingClientRect();
284 const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
285 const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
287 const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
288 const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
290 this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
291 this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
293 // Initial centering constraints; may need adjustment for very large diagrams.
294 this.translateX = Math.max(0, this.translateX);
295 this.translateY = Math.max(0, this.translateY);
297 this.updateTransform();
301 zoom(direction, clientX, clientY) {
302 this.content.classList.add(CSS_CLASSES.ZOOMING);
303 const oldScale = this.scale;
304 let newZoomIndex = this.currentZoomIndex + direction;
306 if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
307 this.currentZoomIndex = newZoomIndex;
308 const newScale = this.zoomLevels[this.currentZoomIndex];
310 const viewportRect = this.viewport.getBoundingClientRect();
311 const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
312 const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
314 this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
315 this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
316 this.scale = newScale;
317 this.updateTransform();
319 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
323 this.content.classList.add(CSS_CLASSES.ZOOMING);
324 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
325 if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
326 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
328 this.scale = this.zoomLevels[this.currentZoomIndex];
329 // Use requestAnimationFrame to ensure layout is stable before centering
330 requestAnimationFrame(() => {
331 this.centerDiagram();
332 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
338 await navigator.clipboard.writeText(this.mermaidCode);
339 this.showNotification('Copied!');
341 // Fallback for older browsers or if clipboard API fails
342 const textArea = document.createElement('textarea');
343 textArea.value = this.mermaidCode;
344 // Style to make it invisible
345 textArea.style.position = 'fixed';
346 textArea.style.top = '-9999px';
347 textArea.style.left = '-9999px';
348 document.body.appendChild(textArea);
351 document.execCommand('copy');
352 this.showNotification('Copied!');
353 } catch (copyError) {
354 console.error('Fallback copy failed:', copyError);
355 this.showNotification('Copy failed.', true); // Error
357 document.body.removeChild(textArea);
361 showNotification(message, isError = false) {
362 if (window.$events) {
363 const eventName = isError ? 'error' : 'success';
364 window.$events.emit(eventName, message);
366 // Fallback for if the event system is not available
367 console.warn('BookStack event system not found, falling back to console log for notification.');
369 console.error(message);
371 console.log(message);
377 // Remove event listeners specific to this instance
378 this.toggleInteractionBtn.removeEventListener('click', this.boundToggleInteraction);
379 this.copyCodeBtn.removeEventListener('click', this.boundCopyCode);
380 this.zoomInBtn.removeEventListener('click', this.boundZoomIn);
381 this.zoomOutBtn.removeEventListener('click', this.boundZoomOut);
382 this.zoomResetBtn.removeEventListener('click', this.boundResetZoom);
384 this.viewport.removeEventListener('wheel', this.boundHandleWheel, { passive: false });
385 this.viewport.removeEventListener('mousedown', this.boundHandleMouseDown);
386 this.viewport.removeEventListener('contextmenu', this.boundPreventDefault);
387 this.viewport.removeEventListener('selectstart', this.boundPreventSelect);
389 document.removeEventListener('mousemove', this.boundMouseMoveHandler);
390 window.removeEventListener('mouseup', this.boundMouseUpHandler, true);
392 this.container.innerHTML = ''; // Clear the container's content
396 const mermaidViewers = [];
397 function initializeMermaidViewers() {
398 // Adjust the selector if your CMS wraps mermaid code blocks differently
399 const codeBlocks = document.querySelectorAll('pre code.language-mermaid');
400 for (const codeBlock of codeBlocks) {
401 // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
402 if (codeBlock.dataset.mermaidViewerInitialized) continue;
404 const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
405 const container = document.createElement('div');
406 container.className = CSS_CLASSES.CONTAINER;
408 const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
410 // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
411 if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
413 replaceTarget.after(container);
414 replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
416 const viewer = new InteractiveMermaidViewer(container, mermaidCode);
417 mermaidViewers.push(viewer);
418 codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
422 // Initialize on DOMContentLoaded
423 if (document.readyState === 'loading') {
424 document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
426 // DOMContentLoaded has already fired
427 initializeMermaidViewers();
430 // Re-center diagrams on window load, as images/fonts inside SVG might affect size
431 window.addEventListener('load', () => {
432 mermaidViewers.forEach(viewer => {
433 // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
434 setTimeout(() => viewer.centerDiagram(), 100);
438 // Optional: If your CMS dynamically adds content, you might need a way to re-run initialization
439 // For example, using a MutationObserver or a custom event.
440 // document.addEventListener('myCMSContentLoaded', () => initializeMermaidViewers());
446 border: 1px solid #d0d7de;
454 /* This will now be 100% of the dynamically set container height */
456 /* Keep this for panning/zooming when content exceeds viewport */
458 /* Default to normal system cursor */
461 /* Ensure viewport cursor is auto when locked, even if active.
462 The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */
463 .mermaid-viewport:not(.interaction-enabled):active {
467 /* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */
468 .mermaid-viewport.interactive-hover {
472 /* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */
473 .mermaid-viewport.interactive-pan {
474 cursor: grabbing !important;
478 transform-origin: 0 0;
479 /* Allow text selection by default (when interaction is locked) */
482 will-change: transform;
485 /* Disable text selection ONLY when interaction is enabled on the viewport */
486 .mermaid-viewport.interaction-enabled .mermaid-content {
490 /* SVG elements inherit cursor from the viewport when interaction is enabled. */
491 .mermaid-viewport.interaction-enabled .mermaid-content svg,
492 .mermaid-viewport.interaction-enabled .mermaid-content svg * {
493 cursor: inherit !important;
494 /* Force inheritance from the viewport's cursor */
497 .mermaid-content.zooming {
498 transition: transform 0.2s ease;
510 .mermaid-viewer-button-base {
511 border: 1px solid #d0d7de;
516 justify-content: center;
518 background: rgba(255, 255, 255, 0.9);
524 .mermaid-viewer-button-base:hover {
528 .mermaid-zoom-controls {
533 flex-direction: column;