|
| 1 | +// Mermaid is loaded on demand from a CDN (like hljs and Alpine in _head.erb) |
| 2 | +// so pages without diagrams don't pay for the ~500KB library. |
| 3 | +const MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"; |
| 4 | + |
| 5 | +const ZOOM_MIN = 0.25; |
| 6 | +const ZOOM_MAX = 4; |
| 7 | +const ZOOM_STEP = 1.25; |
| 8 | + |
| 9 | +function currentTheme() { |
| 10 | + return document.documentElement.classList.contains("dark") ? "dark" : "default"; |
| 11 | +} |
| 12 | + |
| 13 | +function buildDiagramFigure(code) { |
| 14 | + const figure = document.createElement("div"); |
| 15 | + figure.className = "mermaid-figure relative my-6 rounded-md border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800"; |
| 16 | + |
| 17 | + const container = document.createElement("pre"); |
| 18 | + container.className = "mermaid !my-0 !bg-transparent !p-0"; |
| 19 | + container.dataset.diagram = code.textContent.trim(); |
| 20 | + container.textContent = container.dataset.diagram; |
| 21 | + |
| 22 | + const fullScreenButton = document.createElement("button"); |
| 23 | + fullScreenButton.type = "button"; |
| 24 | + fullScreenButton.textContent = "Full Screen"; |
| 25 | + fullScreenButton.className = "absolute top-2 right-2 rounded-md border border-slate-200 bg-white px-2 py-1 text-2xs font-medium text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"; |
| 26 | + fullScreenButton.addEventListener("click", () => openFullScreen(container)); |
| 27 | + |
| 28 | + figure.appendChild(container); |
| 29 | + figure.appendChild(fullScreenButton); |
| 30 | + return figure; |
| 31 | +} |
| 32 | + |
| 33 | +function toolbarButton(label, title) { |
| 34 | + const button = document.createElement("button"); |
| 35 | + button.type = "button"; |
| 36 | + button.textContent = label; |
| 37 | + button.title = title; |
| 38 | + button.className = "rounded-md border border-slate-200 bg-white px-3 py-1 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"; |
| 39 | + return button; |
| 40 | +} |
| 41 | + |
| 42 | +function openFullScreen(container) { |
| 43 | + const svg = container.querySelector("svg"); |
| 44 | + if (!svg) return; |
| 45 | + |
| 46 | + const overlay = document.createElement("div"); |
| 47 | + overlay.className = "fixed inset-0 z-50 flex flex-col bg-slate-50 dark:bg-slate-900"; |
| 48 | + |
| 49 | + const toolbar = document.createElement("div"); |
| 50 | + toolbar.className = "flex items-center justify-end gap-2 border-b border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-800"; |
| 51 | + |
| 52 | + const zoomOut = toolbarButton("−", "Zoom out"); |
| 53 | + const zoomLevel = document.createElement("span"); |
| 54 | + zoomLevel.className = "w-14 text-center text-sm text-slate-600 dark:text-slate-300"; |
| 55 | + const zoomIn = toolbarButton("+", "Zoom in"); |
| 56 | + const reset = toolbarButton("Reset", "Reset zoom"); |
| 57 | + const close = toolbarButton("Close", "Close full screen (Esc)"); |
| 58 | + |
| 59 | + toolbar.append(zoomOut, zoomLevel, zoomIn, reset, close); |
| 60 | + |
| 61 | + const scroller = document.createElement("div"); |
| 62 | + scroller.className = "flex-1 overflow-auto p-6"; |
| 63 | + |
| 64 | + const stage = document.createElement("div"); |
| 65 | + stage.className = "flex min-h-full min-w-full items-center justify-center w-max"; |
| 66 | + |
| 67 | + const clone = svg.cloneNode(true); |
| 68 | + // Mermaid caps the inline SVG with a max-width style; drop it so the clone |
| 69 | + // can be sized freely for zooming. |
| 70 | + const baseWidth = parseFloat(clone.style.maxWidth) || svg.getBoundingClientRect().width; |
| 71 | + clone.style.maxWidth = "none"; |
| 72 | + |
| 73 | + let zoom = 1; |
| 74 | + const applyZoom = () => { |
| 75 | + clone.style.width = `${baseWidth * zoom}px`; |
| 76 | + zoomLevel.textContent = `${Math.round(zoom * 100)}%`; |
| 77 | + }; |
| 78 | + applyZoom(); |
| 79 | + |
| 80 | + zoomIn.addEventListener("click", () => { |
| 81 | + zoom = Math.min(zoom * ZOOM_STEP, ZOOM_MAX); |
| 82 | + applyZoom(); |
| 83 | + }); |
| 84 | + zoomOut.addEventListener("click", () => { |
| 85 | + zoom = Math.max(zoom / ZOOM_STEP, ZOOM_MIN); |
| 86 | + applyZoom(); |
| 87 | + }); |
| 88 | + reset.addEventListener("click", () => { |
| 89 | + zoom = 1; |
| 90 | + applyZoom(); |
| 91 | + }); |
| 92 | + |
| 93 | + const dismiss = () => { |
| 94 | + document.removeEventListener("keydown", onKeyDown); |
| 95 | + overlay.remove(); |
| 96 | + document.body.style.overflow = ""; |
| 97 | + }; |
| 98 | + const onKeyDown = (event) => { |
| 99 | + if (event.key === "Escape") dismiss(); |
| 100 | + }; |
| 101 | + close.addEventListener("click", dismiss); |
| 102 | + document.addEventListener("keydown", onKeyDown); |
| 103 | + |
| 104 | + stage.appendChild(clone); |
| 105 | + scroller.appendChild(stage); |
| 106 | + overlay.append(toolbar, scroller); |
| 107 | + document.body.appendChild(overlay); |
| 108 | + document.body.style.overflow = "hidden"; |
| 109 | +} |
| 110 | + |
| 111 | +export function renderMermaidDiagrams() { |
| 112 | + // Kramdown renders ```mermaid fences as <pre><code class="language-mermaid">. |
| 113 | + // Swap them for the <pre class="mermaid"> containers mermaid.run() expects |
| 114 | + // synchronously, before hljs and the copy-button code see them. |
| 115 | + const blocks = document.querySelectorAll("pre > code.language-mermaid"); |
| 116 | + blocks.forEach((code) => { |
| 117 | + code.parentNode.replaceWith(buildDiagramFigure(code)); |
| 118 | + }); |
| 119 | + |
| 120 | + if (blocks.length === 0) return; |
| 121 | + |
| 122 | + import(MERMAID_URL) |
| 123 | + .then(({ default: mermaid }) => { |
| 124 | + let renderedTheme = null; |
| 125 | + const run = async () => { |
| 126 | + if (renderedTheme === currentTheme()) return; |
| 127 | + renderedTheme = currentTheme(); |
| 128 | + mermaid.initialize({ startOnLoad: false, theme: renderedTheme }); |
| 129 | + document.querySelectorAll("pre.mermaid").forEach((el) => { |
| 130 | + el.removeAttribute("data-processed"); |
| 131 | + el.textContent = el.dataset.diagram; |
| 132 | + }); |
| 133 | + await mermaid.run(); |
| 134 | + }; |
| 135 | + |
| 136 | + run(); |
| 137 | + |
| 138 | + // Re-render when the Alpine theme toggle flips the `dark` class on <html>. |
| 139 | + new MutationObserver((mutations) => { |
| 140 | + if (mutations.some((m) => m.attributeName === "class")) run(); |
| 141 | + }).observe(document.documentElement, { attributes: true }); |
| 142 | + }) |
| 143 | + .catch((error) => { |
| 144 | + // Leave the raw diagram text visible as a fallback. |
| 145 | + console.error("Failed to load mermaid:", error); |
| 146 | + }); |
| 147 | +} |
0 commit comments