@nu-appdev/northwestern-starlight-theme 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -2
- package/index.ts +84 -5
- package/mermaid.ts +196 -80
- package/package.json +19 -4
- package/src/components/Expandable.astro +19 -0
- package/src/components/Glossary.astro +11 -0
- package/src/components/Hero.astro +0 -2
- package/src/components/Kbd.astro +147 -0
- package/src/components/Property.astro +64 -0
- package/src/components/PropertyGroup.astro +13 -0
- package/src/components/PropertyTable.astro +54 -0
- package/src/components/Term.astro +29 -0
- package/src/components/Tooltip.astro +183 -0
- package/src/components/glossary-store.ts +12 -0
- package/src/components/index.ts +8 -0
- package/src/rehype-table-scroll.ts +35 -0
- package/src/scripts/mermaid/focus.ts +100 -0
- package/src/scripts/mermaid/fullscreen.ts +120 -0
- package/src/scripts/mermaid/index.ts +2 -0
- package/src/scripts/mermaid/overlay.ts +132 -0
- package/src/scripts/mermaid/pan-zoom.ts +285 -0
- package/src/scripts/mermaid/render.ts +143 -0
- package/src/scripts/mermaid/toolbar.ts +137 -0
- package/src/scripts/mermaid/ui.ts +265 -0
- package/src/styles/a11y.css +69 -0
- package/src/styles/components/blockquotes.css +37 -0
- package/src/styles/components/code.css +8 -5
- package/src/styles/components/footnotes.css +76 -0
- package/src/styles/components/kbd.css +69 -0
- package/src/styles/components/mermaid.css +4 -4
- package/src/styles/components/property-table.css +569 -0
- package/src/styles/components/steps.css +0 -3
- package/src/styles/components/tables.css +60 -0
- package/src/styles/components/tabs.css +0 -3
- package/src/styles/components/tooltip.css +145 -0
- package/src/styles/content.css +4 -160
- package/src/styles/layers.css +0 -7
- package/src/styles/mermaid-toolbar.css +62 -13
- package/src/styles/navigation.css +66 -17
- package/src/styles/openapi.css +0 -3
- package/src/styles/theme.css +0 -4
- package/src/styles/typography.css +4 -0
- package/src/styles/variables.css +0 -3
- package/src/virtual.d.ts +11 -0
- package/src/scripts/mermaid-toolbar.ts +0 -659
|
@@ -1,659 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mermaid Toolbar — Fullscreen viewer with pan/zoom, download, and copy.
|
|
3
|
-
*
|
|
4
|
-
* Features:
|
|
5
|
-
* - Hover toolbar: fullscreen, download SVG, copy mermaid source
|
|
6
|
-
* - Fullscreen overlay: pan/zoom, download, copy
|
|
7
|
-
* - Keyboard: +/- zoom, 0 reset, arrows pan, Escape close
|
|
8
|
-
* - Double-click to zoom in
|
|
9
|
-
* - Smooth open/close animation
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
// Force module scope so `declare global` works correctly
|
|
13
|
-
export {};
|
|
14
|
-
|
|
15
|
-
declare global {
|
|
16
|
-
interface Window {
|
|
17
|
-
__NU_MERMAID_TOOLBAR__?: boolean;
|
|
18
|
-
__NU_MERMAID_CONFIGS__?: {
|
|
19
|
-
light?: Record<string, unknown>;
|
|
20
|
-
dark?: Record<string, unknown>;
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const ICONS = {
|
|
26
|
-
fullscreen: ["M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"],
|
|
27
|
-
download: ["M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4", "M7 10l5 5 5-5", "M12 15V3"],
|
|
28
|
-
close: ["M18 6L6 18M6 6l12 12"],
|
|
29
|
-
zoomIn: ["M12 5v14", "M5 12h14"],
|
|
30
|
-
zoomOut: ["M5 12h14"],
|
|
31
|
-
reset: ["M3 12a9 9 0 1018 0 9 9 0 00-18 0z", "M12 8v4l2 2"],
|
|
32
|
-
copy: [
|
|
33
|
-
"M8 4H6a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2v-2",
|
|
34
|
-
"M16 4h2a2 2 0 012 2v6a2 2 0 01-2 2h-8a2 2 0 01-2-2V6a2 2 0 012-2",
|
|
35
|
-
],
|
|
36
|
-
code: ["M16 18l6-6-6-6", "M8 6l-6 6 6 6"],
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function createSvgIcon(paths: string[], size = 14): SVGElement {
|
|
40
|
-
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
41
|
-
svg.setAttribute("width", String(size));
|
|
42
|
-
svg.setAttribute("height", String(size));
|
|
43
|
-
svg.setAttribute("viewBox", "0 0 24 24");
|
|
44
|
-
svg.setAttribute("fill", "none");
|
|
45
|
-
svg.setAttribute("stroke", "currentColor");
|
|
46
|
-
svg.setAttribute("stroke-width", "2");
|
|
47
|
-
svg.setAttribute("stroke-linecap", "round");
|
|
48
|
-
svg.setAttribute("stroke-linejoin", "round");
|
|
49
|
-
for (const d of paths) {
|
|
50
|
-
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
51
|
-
path.setAttribute("d", d);
|
|
52
|
-
svg.appendChild(path);
|
|
53
|
-
}
|
|
54
|
-
return svg;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function createButton(title: string, action: string, iconPaths: string[], cls = "nu-mermaid-btn"): HTMLButtonElement {
|
|
58
|
-
const btn = document.createElement("button");
|
|
59
|
-
btn.className = cls;
|
|
60
|
-
btn.dataset.action = action;
|
|
61
|
-
btn.title = title;
|
|
62
|
-
btn.setAttribute("aria-label", title);
|
|
63
|
-
btn.appendChild(createSvgIcon(iconPaths));
|
|
64
|
-
return btn;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function createZoomIcon(extra: string[]): SVGElement {
|
|
68
|
-
const svg = createSvgIcon(extra, 14);
|
|
69
|
-
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
70
|
-
circle.setAttribute("cx", "11");
|
|
71
|
-
circle.setAttribute("cy", "11");
|
|
72
|
-
circle.setAttribute("r", "8");
|
|
73
|
-
svg.insertBefore(circle, svg.firstChild);
|
|
74
|
-
const line = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
75
|
-
line.setAttribute("d", "M21 21l-4.35-4.35");
|
|
76
|
-
svg.insertBefore(line, svg.children[1]);
|
|
77
|
-
return svg;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function createZoomButton(
|
|
81
|
-
title: string,
|
|
82
|
-
action: string,
|
|
83
|
-
iconExtra: string[],
|
|
84
|
-
cls = "nu-mermaid-btn",
|
|
85
|
-
): HTMLButtonElement {
|
|
86
|
-
const btn = document.createElement("button");
|
|
87
|
-
btn.className = cls;
|
|
88
|
-
btn.dataset.action = action;
|
|
89
|
-
btn.title = title;
|
|
90
|
-
btn.setAttribute("aria-label", title);
|
|
91
|
-
btn.appendChild(createZoomIcon(iconExtra));
|
|
92
|
-
return btn;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
type MermaidThemeMode = "light" | "dark";
|
|
96
|
-
|
|
97
|
-
const mermaidSources = new Map<Element, string>();
|
|
98
|
-
const renderedThemes = new WeakMap<HTMLElement, MermaidThemeMode>();
|
|
99
|
-
let mermaidRuntimePromise: Promise<typeof import("mermaid")["default"]> | undefined;
|
|
100
|
-
|
|
101
|
-
function currentThemeMode(): MermaidThemeMode {
|
|
102
|
-
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function getRuntimeMermaidConfig(mode: MermaidThemeMode): Record<string, unknown> {
|
|
106
|
-
return window.__NU_MERMAID_CONFIGS__?.[mode] ?? {};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function getMermaidRuntime() {
|
|
110
|
-
mermaidRuntimePromise ??= import("mermaid").then((module) => module.default);
|
|
111
|
-
return mermaidRuntimePromise;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function captureSource() {
|
|
115
|
-
document.querySelectorAll<HTMLElement>("pre.mermaid").forEach((pre) => {
|
|
116
|
-
if (mermaidSources.has(pre)) return;
|
|
117
|
-
// Prefer data-diagram attribute (set by astro-mermaid before it renders SVG)
|
|
118
|
-
// over textContent, which may contain rendered SVG text nodes instead of source
|
|
119
|
-
const source = pre.getAttribute("data-diagram")?.trim() || pre.textContent?.trim() || "";
|
|
120
|
-
if (source) {
|
|
121
|
-
mermaidSources.set(pre, source);
|
|
122
|
-
// Persist source to data attribute so it survives across render cycles
|
|
123
|
-
if (!pre.hasAttribute("data-diagram")) {
|
|
124
|
-
pre.setAttribute("data-diagram", source);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function renderDiagram(container: HTMLElement, mode: MermaidThemeMode, index: number) {
|
|
131
|
-
const source =
|
|
132
|
-
mermaidSources.get(container) ??
|
|
133
|
-
container.getAttribute("data-diagram")?.trim() ??
|
|
134
|
-
container.textContent?.trim();
|
|
135
|
-
if (!source) return;
|
|
136
|
-
const mermaid = await getMermaidRuntime();
|
|
137
|
-
|
|
138
|
-
mermaid.initialize({
|
|
139
|
-
startOnLoad: false,
|
|
140
|
-
...(getRuntimeMermaidConfig(mode) as Record<string, unknown>),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const renderId = `nu-mermaid-${mode}-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
|
144
|
-
const { svg, bindFunctions } = await mermaid.render(renderId, source);
|
|
145
|
-
container.innerHTML = svg;
|
|
146
|
-
bindFunctions?.(container);
|
|
147
|
-
renderedThemes.set(container, mode);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function renderMermaidDiagrams(force = false) {
|
|
151
|
-
captureSource();
|
|
152
|
-
const mode = currentThemeMode();
|
|
153
|
-
const diagrams = [...document.querySelectorAll<HTMLElement>("pre.mermaid:not(.nu-mermaid-fullscreen)")];
|
|
154
|
-
if (!diagrams.length) return;
|
|
155
|
-
|
|
156
|
-
// If force (theme change), re-render all visible diagrams immediately
|
|
157
|
-
if (force) {
|
|
158
|
-
for (const [index, container] of diagrams.entries()) {
|
|
159
|
-
if (renderedThemes.get(container) === mode && container.querySelector("svg")) continue;
|
|
160
|
-
await renderDiagram(container, mode, index);
|
|
161
|
-
}
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Lazy render: only render diagrams when they scroll into view
|
|
166
|
-
const observer = new IntersectionObserver(
|
|
167
|
-
(entries) => {
|
|
168
|
-
for (const entry of entries) {
|
|
169
|
-
if (!entry.isIntersecting) continue;
|
|
170
|
-
const container = entry.target as HTMLElement;
|
|
171
|
-
const index = diagrams.indexOf(container);
|
|
172
|
-
if (renderedThemes.get(container) === mode && container.querySelector("svg")) continue;
|
|
173
|
-
observer.unobserve(container);
|
|
174
|
-
void renderDiagram(container, mode, index);
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
{ rootMargin: "200px" },
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
for (const container of diagrams) {
|
|
181
|
-
if (renderedThemes.get(container) === mode && container.querySelector("svg")) continue;
|
|
182
|
-
observer.observe(container);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function showSuccess(btn: HTMLButtonElement, label = "Copied!") {
|
|
187
|
-
const originalTitle = btn.title;
|
|
188
|
-
const originalIcon = btn.querySelector("svg");
|
|
189
|
-
const check = createSvgIcon(["M20 6L9 17l-5-5"], 14);
|
|
190
|
-
|
|
191
|
-
btn.title = label;
|
|
192
|
-
btn.classList.add("nu-mermaid-success");
|
|
193
|
-
if (originalIcon) {
|
|
194
|
-
originalIcon.style.display = "none";
|
|
195
|
-
btn.insertBefore(check, btn.firstChild);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
setTimeout(() => {
|
|
199
|
-
btn.title = originalTitle;
|
|
200
|
-
btn.classList.remove("nu-mermaid-success");
|
|
201
|
-
check.remove();
|
|
202
|
-
if (originalIcon) originalIcon.style.display = "";
|
|
203
|
-
}, 1500);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function copyToClipboard(text: string, btn: HTMLButtonElement) {
|
|
207
|
-
try {
|
|
208
|
-
await navigator.clipboard.writeText(text);
|
|
209
|
-
showSuccess(btn, "Copied!");
|
|
210
|
-
} catch {
|
|
211
|
-
// Fallback
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function initMermaidToolbar(): number {
|
|
216
|
-
const diagrams = document.querySelectorAll<HTMLElement>(".mermaid");
|
|
217
|
-
if (!diagrams.length) return 0;
|
|
218
|
-
let injected = 0;
|
|
219
|
-
|
|
220
|
-
diagrams.forEach((container, index) => {
|
|
221
|
-
if (container.classList.contains("nu-mermaid-fullscreen")) return;
|
|
222
|
-
if (!container.querySelector("svg")) return;
|
|
223
|
-
if (container.querySelector(".nu-mermaid-toolbar")) return;
|
|
224
|
-
|
|
225
|
-
const toolbar = document.createElement("div");
|
|
226
|
-
toolbar.className = "nu-mermaid-toolbar";
|
|
227
|
-
|
|
228
|
-
toolbar.appendChild(createButton("Fullscreen", "fullscreen", ICONS.fullscreen));
|
|
229
|
-
toolbar.appendChild(createButton("Download SVG", "download-svg", ICONS.download));
|
|
230
|
-
|
|
231
|
-
const source = mermaidSources.get(container);
|
|
232
|
-
if (source) {
|
|
233
|
-
toolbar.appendChild(createButton("Copy Mermaid", "copy-source", ICONS.code));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
container.style.position = "relative";
|
|
237
|
-
container.appendChild(toolbar);
|
|
238
|
-
injected++;
|
|
239
|
-
|
|
240
|
-
toolbar.addEventListener("click", (e) => {
|
|
241
|
-
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".nu-mermaid-btn");
|
|
242
|
-
if (!btn) return;
|
|
243
|
-
e.stopPropagation();
|
|
244
|
-
|
|
245
|
-
const svg = container.querySelector("svg");
|
|
246
|
-
if (!svg) return;
|
|
247
|
-
|
|
248
|
-
const action = btn.dataset.action;
|
|
249
|
-
if (action === "fullscreen") openFullscreen(svg, container, index, btn, e.detail === 0);
|
|
250
|
-
else if (action === "download-svg") {
|
|
251
|
-
downloadSvg(svg, container, index);
|
|
252
|
-
showSuccess(btn, "Downloaded!");
|
|
253
|
-
} else if (action === "copy-source" && source) copyToClipboard(source, btn);
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
return injected;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function openFullscreen(
|
|
260
|
-
svg: SVGElement,
|
|
261
|
-
container: HTMLElement,
|
|
262
|
-
index: number,
|
|
263
|
-
triggerBtn?: HTMLElement,
|
|
264
|
-
keyboardOpen = false,
|
|
265
|
-
) {
|
|
266
|
-
const overlay = document.createElement("div");
|
|
267
|
-
overlay.className = "nu-mermaid-overlay";
|
|
268
|
-
overlay.setAttribute("role", "dialog");
|
|
269
|
-
overlay.setAttribute("aria-modal", "true");
|
|
270
|
-
overlay.setAttribute("aria-label", "Mermaid diagram fullscreen viewer");
|
|
271
|
-
|
|
272
|
-
// Controls
|
|
273
|
-
const controls = document.createElement("div");
|
|
274
|
-
controls.className = "nu-mermaid-overlay-controls";
|
|
275
|
-
|
|
276
|
-
const btnCls = "nu-mermaid-btn nu-mermaid-overlay-btn";
|
|
277
|
-
controls.appendChild(createZoomButton("Zoom In (+)", "zoom-in", ICONS.zoomIn, btnCls));
|
|
278
|
-
controls.appendChild(createZoomButton("Zoom Out (-)", "zoom-out", ICONS.zoomOut, btnCls));
|
|
279
|
-
controls.appendChild(createButton("Reset (0)", "zoom-reset", ICONS.reset, btnCls));
|
|
280
|
-
|
|
281
|
-
const sep1 = document.createElement("span");
|
|
282
|
-
sep1.className = "nu-mermaid-separator";
|
|
283
|
-
controls.appendChild(sep1);
|
|
284
|
-
|
|
285
|
-
controls.appendChild(createButton("Download SVG", "download-svg", ICONS.download, btnCls));
|
|
286
|
-
controls.appendChild(createButton("Copy SVG", "copy-svg", ICONS.copy, btnCls));
|
|
287
|
-
|
|
288
|
-
const source = mermaidSources.get(container);
|
|
289
|
-
if (source) {
|
|
290
|
-
controls.appendChild(createButton("Copy Mermaid", "copy-source", ICONS.code, btnCls));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Spacer pushes close button to the right
|
|
294
|
-
const spacer = document.createElement("span");
|
|
295
|
-
spacer.className = "nu-mermaid-spacer";
|
|
296
|
-
controls.appendChild(spacer);
|
|
297
|
-
|
|
298
|
-
controls.appendChild(createButton("Close (Esc)", "close", ICONS.close, `${btnCls} nu-mermaid-close`));
|
|
299
|
-
|
|
300
|
-
overlay.appendChild(controls);
|
|
301
|
-
|
|
302
|
-
const viewport = document.createElement("div");
|
|
303
|
-
viewport.className = "nu-mermaid-viewport";
|
|
304
|
-
|
|
305
|
-
const wrapper = document.createElement("div");
|
|
306
|
-
wrapper.className = "mermaid nu-mermaid-fullscreen";
|
|
307
|
-
|
|
308
|
-
const cloned = svg.cloneNode(true) as SVGElement;
|
|
309
|
-
cloned.removeAttribute("style");
|
|
310
|
-
|
|
311
|
-
const viewBox = svg.getAttribute("viewBox");
|
|
312
|
-
let vbWidth = 800;
|
|
313
|
-
let vbHeight = 600;
|
|
314
|
-
if (viewBox) {
|
|
315
|
-
const parts = viewBox.split(/[\s,]+/).map(Number);
|
|
316
|
-
vbWidth = parts[2] || 800;
|
|
317
|
-
vbHeight = parts[3] || 600;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function calcFitScale(): number {
|
|
321
|
-
const maxW = window.innerWidth * 0.92;
|
|
322
|
-
const maxH = (window.innerHeight - 120) * 0.92; // account for controls bar
|
|
323
|
-
return Math.min(maxW / vbWidth, maxH / vbHeight);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const initialScale = calcFitScale();
|
|
327
|
-
cloned.setAttribute("width", String(Math.round(vbWidth * initialScale)));
|
|
328
|
-
cloned.setAttribute("height", String(Math.round(vbHeight * initialScale)));
|
|
329
|
-
|
|
330
|
-
const zoomBadge = document.createElement("span");
|
|
331
|
-
zoomBadge.className = "nu-mermaid-zoom-badge";
|
|
332
|
-
zoomBadge.textContent = "100%";
|
|
333
|
-
|
|
334
|
-
wrapper.appendChild(cloned);
|
|
335
|
-
viewport.appendChild(wrapper);
|
|
336
|
-
viewport.appendChild(zoomBadge);
|
|
337
|
-
overlay.appendChild(viewport);
|
|
338
|
-
|
|
339
|
-
overlay.style.opacity = "0";
|
|
340
|
-
document.body.appendChild(overlay);
|
|
341
|
-
document.body.style.overflow = "hidden";
|
|
342
|
-
requestAnimationFrame(() => {
|
|
343
|
-
overlay.style.opacity = "1";
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Pan + zoom state
|
|
347
|
-
let scale = 1; // relative to initial fit
|
|
348
|
-
let panX = 0;
|
|
349
|
-
let panY = 0;
|
|
350
|
-
let isPanning = false;
|
|
351
|
-
let startX = 0;
|
|
352
|
-
let startY = 0;
|
|
353
|
-
|
|
354
|
-
function updateTransform() {
|
|
355
|
-
wrapper.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
|
356
|
-
wrapper.style.transformOrigin = "center center";
|
|
357
|
-
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function zoomTo(newScale: number) {
|
|
361
|
-
scale = Math.min(Math.max(0.1, newScale), 20);
|
|
362
|
-
updateTransform();
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function resetView() {
|
|
366
|
-
scale = 1;
|
|
367
|
-
panX = 0;
|
|
368
|
-
panY = 0;
|
|
369
|
-
updateTransform();
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
viewport.addEventListener(
|
|
373
|
-
"wheel",
|
|
374
|
-
(e) => {
|
|
375
|
-
e.preventDefault();
|
|
376
|
-
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
377
|
-
const newScale = Math.min(Math.max(0.1, scale * factor), 20);
|
|
378
|
-
// Anchor zoom to cursor: keep the point under the cursor fixed
|
|
379
|
-
const rect = viewport.getBoundingClientRect();
|
|
380
|
-
const cx = e.clientX - rect.left - rect.width / 2;
|
|
381
|
-
const cy = e.clientY - rect.top - rect.height / 2;
|
|
382
|
-
const ratio = 1 - newScale / scale;
|
|
383
|
-
panX += (cx - panX) * ratio;
|
|
384
|
-
panY += (cy - panY) * ratio;
|
|
385
|
-
scale = newScale;
|
|
386
|
-
updateTransform();
|
|
387
|
-
},
|
|
388
|
-
{ passive: false },
|
|
389
|
-
);
|
|
390
|
-
|
|
391
|
-
viewport.addEventListener("mousedown", (e) => {
|
|
392
|
-
isPanning = true;
|
|
393
|
-
startX = e.clientX - panX;
|
|
394
|
-
startY = e.clientY - panY;
|
|
395
|
-
viewport.style.cursor = "grabbing";
|
|
396
|
-
e.preventDefault();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
const onMouseMove = (e: MouseEvent) => {
|
|
400
|
-
if (!isPanning) return;
|
|
401
|
-
panX = e.clientX - startX;
|
|
402
|
-
panY = e.clientY - startY;
|
|
403
|
-
updateTransform();
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
const onMouseUp = () => {
|
|
407
|
-
isPanning = false;
|
|
408
|
-
viewport.style.cursor = "grab";
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
window.addEventListener("mousemove", onMouseMove);
|
|
412
|
-
window.addEventListener("mouseup", onMouseUp);
|
|
413
|
-
|
|
414
|
-
let lastTouchDist = 0;
|
|
415
|
-
let lastTouchX = 0;
|
|
416
|
-
let lastTouchY = 0;
|
|
417
|
-
let isTouchPanning = false;
|
|
418
|
-
|
|
419
|
-
viewport.addEventListener(
|
|
420
|
-
"touchstart",
|
|
421
|
-
(e) => {
|
|
422
|
-
if (e.touches.length === 1) {
|
|
423
|
-
isTouchPanning = true;
|
|
424
|
-
lastTouchX = e.touches[0].clientX;
|
|
425
|
-
lastTouchY = e.touches[0].clientY;
|
|
426
|
-
} else if (e.touches.length === 2) {
|
|
427
|
-
isTouchPanning = false;
|
|
428
|
-
lastTouchDist = Math.hypot(
|
|
429
|
-
e.touches[0].clientX - e.touches[1].clientX,
|
|
430
|
-
e.touches[0].clientY - e.touches[1].clientY,
|
|
431
|
-
);
|
|
432
|
-
lastTouchX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
433
|
-
lastTouchY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
434
|
-
}
|
|
435
|
-
e.preventDefault();
|
|
436
|
-
},
|
|
437
|
-
{ passive: false },
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
viewport.addEventListener(
|
|
441
|
-
"touchmove",
|
|
442
|
-
(e) => {
|
|
443
|
-
if (e.touches.length === 1 && isTouchPanning) {
|
|
444
|
-
panX += e.touches[0].clientX - lastTouchX;
|
|
445
|
-
panY += e.touches[0].clientY - lastTouchY;
|
|
446
|
-
lastTouchX = e.touches[0].clientX;
|
|
447
|
-
lastTouchY = e.touches[0].clientY;
|
|
448
|
-
updateTransform();
|
|
449
|
-
} else if (e.touches.length === 2) {
|
|
450
|
-
const dist = Math.hypot(
|
|
451
|
-
e.touches[0].clientX - e.touches[1].clientX,
|
|
452
|
-
e.touches[0].clientY - e.touches[1].clientY,
|
|
453
|
-
);
|
|
454
|
-
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
455
|
-
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
456
|
-
|
|
457
|
-
if (lastTouchDist > 0) {
|
|
458
|
-
zoomTo(scale * (dist / lastTouchDist));
|
|
459
|
-
panX += midX - lastTouchX;
|
|
460
|
-
panY += midY - lastTouchY;
|
|
461
|
-
updateTransform();
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
lastTouchDist = dist;
|
|
465
|
-
lastTouchX = midX;
|
|
466
|
-
lastTouchY = midY;
|
|
467
|
-
}
|
|
468
|
-
e.preventDefault();
|
|
469
|
-
},
|
|
470
|
-
{ passive: false },
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
viewport.addEventListener("touchend", () => {
|
|
474
|
-
isTouchPanning = false;
|
|
475
|
-
lastTouchDist = 0;
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
viewport.addEventListener("dblclick", (e) => {
|
|
479
|
-
e.preventDefault();
|
|
480
|
-
const newScale = Math.min(Math.max(0.1, scale * 1.5), 20);
|
|
481
|
-
const rect = viewport.getBoundingClientRect();
|
|
482
|
-
const cx = e.clientX - rect.left - rect.width / 2;
|
|
483
|
-
const cy = e.clientY - rect.top - rect.height / 2;
|
|
484
|
-
const ratio = 1 - newScale / scale;
|
|
485
|
-
panX += (cx - panX) * ratio;
|
|
486
|
-
panY += (cy - panY) * ratio;
|
|
487
|
-
scale = newScale;
|
|
488
|
-
updateTransform();
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
controls.addEventListener("click", (e) => {
|
|
492
|
-
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".nu-mermaid-overlay-btn");
|
|
493
|
-
if (!btn) return;
|
|
494
|
-
|
|
495
|
-
const action = btn.dataset.action;
|
|
496
|
-
if (action === "close") close();
|
|
497
|
-
else if (action === "zoom-in") zoomTo(scale * 1.3);
|
|
498
|
-
else if (action === "zoom-out") zoomTo(scale * 0.7);
|
|
499
|
-
else if (action === "zoom-reset") resetView();
|
|
500
|
-
else if (action === "download-svg") {
|
|
501
|
-
downloadSvg(svg, container, index);
|
|
502
|
-
showSuccess(btn, "Downloaded!");
|
|
503
|
-
} else if (action === "copy-svg") copyToClipboard(svg.outerHTML, btn);
|
|
504
|
-
else if (action === "copy-source" && source) copyToClipboard(source, btn);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
const onKeydown = (e: KeyboardEvent) => {
|
|
508
|
-
if (e.key === "Tab") {
|
|
509
|
-
// Focus trap: cycle focus within the overlay
|
|
510
|
-
const focusable = overlay.querySelectorAll<HTMLElement>(
|
|
511
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
512
|
-
);
|
|
513
|
-
if (!focusable.length) return;
|
|
514
|
-
const first = focusable[0];
|
|
515
|
-
const last = focusable[focusable.length - 1];
|
|
516
|
-
if (e.shiftKey) {
|
|
517
|
-
if (document.activeElement === first) {
|
|
518
|
-
e.preventDefault();
|
|
519
|
-
last.focus();
|
|
520
|
-
}
|
|
521
|
-
} else {
|
|
522
|
-
if (document.activeElement === last) {
|
|
523
|
-
e.preventDefault();
|
|
524
|
-
first.focus();
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
if (e.key === "Escape") close();
|
|
530
|
-
else if (e.key === "+" || e.key === "=") zoomTo(scale * 1.2);
|
|
531
|
-
else if (e.key === "-" || e.key === "_") zoomTo(scale * 0.8);
|
|
532
|
-
else if (e.key === "0") resetView();
|
|
533
|
-
else if (e.key === "ArrowUp") {
|
|
534
|
-
panY += 50;
|
|
535
|
-
updateTransform();
|
|
536
|
-
} else if (e.key === "ArrowDown") {
|
|
537
|
-
panY -= 50;
|
|
538
|
-
updateTransform();
|
|
539
|
-
} else if (e.key === "ArrowLeft") {
|
|
540
|
-
panX += 50;
|
|
541
|
-
updateTransform();
|
|
542
|
-
} else if (e.key === "ArrowRight") {
|
|
543
|
-
panX -= 50;
|
|
544
|
-
updateTransform();
|
|
545
|
-
} else return;
|
|
546
|
-
e.preventDefault();
|
|
547
|
-
};
|
|
548
|
-
window.addEventListener("keydown", onKeydown);
|
|
549
|
-
|
|
550
|
-
// Close only on overlay backdrop click (not viewport/svg)
|
|
551
|
-
overlay.addEventListener("click", (e) => {
|
|
552
|
-
if (e.target === overlay) close();
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
// Keyboard: focus first button (shows focus ring). Mouse: focus overlay container (no ring, but Tab works).
|
|
556
|
-
if (keyboardOpen) {
|
|
557
|
-
const firstBtn = controls.querySelector<HTMLElement>(".nu-mermaid-btn");
|
|
558
|
-
firstBtn?.focus();
|
|
559
|
-
} else {
|
|
560
|
-
overlay.setAttribute("tabindex", "-1");
|
|
561
|
-
overlay.focus();
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function close() {
|
|
565
|
-
overlay.style.opacity = "0";
|
|
566
|
-
setTimeout(() => {
|
|
567
|
-
window.removeEventListener("keydown", onKeydown);
|
|
568
|
-
window.removeEventListener("mousemove", onMouseMove);
|
|
569
|
-
window.removeEventListener("mouseup", onMouseUp);
|
|
570
|
-
document.body.style.overflow = "";
|
|
571
|
-
overlay.remove();
|
|
572
|
-
triggerBtn?.focus();
|
|
573
|
-
}, 200);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function slugify(text: string): string {
|
|
578
|
-
return text
|
|
579
|
-
.toLowerCase()
|
|
580
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
581
|
-
.replace(/^-|-$/g, "");
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function getSiteSlug(): string {
|
|
585
|
-
const parts = document.title.split("|");
|
|
586
|
-
const site = (parts.length > 1 ? parts[parts.length - 1] : parts[0]).trim();
|
|
587
|
-
return slugify(site);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function getPageSlug(): string {
|
|
591
|
-
const path = window.location.pathname.replace(/^\/|\/$/g, "");
|
|
592
|
-
return slugify(path) || "index";
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function getDiagramType(container: Element): string {
|
|
596
|
-
const source = mermaidSources.get(container) ?? "";
|
|
597
|
-
const raw = source.trim().split(/[\s\n]/)[0];
|
|
598
|
-
// Strip version suffixes (-v2, -beta), then common trailing words (Diagram, Chart)
|
|
599
|
-
return slugify(raw.replace(/[-_](v\d+|beta)$/i, "").replace(/(diagram|chart)$/i, "")) || "diagram";
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function buildFilename(container: Element): string {
|
|
603
|
-
const site = getSiteSlug();
|
|
604
|
-
const page = getPageSlug();
|
|
605
|
-
const type = getDiagramType(container);
|
|
606
|
-
|
|
607
|
-
// Count diagrams of same type on page to add suffix if needed
|
|
608
|
-
const allDiagrams = [...document.querySelectorAll<HTMLElement>(".mermaid:not(.nu-mermaid-fullscreen)")];
|
|
609
|
-
const sameType = allDiagrams.filter((d) => getDiagramType(d) === type);
|
|
610
|
-
const suffix = sameType.length > 1 ? `-${sameType.indexOf(container as HTMLElement) + 1}` : "";
|
|
611
|
-
|
|
612
|
-
return `${site}_${page}-${type}${suffix}.svg`;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function downloadSvg(svg: SVGElement, container: Element, index: number) {
|
|
616
|
-
const clone = svg.cloneNode(true) as SVGElement;
|
|
617
|
-
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
618
|
-
const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" });
|
|
619
|
-
const url = URL.createObjectURL(blob);
|
|
620
|
-
const a = document.createElement("a");
|
|
621
|
-
a.href = url;
|
|
622
|
-
a.download = buildFilename(container);
|
|
623
|
-
a.click();
|
|
624
|
-
URL.revokeObjectURL(url);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function watchForMermaidRender() {
|
|
628
|
-
const toolbarEnabled = window.__NU_MERMAID_TOOLBAR__ !== false;
|
|
629
|
-
// Initial load: lazy render (only diagrams in/near viewport)
|
|
630
|
-
void renderMermaidDiagrams(false).then(() => {
|
|
631
|
-
if (toolbarEnabled) initMermaidToolbar();
|
|
632
|
-
|
|
633
|
-
// Re-check for toolbars as lazy diagrams render
|
|
634
|
-
const toolbarObserver = new MutationObserver(() => {
|
|
635
|
-
if (toolbarEnabled) initMermaidToolbar();
|
|
636
|
-
});
|
|
637
|
-
toolbarObserver.observe(document.body, { childList: true, subtree: true });
|
|
638
|
-
setTimeout(() => toolbarObserver.disconnect(), 30000);
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const themeObserver = new MutationObserver(() => {
|
|
643
|
-
void renderMermaidDiagrams(true).then(() => {
|
|
644
|
-
if (window.__NU_MERMAID_TOOLBAR__ !== false) initMermaidToolbar();
|
|
645
|
-
});
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
themeObserver.observe(document.documentElement, {
|
|
649
|
-
attributes: true,
|
|
650
|
-
attributeFilter: ["data-theme"],
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
if (document.readyState === "loading") {
|
|
654
|
-
document.addEventListener("DOMContentLoaded", watchForMermaidRender);
|
|
655
|
-
} else {
|
|
656
|
-
watchForMermaidRender();
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
document.addEventListener("astro:page-load", watchForMermaidRender);
|