Simple Plain Text Diagrams in HTML ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Since GitHub started supporting mermaid in their markdown I wanted to take another look at how to implement it on my site, I think it has some very nice... Date: March 3, 2022 Since GitHub started supporting mermaid in their markdown I wanted to take another look at how to implement it on my site, I think it has some very nice opportunities in teaching, documenting, and explaining things. The docs kinda just jumped right into their mermaid language and really went through that in a lot of depth, and skipped over how to implement it yourself, turns out its pretty simple. You just write mermaid syntax in a div with a class of mermaid on it! [code]
graph TD; a --> A A --> B B --> C
│ You just write mermaid syntax in a div with a class of mermaid on it! The above gets me this diagram. graph TD; a --> A A --> B B --> C This feels so quick and easy to start getting some graphs up and running, but does lead to layout shift and extra bytes down the pipe. The best solution in my opionion would be to forgo the js and ship svg. That said, this is do dang convenient I will be using it for some things. import mermaid from '/assets/vendor/mermaid/mermaid.esm.min.mjs'; const rootStyle = getComputedStyle(document.documentElement); const css = (name, fallback) => (rootStyle.getPropertyValue(name) || fallback).trim(); const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.dataset.theme === 'dark'; const accent = css('--color-primary', '#ffcd11'); const flowchart = { nodeSpacing: 60, rankSpacing: 90, padding: 12, }; const themeCSS = ` .label foreignObject > div { padding: 14px 14px 10px; line-height: 1.2; } .nodeLabel { padding: 14px 14px 10px; line-height: 1.2; } * { cursor: pointer; } `; const themeVariables = { background: css('--color-background', '#ffffff'), primaryColor: css('--color-code-bg', '#0a0a0a'), primaryTextColor: css('--color-text', '#1f2937'), primaryBorderColor: accent, lineColor: accent, textColor: css('--color-text', '#1f2937'), nodeBkg: css('--color-code-bg', '#0a0a0a'), nodeBorder: accent, nodeTextColor: css('--color-text', '#1f2937'), fontSize: '16px', nodePadding: 20, nodeTextMargin: 14, clusterBkg: isDark ? css('--color-background', '#0f0f0f') : css('--color-surface', '#f9fafb'), clusterBorder: accent, clusterTextColor: css('--color-text', '#1f2937'), titleColor: css('--color-text', '#1f2937'), edgeLabelBackground: css('--color-code-bg', '#0a0a0a'), }; const SVG_PAN_ZOOM_CDN = 'https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js'; let mermaidLightbox = null; let activePanZoom = null; // Inject lightbox styles once const injectLightboxStyles = () => { if (document.getElementById('mermaid-lightbox-css')) return; const style = document.createElement('style'); style.id = 'mermaid-lightbox-css'; style.textContent = ` /* Container fills the GLightbox slide */ .mermaid-lightbox-wrap { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: transparent; position: relative; } .mermaid-lightbox-wrap svg { width: 100% !important; height: 100% !important; max-width: 100%; max-height: 100%; } /* Hide GLightbox prev/next arrows (single-slide lightbox) */ .glightbox-container .gprev, .glightbox-container .gnext { display: none !important; } /* Hide description area that renders as a white box */ .glightbox-container .gslide-description, .glightbox-container .gslide-title, .glightbox-container .gdesc-inner, .glightbox-container .gslide-desc { display: none !important; } /* Remove white background from inline slide content */ .glightbox-container .gslide-inline { background: transparent !important; } /* Make the inline content area fill the slide */ .glightbox-container .ginlined-content { max-width: none !important; max-height: none !important; width: 100%; height: 100%; padding: 0 !important; } /* Remove box-shadow from the media container */ .glightbox-container .gslide-media { box-shadow: none !important; } /* Toolbar styling */ .mermaid-lightbox-toolbar { position: absolute; top: 8px; right: 8px; z-index: 10; display: flex; gap: 4px; } .mermaid-pz-btn { background: rgba(0,0,0,0.6); color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 14px; line-height: 1; } .mermaid-pz-btn:hover { background: rgba(0,0,0,0.8); border-color: rgba(255,255,255,0.6); } `; document.head.appendChild(style); }; // Lazy-load svg-pan-zoom from CDN, returns a promise const loadSvgPanZoom = () => { if (typeof svgPanZoom !== 'undefined') return Promise.resolve(); return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = SVG_PAN_ZOOM_CDN; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); }; // Initialize svg-pan-zoom on the SVG inside the lightbox. // Retries until the lightbox container has settled dimensions. let _pzRetries = 0; const initPanZoom = () => { if (activePanZoom) return; const container = document.querySelector('.glightbox-container .gslide.current .mermaid-lightbox-wrap'); if (!container) return; const svgEl = container.querySelector('svg'); if (!svgEl) return; // Ensure the container has layout dimensions before initializing. const cRect = container.getBoundingClientRect(); if (cRect.width < 10 || cRect.height < 10) { if (_pzRetries < 20) { _pzRetries++; setTimeout(initPanZoom, 50); } return; } // svg-pan-zoom needs a viewBox. Pre-rendered SVGs from mermaid // usually have one; browser-rendered ones may not. if (!svgEl.getAttribute('viewBox')) { let w = parseFloat(svgEl.getAttribute('width')); let h = parseFloat(svgEl.getAttribute('height')); if (!w && svgEl.style.maxWidth) w = parseFloat(svgEl.style.maxWidth); if (!w || !h) { const r = svgEl.getBoundingClientRect(); if (!w) w = r.width; if (!h) h = r.height; } if (w > 0 && h > 0) { svgEl.setAttribute('viewBox', '0 0 ' + w + ' ' + h); } else if (_pzRetries < 20) { _pzRetries++; setTimeout(initPanZoom, 50); return; } } _pzRetries = 0; // Clear inline dimensions so SVG can be sized by the container // and svg-pan-zoom can manage transforms. svgEl.removeAttribute('width'); svgEl.removeAttribute('height'); svgEl.style.cssText = 'width:100%;height:100%;'; try { activePanZoom = svgPanZoom(svgEl, { zoomEnabled: true, panEnabled: true, controlIconsEnabled: false, fit: true, center: true, contain: false, minZoom: 0.3, maxZoom: 10, zoomScaleSensitivity: 0.3, mouseWheelZoomEnabled: true, preventMouseEventsDefault: true, }); // Double-check fit after a frame in case dimensions shifted requestAnimationFrame(() => { if (!activePanZoom) return; activePanZoom.resize(); activePanZoom.fit(); activePanZoom.center(); }); } catch (_) { activePanZoom = null; } // Add reset/fit buttons let toolbar = container.querySelector('.mermaid-lightbox-toolbar'); if (!toolbar) { toolbar = document.createElement('div'); toolbar.className = 'mermaid-lightbox-toolbar'; toolbar.innerHTML = '' + '' + ''; toolbar.addEventListener('click', (ev) => { const btn = ev.target.closest('[data-action]'); if (!btn || !activePanZoom) return; ev.preventDefault(); ev.stopPropagation(); const action = btn.dataset.action; if (action === 'fit') { activePanZoom.resize(); activePanZoom.fit(); activePanZoom.center(); } else if (action === 'zoomin') { activePanZoom.zoomIn(); } else if (action === 'zoomout') { activePanZoom.zoomOut(); } }); container.prepend(toolbar); } }; // Destroy pan-zoom on lightbox close const destroyPanZoom = () => { if (activePanZoom) { try { activePanZoom.destroy(); } catch (_) { /* no-op */ } activePanZoom = null; } }; let _lbRetries = 0; const ensureMermaidLightbox = () => { const diagrams = document.querySelectorAll('.mermaid svg'); if (!diagrams.length) { // Mermaid ESM may still be rendering -- retry up to 2s if (_lbRetries < 20) { _lbRetries++; setTimeout(ensureMermaidLightbox, 100); } return; } _lbRetries = 0; injectLightboxStyles(); diagrams.forEach((svg) => { if (svg.dataset.lightboxBound) return; svg.dataset.lightboxBound = 'true'; svg.style.cursor = 'pointer'; svg.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const svgHtml = svg.outerHTML; const openLightbox = () => { if (!mermaidLightbox) { mermaidLightbox = GLightbox({ selector: false, openEffect: 'fade', closeEffect: 'fade', zoomable: false, draggable: false, skin: 'clean', }); mermaidLightbox.on('slide_after_load', () => { destroyPanZoom(); _pzRetries = 0; loadSvgPanZoom().then(() => initPanZoom()); }); mermaidLightbox.on('close', destroyPanZoom); } mermaidLightbox.setElements([{ content: '
' + svgHtml + '
', width: '90vw', height: '90vh' }]); mermaidLightbox.open(); loadSvgPanZoom(); }; if (typeof GLightbox !== 'undefined') { openLightbox(); } else if (window.initGLightbox) { window.initGLightbox(); openLightbox(); } else { window.addEventListener('glightbox-ready', () => { openLightbox(); }, { once: true }); } }); }); }; mermaid.initialize({ startOnLoad: false, theme: 'base', themeVariables, flowchart, themeCSS }); window.initMermaid = async () => { try { await mermaid.run(); } catch (e) { console.error('mermaid.run failed:', e); } ensureMermaidLightbox(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => window.initMermaid()); } else { window.initMermaid(); }