Reviewed mermaid viewer hack
Modified with the changes: - Split out files to make use of the public files system, and so it's not a massive chunk of head content loaded on all pages. - Added a little extra size control during load to reduce bouncing. - Updated code block targeting just to avoid potention editor conflicts. - Removed some non-existing CSS variable usages. - Tweaked hack wording/guidance a little.
This commit is contained in:
parent 4a572fa7e5
commit a7185d10f9
5 changed files with 583 additions and 571 deletions
File diff suppressed because it is too large Load diff
| @ -1,13 +1,12 @@ | |||
+++ | ||||
title = "Mermaid viewer" | ||||
title = "Mermaid Viewer" | ||||
author = "@Alexander-Wilms" | ||||
date = 2024-07-16T00:00:00Z | ||||
updated = 2024-07-16T00:00:00Z | ||||
tested = "v25.05" | ||||
date = 2025-06-27T00:00:00Z | ||||
updated = 2025-06-27T00:00:00Z | ||||
tested = "v25.05.1" | ||||
+++ | ||||
| ||||
This hack enables interactive Mermaid diagrams to be rendered within a page on BookStack. The Mermaid code itself can be written and edited using either BookStack's WYSIWYG editor by creating a code block and assigning it the language "mermaid" or the Markdown editor (using standard ` ```mermaid ... ``` ` code fences). | ||||
It automatically detects `pre code` blocks with the class `language-mermaid` and replaces them with a feature-rich viewer: | ||||
This hack enables interactive Mermaid diagrams to be rendered within a page on BookStack. The Mermaid diagram code itself can be written & edited using either BookStack's WYSIWYG editor, by creating a code block and assigning it the language "mermaid", or via the Markdown editor using standard `mermaid` code fences like so: | ||||
| ||||
````markdown | ||||
```mermaid | ||||
| @ -20,7 +19,8 @@ flowchart TD | |||
``` | ||||
```` | ||||
| ||||
The viewer provides the following functionalities: | ||||
On page view, the hack will replace these "mermaid" code blocks with a feature-rich viewer. The viewer provides the following functionalities: | ||||
| ||||
- **Pan and Zoom:** Pan the diagram by clicking and dragging, zoom using the mouse wheel or use dedicated buttons for both. | ||||
- **Interaction Toggle:** Toggle manual interaction (pan/zoom) on or off using a lock/unlock button so it doesn't interfere with scrolling the page. | ||||
- **Reset Zoom:** Reset the diagram to its default zoom level and centered position. | ||||
| @ -31,10 +31,16 @@ The viewer provides the following functionalities: | |||
#### Considerations | ||||
| ||||
- This relies on JavaScript to parse and render content on page load. | ||||
- This loads the Mermaid.js library from `cdn.jsdelivr.net` and Font Awesome icons from `cdnjs.cloudflare.com`. | ||||
- This loads both the Mermaid JavaScript library and Font Awesome icons from external Cloudflare based CDN URLs. | ||||
- Diagrams will not be rendered when using system export options. | ||||
- There is no live preview of the *interactive viewer features* while editing the page content. | ||||
- The viewer injects its own CSS for styling. While designed to be self-contained, there's a minor possibility of style interactions with highly customized BookStack themes. | ||||
- Since the viewer is rendered after page load, it may result in extra visual jumping of page content during page load. | ||||
| ||||
#### Code | ||||
| ||||
{{<hack file="head.html" type="head">}} | ||||
This hack makes use of [publicly accessible files](https://github.com/BookStackApp/BookStack/blob/development/dev/docs/visual-theme-system.md#publicly-accessible-files) via the visual theme system so that the required files are only loaded in when potentially required: | ||||
| ||||
{{<hack file="layouts/parts/base-body-start.blade.php" type="visual">}} | ||||
{{<hack file="public/mermaid-viewer.css" type="visual">}} | ||||
{{<hack file="public/mermaid-viewer.js" type="visual">}} | ||||
| |
| @ -0,0 +1,13 @@ | |||
{{-- Only include on page related views --}} | ||||
@if(request()->fullUrlIs('*/page/*')) | ||||
{{--External Requirements--}} | ||||
<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" defer referrerpolicy="no-referrer" nonce="{{ $cspNonce ?? '' }}"></script> | ||||
{{--Use files from theme folder--}} | ||||
<link rel="stylesheet" href="{{ url('/theme/' . \BookStack\Facades\Theme::getTheme() . '/mermaid-viewer.css') }}"> | ||||
<script src="{{ url('/theme/' . \BookStack\Facades\Theme::getTheme() . '/mermaid-viewer.js') }}" type="module" nonce="{{ $cspNonce ?? '' }}"></script> | ||||
@endif |
103 content/mermaid-viewer/public/mermaid-viewer.css Normal file
103
content/mermaid-viewer/public/mermaid-viewer.css Normal file | @ -0,0 +1,103 @@ | |||
/* Use BookStack's CSS variables for seamless theme integration */ | ||||
.mermaid-container { | ||||
border: 1px solid #d0d7de; | ||||
border-radius: 6px; | ||||
position: relative; | ||||
margin: 20px 0; | ||||
overflow: hidden; | ||||
} | ||||
| ||||
.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; | ||||
} | ||||
| ||||
.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; | ||||
} |
452 content/mermaid-viewer/public/mermaid-viewer.js Normal file
452
content/mermaid-viewer/public/mermaid-viewer.js Normal file | @ -0,0 +1,452 @@ | |||
// 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('button'); | ||||
button.type = 'button'; | ||||
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%'; | ||||
} | ||||
| ||||
// Remove any set height on the container once the viewer has had a chance to render | ||||
window.requestAnimationFrame(() => { | ||||
this.container.style.removeProperty('height'); | ||||
}); | ||||
} | ||||
| ||||
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('.content-wrap > .page-content 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; | ||||
const targetBounds = replaceTarget.getBoundingClientRect(); | ||||
| ||||
// Check if replaceTarget is already a mermaid-container (e.g. from previous init) | ||||
if (replaceTarget.classList.contains(CSS_CLASSES.CONTAINER)) continue; | ||||
| ||||
container.style.height = `${targetBounds.height}px`; | ||||
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); |
Loading…
Add table
Add a link
Reference in a new issue