|
1 | 1 | (() => { |
2 | | - const MIN_SCALE = 0.35; |
3 | | - const MAX_SCALE = 3; |
| 2 | + const MIN_SCALE = 0.5; |
| 3 | + const MAX_SCALE = 8; |
4 | 4 | const diagrams = () => [...document.querySelectorAll(".mermaid")]; |
| 5 | + let lightboxSequence = 0; |
| 6 | + let closeActiveLightbox = null; |
5 | 7 |
|
6 | 8 | const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value)); |
| 9 | + const icon = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`; |
7 | 10 |
|
8 | | - const enhance = (host) => { |
9 | | - if (host.dataset.mermaidInteractive === "true") return; |
10 | | - const svg = host.querySelector("svg"); |
11 | | - if (!svg) return; |
| 11 | + const createButton = ({ className, label, title = label, content, dataAttribute }) => { |
| 12 | + const button = document.createElement("button"); |
| 13 | + button.type = "button"; |
| 14 | + button.className = className; |
| 15 | + button.setAttribute("aria-label", label); |
| 16 | + button.title = title; |
| 17 | + button.innerHTML = content; |
| 18 | + if (dataAttribute) button.dataset[dataAttribute] = ""; |
| 19 | + return button; |
| 20 | + }; |
12 | 21 |
|
13 | | - host.dataset.mermaidInteractive = "true"; |
14 | | - host.classList.add("mermaid-pan-zoom"); |
| 22 | + const parseViewBox = (svg) => { |
| 23 | + const values = (svg.getAttribute("viewBox") || "").trim().split(/[\s,]+/).map(Number); |
| 24 | + if (values.length === 4 && values.every(Number.isFinite) && values[2] > 0 && values[3] > 0) { |
| 25 | + return { x: values[0], y: values[1], width: values[2], height: values[3] }; |
| 26 | + } |
| 27 | + const width = Number.parseFloat(svg.getAttribute("width")) || 800; |
| 28 | + const height = Number.parseFloat(svg.getAttribute("height")) || 500; |
| 29 | + return { x: 0, y: 0, width, height }; |
| 30 | + }; |
| 31 | + |
| 32 | + const rewriteSvgIds = (svg, prefix) => { |
| 33 | + const replacements = new Map(); |
| 34 | + [svg, ...svg.querySelectorAll("[id]")].forEach((element) => { |
| 35 | + if (!element.id) return; |
| 36 | + const previous = element.id; |
| 37 | + const next = `${prefix}-${previous}`; |
| 38 | + replacements.set(previous, next); |
| 39 | + element.id = next; |
| 40 | + }); |
| 41 | + if (replacements.size === 0) return; |
15 | 42 |
|
16 | | - const controls = document.createElement("div"); |
17 | | - controls.className = "mermaid-controls"; |
18 | | - controls.setAttribute("aria-label", "Diagram zoom controls"); |
| 43 | + svg.querySelectorAll("*").forEach((element) => { |
| 44 | + element.getAttributeNames?.().forEach((name) => { |
| 45 | + const value = element.getAttribute(name); |
| 46 | + if (!value) return; |
| 47 | + let next = value; |
| 48 | + replacements.forEach((replacement, previous) => { |
| 49 | + next = next.replaceAll(`url(#${previous})`, `url(#${replacement})`); |
| 50 | + if (next === `#${previous}`) next = `#${replacement}`; |
| 51 | + }); |
| 52 | + if (next !== value) element.setAttribute(name, next); |
| 53 | + }); |
| 54 | + }); |
| 55 | + svg.querySelectorAll("style").forEach((style) => { |
| 56 | + let text = style.textContent || ""; |
| 57 | + replacements.forEach((replacement, previous) => { |
| 58 | + text = text.replaceAll(`#${previous}`, `#${replacement}`); |
| 59 | + }); |
| 60 | + style.textContent = text; |
| 61 | + }); |
| 62 | + }; |
19 | 63 |
|
20 | | - const zoomOut = document.createElement("button"); |
21 | | - zoomOut.type = "button"; |
22 | | - zoomOut.textContent = "−"; |
23 | | - zoomOut.setAttribute("aria-label", "Zoom out"); |
| 64 | + const openLightbox = (sourceSvg, opener) => { |
| 65 | + closeActiveLightbox?.(); |
24 | 66 |
|
25 | | - const zoomValue = document.createElement("output"); |
26 | | - zoomValue.dataset.mermaidZoomValue = ""; |
27 | | - zoomValue.setAttribute("aria-live", "polite"); |
| 67 | + const originalBodyOverflow = document.body.style.overflow; |
| 68 | + const original = parseViewBox(sourceSvg); |
| 69 | + let scale = 1; |
| 70 | + let view = { ...original }; |
| 71 | + let drag = null; |
28 | 72 |
|
29 | | - const zoomIn = document.createElement("button"); |
30 | | - zoomIn.type = "button"; |
31 | | - zoomIn.textContent = "+"; |
32 | | - zoomIn.setAttribute("aria-label", "Zoom in"); |
| 73 | + const lightbox = document.createElement("div"); |
| 74 | + lightbox.className = "mermaid-lightbox"; |
| 75 | + lightbox.dataset.mermaidLightbox = ""; |
| 76 | + lightbox.setAttribute("role", "dialog"); |
| 77 | + lightbox.setAttribute("aria-modal", "true"); |
| 78 | + lightbox.setAttribute("aria-label", "Expanded Mermaid diagram"); |
33 | 79 |
|
34 | | - const reset = document.createElement("button"); |
35 | | - reset.type = "button"; |
36 | | - reset.textContent = "Reset"; |
37 | | - reset.dataset.mermaidZoomReset = ""; |
38 | | - reset.setAttribute("aria-label", "Reset diagram zoom and position"); |
| 80 | + const panel = document.createElement("div"); |
| 81 | + panel.className = "mermaid-lightbox-panel"; |
39 | 82 |
|
40 | | - controls.append(zoomOut, zoomValue, zoomIn, reset); |
| 83 | + const toolbar = document.createElement("div"); |
| 84 | + toolbar.className = "mermaid-lightbox-toolbar"; |
| 85 | + toolbar.setAttribute("aria-label", "Diagram controls"); |
| 86 | + |
| 87 | + const zoomOut = createButton({ |
| 88 | + className: "mermaid-lightbox-control", |
| 89 | + label: "Zoom out", |
| 90 | + content: icon('<path d="M5 12h14" />'), |
| 91 | + }); |
| 92 | + const zoomValue = document.createElement("output"); |
| 93 | + zoomValue.dataset.mermaidZoomValue = ""; |
| 94 | + zoomValue.setAttribute("aria-live", "polite"); |
| 95 | + const zoomIn = createButton({ |
| 96 | + className: "mermaid-lightbox-control", |
| 97 | + label: "Zoom in", |
| 98 | + content: icon('<path d="M12 5v14M5 12h14" />'), |
| 99 | + }); |
| 100 | + const reset = createButton({ |
| 101 | + className: "mermaid-lightbox-reset", |
| 102 | + label: "Reset diagram zoom and position", |
| 103 | + content: "Reset", |
| 104 | + dataAttribute: "mermaidZoomReset", |
| 105 | + }); |
| 106 | + const close = createButton({ |
| 107 | + className: "mermaid-lightbox-close", |
| 108 | + label: "Close expanded diagram", |
| 109 | + content: icon('<path d="m6 6 12 12M18 6 6 18" />'), |
| 110 | + dataAttribute: "mermaidClose", |
| 111 | + }); |
| 112 | + toolbar.append(zoomOut, zoomValue, zoomIn, reset, close); |
41 | 113 |
|
42 | 114 | const viewport = document.createElement("div"); |
43 | | - viewport.className = "mermaid-viewport"; |
| 115 | + viewport.className = "mermaid-lightbox-viewport"; |
44 | 116 | viewport.setAttribute("role", "region"); |
45 | | - viewport.setAttribute("aria-label", "Interactive Mermaid diagram. Use the mouse wheel to zoom and drag to pan."); |
46 | | - viewport.append(svg); |
47 | | - host.replaceChildren(controls, viewport); |
| 117 | + viewport.setAttribute("aria-label", "Vector Mermaid diagram. Use the mouse wheel to zoom and drag to pan."); |
48 | 118 |
|
49 | | - let scale = 1; |
50 | | - let x = 0; |
51 | | - let y = 0; |
52 | | - let drag = null; |
| 119 | + const svg = sourceSvg.cloneNode(true); |
| 120 | + svg.classList.add("mermaid-lightbox-svg"); |
| 121 | + svg.removeAttribute("width"); |
| 122 | + svg.removeAttribute("height"); |
| 123 | + svg.removeAttribute("style"); |
| 124 | + rewriteSvgIds(svg, `mermaid-lightbox-${++lightboxSequence}`); |
| 125 | + viewport.append(svg); |
| 126 | + panel.append(toolbar, viewport); |
| 127 | + lightbox.append(panel); |
53 | 128 |
|
54 | 129 | const render = () => { |
55 | | - svg.style.transformOrigin = "0 0"; |
56 | | - svg.style.transform = `translate(${x}px, ${y}px) scale(${scale})`; |
| 130 | + svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`); |
57 | 131 | zoomValue.textContent = `${Math.round(scale * 100)}%`; |
58 | 132 | }; |
59 | | - const setScale = (nextScale, focalX = 0, focalY = 0) => { |
| 133 | + const setScale = (nextScale, focalX = 0.5, focalY = 0.5) => { |
60 | 134 | const next = clamp(nextScale, MIN_SCALE, MAX_SCALE); |
61 | | - const ratio = next / scale; |
62 | | - x = focalX - (focalX - x) * ratio; |
63 | | - y = focalY - (focalY - y) * ratio; |
| 135 | + const focusSvgX = view.x + view.width * focalX; |
| 136 | + const focusSvgY = view.y + view.height * focalY; |
| 137 | + const nextWidth = original.width / next; |
| 138 | + const nextHeight = original.height / next; |
| 139 | + view = { |
| 140 | + x: focusSvgX - nextWidth * focalX, |
| 141 | + y: focusSvgY - nextHeight * focalY, |
| 142 | + width: nextWidth, |
| 143 | + height: nextHeight, |
| 144 | + }; |
64 | 145 | scale = next; |
65 | 146 | render(); |
66 | 147 | }; |
67 | 148 | const resetView = () => { |
68 | 149 | scale = 1; |
69 | | - x = 0; |
70 | | - y = 0; |
| 150 | + view = { ...original }; |
71 | 151 | render(); |
72 | 152 | }; |
| 153 | + const closeLightbox = () => { |
| 154 | + if (!lightbox.isConnected) return; |
| 155 | + document.removeEventListener("keydown", onDocumentKeydown); |
| 156 | + lightbox.remove(); |
| 157 | + document.body.style.overflow = originalBodyOverflow; |
| 158 | + closeActiveLightbox = null; |
| 159 | + opener.focus?.(); |
| 160 | + }; |
| 161 | + const onDocumentKeydown = (event) => { |
| 162 | + if (event.key === "Escape") closeLightbox(); |
| 163 | + }; |
73 | 164 |
|
74 | 165 | viewport.addEventListener("wheel", (event) => { |
75 | 166 | event.preventDefault(); |
76 | 167 | const bounds = viewport.getBoundingClientRect(); |
77 | | - setScale(scale * Math.exp(-event.deltaY * 0.0015), event.clientX - bounds.left, event.clientY - bounds.top); |
| 168 | + const focalX = bounds.width > 0 ? clamp((event.clientX - bounds.left) / bounds.width, 0, 1) : 0.5; |
| 169 | + const focalY = bounds.height > 0 ? clamp((event.clientY - bounds.top) / bounds.height, 0, 1) : 0.5; |
| 170 | + setScale(scale * Math.exp(-event.deltaY * 0.0015), focalX, focalY); |
78 | 171 | }, { passive: false }); |
79 | 172 | viewport.addEventListener("pointerdown", (event) => { |
80 | 173 | if (event.button !== 0) return; |
81 | | - drag = { x: event.clientX, y: event.clientY, offsetX: x, offsetY: y }; |
| 174 | + drag = { clientX: event.clientX, clientY: event.clientY, view: { ...view } }; |
82 | 175 | viewport.classList.add("is-panning"); |
83 | 176 | viewport.setPointerCapture?.(event.pointerId); |
84 | 177 | event.preventDefault(); |
85 | 178 | }); |
86 | 179 | viewport.addEventListener("pointermove", (event) => { |
87 | 180 | if (!drag) return; |
88 | | - x = drag.offsetX + event.clientX - drag.x; |
89 | | - y = drag.offsetY + event.clientY - drag.y; |
| 181 | + const bounds = viewport.getBoundingClientRect(); |
| 182 | + if (bounds.width <= 0 || bounds.height <= 0) return; |
| 183 | + view.x = drag.view.x - (event.clientX - drag.clientX) * drag.view.width / bounds.width; |
| 184 | + view.y = drag.view.y - (event.clientY - drag.clientY) * drag.view.height / bounds.height; |
90 | 185 | render(); |
| 186 | + event.preventDefault(); |
91 | 187 | }); |
92 | 188 | const stopPanning = (event) => { |
93 | 189 | if (!drag) return; |
|
97 | 193 | }; |
98 | 194 | viewport.addEventListener("pointerup", stopPanning); |
99 | 195 | viewport.addEventListener("pointercancel", stopPanning); |
100 | | - |
101 | 196 | zoomOut.addEventListener("click", () => setScale(scale / 1.25)); |
102 | 197 | zoomIn.addEventListener("click", () => setScale(scale * 1.25)); |
103 | 198 | reset.addEventListener("click", resetView); |
| 199 | + close.addEventListener("click", closeLightbox); |
| 200 | + lightbox.addEventListener("pointerdown", (event) => { |
| 201 | + if (event.target === lightbox) closeLightbox(); |
| 202 | + }); |
| 203 | + document.addEventListener("keydown", onDocumentKeydown); |
| 204 | + |
| 205 | + document.body.append(lightbox); |
| 206 | + document.body.style.overflow = "hidden"; |
| 207 | + closeActiveLightbox = closeLightbox; |
104 | 208 | render(); |
| 209 | + close.focus?.(); |
| 210 | + }; |
| 211 | + |
| 212 | + const enhance = (host) => { |
| 213 | + if (host.dataset.mermaidInteractive === "true") return; |
| 214 | + const svg = host.querySelector("svg"); |
| 215 | + if (!svg) return; |
| 216 | + |
| 217 | + host.dataset.mermaidInteractive = "true"; |
| 218 | + host.classList.add("mermaid-expandable"); |
| 219 | + svg.classList.add("mermaid-inline-svg"); |
| 220 | + |
| 221 | + const expand = createButton({ |
| 222 | + className: "mermaid-expand", |
| 223 | + label: "Expand Mermaid diagram", |
| 224 | + title: "Expand diagram", |
| 225 | + content: icon('<path d="M8 3H3v5M16 3h5v5M21 16v5h-5M3 16v5h5M3 8l6-6M15 2l6 6M21 16l-6 6M9 22l-6-6" />'), |
| 226 | + dataAttribute: "mermaidExpand", |
| 227 | + }); |
| 228 | + expand.addEventListener("click", () => openLightbox(svg, expand)); |
| 229 | + host.append(expand); |
105 | 230 | }; |
106 | 231 |
|
107 | 232 | const render = async () => { |
|
113 | 238 | startOnLoad: false, |
114 | 239 | securityLevel: "strict", |
115 | 240 | theme: dark ? "dark" : "neutral", |
116 | | - flowchart: { htmlLabels: true, useMaxWidth: true }, |
| 241 | + flowchart: { htmlLabels: false, useMaxWidth: true }, |
117 | 242 | }); |
118 | 243 |
|
119 | 244 | try { |
|
0 commit comments