@eventcatalog/core 4.10.5 → 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.
- 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-NWWGCVSE.js → chunk-EB43LSVE.js} +1 -1
- package/dist/{chunk-7PXZNQPR.js → chunk-GMVJ4B2S.js} +1 -1
- package/dist/{chunk-GLEQKW6W.js → chunk-NECJFWMI.js} +1 -1
- package/dist/{chunk-JE6ZSCPU.js → chunk-XFS3XVP2.js} +1 -1
- package/dist/{chunk-RX2XDKHZ.js → chunk-YSWJHMAX.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/MDX/MermaidFileLoader/MermaidFileLoader.astro +10 -3
- package/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro +665 -325
- package/eventcatalog/src/remark-plugins/mermaid.ts +48 -1
- package/eventcatalog/src/utils/mermaid-zoom.ts +781 -404
- package/package.json +3 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Diagram Zoom Utility
|
|
3
|
-
* Provides pan/zoom functionality for Mermaid and PlantUML diagrams with
|
|
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,
|
|
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
|
-
|
|
45
|
-
fullscreenHandlers.forEach((handler) => {
|
|
46
|
-
document.removeEventListener('fullscreenchange', handler);
|
|
47
|
-
});
|
|
48
|
-
fullscreenHandlers.clear();
|
|
67
|
+
closeOpenModal?.();
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
/**
|
|
52
|
-
*
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
*
|
|
284
|
+
* Injects the diagram stylesheet once per document
|
|
66
285
|
*/
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
*
|
|
295
|
+
* SVG icons (Lucide-style, 24px viewBox). Stroke widths are tuned for rendering at 16px.
|
|
82
296
|
*/
|
|
83
|
-
|
|
84
|
-
svg
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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
|
-
*
|
|
331
|
+
* Creates a "copy diagram code" button with success feedback
|
|
139
332
|
*/
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
*
|
|
353
|
+
* Resolves the controls placement from a raw attribute/config value
|
|
154
354
|
*/
|
|
155
|
-
function
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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 {
|
|
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
|
-
*
|
|
556
|
+
* Builds the viewport + content wrapper around an SVG and sizes the SVG to its natural dimensions
|
|
292
557
|
*/
|
|
293
|
-
function
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
*
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
'left'
|
|
337
|
-
);
|
|
623
|
+
});
|
|
338
624
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
392
|
-
svgElement
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
401
|
-
const
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
svgElement
|
|
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
|
-
|
|
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
|
-
|
|
890
|
+
const { width, height } = getSvgDimensions(svgElement);
|
|
467
891
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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, {
|
|
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
|
|
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, {
|
|
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)
|