Back to "Améliorer les diagrammes de mermaid avec Pan/Zoom et Export"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

DaisyUI Javascript Mermaid SVG Tailwind

Améliorer les diagrammes de mermaid avec Pan/Zoom et Export

Friday, 07 November 2025

Introduction

Package npm disponible: Cette mise en œuvre est maintenant disponible sous @mostlylucid/mermaidM SK2améliorations - une production - un paquet ready npmM SK2 Voir Publier les améliorations Mermaid en tant que paquet npm pour de plus amples renseignements sur l’utilisation dans vos projets.

Mermaid est un outil fantastique pour créer des diagrammes à partir du texte, mais le traçage par défaut peut être limité pour les diagrammes complexes . Les utilisateurs ne peuvent pas facilement zoomer en vue de voir les détails, tourner autour des grands diagrammesMSC4 ou les exporter pour la documentationM SK5 Dans cet articleMST6 JeMst7je vous montrerai comment j’ai amélioré les diagrammas Mermaid sur ce site avec un pan interactifM st8contrôles de zoomMstr9 visualisation d’une boîte lumineuse à écran completMSt10 et fonctionnalité d’exportation Mtr11formats PNG et SVG à la foisMrt12

Cette mise en oeuvre est de production-ready, manipule le mode noir gracieusementM SK2 et est résistant à l’interférence du chargeur de roquette CloudflareMSC3

À quoi cela ressemble

Par conséquent, ce que nous voulons c’est ce qui suit : une page agréable sur ( et un pop-out sur la mermaid de ). améliorer. signifie que les diagrammes ne prennent pas en compte les SCREENS, mais qu’ils sont toujours faciles à lire.

mermaid_pan_zoom.png

Le problème

Out of the box, Les diagrammes de mermaid ont plusieurs limites:

  1. Taille fixe - Les diagrammes de grande envergure sont soit coupés, soit réduits pour tenir compte
  2. Aucune interopérabilité - PeutM SK1 ne pas zoomer pour voir les détails ou faire tourner autour
  3. Aucune exportation - Les utilisateurs peuvent't sauvegarder les diagrammes pour usage externe
  4. Faible expérience mobile - Les petits écrans rendent les diagrammes complexes inutilisables
  5. Questions liées au changement de thème - Les diagrammes ne donnent pas toujours la correction de l’interruption entre mode lumineux et mode foncé

La solution

I' a mis en place un système d’amélioration complet qui ajoute

  • Pan interactif/zoom utilisant la bibliothèque svg-pan-zoom
  • boutons de contrôle flottants pour zoom in/outM SK1 reset, pan toggle , et exportation
  • Case lumineuse à écran complet mode pour une meilleure visualisation
  • Exportation de PNG et SVG fonctions
  • Auto-adaptation à la charge pour que le diagramme entier soit visible par défaut Voir
  • Display complet- largeur supprimer les contraintes de taille artificielle

Aperçu de l'architecture

La solution se compose de trois composants principaux:

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]

Mise en œuvre

Installation des dépendances

Premièrement, installez les paquets npm requis:

npm install svg-pan-zoom html-to-image

Ces bibliothèques fournissent:

  • svg-pan-zoom - Functionnalité interactive de pan et de zoom pour les éléments SVG
  • html-to-image - Functionnalité de l'exportation SVG/PNG

Module d'amélioration de la base

Le module principal d'amélioration (mermaid_enhancements.js) gère toutes les fonctions interactives.

Création de boutons de contrôle

Chaque diagramme reçoit un panneau de commande flottant avec des boutons pour toutes les actions:

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);
}

Initialisation de Pan/Zoom

La bibliothèque svg-panM SK1zoom fournit une interaction efficace ,smooth

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;
    }
}

Fonctionnalité d'exportation

Le système d'exportation préserve la qualité du diagramme et gère les formats PNG et SVG:

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');
    }
}

Principales considérations à l’exportation:

  1. Préserver la boîte de vue - C'est essentiel pour capter l'ensemble du diagramme , pas seulement la partie visible
  2. Calculer les dimensions - Établir explicitement la largeurM SK1 la hauteur de la boîte de vue pour l'exportation cohérente
  3. Remplacer les transformations - Panne à bandesM SK1 transforme le zoom de sorte que l'exportation montre le diagramme complet
  4. Traitement du contexte - Fonds blanc pour PNG, transparent pour SVG
  5. Résolution élevée - Usage pixelRatio: 2 pour les exportations de PNG crisp

Case lumineuse à écran complet

La boîte lumineuse offre une expérience immersive de visualisation:

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);
}

Fonction principale d'amélioration

Cela relie tout ensemble et est appelé après Mermaid renders:

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');
    }
}

Révision critique: Mermaid applique une ligne en ligne style="max-width: 1020px" à des éléments SVG, qui empêche l'affichage complet-width displayM SK2 La suppression de ce dernier est essentielle pour un comportement réceptif appropriéMSC3

Intégration du thème

Le commutateur thématique assure que les diagrammes re-render correctement lors de la transition entre modes lumineux et foncés:

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);
    }
};

Utilisation requestAnimationFrame deux fois s’assurer que le navigateur a terminé la peinture du SVG avant de nous essayer d’en améliorer.

Compatibilité du chargeur de roquettes Cloudflare

Cloudflare's Rocket Loader peut retarder l'exécution de JavaScriptM SK1break initialization. HereMSC3s the bulletproof solutionMNK4

// 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();

Veuillez également vous assurer que votre script principal data-cfasync="false" attribut pour l'exclure de Rocket Loader:

<script src="~/js/dist/main.js" type="module" asp-append-version="true" data-cfasync="false"></script>

Styling avec Tailwind/DaisyUI

Le CSS utilise les classes d'utilité Tailwind et le style personnalisé pour la polish:

/* 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; }
}

Évaluation et débugging

Voici' comment vérifier que tout fonctionne:

Résultats de la console

Avec l’initialisation appropriée, vous devriez voir

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

Contenu dynamique HTMX

Après les échanges HTMX, vous devriez voir:

HTMX afterSettle triggered for: contentcontainer
Highlight.js applied after HTMX swap
Mermaid initialized
Mermaid applied after HTMX swap
HTMX afterSettle complete for: contentcontainer

Examen de la liste de vérification

  • Rendre les diagrammes lors de la charge initiale des pages
  • PanM SK1contrôle du travail de la couche
  • La boîte lumineuse à écran complet s'ouvre et se ferme ( Le bouton X M SK2 Cliquez à l'extérieur
  • L'exportation de GNP saisit le diagramme complet ( pas seulement l'angle
  • L'exportation de SVG préserve les vecteurs
  • fonctionne après échange de contenu HTMX
  • Changement de thème re-rend les diagrammes correctement
  • Réceptif mobile ( Les contrôles demeurent visiblesM SK2 Échelle des diagrammes)
  • Le style en mode foncé s'applique correctement
  • Accessibilité de la claviere (tab aux commandesM SK2 entrer pour activer)

Considérations relatives au rendement

  1. Initialisation lente - Améliorer seulement les diagrammes qui existent sur la page
  2. Nettoyage de l'instance - Détruire les instances de pan-zoom lorsque les diagrammes sont supprimés
  3. Délégation des événements - L'écouteur à simple clic manipule tous les boutons de commande
  4. demandeFrame d'animation - Amélioration du temps par rapport aux valeurs d'arrêt arbitraire
  5. Exportations débitrices - Prévenir les clics rapides d'exportation de feuM SK1

Compatibilité du navigateur

Testé et travaillé sur:

  • Chrome/Edge 90+
  • Firefox 88+
  • Safari 14+
  • Les navigateurs mobiles (iOS SafariM SK1 Chrome Mobile)

IE11 n'est pas supporté en raison des fonctionnalités modernes de JavaScript (constM SK1 fonctions d'armoiries , async/awaitMSC4 demandeAnimationFrameMST5

Conclusion

Cette amélioration complète transforme les diagrammes statiques Mermaid en des visualisations interactives exportables, . La mise en œuvre est de productionM SK2 prête, résistante aux cases à bordMSC4 et offre une excellente expérience pour l’utilisateurMNK5

Key takeaways:

  • Supprimer Mermaid's en ligne max-width restriction pour les diagrammes complets-diagrammes de largeur
  • Préservez la boîte de vue lorsque vous exportez pour capturer tout le diagramme
  • Utiliser la demandeAnimationFrame pour le timing au lieu de retards arbitraires
  • Gérer Cloudflare Rocket Loader avec vérification de la dépendance et logique de réessai
  • Fournir plusieurs méthodes de fermeture pour la boîte lumineuse ( bouton , cliquer à l'extérieur
  • Utiliser la délégation des événements pour le rendement avec de nombreux diagrammes

Utilisation du paquet npm

Plutôt que de copier le code, vous pouvez maintenant installer cette fonctionnalité comme un paquet npm:

npm install @mostlylucid/mermaid-enhancements
import { init } from '@mostlylucid/mermaid-enhancements';
import '@mostlylucid/mermaid-enhancements/styles.css';

await init();

Voir Publier les améliorations Mermaid en tant que paquet npm pour la documentation intégrale, exemples d'intégration de cadres , et options de configuration avancées.

Le code source complet est également disponible dans ce blog's repository à Mostlylucid/src/js/mermaid_enhancements.js et comme un paquet open-source npm à Web plus lumineux/plus lumineux.

Diagramme d'exemple

Voici un exemple complexe de l’architecture de ce blog:

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

Essayez de cliquer sur les commandes du diagramme ci-dessus!

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.