Verbesserung der Meerjungfrauen-Diagramme mit Pan/Zoom und Export
Einleitung
npm-Paket verfügbar: Diese Umsetzung ist jetzt verfügbar als @Mostlylucid/Mermaid-Enhancements - ein produktionsfertiges npm-Paket. Siehe Veröffentlichen von Mermaid Enhancements als npm-Paket für Details, wie man es in Ihren Projekten verwendet.
Mermaid ist ein fantastisches Werkzeug zum Erstellen von Diagrammen aus Text, aber die Standard-Rendering kann für komplexe Diagramme begrenzt sein. Benutzer können nicht einfach vergrößern, um Details zu sehen, schwenken um große Diagramme, oder exportieren Sie sie für Dokumentation. In diesem Artikel, Ich werde Ihnen zeigen, wie ich Mermaid Diagramme auf dieser Website mit interaktiven Pan / Zoom-Steuerungen, Vollbild Lightbox-Anzeige und Export-Funktionalitäten (sowohl PNG-und SVG-Formate).
Diese Implementierung ist produktionsbereit, handhabt dunkle Modus schalten anmutig, und ist widerstandsfähig gegen Cloudflare Rocket Loader Interferenzen.
Wie es aussieht
Eine schöne in Seite (und Popout) mermaid.js Anzeige, die ist irgendwie wie GitHub's aber besser. Mittel Diagramme nehmen SCREENS nicht auf, sondern sind immer noch easuu zu lesen.

Das Problem
Aus dem Kasten, Mermaid Diagramme haben mehrere Einschränkungen:
- Feste Größe - Große Diagramme sind entweder abgeschnitten oder geschrumpft
- Keine Interaktivität - Kann nicht hineinzoomen, um Details zu sehen oder herum zu schwenken
- Keine Ausfuhr - Benutzer können keine Diagramme für die externe Verwendung speichern
- Schlechte mobile Erfahrung - Kleine Bildschirme machen komplexe Diagramme unbrauchbar
- Themenwechsel-Probleme - Diagramme re-render nicht immer korrekt beim Umschalten zwischen Licht- und Dunkelmodus
Die Lösung
Ich habe ein umfassendes Verbesserungssystem implementiert, das fügt hinzu:
- Interaktiver Schwenk/Zoom Verwendung der Bibliothek svg-pan-zoom
- Drehtasten für die Schwimmsteuerung zum Ein-/Auszoomen, Zurücksetzen, Schwenken und Exportieren
- Vollbild-Lightbox Modus für eine bessere Ansicht
- Export von PNG und SVG Funktionstüchtigkeit
- Auto-Fit bei Last so ist das gesamte Diagramm standardmäßig sichtbar
- Anzeige der vollen Breite Beseitigung künstlicher Größenbeschränkungen
Überblick über die Architektur
Die Lösung besteht aus drei Hauptkomponenten:
graph TB
A[mermaid_theme_switch.js] -->|Initializes| B[Mermaid Diagrams]
B -->|Renders SVG| C[mermaid_enhancements.js]
C -->|Adds| D[Control Buttons]
C -->|Initializes| E[svg-pan-zoom]
D -->|Triggers| F[Pan/Zoom Actions]
D -->|Triggers| G[Export Functions]
D -->|Triggers| H[Fullscreen Lightbox]
Durchführung
Abhängigkeiten installieren
Installieren Sie zunächst die erforderlichen npm-Pakete:
npm install svg-pan-zoom html-to-image
Diese Bibliotheken bieten:
svg-pan-zoom- Interaktive Pan- und Zoom-Funktionalität für SVG-Elementehtml-to-image- Exportieren der SVG/PNG-Funktionalität
Kernverbesserungsmodul
Das Hauptverbesserungsmodul (mermaid_enhancements.js) behandelt alle interaktiven Funktionen.
Steuerknöpfe erstellen
Jedes Diagramm erhält ein schwebendes Bedienfeld mit Tasten für alle Aktionen:
function createControlButtons(container, diagramId) {
// Check if controls already exist
if (container.querySelector('.mermaid-controls')) {
return;
}
const controlsDiv = document.createElement('div');
controlsDiv.className = 'mermaid-controls';
const buttons = [
{ icon: 'bx-fullscreen', title: 'Fullscreen', action: 'fullscreen' },
{ icon: 'bx-zoom-in', title: 'Zoom In', action: 'zoomIn' },
{ icon: 'bx-zoom-out', title: 'Zoom Out', action: 'zoomOut' },
{ icon: 'bx-reset', title: 'Reset View', action: 'reset' },
{ icon: 'bx-move', title: 'Pan', action: 'pan' },
{ icon: 'bx-image', title: 'Export as PNG', action: 'exportPng' },
{ icon: 'bx-code-alt', title: 'Export as SVG', action: 'exportSvg' }
];
buttons.forEach(btn => {
const button = document.createElement('button');
button.className = `mermaid-control-btn bx ${btn.icon}`;
button.setAttribute('title', btn.title);
button.setAttribute('aria-label', btn.title);
button.setAttribute('data-action', btn.action);
button.setAttribute('data-diagram-id', diagramId);
controlsDiv.appendChild(button);
});
container.appendChild(controlsDiv);
}
Initialisierung von Pfanne/Zoom
Die Bibliothek svg-pan-zoom bietet eine reibungslose, performante Interaktion:
function initPanZoom(svgElement, diagramId) {
// Clean up existing instance if present
if (panZoomInstances.has(diagramId)) {
try {
panZoomInstances.get(diagramId).destroy();
} catch (e) {
console.warn('Failed to destroy existing pan-zoom instance:', e);
}
panZoomInstances.delete(diagramId);
}
try {
const panZoomInstance = svgPanZoom(svgElement, {
zoomEnabled: true,
controlIconsEnabled: false, // We use custom controls
fit: true,
center: true,
minZoom: 0.1,
maxZoom: 10,
zoomScaleSensitivity: 0.3,
dblClickZoomEnabled: true,
mouseWheelZoomEnabled: true,
preventMouseEventsDefault: true,
contain: false
});
panZoomInstances.set(diagramId, panZoomInstance);
return panZoomInstance;
} catch (error) {
console.error('Failed to initialize pan-zoom:', error);
return null;
}
}
Exportfunktionalität
Das Exportsystem bewahrt die Diagrammqualität und verarbeitet sowohl PNG- als auch SVG-Formate:
async function exportDiagram(container, format, diagramId) {
try {
const svgElement = container.querySelector('svg');
if (!svgElement) {
window.showToast && window.showToast('No diagram found to export', 3000, 'error');
return;
}
// Clone the SVG to avoid modifying the original
const clonedSvg = svgElement.cloneNode(true);
// Get the viewBox or calculate from bounding box
let viewBox = clonedSvg.getAttribute('viewBox');
if (!viewBox) {
const bbox = svgElement.getBBox();
viewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`;
clonedSvg.setAttribute('viewBox', viewBox);
}
// Parse viewBox to get dimensions
const [, , vbWidth, vbHeight] = viewBox.split(' ').map(Number);
// Set explicit dimensions based on viewBox for proper export
clonedSvg.setAttribute('width', vbWidth);
clonedSvg.setAttribute('height', vbHeight);
// Remove inline styles but keep viewBox
clonedSvg.removeAttribute('style');
clonedSvg.style.backgroundColor = 'transparent';
clonedSvg.style.maxWidth = 'none';
// Create temporary container
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
tempDiv.style.left = '-9999px';
tempDiv.appendChild(clonedSvg);
document.body.appendChild(tempDiv);
let dataUrl;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `mermaid-diagram-${timestamp}`;
if (format === 'png') {
dataUrl = await toPng(clonedSvg, {
backgroundColor: 'white',
pixelRatio: 2 // Higher quality
});
downloadFile(dataUrl, `${filename}.png`);
} else {
dataUrl = await toSvg(clonedSvg, {
backgroundColor: 'transparent'
});
downloadFile(dataUrl, `${filename}.svg`);
}
// Clean up
document.body.removeChild(tempDiv);
window.showToast && window.showToast(`Diagram exported as ${format.toUpperCase()}`, 3000, 'success');
} catch (error) {
console.error('Failed to export diagram:', error);
window.showToast && window.showToast('Failed to export diagram', 3000, 'error');
}
}
Wichtigste Ausfuhrüberlegungen:
- Ansicht beibehaltenBox - Kritisch für die Erfassung des gesamten Diagramms, nicht nur der sichtbare Teil
- Abmessungen berechnen - Explizit eingestellte Breite/Höhe von viewBox für konsistenten Export
- Transformationen entfernen - Streifen Pan-Zoom-Transformationen so Export zeigt das vollständige Diagramm
- Umgang mit dem Hintergrund - Weißer Hintergrund für PNG, transparent für SVG
- Hohe Auflösung - Verwendung
pixelRatio: 2für knusprige PNG-Exporte
Vollbild-Lightbox
Die Lightbox bietet ein immersives Seherlebnis:
function openFullscreenLightbox(container, diagramId) {
const svgElement = container.querySelector('svg');
if (!svgElement) return;
// Create lightbox overlay
const lightbox = document.createElement('div');
lightbox.className = 'mermaid-lightbox';
lightbox.innerHTML = `
<div class="mermaid-lightbox-content">
<button class="mermaid-lightbox-close bx bx-x" aria-label="Close"></button>
<div class="mermaid-lightbox-diagram-wrapper">
<div class="mermaid-lightbox-diagram"></div>
</div>
</div>
`;
// Clone and prepare SVG
const clonedSvg = svgElement.cloneNode(true);
clonedSvg.removeAttribute('width');
clonedSvg.removeAttribute('height');
clonedSvg.style.width = '100%';
clonedSvg.style.height = '100%';
const diagramContainer = lightbox.querySelector('.mermaid-lightbox-diagram');
diagramContainer.appendChild(clonedSvg);
// Add controls to lightbox
const wrapper = lightbox.querySelector('.mermaid-lightbox-diagram-wrapper');
const lightboxDiagramId = `${diagramId}-lightbox`;
createControlButtons(wrapper, lightboxDiagramId);
document.body.appendChild(lightbox);
// Initialize pan-zoom after layout completes
setTimeout(() => {
const panZoom = initPanZoom(clonedSvg, lightboxDiagramId);
if (panZoom) {
panZoom.resize();
panZoom.fit();
panZoom.center();
}
}, 100);
// Close handlers
const closeLightbox = () => {
if (panZoomInstances.has(lightboxDiagramId)) {
try {
panZoomInstances.get(lightboxDiagramId).destroy();
} catch (e) {
console.warn('Failed to destroy lightbox pan-zoom:', e);
}
panZoomInstances.delete(lightboxDiagramId);
}
lightbox.remove();
};
lightbox.querySelector('.mermaid-lightbox-close').addEventListener('click', closeLightbox);
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
// ESC key to close
const escHandler = (e) => {
if (e.key === 'Escape') {
closeLightbox();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
Hauptverbesserungsfunktion
Das verbindet alles und wird nach Mermaids Rendern genannt:
export function enhanceMermaidDiagrams() {
const diagrams = document.querySelectorAll('.mermaid[data-processed="true"]');
diagrams.forEach(diagram => {
const svgElement = diagram.querySelector('svg');
if (!svgElement) return;
// CRITICAL: Remove inline max-width constraint that Mermaid adds
svgElement.style.maxWidth = 'none';
// Wrap diagram with controls
const diagramId = wrapDiagramWithControls(diagram);
// Initialize pan/zoom and auto-fit
const panZoom = initPanZoom(svgElement, diagramId);
if (panZoom) {
// Fit diagram to container by default
setTimeout(() => {
panZoom.resize();
panZoom.fit();
panZoom.center();
}, 100);
}
});
// Set up event delegation for control buttons (only once)
if (!document.body.hasAttribute('data-mermaid-controls-initialized')) {
document.body.addEventListener('click', handleControlClick);
document.body.setAttribute('data-mermaid-controls-initialized', 'true');
}
}
Kritische Korrektur: Meerjungfrau gilt eine Inline style="max-width: 1020px" zu SVG-Elementen, was die Anzeige in voller Breite verhindert. Das Entfernen ist für ein korrektes responsives Verhalten unerlässlich.
Themenintegration
Der Themenschalter stellt sicher, dass Diagramme beim Umschalten zwischen hellen und dunklen Modi korrekt re-rendern:
import { enhanceMermaidDiagrams } from './mermaid_enhancements';
const loadMermaid = async (theme) => {
if (!window.mermaid) return;
try {
window.mermaid.initialize({
startOnLoad: false,
theme,
themeVariables: {
background: 'transparent'
}
});
await window.mermaid.run({
querySelector: elementSelector,
});
// Enhance diagrams after rendering completes
// Use requestAnimationFrame for better timing
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
enhanceMermaidDiagrams();
resolve();
});
});
});
} catch (err) {
console.error('Mermaid render error:', err);
}
};
Verwendung requestAnimationFrame zweimal stellt sicher, dass der Browser das Malen des SVG abgeschlossen hat, bevor wir versuchen, es zu verbessern.
Cloudflare Rocket Loader Kompatibilität
Cloudflare Rocket Loader kann die Ausführung von JavaScript verzögern und die Initialisierung unterbrechen. Hier ist die kugelsichere Lösung:
// Wait for all dependencies to load with exponential backoff
function waitForDependencies(maxAttempts = 50) {
return new Promise((resolve) => {
let attempts = 0;
const checkDependencies = () => {
attempts++;
const depsReady =
typeof window.hljs !== 'undefined' &&
typeof window.mermaid !== 'undefined' &&
typeof window.Alpine !== 'undefined' &&
typeof window.htmx !== 'undefined';
if (depsReady) {
console.log('All dependencies loaded after', attempts, 'attempts');
// Start Alpine.js now that it's loaded
if (window.Alpine && !window.Alpine.version) {
try {
window.Alpine.start();
console.log('Alpine.js started');
} catch (err) {
console.error('Failed to start Alpine:', err);
}
}
resolve();
} else if (attempts >= maxAttempts) {
console.warn('Timeout waiting for dependencies');
resolve(); // Continue anyway
} else {
// Retry with exponential backoff
const delay = Math.min(50 * Math.pow(1.2, attempts), 500);
setTimeout(checkDependencies, delay);
}
};
checkDependencies();
});
}
// Robust initialization
async function safeInitialize() {
try {
await waitForDependencies();
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
});
}
await initializePage();
} catch (err) {
console.error('Failed to initialize page:', err);
// Retry once after delay
setTimeout(() => {
initializePage().catch(e => console.error('Retry failed:', e));
}, 1000);
}
}
safeInitialize();
Vergewissern Sie sich auch, dass Ihr Hauptskript die data-cfasync="false" Attribut, um es von Rocket Loader auszuschließen:
<script src="~/js/dist/main.js" type="module" asp-append-version="true" data-cfasync="false"></script>
Styling mit Tailwind/DaisyUI
Das CSS verwendet Tailwind Utility Klassen und benutzerdefinierte Styling für Polieren:
/* Mermaid diagram wrapper */
.mermaid-wrapper {
@apply relative rounded-lg overflow-hidden w-full;
margin: 1rem 0;
}
.mermaid-wrapper .mermaid {
@apply m-0 w-full;
min-height: 500px;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.mermaid-wrapper .mermaid svg {
width: 100% !important;
height: auto !important;
min-height: 450px;
}
/* Control buttons */
.mermaid-controls {
@apply absolute top-2 right-2 flex gap-1 z-10;
background: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .mermaid-controls {
background: rgba(31, 41, 55, 0.95);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.mermaid-control-btn {
@apply p-2 rounded cursor-pointer transition-all duration-200;
background: transparent;
border: none;
color: #4b5563;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.mermaid-control-btn:hover {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
transform: scale(1.1);
}
.dark .mermaid-control-btn {
color: #9ca3af;
}
.dark .mermaid-control-btn:hover {
background: rgba(55, 65, 81, 0.8);
color: #60a5fa;
}
/* Lightbox */
.mermaid-lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
.dark .mermaid-lightbox {
background: rgba(0, 0, 0, 0.95);
}
.mermaid-lightbox-content {
@apply relative w-11/12 h-5/6 bg-white rounded-lg shadow-2xl;
max-width: 1400px;
}
.dark .mermaid-lightbox-content {
@apply bg-gray-800;
}
.mermaid-lightbox-close {
@apply absolute top-4 right-4 z-10 p-2 rounded-full cursor-pointer transition-all;
background: rgba(0, 0, 0, 0.5);
border: none;
color: white;
font-size: 2rem;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
}
.mermaid-lightbox-close:hover {
background: rgba(220, 38, 38, 0.8);
transform: scale(1.1);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
Testen und Debuggen
Hier ist, wie alles zu überprüfen funktioniert:
Ausgabe der Konsole
Bei richtiger Initialisierung sollten Sie sehen:
All dependencies loaded after 1 attempts
Alpine.js started
Highlight.js copy plugin registered
Highlight.js initialized on page load
Mermaid initialized on page load
Document is ready - all initializations complete
HTMX event listener registered successfully
HTMX Dynamischer Inhalt
Nach HTMX-Swaps sollten Sie sehen:
HTMX afterSettle triggered for: contentcontainer
Highlight.js applied after HTMX swap
Mermaid initialized
Mermaid applied after HTMX swap
HTMX afterSettle complete for: contentcontainer
Prüfliste
- Diagramme rendern auf der ersten Seite laden
- Pan/Zoom-Kontrollen
- Vollbild-Lichtkasten öffnet und schließt (X-Taste, klicken Sie draußen, ESC-Taste)
- PNG-Export erfasst vollständiges Diagramm (nicht nur Ecke)
- SVG Export bewahrt Vektoren
- Funktioniert nach HTMX Content Swap
- Thema Schalten Re-Renders Diagramme richtig
- Mobile responsive (Steuerungen bleiben sichtbar, Diagrammskala)
- Dunkel Modus Styling gilt richtig
- Tastatur-Zugänglichkeit (Tab zu Steuerelementen, Eingabe zum Aktivieren)
Leistungserwägungen
- Faule Initialisierung - Nur Diagramme verbessern, die auf der Seite vorhanden sind
- Bereinigung der Instanz - Pan-Zoom-Instanzen zerstören, wenn Diagramme entfernt werden
- Delegation der Veranstaltung - Einzelklick-Hörer verarbeitet alle Steuerknöpfe
- AnfrageAnimationFrame - Besseres Timing als willkürliche SetTimeout-Werte
- Entschuldete Ausfuhren - Schnellfeuer Export-Klicks verhindern
Browser-Kompatibilität
Getestet und bearbeitet an:
- Chrom/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile Browser (iOS Safari, Chrome Mobile)
IE11 wird nicht unterstützt durch moderne JavaScript-Funktionen (Const, Pfeilfunktionen, async/await, RequestAnimationFrame).
Schlußfolgerung
Diese umfassende Erweiterung verwandelt statische Mermaid-Diagramme in interaktive, exportierbare Visualisierungen. Die Implementierung ist produktionsbereit, widerstandsfähig gegen Randfälle und bietet eine hervorragende Benutzererfahrung.
Schlüssel zum Mitnehmen:
- Entfernen Mermaid Inline
max-widthEinschränkungen für Vollbreitendiagramme - Preserve viewBox beim Exportieren zur Erfassung des gesamten Diagramms
- AnimationFrame für Timing anstelle von willkürlichen Verzögerungen verwenden
- Handle Cloudflare Rocket Loader mit Abhängigkeitsprüfung und retry Logik
- Geben Sie mehrere Close-Methoden für Lightbox (Knopf, klicken Sie draußen, ESC)
- Event-Delegation für Performance mit vielen Diagrammen nutzen
Verwendung des npm-Pakets
Anstatt Code zu kopieren, können Sie diese Funktionalität nun als npm-Paket installieren:
npm install @mostlylucid/mermaid-enhancements
import { init } from '@mostlylucid/mermaid-enhancements';
import '@mostlylucid/mermaid-enhancements/styles.css';
await init();
Siehe Veröffentlichen von Mermaid Enhancements als npm-Paket für vollständige Dokumentation, Beispiele für die Integration von Frameworks und erweiterte Konfigurationsoptionen.
Der komplette Quellcode ist auch im Repository dieses Blogs unter Mostlylucid/src/js/mermaid_enhancements.js und als Open-Source npm-Paket bei meistlucidweb/meistlucid-mermaid.
Beispieldiagramm
Hier ist ein komplexes Beispiel, das die Architektur des Content-Systems dieses Blogs zeigt:
graph TB
subgraph Client["Client Browser"]
A[User Request] -->|HTMX| B[Blog Controller]
B -->|Cache Miss| C[Blog Service]
C -->|File Mode| D[Markdown Service]
C -->|DB Mode| E[EF Core Context]
D -->|Parse| F[Markdig Pipeline]
F -->|Render| G[HTML + Mermaid]
E -->|Query| H[PostgreSQL]
H -->|Full-Text Search| I[GIN Index]
G -->|Enhance| J[mermaid_enhancements.js]
J -->|Initialize| K[svg-pan-zoom]
J -->|Add| L[Control Buttons]
L -->|Export| M[html-to-image]
end
subgraph Background["Background Services"]
N[File Watcher] -->|Change Detected| O[Saves to DB]
O -->|Trigger| P[Translation Service]
P -->|Batch| Q[EasyNMT API]
Q -->|12 Languages| R[Translated Files]
end
style A stroke:#22c55e,stroke-width:3px,color:#4ade80
style G stroke:#3b82f6,stroke-width:3px,color:#60a5fa
style J stroke:#f59e0b,stroke-width:3px,color:#f59e0b
style K stroke:#ec4899,stroke-width:3px,color:#ec4899
style M stroke:#8b5cf6,stroke-width:3px,color:#8b5cf6
Versuchen Sie, die Steuerelemente auf dem Diagramm oben klicken!