Adds event listener to recenter mermaid diagrams when the window is resized.
562 lines No EOL 25 KiB HTML
562 lines
No EOL
25 KiB
HTML
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" | |
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" | |
crossorigin="anonymous" referrerpolicy="no-referrer" /> | |
| |
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.7.0/mermaid.min.js" | |
integrity="sha512-ecc+vlmmc1f51s2l/AeIC552wULnv9Q8bYJ4FbODxsL6jGrFoLaKnGkN5JUZNH6LBjkAYy9Q4fKqyTuFUIvvFA==" | |
crossorigin="anonymous" referrerpolicy="no-referrer"></script> | |
<script type="module"> | |
// Detect if BookStack's dark mode is enabled | |
const isDarkMode = document.documentElement.classList.contains('dark-mode'); | |
| |
// Initialize Mermaid.js, dynamically setting the theme based on BookStack's mode | |
mermaid.initialize({ | |
startOnLoad: false, | |
securityLevel: 'loose', | |
theme: isDarkMode ? 'dark' : 'default' | |
}); | |
| |
// Zoom Level Configuration | |
const ZOOM_LEVEL_MIN = 0.5; | |
const ZOOM_LEVEL_MAX = 2.0; | |
const ZOOM_LEVEL_INCREMENT = 0.1; | |
const DEFAULT_ZOOM_SCALE = 1.0; | |
| |
const DRAG_THRESHOLD_PIXELS = 3; | |
const ZOOM_ANIMATION_CLASS_TIMEOUT_MS = 200; | |
| |
const CSS_CLASSES = { | |
CONTAINER: 'mermaid-container', | |
VIEWPORT: 'mermaid-viewport', | |
CONTENT: 'mermaid-content', | |
DIAGRAM: 'mermaid-diagram', | |
CONTROLS: 'mermaid-controls', | |
ZOOM_CONTROLS: 'mermaid-zoom-controls', | |
INTERACTION_ENABLED: 'interaction-enabled', | |
DRAGGING: 'dragging', | |
ZOOMING: 'zooming', | |
LOCK_ICON: 'fa fa-lock', | |
UNLOCK_ICON: 'fa fa-unlock', | |
INTERACTIVE_HOVER: 'interactive-hover', // Class for 'grab' cursor state | |
INTERACTIVE_PAN: 'interactive-pan', // Class for 'grabbing' cursor state | |
BUTTON_BASE: 'mermaid-viewer-button-base' // Base class for all viewer buttons | |
}; | |
| |
class InteractiveMermaidViewer { | |
constructor(container, mermaidCode) { | |
this.container = container; | |
this.mermaidCode = mermaidCode; | |
this.scale = 1.0; | |
this.translateX = 0; | |
this.translateY = 0; | |
this.isDragging = false; | |
this.dragStarted = false; | |
this.startX = 0; | |
this.startY = 0; | |
| |
const numDecimalPlaces = (ZOOM_LEVEL_INCREMENT.toString().split('.')[1] || '').length; | |
this.zoomLevels = Array.from( | |
{ length: Math.round((ZOOM_LEVEL_MAX - ZOOM_LEVEL_MIN) / ZOOM_LEVEL_INCREMENT) + 1 }, | |
(_, i) => parseFloat((ZOOM_LEVEL_MIN + i * ZOOM_LEVEL_INCREMENT).toFixed(numDecimalPlaces)) | |
); | |
| |
this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9); | |
if (this.currentZoomIndex === -1) { | |
this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2); | |
} | |
this.interactionEnabled = false; | |
this.initialContentOffset = { x: 0, y: 0 }; | |
| |
// Cache DOM elements | |
this.toggleInteractionBtn = null; | |
this.copyCodeBtn = null; | |
this.zoomInBtn = null; | |
this.zoomOutBtn = null; | |
this.zoomResetBtn = null; | |
| |
// Use an AbortController for robust event listener cleanup. | |
this.abortController = new AbortController(); | |
| |
// Bind event handlers for proper addition and removal | |
this.boundMouseMoveHandler = this.handleMouseMove.bind(this); | |
this.boundMouseUpHandler = this.handleMouseUp.bind(this); | |
this.boundToggleInteraction = this.toggleInteraction.bind(this); | |
this.boundCopyCode = this.copyCode.bind(this); | |
this.boundZoomIn = this.handleZoomClick.bind(this, 1); | |
this.boundZoomOut = this.handleZoomClick.bind(this, -1); | |
this.boundResetZoom = this.resetZoom.bind(this); | |
this.boundHandleWheel = this.handleWheel.bind(this); | |
this.boundHandleMouseDown = this.handleMouseDown.bind(this); | |
this.boundPreventDefault = e => e.preventDefault(); | |
this.boundPreventSelect = e => { if (this.isDragging || this.interactionEnabled) e.preventDefault(); }; | |
| |
this.setupViewer(); | |
this.setupEventListeners(); | |
} | |
| |
/** | |
* Creates the DOM structure for the viewer programmatically. | |
* This is safer and more maintainable than using innerHTML with a large template string. | |
*/ | |
setupViewer() { | |
const createButton = (title, iconClass, ...extraClasses) => { | |
const button = document.createElement('div'); | |
button.setAttribute('role', 'button'); | |
button.setAttribute('tabindex', '0'); | |
button.className = `${CSS_CLASSES.BUTTON_BASE} ${extraClasses.join(' ')}`; | |
button.title = title; | |
const icon = document.createElement('i'); | |
icon.className = iconClass; | |
icon.setAttribute('aria-hidden', 'true'); | |
button.append(icon); | |
return button; | |
}; | |
| |
const controls = document.createElement('div'); | |
controls.className = CSS_CLASSES.CONTROLS; | |
this.toggleInteractionBtn = createButton('Toggle interaction', CSS_CLASSES.LOCK_ICON, 'mermaid-btn', 'toggle-interaction'); | |
this.copyCodeBtn = createButton('Copy code', 'fa fa-copy', 'mermaid-btn'); | |
controls.append(this.toggleInteractionBtn, this.copyCodeBtn); | |
| |
const zoomControls = document.createElement('div'); | |
zoomControls.className = CSS_CLASSES.ZOOM_CONTROLS; | |
this.zoomInBtn = createButton('Zoom in', 'fa fa-search-plus', 'mermaid-zoom-btn', 'zoom-in'); | |
this.zoomOutBtn = createButton('Zoom out', 'fa fa-search-minus', 'mermaid-zoom-btn', 'zoom-out'); | |
this.zoomResetBtn = createButton('Reset', 'fa fa-refresh', 'mermaid-zoom-btn', 'zoom-reset'); | |
zoomControls.append(this.zoomInBtn, this.zoomOutBtn, this.zoomResetBtn); | |
| |
this.diagram = document.createElement('div'); | |
this.diagram.className = CSS_CLASSES.DIAGRAM; | |
// Use textContent for security, preventing any potential HTML injection. | |
// Mermaid will parse the text content safely. | |
this.diagram.textContent = this.mermaidCode; | |
| |
this.content = document.createElement('div'); | |
this.content.className = CSS_CLASSES.CONTENT; | |
this.content.append(this.diagram); | |
| |
this.viewport = document.createElement('div'); | |
this.viewport.className = CSS_CLASSES.VIEWPORT; | |
this.viewport.append(this.content); | |
| |
// Clear the container and append the new structure | |
this.container.innerHTML = ''; | |
this.container.append(controls, zoomControls, this.viewport); | |
| |
// Function to render the diagram and perform post-render setup | |
const renderAndSetup = () => { | |
mermaid.run({ nodes: [this.diagram] }).then(() => { | |
this.adjustContainerHeight(); | |
this.calculateInitialOffset(); | |
this.centerDiagram(); | |
}).catch(error => { | |
console.error("Mermaid rendering error for diagram:", this.mermaidCode, error); | |
// Use BookStack's negative color variable and provide a clearer message for debugging. | |
this.diagram.innerHTML = `<p style="color: var(--color-neg); padding: 10px;">Error rendering diagram. Check browser console for details.</p>`; | |
}); | |
}; | |
| |
// Check if Font Awesome is loaded before rendering | |
// This checks for the 'Font Awesome 6 Free' font family, which is common. | |
// Adjust if your Font Awesome version uses a different family name for its core icons. | |
if (document.fonts && typeof document.fonts.check === 'function' && document.fonts.check('1em "Font Awesome 6 Free"')) { // Check if Font Awesome is immediately available | |
renderAndSetup(); | |
} else if (document.fonts && document.fonts.ready) { // Simplified check for document.fonts.ready | |
document.fonts.ready.then(renderAndSetup).catch(err => { | |
renderAndSetup(); // Proceed with rendering even if font check fails after timeout/error | |
}); | |
} else { | |
renderAndSetup(); | |
} | |
} | |
| |
adjustContainerHeight() { | |
const svgElement = this.content.querySelector('svg'); | |
if (svgElement) { | |
// Ensure the viewport takes up the height of the rendered SVG | |
this.viewport.style.height = '100%'; | |
} | |
} | |
| |
calculateInitialOffset() { | |
const originalTransform = this.content.style.transform; | |
this.content.style.transform = ''; | |
const contentRect = this.content.getBoundingClientRect(); | |
const viewportRect = this.viewport.getBoundingClientRect(); | |
this.initialContentOffset.x = contentRect.left - viewportRect.left; | |
this.initialContentOffset.y = contentRect.top - viewportRect.top; | |
this.content.style.transform = originalTransform; | |
} | |
| |
_getViewportCenterClientCoords() { | |
const viewportRect = this.viewport.getBoundingClientRect(); | |
return { | |
clientX: viewportRect.left + viewportRect.width / 2, | |
clientY: viewportRect.top + viewportRect.height / 2, | |
}; | |
} | |
| |
setupEventListeners() { | |
const { signal } = this.abortController; | |
| |
this.toggleInteractionBtn.addEventListener('click', this.boundToggleInteraction, { signal }); | |
this.copyCodeBtn.addEventListener('click', this.boundCopyCode, { signal }); | |
this.zoomInBtn.addEventListener('click', this.boundZoomIn, { signal }); | |
this.zoomOutBtn.addEventListener('click', this.boundZoomOut, { signal }); | |
this.zoomResetBtn.addEventListener('click', this.boundResetZoom, { signal }); | |
| |
this.viewport.addEventListener('wheel', this.boundHandleWheel, { passive: false, signal }); | |
this.viewport.addEventListener('mousedown', this.boundHandleMouseDown, { signal }); | |
| |
// Listen on document for mousemove to handle dragging outside viewport | |
document.addEventListener('mousemove', this.boundMouseMoveHandler, { signal }); | |
// Listen on window for mouseup to ensure drag ends even if mouse is released outside | |
window.addEventListener('mouseup', this.boundMouseUpHandler, { signal, capture: true }); | |
| |
this.viewport.addEventListener('contextmenu', this.boundPreventDefault, { signal }); | |
this.viewport.addEventListener('selectstart', this.boundPreventSelect, { signal }); | |
} | |
| |
toggleInteraction() { | |
this.interactionEnabled = !this.interactionEnabled; | |
const icon = this.toggleInteractionBtn.querySelector('i'); | |
this.toggleInteractionBtn.setAttribute('aria-pressed', this.interactionEnabled.toString()); | |
| |
if (this.interactionEnabled) { | |
icon.className = CSS_CLASSES.UNLOCK_ICON; | |
this.toggleInteractionBtn.title = 'Disable manual interaction'; | |
this.viewport.classList.add(CSS_CLASSES.INTERACTION_ENABLED); | |
this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); // Set grab cursor state | |
this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); // Ensure pan cursor state is off | |
} else { | |
icon.className = CSS_CLASSES.LOCK_ICON; | |
this.toggleInteractionBtn.title = 'Enable manual interaction'; | |
this.viewport.classList.remove(CSS_CLASSES.INTERACTION_ENABLED); | |
this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER); | |
this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); | |
this.isDragging = false; // Ensure dragging stops if interaction is disabled mid-drag | |
this.dragStarted = false; | |
this.viewport.classList.remove(CSS_CLASSES.DRAGGING); | |
} | |
} | |
| |
updateTransform() { | |
this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`; | |
} | |
| |
handleZoomClick(direction) { | |
const { clientX, clientY } = this._getViewportCenterClientCoords(); | |
this.zoom(direction, clientX, clientY); | |
} | |
| |
handleWheel(e) { | |
if (!this.interactionEnabled) return; | |
// Prevent default browser scroll/zoom behavior when wheeling over the diagram | |
e.preventDefault(); | |
this.content.classList.add(CSS_CLASSES.ZOOMING); | |
const clientX = e.clientX; | |
const clientY = e.clientY; | |
if (e.deltaY > 0) this.zoom(-1, clientX, clientY); | |
else this.zoom(1, clientX, clientY); | |
setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS); | |
} | |
| |
handleMouseDown(e) { | |
if (!this.interactionEnabled || e.button !== 0) return; | |
e.preventDefault(); | |
this.isDragging = true; | |
this.dragStarted = false; | |
this.startX = e.clientX; | |
this.startY = e.clientY; | |
this.dragBaseTranslateX = this.translateX; | |
this.dragBaseTranslateY = this.translateY; | |
this.viewport.classList.add(CSS_CLASSES.DRAGGING); | |
this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_HOVER); | |
this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_PAN); | |
this.content.classList.remove(CSS_CLASSES.ZOOMING); | |
} | |
| |
handleMouseMove(e) { | |
if (!this.isDragging) return; | |
// e.preventDefault() is called only after dragStarted is true to allow clicks if threshold isn't met. | |
const deltaX = e.clientX - this.startX; | |
const deltaY = e.clientY - this.startY; | |
if (!this.dragStarted && (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS)) { | |
this.dragStarted = true; | |
} | |
if (this.dragStarted) { | |
e.preventDefault(); // Prevent text selection, etc., only when drag has truly started | |
this.translateX = this.dragBaseTranslateX + deltaX; | |
this.translateY = this.dragBaseTranslateY + deltaY; | |
this.updateTransform(); | |
} | |
} | |
| |
handleMouseUp() { | |
if (this.isDragging) { | |
this.isDragging = false; | |
this.dragStarted = false; | |
this.viewport.classList.remove(CSS_CLASSES.DRAGGING); | |
this.viewport.classList.remove(CSS_CLASSES.INTERACTIVE_PAN); | |
if (this.interactionEnabled) { // Revert to grab cursor if interaction is still enabled | |
this.viewport.classList.add(CSS_CLASSES.INTERACTIVE_HOVER); | |
} | |
} | |
this.content.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`; | |
} | |
| |
centerDiagram() { | |
const svgElement = this.content.querySelector('svg'); | |
if (svgElement) { | |
const viewportRect = this.viewport.getBoundingClientRect(); | |
const svgIntrinsicWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth; | |
const svgIntrinsicHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight; | |
| |
const targetContentLeftRelativeToViewport = (viewportRect.width - (svgIntrinsicWidth * this.scale)) / 2; | |
const targetContentTopRelativeToViewport = (viewportRect.height - (svgIntrinsicHeight * this.scale)) / 2; | |
| |
this.translateX = targetContentLeftRelativeToViewport - this.initialContentOffset.x; | |
this.translateY = targetContentTopRelativeToViewport - this.initialContentOffset.y; | |
| |
// Initial centering constraints; may need adjustment for very large diagrams. | |
this.translateX = Math.max(0, this.translateX); | |
this.translateY = Math.max(0, this.translateY); | |
| |
this.updateTransform(); | |
} | |
} | |
| |
zoom(direction, clientX, clientY) { | |
this.content.classList.add(CSS_CLASSES.ZOOMING); | |
const oldScale = this.scale; | |
let newZoomIndex = this.currentZoomIndex + direction; | |
| |
if (newZoomIndex >= 0 && newZoomIndex < this.zoomLevels.length) { | |
this.currentZoomIndex = newZoomIndex; | |
const newScale = this.zoomLevels[this.currentZoomIndex]; | |
| |
const viewportRect = this.viewport.getBoundingClientRect(); | |
const pointXInContent = (clientX - viewportRect.left - this.translateX) / oldScale; | |
const pointYInContent = (clientY - viewportRect.top - this.translateY) / oldScale; | |
| |
this.translateX = (clientX - viewportRect.left) - (pointXInContent * newScale); | |
this.translateY = (clientY - viewportRect.top) - (pointYInContent * newScale); | |
this.scale = newScale; | |
this.updateTransform(); | |
} | |
setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS); | |
} | |
| |
resetZoom() { | |
this.content.classList.add(CSS_CLASSES.ZOOMING); | |
this.currentZoomIndex = this.zoomLevels.findIndex(level => Math.abs(level - DEFAULT_ZOOM_SCALE) < 1e-9); | |
if (this.currentZoomIndex === -1) { // Fallback if default not exactly in levels | |
this.currentZoomIndex = Math.floor(this.zoomLevels.length / 2); | |
} | |
this.scale = this.zoomLevels[this.currentZoomIndex]; | |
// Use requestAnimationFrame to ensure layout is stable before centering | |
requestAnimationFrame(() => { | |
this.centerDiagram(); | |
setTimeout(() => this.content.classList.remove(CSS_CLASSES.ZOOMING), ZOOM_ANIMATION_CLASS_TIMEOUT_MS); | |
}); | |
} | |
| |
async copyCode() { | |
try { | |
await navigator.clipboard.writeText(this.mermaidCode); | |
this.showNotification('Copied!'); | |
} catch (error) { | |
// Fallback for older browsers or if clipboard API fails | |
console.error('Clipboard API copy failed, attempting fallback:', error); | |
const textArea = document.createElement('textarea'); | |
textArea.value = this.mermaidCode; | |
// Style to make it invisible | |
textArea.style.position = 'fixed'; | |
textArea.style.top = '-9999px'; | |
textArea.style.left = '-9999px'; | |
document.body.appendChild(textArea); | |
textArea.select(); | |
try { | |
document.execCommand('copy'); | |
this.showNotification('Copied!'); | |
} catch (copyError) { | |
console.error('Fallback copy failed:', copyError); | |
this.showNotification('Copy failed.', true); // Error | |
} | |
document.body.removeChild(textArea); | |
} | |
} | |
| |
showNotification(message, isError = false) { | |
if (window.$events) { | |
const eventName = isError ? 'error' : 'success'; | |
window.$events.emit(eventName, message); | |
} else { | |
// Fallback for if the event system is not available | |
console.warn('BookStack event system not found, falling back to console log for notification.'); | |
if (isError) { | |
console.error(message); | |
} else { | |
console.log(message); | |
} | |
} | |
} | |
| |
destroy() { | |
// Abort all listeners attached with this controller's signal. | |
this.abortController.abort(); | |
this.container.innerHTML = ''; // Clear the container's content | |
} | |
} | |
| |
const mermaidViewers = []; | |
function initializeMermaidViewers() { | |
const codeBlocks = document.querySelectorAll('pre code.language-mermaid'); | |
for (const codeBlock of codeBlocks) { | |
// Ensure we don't re-initialize if this script runs multiple times or content is dynamic | |
if (codeBlock.dataset.mermaidViewerInitialized) continue; | |
| |
const mermaidCode = codeBlock.textContent || codeBlock.innerHTML; // textContent is usually better | |
const container = document.createElement('div'); | |
container.className = CSS_CLASSES.CONTAINER; | |
| |
const replaceTarget = (codeBlock.nodeName === 'CODE') ? codeBlock.parentElement : codeBlock; | |
| |
// Check if replaceTarget is already a mermaid-container (e.g. from previous init) | |
if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue; | |
| |
replaceTarget.after(container); | |
replaceTarget.remove(); // Remove the original <pre> or <pre><code> block | |
| |
const viewer = new InteractiveMermaidViewer(container, mermaidCode); | |
mermaidViewers.push(viewer); | |
codeBlock.dataset.mermaidViewerInitialized = 'true'; // Mark as initialized | |
} | |
} | |
| |
// Initialize on DOMContentLoaded | |
if (document.readyState === 'loading') { | |
document.addEventListener('DOMContentLoaded', initializeMermaidViewers); | |
} else { | |
// DOMContentLoaded has already fired | |
initializeMermaidViewers(); | |
} | |
| |
const recenterAllViewers = () => mermaidViewers.forEach(viewer => viewer.centerDiagram()); | |
| |
// Re-center diagrams on window load and window resize, as images/fonts inside SVG might affect size | |
window.addEventListener('load', () => { | |
// Delay slightly to ensure mermaid rendering is fully complete and dimensions are stable | |
setTimeout(recenterAllViewers, 100); | |
}); | |
| |
window.addEventListener('resize', recenterAllViewers); | |
| |
</script> | |
<style> | |
/* Use BookStack's CSS variables for seamless theme integration */ | |
.mermaid-container { | |
background: var(--color-bg-alt); | |
border: 1px solid #d0d7de; | |
border-radius: 6px; | |
position: relative; | |
margin: 20px 0; | |
} | |
| |
.mermaid-viewport { | |
height: 100%; | |
/* This will now be 100% of the dynamically set container height */ | |
overflow: hidden; | |
/* Keep this for panning/zooming when content exceeds viewport */ | |
cursor: auto; | |
/* Default to normal system cursor */ | |
} | |
| |
/* Ensure viewport cursor is auto when locked, even if active. | |
The text selection (I-beam) cursor will still appear over selectable text within .mermaid-content. */ | |
.mermaid-viewport:not(.interaction-enabled):active { | |
cursor: auto; | |
} | |
| |
/* Set 'grab' cursor when the viewport has the 'interactive-hover' class. */ | |
.mermaid-viewport.interactive-hover { | |
cursor: grab; | |
} | |
| |
/* Set 'grabbing' cursor when the viewport has the 'interactive-pan' class. */ | |
.mermaid-viewport.interactive-pan { | |
cursor: grabbing !important; | |
} | |
| |
.mermaid-content { | |
transform-origin: 0 0; | |
/* Allow text selection by default (when interaction is locked) */ | |
user-select: auto; | |
/* or 'text' */ | |
will-change: transform; | |
} | |
| |
/* Disable text selection ONLY when interaction is enabled on the viewport */ | |
.mermaid-viewport.interaction-enabled .mermaid-content { | |
user-select: none; | |
} | |
| |
/* SVG elements inherit cursor from the viewport when interaction is enabled. */ | |
.mermaid-viewport.interaction-enabled .mermaid-content svg, | |
.mermaid-viewport.interaction-enabled .mermaid-content svg * { | |
cursor: inherit !important; | |
/* Force inheritance from the viewport's cursor */ | |
} | |
| |
.mermaid-content.zooming { | |
transition: transform 0.2s ease; | |
} | |
| |
.mermaid-controls { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
display: flex; | |
gap: 5px; | |
z-index: 10; | |
} | |
| |
.mermaid-viewer-button-base { | |
border: 1px solid #C0C0C0; | |
border-radius: 6px; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
user-select: none; | |
width: 32px; | |
height: 32px; | |
color: var(--color-text); | |
/* The above color is overridden in dark mode below to ensure visibility */ | |
} | |
| |
.mermaid-viewer-button-base:hover { | |
background: #C8C8C8; | |
} | |
| |
.dark-mode .mermaid-viewer-button-base { | |
background: #282828; | |
border: 1px solid #444444; | |
color: #FFFFFF; | |
/* Explicitly set to white for dark mode icons */ | |
} | |
| |
.dark-mode .mermaid-viewer-button-base:hover { | |
background: #383838; | |
} | |
| |
.mermaid-zoom-controls { | |
position: absolute; | |
bottom: 10px; | |
left: 10px; | |
display: flex; | |
flex-direction: column; | |
gap: 5px; | |
z-index: 10; | |
} | |
</style> |