@eventcatalog/core 3.4.0 → 3.4.2

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.
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Diagram Zoom Utility
3
+ * Provides pan/zoom functionality for Mermaid and PlantUML diagrams with React Flow-style controls
4
+ *
5
+ * NOTE: A standalone version of this code also exists in the embed page at:
6
+ * src/pages/diagrams/[id]/[version]/embed.astro
7
+ *
8
+ * The embed page uses CDN imports for isolation in iframes. If you update this file,
9
+ * please also update the embed page to keep the zoom functionality in sync.
10
+ */
11
+
12
+ // Store zoom instances for cleanup
13
+ const zoomInstances = new Map<string, any>();
14
+ const resizeObservers = new Map<string, ResizeObserver>();
15
+ const fullscreenHandlers = new Map<string, () => void>();
16
+
17
+ // Abort flag for cancelling in-progress renders during cleanup
18
+ let renderingAborted = false;
19
+
20
+ /**
21
+ * Destroys all zoom instances and cleans up observers
22
+ */
23
+ export function destroyZoomInstances(): void {
24
+ // Set abort flag to cancel any in-progress renders
25
+ renderingAborted = true;
26
+
27
+ zoomInstances.forEach((instance) => {
28
+ try {
29
+ instance.destroy();
30
+ } catch (e) {
31
+ // Instance may already be destroyed
32
+ }
33
+ });
34
+ zoomInstances.clear();
35
+
36
+ resizeObservers.forEach((observer) => {
37
+ observer.disconnect();
38
+ });
39
+ resizeObservers.clear();
40
+
41
+ // Clean up fullscreen event listeners
42
+ fullscreenHandlers.forEach((handler) => {
43
+ document.removeEventListener('fullscreenchange', handler);
44
+ });
45
+ fullscreenHandlers.clear();
46
+ }
47
+
48
+ /**
49
+ * Gets an RGB color string from a CSS variable
50
+ * CSS variables store RGB values as "R G B" format, so we convert to "rgb(R, G, B)"
51
+ */
52
+ function getCssVariableColor(variableName: string, fallback: string): string {
53
+ const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
54
+ if (value) {
55
+ // Convert "R G B" format to "rgb(R, G, B)"
56
+ return `rgb(${value.split(' ').join(', ')})`;
57
+ }
58
+ return fallback;
59
+ }
60
+
61
+ /**
62
+ * Gets theme colors based on current mode using CSS variables
63
+ */
64
+ function getThemeColors() {
65
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
66
+ return {
67
+ isDark,
68
+ bgColor: getCssVariableColor('--ec-card-bg', isDark ? '#161b22' : '#ffffff'),
69
+ borderColor: getCssVariableColor('--ec-page-border', isDark ? '#30363d' : '#e2e8f0'),
70
+ iconColor: getCssVariableColor('--ec-icon-color', isDark ? '#8b949e' : '#64748b'),
71
+ iconHoverColor: getCssVariableColor('--ec-icon-hover', isDark ? '#f0f6fc' : '#0f172a'),
72
+ hoverBgColor: getCssVariableColor('--ec-content-hover', isDark ? '#21262d' : '#f1f5f9'),
73
+ overlayBg: getCssVariableColor('--ec-page-bg', isDark ? '#0d1117' : '#ffffff'),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Creates a styled button with inline CSS
79
+ */
80
+ function createStyledButton(
81
+ svg: string,
82
+ title: string,
83
+ onClick: () => void,
84
+ colors: ReturnType<typeof getThemeColors>,
85
+ options: { isLast?: boolean; isRound?: boolean } = {}
86
+ ): HTMLButtonElement {
87
+ const { isLast = false, isRound = false } = options;
88
+ const btn = document.createElement('button');
89
+ btn.type = 'button';
90
+ btn.title = title;
91
+ btn.innerHTML = svg;
92
+ btn.onclick = onClick;
93
+
94
+ btn.style.cssText = `
95
+ all: unset;
96
+ box-sizing: border-box;
97
+ display: flex;
98
+ justify-content: center;
99
+ align-items: center;
100
+ width: 26px;
101
+ height: 26px;
102
+ min-width: 26px;
103
+ min-height: 26px;
104
+ padding: 0;
105
+ margin: 0;
106
+ border: none;
107
+ background: ${colors.bgColor};
108
+ color: ${colors.iconColor};
109
+ cursor: pointer;
110
+ transition: background-color 0.15s, color 0.15s;
111
+ line-height: 1;
112
+ font-size: 12px;
113
+ ${!isLast && !isRound ? `border-bottom: 1px solid ${colors.borderColor};` : ''}
114
+ ${isRound ? 'border-radius: 6px;' : ''}
115
+ `;
116
+
117
+ const svgEl = btn.querySelector('svg');
118
+ if (svgEl) {
119
+ svgEl.style.cssText = 'display: block; width: 12px; height: 12px;';
120
+ }
121
+
122
+ btn.onmouseenter = () => {
123
+ btn.style.backgroundColor = colors.hoverBgColor;
124
+ btn.style.color = colors.iconHoverColor;
125
+ };
126
+ btn.onmouseleave = () => {
127
+ btn.style.backgroundColor = colors.bgColor;
128
+ btn.style.color = colors.iconColor;
129
+ };
130
+
131
+ return btn;
132
+ }
133
+
134
+ /**
135
+ * SVG icons
136
+ */
137
+ const ICONS = {
138
+ plus: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
139
+ minus: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
140
+ fit: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>`,
141
+ // Heroicons PresentationChartLineIcon (outline)
142
+ presentation: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"></path></svg>`,
143
+ // Heroicons ClipboardDocumentIcon (outline)
144
+ copy: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z"></path></svg>`,
145
+ // Heroicons CheckIcon (outline)
146
+ check: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
147
+ };
148
+
149
+ /**
150
+ * Creates React Flow-style zoom controls
151
+ */
152
+ function createZoomControls(onZoomIn: () => void, onZoomOut: () => void, onFitView: () => void): HTMLElement {
153
+ const colors = getThemeColors();
154
+
155
+ const controls = document.createElement('div');
156
+ controls.style.cssText = `
157
+ position: absolute;
158
+ bottom: 12px;
159
+ left: 12px;
160
+ display: flex;
161
+ flex-direction: column;
162
+ background: ${colors.bgColor};
163
+ border-radius: 6px;
164
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
165
+ border: 1px solid ${colors.borderColor};
166
+ overflow: hidden;
167
+ z-index: 10;
168
+ `;
169
+
170
+ controls.appendChild(createStyledButton(ICONS.plus, 'Zoom in', onZoomIn, colors));
171
+ controls.appendChild(createStyledButton(ICONS.minus, 'Zoom out', onZoomOut, colors));
172
+ controls.appendChild(createStyledButton(ICONS.fit, 'Fit view', onFitView, colors, { isLast: true }));
173
+
174
+ return controls;
175
+ }
176
+
177
+ /**
178
+ * Creates a toolbar button with tooltip
179
+ */
180
+ function createToolbarButton(
181
+ icon: string,
182
+ tooltipText: string,
183
+ onClick: () => void,
184
+ colors: ReturnType<typeof getThemeColors>,
185
+ tooltipPosition: 'left' | 'right' | 'bottom' = 'bottom'
186
+ ): { wrapper: HTMLElement; btn: HTMLButtonElement; tooltip: HTMLElement } {
187
+ const wrapper = document.createElement('div');
188
+ wrapper.style.cssText = `position: relative;`;
189
+
190
+ const btn = document.createElement('button');
191
+ btn.type = 'button';
192
+ btn.innerHTML = icon;
193
+ btn.style.cssText = `
194
+ all: unset;
195
+ box-sizing: border-box;
196
+ display: flex;
197
+ justify-content: center;
198
+ align-items: center;
199
+ width: 40px;
200
+ height: 40px;
201
+ min-width: 40px;
202
+ min-height: 40px;
203
+ padding: 0;
204
+ margin: 0;
205
+ border: none;
206
+ border-radius: 6px;
207
+ background: ${colors.bgColor};
208
+ color: ${colors.iconColor};
209
+ cursor: pointer;
210
+ transition: background-color 0.15s, color 0.15s;
211
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
212
+ `;
213
+
214
+ const svgEl = btn.querySelector('svg');
215
+ if (svgEl) {
216
+ svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
217
+ }
218
+
219
+ btn.onmouseenter = () => {
220
+ btn.style.backgroundColor = colors.hoverBgColor;
221
+ btn.style.color = colors.iconHoverColor;
222
+ };
223
+ btn.onmouseleave = () => {
224
+ btn.style.backgroundColor = colors.bgColor;
225
+ btn.style.color = colors.iconColor;
226
+ };
227
+ btn.onclick = onClick;
228
+
229
+ // Tooltip with position-based styling
230
+ const tooltip = document.createElement('div');
231
+ tooltip.textContent = tooltipText;
232
+
233
+ let tooltipStyles = `
234
+ position: absolute;
235
+ padding: 4px 8px;
236
+ background: #1f2937;
237
+ color: white;
238
+ font-size: 12px;
239
+ border-radius: 4px;
240
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
241
+ white-space: nowrap;
242
+ pointer-events: none;
243
+ opacity: 0;
244
+ transition: opacity 0.15s;
245
+ z-index: 50;
246
+ `;
247
+
248
+ if (tooltipPosition === 'right') {
249
+ tooltipStyles += `
250
+ top: 50%;
251
+ left: 100%;
252
+ transform: translateY(-50%);
253
+ margin-left: 8px;
254
+ `;
255
+ } else if (tooltipPosition === 'left') {
256
+ tooltipStyles += `
257
+ top: 50%;
258
+ right: 100%;
259
+ transform: translateY(-50%);
260
+ margin-right: 8px;
261
+ `;
262
+ } else {
263
+ // Default: bottom
264
+ tooltipStyles += `
265
+ top: 100%;
266
+ left: 50%;
267
+ transform: translateX(-50%);
268
+ margin-top: 8px;
269
+ `;
270
+ }
271
+
272
+ tooltip.style.cssText = tooltipStyles;
273
+
274
+ wrapper.onmouseenter = () => {
275
+ tooltip.style.opacity = '1';
276
+ };
277
+ wrapper.onmouseleave = () => {
278
+ tooltip.style.opacity = '0';
279
+ };
280
+
281
+ wrapper.appendChild(btn);
282
+ wrapper.appendChild(tooltip);
283
+
284
+ return { wrapper, btn, tooltip };
285
+ }
286
+
287
+ /**
288
+ * Creates the fullscreen button for top-left (tooltip shows to the right)
289
+ */
290
+ function createFullscreenButton(onClick: () => void): HTMLElement {
291
+ const colors = getThemeColors();
292
+ const { wrapper } = createToolbarButton(ICONS.presentation, 'Presentation Mode', onClick, colors, 'right');
293
+ wrapper.style.cssText = `
294
+ position: absolute;
295
+ top: 12px;
296
+ left: 12px;
297
+ z-index: 10;
298
+ `;
299
+ return wrapper;
300
+ }
301
+
302
+ /**
303
+ * Creates the copy button for top-right (tooltip shows to the left)
304
+ */
305
+ function createCopyButton(onCopy: () => void): HTMLElement {
306
+ const colors = getThemeColors();
307
+ const copy = createToolbarButton(
308
+ ICONS.copy,
309
+ 'Copy diagram code',
310
+ () => {
311
+ onCopy();
312
+ // Show feedback
313
+ copy.btn.innerHTML = ICONS.check;
314
+ copy.btn.style.color = '#10b981'; // Green color for success
315
+ copy.tooltip.textContent = 'Copied!';
316
+
317
+ const svgEl = copy.btn.querySelector('svg');
318
+ if (svgEl) {
319
+ svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
320
+ }
321
+
322
+ setTimeout(() => {
323
+ copy.btn.innerHTML = ICONS.copy;
324
+ copy.btn.style.color = colors.iconColor;
325
+ copy.tooltip.textContent = 'Copy diagram code';
326
+ const svgEl = copy.btn.querySelector('svg');
327
+ if (svgEl) {
328
+ svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
329
+ }
330
+ }, 2000);
331
+ },
332
+ colors,
333
+ 'left'
334
+ );
335
+
336
+ copy.wrapper.style.cssText = `
337
+ position: absolute;
338
+ top: 12px;
339
+ right: 12px;
340
+ z-index: 10;
341
+ `;
342
+
343
+ return copy.wrapper;
344
+ }
345
+
346
+ /**
347
+ * Toggles native fullscreen mode on a container
348
+ */
349
+ function toggleFullscreen(container: HTMLElement): void {
350
+ if (!document.fullscreenElement) {
351
+ container.requestFullscreen().catch((err) => {
352
+ console.warn(`Error entering fullscreen: ${err.message}`);
353
+ });
354
+ } else {
355
+ document.exitFullscreen();
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Creates the zoom container with React Flow-style appearance
361
+ */
362
+ export function createZoomContainer(): HTMLElement {
363
+ const container = document.createElement('div');
364
+ container.className = 'mermaid-zoom-container';
365
+ container.style.cssText = `
366
+ position: relative;
367
+ width: 100%;
368
+ min-height: 200px;
369
+ overflow: hidden;
370
+ margin: 0;
371
+ cursor: grab;
372
+ `;
373
+ return container;
374
+ }
375
+
376
+ interface ZoomOptions {
377
+ minZoom?: number;
378
+ maxZoom?: number;
379
+ zoomScaleSensitivity?: number;
380
+ maxHeight?: number;
381
+ minHeight?: number;
382
+ diagramContent?: string;
383
+ }
384
+
385
+ /**
386
+ * Initializes zoom on a Mermaid SVG element
387
+ */
388
+ export async function initMermaidZoom(
389
+ svgElement: SVGElement,
390
+ container: HTMLElement,
391
+ id: string,
392
+ options: ZoomOptions = {}
393
+ ): Promise<void> {
394
+ // Lower zoomScaleSensitivity = smoother but slower zoom
395
+ const { minZoom = 0.5, maxZoom = 10, zoomScaleSensitivity = 0.15, maxHeight = 500, minHeight = 200, diagramContent } = options;
396
+
397
+ // Dynamic import for performance
398
+ const { default: svgPanZoom } = await import('svg-pan-zoom');
399
+
400
+ // Get the natural dimensions from viewBox or getBBox
401
+ let width: number = 0;
402
+ let height: number = 0;
403
+ const viewBox = svgElement.getAttribute('viewBox');
404
+
405
+ if (viewBox) {
406
+ const parts = viewBox.split(/[\s,]+/).map(Number);
407
+ width = parts[2];
408
+ height = parts[3];
409
+ }
410
+
411
+ // If viewBox didn't give us dimensions, try getBBox
412
+ if (width <= 0 || height <= 0) {
413
+ try {
414
+ // Cast to SVGGraphicsElement which has getBBox method
415
+ const bbox = (svgElement as unknown as SVGGraphicsElement).getBBox();
416
+ width = bbox.width;
417
+ height = bbox.height;
418
+ if (width > 0 && height > 0 && !viewBox) {
419
+ svgElement.setAttribute('viewBox', `${bbox.x} ${bbox.y} ${width} ${height}`);
420
+ }
421
+ } catch (e) {
422
+ // getBBox can fail if SVG isn't in DOM yet
423
+ }
424
+ }
425
+
426
+ // Fallback to element dimensions if still no size
427
+ if (width <= 0 || height <= 0) {
428
+ width = svgElement.clientWidth || parseFloat(svgElement.getAttribute('width') || '0') || 800;
429
+ height = svgElement.clientHeight || parseFloat(svgElement.getAttribute('height') || '0') || 400;
430
+ }
431
+
432
+ // Set container height based on SVG aspect ratio, capped for usability
433
+ if (width > 0 && height > 0) {
434
+ const containerWidth = container.clientWidth || 800;
435
+ const aspectRatio = height / width;
436
+ const calculatedHeight = Math.min(Math.max(containerWidth * aspectRatio, minHeight), maxHeight);
437
+ container.style.height = `${calculatedHeight}px`;
438
+ } else {
439
+ container.style.height = `${minHeight}px`;
440
+ }
441
+
442
+ // SVG needs to fill the container for svg-pan-zoom
443
+ svgElement.style.width = '100%';
444
+ svgElement.style.height = '100%';
445
+ svgElement.removeAttribute('height');
446
+ svgElement.removeAttribute('width');
447
+
448
+ try {
449
+ const instance = svgPanZoom(svgElement, {
450
+ zoomEnabled: true,
451
+ controlIconsEnabled: false, // We use custom controls
452
+ fit: true,
453
+ center: true,
454
+ minZoom,
455
+ maxZoom,
456
+ zoomScaleSensitivity,
457
+ dblClickZoomEnabled: true,
458
+ mouseWheelZoomEnabled: false, // Disabled to avoid hijacking page scroll
459
+ preventMouseEventsDefault: true,
460
+ panEnabled: true,
461
+ });
462
+
463
+ zoomInstances.set(id, instance);
464
+
465
+ // Update cursor during pan
466
+ container.addEventListener('mousedown', () => {
467
+ container.style.cursor = 'grabbing';
468
+ });
469
+ container.addEventListener('mouseup', () => {
470
+ container.style.cursor = 'grab';
471
+ });
472
+ container.addEventListener('mouseleave', () => {
473
+ container.style.cursor = 'grab';
474
+ });
475
+
476
+ // Add custom controls
477
+ const controls = createZoomControls(
478
+ () => instance.zoomIn(),
479
+ () => instance.zoomOut(),
480
+ () => {
481
+ instance.fit();
482
+ instance.center();
483
+ }
484
+ );
485
+ container.appendChild(controls);
486
+
487
+ // Add fullscreen button (top-left)
488
+ const fullscreenBtn = createFullscreenButton(() => toggleFullscreen(container));
489
+ container.appendChild(fullscreenBtn);
490
+
491
+ // Add copy button (top-right) if diagram content is available
492
+ if (diagramContent) {
493
+ const copyBtn = createCopyButton(() => {
494
+ navigator.clipboard.writeText(diagramContent).catch((err) => {
495
+ console.warn('Failed to copy diagram code:', err);
496
+ });
497
+ });
498
+ container.appendChild(copyBtn);
499
+ }
500
+
501
+ // Handle fullscreen changes - enable scroll zoom in fullscreen, disable when exiting
502
+ const handleFullscreenChange = () => {
503
+ const isFullscreen = document.fullscreenElement === container;
504
+
505
+ if (isFullscreen) {
506
+ // Enable scroll zoom in fullscreen
507
+ instance.enableMouseWheelZoom();
508
+ // Update container styles for fullscreen
509
+ container.style.background = getThemeColors().overlayBg;
510
+ } else {
511
+ // Disable scroll zoom when not fullscreen
512
+ instance.disableMouseWheelZoom();
513
+ // Reset container background
514
+ container.style.background = '';
515
+ }
516
+
517
+ // Fit and center after transition
518
+ setTimeout(() => {
519
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
520
+ try {
521
+ instance.resize();
522
+ instance.fit();
523
+ instance.center();
524
+ } catch (e) {
525
+ // Ignore matrix inversion errors
526
+ }
527
+ }
528
+ }, 100);
529
+ };
530
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
531
+ fullscreenHandlers.set(id, handleFullscreenChange);
532
+
533
+ // Resize handler for responsiveness
534
+ const resizeObserver = new ResizeObserver(() => {
535
+ // Guard against zero-dimension containers which cause matrix inversion errors
536
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
537
+ try {
538
+ instance.resize();
539
+ instance.fit();
540
+ instance.center();
541
+ } catch (e) {
542
+ // Ignore matrix inversion errors during resize
543
+ }
544
+ }
545
+ });
546
+ resizeObserver.observe(container);
547
+ resizeObservers.set(id, resizeObserver);
548
+ } catch (e) {
549
+ console.warn('Failed to initialize zoom on mermaid diagram:', e);
550
+ }
551
+ }
552
+
553
+ /**
554
+ * High-level function to render Mermaid diagrams with zoom
555
+ */
556
+ export async function renderMermaidWithZoom(graphs: HTMLCollectionOf<Element>, mermaidConfig?: any): Promise<void> {
557
+ if (graphs.length === 0) return;
558
+
559
+ // Reset abort flag at the start of rendering
560
+ renderingAborted = false;
561
+
562
+ const { default: mermaid } = await import('mermaid');
563
+
564
+ // Apply any custom mermaid configuration
565
+ if (mermaidConfig) {
566
+ const { icons } = await import('@iconify-json/logos');
567
+ const { iconPacks = [], enableSupportForElkLayout = false } = mermaidConfig;
568
+
569
+ if (iconPacks.length > 0) {
570
+ const iconPacksToRegister = iconPacks.map((name: string) => ({
571
+ name,
572
+ icons,
573
+ }));
574
+ mermaid.registerIconPacks(iconPacksToRegister);
575
+ }
576
+
577
+ if (enableSupportForElkLayout) {
578
+ // @ts-ignore
579
+ const { default: elkLayouts } = await import('@mermaid-js/layout-elk/dist/mermaid-layout-elk.core.mjs');
580
+ mermaid.registerLayoutLoaders(elkLayouts);
581
+ }
582
+ }
583
+
584
+ // Detect current theme
585
+ const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
586
+ const currentTheme = isDarkMode ? 'dark' : 'default';
587
+
588
+ // Custom theme variables for better readability in dark mode
589
+ const darkThemeVariables = {
590
+ signalColor: '#f0f6fc',
591
+ signalTextColor: '#f0f6fc',
592
+ actorTextColor: '#0d1117',
593
+ actorBkg: '#f0f6fc',
594
+ actorBorder: '#484f58',
595
+ actorLineColor: '#6b7280',
596
+ primaryTextColor: '#f0f6fc',
597
+ secondaryTextColor: '#c9d1d9',
598
+ tertiaryTextColor: '#f0f6fc',
599
+ lineColor: '#6b7280',
600
+ };
601
+
602
+ mermaid.initialize({
603
+ flowchart: {
604
+ curve: 'linear',
605
+ rankSpacing: 0,
606
+ nodeSpacing: 0,
607
+ },
608
+ startOnLoad: false,
609
+ fontFamily: 'var(--sans-font)',
610
+ theme: currentTheme,
611
+ themeVariables: isDarkMode ? darkThemeVariables : undefined,
612
+ architecture: {
613
+ useMaxWidth: true,
614
+ },
615
+ });
616
+
617
+ // Convert to array to avoid live collection issues when modifying DOM
618
+ const graphsArray = Array.from(graphs);
619
+
620
+ for (const graph of graphsArray) {
621
+ // Check if rendering was aborted (e.g., user navigated away)
622
+ if (renderingAborted) return;
623
+
624
+ const content = graph.getAttribute('data-content');
625
+ if (!content) continue;
626
+
627
+ const id = 'mermaid-' + Math.round(Math.random() * 100000);
628
+
629
+ try {
630
+ const result = await mermaid.render(id, content);
631
+
632
+ // Check again after async operation
633
+ if (renderingAborted) return;
634
+
635
+ // Create zoom container
636
+ const container = createZoomContainer();
637
+ container.innerHTML = result.svg;
638
+
639
+ // Replace the graph content with the container
640
+ graph.innerHTML = '';
641
+ graph.appendChild(container);
642
+
643
+ // Initialize zoom on the SVG
644
+ const svgElement = container.querySelector('svg');
645
+ if (svgElement) {
646
+ await initMermaidZoom(svgElement as SVGElement, container, id, { diagramContent: content });
647
+ }
648
+ } catch (e) {
649
+ console.error('Mermaid render error:', e);
650
+ }
651
+ }
652
+ }
653
+
654
+ /**
655
+ * PlantUML encoding utilities
656
+ */
657
+ function encode64(data: Uint8Array): string {
658
+ const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
659
+ let str = '';
660
+ const len = data.length;
661
+ for (let i = 0; i < len; i += 3) {
662
+ const b1 = data[i];
663
+ const b2 = i + 1 < len ? data[i + 1] : 0;
664
+ const b3 = i + 2 < len ? data[i + 2] : 0;
665
+
666
+ const c1 = b1 >> 2;
667
+ const c2 = ((b1 & 0x3) << 4) | (b2 >> 4);
668
+ const c3 = ((b2 & 0xf) << 2) | (b3 >> 6);
669
+ const c4 = b3 & 0x3f;
670
+
671
+ str += chars[c1] + chars[c2] + chars[c3] + chars[c4];
672
+ }
673
+ return str;
674
+ }
675
+
676
+ function encodePlantUML(text: string, deflate: (data: Uint8Array, options: any) => Uint8Array): string {
677
+ const data = new TextEncoder().encode(text);
678
+ const compressed = deflate(data, { level: 9, to: 'Uint8Array' });
679
+ return encode64(compressed);
680
+ }
681
+
682
+ /**
683
+ * High-level function to render PlantUML diagrams with zoom
684
+ */
685
+ export async function renderPlantUMLWithZoom(blocks: HTMLCollectionOf<Element>): Promise<void> {
686
+ if (blocks.length === 0) return;
687
+
688
+ // Reset abort flag at the start of rendering (only if not already rendering mermaid)
689
+ // Note: renderMermaidWithZoom also resets this flag, so we check if it's currently aborted
690
+ if (renderingAborted) renderingAborted = false;
691
+
692
+ // Dynamic import pako for compression
693
+ const { deflate } = await import('pako');
694
+
695
+ // Convert to array to avoid live collection issues when modifying DOM
696
+ const blocksArray = Array.from(blocks);
697
+
698
+ for (const block of blocksArray) {
699
+ // Check if rendering was aborted (e.g., user navigated away)
700
+ if (renderingAborted) return;
701
+
702
+ const content = block.getAttribute('data-content');
703
+ if (!content) continue;
704
+
705
+ const id = 'plantuml-' + Math.round(Math.random() * 100000);
706
+ const encoded = encodePlantUML(content, deflate);
707
+ const svgUrl = `https://www.plantuml.com/plantuml/svg/~1${encoded}`;
708
+
709
+ try {
710
+ // Fetch SVG content so we can use svg-pan-zoom
711
+ const response = await fetch(svgUrl);
712
+
713
+ // Check again after async operation
714
+ if (renderingAborted) return;
715
+
716
+ if (!response.ok) {
717
+ throw new Error(`Failed to fetch PlantUML diagram: ${response.status}`);
718
+ }
719
+
720
+ const svgText = await response.text();
721
+
722
+ // Check again after async operation
723
+ if (renderingAborted) return;
724
+
725
+ // Create zoom container
726
+ const container = createZoomContainer();
727
+ container.innerHTML = svgText;
728
+
729
+ // Replace the block content with the container
730
+ block.innerHTML = '';
731
+ block.appendChild(container);
732
+
733
+ // Initialize zoom on the SVG
734
+ const svgElement = container.querySelector('svg');
735
+ if (svgElement) {
736
+ await initMermaidZoom(svgElement as SVGElement, container, id, { diagramContent: content });
737
+ }
738
+ } catch (e) {
739
+ // Fallback to img tag if fetch fails (e.g., CORS issues)
740
+ console.warn('PlantUML SVG fetch failed, falling back to img:', e);
741
+ const img = document.createElement('img');
742
+ img.src = svgUrl;
743
+ img.alt = 'PlantUML diagram';
744
+ img.loading = 'lazy';
745
+ img.style.margin = '0 auto';
746
+ img.style.display = 'block';
747
+ block.innerHTML = '';
748
+ block.appendChild(img);
749
+ }
750
+ }
751
+ }