@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.
@@ -73,16 +73,6 @@ const diagramId = props.data.id;
73
73
  display: block;
74
74
  }
75
75
 
76
- /* Zoom controls styling - must stay as CSS since svg-pan-zoom injects them */
77
- .svg-pan-zoom-control {
78
- fill: rgb(var(--ec-page-text-muted)) !important;
79
- cursor: pointer;
80
- }
81
-
82
- .svg-pan-zoom-control:hover {
83
- fill: rgb(var(--ec-page-text)) !important;
84
- }
85
-
86
76
  /* Prose styles */
87
77
  .prose {
88
78
  max-width: none;
@@ -216,351 +206,702 @@ const diagramId = props.data.id;
216
206
  // Store zoom instances for cleanup
217
207
  const zoomInstances = new Map();
218
208
 
219
- function createZoomControls(onZoomIn, onZoomOut, onFitView) {
220
- // Detect dark mode
221
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
209
+ // ---------------------------------------------------------------------------
210
+ // Diagram pan/zoom, controls and fullscreen modal
211
+ // (kept in sync with eventcatalog/src/utils/mermaid-zoom.ts)
212
+ // ---------------------------------------------------------------------------
213
+ const CONTROLS_PLACEMENTS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
214
+ const DEFAULT_PLACEMENT = 'top-right';
215
+ const CONTROLS_MIN_HEIGHT = 120;
216
+ const PAN_STEP = 60;
217
+ const ZOOM_STEP = 1.2;
218
+ const TRANSITION_MS = 150;
219
+ const FIT_PADDING = 16;
220
+ const STYLE_ID = 'ec-diagram-styles';
221
+ let closeOpenModal = null;
222
+
223
+ const DIAGRAM_STYLES = `
224
+ .mermaid-zoom-container {
225
+ position: relative;
226
+ width: 100%;
227
+ overflow: hidden;
228
+ }
229
+ .ec-diagram-viewport {
230
+ position: absolute;
231
+ inset: 0;
232
+ overflow: hidden;
233
+ cursor: grab;
234
+ touch-action: pan-y pinch-zoom;
235
+ user-select: none;
236
+ -webkit-user-select: none;
237
+ outline: none;
238
+ }
239
+ .ec-diagram-viewport.is-panning {
240
+ cursor: grabbing;
241
+ }
242
+ .ec-diagram-viewport--modal {
243
+ touch-action: none;
244
+ background-image: radial-gradient(rgb(var(--ec-page-text, 15 23 42) / 0.08) 1px, transparent 1px);
245
+ background-size: 16px 16px;
246
+ }
247
+ .ec-diagram-content {
248
+ position: absolute;
249
+ left: 0;
250
+ top: 0;
251
+ transform-origin: 0 0;
252
+ transition: transform ${TRANSITION_MS}ms ease-out;
253
+ will-change: transform;
254
+ }
255
+ /* Element selectors bump specificity above the page's global ".mermaid svg" rules */
256
+ div.ec-diagram-content > svg {
257
+ display: block;
258
+ margin: 0;
259
+ max-width: none !important;
260
+ }
261
+ .ec-diagram-btn {
262
+ all: unset;
263
+ box-sizing: border-box;
264
+ display: inline-flex;
265
+ align-items: center;
266
+ justify-content: center;
267
+ width: 28px;
268
+ height: 28px;
269
+ border-radius: 6px;
270
+ cursor: pointer;
271
+ background: rgb(var(--ec-card-bg, 255 255 255));
272
+ color: rgb(var(--ec-page-text, 15 23 42));
273
+ border: 1px solid rgb(var(--ec-page-border, 226 232 240));
274
+ transition: background-color 0.15s, transform 0.1s;
275
+ }
276
+ .ec-diagram-btn:hover {
277
+ background: rgb(var(--ec-content-hover, 241 245 249));
278
+ }
279
+ .ec-diagram-btn:active {
280
+ transform: scale(0.95);
281
+ }
282
+ .ec-diagram-btn:focus-visible {
283
+ outline: 2px solid rgb(var(--ec-accent, 59 130 246));
284
+ outline-offset: 1px;
285
+ }
286
+ button.ec-diagram-btn svg {
287
+ display: block;
288
+ width: 16px;
289
+ height: 16px;
290
+ margin: 0;
291
+ flex-shrink: 0;
292
+ }
293
+ .ec-diagram-btn.ec-diagram-btn--success {
294
+ color: #10b981;
295
+ }
296
+ .ec-diagram-controls {
297
+ position: absolute;
298
+ z-index: 10;
299
+ display: grid;
300
+ grid-template-columns: repeat(3, 28px);
301
+ gap: 4px;
302
+ opacity: 0;
303
+ pointer-events: none;
304
+ transition: opacity 0.15s ease;
305
+ }
306
+ .ec-diagram-controls[data-placement="top-left"] { top: 8px; left: 8px; }
307
+ .ec-diagram-controls[data-placement="top-right"] { top: 8px; right: 8px; }
308
+ .ec-diagram-controls[data-placement="bottom-left"] { bottom: 8px; left: 8px; }
309
+ .ec-diagram-controls[data-placement="bottom-right"] { bottom: 8px; right: 8px; }
310
+ .mermaid-zoom-container:hover .ec-diagram-controls,
311
+ .mermaid-zoom-container:focus-within .ec-diagram-controls {
312
+ opacity: 1;
313
+ pointer-events: auto;
314
+ }
315
+ @media (pointer: coarse) {
316
+ .ec-diagram-controls { opacity: 1; pointer-events: auto; }
317
+ }
318
+ @media print {
319
+ .ec-diagram-controls { display: none; }
320
+ }
321
+ .ec-diagram-modal-backdrop {
322
+ position: fixed;
323
+ inset: 0;
324
+ z-index: 9998;
325
+ background: rgb(0 0 0 / 0.4);
326
+ opacity: 0;
327
+ transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1);
328
+ }
329
+ .ec-diagram-modal-backdrop.is-open {
330
+ opacity: 1;
331
+ }
332
+ .ec-diagram-modal {
333
+ position: fixed;
334
+ inset: 0;
335
+ z-index: 9999;
336
+ padding: 16px;
337
+ }
338
+ @media (min-width: 640px) {
339
+ .ec-diagram-modal { padding: 24px; }
340
+ }
341
+ .ec-diagram-modal__dialog {
342
+ position: relative;
343
+ width: 100%;
344
+ height: 100%;
345
+ overflow: hidden;
346
+ border-radius: 16px;
347
+ background: rgb(var(--ec-page-bg, 255 255 255));
348
+ box-shadow:
349
+ 0 0 0 1px rgb(var(--ec-page-border, 226 232 240)),
350
+ 0 25px 50px -12px rgb(0 0 0 / 0.25);
351
+ transform: scale(0.96);
352
+ opacity: 0;
353
+ transition:
354
+ transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
355
+ opacity 250ms cubic-bezier(0.22, 1, 0.36, 1);
356
+ }
357
+ .ec-diagram-modal.is-open .ec-diagram-modal__dialog {
358
+ transform: none;
359
+ opacity: 1;
360
+ }
361
+ .ec-diagram-modal.is-closing .ec-diagram-modal__dialog,
362
+ .ec-diagram-modal-backdrop.is-closing {
363
+ transition-duration: 150ms;
364
+ }
365
+ .ec-diagram-modal__toolbar {
366
+ position: absolute;
367
+ left: 12px;
368
+ top: 12px;
369
+ z-index: 10;
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 4px;
373
+ }
374
+ .ec-diagram-modal__zoom {
375
+ box-sizing: border-box;
376
+ display: inline-flex;
377
+ align-items: center;
378
+ justify-content: center;
379
+ height: 28px;
380
+ min-width: 56px;
381
+ padding: 0 6px;
382
+ border-radius: 6px;
383
+ border: 1px solid rgb(var(--ec-page-border, 226 232 240));
384
+ background: rgb(var(--ec-card-bg, 255 255 255));
385
+ color: rgb(var(--ec-page-text, 15 23 42));
386
+ font-family: inherit;
387
+ font-size: 12px;
388
+ line-height: 1;
389
+ font-variant-numeric: tabular-nums;
390
+ }
391
+ .ec-diagram-modal__close {
392
+ position: absolute;
393
+ right: 12px;
394
+ top: 12px;
395
+ z-index: 10;
396
+ }
397
+ @media (prefers-reduced-motion: reduce) {
398
+ .ec-diagram-content,
399
+ .ec-diagram-controls,
400
+ .ec-diagram-modal-backdrop,
401
+ .ec-diagram-modal__dialog {
402
+ transition: none;
403
+ }
404
+ }
405
+ `;
406
+
407
+ function ensureDiagramStyles() {
408
+ if (document.getElementById(STYLE_ID)) return;
409
+ const style = document.createElement('style');
410
+ style.id = STYLE_ID;
411
+ style.textContent = DIAGRAM_STYLES;
412
+ document.head.appendChild(style);
413
+ }
222
414
 
223
- // Use explicit colors based on theme
224
- const bgColor = isDark ? '#161b22' : '#ffffff';
225
- const borderColor = isDark ? '#30363d' : '#e2e8f0';
226
- const iconColor = isDark ? '#8b949e' : '#64748b';
227
- const iconHoverColor = isDark ? '#f0f6fc' : '#0f172a';
228
- const hoverBgColor = isDark ? '#21262d' : '#f1f5f9';
415
+ const icon = (paths, strokeWidth = 2) =>
416
+ `<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>`;
229
417
 
230
- const controls = document.createElement('div');
231
- controls.className = 'mermaid-zoom-controls';
232
- controls.style.cssText = `
233
- position: absolute;
234
- bottom: 12px;
235
- left: 12px;
236
- display: flex;
237
- flex-direction: column;
238
- background: ${bgColor};
239
- border-radius: 6px;
240
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
241
- border: 1px solid ${borderColor};
242
- overflow: hidden;
243
- z-index: 10;
244
- `;
245
-
246
- const createButton = (svg, title, onClick, isLast = false) => {
247
- const btn = document.createElement('button');
248
- btn.type = 'button';
249
- btn.title = title;
250
- btn.innerHTML = svg;
251
- btn.onclick = onClick;
252
- btn.style.cssText = `
253
- display: flex;
254
- justify-content: center;
255
- align-items: center;
256
- width: 26px;
257
- height: 26px;
258
- padding: 0;
259
- margin: 0;
260
- border: none;
261
- background: ${bgColor};
262
- color: ${iconColor};
263
- cursor: pointer;
264
- transition: background-color 0.15s, color 0.15s;
265
- line-height: 1;
266
- ${!isLast ? `border-bottom: 1px solid ${borderColor};` : ''}
267
- `;
268
- // Ensure SVG is properly centered
269
- const svgEl = btn.querySelector('svg');
270
- if (svgEl) {
271
- svgEl.style.display = 'block';
418
+ const ICONS = {
419
+ fullscreen: icon('<path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/>'),
420
+ close: icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
421
+ panUp: icon('<path d="m18 15-6-6-6 6"/>', 2.5),
422
+ panDown: icon('<path d="m6 9 6 6 6-6"/>', 2.5),
423
+ panLeft: icon('<path d="m15 18-6-6 6-6"/>', 2.5),
424
+ panRight: icon('<path d="m9 18 6-6-6-6"/>', 2.5),
425
+ 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"/>'),
426
+ zoomOut: icon('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/>'),
427
+ 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),
428
+ copy: icon(
429
+ '<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"/>'
430
+ ),
431
+ check: icon('<path d="M20 6 9 17l-5-5"/>', 2.5),
432
+ };
433
+
434
+ function createControlButton(svg, label, onClick) {
435
+ const btn = document.createElement('button');
436
+ btn.type = 'button';
437
+ btn.className = 'ec-diagram-btn';
438
+ btn.title = label;
439
+ btn.setAttribute('aria-label', label);
440
+ btn.innerHTML = svg;
441
+ btn.onclick = onClick;
442
+ return btn;
443
+ }
444
+
445
+ function createCopyButton(diagramContent) {
446
+ let copyTimeout;
447
+ const button = createControlButton(ICONS.copy, 'Copy diagram code', () => {
448
+ navigator.clipboard.writeText(diagramContent).catch((err) => {
449
+ console.warn('Failed to copy diagram code:', err);
450
+ });
451
+ button.innerHTML = ICONS.check;
452
+ button.classList.add('ec-diagram-btn--success');
453
+ button.title = 'Copied!';
454
+ if (copyTimeout) clearTimeout(copyTimeout);
455
+ copyTimeout = setTimeout(() => {
456
+ button.innerHTML = ICONS.copy;
457
+ button.classList.remove('ec-diagram-btn--success');
458
+ button.title = 'Copy diagram code';
459
+ }, 2000);
460
+ });
461
+ return button;
462
+ }
463
+
464
+ function resolvePlacement(value) {
465
+ return CONTROLS_PLACEMENTS.includes(value) ? value : DEFAULT_PLACEMENT;
466
+ }
467
+
468
+ function resolveActions(value) {
469
+ if (value === 'true') return true;
470
+ if (value === 'false') return false;
471
+ return undefined;
472
+ }
473
+
474
+ function getControlOptionsFromElement(element) {
475
+ return {
476
+ placement: resolvePlacement(element.getAttribute('data-placement')),
477
+ actions: resolveActions(element.getAttribute('data-actions')),
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Pan/zoom engine: applies translate/scale to the content element. Button/keyboard changes
483
+ * are animated with a CSS transition; drag, wheel and pinch changes are instant.
484
+ */
485
+ function createPanZoom(viewport, content, contentWidth, contentHeight, options = {}) {
486
+ const { minScale = 0.1, maxScale = 8, wheelZoom = false, onChange } = options;
487
+
488
+ let scale = 1;
489
+ let x = 0;
490
+ let y = 0;
491
+
492
+ const clamp = (value) => Math.min(maxScale, Math.max(minScale, value));
493
+
494
+ const apply = (animate) => {
495
+ content.style.transitionDuration = animate ? `${TRANSITION_MS}ms` : '0ms';
496
+ content.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
497
+ if (onChange) onChange(scale);
498
+ };
499
+
500
+ const fit = (animate = false) => {
501
+ const vw = viewport.clientWidth;
502
+ const vh = viewport.clientHeight;
503
+ if (vw <= 0 || vh <= 0) return;
504
+ scale = clamp(Math.min((vw - FIT_PADDING * 2) / contentWidth, (vh - FIT_PADDING * 2) / contentHeight, 1));
505
+ x = (vw - contentWidth * scale) / 2;
506
+ y = (vh - contentHeight * scale) / 2;
507
+ apply(animate);
508
+ };
509
+
510
+ const zoomTo = (next, ox, oy, animate) => {
511
+ const target = clamp(next);
512
+ const ratio = target / scale;
513
+ x = ox - (ox - x) * ratio;
514
+ y = oy - (oy - y) * ratio;
515
+ scale = target;
516
+ apply(animate);
517
+ };
518
+
519
+ const zoomAtCenter = (factor) => zoomTo(scale * factor, viewport.clientWidth / 2, viewport.clientHeight / 2, true);
520
+
521
+ const panBy = (dx, dy) => {
522
+ x += dx;
523
+ y += dy;
524
+ apply(true);
525
+ };
526
+
527
+ const pointers = new Map();
528
+ let dragStart = null;
529
+ let pinchStart = null;
530
+
531
+ const pointerDistance = () => {
532
+ const [a, b] = Array.from(pointers.values());
533
+ return Math.hypot(a.x - b.x, a.y - b.y);
534
+ };
535
+
536
+ const pointerMidpoint = () => {
537
+ const [a, b] = Array.from(pointers.values());
538
+ const rect = viewport.getBoundingClientRect();
539
+ return { x: (a.x + b.x) / 2 - rect.left, y: (a.y + b.y) / 2 - rect.top };
540
+ };
541
+
542
+ const onPointerDown = (event) => {
543
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
544
+ if (event.target.closest('button')) return;
545
+ viewport.setPointerCapture(event.pointerId);
546
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
547
+
548
+ if (pointers.size === 1) {
549
+ dragStart = { px: event.clientX, py: event.clientY, x, y };
550
+ viewport.classList.add('is-panning');
551
+ } else if (pointers.size === 2) {
552
+ dragStart = null;
553
+ pinchStart = { distance: pointerDistance(), scale };
272
554
  }
273
- btn.onmouseenter = () => {
274
- btn.style.backgroundColor = hoverBgColor;
275
- btn.style.color = iconHoverColor;
276
- };
277
- btn.onmouseleave = () => {
278
- btn.style.backgroundColor = bgColor;
279
- btn.style.color = iconColor;
280
- };
281
- return btn;
282
555
  };
283
556
 
284
- const plusSvg = `<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>`;
285
- const minusSvg = `<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>`;
286
- const fitSvg = `<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>`;
557
+ const onPointerMove = (event) => {
558
+ if (!pointers.has(event.pointerId)) return;
559
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
560
+
561
+ if (pointers.size === 2 && pinchStart) {
562
+ const mid = pointerMidpoint();
563
+ zoomTo((pinchStart.scale * pointerDistance()) / pinchStart.distance, mid.x, mid.y, false);
564
+ } else if (pointers.size === 1 && dragStart) {
565
+ x = dragStart.x + (event.clientX - dragStart.px);
566
+ y = dragStart.y + (event.clientY - dragStart.py);
567
+ apply(false);
568
+ }
569
+ };
287
570
 
288
- controls.appendChild(createButton(plusSvg, 'Zoom in', onZoomIn));
289
- controls.appendChild(createButton(minusSvg, 'Zoom out', onZoomOut));
290
- controls.appendChild(createButton(fitSvg, 'Fit view', onFitView, true));
571
+ const onPointerUp = (event) => {
572
+ if (!pointers.has(event.pointerId)) return;
573
+ pointers.delete(event.pointerId);
574
+ try {
575
+ viewport.releasePointerCapture(event.pointerId);
576
+ } catch (e) {}
577
+
578
+ if (pointers.size === 0) {
579
+ dragStart = null;
580
+ pinchStart = null;
581
+ viewport.classList.remove('is-panning');
582
+ } else if (pointers.size === 1) {
583
+ const [remaining] = Array.from(pointers.values());
584
+ pinchStart = null;
585
+ dragStart = { px: remaining.x, py: remaining.y, x, y };
586
+ }
587
+ };
291
588
 
292
- return controls;
589
+ const onWheel = (event) => {
590
+ if (!wheelZoom) return;
591
+ event.preventDefault();
592
+ const rect = viewport.getBoundingClientRect();
593
+ const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
594
+ zoomTo(scale * Math.exp(-delta * 0.0015), event.clientX - rect.left, event.clientY - rect.top, false);
595
+ };
596
+
597
+ const onDoubleClick = (event) => {
598
+ if (event.target.closest('button')) return;
599
+ const rect = viewport.getBoundingClientRect();
600
+ zoomTo(scale * ZOOM_STEP * ZOOM_STEP, event.clientX - rect.left, event.clientY - rect.top, true);
601
+ };
602
+
603
+ viewport.addEventListener('pointerdown', onPointerDown);
604
+ viewport.addEventListener('pointermove', onPointerMove);
605
+ viewport.addEventListener('pointerup', onPointerUp);
606
+ viewport.addEventListener('pointercancel', onPointerUp);
607
+ viewport.addEventListener('wheel', onWheel, { passive: false });
608
+ viewport.addEventListener('dblclick', onDoubleClick);
609
+
610
+ return {
611
+ fit,
612
+ zoomIn: () => zoomAtCenter(ZOOM_STEP),
613
+ zoomOut: () => zoomAtCenter(1 / ZOOM_STEP),
614
+ panBy,
615
+ getScale: () => scale,
616
+ destroy: () => {
617
+ viewport.removeEventListener('pointerdown', onPointerDown);
618
+ viewport.removeEventListener('pointermove', onPointerMove);
619
+ viewport.removeEventListener('pointerup', onPointerUp);
620
+ viewport.removeEventListener('pointercancel', onPointerUp);
621
+ viewport.removeEventListener('wheel', onWheel);
622
+ viewport.removeEventListener('dblclick', onDoubleClick);
623
+ pointers.clear();
624
+ },
625
+ };
293
626
  }
294
627
 
295
- // Icons for toolbar
296
- const ICONS = {
297
- 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>`,
298
- 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>`,
299
- 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>`,
300
- };
628
+ function createViewport(svgElement, width, height, modal = false) {
629
+ const viewport = document.createElement('div');
630
+ viewport.className = modal ? 'ec-diagram-viewport ec-diagram-viewport--modal' : 'ec-diagram-viewport';
301
631
 
302
- function createToolbarButton(icon, tooltipText, onClick, tooltipPosition = 'bottom') {
303
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
304
- const bgColor = isDark ? '#161b22' : '#ffffff';
305
- const iconColor = isDark ? '#8b949e' : '#64748b';
306
- const iconHoverColor = isDark ? '#f0f6fc' : '#0f172a';
307
- const hoverBgColor = isDark ? '#21262d' : '#f1f5f9';
632
+ const content = document.createElement('div');
633
+ content.className = 'ec-diagram-content';
634
+ content.style.width = `${width}px`;
635
+ content.style.height = `${height}px`;
308
636
 
309
- const wrapper = document.createElement('div');
310
- wrapper.style.cssText = 'position: relative;';
637
+ svgElement.setAttribute('width', String(width));
638
+ svgElement.setAttribute('height', String(height));
639
+ svgElement.style.width = `${width}px`;
640
+ svgElement.style.height = `${height}px`;
641
+ svgElement.style.maxWidth = 'none';
311
642
 
312
- const btn = document.createElement('button');
313
- btn.type = 'button';
314
- btn.innerHTML = icon;
315
- btn.style.cssText = `
316
- all: unset;
317
- box-sizing: border-box;
318
- display: flex;
319
- justify-content: center;
320
- align-items: center;
321
- width: 40px;
322
- height: 40px;
323
- min-width: 40px;
324
- min-height: 40px;
325
- padding: 0;
326
- margin: 0;
327
- border: none;
328
- border-radius: 6px;
329
- background: ${bgColor};
330
- color: ${iconColor};
331
- cursor: pointer;
332
- transition: background-color 0.15s, color 0.15s;
333
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
334
- `;
335
-
336
- const svgEl = btn.querySelector('svg');
337
- if (svgEl) svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
338
-
339
- btn.onmouseenter = () => {
340
- btn.style.backgroundColor = hoverBgColor;
341
- btn.style.color = iconHoverColor;
643
+ content.appendChild(svgElement);
644
+ viewport.appendChild(content);
645
+ return { viewport, content };
646
+ }
647
+
648
+ /**
649
+ * Opens the diagram in a fullscreen modal viewer with its own zoom toolbar.
650
+ */
651
+ function openDiagramModal(source) {
652
+ ensureDiagramStyles();
653
+ if (closeOpenModal) closeOpenModal();
654
+
655
+ const previouslyFocused = document.activeElement;
656
+ const previousBodyOverflow = document.body.style.overflow;
657
+
658
+ const backdrop = document.createElement('div');
659
+ backdrop.className = 'ec-diagram-modal-backdrop';
660
+
661
+ const root = document.createElement('div');
662
+ root.className = 'ec-diagram-modal';
663
+
664
+ const dialog = document.createElement('div');
665
+ dialog.className = 'ec-diagram-modal__dialog';
666
+ dialog.setAttribute('role', 'dialog');
667
+ dialog.setAttribute('aria-modal', 'true');
668
+ dialog.setAttribute('aria-label', 'Diagram');
669
+
670
+ const { viewport, content } = createViewport(source.svg.cloneNode(true), source.width, source.height, true);
671
+ viewport.tabIndex = 0;
672
+ viewport.setAttribute('role', 'application');
673
+ viewport.setAttribute(
674
+ 'aria-label',
675
+ 'Diagram viewer. Use the arrow keys to pan, plus and minus to zoom, and zero to reset.'
676
+ );
677
+
678
+ const zoomStatus = document.createElement('span');
679
+ zoomStatus.className = 'ec-diagram-modal__zoom';
680
+ zoomStatus.setAttribute('role', 'status');
681
+ zoomStatus.textContent = '100%';
682
+
683
+ const panZoom = createPanZoom(viewport, content, source.width, source.height, {
684
+ wheelZoom: true,
685
+ onChange: (scale) => {
686
+ zoomStatus.textContent = `${Math.round(scale * 100)}%`;
687
+ },
688
+ });
689
+
690
+ let closed = false;
691
+ const close = () => {
692
+ if (closed) return;
693
+ closed = true;
694
+ closeOpenModal = null;
695
+ document.removeEventListener('keydown', onKeyDown);
696
+ panZoom.destroy();
697
+ root.classList.add('is-closing');
698
+ backdrop.classList.add('is-closing');
699
+ root.classList.remove('is-open');
700
+ backdrop.classList.remove('is-open');
701
+ document.body.style.overflow = previousBodyOverflow;
702
+ setTimeout(() => {
703
+ root.remove();
704
+ backdrop.remove();
705
+ }, 150);
706
+ if (previouslyFocused && previouslyFocused.focus) previouslyFocused.focus({ preventScroll: true });
342
707
  };
343
- btn.onmouseleave = () => {
344
- btn.style.backgroundColor = bgColor;
345
- btn.style.color = iconColor;
708
+ closeOpenModal = close;
709
+
710
+ const onKeyDown = (event) => {
711
+ switch (event.key) {
712
+ case 'Escape':
713
+ close();
714
+ break;
715
+ case 'ArrowUp':
716
+ panZoom.panBy(0, PAN_STEP);
717
+ break;
718
+ case 'ArrowDown':
719
+ panZoom.panBy(0, -PAN_STEP);
720
+ break;
721
+ case 'ArrowLeft':
722
+ panZoom.panBy(PAN_STEP, 0);
723
+ break;
724
+ case 'ArrowRight':
725
+ panZoom.panBy(-PAN_STEP, 0);
726
+ break;
727
+ case '+':
728
+ case '=':
729
+ panZoom.zoomIn();
730
+ break;
731
+ case '-':
732
+ case '_':
733
+ panZoom.zoomOut();
734
+ break;
735
+ case '0':
736
+ panZoom.fit(true);
737
+ break;
738
+ default:
739
+ return;
740
+ }
741
+ event.preventDefault();
346
742
  };
347
- btn.onclick = onClick;
348
743
 
349
- const tooltip = document.createElement('div');
350
- tooltip.textContent = tooltipText;
351
-
352
- // Position tooltip based on tooltipPosition parameter
353
- let tooltipStyles = `
354
- position: absolute;
355
- padding: 4px 8px;
356
- background: #1f2937;
357
- color: white;
358
- font-size: 12px;
359
- border-radius: 4px;
360
- box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
361
- white-space: nowrap;
362
- pointer-events: none;
363
- opacity: 0;
364
- transition: opacity 0.15s;
365
- z-index: 50;
366
- `;
367
-
368
- if (tooltipPosition === 'right') {
369
- tooltipStyles += `
370
- top: 50%;
371
- left: 100%;
372
- transform: translateY(-50%);
373
- margin-left: 8px;
374
- `;
375
- } else if (tooltipPosition === 'left') {
376
- tooltipStyles += `
377
- top: 50%;
378
- right: 100%;
379
- transform: translateY(-50%);
380
- margin-right: 8px;
381
- `;
382
- } else {
383
- // Default: bottom
384
- tooltipStyles += `
385
- top: 100%;
386
- left: 50%;
387
- transform: translateX(-50%);
388
- margin-top: 8px;
389
- `;
744
+ const toolbar = document.createElement('div');
745
+ toolbar.className = 'ec-diagram-modal__toolbar';
746
+ toolbar.appendChild(createControlButton(ICONS.zoomOut, 'Zoom out', () => panZoom.zoomOut()));
747
+ toolbar.appendChild(zoomStatus);
748
+ toolbar.appendChild(createControlButton(ICONS.zoomIn, 'Zoom in', () => panZoom.zoomIn()));
749
+ toolbar.appendChild(createControlButton(ICONS.reset, 'Reset view', () => panZoom.fit(true)));
750
+ if (source.diagramContent) {
751
+ toolbar.appendChild(createCopyButton(source.diagramContent));
390
752
  }
391
753
 
392
- tooltip.style.cssText = tooltipStyles;
393
-
394
- wrapper.onmouseenter = () => (tooltip.style.opacity = '1');
395
- wrapper.onmouseleave = () => (tooltip.style.opacity = '0');
396
-
397
- wrapper.appendChild(btn);
398
- wrapper.appendChild(tooltip);
399
- return { wrapper, btn, tooltip, iconColor };
400
- }
401
-
402
- function createFullscreenButton(onClick) {
403
- const { wrapper } = createToolbarButton(ICONS.presentation, 'Presentation Mode', onClick, 'right');
404
- wrapper.style.cssText = 'position: absolute; top: 12px; left: 12px; z-index: 10;';
405
- return wrapper;
406
- }
407
-
408
- function createCopyButton(onCopy) {
409
- const copy = createToolbarButton(
410
- ICONS.copy,
411
- 'Copy diagram code',
412
- () => {
413
- onCopy();
414
- copy.btn.innerHTML = ICONS.check;
415
- copy.btn.style.color = '#10b981';
416
- copy.tooltip.textContent = 'Copied!';
417
- const svgEl = copy.btn.querySelector('svg');
418
- if (svgEl) svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
419
-
420
- setTimeout(() => {
421
- copy.btn.innerHTML = ICONS.copy;
422
- copy.btn.style.color = copy.iconColor;
423
- copy.tooltip.textContent = 'Copy diagram code';
424
- const svgEl = copy.btn.querySelector('svg');
425
- if (svgEl) svgEl.style.cssText = 'display: block; width: 20px; height: 20px;';
426
- }, 2000);
427
- },
428
- 'left'
429
- );
430
- copy.wrapper.style.cssText = 'position: absolute; top: 12px; right: 12px; z-index: 10;';
431
- return copy.wrapper;
754
+ const closeBtn = createControlButton(ICONS.close, 'Close fullscreen', close);
755
+ closeBtn.classList.add('ec-diagram-modal__close');
756
+
757
+ dialog.appendChild(viewport);
758
+ dialog.appendChild(toolbar);
759
+ dialog.appendChild(closeBtn);
760
+ root.appendChild(dialog);
761
+
762
+ backdrop.addEventListener('click', close);
763
+ root.addEventListener('click', (event) => {
764
+ if (event.target === root) close();
765
+ });
766
+ document.addEventListener('keydown', onKeyDown);
767
+
768
+ document.body.appendChild(backdrop);
769
+ document.body.appendChild(root);
770
+ document.body.style.overflow = 'hidden';
771
+
772
+ panZoom.fit(false);
773
+ viewport.focus({ preventScroll: true });
774
+
775
+ requestAnimationFrame(() => {
776
+ requestAnimationFrame(() => {
777
+ backdrop.classList.add('is-open');
778
+ root.classList.add('is-open');
779
+ });
780
+ });
432
781
  }
433
782
 
434
- function toggleFullscreen(container) {
435
- if (!document.fullscreenElement) {
436
- container.requestFullscreen().catch((err) => console.warn('Fullscreen error:', err));
783
+ /**
784
+ * 3x3 grid of inline controls:
785
+ * [fullscreen] [pan up] [zoom in]
786
+ * [pan left] [reset] [pan right]
787
+ * [copy] [pan down] [zoom out]
788
+ */
789
+ function createDiagramControls(panZoom, { placement, source }) {
790
+ const controls = document.createElement('div');
791
+ controls.className = 'ec-diagram-controls';
792
+ controls.setAttribute('data-placement', placement);
793
+ controls.setAttribute('role', 'toolbar');
794
+ controls.setAttribute('aria-label', 'Diagram controls');
795
+
796
+ let copyBtn;
797
+ if (source.diagramContent) {
798
+ copyBtn = createCopyButton(source.diagramContent);
437
799
  } else {
438
- document.exitFullscreen();
800
+ copyBtn = document.createElement('div');
801
+ copyBtn.setAttribute('aria-hidden', 'true');
439
802
  }
803
+
804
+ [
805
+ createControlButton(ICONS.fullscreen, 'Open fullscreen', () => openDiagramModal(source)),
806
+ createControlButton(ICONS.panUp, 'Pan up', () => panZoom.panBy(0, PAN_STEP)),
807
+ createControlButton(ICONS.zoomIn, 'Zoom in', () => panZoom.zoomIn()),
808
+ createControlButton(ICONS.panLeft, 'Pan left', () => panZoom.panBy(PAN_STEP, 0)),
809
+ createControlButton(ICONS.reset, 'Reset view', () => panZoom.fit(true)),
810
+ createControlButton(ICONS.panRight, 'Pan right', () => panZoom.panBy(-PAN_STEP, 0)),
811
+ copyBtn,
812
+ createControlButton(ICONS.panDown, 'Pan down', () => panZoom.panBy(0, -PAN_STEP)),
813
+ createControlButton(ICONS.zoomOut, 'Zoom out', () => panZoom.zoomOut()),
814
+ ].forEach((btn) => controls.appendChild(btn));
815
+
816
+ return controls;
440
817
  }
441
818
 
442
- async function initMermaidZoom(svgElement, container, id, diagramContent) {
443
- const { default: svgPanZoom } = await import('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/+esm');
819
+ const TRIM_PADDING = 8;
820
+ const TRIM_THRESHOLD = 16;
444
821
 
445
- // Get the natural dimensions from viewBox or getBBox
446
- let width, height;
822
+ // Tighten over-allocated viewBoxes (e.g. mermaid C4 diagrams) around the drawn content
823
+ function trimSvgViewBox(svgElement) {
824
+ const viewBox = svgElement.getAttribute('viewBox');
825
+ if (!viewBox) return;
826
+ const [vx, vy, vw, vh] = viewBox.split(/[\s,]+/).map(Number);
827
+ if (!(vw > 0 && vh > 0)) return;
828
+
829
+ let bbox;
830
+ try {
831
+ bbox = svgElement.getBBox();
832
+ } catch (e) {
833
+ return;
834
+ }
835
+ if (!(bbox.width > 0 && bbox.height > 0)) return;
836
+
837
+ const slack = Math.max(bbox.x - vx, bbox.y - vy, vx + vw - (bbox.x + bbox.width), vy + vh - (bbox.y + bbox.height));
838
+ if (slack <= TRIM_THRESHOLD) return;
839
+
840
+ const x = Math.max(vx, bbox.x - TRIM_PADDING);
841
+ const y = Math.max(vy, bbox.y - TRIM_PADDING);
842
+ const width = Math.min(vx + vw, bbox.x + bbox.width + TRIM_PADDING) - x;
843
+ const height = Math.min(vy + vh, bbox.y + bbox.height + TRIM_PADDING) - y;
844
+ svgElement.setAttribute('viewBox', `${x} ${y} ${width} ${height}`);
845
+ }
846
+
847
+ function getSvgDimensions(svgElement) {
848
+ let width = 0;
849
+ let height = 0;
850
+ trimSvgViewBox(svgElement);
447
851
  const viewBox = svgElement.getAttribute('viewBox');
448
852
  if (viewBox) {
449
853
  const parts = viewBox.split(/[\s,]+/).map(Number);
450
854
  width = parts[2];
451
855
  height = parts[3];
452
- } else {
453
- const bbox = svgElement.getBBox();
454
- width = bbox.width;
455
- height = bbox.height;
456
- if (width > 0 && height > 0) {
457
- svgElement.setAttribute('viewBox', `${bbox.x} ${bbox.y} ${width} ${height}`);
458
- }
459
856
  }
460
-
461
- // Set container height based on SVG aspect ratio, capped for usability
462
- if (width > 0 && height > 0) {
463
- const containerWidth = container.clientWidth || 800;
464
- const aspectRatio = height / width;
465
- const calculatedHeight = Math.min(Math.max(containerWidth * aspectRatio, 200), 500);
466
- container.style.height = `${calculatedHeight}px`;
857
+ if (!(width > 0 && height > 0)) {
858
+ try {
859
+ const bbox = svgElement.getBBox();
860
+ width = bbox.width;
861
+ height = bbox.height;
862
+ if (width > 0 && height > 0 && !viewBox) {
863
+ svgElement.setAttribute('viewBox', `${bbox.x} ${bbox.y} ${width} ${height}`);
864
+ }
865
+ } catch (e) {}
467
866
  }
867
+ if (!(width > 0 && height > 0)) {
868
+ width = svgElement.clientWidth || parseFloat(svgElement.getAttribute('width') || '0') || 800;
869
+ height = svgElement.clientHeight || parseFloat(svgElement.getAttribute('height') || '0') || 400;
870
+ }
871
+ return { width, height };
872
+ }
468
873
 
469
- // SVG needs to fill the container for svg-pan-zoom
470
- svgElement.style.width = '100%';
471
- svgElement.style.height = '100%';
472
- svgElement.removeAttribute('height');
473
- svgElement.removeAttribute('width');
474
-
475
- try {
476
- const instance = svgPanZoom(svgElement, {
477
- zoomEnabled: true,
478
- controlIconsEnabled: false,
479
- fit: true,
480
- center: true,
481
- minZoom: 0.5,
482
- maxZoom: 10,
483
- zoomScaleSensitivity: 0.15,
484
- dblClickZoomEnabled: true,
485
- mouseWheelZoomEnabled: false, // Disabled to avoid hijacking page scroll
486
- preventMouseEventsDefault: true,
487
- panEnabled: true,
488
- });
874
+ async function initMermaidZoom(svgElement, container, id, diagramContent, controlOptions = {}) {
875
+ ensureDiagramStyles();
489
876
 
490
- zoomInstances.set(id, instance);
877
+ const { placement = DEFAULT_PLACEMENT, actions } = controlOptions;
878
+ const { width, height } = getSvgDimensions(svgElement);
491
879
 
492
- // Update cursor during pan
493
- container.addEventListener('mousedown', () => {
494
- container.style.cursor = 'grabbing';
495
- });
496
- container.addEventListener('mouseup', () => {
497
- container.style.cursor = 'grab';
498
- });
499
- container.addEventListener('mouseleave', () => {
500
- container.style.cursor = 'grab';
501
- });
880
+ // Set container height based on SVG aspect ratio, capped for usability
881
+ const containerWidth = container.clientWidth || 800;
882
+ container.style.height = `${Math.min(Math.max(containerWidth * (height / width), 200), 500)}px`;
883
+
884
+ const { viewport, content } = createViewport(svgElement, width, height);
885
+ container.innerHTML = '';
886
+ container.appendChild(viewport);
887
+
888
+ const panZoom = createPanZoom(viewport, content, width, height);
889
+ zoomInstances.set(id, panZoom);
890
+ panZoom.fit(false);
891
+
892
+ // Add interactive controls. By default they are only shown for diagrams taller than 120px.
893
+ const showControls = actions ?? height > CONTROLS_MIN_HEIGHT;
894
+ if (showControls) {
895
+ const source = { svg: svgElement, width, height, diagramContent };
896
+ container.appendChild(createDiagramControls(panZoom, { placement, source }));
897
+ }
502
898
 
503
- // Add custom controls
504
- const controls = createZoomControls(
505
- () => instance.zoomIn(),
506
- () => instance.zoomOut(),
507
- () => {
508
- instance.fit();
509
- instance.center();
510
- }
511
- );
512
- container.appendChild(controls);
513
-
514
- // Add fullscreen button (top-left)
515
- const fullscreenBtn = createFullscreenButton(() => toggleFullscreen(container));
516
- container.appendChild(fullscreenBtn);
517
-
518
- // Add copy button (top-right) if diagram content is available
519
- if (diagramContent) {
520
- const copyBtn = createCopyButton(() => {
521
- navigator.clipboard.writeText(diagramContent).catch((err) => {
522
- console.warn('Failed to copy:', err);
523
- });
524
- });
525
- container.appendChild(copyBtn);
899
+ const resizeObserver = new ResizeObserver(() => {
900
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
901
+ panZoom.fit(false);
526
902
  }
527
-
528
- // Handle fullscreen changes
529
- document.addEventListener('fullscreenchange', () => {
530
- const isFullscreen = document.fullscreenElement === container;
531
- if (isFullscreen) {
532
- instance.enableMouseWheelZoom();
533
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
534
- container.style.background = isDark ? '#0d1117' : '#ffffff';
535
- } else {
536
- instance.disableMouseWheelZoom();
537
- container.style.background = '';
538
- }
539
- setTimeout(() => {
540
- if (container.clientWidth > 0 && container.clientHeight > 0) {
541
- try {
542
- instance.resize();
543
- instance.fit();
544
- instance.center();
545
- } catch (e) {}
546
- }
547
- }, 100);
548
- });
549
-
550
- // Resize handler for responsiveness
551
- const resizeObserver = new ResizeObserver(() => {
552
- if (container.clientWidth > 0 && container.clientHeight > 0) {
553
- try {
554
- instance.resize();
555
- instance.fit();
556
- instance.center();
557
- } catch (e) {}
558
- }
559
- });
560
- resizeObserver.observe(container);
561
- } catch (e) {
562
- console.warn('Failed to initialize zoom on mermaid diagram:', e);
563
- }
903
+ });
904
+ resizeObserver.observe(container);
564
905
  }
565
906
 
566
907
  async function renderMermaid() {
@@ -608,8 +949,8 @@ const diagramId = props.data.id;
608
949
 
609
950
  // Create zoom container with inline styles (no Tailwind in embed)
610
951
  const container = document.createElement('div');
611
- container.style.cssText =
612
- 'position: relative; width: 100%; min-height: 200px; overflow: hidden; margin: 1em 0; cursor: grab;';
952
+ container.className = 'mermaid-zoom-container';
953
+ container.style.cssText = 'min-height: 200px; margin: 1em 0;';
613
954
 
614
955
  // Insert the rendered SVG
615
956
  container.innerHTML = result.svg;
@@ -621,7 +962,7 @@ const diagramId = props.data.id;
621
962
  // Initialize zoom on the SVG
622
963
  const svgElement = container.querySelector('svg');
623
964
  if (svgElement) {
624
- await initMermaidZoom(svgElement, container, id, content);
965
+ await initMermaidZoom(svgElement, container, id, content, getControlOptionsFromElement(graph));
625
966
  }
626
967
  } catch (e) {
627
968
  console.error('Mermaid render error:', e);
@@ -637,7 +978,6 @@ const diagramId = props.data.id;
637
978
  if (blocks.length === 0) return;
638
979
 
639
980
  const { deflate } = await import('https://cdn.jsdelivr.net/npm/pako@2.1.0/+esm');
640
- const { default: svgPanZoom } = await import('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/+esm');
641
981
 
642
982
  function encode64(data) {
643
983
  const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
@@ -681,8 +1021,8 @@ const diagramId = props.data.id;
681
1021
 
682
1022
  // Create zoom container
683
1023
  const container = document.createElement('div');
684
- container.style.cssText =
685
- 'position: relative; width: 100%; min-height: 200px; overflow: hidden; margin: 1em 0; cursor: grab;';
1024
+ container.className = 'mermaid-zoom-container';
1025
+ container.style.cssText = 'min-height: 200px; margin: 1em 0;';
686
1026
  container.innerHTML = svgText;
687
1027
 
688
1028
  block.innerHTML = '';