Förbättra sjöjungfrudiagram med Pan/Zoom och export
Inledning
npm-paketet finns tillgängligt: Detta genomförande är nu tillgängligt som @mestlylucid/mermaid-enhancements - ett produktionsklart npm-paket. Publicera Mermaid förbättringar som ett npm-paket för detaljer om hur du använder det i dina projekt.
Mermaid är ett fantastiskt verktyg för att skapa diagram från text, men standard rendering kan vara att begränsa för komplexa diagram. Användare kan inte enkelt zooma in för att se detaljer, panorera runt stora diagram, eller exportera dem för dokumentation. I den här artikeln, Jag ska visa dig hur jag förbättrade Mermaid diagram på denna webbplats med interaktiva pan / zoom kontroller, fullscreen ljusbox visning, och export funktionalitet (både PNG- och SVG-format).
Denna implementation är produktionsklar, hanterar mörkt läge växla graciöst, och är motståndskraftig mot Cloudflare Rocket Loader störningar.
Hur det ser ut
Så vad vi går för är detta. En trevlig sida (och popout) sjöjungfru.js display som är typ som GitHubs men bättre. Betyder diagram inte tar upp SCREEENS men är fortfarande easuu att läsa.

Problemet
Ut ur lådan, Mermaid diagram har flera begränsningar:
- Fast storlek - Stora diagram är antingen avskurna eller krympta för att passa
- Ingen interaktivitet - Kan inte zooma in för att se detaljer eller panorera runt
- Ingen export - Användare kan inte spara diagram för extern användning
- Dålig mobil erfarenhet - Små skärmar gör komplexa diagram oanvändbara
- Teman för byte av problem - Diagram re-renderar inte alltid korrekt när man växlar mellan ljus och mörkt läge
Lösningen
Jag har implementerat ett omfattande förbättringssystem som lägger till:
- Interactive pan/zoom att använda biblioteket svg-pan-zoom
- Flytande styrknappar för att zooma in/ut, återställa, panorera och exportera
- Fullskärmsljusbox läge för bättre visning
- PNG- och SVG-export funktionalitet
- Automatisk anpassning vid belastning så hela diagrammet är synligt som standard Seing
- Fullbreddsdisplay ta bort artificiella storleksbegränsningar
Översikt över arkitekturen
Lösningen består av tre huvudkomponenter:
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]
Genomförande
Installera beroenden
Installera först de nödvändiga npm-paketen:
npm install svg-pan-zoom html-to-image
Dessa bibliotek tillhandahåller följande:
svg-pan-zoom- Interactive pan och zoom funktionalitet för SVG elementhtml-to-image- Exportera SVG/PNG-funktionalitet
Modul för kärnförbättring
Den viktigaste förbättringsmodulen (mermaid_enhancements.js) hanterar alla interaktiva funktioner.
Skapa kontrollknappar
Varje diagram får en flytande kontrollpanel med knappar för alla åtgärder:
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);
}
Initiering av Pan/Zoom
Det svg-pan-zoom biblioteket ger smidig, performant 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;
}
}
Exportfunktionalitet
Exportsystemet bevarar diagramkvalitet och hanterar både PNG- och SVG-format:
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');
}
}
Viktiga exportöverväganden:
- Bevara vyruta - Kritisk för att fånga hela diagrammet, inte bara den synliga delen
- Beräkna mått - explicit inställd bredd/höjd från viewBox för konsekvent export
- Ta bort transformer - Strip pan-zoom transformerar så export visar hela diagrammet
- Bakgrundshantering - Vit bakgrund för PNG, transparent för SVG
- Hög upplösning - Användning
pixelRatio: 2för skarp PNG-export
Fullskärmsljusbox
Lightboxen ger en uppslukande visningsupplevelse:
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);
}
Huvudsaklig förbättringsfunktion
Detta knyter ihop allt och kallas efter Mermaid gör:
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');
}
}
Kritisk korrigering: Sjöjungfrun tillämpar en inline style="max-width: 1020px" till SVG- element, som förhindrar full bredd visning. Ta bort detta är viktigt för korrekt responsivt beteende.
Integrering av temaområden
Temaomkopplaren säkerställer att diagram återsänds korrekt när du växlar mellan ljus och mörka lägen:
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);
}
};
Användning requestAnimationFrame två gånger säkerställer att webbläsaren har slutfört målningen SVG innan vi försöker förbättra den.
Cloudflare Rocket Loader-kompatibilitet
Cloudflare's Rocket Loader kan fördröja JavaScript utförande, bryta initiering. Här är den skottsäkra lösningen:
// 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();
Se också till att ditt huvudskript har data-cfasync="false" attribut för att utesluta den från Rocket Loader:
<script src="~/js/dist/main.js" type="module" asp-append-version="true" data-cfasync="false"></script>
Styling med tailwind/DaisyUI
CSS använder Tailwind verktygsklasser och anpassad styling för lack:
/* 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; }
}
Testning och felsökning
Här är hur man verifierar allt fungerar:
Konsolutmatning
Med rätt initiering, bör du se:
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 Dynamiskt innehåll
Efter HTMX-byten bör du se:
HTMX afterSettle triggered for: contentcontainer
Highlight.js applied after HTMX swap
Mermaid initialized
Mermaid applied after HTMX swap
HTMX afterSettle complete for: contentcontainer
Checklista för testning
- Diagram ger vid första sidbelastningen
- Pan / zoom kontroller arbete
- Fullskärmsljusbox öppnas och stängs (X-knappen, klicka utanför, ESC-tangenten)
- PNG export fångar hela diagrammet (inte bara hörn)
- SVG export bevarar vektorer
- Fungerar efter HTMX innehållsbyte
- Tema byta re-renders diagram korrekt
- Mobil respons (kontrollerna förblir synliga, diagramskala)
- Mörk stil gäller korrekt
- Tangentbordstillgänglighet (tab to controls, ange för att aktivera)
Prestandaöverväganden
- Lasig initiering - Bara förbättra diagram som finns på sidan
- Rensning av instanser - Förstör pan-zoom fall när diagram tas bort
- Delegering av evenemang - Enklickslyssnare hanterar alla styrknappar
- begäranAnimationFrame - Bättre timing än godtyckliga setTimeout-värden
- Avslöjad export - Förhindra snabbbrand export klick
Browser-kompatibilitet
Testad och arbeta på:
- Krom/Edge 90+
- Firefox 88+ Ordförande
- Safari 14+
- Mobilwebbläsare (iOS Safari, Chrome Mobile)
IE11 stöds inte Tack vare moderna JavaScript-funktioner (konst, pilfunktioner, async/await, requestAnimationFrame).
Slutsatser
Denna omfattande förbättring omvandlar statiska Mermaid diagram till interaktiva, exporterbara visualiseringar. Implementationen är produktionsklar, motståndskraftig mot kant fall, och ger en utmärkt användarupplevelse.
Nyckelutflykter:
- Ta bort sjöjungfruns inline
max-widthbegränsning för diagram med full bredd - Bevara vyruta vid export för att fånga hela diagrammet
- Använd begäranAnimationFrame för timing i stället för godtyckliga förseningar
- Hantera Cloudflare Rocket Loader med beroendekontroll och försök logik
- Tillhandahåll flera slutmetoder för ljusbox (knapp, klicka utanför, ESC)
- Använd händelsedelegation för prestanda med många diagram
Använda npm- paketet
Istället för att kopiera kod, kan du nu installera denna funktionalitet som ett npm-paket:
npm install @mostlylucid/mermaid-enhancements
import { init } from '@mostlylucid/mermaid-enhancements';
import '@mostlylucid/mermaid-enhancements/styles.css';
await init();
Se också Publicera Mermaid förbättringar som ett npm-paket för fullständig dokumentation, exempel på ramintegration och avancerade konfigurationsalternativ.
Den fullständiga källkoden finns också tillgänglig i den här bloggens arkiv på Mostlylucid/src/js/mermaid_enhancements.js och som ett open-source npm-paket på Mest lucidweb/mestadlylucid-mammaid.
Exempeldiagram
Här är ett komplext exempel som visar arkitekturen i bloggens innehållssystem:
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
Försök att klicka på kontrollerna på diagrammet ovan!