@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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-VAGFX36R.js → chunk-363MMCIA.js} +1 -1
- package/dist/{chunk-Q4DKMESA.js → chunk-6TCIKRLP.js} +1 -1
- package/dist/{chunk-GLMX3ZTY.js → chunk-E4RUDCKA.js} +1 -1
- package/dist/{chunk-KFZIBXRQ.js → chunk-JKOCGMX6.js} +1 -1
- package/dist/{chunk-MJRHV77M.js → chunk-JMIRAK3A.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +12 -11
- package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +21 -143
- package/eventcatalog/src/enterprise/mcp/mcp-server.ts +4 -4
- package/eventcatalog/src/env.d.ts +11 -0
- package/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro +433 -10
- package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +21 -100
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +26 -143
- package/eventcatalog/src/types/mcp-modules.d.ts +66 -0
- package/eventcatalog/src/utils/mermaid-zoom.ts +751 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|