Skip to content

Commit 18e417c

Browse files
authored
feat: rendering improvements and fullscreen mermaid diagrams (#29863)
* feat: rendering improvements and fullscreen mermaid diagrams * simplify fullscreen button
1 parent 068433c commit 18e417c

File tree

2 files changed

+407
-105
lines changed

2 files changed

+407
-105
lines changed

src/scripts/mermaid.ts

Lines changed: 227 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,242 @@
1-
import mermaid from "mermaid";
2-
31
const diagrams = document.querySelectorAll<HTMLPreElement>("pre.mermaid");
42

5-
let init = false;
3+
// Skip all setup if no mermaid diagrams on this page
4+
if (diagrams.length === 0) {
5+
// No-op — avoid creating dialog, listeners, or loading the mermaid bundle
6+
} else {
7+
let init = false;
68

7-
// Get computed font family from CSS variable
8-
function getFontFamily(): string {
9-
const computedStyle = getComputedStyle(document.documentElement);
10-
const slFont = computedStyle.getPropertyValue("--__sl-font").trim();
11-
return slFont || "system-ui, -apple-system, sans-serif";
12-
}
9+
// Full-screen expand dialog (lazy — only created because diagrams exist)
10+
let dialog: HTMLDialogElement | null = null;
11+
12+
function getDialog(): HTMLDialogElement {
13+
if (dialog) return dialog;
14+
15+
dialog = document.createElement("dialog");
16+
dialog.className = "mermaid-dialog";
17+
dialog.innerHTML = `
18+
<div class="mermaid-dialog-body"></div>
19+
<button class="mermaid-dialog-close" aria-label="Close">
20+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
21+
<line x1="18" y1="6" x2="6" y2="18"></line>
22+
<line x1="6" y1="6" x2="18" y2="18"></line>
23+
</svg>
24+
</button>
25+
`;
26+
document.body.appendChild(dialog);
27+
28+
function closeWithAnimation() {
29+
if (!dialog || !dialog.open) return;
30+
dialog.classList.add("closing");
31+
dialog.addEventListener(
32+
"animationend",
33+
() => {
34+
dialog!.classList.remove("closing");
35+
dialog!.close();
36+
document.documentElement.style.overflow = "";
37+
},
38+
{ once: true },
39+
);
40+
}
41+
42+
dialog.addEventListener("click", (e) => {
43+
if (e.target === dialog) closeWithAnimation();
44+
});
45+
dialog
46+
.querySelector(".mermaid-dialog-close")
47+
?.addEventListener("click", () => {
48+
closeWithAnimation();
49+
});
50+
// Handle Escape key — native dialog closes immediately,
51+
// so we intercept cancel to animate first
52+
dialog.addEventListener("cancel", (e) => {
53+
e.preventDefault();
54+
closeWithAnimation();
55+
});
1356

14-
// Create wrapper container with annotation
15-
function wrapDiagram(diagram: HTMLPreElement, title: string | null) {
16-
// Skip if already wrapped
17-
if (diagram.parentElement?.classList.contains("mermaid-container")) {
18-
return;
57+
return dialog;
1958
}
2059

21-
// Create container
22-
const container = document.createElement("div");
23-
container.className = "mermaid-container";
60+
function openDiagram(container: HTMLElement) {
61+
const d = getDialog();
62+
const clone = container.cloneNode(true) as HTMLElement;
2463

25-
// Wrap the diagram
26-
diagram.parentNode?.insertBefore(container, diagram);
27-
container.appendChild(diagram);
64+
// Remove the expand button from the clone
65+
clone.querySelector(".mermaid-expand")?.remove();
2866

29-
// Add annotation footer if title exists
30-
if (title) {
31-
const footer = document.createElement("div");
32-
footer.className = "mermaid-annotation";
67+
// Let the SVG scale freely in the expanded view
68+
const svg = clone.querySelector("svg");
69+
if (svg) {
70+
svg.removeAttribute("style");
71+
svg.setAttribute("width", "100%");
72+
svg.setAttribute("height", "auto");
73+
}
3374

34-
const titleSpan = document.createElement("span");
35-
titleSpan.className = "mermaid-annotation-title";
36-
titleSpan.textContent = title;
75+
const body = d.querySelector(".mermaid-dialog-body");
76+
if (!body) return;
77+
body.replaceChildren(clone);
3778

38-
const logo = document.createElement("img");
39-
logo.src = "/logo.svg";
40-
logo.alt = "Cloudflare";
41-
logo.className = "mermaid-annotation-logo";
79+
// Close dialog when clicking Mermaid `click` links inside the expanded view.
80+
clone.addEventListener("click", (e) => {
81+
const target = e.target as Element;
82+
const anchor = target.closest("a");
83+
const clickable = target.closest(".clickable");
84+
if (anchor || clickable) {
85+
// Skip animation for link clicks — navigate immediately
86+
d.close();
87+
document.documentElement.style.overflow = "";
88+
}
89+
});
4290

43-
footer.appendChild(titleSpan);
44-
footer.appendChild(logo);
45-
container.appendChild(footer);
91+
document.documentElement.style.overflow = "hidden";
92+
d.showModal();
4693
}
47-
}
4894

49-
async function render() {
50-
const isLight =
51-
document.documentElement.getAttribute("data-theme") === "light";
52-
const fontFamily = getFontFamily();
53-
54-
// Custom theme variables for Cloudflare branding
55-
const lightThemeVars = {
56-
fontFamily,
57-
primaryColor: "#fef1e6", // cl1-orange-9 (very light orange for node backgrounds)
58-
primaryBorderColor: "#f6821f", // cl1-brand-orange
59-
primaryTextColor: "#1d1d1d", // cl1-gray-0
60-
secondaryColor: "#f2f2f2", // cl1-gray-9
61-
secondaryBorderColor: "#999999", // cl1-gray-6
62-
secondaryTextColor: "#1d1d1d", // cl1-gray-0
63-
tertiaryColor: "#f2f2f2", // cl1-gray-9
64-
tertiaryBorderColor: "#999999", // cl1-gray-6
65-
tertiaryTextColor: "#1d1d1d", // cl1-gray-0
66-
lineColor: "#f6821f", // cl1-brand-orange for arrows
67-
textColor: "#1d1d1d", // cl1-gray-0
68-
mainBkg: "#fef1e6", // cl1-orange-9
69-
errorBkgColor: "#ffefee", // cl1-red-9
70-
errorTextColor: "#3c0501", // cl1-red-0
71-
edgeLabelBackground: "#ffffff", // white background for edge labels in light mode
72-
labelBackground: "#ffffff", // white background for labels in light mode
73-
};
74-
75-
const darkThemeVars = {
76-
fontFamily,
77-
primaryColor: "#482303", // cl1-orange-1 (dark orange for node backgrounds)
78-
primaryBorderColor: "#f6821f", // cl1-brand-orange
79-
primaryTextColor: "#f2f2f2", // cl1-gray-9
80-
secondaryColor: "#313131", // cl1-gray-1
81-
secondaryBorderColor: "#797979", // cl1-gray-5
82-
secondaryTextColor: "#f2f2f2", // cl1-gray-9
83-
tertiaryColor: "#313131", // cl1-gray-1
84-
tertiaryBorderColor: "#797979", // cl1-gray-5
85-
tertiaryTextColor: "#f2f2f2", // cl1-gray-9
86-
lineColor: "#f6821f", // cl1-brand-orange for arrows
87-
textColor: "#f2f2f2", // cl1-gray-9
88-
mainBkg: "#482303", // cl1-orange-1
89-
background: "#1d1d1d", // cl1-gray-0
90-
errorBkgColor: "#3c0501", // cl1-red-0
91-
errorTextColor: "#ffefee", // cl1-red-9
92-
edgeLabelBackground: "#1d1d1d", // dark background for edge labels
93-
labelBackground: "#1d1d1d", // dark background for labels
94-
};
95-
96-
const themeVariables = isLight ? lightThemeVars : darkThemeVars;
97-
98-
for (const diagram of diagrams) {
99-
if (!init) {
100-
diagram.setAttribute("data-diagram", diagram.textContent as string);
95+
// Get computed font family from CSS variable
96+
function getFontFamily(): string {
97+
const computedStyle = getComputedStyle(document.documentElement);
98+
const slFont = computedStyle.getPropertyValue("--__sl-font").trim();
99+
return slFont || "system-ui, -apple-system, sans-serif";
100+
}
101+
102+
// Read the actual page background color from CSS
103+
function getPageBackground(): string {
104+
const style = getComputedStyle(document.documentElement);
105+
const bg = style.getPropertyValue("--sl-color-bg").trim();
106+
return (
107+
bg ||
108+
(document.documentElement.getAttribute("data-theme") === "light"
109+
? "#ffffff"
110+
: "#1d1d1d")
111+
);
112+
}
113+
114+
// Create wrapper container with annotation
115+
function wrapDiagram(diagram: HTMLPreElement, title: string | null) {
116+
// Skip if already wrapped
117+
if (diagram.parentElement?.classList.contains("mermaid-container")) {
118+
return;
119+
}
120+
121+
// Create container
122+
const container = document.createElement("div");
123+
container.className = "mermaid-container";
124+
125+
// Wrap the diagram
126+
diagram.parentNode?.insertBefore(container, diagram);
127+
container.appendChild(diagram);
128+
129+
// Add expand button
130+
const expandBtn = document.createElement("button");
131+
expandBtn.className = "mermaid-expand";
132+
expandBtn.setAttribute("aria-label", "Expand diagram");
133+
expandBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134+
<polyline points="15 3 21 3 21 9"></polyline>
135+
<polyline points="9 21 3 21 3 15"></polyline>
136+
<line x1="21" y1="3" x2="14" y2="10"></line>
137+
<line x1="3" y1="21" x2="10" y2="14"></line>
138+
</svg>`;
139+
expandBtn.addEventListener("click", () => openDiagram(container));
140+
container.appendChild(expandBtn);
141+
142+
// Add annotation footer if title exists
143+
if (title) {
144+
const footer = document.createElement("div");
145+
footer.className = "mermaid-annotation";
146+
147+
const titleSpan = document.createElement("span");
148+
titleSpan.className = "mermaid-annotation-title";
149+
titleSpan.textContent = title;
150+
151+
const logo = document.createElement("img");
152+
logo.src = "/logo.svg";
153+
logo.alt = "Cloudflare";
154+
logo.className = "mermaid-annotation-logo";
155+
156+
footer.appendChild(titleSpan);
157+
footer.appendChild(logo);
158+
container.appendChild(footer);
101159
}
160+
}
161+
162+
async function render() {
163+
// Dynamically import mermaid — the ~2.5 MB bundle is only fetched
164+
// on the ~2% of pages that actually contain diagrams.
165+
const { default: mermaid } = await import("mermaid");
166+
167+
const isLight =
168+
document.documentElement.getAttribute("data-theme") === "light";
169+
const fontFamily = getFontFamily();
170+
const pageBg = getPageBackground();
171+
172+
// Custom theme variables for Cloudflare branding
173+
const lightThemeVars = {
174+
fontFamily,
175+
primaryColor: "#fef1e6", // cl1-orange-9 (very light orange for node backgrounds)
176+
primaryBorderColor: "#f6821f", // cl1-brand-orange
177+
primaryTextColor: "#1d1d1d", // cl1-gray-0
178+
secondaryColor: "#f2f2f2", // cl1-gray-9
179+
secondaryBorderColor: "#999999", // cl1-gray-6
180+
secondaryTextColor: "#1d1d1d", // cl1-gray-0
181+
tertiaryColor: "#f2f2f2", // cl1-gray-9
182+
tertiaryBorderColor: "#999999", // cl1-gray-6
183+
tertiaryTextColor: "#1d1d1d", // cl1-gray-0
184+
lineColor: "#f6821f", // cl1-brand-orange for arrows
185+
textColor: "#1d1d1d", // cl1-gray-0
186+
mainBkg: "#fef1e6", // cl1-orange-9
187+
errorBkgColor: "#ffefee", // cl1-red-9
188+
errorTextColor: "#3c0501", // cl1-red-0
189+
edgeLabelBackground: pageBg, // match page background to occlude arrows
190+
labelBackground: pageBg,
191+
};
102192

103-
const def = diagram.getAttribute("data-diagram") as string;
193+
const darkThemeVars = {
194+
fontFamily,
195+
primaryColor: "#482303", // cl1-orange-1 (dark orange for node backgrounds)
196+
primaryBorderColor: "#f6821f", // cl1-brand-orange
197+
primaryTextColor: "#f2f2f2", // cl1-gray-9
198+
secondaryColor: "#313131", // cl1-gray-1
199+
secondaryBorderColor: "#797979", // cl1-gray-5
200+
secondaryTextColor: "#f2f2f2", // cl1-gray-9
201+
tertiaryColor: "#313131", // cl1-gray-1
202+
tertiaryBorderColor: "#797979", // cl1-gray-5
203+
tertiaryTextColor: "#f2f2f2", // cl1-gray-9
204+
lineColor: "#f6821f", // cl1-brand-orange for arrows
205+
textColor: "#f2f2f2", // cl1-gray-9
206+
mainBkg: "#482303", // cl1-orange-1
207+
background: "#1d1d1d", // cl1-gray-0
208+
errorBkgColor: "#3c0501", // cl1-red-0
209+
errorTextColor: "#ffefee", // cl1-red-9
210+
edgeLabelBackground: pageBg, // match page background to occlude arrows
211+
labelBackground: pageBg,
212+
};
104213

105-
// Initialize with base theme and custom variables
214+
const themeVariables = isLight ? lightThemeVars : darkThemeVars;
215+
216+
// Initialize once before the loop — config is identical for all diagrams
106217
mermaid.initialize({
107218
startOnLoad: false,
108219
theme: "base",
109220
themeVariables,
110221
flowchart: {
111222
htmlLabels: true,
112223
useMaxWidth: true,
224+
curve: "linear",
113225
},
114226
});
115227

116-
await mermaid
117-
.render(`mermaid-${crypto.randomUUID()}`, def)
118-
.then(({ svg }) => {
228+
for (const diagram of diagrams) {
229+
try {
230+
if (!init) {
231+
diagram.setAttribute("data-diagram", diagram.textContent as string);
232+
}
233+
234+
const def = diagram.getAttribute("data-diagram") as string;
235+
236+
const { svg } = await mermaid.render(
237+
`mermaid-${crypto.randomUUID()}`,
238+
def,
239+
);
119240
diagram.innerHTML = svg;
120241

121242
// Extract title from SVG for annotation
@@ -125,19 +246,22 @@ async function render() {
125246

126247
// Wrap diagram with container and annotation
127248
wrapDiagram(diagram, title);
128-
});
249+
} catch (e) {
250+
console.error("Mermaid render failed:", e);
251+
}
129252

130-
diagram.setAttribute("data-processed", "true");
131-
}
253+
diagram.setAttribute("data-processed", "true");
254+
}
132255

133-
init = true;
134-
}
256+
init = true;
257+
}
135258

136-
const obs = new MutationObserver(() => render());
259+
const obs = new MutationObserver(() => render());
137260

138-
obs.observe(document.documentElement, {
139-
attributes: true,
140-
attributeFilter: ["data-theme"],
141-
});
261+
obs.observe(document.documentElement, {
262+
attributes: true,
263+
attributeFilter: ["data-theme"],
264+
});
142265

143-
render();
266+
render();
267+
}

0 commit comments

Comments
 (0)