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
37 NOTIFICATION: 'mermaid-notification', // Keep existing notification classes
38 NOTIFICATION_SHOW: 'show' // Keep existing notification classes
41 class InteractiveMermaidViewer {
42 constructor(container, mermaidCode) {
43 this.container = container;
44 this.mermaidCode = mermaidCode;
48 this.isDragging = false;
49 this.dragStarted = false;
53 const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length;
54 this.zoomLevels = Array.from(
55 { length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 },
56 (_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces))
59 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
60 if (this.currentZoomIndex === -1) {
61 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
63 this.interactionEnabled = false;
64 this.initialContentOffset = { x: 0, y: 0 };
67 this.toggleInteractionBtn = null;
68 this.copyCodeBtn = null;
69 this.zoomInBtn = null;
70 this.zoomOutBtn = null;
71 this.zoomResetBtn = null;
73 this.boundMouseMoveHandler = this.handleMouseMove.bind(this);
74 this.boundMouseUpHandler = this.handleMouseUp.bind(this);
76 this.setupEventListeners();
80 this.container.innerHTML = `
81 <div class="${CSS_CLASSES.CONTROLS}">
82 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn toggle-interaction" title="Toggle interaction">
83 <i class="${CSS_CLASSES.LOCK_ICON}" aria-hidden="true"></i>
85 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-btn" title="Copy code">
86 <i class="fa fa-copy" aria-hidden="true"></i>
89 <div class="${CSS_CLASSES.ZOOM_CONTROLS}">
90 <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>
91 <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>
92 <div class="${CSS_CLASSES.BUTTON_BASE} mermaid-zoom-btn zoom-reset" title="Reset"><i class="fa fa-refresh" aria-hidden="true"></i></div>
94 <div class="${CSS_CLASSES.VIEWPORT}">
95 <div class="${CSS_CLASSES.CONTENT}">
96 <div class="${CSS_CLASSES.DIAGRAM}">${this.mermaidCode}</div>
100 this.viewport = this.container.querySelector(`.${CSS_CLASSES.VIEWPORT}`);
101 this.content = this.container.querySelector(`.${CSS_CLASSES.CONTENT}`);
102 this.diagram = this.container.querySelector(`.${CSS_CLASSES.DIAGRAM}`);
104 // Cache control elements
105 this.toggleInteractionBtn = this.container.querySelector('.toggle-interaction');
106 this.copyCodeBtn = this.container.querySelector('.mermaid-btn:not(.toggle-interaction)');
107 this.zoomInBtn = this.container.querySelector('.zoom-in');
108 this.zoomOutBtn = this.container.querySelector('.zoom-out');
109 this.zoomResetBtn = this.container.querySelector('.zoom-reset');
111 // Function to render the diagram and perform post-render setup
112 const renderAndSetup = () => {
113 mermaid.run({ nodes: [this.diagram] }).then(() => {
114 this.adjustContainerHeight();
115 this.calculateInitialOffset();
116 this.centerDiagram();
118 console.error("Mermaid rendering error for diagram:", this.mermaidCode, error);
119 this.diagram.innerHTML = `<p style="color: red; padding: 10px;">Error rendering diagram. Check console.</p>`;
123 // Check if Font Awesome is loaded before rendering
124 // This checks for the 'Font Awesome 6 Free' font family, which is common.
125 // Adjust if your Font Awesome version uses a different family name for its core icons.
126 if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available
128 } else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready
129 document.fonts.ready.then(renderAndSetup).catch(err => {
130 renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error
137 adjustContainerHeight() {
138 const svgElement = this.content.querySelector('svg');
140 // Ensure the viewport takes up the height of the rendered SVG
141 this.viewport.style.height = '100%';
145 calculateInitialOffset() {
146 const originalTransform = this.content.style.transform;
147 this.content.style.transform = '';
148 const contentRect = this.content.getBoundingClientRect();
149 const viewportRect = this.viewport.getBoundingClientRect();
150 this.initialContentOffset.x = contentRect.left - viewportRect.left;
151 this.initialContentOffset.y = contentRect.top - viewportRect.top;
152 this.content.style.transform = originalTransform;
155 _getViewportCenterClientCoords() {
156 const viewportRect = this.viewport.getBoundingClientRect();
158 clientX: viewportRect.left + viewportRect.width / 2,
159 clientY: viewportRect.top + viewportRect.height / 2,
163 setupEventListeners() {
164 this.toggleInteractionBtn.addEventListener('click', () => this.toggleInteraction());
165 this.copyCodeBtn.addEventListener('click', () => this.copyCode());
166 this.zoomInBtn.addEventListener('click', () => {
167 const { clientX, clientY } = this._getViewportCenterClientCoords();
168 this.zoom(1, clientX, clientY);
170 this.zoomOutBtn.addEventListener('click', () => {
171 const { clientX, clientY } = this._getViewportCenterClientCoords();
172 this.zoom(-1, clientX, clientY);
174 this.zoomResetBtn.addEventListener('click', () => this.resetZoom());
176 this.viewport.addEventListener('wheel', (e) => {
177 if (!this.interactionEnabled) return;
178 // Prevent default browser scroll/zoom behavior when wheeling over the diagram
180 this.content.classList.add(CSS_CLASSES.ZOOMING);
181 const clientX = e.clientX;
182 const clientY = e.clientY;
183 if (e.deltaY > 0) this.zoom(-1, clientX, clientY);
184 else this.zoom(1, clientX, clientY);
185 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
186 }, { passive: false });
188 this.viewport.addEventListener('mousedown', (e) => {
189 if (!this.interactionEnabled || e.button !== 0) return;
191 this.isDragging = true;
192 this.dragStarted = false;
193 this.startX = e.clientX;
194 this.startY = e.clientY;
195 this.dragBaseTranslateX = this.translateX;
196 this.dragBaseTranslateY = this.translateY;
197 this.viewport.classList.add(CSS_CLASSES.DRAGGING);
198 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
199 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN);
200 this.content.classList.remove(CSS_CLASSES.ZOOMING);
203 // Listen on document for mousemove to handle dragging outside viewport
204 document.addEventListener('mousemove', this.boundMouseMoveHandler);
205 // Listen on window for mouseup to ensure drag ends even if mouse is released outside
206 window.addEventListener('mouseup', this.boundMouseUpHandler, true); // Use capture phase
208 this.viewport.addEventListener('contextmenu', (e) => e.preventDefault());
209 this.viewport.addEventListener('selectstart', (e) => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); });
212 toggleInteraction() {
213 this.interactionEnabled = !this.interactionEnabled;
214 const icon = this.toggleInteractionBtn.querySelector('i');
215 this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString());
217 if (this.interactionEnabled) {
218 icon.className = CSS_CLASSES.UNLOCK_ICON;
219 this.toggleInteractionBtn.title = 'Disable manual interaction';
220 this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED);
221 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state
222 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off
224 icon.className = CSS_CLASSES.LOCK_ICON;
225 this.toggleInteractionBtn.title = 'Enable manual interaction';
226 this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED);
227 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER);
228 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
229 this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag
230 this.dragStarted = false;
231 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
236 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
240 if (!this.isDragging) return;
241 // e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met.
242 const deltaX = e.clientX - this.startX;
243 const deltaY = e.clientY - this.startY;
244 if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) {
245 this.dragStarted = true;
247 if (this.dragStarted) {
248 e.preventDefault(); // Prevent text selection, etc., only when drag has truly started
249 this.translateX = this.dragBaseTranslateX + deltaX;
250 this.translateY = this.dragBaseTranslateY + deltaY;
251 this.updateTransform();
256 if (this.isDragging) {
257 this.isDragging = false;
258 this.dragStarted = false;
259 this.viewport.classList.remove(CSS_CLASSES.DRAGGING);
260 this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN);
261 if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled
262 this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER);
265 this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;
269 const svgElement = this.content.querySelector('svg');
271 const viewportRect = this.viewport.getBoundingClientRect();
272 const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
273 const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
275 const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2;
276 const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2;
278 this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x;
279 this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y;
281 // Initial centering constraints; may need adjustment for very large diagrams.
282 this.translateX = Math.max(0, this.translateX);
283 this.translateY = Math.max(0, this.translateY);
285 this.updateTransform();
289 zoom(direction, clientX, clientY) {
290 this.content.classList.add(CSS_CLASSES.ZOOMING);
291 const oldScale = this.scale;
292 let newZoomIndex = this.currentZoomIndex + direction;
294 if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) {
295 this.currentZoomIndex = newZoomIndex;
296 const newScale = this.zoomLevels[this.currentZoomIndex];
298 const viewportRect = this.viewport.getBoundingClientRect();
299 const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale;
300 const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale;
302 this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale);
303 this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale);
304 this.scale = newScale;
305 this.updateTransform();
307 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
311 this.content.classList.add(CSS_CLASSES.ZOOMING);
312 this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9);
313 if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels
314 this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2);
316 this.scale = this.zoomLevels[this.currentZoomIndex];
317 // Use requestAnimationFrame to ensure layout is stable before centering
318 requestAnimationFrame(() => {
319 this.centerDiagram();
320 setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS);
326 await navigator.clipboard.writeText(this.mermaidCode);
327 this.showNotification('Copied!');
329 // Fallback for older browsers or if clipboard API fails
330 const textArea = document.createElement('textarea');
331 textArea.value = this.mermaidCode;
332 // Style to make it invisible
333 textArea.style.position = 'fixed';
334 textArea.style.top = '-9999px';
335 textArea.style.left = '-9999px';
336 document.body.appendChild(textArea);
339 document.execCommand('copy');
340 this.showNotification('Copied!');
341 } catch (copyError) {
342 console.error('Fallback copy failed:', copyError);
343 this.showNotification('Copy failed.', true); // Error
345 document.body.removeChild(textArea);
349 showNotification(message, isError = false) {
350 let notification = document.querySelector(`.${CSS_CLASSES.NOTIFICATION}`);
352 notification = document.createElement('div');
353 notification.className = CSS_CLASSES.NOTIFICATION;
354 document.body.appendChild(notification);
356 notification.innerHTML = `<i class="fa ${isError ? 'fa-times-circle' : 'fa-check'}"></i> ${message}`;
357 notification.style.background = isError ? '#dc3545' : '#28a745'; // Red for error, green for success
358 // Ensure it's visible before triggering transition
359 notification.style.transform = 'translateX(400px)'; // Reset if previously shown
360 requestAnimationFrame(() => { // Allow repaint
361 notification.classList.add(CSS_CLASSES.NOTIFICATION_SHOW);
364 // Clear any existing timeout to prevent premature hiding if clicked again
365 if (this.notificationTimeout) {
366 clearTimeout(this.notificationTimeout);
368 this.notificationTimeout = setTimeout(() => {
369 notification.classList.remove(CSS_CLASSES.NOTIFICATION_SHOW);
370 this.notificationTimeout = null;
371 }, NOTIFICATION_DISPLAY_TIMEOUT_MS);
375 // Remove event listeners specific to this instance
376 this.toggleInteractionBtn.removeEventListener('click', this.toggleInteraction); // Need to ensure this is the same function reference
377 this.copyCodeBtn.removeEventListener('click', this.copyCode);
378 this.zoomInBtn.removeEventListener('click', this.zoom); // These would need bound versions or careful handling
379 this.zoomOutBtn.removeEventListener('click', this.zoom);
380 this.zoomResetBtn.removeEventListener('click', this.resetZoom);
382 this.viewport.removeEventListener('wheel', this.handleWheel); // Assuming handleWheel is the actual handler
383 this.viewport.removeEventListener('mousedown', this.handleMouseDown); // Assuming handleMouseDown
384 this.viewport.removeEventListener('contextmenu', this.handleContextMenu);
385 this.viewport.removeEventListener('selectstart', this.handleSelectStart);
387 document.removeEventListener('mousemove', this.boundMouseMoveHandler);
388 window.removeEventListener('mouseup', this.boundMouseUpHandler, true);
390 if (this.notificationTimeout) {
391 clearTimeout(this.notificationTimeout);
393 this.container.innerHTML = ''; // Clear the container's content
397 const mermaidViewers = [];
398 function initializeMermaidViewers() {
399 // Adjust the selector if your CMS wraps mermaid code blocks differently
400 const codeBlocks = document.querySelectorAll('pre code.language-mermaid');
401 for (const codeBlock of codeBlocks) {
402 // Ensure we don't re-initialize if this script runs multiple times or content is dynamic
403 if (codeBlock.dataset.mermaidViewerInitialized) continue;
405 const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better
406 const container = document.createElement('div');
407 container.className = CSS_CLASSES.CONTAINER;
409 const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock;
411 // Check if replaceTarget is already a mermaid-container (e.g. from previous init)
412 if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue;
414 replaceTarget.after(container);
415 replaceTarget.remove(); // Remove the original <pre> or <pre><code> block
417 const viewer = new InteractiveMermaidViewer(container, mermaidCode);
418 mermaidViewers.push(viewer);
419 codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized
423 // Initialize on DOMContentLoaded
424 if (document.readyState === 'loading') {
425 document.addEventListener('DOMContentLoaded', initializeMermaidViewers);
427 // DOMContentLoaded has already fired
428 initializeMermaidViewers();
431 // Re-center diagrams on window load, as images/fonts inside SVG might affect size
432 window.addEventListener('load', () => {
433 mermaidViewers.forEach(viewer => {
434 // Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable
435 setTimeout(() => viewer.centerDiagram(), 100);
439 // Optional: If your CMS dynamically adds content, you might need a way to re-run initialization
440 // For example, using a MutationObserver or a custom event.
441 // document.addEventListener('myCMSContentLoaded', () => initializeMermaidViewers());
447 border: 1px solid #d0d7de;
455 /* This will now be 100% of the dynamically set container height */
457 /* Keep this for panning/zooming when content exceeds viewport */
459 /* Default to normal system cursor */
462 /* Ensure viewport cursor is auto when locked, even if active.
463 The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */
464 .mermaid-viewport:not(.interaction-enabled):active {
468 /* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */
469 .mermaid-viewport.interactive-hover {
473 /* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */
474 .mermaid-viewport.interactive-pan {
475 cursor: grabbing !important;
479 transform-origin: 0 0;
480 /* Allow text selection by default (when interaction is locked) */
483 will-change: transform;
486 /* Disable text selection ONLY when interaction is enabled on the viewport */
487 .mermaid-viewport.interaction-enabled .mermaid-content {
491 /* SVG elements inherit cursor from the viewport when interaction is enabled. */
492 .mermaid-viewport.interaction-enabled .mermaid-content svg,
493 .mermaid-viewport.interaction-enabled .mermaid-content svg * {
494 cursor: inherit !important;
495 /* Force inheritance from the viewport's cursor */
498 .mermaid-content.zooming {
499 transition: transform 0.2s ease;
511 .mermaid-viewer-button-base {
512 border: 1px solid #d0d7de;
517 justify-content: center;
519 background: rgba(255, 255, 255, 0.9);
525 .mermaid-viewer-button-base:hover {
529 .mermaid-zoom-controls {
534 flex-direction: column;
539 .mermaid-notification {
547 transform: translateX(400px);
548 transition: transform 0.3s ease;
552 .mermaid-notification.show {
553 transform: translateX(0);