]> BookStack Code Mirror - hacks/blob - content/mermaid-viewer/public/mermaid-viewer.js
Reviewed mermaid viewer hack
[hacks] / content / mermaid-viewer / public / mermaid-viewer.js
1 // Detect if BookStack's dark mode is enabled
2 const isDarkMode = document.documentElement.classList.contains('dark-mode');
3
4 // Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode
5 mermaid.initialize({
6     startOnLoad: false,
7     securityLevel: 'loose',
8     theme: isDarkMode ? 'dark' : 'default'
9 });
10
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;
16
17 const DRAG_THRESHOLD_PIXELS = 3;
18 const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200;
19
20 const CSS_CLASSES = {
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',
28     DRAGGING: 'dragging',
29     ZOOMING: 'zooming',
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
35 };
36
37 class InteractiveMermaidViewer {
38     constructor(container, mermaidCode) {
39         this.container = container;
40         this.mermaidCode = mermaidCode;
41         this.scale = 1.0;
42         this.translateX = 0;
43         this.translateY = 0;
44         this.isDragging = false;
45         this.dragStarted = false;
46         this.startX = 0;
47         this.startY = 0;
48
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))
53         );
54
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);
58         }
59         this.interactionEnabled = false;
60         this.initialContentOffset = { x: 0, y: 0 };
61
62         // Cache DOM elements
63         this.toggleInteractionBtn = null;
64         this.copyCodeBtn = null;
65         this.zoomInBtn = null;
66         this.zoomOutBtn = null;
67         this.zoomResetBtn = null;
68
69         // Use an AbortController for robust event listener cleanup.
70         this.abortController = new AbortController();
71
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(); };
84
85         this.setupViewer();
86         this.setupEventListeners();
87     }
88
89     /**
90      * Creates the DOM structure for the viewer programmatically.
91      * This is safer and more maintainable than using innerHTML with a large template string.
92      */
93     setupViewer() {
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(' ')}`;
98             button.title = title;
99             const icon = document.createElement('i');
100             icon.className = iconClass;
101             icon.setAttribute('aria-hidden', 'true');
102             button.append(icon);
103             return button;
104         };
105
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);
111
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);
118
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;
124
125         this.content = document.createElement('div');
126         this.content.className = CSS_CLASSES.CONTENT;
127         this.content.append(this.diagram);
128
129         this.viewport = document.createElement('div');
130         this.viewport.className = CSS_CLASSES.VIEWPORT;
131         this.viewport.append(this.content);
132
133         // Clear the container and append the new structure
134         this.container.innerHTML = '';
135         this.container.append(controls, zoomControls, this.viewport);
136
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();
143             }).catch(error => {
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>`;
147             });
148         };
149
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
154             renderAndSetup();
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
158             });
159         } else {
160             renderAndSetup();
161         }
162     }
163
164     adjustContainerHeight() {
165         const svgElement = this.content.querySelector('svg');
166         if (svgElement) {
167             // Ensure the viewport takes up the height of the rendered SVG
168             this.viewport.style.height = '100%';
169         }
170
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');
174         });
175     }
176
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;
185     }
186
187     _getViewportCenterClientCoords() {
188         const viewportRect = this.viewport.getBoundingClientRect();
189         return {
190             clientX: viewportRect.left + viewportRect.width / 2,
191             clientY: viewportRect.top + viewportRect.height / 2,
192         };
193     }
194
195     setupEventListeners() {
196         const { signal } = this.abortController;
197
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 });
203
204         this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false, signal });
205         this.viewport.addEventListener('mousedown', this.boundHandleMouseDown, { signal });
206
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 });
211
212         this.viewport.addEventListener('contextmenu', this.boundPreventDefault, { signal });
213         this.viewport.addEventListener('selectstart', this.boundPreventSelect, { signal });
214     }
215
216     toggleInteraction() {
217         this.interactionEnabled = !this.interactionEnabled;
218         const icon = this.toggleInteractionBtn.querySelector('i');
219         this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
220
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
227         } else {
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);
236         }
237     }
238
239     updateTransform() {
240         this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
241     }
242
243     handleZoomClick(direction) {
244         const { clientX, clientY } = this._getViewportCenterClientCoords();
245         this.zoom(direction, clientX, clientY);
246     }
247
248     handleWheel(e) {
249         if (!this.interactionEnabled) return;
250         // Prevent default browser scroll/zoom behavior when wheeling over the diagram
251         e.preventDefault();
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);
258     }
259
260     handleMouseDown(e) {
261         if (!this.interactionEnabled || e.button !== 0) return;
262         e.preventDefault();
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);
273     }
274
275     handleMouseMove(e) {
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;
282         }
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();
288         }
289     }
290
291     handleMouseUp() {
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);
299             }
300         }
301         this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
302     }
303
304     centerDiagram() {
305         const svgElement = this.content.querySelector('svg');
306         if (svgElement) {
307             const viewportRect = this.viewport.getBoundingClientRect();
308             const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
309             const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
310
311             const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
312             const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
313
314             this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
315             this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
316
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);
320
321             this.updateTransform();
322         }
323     }
324
325     zoom(direction, clientX, clientY) {
326         this.content.classList.add(CSS_CLASSES.ZOOMING);
327         const oldScale = this.scale;
328         let newZoomIndex = this.currentZoomIndex + direction;
329
330         if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
331             this.currentZoomIndex = newZoomIndex;
332             const newScale = this.zoomLevels[this.currentZoomIndex];
333
334             const viewportRect = this.viewport.getBoundingClientRect();
335             const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
336             const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
337
338             this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
339             this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
340             this.scale = newScale;
341             this.updateTransform();
342         }
343         setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
344     }
345
346     resetZoom() {
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);
351         }
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);
357         });
358     }
359
360     async copyCode() {
361         try {
362             await navigator.clipboard.writeText(this.mermaidCode);
363             this.showNotification('Copied!');
364         } catch (error) {
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);
374             textArea.select();
375             try {
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
381             }
382             document.body.removeChild(textArea);
383         }
384     }
385
386     showNotification(message, isError = false) {
387         if (window.$events) {
388             const eventName = isError ? 'error' : 'success';
389             window.$events.emit(eventName, message);
390         } else {
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.');
393             if (isError) {
394                 console.error(message);
395             } else {
396                 console.log(message);
397             }
398         }
399     }
400
401     destroy() {
402         // Abort all listeners attached with this controller's signal.
403         this.abortController.abort();
404         this.container.innerHTML = ''; // Clear the container's content
405     }
406 }
407
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;
414
415         const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
416         const container = document.createElement('div');
417         container.className = CSS_CLASSES.CONTAINER;
418
419
420         const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
421         const targetBounds = replaceTarget.getBoundingClientRect();
422
423         // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
424         if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
425
426         container.style.height = `${targetBounds.height}px`;
427         replaceTarget.after(container);
428         replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
429
430         const viewer = new InteractiveMermaidViewer(container, mermaidCode);
431         mermaidViewers.push(viewer);
432         codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
433     }
434 }
435
436 // Initialize on DOMContentLoaded
437 if (document.readyState === 'loading') {
438     document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
439 } else {
440     // DOMContentLoaded has already fired
441     initializeMermaidViewers();
442 }
443
444 const recenterAllViewers = () => mermaidViewers.forEach(viewer => viewer.centerDiagram());
445
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);
450 });
451
452 window.addEventListener('resize', recenterAllViewers);