@eventcatalog/core 4.10.4 → 4.10.6

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Diagram Zoom Utility
3
- * Provides pan/zoom functionality for Mermaid and PlantUML diagrams with React Flow-style controls
3
+ * Provides pan/zoom functionality for Mermaid and PlantUML diagrams with hover-revealed controls,
4
+ * smooth (CSS transform) transitions and a fullscreen modal viewer.
4
5
  *
5
6
  * NOTE: A standalone version of this code also exists in the embed page at:
6
7
  * src/pages/diagrams/[id]/[version]/embed.astro
@@ -10,9 +11,8 @@
10
11
  */
11
12
 
12
13
  // Store zoom instances for cleanup
13
- const zoomInstances = new Map<string, any>();
14
+ const zoomInstances = new Map<string, PanZoomInstance>();
14
15
  const resizeObservers = new Map<string, ResizeObserver>();
15
- const fullscreenHandlers = new Map<string, () => void>();
16
16
 
17
17
  // Track registered icon pack names to avoid re-registering on subsequent renders
18
18
  const registeredIconPacks = new Set<string>();
@@ -20,6 +20,29 @@ const registeredIconPacks = new Set<string>();
20
20
  // Abort flag for cancelling in-progress renders during cleanup
21
21
  let renderingAborted = false;
22
22
 
23
+ // Closer for the currently open fullscreen modal (only one can be open at a time)
24
+ let closeOpenModal: (() => void) | null = null;
25
+
26
+ // Mermaid renders are generation-tracked so a re-render (e.g. on theme change) supersedes in-flight ones
27
+ let mermaidRenderGeneration = 0;
28
+ let lastMermaidConfig: any;
29
+ let themeObserver: MutationObserver | null = null;
30
+
31
+ /**
32
+ * Mermaid bakes the theme into the rendered SVG, so diagrams are re-rendered whenever the
33
+ * `data-theme` attribute on <html> changes (light/dark toggle).
34
+ */
35
+ function ensureThemeObserver(): void {
36
+ if (themeObserver) return;
37
+ themeObserver = new MutationObserver(() => {
38
+ const graphs = document.getElementsByClassName('mermaid');
39
+ if (graphs.length === 0) return;
40
+ destroyZoomInstances();
41
+ renderMermaidWithZoom(graphs, lastMermaidConfig);
42
+ });
43
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
44
+ }
45
+
23
46
  /**
24
47
  * Destroys all zoom instances and cleans up observers
25
48
  */
@@ -41,368 +64,776 @@ export function destroyZoomInstances(): void {
41
64
  });
42
65
  resizeObservers.clear();
43
66
 
44
- // Clean up fullscreen event listeners
45
- fullscreenHandlers.forEach((handler) => {
46
- document.removeEventListener('fullscreenchange', handler);
47
- });
48
- fullscreenHandlers.clear();
67
+ closeOpenModal?.();
49
68
  }
50
69
 
51
70
  /**
52
- * Gets an RGB color string from a CSS variable
53
- * CSS variables store RGB values as "R G B" format, so we convert to "rgb(R, G, B)"
71
+ * Diagram control options
72
+ */
73
+ export type ControlsPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
74
+
75
+ const CONTROLS_PLACEMENTS: ControlsPlacement[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
76
+ const DEFAULT_PLACEMENT: ControlsPlacement = 'top-right';
77
+
78
+ /** Controls are shown by default only when the diagram's natural height exceeds this value */
79
+ const CONTROLS_MIN_HEIGHT = 120;
80
+
81
+ /** Pixels moved per pan button click / arrow key */
82
+ const PAN_STEP = 60;
83
+
84
+ /** Zoom factor per zoom button click / keyboard press */
85
+ const ZOOM_STEP = 1.2;
86
+
87
+ /** Duration of animated pan/zoom transitions */
88
+ const TRANSITION_MS = 150;
89
+
90
+ /** Padding around the diagram when fitting it into its viewport */
91
+ const FIT_PADDING = 16;
92
+
93
+ const STYLE_ID = 'ec-diagram-styles';
94
+
95
+ /**
96
+ * Styles for the diagram viewport, controls and fullscreen modal. Uses EventCatalog theme
97
+ * variables with sensible fallbacks so the same styles work inside the isolated embed page.
54
98
  */
55
- function getCssVariableColor(variableName: string, fallback: string): string {
56
- const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
57
- if (value) {
58
- // Convert "R G B" format to "rgb(R, G, B)"
59
- return `rgb(${value.split(' ').join(', ')})`;
99
+ const DIAGRAM_STYLES = `
100
+ .mermaid-zoom-container {
101
+ position: relative;
102
+ width: 100%;
103
+ overflow: hidden;
104
+ }
105
+ .ec-diagram-viewport {
106
+ position: absolute;
107
+ inset: 0;
108
+ overflow: hidden;
109
+ cursor: grab;
110
+ touch-action: pan-y pinch-zoom;
111
+ user-select: none;
112
+ -webkit-user-select: none;
113
+ outline: none;
114
+ }
115
+ .ec-diagram-viewport.is-panning {
116
+ cursor: grabbing;
117
+ }
118
+ .ec-diagram-viewport--modal {
119
+ touch-action: none;
120
+ background-image: radial-gradient(rgb(var(--ec-page-text, 15 23 42) / 0.08) 1px, transparent 1px);
121
+ background-size: 16px 16px;
122
+ }
123
+ .ec-diagram-content {
124
+ position: absolute;
125
+ left: 0;
126
+ top: 0;
127
+ transform-origin: 0 0;
128
+ transition: transform ${TRANSITION_MS}ms ease-out;
129
+ will-change: transform;
130
+ }
131
+ /* Element selectors bump specificity above the page's global ".mermaid svg" rules */
132
+ div.ec-diagram-content > svg {
133
+ display: block;
134
+ margin: 0;
135
+ max-width: none !important;
136
+ }
137
+ .ec-diagram-btn {
138
+ all: unset;
139
+ box-sizing: border-box;
140
+ display: inline-flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ width: 28px;
144
+ height: 28px;
145
+ border-radius: 6px;
146
+ cursor: pointer;
147
+ background: rgb(var(--ec-card-bg, 255 255 255));
148
+ color: rgb(var(--ec-page-text, 15 23 42));
149
+ border: 1px solid rgb(var(--ec-page-border, 226 232 240));
150
+ transition: background-color 0.15s, transform 0.1s;
151
+ }
152
+ .ec-diagram-btn:hover {
153
+ background: rgb(var(--ec-content-hover, 241 245 249));
154
+ }
155
+ .ec-diagram-btn:active {
156
+ transform: scale(0.95);
157
+ }
158
+ .ec-diagram-btn:focus-visible {
159
+ outline: 2px solid rgb(var(--ec-accent, 59 130 246));
160
+ outline-offset: 1px;
161
+ }
162
+ button.ec-diagram-btn svg {
163
+ display: block;
164
+ width: 16px;
165
+ height: 16px;
166
+ margin: 0;
167
+ flex-shrink: 0;
168
+ }
169
+ .ec-diagram-btn.ec-diagram-btn--success {
170
+ color: #10b981;
171
+ }
172
+ .ec-diagram-controls {
173
+ position: absolute;
174
+ z-index: 10;
175
+ display: grid;
176
+ grid-template-columns: repeat(3, 28px);
177
+ gap: 4px;
178
+ opacity: 0;
179
+ pointer-events: none;
180
+ transition: opacity 0.15s ease;
181
+ }
182
+ .ec-diagram-controls[data-placement="top-left"] { top: 8px; left: 8px; }
183
+ .ec-diagram-controls[data-placement="top-right"] { top: 8px; right: 8px; }
184
+ .ec-diagram-controls[data-placement="bottom-left"] { bottom: 8px; left: 8px; }
185
+ .ec-diagram-controls[data-placement="bottom-right"] { bottom: 8px; right: 8px; }
186
+ .mermaid-zoom-container:hover .ec-diagram-controls,
187
+ .mermaid-zoom-container:focus-within .ec-diagram-controls {
188
+ opacity: 1;
189
+ pointer-events: auto;
190
+ }
191
+ @media (pointer: coarse) {
192
+ .ec-diagram-controls { opacity: 1; pointer-events: auto; }
193
+ }
194
+ @media print {
195
+ .ec-diagram-controls { display: none; }
196
+ }
197
+ .ec-diagram-modal-backdrop {
198
+ position: fixed;
199
+ inset: 0;
200
+ z-index: 9998;
201
+ background: rgb(0 0 0 / 0.4);
202
+ opacity: 0;
203
+ transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1);
204
+ }
205
+ .ec-diagram-modal-backdrop.is-open {
206
+ opacity: 1;
207
+ }
208
+ .ec-diagram-modal {
209
+ position: fixed;
210
+ inset: 0;
211
+ z-index: 9999;
212
+ padding: 16px;
213
+ }
214
+ @media (min-width: 640px) {
215
+ .ec-diagram-modal { padding: 24px; }
216
+ }
217
+ .ec-diagram-modal__dialog {
218
+ position: relative;
219
+ width: 100%;
220
+ height: 100%;
221
+ overflow: hidden;
222
+ border-radius: 16px;
223
+ background: rgb(var(--ec-page-bg, 255 255 255));
224
+ box-shadow:
225
+ 0 0 0 1px rgb(var(--ec-page-border, 226 232 240)),
226
+ 0 25px 50px -12px rgb(0 0 0 / 0.25);
227
+ transform: scale(0.96);
228
+ opacity: 0;
229
+ transition:
230
+ transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
231
+ opacity 250ms cubic-bezier(0.22, 1, 0.36, 1);
232
+ }
233
+ .ec-diagram-modal.is-open .ec-diagram-modal__dialog {
234
+ transform: none;
235
+ opacity: 1;
236
+ }
237
+ .ec-diagram-modal.is-closing .ec-diagram-modal__dialog,
238
+ .ec-diagram-modal-backdrop.is-closing {
239
+ transition-duration: 150ms;
240
+ }
241
+ .ec-diagram-modal__toolbar {
242
+ position: absolute;
243
+ left: 12px;
244
+ top: 12px;
245
+ z-index: 10;
246
+ display: flex;
247
+ align-items: center;
248
+ gap: 4px;
249
+ }
250
+ .ec-diagram-modal__zoom {
251
+ box-sizing: border-box;
252
+ display: inline-flex;
253
+ align-items: center;
254
+ justify-content: center;
255
+ height: 28px;
256
+ min-width: 56px;
257
+ padding: 0 6px;
258
+ border-radius: 6px;
259
+ border: 1px solid rgb(var(--ec-page-border, 226 232 240));
260
+ background: rgb(var(--ec-card-bg, 255 255 255));
261
+ color: rgb(var(--ec-page-text, 15 23 42));
262
+ font-family: inherit;
263
+ font-size: 12px;
264
+ line-height: 1;
265
+ font-variant-numeric: tabular-nums;
266
+ }
267
+ .ec-diagram-modal__close {
268
+ position: absolute;
269
+ right: 12px;
270
+ top: 12px;
271
+ z-index: 10;
272
+ }
273
+ @media (prefers-reduced-motion: reduce) {
274
+ .ec-diagram-content,
275
+ .ec-diagram-controls,
276
+ .ec-diagram-modal-backdrop,
277
+ .ec-diagram-modal__dialog {
278
+ transition: none;
60
279
  }
61
- return fallback;
62
280
  }
281
+ `;
63
282
 
64
283
  /**
65
- * Gets theme colors based on current mode using CSS variables
284
+ * Injects the diagram stylesheet once per document
66
285
  */
67
- function getThemeColors() {
68
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
69
- return {
70
- isDark,
71
- bgColor: getCssVariableColor('--ec-card-bg', isDark ? '#161b22' : '#ffffff'),
72
- borderColor: getCssVariableColor('--ec-page-border', isDark ? '#30363d' : '#e2e8f0'),
73
- iconColor: getCssVariableColor('--ec-icon-color', isDark ? '#8b949e' : '#64748b'),
74
- iconHoverColor: getCssVariableColor('--ec-icon-hover', isDark ? '#f0f6fc' : '#0f172a'),
75
- hoverBgColor: getCssVariableColor('--ec-content-hover', isDark ? '#21262d' : '#f1f5f9'),
76
- overlayBg: getCssVariableColor('--ec-page-bg', isDark ? '#0d1117' : '#ffffff'),
77
- };
286
+ function ensureDiagramStyles(): void {
287
+ if (document.getElementById(STYLE_ID)) return;
288
+ const style = document.createElement('style');
289
+ style.id = STYLE_ID;
290
+ style.textContent = DIAGRAM_STYLES;
291
+ document.head.appendChild(style);
78
292
  }
79
293
 
80
294
  /**
81
- * Creates a styled button with inline CSS
295
+ * SVG icons (Lucide-style, 24px viewBox). Stroke widths are tuned for rendering at 16px.
82
296
  */
83
- function createStyledButton(
84
- svg: string,
85
- title: string,
86
- onClick: () => void,
87
- colors: ReturnType<typeof getThemeColors>,
88
- options: { isLast?: boolean; isRound?: boolean } = {}
89
- ): HTMLButtonElement {
90
- const { isLast = false, isRound = false } = options;
297
+ const icon = (paths: string, strokeWidth = 2) =>
298
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
299
+
300
+ const ICONS = {
301
+ fullscreen: icon('<path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/>'),
302
+ close: icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
303
+ panUp: icon('<path d="m18 15-6-6-6 6"/>', 2.5),
304
+ panDown: icon('<path d="m6 9 6 6 6-6"/>', 2.5),
305
+ panLeft: icon('<path d="m15 18-6-6 6-6"/>', 2.5),
306
+ panRight: icon('<path d="m9 18 6-6-6-6"/>', 2.5),
307
+ zoomIn: icon('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/>'),
308
+ zoomOut: icon('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/>'),
309
+ reset: icon('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>', 2.5),
310
+ copy: icon(
311
+ '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>'
312
+ ),
313
+ check: icon('<path d="M20 6 9 17l-5-5"/>', 2.5),
314
+ };
315
+
316
+ /**
317
+ * Creates a single control button
318
+ */
319
+ function createControlButton(svg: string, label: string, onClick: () => void): HTMLButtonElement {
91
320
  const btn = document.createElement('button');
92
321
  btn.type = 'button';
93
- btn.title = title;
322
+ btn.className = 'ec-diagram-btn';
323
+ btn.title = label;
324
+ btn.setAttribute('aria-label', label);
94
325
  btn.innerHTML = svg;
95
326
  btn.onclick = onClick;
96
-
97
- btn.style.cssText = `
98
- all: unset;
99
- box-sizing: border-box;
100
- display: flex;
101
- justify-content: center;
102
- align-items: center;
103
- width: 26px;
104
- height: 26px;
105
- min-width: 26px;
106
- min-height: 26px;
107
- padding: 0;
108
- margin: 0;
109
- border: none;
110
- background: ${colors.bgColor};
111
- color: ${colors.iconColor};
112
- cursor: pointer;
113
- transition: background-color 0.15s, color 0.15s;
114
- line-height: 1;
115
- font-size: 12px;
116
- ${!isLast && !isRound ? `border-bottom: 1px solid ${colors.borderColor};` : ''}
117
- ${isRound ? 'border-radius: 6px;' : ''}
118
- `;
119
-
120
- const svgEl = btn.querySelector('svg');
121
- if (svgEl) {
122
- svgEl.style.cssText = 'display: block; width: 12px; height: 12px;';
123
- }
124
-
125
- btn.onmouseenter = () => {
126
- btn.style.backgroundColor = colors.hoverBgColor;
127
- btn.style.color = colors.iconHoverColor;
128
- };
129
- btn.onmouseleave = () => {
130
- btn.style.backgroundColor = colors.bgColor;
131
- btn.style.color = colors.iconColor;
132
- };
133
-
134
327
  return btn;
135
328
  }
136
329
 
137
330
  /**
138
- * SVG icons
331
+ * Creates a "copy diagram code" button with success feedback
139
332
  */
140
- const ICONS = {
141
- 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>`,
142
- 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>`,
143
- 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>`,
144
- // Heroicons PresentationChartLineIcon (outline)
145
- 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>`,
146
- // Heroicons ClipboardDocumentIcon (outline)
147
- 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>`,
148
- // Heroicons CheckIcon (outline)
149
- 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>`,
150
- };
333
+ function createCopyButton(diagramContent: string): HTMLButtonElement {
334
+ let copyTimeout: ReturnType<typeof setTimeout> | undefined;
335
+ const button = createControlButton(ICONS.copy, 'Copy diagram code', () => {
336
+ navigator.clipboard.writeText(diagramContent).catch((err) => {
337
+ console.warn('Failed to copy diagram code:', err);
338
+ });
339
+ button.innerHTML = ICONS.check;
340
+ button.classList.add('ec-diagram-btn--success');
341
+ button.title = 'Copied!';
342
+ if (copyTimeout) clearTimeout(copyTimeout);
343
+ copyTimeout = setTimeout(() => {
344
+ button.innerHTML = ICONS.copy;
345
+ button.classList.remove('ec-diagram-btn--success');
346
+ button.title = 'Copy diagram code';
347
+ }, 2000);
348
+ });
349
+ return button;
350
+ }
151
351
 
152
352
  /**
153
- * Creates React Flow-style zoom controls
353
+ * Resolves the controls placement from a raw attribute/config value
154
354
  */
155
- function createZoomControls(onZoomIn: () => void, onZoomOut: () => void, onFitView: () => void): HTMLElement {
156
- const colors = getThemeColors();
355
+ export function resolvePlacement(value: string | null | undefined): ControlsPlacement {
356
+ return CONTROLS_PLACEMENTS.includes(value as ControlsPlacement) ? (value as ControlsPlacement) : DEFAULT_PLACEMENT;
357
+ }
157
358
 
158
- const controls = document.createElement('div');
159
- controls.style.cssText = `
160
- position: absolute;
161
- bottom: 12px;
162
- left: 12px;
163
- display: flex;
164
- flex-direction: column;
165
- background: ${colors.bgColor};
166
- border-radius: 6px;
167
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
168
- border: 1px solid ${colors.borderColor};
169
- overflow: hidden;
170
- z-index: 10;
171
- `;
172
-
173
- controls.appendChild(createStyledButton(ICONS.plus, 'Zoom in', onZoomIn, colors));
174
- controls.appendChild(createStyledButton(ICONS.minus, 'Zoom out', onZoomOut, colors));
175
- controls.appendChild(createStyledButton(ICONS.fit, 'Fit view', onFitView, colors, { isLast: true }));
359
+ /**
360
+ * Resolves the `actions` option from a raw attribute value ("true" / "false" / undefined)
361
+ */
362
+ export function resolveActions(value: string | null | undefined): boolean | undefined {
363
+ if (value === 'true') return true;
364
+ if (value === 'false') return false;
365
+ return undefined;
366
+ }
176
367
 
177
- return controls;
368
+ /**
369
+ * Reads diagram control options from the `data-*` attributes of a diagram element
370
+ */
371
+ export function getControlOptionsFromElement(element: Element): Pick<ZoomOptions, 'placement' | 'actions'> {
372
+ return {
373
+ placement: resolvePlacement(element.getAttribute('data-placement')),
374
+ actions: resolveActions(element.getAttribute('data-actions')),
375
+ };
178
376
  }
179
377
 
180
378
  /**
181
- * Creates a toolbar button with tooltip
379
+ * Pan/zoom engine
380
+ *
381
+ * Applies `translate(x, y) scale(s)` to a content element inside a viewport. Button/keyboard
382
+ * driven changes are animated with a CSS transition; drag, wheel and pinch changes are instant.
182
383
  */
183
- function createToolbarButton(
184
- icon: string,
185
- tooltipText: string,
186
- onClick: () => void,
187
- colors: ReturnType<typeof getThemeColors>,
188
- tooltipPosition: 'left' | 'right' | 'bottom' = 'bottom'
189
- ): { wrapper: HTMLElement; btn: HTMLButtonElement; tooltip: HTMLElement } {
190
- const wrapper = document.createElement('div');
191
- wrapper.style.cssText = `position: relative;`;
384
+ export interface PanZoomInstance {
385
+ fit: (animate?: boolean) => void;
386
+ zoomIn: () => void;
387
+ zoomOut: () => void;
388
+ panBy: (dx: number, dy: number) => void;
389
+ getScale: () => number;
390
+ destroy: () => void;
391
+ }
192
392
 
193
- const btn = document.createElement('button');
194
- btn.type = 'button';
195
- btn.innerHTML = icon;
196
- btn.style.cssText = `
197
- all: unset;
198
- box-sizing: border-box;
199
- display: flex;
200
- justify-content: center;
201
- align-items: center;
202
- width: 40px;
203
- height: 40px;
204
- min-width: 40px;
205
- min-height: 40px;
206
- padding: 0;
207
- margin: 0;
208
- border: none;
209
- border-radius: 6px;
210
- background: ${colors.bgColor};
211
- color: ${colors.iconColor};
212
- cursor: pointer;
213
- transition: background-color 0.15s, color 0.15s;
214
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
215
- `;
216
-
217
- const svgEl = btn.querySelector('svg');
218
- if (svgEl) {
219
- svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
220
- }
393
+ interface PanZoomOptions {
394
+ minScale?: number;
395
+ maxScale?: number;
396
+ /** Zoom with the mouse wheel / trackpad. Disabled inline so the page keeps scrolling. */
397
+ wheelZoom?: boolean;
398
+ onChange?: (scale: number) => void;
399
+ }
221
400
 
222
- btn.onmouseenter = () => {
223
- btn.style.backgroundColor = colors.hoverBgColor;
224
- btn.style.color = colors.iconHoverColor;
401
+ export function createPanZoom(
402
+ viewport: HTMLElement,
403
+ content: HTMLElement,
404
+ contentWidth: number,
405
+ contentHeight: number,
406
+ options: PanZoomOptions = {}
407
+ ): PanZoomInstance {
408
+ const { minScale = 0.1, maxScale = 8, wheelZoom = false, onChange } = options;
409
+
410
+ let scale = 1;
411
+ let x = 0;
412
+ let y = 0;
413
+
414
+ const clamp = (value: number) => Math.min(maxScale, Math.max(minScale, value));
415
+
416
+ const apply = (animate: boolean) => {
417
+ content.style.transitionDuration = animate ? `${TRANSITION_MS}ms` : '0ms';
418
+ content.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
419
+ onChange?.(scale);
225
420
  };
226
- btn.onmouseleave = () => {
227
- btn.style.backgroundColor = colors.bgColor;
228
- btn.style.color = colors.iconColor;
421
+
422
+ /** Fits the diagram inside the viewport (never scaling it above 100%) and centers it */
423
+ const fit = (animate = false) => {
424
+ const vw = viewport.clientWidth;
425
+ const vh = viewport.clientHeight;
426
+ if (vw <= 0 || vh <= 0) return;
427
+ scale = clamp(Math.min((vw - FIT_PADDING * 2) / contentWidth, (vh - FIT_PADDING * 2) / contentHeight, 1));
428
+ x = (vw - contentWidth * scale) / 2;
429
+ y = (vh - contentHeight * scale) / 2;
430
+ apply(animate);
229
431
  };
230
- btn.onclick = onClick;
231
432
 
232
- // Tooltip with position-based styling
233
- const tooltip = document.createElement('div');
234
- tooltip.textContent = tooltipText;
235
-
236
- let tooltipStyles = `
237
- position: absolute;
238
- padding: 4px 8px;
239
- background: #1f2937;
240
- color: white;
241
- font-size: 12px;
242
- border-radius: 4px;
243
- box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
244
- white-space: nowrap;
245
- pointer-events: none;
246
- opacity: 0;
247
- transition: opacity 0.15s;
248
- z-index: 50;
249
- `;
250
-
251
- if (tooltipPosition === 'right') {
252
- tooltipStyles += `
253
- top: 50%;
254
- left: 100%;
255
- transform: translateY(-50%);
256
- margin-left: 8px;
257
- `;
258
- } else if (tooltipPosition === 'left') {
259
- tooltipStyles += `
260
- top: 50%;
261
- right: 100%;
262
- transform: translateY(-50%);
263
- margin-right: 8px;
264
- `;
265
- } else {
266
- // Default: bottom
267
- tooltipStyles += `
268
- top: 100%;
269
- left: 50%;
270
- transform: translateX(-50%);
271
- margin-top: 8px;
272
- `;
273
- }
433
+ /** Zooms to `next` keeping the viewport point (ox, oy) fixed */
434
+ const zoomTo = (next: number, ox: number, oy: number, animate: boolean) => {
435
+ const target = clamp(next);
436
+ const ratio = target / scale;
437
+ x = ox - (ox - x) * ratio;
438
+ y = oy - (oy - y) * ratio;
439
+ scale = target;
440
+ apply(animate);
441
+ };
442
+
443
+ const zoomAtCenter = (factor: number) => zoomTo(scale * factor, viewport.clientWidth / 2, viewport.clientHeight / 2, true);
444
+
445
+ const panBy = (dx: number, dy: number) => {
446
+ x += dx;
447
+ y += dy;
448
+ apply(true);
449
+ };
450
+
451
+ // Pointer handling (drag to pan, two-finger pinch to zoom)
452
+ const pointers = new Map<number, { x: number; y: number }>();
453
+ let dragStart: { px: number; py: number; x: number; y: number } | null = null;
454
+ let pinchStart: { distance: number; scale: number } | null = null;
455
+
456
+ const pointerDistance = () => {
457
+ const [a, b] = Array.from(pointers.values());
458
+ return Math.hypot(a.x - b.x, a.y - b.y);
459
+ };
460
+
461
+ const pointerMidpoint = () => {
462
+ const [a, b] = Array.from(pointers.values());
463
+ const rect = viewport.getBoundingClientRect();
464
+ return { x: (a.x + b.x) / 2 - rect.left, y: (a.y + b.y) / 2 - rect.top };
465
+ };
466
+
467
+ const onPointerDown = (event: PointerEvent) => {
468
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
469
+ if ((event.target as HTMLElement).closest('button')) return;
470
+ viewport.setPointerCapture(event.pointerId);
471
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
472
+
473
+ if (pointers.size === 1) {
474
+ dragStart = { px: event.clientX, py: event.clientY, x, y };
475
+ viewport.classList.add('is-panning');
476
+ } else if (pointers.size === 2) {
477
+ dragStart = null;
478
+ pinchStart = { distance: pointerDistance(), scale };
479
+ }
480
+ };
481
+
482
+ const onPointerMove = (event: PointerEvent) => {
483
+ if (!pointers.has(event.pointerId)) return;
484
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
485
+
486
+ if (pointers.size === 2 && pinchStart) {
487
+ const mid = pointerMidpoint();
488
+ zoomTo((pinchStart.scale * pointerDistance()) / pinchStart.distance, mid.x, mid.y, false);
489
+ } else if (pointers.size === 1 && dragStart) {
490
+ x = dragStart.x + (event.clientX - dragStart.px);
491
+ y = dragStart.y + (event.clientY - dragStart.py);
492
+ apply(false);
493
+ }
494
+ };
495
+
496
+ const onPointerUp = (event: PointerEvent) => {
497
+ if (!pointers.has(event.pointerId)) return;
498
+ pointers.delete(event.pointerId);
499
+ try {
500
+ viewport.releasePointerCapture(event.pointerId);
501
+ } catch (e) {
502
+ // Pointer may already be released
503
+ }
274
504
 
275
- tooltip.style.cssText = tooltipStyles;
505
+ if (pointers.size === 0) {
506
+ dragStart = null;
507
+ pinchStart = null;
508
+ viewport.classList.remove('is-panning');
509
+ } else if (pointers.size === 1) {
510
+ const [remaining] = Array.from(pointers.values());
511
+ pinchStart = null;
512
+ dragStart = { px: remaining.x, py: remaining.y, x, y };
513
+ }
514
+ };
276
515
 
277
- wrapper.onmouseenter = () => {
278
- tooltip.style.opacity = '1';
516
+ const onWheel = (event: WheelEvent) => {
517
+ if (!wheelZoom) return;
518
+ event.preventDefault();
519
+ const rect = viewport.getBoundingClientRect();
520
+ const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
521
+ zoomTo(scale * Math.exp(-delta * 0.0015), event.clientX - rect.left, event.clientY - rect.top, false);
279
522
  };
280
- wrapper.onmouseleave = () => {
281
- tooltip.style.opacity = '0';
523
+
524
+ const onDoubleClick = (event: MouseEvent) => {
525
+ if ((event.target as HTMLElement).closest('button')) return;
526
+ const rect = viewport.getBoundingClientRect();
527
+ zoomTo(scale * ZOOM_STEP * ZOOM_STEP, event.clientX - rect.left, event.clientY - rect.top, true);
282
528
  };
283
529
 
284
- wrapper.appendChild(btn);
285
- wrapper.appendChild(tooltip);
530
+ viewport.addEventListener('pointerdown', onPointerDown);
531
+ viewport.addEventListener('pointermove', onPointerMove);
532
+ viewport.addEventListener('pointerup', onPointerUp);
533
+ viewport.addEventListener('pointercancel', onPointerUp);
534
+ viewport.addEventListener('wheel', onWheel, { passive: false });
535
+ viewport.addEventListener('dblclick', onDoubleClick);
286
536
 
287
- return { wrapper, btn, tooltip };
537
+ return {
538
+ fit,
539
+ zoomIn: () => zoomAtCenter(ZOOM_STEP),
540
+ zoomOut: () => zoomAtCenter(1 / ZOOM_STEP),
541
+ panBy,
542
+ getScale: () => scale,
543
+ destroy: () => {
544
+ viewport.removeEventListener('pointerdown', onPointerDown);
545
+ viewport.removeEventListener('pointermove', onPointerMove);
546
+ viewport.removeEventListener('pointerup', onPointerUp);
547
+ viewport.removeEventListener('pointercancel', onPointerUp);
548
+ viewport.removeEventListener('wheel', onWheel);
549
+ viewport.removeEventListener('dblclick', onDoubleClick);
550
+ pointers.clear();
551
+ },
552
+ };
288
553
  }
289
554
 
290
555
  /**
291
- * Creates the fullscreen button for top-left (tooltip shows to the right)
556
+ * Builds the viewport + content wrapper around an SVG and sizes the SVG to its natural dimensions
292
557
  */
293
- function createFullscreenButton(onClick: () => void): HTMLElement {
294
- const colors = getThemeColors();
295
- const { wrapper } = createToolbarButton(ICONS.presentation, 'Presentation Mode', onClick, colors, 'right');
296
- wrapper.style.cssText = `
297
- position: absolute;
298
- top: 12px;
299
- left: 12px;
300
- z-index: 10;
301
- `;
302
- return wrapper;
558
+ function createViewport(svgElement: SVGElement, width: number, height: number, modal = false) {
559
+ const viewport = document.createElement('div');
560
+ viewport.className = modal ? 'ec-diagram-viewport ec-diagram-viewport--modal' : 'ec-diagram-viewport';
561
+
562
+ const content = document.createElement('div');
563
+ content.className = 'ec-diagram-content';
564
+ content.style.width = `${width}px`;
565
+ content.style.height = `${height}px`;
566
+
567
+ svgElement.setAttribute('width', String(width));
568
+ svgElement.setAttribute('height', String(height));
569
+ svgElement.style.width = `${width}px`;
570
+ svgElement.style.height = `${height}px`;
571
+ svgElement.style.maxWidth = 'none';
572
+
573
+ content.appendChild(svgElement);
574
+ viewport.appendChild(content);
575
+ return { viewport, content };
576
+ }
577
+
578
+ interface DiagramSource {
579
+ svg: SVGElement;
580
+ width: number;
581
+ height: number;
582
+ diagramContent?: string;
303
583
  }
304
584
 
305
585
  /**
306
- * Creates the copy button for top-right (tooltip shows to the left)
586
+ * Opens the diagram in a fullscreen modal viewer with its own zoom toolbar.
587
+ * Supports drag/wheel/pinch, arrow keys to pan, +/- to zoom, 0 to reset and Escape to close.
307
588
  */
308
- function createCopyButton(onCopy: () => void): HTMLElement {
309
- const colors = getThemeColors();
310
- const copy = createToolbarButton(
311
- ICONS.copy,
312
- 'Copy diagram code',
313
- () => {
314
- onCopy();
315
- // Show feedback
316
- copy.btn.innerHTML = ICONS.check;
317
- copy.btn.style.color = '#10b981'; // Green color for success
318
- copy.tooltip.textContent = 'Copied!';
319
-
320
- const svgEl = copy.btn.querySelector('svg');
321
- if (svgEl) {
322
- svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
323
- }
324
-
325
- setTimeout(() => {
326
- copy.btn.innerHTML = ICONS.copy;
327
- copy.btn.style.color = colors.iconColor;
328
- copy.tooltip.textContent = 'Copy diagram code';
329
- const svgEl = copy.btn.querySelector('svg');
330
- if (svgEl) {
331
- svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
332
- }
333
- }, 2000);
589
+ export function openDiagramModal(source: DiagramSource): void {
590
+ ensureDiagramStyles();
591
+ closeOpenModal?.();
592
+
593
+ const previouslyFocused = document.activeElement as HTMLElement | null;
594
+ const previousBodyOverflow = document.body.style.overflow;
595
+
596
+ const backdrop = document.createElement('div');
597
+ backdrop.className = 'ec-diagram-modal-backdrop';
598
+
599
+ const root = document.createElement('div');
600
+ root.className = 'ec-diagram-modal';
601
+
602
+ const dialog = document.createElement('div');
603
+ dialog.className = 'ec-diagram-modal__dialog';
604
+ dialog.setAttribute('role', 'dialog');
605
+ dialog.setAttribute('aria-modal', 'true');
606
+ dialog.setAttribute('aria-label', 'Diagram');
607
+
608
+ const { viewport, content } = createViewport(source.svg.cloneNode(true) as SVGElement, source.width, source.height, true);
609
+ viewport.tabIndex = 0;
610
+ viewport.setAttribute('role', 'application');
611
+ viewport.setAttribute('aria-label', 'Diagram viewer. Use the arrow keys to pan, plus and minus to zoom, and zero to reset.');
612
+
613
+ const zoomStatus = document.createElement('span');
614
+ zoomStatus.className = 'ec-diagram-modal__zoom';
615
+ zoomStatus.setAttribute('role', 'status');
616
+ zoomStatus.textContent = '100%';
617
+
618
+ const panZoom = createPanZoom(viewport, content, source.width, source.height, {
619
+ wheelZoom: true,
620
+ onChange: (scale) => {
621
+ zoomStatus.textContent = `${Math.round(scale * 100)}%`;
334
622
  },
335
- colors,
336
- 'left'
337
- );
623
+ });
338
624
 
339
- copy.wrapper.style.cssText = `
340
- position: absolute;
341
- top: 12px;
342
- right: 12px;
343
- z-index: 10;
344
- `;
625
+ let closed = false;
626
+ const close = () => {
627
+ if (closed) return;
628
+ closed = true;
629
+ closeOpenModal = null;
630
+ document.removeEventListener('keydown', onKeyDown);
631
+ panZoom.destroy();
632
+ root.classList.add('is-closing');
633
+ backdrop.classList.add('is-closing');
634
+ root.classList.remove('is-open');
635
+ backdrop.classList.remove('is-open');
636
+ document.body.style.overflow = previousBodyOverflow;
637
+ setTimeout(() => {
638
+ root.remove();
639
+ backdrop.remove();
640
+ }, 150);
641
+ previouslyFocused?.focus?.({ preventScroll: true });
642
+ };
643
+ closeOpenModal = close;
644
+
645
+ const onKeyDown = (event: KeyboardEvent) => {
646
+ switch (event.key) {
647
+ case 'Escape':
648
+ close();
649
+ break;
650
+ case 'ArrowUp':
651
+ panZoom.panBy(0, PAN_STEP);
652
+ break;
653
+ case 'ArrowDown':
654
+ panZoom.panBy(0, -PAN_STEP);
655
+ break;
656
+ case 'ArrowLeft':
657
+ panZoom.panBy(PAN_STEP, 0);
658
+ break;
659
+ case 'ArrowRight':
660
+ panZoom.panBy(-PAN_STEP, 0);
661
+ break;
662
+ case '+':
663
+ case '=':
664
+ panZoom.zoomIn();
665
+ break;
666
+ case '-':
667
+ case '_':
668
+ panZoom.zoomOut();
669
+ break;
670
+ case '0':
671
+ panZoom.fit(true);
672
+ break;
673
+ default:
674
+ return;
675
+ }
676
+ event.preventDefault();
677
+ };
345
678
 
346
- return copy.wrapper;
679
+ const toolbar = document.createElement('div');
680
+ toolbar.className = 'ec-diagram-modal__toolbar';
681
+ toolbar.appendChild(createControlButton(ICONS.zoomOut, 'Zoom out', () => panZoom.zoomOut()));
682
+ toolbar.appendChild(zoomStatus);
683
+ toolbar.appendChild(createControlButton(ICONS.zoomIn, 'Zoom in', () => panZoom.zoomIn()));
684
+ toolbar.appendChild(createControlButton(ICONS.reset, 'Reset view', () => panZoom.fit(true)));
685
+ if (source.diagramContent) {
686
+ toolbar.appendChild(createCopyButton(source.diagramContent));
687
+ }
688
+
689
+ const closeBtn = createControlButton(ICONS.close, 'Close fullscreen', close);
690
+ closeBtn.classList.add('ec-diagram-modal__close');
691
+
692
+ dialog.appendChild(viewport);
693
+ dialog.appendChild(toolbar);
694
+ dialog.appendChild(closeBtn);
695
+ root.appendChild(dialog);
696
+
697
+ backdrop.addEventListener('click', close);
698
+ root.addEventListener('click', (event) => {
699
+ // Clicks on the padding around the dialog close the modal
700
+ if (event.target === root) close();
701
+ });
702
+ document.addEventListener('keydown', onKeyDown);
703
+
704
+ document.body.appendChild(backdrop);
705
+ document.body.appendChild(root);
706
+ document.body.style.overflow = 'hidden';
707
+
708
+ panZoom.fit(false);
709
+ viewport.focus({ preventScroll: true });
710
+
711
+ // Let the starting styles paint before transitioning in
712
+ requestAnimationFrame(() => {
713
+ requestAnimationFrame(() => {
714
+ backdrop.classList.add('is-open');
715
+ root.classList.add('is-open');
716
+ });
717
+ });
718
+ }
719
+
720
+ interface DiagramControlsOptions {
721
+ placement: ControlsPlacement;
722
+ source: DiagramSource;
347
723
  }
348
724
 
349
725
  /**
350
- * Toggles native fullscreen mode on a container
726
+ * Creates the inline interactive diagram controls, laid out as a 3x3 grid:
727
+ *
728
+ * [fullscreen] [pan up] [zoom in]
729
+ * [pan left] [reset] [pan right]
730
+ * [copy] [pan down] [zoom out]
351
731
  */
352
- function toggleFullscreen(container: HTMLElement): void {
353
- if (!document.fullscreenElement) {
354
- container.requestFullscreen().catch((err) => {
355
- console.warn(`Error entering fullscreen: ${err.message}`);
356
- });
732
+ function createDiagramControls(panZoom: PanZoomInstance, options: DiagramControlsOptions): HTMLElement {
733
+ const { placement, source } = options;
734
+
735
+ const controls = document.createElement('div');
736
+ controls.className = 'ec-diagram-controls';
737
+ controls.setAttribute('data-placement', placement);
738
+ controls.setAttribute('role', 'toolbar');
739
+ controls.setAttribute('aria-label', 'Diagram controls');
740
+
741
+ let copyBtn: HTMLElement;
742
+ if (source.diagramContent) {
743
+ copyBtn = createCopyButton(source.diagramContent);
357
744
  } else {
358
- document.exitFullscreen();
745
+ // Keep the grid shape when there is nothing to copy
746
+ copyBtn = document.createElement('div');
747
+ copyBtn.setAttribute('aria-hidden', 'true');
359
748
  }
749
+
750
+ const buttons: HTMLElement[] = [
751
+ createControlButton(ICONS.fullscreen, 'Open fullscreen', () => openDiagramModal(source)),
752
+ createControlButton(ICONS.panUp, 'Pan up', () => panZoom.panBy(0, PAN_STEP)),
753
+ createControlButton(ICONS.zoomIn, 'Zoom in', () => panZoom.zoomIn()),
754
+ createControlButton(ICONS.panLeft, 'Pan left', () => panZoom.panBy(PAN_STEP, 0)),
755
+ createControlButton(ICONS.reset, 'Reset view', () => panZoom.fit(true)),
756
+ createControlButton(ICONS.panRight, 'Pan right', () => panZoom.panBy(-PAN_STEP, 0)),
757
+ copyBtn,
758
+ createControlButton(ICONS.panDown, 'Pan down', () => panZoom.panBy(0, -PAN_STEP)),
759
+ createControlButton(ICONS.zoomOut, 'Zoom out', () => panZoom.zoomOut()),
760
+ ];
761
+ buttons.forEach((btn) => controls.appendChild(btn));
762
+
763
+ return controls;
360
764
  }
361
765
 
362
766
  /**
363
- * Creates the zoom container with React Flow-style appearance
767
+ * Creates the zoom container that wraps a rendered diagram
364
768
  */
365
769
  export function createZoomContainer(): HTMLElement {
770
+ ensureDiagramStyles();
366
771
  const container = document.createElement('div');
367
772
  container.className = 'mermaid-zoom-container';
368
- container.style.cssText = `
369
- position: relative;
370
- width: 100%;
371
- min-height: 200px;
372
- overflow: hidden;
373
- margin: 0;
374
- cursor: grab;
375
- `;
773
+ container.style.minHeight = '200px';
376
774
  return container;
377
775
  }
378
776
 
379
777
  interface ZoomOptions {
380
778
  minZoom?: number;
381
779
  maxZoom?: number;
382
- zoomScaleSensitivity?: number;
383
780
  maxHeight?: number;
384
781
  minHeight?: number;
385
782
  diagramContent?: string;
783
+ /** Where to place the interactive controls. Defaults to `top-right`. */
784
+ placement?: ControlsPlacement;
785
+ /** Force the controls on/off. By default they are shown when the diagram is taller than 120px. */
786
+ actions?: boolean;
386
787
  }
387
788
 
789
+ /** Padding kept around the content when a viewBox is trimmed */
790
+ const TRIM_PADDING = 8;
791
+
792
+ /** Only trim a viewBox when it has more than this much empty space on a side */
793
+ const TRIM_THRESHOLD = 16;
794
+
388
795
  /**
389
- * Initializes zoom on a Mermaid SVG element
796
+ * Some renderers (notably mermaid C4 diagrams) declare a viewBox much larger than the drawn
797
+ * content, which makes the diagram appear small and heavily padded. When the SVG is rendered
798
+ * we can measure the real content bounds and tighten the viewBox around them.
390
799
  */
391
- export async function initMermaidZoom(
392
- svgElement: SVGElement,
393
- container: HTMLElement,
394
- id: string,
395
- options: ZoomOptions = {}
396
- ): Promise<void> {
397
- // Lower zoomScaleSensitivity = smoother but slower zoom
398
- const { minZoom = 0.5, maxZoom = 10, zoomScaleSensitivity = 0.15, maxHeight = 500, minHeight = 200, diagramContent } = options;
800
+ function trimSvgViewBox(svgElement: SVGElement): void {
801
+ const viewBox = svgElement.getAttribute('viewBox');
802
+ if (!viewBox) return;
803
+ const [vx, vy, vw, vh] = viewBox.split(/[\s,]+/).map(Number);
804
+ if (!(vw > 0 && vh > 0)) return;
805
+
806
+ let bbox: DOMRect;
807
+ try {
808
+ bbox = (svgElement as unknown as SVGGraphicsElement).getBBox();
809
+ } catch (e) {
810
+ return;
811
+ }
812
+ // getBBox returns zeros when the SVG is not rendered (e.g. inside a collapsed section)
813
+ if (!(bbox.width > 0 && bbox.height > 0)) return;
814
+
815
+ const slack = {
816
+ left: bbox.x - vx,
817
+ top: bbox.y - vy,
818
+ right: vx + vw - (bbox.x + bbox.width),
819
+ bottom: vy + vh - (bbox.y + bbox.height),
820
+ };
821
+ if (Math.max(slack.left, slack.top, slack.right, slack.bottom) <= TRIM_THRESHOLD) return;
399
822
 
400
- // Dynamic import for performance
401
- const { default: svgPanZoom } = await import('svg-pan-zoom');
823
+ const x = Math.max(vx, bbox.x - TRIM_PADDING);
824
+ const y = Math.max(vy, bbox.y - TRIM_PADDING);
825
+ const width = Math.min(vx + vw, bbox.x + bbox.width + TRIM_PADDING) - x;
826
+ const height = Math.min(vy + vh, bbox.y + bbox.height + TRIM_PADDING) - y;
827
+ svgElement.setAttribute('viewBox', `${x} ${y} ${width} ${height}`);
828
+ }
402
829
 
403
- // Get the natural dimensions from viewBox or getBBox
404
- let width: number = 0;
405
- let height: number = 0;
830
+ /**
831
+ * Reads the natural dimensions of an SVG from its viewBox, bounding box or attributes
832
+ */
833
+ function getSvgDimensions(svgElement: SVGElement): { width: number; height: number } {
834
+ let width = 0;
835
+ let height = 0;
836
+ trimSvgViewBox(svgElement);
406
837
  const viewBox = svgElement.getAttribute('viewBox');
407
838
 
408
839
  if (viewBox) {
@@ -412,7 +843,7 @@ export async function initMermaidZoom(
412
843
  }
413
844
 
414
845
  // If viewBox didn't give us dimensions, try getBBox
415
- if (width <= 0 || height <= 0) {
846
+ if (!(width > 0 && height > 0)) {
416
847
  try {
417
848
  // Cast to SVGGraphicsElement which has getBBox method
418
849
  const bbox = (svgElement as unknown as SVGGraphicsElement).getBBox();
@@ -427,130 +858,66 @@ export async function initMermaidZoom(
427
858
  }
428
859
 
429
860
  // Fallback to element dimensions if still no size
430
- if (width <= 0 || height <= 0) {
861
+ if (!(width > 0 && height > 0)) {
431
862
  width = svgElement.clientWidth || parseFloat(svgElement.getAttribute('width') || '0') || 800;
432
863
  height = svgElement.clientHeight || parseFloat(svgElement.getAttribute('height') || '0') || 400;
433
864
  }
434
865
 
435
- // Set container height based on SVG aspect ratio, capped for usability
436
- if (width > 0 && height > 0) {
437
- const containerWidth = container.clientWidth || 800;
438
- const aspectRatio = height / width;
439
- const calculatedHeight = Math.min(Math.max(containerWidth * aspectRatio, minHeight), maxHeight);
440
- container.style.height = `${calculatedHeight}px`;
441
- } else {
442
- container.style.height = `${minHeight}px`;
443
- }
866
+ return { width, height };
867
+ }
444
868
 
445
- // SVG needs to fill the container for svg-pan-zoom
446
- svgElement.style.width = '100%';
447
- svgElement.style.height = '100%';
448
- svgElement.removeAttribute('height');
449
- svgElement.removeAttribute('width');
869
+ /**
870
+ * Initializes pan/zoom (and the interactive controls) on a rendered SVG element
871
+ */
872
+ export async function initMermaidZoom(
873
+ svgElement: SVGElement,
874
+ container: HTMLElement,
875
+ id: string,
876
+ options: ZoomOptions = {}
877
+ ): Promise<void> {
878
+ const {
879
+ minZoom = 0.1,
880
+ maxZoom = 8,
881
+ maxHeight = 500,
882
+ minHeight = 200,
883
+ diagramContent,
884
+ placement = DEFAULT_PLACEMENT,
885
+ actions,
886
+ } = options;
450
887
 
451
- try {
452
- const instance = svgPanZoom(svgElement, {
453
- zoomEnabled: true,
454
- controlIconsEnabled: false, // We use custom controls
455
- fit: true,
456
- center: true,
457
- minZoom,
458
- maxZoom,
459
- zoomScaleSensitivity,
460
- dblClickZoomEnabled: true,
461
- mouseWheelZoomEnabled: false, // Disabled to avoid hijacking page scroll
462
- preventMouseEventsDefault: true,
463
- panEnabled: true,
464
- });
888
+ ensureDiagramStyles();
465
889
 
466
- zoomInstances.set(id, instance);
890
+ const { width, height } = getSvgDimensions(svgElement);
467
891
 
468
- // Update cursor during pan
469
- container.addEventListener('mousedown', () => {
470
- container.style.cursor = 'grabbing';
471
- });
472
- container.addEventListener('mouseup', () => {
473
- container.style.cursor = 'grab';
474
- });
475
- container.addEventListener('mouseleave', () => {
476
- container.style.cursor = 'grab';
477
- });
892
+ // Set container height based on SVG aspect ratio, capped for usability
893
+ const containerWidth = container.clientWidth || 800;
894
+ const calculatedHeight = Math.min(Math.max(containerWidth * (height / width), minHeight), maxHeight);
895
+ container.style.height = `${calculatedHeight}px`;
896
+
897
+ // Wrap the SVG in a viewport + transformable content element
898
+ const { viewport, content } = createViewport(svgElement, width, height);
899
+ container.innerHTML = '';
900
+ container.appendChild(viewport);
901
+
902
+ const panZoom = createPanZoom(viewport, content, width, height, { minScale: minZoom, maxScale: maxZoom });
903
+ zoomInstances.set(id, panZoom);
904
+ panZoom.fit(false);
905
+
906
+ // Add interactive controls. By default they are only shown for diagrams taller than 120px.
907
+ const showControls = actions ?? height > CONTROLS_MIN_HEIGHT;
908
+ if (showControls) {
909
+ const source: DiagramSource = { svg: svgElement, width, height, diagramContent };
910
+ container.appendChild(createDiagramControls(panZoom, { placement, source }));
911
+ }
478
912
 
479
- // Add custom controls
480
- const controls = createZoomControls(
481
- () => instance.zoomIn(),
482
- () => instance.zoomOut(),
483
- () => {
484
- instance.fit();
485
- instance.center();
486
- }
487
- );
488
- container.appendChild(controls);
489
-
490
- // Add fullscreen button (top-left)
491
- const fullscreenBtn = createFullscreenButton(() => toggleFullscreen(container));
492
- container.appendChild(fullscreenBtn);
493
-
494
- // Add copy button (top-right) if diagram content is available
495
- if (diagramContent) {
496
- const copyBtn = createCopyButton(() => {
497
- navigator.clipboard.writeText(diagramContent).catch((err) => {
498
- console.warn('Failed to copy diagram code:', err);
499
- });
500
- });
501
- container.appendChild(copyBtn);
913
+ // Refit on resize for responsiveness
914
+ const resizeObserver = new ResizeObserver(() => {
915
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
916
+ panZoom.fit(false);
502
917
  }
503
-
504
- // Handle fullscreen changes - enable scroll zoom in fullscreen, disable when exiting
505
- const handleFullscreenChange = () => {
506
- const isFullscreen = document.fullscreenElement === container;
507
-
508
- if (isFullscreen) {
509
- // Enable scroll zoom in fullscreen
510
- instance.enableMouseWheelZoom();
511
- // Update container styles for fullscreen
512
- container.style.background = getThemeColors().overlayBg;
513
- } else {
514
- // Disable scroll zoom when not fullscreen
515
- instance.disableMouseWheelZoom();
516
- // Reset container background
517
- container.style.background = '';
518
- }
519
-
520
- // Fit and center after transition
521
- setTimeout(() => {
522
- if (container.clientWidth > 0 && container.clientHeight > 0) {
523
- try {
524
- instance.resize();
525
- instance.fit();
526
- instance.center();
527
- } catch (e) {
528
- // Ignore matrix inversion errors
529
- }
530
- }
531
- }, 100);
532
- };
533
- document.addEventListener('fullscreenchange', handleFullscreenChange);
534
- fullscreenHandlers.set(id, handleFullscreenChange);
535
-
536
- // Resize handler for responsiveness
537
- const resizeObserver = new ResizeObserver(() => {
538
- // Guard against zero-dimension containers which cause matrix inversion errors
539
- if (container.clientWidth > 0 && container.clientHeight > 0) {
540
- try {
541
- instance.resize();
542
- instance.fit();
543
- instance.center();
544
- } catch (e) {
545
- // Ignore matrix inversion errors during resize
546
- }
547
- }
548
- });
549
- resizeObserver.observe(container);
550
- resizeObservers.set(id, resizeObserver);
551
- } catch (e) {
552
- console.warn('Failed to initialize zoom on mermaid diagram:', e);
553
- }
918
+ });
919
+ resizeObserver.observe(container);
920
+ resizeObservers.set(id, resizeObserver);
554
921
  }
555
922
 
556
923
  /**
@@ -590,8 +957,12 @@ export async function renderMermaidWithZoom(graphs: HTMLCollectionOf<Element>, m
590
957
 
591
958
  // Reset abort flag at the start of rendering
592
959
  renderingAborted = false;
960
+ const generation = ++mermaidRenderGeneration;
961
+ lastMermaidConfig = mermaidConfig;
962
+ ensureThemeObserver();
593
963
 
594
964
  const { default: mermaid } = await import('mermaid');
965
+ if (generation !== mermaidRenderGeneration) return;
595
966
 
596
967
  // Apply any custom mermaid configuration
597
968
  if (mermaidConfig) {
@@ -652,7 +1023,7 @@ export async function renderMermaidWithZoom(graphs: HTMLCollectionOf<Element>, m
652
1023
 
653
1024
  for (const graph of graphsArray) {
654
1025
  // Check if rendering was aborted (e.g., user navigated away)
655
- if (renderingAborted) return;
1026
+ if (renderingAborted || generation !== mermaidRenderGeneration) return;
656
1027
 
657
1028
  const content = graph.getAttribute('data-content');
658
1029
  if (!content) continue;
@@ -663,7 +1034,7 @@ export async function renderMermaidWithZoom(graphs: HTMLCollectionOf<Element>, m
663
1034
  const result = await mermaid.render(id, content);
664
1035
 
665
1036
  // Check again after async operation
666
- if (renderingAborted) return;
1037
+ if (renderingAborted || generation !== mermaidRenderGeneration) return;
667
1038
 
668
1039
  // Create zoom container
669
1040
  const container = createZoomContainer();
@@ -676,7 +1047,10 @@ export async function renderMermaidWithZoom(graphs: HTMLCollectionOf<Element>, m
676
1047
  // Initialize zoom on the SVG
677
1048
  const svgElement = container.querySelector('svg');
678
1049
  if (svgElement) {
679
- await initMermaidZoom(svgElement as SVGElement, container, id, { diagramContent: content });
1050
+ await initMermaidZoom(svgElement as SVGElement, container, id, {
1051
+ diagramContent: content,
1052
+ ...getControlOptionsFromElement(graph),
1053
+ });
680
1054
  }
681
1055
  } catch (e) {
682
1056
  console.error('Mermaid render error:', e);
@@ -743,7 +1117,7 @@ export async function renderPlantUMLWithZoom(blocks: HTMLCollectionOf<Element>):
743
1117
  const svgUrl = `https://www.plantuml.com/plantuml/svg/~1${encoded}`;
744
1118
 
745
1119
  try {
746
- // Fetch SVG content so we can use svg-pan-zoom
1120
+ // Fetch SVG content so we can pan/zoom it
747
1121
  const response = await fetch(svgUrl);
748
1122
 
749
1123
  // Check again after async operation
@@ -769,7 +1143,10 @@ export async function renderPlantUMLWithZoom(blocks: HTMLCollectionOf<Element>):
769
1143
  // Initialize zoom on the SVG
770
1144
  const svgElement = container.querySelector('svg');
771
1145
  if (svgElement) {
772
- await initMermaidZoom(svgElement as SVGElement, container, id, { diagramContent: content });
1146
+ await initMermaidZoom(svgElement as SVGElement, container, id, {
1147
+ diagramContent: content,
1148
+ ...getControlOptionsFromElement(block),
1149
+ });
773
1150
  }
774
1151
  } catch (e) {
775
1152
  // Fallback to img tag if fetch fails (e.g., CORS issues)