Skip to content

Commit 54cb84d

Browse files
committed
docs: open Mermaid diagrams in vector lightbox
1 parent 1741480 commit 54cb84d

4 files changed

Lines changed: 246 additions & 85 deletions

File tree

scripts/build-docs.mjs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -296,14 +296,22 @@ function layout({ title, body, breadcrumb, sidebar, hasMermaid }) {
296296
.doc-shell.docs-page p { max-width: 72ch; }
297297
.docs-page .doc-breadcrumb { margin-bottom: 1.65rem; font-size: 0.82rem; }
298298
.doc-shell.docs-page pre { border-radius: 8px; box-shadow: none; }
299-
.mermaid { margin: 1.5rem 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-strong); }
300-
.mermaid-controls { display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.65rem; border-bottom: 1px solid var(--border); }
301-
.mermaid-controls button { min-width: 2rem; padding: 0.28rem 0.5rem; border: 1px solid var(--border); border-radius: 5px; background: transparent; color: var(--ink); font: inherit; font-size: 0.78rem; cursor: pointer; }
302-
.mermaid-controls button:hover { background: var(--card-hover); }
303-
.mermaid-controls output { min-width: 3.4rem; color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; text-align: center; }
304-
.mermaid-viewport { min-height: 20rem; overflow: hidden; padding: 1rem; cursor: grab; touch-action: none; }
305-
.mermaid-viewport.is-panning { cursor: grabbing; user-select: none; }
306-
.mermaid-viewport svg { display: block; width: max-content; min-width: 100%; max-width: none; height: auto; margin: 0; will-change: transform; }
299+
.mermaid { position: relative; overflow: hidden; margin: 1.5rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-strong); }
300+
.mermaid-inline-svg { display: block; width: 100%; max-width: 100%; height: auto; margin: 0 auto; }
301+
.mermaid-expand { position: absolute; right: 0.65rem; bottom: 0.65rem; display: grid; width: 2.35rem; height: 2.35rem; padding: 0; place-items: center; border: 1px solid var(--border); border-radius: 7px; color: var(--ink); background: var(--surface-strong); box-shadow: 0 8px 24px rgba(34, 24, 20, 0.16); cursor: pointer; }
302+
.mermaid-expand:hover { color: var(--accent-strong); background: var(--card-hover); }
303+
.mermaid-expand:focus-visible, .mermaid-lightbox button:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
304+
.mermaid-expand svg, .mermaid-lightbox button svg { width: 1.15rem; height: 1.15rem; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
305+
.mermaid-lightbox { position: fixed; inset: 0; z-index: 1000; display: grid; padding: clamp(0.75rem, 2.5vw, 2rem); place-items: center; background: rgba(18, 16, 15, 0.76); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
306+
.mermaid-lightbox-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(96vw, 100rem); height: min(92vh, 64rem); overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: 0 28px 90px rgba(0, 0, 0, 0.38); }
307+
.mermaid-lightbox-toolbar { display: flex; align-items: center; gap: 0.45rem; padding: 0.6rem 0.7rem; border-bottom: 1px solid var(--border); background: var(--surface-strong); }
308+
.mermaid-lightbox-toolbar button { display: grid; min-width: 2.2rem; height: 2.2rem; padding: 0 0.6rem; place-items: center; border: 1px solid var(--border); border-radius: 6px; color: var(--ink); background: transparent; font: inherit; font-size: 0.78rem; cursor: pointer; }
309+
.mermaid-lightbox-toolbar button:hover { color: var(--accent-strong); background: var(--card-hover); }
310+
.mermaid-lightbox-toolbar output { min-width: 3.5rem; color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; text-align: center; }
311+
.mermaid-lightbox-reset { margin-left: auto; }
312+
.mermaid-lightbox-viewport { min-width: 0; min-height: 0; overflow: hidden; padding: clamp(0.75rem, 2vw, 1.5rem); cursor: grab; touch-action: none; background-color: var(--bg); background-image: radial-gradient(circle at center, rgba(167, 83, 66, 0.1) 1px, transparent 1px); background-size: 18px 18px; }
313+
.mermaid-lightbox-viewport.is-panning { cursor: grabbing; user-select: none; }
314+
.mermaid-lightbox-svg { display: block; width: 100%; height: 100%; max-width: none; margin: 0; }
307315
.mermaid-render-failed { margin: 1.5rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: 8px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-align: left; white-space: pre; }
308316
.docs-table-wrap { overflow-x: auto; margin: 1.25rem 0; border: 1px solid var(--border); border-radius: 8px; }
309317
.docs-table { width: 100%; min-width: 32rem; border-collapse: collapse; }

src/mermaid-init.js

Lines changed: 175 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,189 @@
11
(() => {
2-
const MIN_SCALE = 0.35;
3-
const MAX_SCALE = 3;
2+
const MIN_SCALE = 0.5;
3+
const MAX_SCALE = 8;
44
const diagrams = () => [...document.querySelectorAll(".mermaid")];
5+
let lightboxSequence = 0;
6+
let closeActiveLightbox = null;
57

68
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>`;
710

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

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

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

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?.();
2466

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

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");
3379

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";
3982

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

42114
const viewport = document.createElement("div");
43-
viewport.className = "mermaid-viewport";
115+
viewport.className = "mermaid-lightbox-viewport";
44116
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.");
48118

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

54129
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}`);
57131
zoomValue.textContent = `${Math.round(scale * 100)}%`;
58132
};
59-
const setScale = (nextScale, focalX = 0, focalY = 0) => {
133+
const setScale = (nextScale, focalX = 0.5, focalY = 0.5) => {
60134
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+
};
64145
scale = next;
65146
render();
66147
};
67148
const resetView = () => {
68149
scale = 1;
69-
x = 0;
70-
y = 0;
150+
view = { ...original };
71151
render();
72152
};
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+
};
73164

74165
viewport.addEventListener("wheel", (event) => {
75166
event.preventDefault();
76167
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);
78171
}, { passive: false });
79172
viewport.addEventListener("pointerdown", (event) => {
80173
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 } };
82175
viewport.classList.add("is-panning");
83176
viewport.setPointerCapture?.(event.pointerId);
84177
event.preventDefault();
85178
});
86179
viewport.addEventListener("pointermove", (event) => {
87180
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;
90185
render();
186+
event.preventDefault();
91187
});
92188
const stopPanning = (event) => {
93189
if (!drag) return;
@@ -97,11 +193,40 @@
97193
};
98194
viewport.addEventListener("pointerup", stopPanning);
99195
viewport.addEventListener("pointercancel", stopPanning);
100-
101196
zoomOut.addEventListener("click", () => setScale(scale / 1.25));
102197
zoomIn.addEventListener("click", () => setScale(scale * 1.25));
103198
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;
104208
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);
105230
};
106231

107232
const render = async () => {
@@ -113,7 +238,7 @@
113238
startOnLoad: false,
114239
securityLevel: "strict",
115240
theme: dark ? "dark" : "neutral",
116-
flowchart: { htmlLabels: true, useMaxWidth: true },
241+
flowchart: { htmlLabels: false, useMaxWidth: true },
117242
});
118243

119244
try {

tests/docs-build.test.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ test("documentation generator emits the main routes", async () => {
112112
assert.match(fullDagHtml, /<div class="mermaid" role="img" aria-label="Mermaid diagram">[\s\S]*flowchart LR/);
113113
assert.match(fullDagHtml, /<script defer src="\/assets\/mermaid\.min\.js"><\/script>/);
114114
assert.match(fullDagHtml, /<script defer src="\/assets\/mermaid-init\.js"><\/script>/);
115+
assert.match(fullDagHtml, /\.mermaid-expand\s*\{/);
116+
assert.match(fullDagHtml, /\.mermaid-lightbox\s*\{/);
117+
assert.match(fullDagHtml, /\.mermaid-lightbox-viewport\s*\{/);
118+
assert.doesNotMatch(fullDagHtml, /\.mermaid-controls\s*\{/);
115119
await access(new URL("../public/assets/mermaid.min.js", import.meta.url));
116120
await access(new URL("../public/assets/mermaid-init.js", import.meta.url));
117121

0 commit comments

Comments
 (0)