@nu-appdev/northwestern-starlight-theme 1.2.0 → 1.3.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +53 -2
  2. package/index.ts +84 -5
  3. package/mermaid.ts +196 -80
  4. package/package.json +19 -4
  5. package/src/components/Expandable.astro +19 -0
  6. package/src/components/Glossary.astro +11 -0
  7. package/src/components/Hero.astro +0 -2
  8. package/src/components/Kbd.astro +147 -0
  9. package/src/components/Property.astro +64 -0
  10. package/src/components/PropertyGroup.astro +13 -0
  11. package/src/components/PropertyTable.astro +54 -0
  12. package/src/components/Term.astro +29 -0
  13. package/src/components/Tooltip.astro +138 -0
  14. package/src/components/glossary-store.ts +12 -0
  15. package/src/components/index.ts +8 -0
  16. package/src/rehype-table-scroll.ts +35 -0
  17. package/src/scripts/mermaid/focus.ts +100 -0
  18. package/src/scripts/mermaid/fullscreen.ts +120 -0
  19. package/src/scripts/mermaid/index.ts +2 -0
  20. package/src/scripts/mermaid/overlay.ts +132 -0
  21. package/src/scripts/mermaid/pan-zoom.ts +285 -0
  22. package/src/scripts/mermaid/render.ts +143 -0
  23. package/src/scripts/mermaid/toolbar.ts +137 -0
  24. package/src/scripts/mermaid/ui.ts +265 -0
  25. package/src/styles/a11y.css +69 -0
  26. package/src/styles/components/blockquotes.css +37 -0
  27. package/src/styles/components/code.css +8 -5
  28. package/src/styles/components/footnotes.css +76 -0
  29. package/src/styles/components/kbd.css +69 -0
  30. package/src/styles/components/mermaid.css +4 -4
  31. package/src/styles/components/property-table.css +569 -0
  32. package/src/styles/components/steps.css +0 -3
  33. package/src/styles/components/tables.css +60 -0
  34. package/src/styles/components/tabs.css +0 -3
  35. package/src/styles/components/tooltip.css +145 -0
  36. package/src/styles/content.css +4 -160
  37. package/src/styles/layers.css +0 -7
  38. package/src/styles/mermaid-toolbar.css +62 -13
  39. package/src/styles/navigation.css +66 -17
  40. package/src/styles/openapi.css +0 -3
  41. package/src/styles/theme.css +0 -4
  42. package/src/styles/typography.css +4 -0
  43. package/src/styles/variables.css +0 -3
  44. package/src/virtual.d.ts +11 -0
  45. package/src/scripts/mermaid-toolbar.ts +0 -659
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Fullscreen viewer coordinator.
3
+ *
4
+ * Wires together overlay construction, pan/zoom interaction, keyboard/focus
5
+ * management, and controls bar action dispatch. Manages the open/close
6
+ * lifecycle including entry/exit animations and focus restoration.
7
+ */
8
+
9
+ import { installKeyboardAndFocus } from "./focus";
10
+ import { buildOverlay } from "./overlay";
11
+ import { createPanZoomController } from "./pan-zoom";
12
+ import { downloadDiagramSvg, flashSuccess, showToast, writeToClipboard } from "./ui";
13
+
14
+ const OPEN_SCALE = "scale(0.97)";
15
+ const OPEN_TRANSITION = "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)";
16
+ const OPEN_DURATION_MS = 300;
17
+ const CLOSE_TRANSITION = "transform 200ms ease-in";
18
+ const CLOSE_DURATION_MS = 200;
19
+ const CLOSE_SCALE_FACTOR = 0.97;
20
+
21
+ /**
22
+ * Open the fullscreen viewer for a rendered Mermaid diagram.
23
+ *
24
+ * @param diagramSvg - The rendered SVG element to display (cloned, not modified).
25
+ * @param diagramContainer - The `pre.mermaid` container holding the diagram.
26
+ * @param triggerButton - The button that opened fullscreen (receives focus on close).
27
+ * @param openedViaKeyboard - `true` if opened with Enter/Space (shows focus ring on first control).
28
+ */
29
+ export function openFullscreen(
30
+ diagramSvg: SVGElement,
31
+ diagramContainer: HTMLElement,
32
+ triggerButton?: HTMLElement,
33
+ openedViaKeyboard = false,
34
+ ): void {
35
+ const { overlay, controls, viewport, wrapper, zoomBadge, diagramSource } = buildOverlay(
36
+ diagramSvg,
37
+ diagramContainer,
38
+ );
39
+
40
+ overlay.style.opacity = "0";
41
+ wrapper.style.transform = OPEN_SCALE;
42
+ wrapper.style.transition = OPEN_TRANSITION;
43
+ document.body.appendChild(overlay);
44
+ document.body.style.overflow = "hidden";
45
+
46
+ requestAnimationFrame(() => {
47
+ overlay.style.opacity = "1";
48
+ wrapper.style.transform = "scale(1)";
49
+ setTimeout(() => {
50
+ wrapper.style.transition = "";
51
+ }, OPEN_DURATION_MS);
52
+ });
53
+
54
+ const panZoom = createPanZoomController(viewport, wrapper, zoomBadge);
55
+
56
+ controls.addEventListener("click", (event) => {
57
+ const button = (event.target as HTMLElement).closest<HTMLButtonElement>(".nu-mermaid-overlay-btn");
58
+ if (!button) return;
59
+
60
+ switch (button.dataset.action) {
61
+ case "close":
62
+ closeOverlay();
63
+ break;
64
+ case "zoom-in":
65
+ panZoom.zoomTo(panZoom.scale * 1.3);
66
+ break;
67
+ case "zoom-out":
68
+ panZoom.zoomTo(panZoom.scale * 0.7);
69
+ break;
70
+ case "zoom-reset":
71
+ panZoom.resetView();
72
+ break;
73
+ case "download-svg":
74
+ downloadDiagramSvg(diagramSvg, diagramContainer);
75
+ flashSuccess(button, "Downloaded!");
76
+ break;
77
+ case "copy-svg":
78
+ writeToClipboard(diagramSvg.outerHTML, button);
79
+ showToast(viewport, "SVG copied to clipboard");
80
+ break;
81
+ case "copy-source":
82
+ if (diagramSource) {
83
+ writeToClipboard(diagramSource, button);
84
+ showToast(viewport, "Mermaid source copied to clipboard");
85
+ }
86
+ break;
87
+ }
88
+ });
89
+
90
+ overlay.addEventListener("click", (event) => {
91
+ if (event.target === overlay) closeOverlay();
92
+ });
93
+
94
+ const removeKeyboardHandlers = installKeyboardAndFocus({
95
+ overlay,
96
+ controls,
97
+ panZoom,
98
+ onClose: closeOverlay,
99
+ openedViaKeyboard,
100
+ });
101
+
102
+ let closed = false;
103
+ function closeOverlay(): void {
104
+ if (closed) return;
105
+ closed = true;
106
+ panZoom.destroy();
107
+
108
+ overlay.style.opacity = "0";
109
+ wrapper.style.transition = CLOSE_TRANSITION;
110
+ wrapper.style.transform = `translate(${panZoom.panX}px, ${panZoom.panY}px) scale(${panZoom.scale * CLOSE_SCALE_FACTOR})`;
111
+
112
+ triggerButton?.focus();
113
+
114
+ setTimeout(() => {
115
+ removeKeyboardHandlers();
116
+ document.body.style.overflow = "";
117
+ overlay.remove();
118
+ }, CLOSE_DURATION_MS);
119
+ }
120
+ }
@@ -0,0 +1,2 @@
1
+ export { openFullscreen } from "./fullscreen";
2
+ export { diagramSources, renderAllDiagrams, type ThemeMode } from "./render";
@@ -0,0 +1,132 @@
1
+ /**
2
+ * DOM construction for the fullscreen Mermaid viewer.
3
+ *
4
+ * Builds the complete overlay element tree: dialog shell, controls bar with
5
+ * zoom/download/copy/close buttons, scrollable viewport, diagram wrapper,
6
+ * and zoom percentage badge. Returns typed element references for the coordinator.
7
+ */
8
+
9
+ import { diagramSources } from "./render";
10
+ import { createActionButton, createZoomButton, ICON_PATHS } from "./ui";
11
+
12
+ /** Element references returned by {@link buildOverlay} for the coordinator to wire up. */
13
+ export interface OverlayElements {
14
+ /** Root dialog element covering the full viewport. */
15
+ overlay: HTMLDivElement;
16
+ /** Top controls bar containing zoom, download, copy, and close buttons. */
17
+ controls: HTMLDivElement;
18
+ /** Scrollable/pannable area containing the diagram. */
19
+ viewport: HTMLDivElement;
20
+ /** Inner wrapper around the cloned SVG (receives pan/zoom transforms). */
21
+ wrapper: HTMLDivElement;
22
+ /** Zoom percentage indicator in the viewport corner. */
23
+ zoomBadge: HTMLSpanElement;
24
+ /** Raw Mermaid source text if available (for "Copy Mermaid" action). */
25
+ diagramSource: string | undefined;
26
+ }
27
+
28
+ const CONTROLS_BAR_HEIGHT_PX = 120;
29
+ const VIEWPORT_FILL_RATIO = 0.92;
30
+
31
+ /**
32
+ * Build the complete overlay DOM tree for a given diagram.
33
+ *
34
+ * The SVG is cloned and scaled to fit the viewport at 92% fill. The original
35
+ * diagram element is not modified.
36
+ */
37
+ export function buildOverlay(sourceSvg: SVGElement, diagramContainer: HTMLElement): OverlayElements {
38
+ const overlay = document.createElement("div");
39
+ overlay.className = "nu-mermaid-overlay";
40
+ overlay.setAttribute("role", "dialog");
41
+ overlay.setAttribute("aria-modal", "true");
42
+ overlay.setAttribute("aria-label", "Mermaid diagram fullscreen viewer");
43
+
44
+ const controls = buildControlsBar(diagramContainer);
45
+ overlay.appendChild(controls.element);
46
+
47
+ const { viewport, wrapper, zoomBadge } = buildViewport(sourceSvg);
48
+ overlay.appendChild(viewport);
49
+
50
+ return {
51
+ overlay,
52
+ controls: controls.element,
53
+ viewport,
54
+ wrapper,
55
+ zoomBadge,
56
+ diagramSource: controls.diagramSource,
57
+ };
58
+ }
59
+
60
+ const OVERLAY_BTN_CLASS = "nu-mermaid-btn nu-mermaid-overlay-btn";
61
+
62
+ function buildControlsBar(diagramContainer: HTMLElement) {
63
+ const bar = document.createElement("div");
64
+ bar.className = "nu-mermaid-overlay-controls";
65
+
66
+ bar.appendChild(createZoomButton("Zoom In (+)", "zoom-in", ICON_PATHS.zoomIn, OVERLAY_BTN_CLASS));
67
+ bar.appendChild(createZoomButton("Zoom Out (-)", "zoom-out", ICON_PATHS.zoomOut, OVERLAY_BTN_CLASS));
68
+ bar.appendChild(createActionButton("Reset (0)", "zoom-reset", ICON_PATHS.reset, OVERLAY_BTN_CLASS));
69
+
70
+ const separator = document.createElement("span");
71
+ separator.className = "nu-mermaid-separator";
72
+ bar.appendChild(separator);
73
+
74
+ bar.appendChild(createActionButton("Download SVG", "download-svg", ICON_PATHS.download, OVERLAY_BTN_CLASS));
75
+ bar.appendChild(createActionButton("Copy SVG", "copy-svg", ICON_PATHS.copy, OVERLAY_BTN_CLASS));
76
+
77
+ const diagramSource = diagramSources.get(diagramContainer);
78
+ if (diagramSource) {
79
+ bar.appendChild(createActionButton("Copy Mermaid", "copy-source", ICON_PATHS.code, OVERLAY_BTN_CLASS));
80
+ }
81
+
82
+ const spacer = document.createElement("span");
83
+ spacer.className = "nu-mermaid-spacer";
84
+ bar.appendChild(spacer);
85
+
86
+ bar.appendChild(
87
+ createActionButton("Close (Esc)", "close", ICON_PATHS.close, `${OVERLAY_BTN_CLASS} nu-mermaid-close`),
88
+ );
89
+
90
+ return { element: bar, diagramSource };
91
+ }
92
+
93
+ function buildViewport(sourceSvg: SVGElement) {
94
+ const viewport = document.createElement("div");
95
+ viewport.className = "nu-mermaid-viewport";
96
+
97
+ const wrapper = document.createElement("div");
98
+ wrapper.className = "mermaid nu-mermaid-fullscreen";
99
+
100
+ const clonedSvg = sourceSvg.cloneNode(true) as SVGElement;
101
+ clonedSvg.removeAttribute("style");
102
+
103
+ const { width, height } = parseSvgDimensions(sourceSvg);
104
+ const fitScale = calculateFitScale(width, height);
105
+ clonedSvg.setAttribute("width", String(Math.round(width * fitScale)));
106
+ clonedSvg.setAttribute("height", String(Math.round(height * fitScale)));
107
+
108
+ const zoomBadge = document.createElement("span");
109
+ zoomBadge.className = "nu-mermaid-zoom-badge";
110
+ zoomBadge.textContent = "100%";
111
+
112
+ wrapper.appendChild(clonedSvg);
113
+ viewport.appendChild(wrapper);
114
+ viewport.appendChild(zoomBadge);
115
+
116
+ return { viewport, wrapper, zoomBadge };
117
+ }
118
+
119
+ function parseSvgDimensions(svg: SVGElement): { width: number; height: number } {
120
+ const viewBox = svg.getAttribute("viewBox");
121
+ if (viewBox) {
122
+ const [, , w, h] = viewBox.split(/[\s,]+/).map(Number);
123
+ if (w > 0 && h > 0) return { width: w, height: h };
124
+ }
125
+ return { width: 800, height: 600 };
126
+ }
127
+
128
+ function calculateFitScale(contentWidth: number, contentHeight: number): number {
129
+ const maxWidth = window.innerWidth * VIEWPORT_FILL_RATIO;
130
+ const maxHeight = (window.innerHeight - CONTROLS_BAR_HEIGHT_PX) * VIEWPORT_FILL_RATIO;
131
+ return Math.min(maxWidth / contentWidth, maxHeight / contentHeight);
132
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Pan/zoom interaction controller for the fullscreen Mermaid viewport.
3
+ *
4
+ * Manages all pointer interactions: cursor-anchored wheel zoom, mouse drag
5
+ * with momentum, touch pan/pinch-to-zoom, and double-click zoom.
6
+ * Returns a controller object with methods to programmatically zoom, pan,
7
+ * reset, and tear down all listeners.
8
+ */
9
+
10
+ /** Controller returned by {@link createPanZoomController}. */
11
+ export interface PanZoomController {
12
+ /** Current horizontal pan offset in pixels. */
13
+ readonly panX: number;
14
+ /** Current vertical pan offset in pixels. */
15
+ readonly panY: number;
16
+ /** Current zoom scale relative to initial fit (1.0 = 100%). */
17
+ readonly scale: number;
18
+ /** Set the zoom scale, clamped to [{@link MIN_SCALE}, {@link MAX_SCALE}]. */
19
+ zoomTo(scale: number): void;
20
+ /** Reset pan and zoom to the initial state (0, 0, 1.0). */
21
+ resetView(): void;
22
+ /** Shift the pan offset by the given pixel deltas. */
23
+ panBy(dx: number, dy: number): void;
24
+ /** Cancel any active inertia and remove all event listeners. */
25
+ destroy(): void;
26
+ }
27
+
28
+ const MIN_SCALE = 0.1;
29
+ const MAX_SCALE = 20;
30
+ const WHEEL_ZOOM_IN_FACTOR = 1.1;
31
+ const WHEEL_ZOOM_OUT_FACTOR = 0.9;
32
+ const DOUBLE_CLICK_ZOOM_FACTOR = 1.5;
33
+ const INERTIA_FRICTION = 0.92;
34
+ const INERTIA_MIN_VELOCITY = 0.5;
35
+ const INERTIA_RELEASE_WINDOW_MS = 50;
36
+ const FRAME_DURATION_MS = 16;
37
+
38
+ /**
39
+ * Create a pan/zoom controller bound to the given viewport elements.
40
+ *
41
+ * @param viewport - The scrollable container that receives pointer events.
42
+ * @param wrapper - The inner element that receives CSS transforms.
43
+ * @param zoomBadge - The element displaying the current zoom percentage.
44
+ */
45
+ export function createPanZoomController(
46
+ viewport: HTMLElement,
47
+ wrapper: HTMLElement,
48
+ zoomBadge: HTMLElement,
49
+ ): PanZoomController {
50
+ let scale = 1;
51
+ let panX = 0;
52
+ let panY = 0;
53
+
54
+ function applyTransform(): void {
55
+ wrapper.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
56
+ wrapper.style.transformOrigin = "center center";
57
+ zoomBadge.textContent = `${Math.round(scale * 100)}%`;
58
+ }
59
+
60
+ function clampScale(value: number): number {
61
+ return Math.min(Math.max(MIN_SCALE, value), MAX_SCALE);
62
+ }
63
+
64
+ function zoomTo(newScale: number): void {
65
+ scale = clampScale(newScale);
66
+ applyTransform();
67
+ }
68
+
69
+ function resetView(): void {
70
+ scale = 1;
71
+ panX = 0;
72
+ panY = 0;
73
+ applyTransform();
74
+ }
75
+
76
+ function panBy(dx: number, dy: number): void {
77
+ panX += dx;
78
+ panY += dy;
79
+ applyTransform();
80
+ }
81
+
82
+ /**
83
+ * Zoom toward a specific screen coordinate so the point under the
84
+ * cursor/finger stays fixed. Used by wheel, double-click, and pinch.
85
+ */
86
+ function zoomTowardPoint(clientX: number, clientY: number, targetScale: number): void {
87
+ const clamped = clampScale(targetScale);
88
+ const rect = viewport.getBoundingClientRect();
89
+ const cursorX = clientX - rect.left - rect.width / 2;
90
+ const cursorY = clientY - rect.top - rect.height / 2;
91
+ const ratio = 1 - clamped / scale;
92
+ panX += (cursorX - panX) * ratio;
93
+ panY += (cursorY - panY) * ratio;
94
+ scale = clamped;
95
+ applyTransform();
96
+ }
97
+
98
+ let velocityX = 0;
99
+ let velocityY = 0;
100
+ let lastMoveTimestamp = 0;
101
+ let inertiaFrameId = 0;
102
+
103
+ function runInertia(): void {
104
+ cancelAnimationFrame(inertiaFrameId);
105
+ function tick(): void {
106
+ if (Math.abs(velocityX) < INERTIA_MIN_VELOCITY && Math.abs(velocityY) < INERTIA_MIN_VELOCITY) return;
107
+ panX += velocityX;
108
+ panY += velocityY;
109
+ velocityX *= INERTIA_FRICTION;
110
+ velocityY *= INERTIA_FRICTION;
111
+ applyTransform();
112
+ inertiaFrameId = requestAnimationFrame(tick);
113
+ }
114
+ inertiaFrameId = requestAnimationFrame(tick);
115
+ }
116
+
117
+ function cancelInertia(): void {
118
+ cancelAnimationFrame(inertiaFrameId);
119
+ velocityX = 0;
120
+ velocityY = 0;
121
+ }
122
+
123
+ /** Record the velocity for a movement delta, normalized to 16ms frames. */
124
+ function trackVelocity(dx: number, dy: number, timestamp: number): void {
125
+ const elapsed = timestamp - lastMoveTimestamp;
126
+ if (elapsed > 0) {
127
+ velocityX = dx * (FRAME_DURATION_MS / elapsed);
128
+ velocityY = dy * (FRAME_DURATION_MS / elapsed);
129
+ }
130
+ lastMoveTimestamp = timestamp;
131
+ }
132
+
133
+ /** Start inertia only if the last movement was recent (finger/mouse was still moving at release). */
134
+ function releaseWithMomentum(): void {
135
+ if (performance.now() - lastMoveTimestamp < INERTIA_RELEASE_WINDOW_MS) {
136
+ runInertia();
137
+ }
138
+ }
139
+
140
+ function handleWheel(event: WheelEvent): void {
141
+ event.preventDefault();
142
+ const factor = event.deltaY > 0 ? WHEEL_ZOOM_OUT_FACTOR : WHEEL_ZOOM_IN_FACTOR;
143
+ zoomTowardPoint(event.clientX, event.clientY, scale * factor);
144
+ }
145
+
146
+ viewport.addEventListener("wheel", handleWheel, { passive: false });
147
+
148
+ let isMousePanning = false;
149
+ let mouseAnchorX = 0;
150
+ let mouseAnchorY = 0;
151
+ let previousMouseX = 0;
152
+ let previousMouseY = 0;
153
+
154
+ function handleMouseDown(event: MouseEvent): void {
155
+ cancelInertia();
156
+ isMousePanning = true;
157
+ mouseAnchorX = event.clientX - panX;
158
+ mouseAnchorY = event.clientY - panY;
159
+ previousMouseX = event.clientX;
160
+ previousMouseY = event.clientY;
161
+ lastMoveTimestamp = performance.now();
162
+ viewport.style.cursor = "grabbing";
163
+ event.preventDefault();
164
+ }
165
+
166
+ function handleMouseMove(event: MouseEvent): void {
167
+ if (!isMousePanning) return;
168
+ trackVelocity(event.clientX - previousMouseX, event.clientY - previousMouseY, performance.now());
169
+ previousMouseX = event.clientX;
170
+ previousMouseY = event.clientY;
171
+ panX = event.clientX - mouseAnchorX;
172
+ panY = event.clientY - mouseAnchorY;
173
+ applyTransform();
174
+ }
175
+
176
+ function handleMouseUp(): void {
177
+ isMousePanning = false;
178
+ viewport.style.cursor = "grab";
179
+ releaseWithMomentum();
180
+ }
181
+
182
+ viewport.addEventListener("mousedown", handleMouseDown);
183
+ window.addEventListener("mousemove", handleMouseMove);
184
+ window.addEventListener("mouseup", handleMouseUp);
185
+
186
+ let isTouchPanning = false;
187
+ let pinchDistance = 0;
188
+ let touchCenterX = 0;
189
+ let touchCenterY = 0;
190
+
191
+ function handleTouchStart(event: TouchEvent): void {
192
+ cancelInertia();
193
+ if (event.touches.length === 1) {
194
+ isTouchPanning = true;
195
+ touchCenterX = event.touches[0].clientX;
196
+ touchCenterY = event.touches[0].clientY;
197
+ lastMoveTimestamp = performance.now();
198
+ } else if (event.touches.length === 2) {
199
+ isTouchPanning = false;
200
+ pinchDistance = Math.hypot(
201
+ event.touches[0].clientX - event.touches[1].clientX,
202
+ event.touches[0].clientY - event.touches[1].clientY,
203
+ );
204
+ touchCenterX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
205
+ touchCenterY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
206
+ }
207
+ event.preventDefault();
208
+ }
209
+
210
+ function handleTouchMove(event: TouchEvent): void {
211
+ if (event.touches.length === 1 && isTouchPanning) {
212
+ const dx = event.touches[0].clientX - touchCenterX;
213
+ const dy = event.touches[0].clientY - touchCenterY;
214
+ trackVelocity(dx, dy, performance.now());
215
+ panX += dx;
216
+ panY += dy;
217
+ touchCenterX = event.touches[0].clientX;
218
+ touchCenterY = event.touches[0].clientY;
219
+ applyTransform();
220
+ } else if (event.touches.length === 2) {
221
+ const distance = Math.hypot(
222
+ event.touches[0].clientX - event.touches[1].clientX,
223
+ event.touches[0].clientY - event.touches[1].clientY,
224
+ );
225
+ const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
226
+ const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
227
+
228
+ if (pinchDistance > 0) {
229
+ zoomTowardPoint(midX, midY, scale * (distance / pinchDistance));
230
+ panX += midX - touchCenterX;
231
+ panY += midY - touchCenterY;
232
+ applyTransform();
233
+ }
234
+
235
+ pinchDistance = distance;
236
+ touchCenterX = midX;
237
+ touchCenterY = midY;
238
+ }
239
+ event.preventDefault();
240
+ }
241
+
242
+ function handleTouchEnd(): void {
243
+ const wasPanning = isTouchPanning;
244
+ isTouchPanning = false;
245
+ pinchDistance = 0;
246
+ if (wasPanning) releaseWithMomentum();
247
+ }
248
+
249
+ viewport.addEventListener("touchstart", handleTouchStart, { passive: false });
250
+ viewport.addEventListener("touchmove", handleTouchMove, { passive: false });
251
+ viewport.addEventListener("touchend", handleTouchEnd);
252
+
253
+ function handleDoubleClick(event: MouseEvent): void {
254
+ event.preventDefault();
255
+ zoomTowardPoint(event.clientX, event.clientY, scale * DOUBLE_CLICK_ZOOM_FACTOR);
256
+ }
257
+
258
+ viewport.addEventListener("dblclick", handleDoubleClick);
259
+
260
+ return {
261
+ get panX() {
262
+ return panX;
263
+ },
264
+ get panY() {
265
+ return panY;
266
+ },
267
+ get scale() {
268
+ return scale;
269
+ },
270
+ zoomTo,
271
+ resetView,
272
+ panBy,
273
+ destroy(): void {
274
+ cancelInertia();
275
+ viewport.removeEventListener("wheel", handleWheel);
276
+ viewport.removeEventListener("mousedown", handleMouseDown);
277
+ window.removeEventListener("mousemove", handleMouseMove);
278
+ window.removeEventListener("mouseup", handleMouseUp);
279
+ viewport.removeEventListener("touchstart", handleTouchStart);
280
+ viewport.removeEventListener("touchmove", handleTouchMove);
281
+ viewport.removeEventListener("touchend", handleTouchEnd);
282
+ viewport.removeEventListener("dblclick", handleDoubleClick);
283
+ },
284
+ };
285
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Diagram source registry, Mermaid runtime adapter, and viewport-aware render scheduler.
3
+ *
4
+ * Captures raw diagram definitions before `astro-mermaid` replaces them with SVG,
5
+ * lazily renders diagrams as they enter the viewport, and re-renders on theme changes.
6
+ */
7
+
8
+ /** Active theme mode derived from the `data-theme` attribute on `<html>`. */
9
+ export type ThemeMode = "light" | "dark";
10
+
11
+ type MermaidRuntime = typeof import("mermaid")["default"];
12
+
13
+ const DIAGRAM_SELECTOR = "pre.mermaid:not(.nu-mermaid-fullscreen)";
14
+ const PRERENDER_MARGIN = "200px";
15
+
16
+ /** Registry mapping diagram containers to their raw Mermaid source text. */
17
+ export const diagramSources = new WeakMap<Element, string>();
18
+
19
+ /** Tracks which theme each diagram was last rendered with to avoid redundant re-renders. */
20
+ const renderedThemes = new WeakMap<HTMLElement, ThemeMode>();
21
+
22
+ /** Per-container render version to discard stale async completions. */
23
+ const renderVersions = new WeakMap<HTMLElement, number>();
24
+
25
+ let runtimePromise: Promise<MermaidRuntime> | undefined;
26
+
27
+ /** Resolve the active theme from the document's `data-theme` attribute. */
28
+ export function resolveThemeMode(): ThemeMode {
29
+ return document.documentElement.dataset.theme === "light" ? "light" : "dark";
30
+ }
31
+
32
+ /** Lazy-load the Mermaid runtime. Cached after first import. */
33
+ async function loadRuntime(): Promise<MermaidRuntime> {
34
+ runtimePromise ??= import("mermaid").then((m) => m.default);
35
+ return runtimePromise;
36
+ }
37
+
38
+ /** Read the Northwestern Mermaid config injected by the theme integration for the given mode. */
39
+ function themeConfig(mode: ThemeMode): Record<string, unknown> {
40
+ return window.__NU_MERMAID_CONFIGS__?.[mode] ?? {};
41
+ }
42
+
43
+ /**
44
+ * Walk the DOM and capture raw source text from all `pre.mermaid` elements.
45
+ *
46
+ * Prefers the `data-diagram` attribute (set by `astro-mermaid` before it renders SVG)
47
+ * over `textContent`, which may contain concatenated SVG text nodes after rendering.
48
+ * Persists source back to `data-diagram` so it survives across render cycles and
49
+ * Astro view transitions.
50
+ */
51
+ export function captureDiagramSources(): void {
52
+ for (const element of document.querySelectorAll<HTMLElement>("pre.mermaid")) {
53
+ if (diagramSources.has(element)) continue;
54
+
55
+ const source = element.getAttribute("data-diagram")?.trim() || element.textContent?.trim() || "";
56
+ if (!source) continue;
57
+
58
+ diagramSources.set(element, source);
59
+ if (!element.hasAttribute("data-diagram")) {
60
+ element.setAttribute("data-diagram", source);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Render a single diagram container with the given theme.
67
+ *
68
+ * Initializes the Mermaid runtime with the Northwestern theme config,
69
+ * generates SVG, and injects it into the container.
70
+ */
71
+ async function renderDiagram(container: HTMLElement, mode: ThemeMode, index: number): Promise<void> {
72
+ const source =
73
+ diagramSources.get(container) ??
74
+ container.getAttribute("data-diagram")?.trim() ??
75
+ container.textContent?.trim();
76
+ if (!source) return;
77
+
78
+ const version = (renderVersions.get(container) ?? 0) + 1;
79
+ renderVersions.set(container, version);
80
+
81
+ const mermaidRuntime = await loadRuntime();
82
+ if (renderVersions.get(container) !== version) return;
83
+
84
+ mermaidRuntime.initialize({ startOnLoad: false, ...(themeConfig(mode) as Record<string, unknown>) });
85
+
86
+ const renderId = `nu-mermaid-${mode}-${index}-${Math.random().toString(36).slice(2, 8)}`;
87
+ // mermaid.render returns sanitized SVG from trusted diagram source (user-authored markdown)
88
+ const { svg, bindFunctions } = await mermaidRuntime.render(renderId, source);
89
+ if (renderVersions.get(container) !== version) return;
90
+
91
+ // SVG is generated by Mermaid from user-authored markdown, not external input
92
+ container.innerHTML = svg;
93
+ bindFunctions?.(container);
94
+ renderedThemes.set(container, mode);
95
+ }
96
+
97
+ /** Check whether a container needs rendering for the given mode. */
98
+ function needsRender(container: HTMLElement, mode: ThemeMode): boolean {
99
+ return renderedThemes.get(container) !== mode || !container.querySelector("svg");
100
+ }
101
+
102
+ /**
103
+ * Render all mermaid diagrams on the page.
104
+ *
105
+ * @param forceAll - When `true` (e.g., theme change), re-renders every diagram immediately.
106
+ * When `false` (default), uses an IntersectionObserver to lazily render diagrams
107
+ * as they scroll within {@link PRERENDER_MARGIN} of the viewport.
108
+ */
109
+ export async function renderAllDiagrams(forceAll = false): Promise<void> {
110
+ captureDiagramSources();
111
+ const mode = resolveThemeMode();
112
+ const containers = [...document.querySelectorAll<HTMLElement>(DIAGRAM_SELECTOR)];
113
+ if (!containers.length) return;
114
+
115
+ if (forceAll) {
116
+ for (const [index, container] of containers.entries()) {
117
+ if (needsRender(container, mode)) {
118
+ await renderDiagram(container, mode, index);
119
+ }
120
+ }
121
+ return;
122
+ }
123
+
124
+ const observer = new IntersectionObserver(
125
+ (entries) => {
126
+ for (const entry of entries) {
127
+ if (!entry.isIntersecting) continue;
128
+ const container = entry.target as HTMLElement;
129
+ const currentMode = resolveThemeMode();
130
+ if (!needsRender(container, currentMode)) continue;
131
+ observer.unobserve(container);
132
+ void renderDiagram(container, currentMode, containers.indexOf(container));
133
+ }
134
+ },
135
+ { rootMargin: PRERENDER_MARGIN },
136
+ );
137
+
138
+ for (const container of containers) {
139
+ if (needsRender(container, mode)) {
140
+ observer.observe(container);
141
+ }
142
+ }
143
+ }