@barzhsieh/nuxt-content-mermaid 2.1.2 → 2.2.0

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.
Files changed (87) hide show
  1. package/README.md +162 -1
  2. package/README.zh-TW.md +166 -2
  3. package/dist/module.d.mts +83 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +126 -9
  6. package/dist/runtime/components/Mermaid.d.vue.ts +6 -4
  7. package/dist/runtime/components/Mermaid.vue +492 -36
  8. package/dist/runtime/components/Mermaid.vue.d.ts +6 -4
  9. package/dist/runtime/components/MermaidExpandOverlay.d.vue.ts +25 -0
  10. package/dist/runtime/components/MermaidExpandOverlay.vue +140 -0
  11. package/dist/runtime/components/MermaidExpandOverlay.vue.d.ts +25 -0
  12. package/dist/runtime/components/MermaidZoomToolbar.d.vue.ts +23 -0
  13. package/dist/runtime/components/MermaidZoomToolbar.vue +66 -0
  14. package/dist/runtime/components/MermaidZoomToolbar.vue.d.ts +23 -0
  15. package/dist/runtime/components/icons/IconClipboard.d.vue.ts +8 -0
  16. package/dist/runtime/components/icons/IconClipboard.vue +32 -0
  17. package/dist/runtime/components/icons/IconClipboard.vue.d.ts +8 -0
  18. package/dist/runtime/components/icons/IconClipboardHover.d.vue.ts +8 -0
  19. package/dist/runtime/components/icons/IconClipboardHover.vue +33 -0
  20. package/dist/runtime/components/icons/IconClipboardHover.vue.d.ts +8 -0
  21. package/dist/runtime/components/icons/IconClipboardSuccess.d.vue.ts +8 -0
  22. package/dist/runtime/components/icons/IconClipboardSuccess.vue +33 -0
  23. package/dist/runtime/components/icons/IconClipboardSuccess.vue.d.ts +8 -0
  24. package/dist/runtime/components/icons/IconCollapse.d.vue.ts +8 -0
  25. package/dist/runtime/components/icons/IconCollapse.vue +34 -0
  26. package/dist/runtime/components/icons/IconCollapse.vue.d.ts +8 -0
  27. package/dist/runtime/components/icons/IconExpand.d.vue.ts +8 -0
  28. package/dist/runtime/components/icons/IconExpand.vue +34 -0
  29. package/dist/runtime/components/icons/IconExpand.vue.d.ts +8 -0
  30. package/dist/runtime/components/icons/IconMinus.d.vue.ts +8 -0
  31. package/dist/runtime/components/icons/IconMinus.vue +46 -0
  32. package/dist/runtime/components/icons/IconMinus.vue.d.ts +8 -0
  33. package/dist/runtime/components/icons/IconPlus.d.vue.ts +8 -0
  34. package/dist/runtime/components/icons/IconPlus.vue +57 -0
  35. package/dist/runtime/components/icons/IconPlus.vue.d.ts +8 -0
  36. package/dist/runtime/components/icons/iconFullScreen.d.vue.ts +8 -0
  37. package/dist/runtime/components/icons/iconFullScreen.vue +26 -0
  38. package/dist/runtime/components/icons/iconFullScreen.vue.d.ts +8 -0
  39. package/dist/runtime/components/icons/iconFullScreenExit.d.vue.ts +8 -0
  40. package/dist/runtime/components/icons/iconFullScreenExit.vue +26 -0
  41. package/dist/runtime/components/icons/iconFullScreenExit.vue.d.ts +8 -0
  42. package/dist/runtime/composables/_configurable.d.ts +27 -0
  43. package/dist/runtime/composables/_configurable.js +5 -0
  44. package/dist/runtime/composables/shared/index.d.ts +5 -0
  45. package/dist/runtime/composables/shared/index.js +5 -0
  46. package/dist/runtime/composables/shared/tryOnMounted.d.ts +10 -0
  47. package/dist/runtime/composables/shared/tryOnMounted.js +11 -0
  48. package/dist/runtime/composables/shared/tryOnScopeDispose.d.ts +7 -0
  49. package/dist/runtime/composables/shared/tryOnScopeDispose.js +8 -0
  50. package/dist/runtime/composables/shared/watchImmediate.d.ts +5 -0
  51. package/dist/runtime/composables/shared/watchImmediate.js +11 -0
  52. package/dist/runtime/composables/unrefElement.d.ts +12 -0
  53. package/dist/runtime/composables/unrefElement.js +5 -0
  54. package/dist/runtime/composables/useEventListener.d.ts +73 -0
  55. package/dist/runtime/composables/useEventListener.js +40 -0
  56. package/dist/runtime/composables/useFullscreen.d.ts +25 -0
  57. package/dist/runtime/composables/useFullscreen.js +122 -0
  58. package/dist/runtime/composables/useMermaidCursors.d.ts +12 -0
  59. package/dist/runtime/composables/useMermaidCursors.js +41 -0
  60. package/dist/runtime/composables/useMermaidExpand.d.ts +45 -0
  61. package/dist/runtime/composables/useMermaidExpand.js +480 -0
  62. package/dist/runtime/composables/useMermaidZoom.d.ts +37 -0
  63. package/dist/runtime/composables/useMermaidZoom.js +261 -0
  64. package/dist/runtime/composables/useMounted.d.ts +8 -0
  65. package/dist/runtime/composables/useMounted.js +16 -0
  66. package/dist/runtime/composables/useSupported.d.ts +2 -0
  67. package/dist/runtime/composables/useSupported.js +10 -0
  68. package/dist/runtime/constants.d.ts +4 -0
  69. package/dist/runtime/constants.js +24 -0
  70. package/dist/runtime/mermaid-config.d.ts +1 -1
  71. package/dist/runtime/plugins/mermaid.client.d.ts +2 -2
  72. package/dist/runtime/styles.css +1 -0
  73. package/dist/runtime/types/expand.d.ts +48 -0
  74. package/dist/runtime/types/expand.js +0 -0
  75. package/dist/runtime/types/utils.d.ts +115 -0
  76. package/dist/runtime/types/utils.js +0 -0
  77. package/dist/runtime/utils/index.d.ts +52 -0
  78. package/dist/runtime/utils/index.js +113 -0
  79. package/dist/runtime/utils/is.d.ts +15 -0
  80. package/dist/runtime/utils/is.js +31 -0
  81. package/dist/runtime/utils/mermaid-transform.d.ts +18 -0
  82. package/dist/runtime/utils/mermaid-transform.js +345 -0
  83. package/dist/runtime/utils/vue.d.ts +2 -0
  84. package/dist/runtime/utils/vue.js +4 -0
  85. package/package.json +4 -2
  86. package/dist/runtime/utils.d.ts +0 -1
  87. package/dist/runtime/utils.js +0 -30
@@ -10,12 +10,30 @@ import {
10
10
  shallowRef,
11
11
  useTemplateRef
12
12
  } from "vue";
13
+ import { defu } from "defu";
13
14
  import { mergeMermaidConfig, resolveMermaidTheme } from "../mermaid-config";
14
- import { enqueueRender } from "../utils";
15
+ import { enqueueRender, parseSizeToPx, isRecord } from "../utils";
15
16
  import { useMermaidTheme } from "../composables/useMermaidTheme";
17
+ import { useMermaidExpand } from "../composables/useMermaidExpand";
18
+ import { useMermaidCursors } from "../composables/useMermaidCursors";
19
+ import { useFullscreen } from "../composables/useFullscreen";
20
+ import { useMermaidZoom } from "../composables/useMermaidZoom";
21
+ import { useEventListener } from "../composables/useEventListener";
22
+ import { DEFAULT_TOOLBAR_OPTIONS, DEFAULT_EXPAND_OPTIONS } from "../constants";
16
23
  import Spinner from "./Spinner.vue";
24
+ import IconClipboard from "./icons/IconClipboard.vue";
25
+ import IconClipboardHover from "./icons/IconClipboardHover.vue";
26
+ import IconClipboardSuccess from "./icons/IconClipboardSuccess.vue";
27
+ import IconFullScreen from "./icons/iconFullScreen.vue";
28
+ import IconFullScreenExit from "./icons/iconFullScreenExit.vue";
29
+ import MermaidExpandOverlay from "./MermaidExpandOverlay.vue";
30
+ import IconExpand from "./icons/IconExpand.vue";
31
+ import IconCollapse from "./icons/IconCollapse.vue";
32
+ import MermaidZoomToolbar from "./MermaidZoomToolbar.vue";
33
+ const isMac = import.meta.client ? /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) : false;
17
34
  const props = defineProps({
18
35
  config: { type: null, required: false },
36
+ toolbar: { type: Object, required: false },
19
37
  code: { type: String, required: false }
20
38
  });
21
39
  const nuxtApp = useNuxtApp();
@@ -26,6 +44,24 @@ const debug = contentMermaidOptions.debug || false;
26
44
  const loaderOptions = contentMermaidOptions.loader || {};
27
45
  const themeOptions = contentMermaidOptions.theme || {};
28
46
  const componentOptions = contentMermaidOptions.components || {};
47
+ function resolveExpandOptions(expand) {
48
+ if (expand === false)
49
+ return { ...DEFAULT_EXPAND_OPTIONS, enabled: false };
50
+ if (expand === true || !expand || typeof expand !== "object")
51
+ return DEFAULT_EXPAND_OPTIONS;
52
+ return defu({}, expand, DEFAULT_EXPAND_OPTIONS);
53
+ }
54
+ const expandOptions = resolveExpandOptions(contentMermaidOptions.expand);
55
+ const expandEnabled = expandOptions.enabled !== false;
56
+ const expandOpenOptions = expandOptions.invokeOpenOn || {};
57
+ const expandCloseOptions = expandOptions.invokeCloseOn || {};
58
+ const expandMargin = typeof expandOptions.margin === "number" && Number.isFinite(expandOptions.margin) ? Math.max(0, expandOptions.margin) : 0;
59
+ const allowOpenDiagramClick = expandOpenOptions.diagramClick !== false;
60
+ const allowOverlayClose = expandCloseOptions.overlayClick !== false;
61
+ const allowCloseButtonClose = expandCloseOptions.closeButtonClick !== false;
62
+ const allowEscClose = expandCloseOptions.esc !== false;
63
+ const allowWheelClose = expandCloseOptions.wheel !== false;
64
+ const allowSwipeClose = expandCloseOptions.swipe !== false;
29
65
  const lazyOption = loaderOptions.lazy;
30
66
  const isLazy = lazyOption !== false;
31
67
  const lazyThreshold = typeof lazyOption === "object" && typeof lazyOption.threshold === "number" ? lazyOption.threshold : 0.1;
@@ -35,6 +71,7 @@ const darkTheme = themeOptions.dark;
35
71
  const colorMode = nuxtApp.$colorMode;
36
72
  const { $mermaid } = nuxtApp;
37
73
  const { currentTheme: manualThemeMode } = useMermaidTheme();
74
+ const mermaidBlock = useTemplateRef("mermaidBlock");
38
75
  const mermaidContainer = useTemplateRef("mermaidContainer");
39
76
  const hasRenderedOnce = ref(false);
40
77
  const isLoading = ref(false);
@@ -43,7 +80,81 @@ const errorContent = shallowRef(null);
43
80
  const decodedCode = computed(() => props.code ? decodeURIComponent(props.code) : "");
44
81
  const mermaidDefinition = ref(decodedCode.value);
45
82
  let observer = null;
46
- const frontmatterConfig = computed(() => {
83
+ const defaultToolbarTitle = DEFAULT_TOOLBAR_OPTIONS.title ?? "mermaid";
84
+ const defaultToolbarFontSize = DEFAULT_TOOLBAR_OPTIONS.fontSize ?? "14px";
85
+ const defaultFullscreenToolbarScale = DEFAULT_TOOLBAR_OPTIONS.fullscreenToolbarScale ?? 1.25;
86
+ const baseToolbarDefaults = DEFAULT_TOOLBAR_OPTIONS;
87
+ const runtimeToolbarDefaults = computed(() => {
88
+ const toolbar = contentMermaidOptions.toolbar;
89
+ return toolbar && typeof toolbar === "object" ? toolbar : {};
90
+ });
91
+ const resolvedToolbar = computed(() => {
92
+ return defu({}, props.toolbar ?? {}, runtimeToolbarDefaults.value, baseToolbarDefaults);
93
+ });
94
+ const toolbarButtons = computed(() => {
95
+ const buttons = resolvedToolbar.value.buttons;
96
+ return isRecord(buttons) ? buttons : {};
97
+ });
98
+ const toolbarTitle = computed(() => resolvedToolbar.value.title ?? defaultToolbarTitle);
99
+ const toolbarFontSize = computed(() => {
100
+ const value = resolvedToolbar.value.fontSize ?? defaultToolbarFontSize;
101
+ if (typeof value === "number")
102
+ return `${value}px`;
103
+ if (typeof value === "string") {
104
+ const trimmed = value.trim();
105
+ if (/^\d+(?:\.\d+)?$/.test(trimmed))
106
+ return `${trimmed}px`;
107
+ return trimmed;
108
+ }
109
+ return defaultToolbarFontSize;
110
+ });
111
+ const showCopyButton = computed(() => toolbarButtons.value.copy !== false);
112
+ const copySource = computed(() => decodedCode.value || mermaidDefinition.value || "");
113
+ const hasCopySource = computed(() => !!copySource.value);
114
+ const copyState = ref("idle");
115
+ const copyLabel = computed(() => {
116
+ if (copyState.value === "copied") return "Copied";
117
+ if (copyState.value === "error") return "Copy failed";
118
+ return "Copy";
119
+ });
120
+ let copyResetTimer = null;
121
+ const isCopyHovered = ref(false);
122
+ const copyIcon = computed(() => {
123
+ if (copyState.value === "copied") return IconClipboardSuccess;
124
+ if (isCopyHovered.value && hasCopySource.value) return IconClipboardHover;
125
+ return IconClipboard;
126
+ });
127
+ const { isSupported: isFullscreenSupported, isFullscreen, toggle: toggleFullscreen } = useFullscreen(mermaidBlock, {
128
+ autoExit: true
129
+ });
130
+ const showFullscreenButton = computed(() => toolbarButtons.value.fullscreen !== false && isFullscreenSupported.value);
131
+ const fullscreenLabel = computed(() => isFullscreen.value ? "Exit fullscreen" : "Enter fullscreen");
132
+ const fullscreenToolbarScale = computed(() => {
133
+ const raw = resolvedToolbar.value.fullscreenToolbarScale ?? defaultFullscreenToolbarScale;
134
+ if (typeof raw === "number" && Number.isFinite(raw))
135
+ return raw;
136
+ if (typeof raw === "string") {
137
+ const parsed = Number.parseFloat(raw);
138
+ if (Number.isFinite(parsed))
139
+ return parsed;
140
+ }
141
+ return defaultFullscreenToolbarScale;
142
+ });
143
+ const toolbarFontSizePx = computed(() => {
144
+ return parseSizeToPx(resolvedToolbar.value.fontSize ?? defaultToolbarFontSize, 14);
145
+ });
146
+ const fullscreenToolbarFontSize = computed(() => {
147
+ const scaled = toolbarFontSizePx.value * fullscreenToolbarScale.value;
148
+ return `${Math.round(scaled * 100) / 100}px`;
149
+ });
150
+ const effectiveToolbarFontSize = computed(() => {
151
+ return isFullscreen.value ? fullscreenToolbarFontSize.value : toolbarFontSize.value;
152
+ });
153
+ const effectiveToolbarFontSizePx = computed(() => {
154
+ const baseSize = toolbarFontSizePx.value;
155
+ return isFullscreen.value ? baseSize * fullscreenToolbarScale.value : baseSize;
156
+ });
157
+ const pageConfig = computed(() => {
47
158
  const value = props.config;
48
159
  if (!value)
49
160
  return void 0;
@@ -62,7 +173,7 @@ const mermaidTheme = computed(() => {
62
173
  return resolveMermaidTheme({
63
174
  colorModeValue: colorMode?.value,
64
175
  manualThemeMode: manualThemeMode.value,
65
- frontmatterTheme: frontmatterConfig.value?.theme,
176
+ frontmatterTheme: pageConfig.value?.theme,
66
177
  baseTheme: baseMermaidInit.theme,
67
178
  lightTheme,
68
179
  darkTheme
@@ -71,7 +182,7 @@ const mermaidTheme = computed(() => {
71
182
  const effectiveMermaidInit = computed(() => {
72
183
  return mergeMermaidConfig({
73
184
  baseConfig: baseMermaidInit,
74
- overrideConfig: frontmatterConfig.value,
185
+ overrideConfig: pageConfig.value,
75
186
  theme: mermaidTheme.value
76
187
  });
77
188
  });
@@ -82,6 +193,162 @@ const customMermaidImpl = shallowRef(null);
82
193
  const configuredErrorName = computed(() => componentOptions.error?.trim() || "");
83
194
  const errorComponent = shallowRef(null);
84
195
  const spinnerComponent = computed(() => customSpinner.value || Spinner);
196
+ const iconSize = computed(() => {
197
+ return Math.round(effectiveToolbarFontSizePx.value * 1.2);
198
+ });
199
+ const isExpandBlocked = computed(() => !isEnabled || !expandEnabled || isLoading.value || hasError.value || isFullscreen.value);
200
+ const fullscreenZoom = useMermaidZoom({
201
+ active: isFullscreen,
202
+ minScale: 0.1,
203
+ maxScale: 10
204
+ });
205
+ const showFullscreenZoomHint = ref(false);
206
+ const fullscreenHintDurationMs = 3e3;
207
+ let fullscreenHintTimeout;
208
+ let hasShownFullscreenZoomHint = false;
209
+ watch(isFullscreen, (value) => {
210
+ if (value) {
211
+ hasShownFullscreenZoomHint = false;
212
+ nextTick(() => {
213
+ const el = mermaidContainer.value;
214
+ if (el) {
215
+ const rect = el.getBoundingClientRect();
216
+ fullscreenZoom.init({
217
+ scale: 1,
218
+ translateX: 0,
219
+ translateY: 0,
220
+ top: rect.top,
221
+ left: rect.left
222
+ });
223
+ }
224
+ });
225
+ } else {
226
+ showFullscreenZoomHint.value = false;
227
+ clearTimeout(fullscreenHintTimeout);
228
+ }
229
+ });
230
+ function refreshFullscreenOrigin() {
231
+ if (!import.meta.client || !isFullscreen.value) return;
232
+ const el = mermaidContainer.value;
233
+ if (!el) return;
234
+ requestAnimationFrame(() => {
235
+ requestAnimationFrame(() => {
236
+ const rect = el.getBoundingClientRect();
237
+ fullscreenZoom.setMetrics({
238
+ scale: fullscreenZoom.scale.value,
239
+ translateX: fullscreenZoom.translateX.value,
240
+ translateY: fullscreenZoom.translateY.value,
241
+ top: rect.top,
242
+ left: rect.left
243
+ });
244
+ });
245
+ });
246
+ }
247
+ function handleFullscreenWheel(event) {
248
+ if (!isFullscreen.value) return;
249
+ const handled = fullscreenZoom.handleWheel(event);
250
+ if (handled) {
251
+ if (fullscreenHintTimeout && showFullscreenZoomHint.value) {
252
+ showFullscreenZoomHint.value = false;
253
+ clearTimeout(fullscreenHintTimeout);
254
+ }
255
+ return;
256
+ }
257
+ if (!hasShownFullscreenZoomHint) {
258
+ hasShownFullscreenZoomHint = true;
259
+ showFullscreenZoomHint.value = true;
260
+ clearTimeout(fullscreenHintTimeout);
261
+ fullscreenHintTimeout = setTimeout(() => {
262
+ showFullscreenZoomHint.value = false;
263
+ }, fullscreenHintDurationMs);
264
+ }
265
+ }
266
+ function handleFullscreenKeyDown(event) {
267
+ if (!isFullscreen.value) return;
268
+ if (event.defaultPrevented) return;
269
+ const target = event.target;
270
+ if (target) {
271
+ const tagName = target.tagName;
272
+ if (target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT")
273
+ return;
274
+ }
275
+ const browserZoomKeys = /* @__PURE__ */ new Set(["+", "=", "-", "_"]);
276
+ if (event.ctrlKey || event.metaKey) {
277
+ if (browserZoomKeys.has(event.key))
278
+ event.preventDefault();
279
+ return;
280
+ }
281
+ const moveStep = 20 / fullscreenZoom.scale.value;
282
+ const keyHandlers = {
283
+ "+": () => fullscreenZoom.zoomIn(),
284
+ "=": () => fullscreenZoom.zoomIn(),
285
+ "-": () => fullscreenZoom.zoomOut(),
286
+ "_": () => fullscreenZoom.zoomOut(),
287
+ "0": () => fullscreenZoom.reset(),
288
+ "ArrowUp": () => {
289
+ fullscreenZoom.translateY.value += moveStep;
290
+ },
291
+ "ArrowDown": () => {
292
+ fullscreenZoom.translateY.value -= moveStep;
293
+ },
294
+ "ArrowLeft": () => {
295
+ fullscreenZoom.translateX.value += moveStep;
296
+ },
297
+ "ArrowRight": () => {
298
+ fullscreenZoom.translateX.value -= moveStep;
299
+ }
300
+ };
301
+ const handler = keyHandlers[event.key];
302
+ if (handler) {
303
+ event.preventDefault();
304
+ handler();
305
+ }
306
+ }
307
+ if (import.meta.client) {
308
+ const fullscreenDocument = computed(() => isFullscreen.value ? document : null);
309
+ const fullscreenWindow = computed(() => isFullscreen.value ? window : null);
310
+ const fullscreenVisualViewport = computed(() => isFullscreen.value ? window.visualViewport : null);
311
+ useEventListener(fullscreenWindow, "wheel", handleFullscreenWheel, { passive: false });
312
+ useEventListener(fullscreenDocument, "keydown", handleFullscreenKeyDown);
313
+ useEventListener(fullscreenWindow, "focus", refreshFullscreenOrigin);
314
+ useEventListener(fullscreenDocument, "fullscreenchange", refreshFullscreenOrigin);
315
+ useEventListener(fullscreenVisualViewport, "resize", refreshFullscreenOrigin, { passive: true });
316
+ useEventListener(fullscreenDocument, "visibilitychange", () => {
317
+ if (document.visibilityState === "visible") refreshFullscreenOrigin();
318
+ });
319
+ }
320
+ const {
321
+ setExpandTargetWrap,
322
+ expandTargetStyle,
323
+ isExpandActive,
324
+ isVisible,
325
+ closeExpand,
326
+ toggleExpand,
327
+ handleExpandTransitionEnd,
328
+ handleMermaidClick,
329
+ resetExpand,
330
+ zoom,
331
+ showZoomHint
332
+ } = useMermaidExpand({
333
+ getExpandTarget: getMermaidSvg,
334
+ expandMargin,
335
+ isBlocked: isExpandBlocked,
336
+ invokeOpenOn: {
337
+ diagramClick: allowOpenDiagramClick
338
+ },
339
+ invokeCloseOn: {
340
+ esc: allowEscClose,
341
+ wheel: allowWheelClose,
342
+ swipe: allowSwipeClose
343
+ }
344
+ });
345
+ const showExpandToolbarButton = computed(() => {
346
+ if (!expandEnabled) return false;
347
+ if (toolbarButtons.value.expand === false) return false;
348
+ if (isExpandActive.value) return false;
349
+ if (isFullscreen.value) return false;
350
+ return true;
351
+ });
85
352
  function normalizeIdentifier(value) {
86
353
  return value.replace(/\.vue$/i, "").split(/[\\/]/).pop()?.replace(/[\s_-]+/g, "").toLowerCase() || "";
87
354
  }
@@ -192,11 +459,19 @@ function extractMermaidDefinition(container) {
192
459
  if (pre) return serializeMermaidFromNode(pre).trim();
193
460
  return serializeMermaidFromNode(container).trim();
194
461
  }
462
+ function getMermaidSvg() {
463
+ if (!mermaidContainer.value) return null;
464
+ const svg = mermaidContainer.value.querySelector("svg");
465
+ return svg instanceof SVGSVGElement ? svg : null;
466
+ }
195
467
  async function renderMermaid() {
196
468
  if (!mermaidContainer.value || !mermaidDefinition.value) return;
197
469
  isLoading.value = true;
198
470
  const performRender = async () => {
199
471
  if (!mermaidContainer.value) return;
472
+ if (import.meta.client && isExpandActive.value) {
473
+ resetExpand();
474
+ }
200
475
  hasError.value = false;
201
476
  errorContent.value = null;
202
477
  const startTime = performance.now();
@@ -211,6 +486,8 @@ async function renderMermaid() {
211
486
  suppressErrors: !debug
212
487
  });
213
488
  hasRenderedOnce.value = true;
489
+ const svg = getMermaidSvg();
490
+ if (svg) ensureViewBox(svg);
214
491
  if (debug) {
215
492
  const endTime = performance.now();
216
493
  console.log(`[nuxt-content-mermaid] \u23F1\uFE0F Rendered in ${(endTime - startTime).toFixed(2)}ms`);
@@ -245,6 +522,44 @@ function setupMermaidContainer() {
245
522
  );
246
523
  observer.observe(container);
247
524
  }
525
+ async function copyMermaidSource() {
526
+ if (!copySource.value) return;
527
+ const copied = await copyToClipboard(copySource.value);
528
+ copyState.value = copied ? "copied" : "error";
529
+ if (copyResetTimer)
530
+ clearTimeout(copyResetTimer);
531
+ copyResetTimer = setTimeout(() => {
532
+ copyState.value = "idle";
533
+ }, 2e3);
534
+ }
535
+ async function copyToClipboard(text) {
536
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
537
+ try {
538
+ await navigator.clipboard.writeText(text);
539
+ return true;
540
+ } catch {
541
+ }
542
+ }
543
+ if (typeof document === "undefined")
544
+ return false;
545
+ const textarea = document.createElement("textarea");
546
+ textarea.value = text;
547
+ textarea.setAttribute("readonly", "");
548
+ textarea.style.position = "fixed";
549
+ textarea.style.top = "-9999px";
550
+ textarea.style.opacity = "0";
551
+ document.body.appendChild(textarea);
552
+ textarea.select();
553
+ let copied = false;
554
+ try {
555
+ copied = document.execCommand("copy");
556
+ } catch {
557
+ copied = false;
558
+ } finally {
559
+ document.body.removeChild(textarea);
560
+ }
561
+ return copied;
562
+ }
248
563
  onMounted(() => {
249
564
  if (!isEnabled) return;
250
565
  if (!mermaidDefinition.value && mermaidContainer.value)
@@ -253,13 +568,14 @@ onMounted(() => {
253
568
  });
254
569
  onUnmounted(() => {
255
570
  if (observer) observer.disconnect();
571
+ if (copyResetTimer) clearTimeout(copyResetTimer);
256
572
  });
257
573
  watch(mermaidTheme, () => {
258
574
  if (!isEnabled) return;
259
575
  if (hasRenderedOnce.value) renderMermaid();
260
576
  });
261
577
  watch(
262
- frontmatterConfig,
578
+ pageConfig,
263
579
  () => {
264
580
  if (!isEnabled) return;
265
581
  if (hasRenderedOnce.value) renderMermaid();
@@ -271,6 +587,17 @@ watch(decodedCode, (newCode) => {
271
587
  mermaidDefinition.value = newCode;
272
588
  if (hasRenderedOnce.value) renderMermaid();
273
589
  });
590
+ function ensureViewBox(svg) {
591
+ if (svg.hasAttribute("viewBox"))
592
+ return;
593
+ try {
594
+ const bbox = svg.getBBox();
595
+ if (bbox.width > 0 && bbox.height > 0)
596
+ svg.setAttribute("viewBox", `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`);
597
+ } catch {
598
+ }
599
+ }
600
+ const { cursorVariables } = useMermaidCursors(iconSize, expandEnabled);
274
601
  </script>
275
602
 
276
603
  <template>
@@ -279,7 +606,10 @@ watch(decodedCode, (newCode) => {
279
606
  v-if="!isEnabled"
280
607
  class="mermaid-wrapper"
281
608
  >
282
- <div ref="mermaidContainer">
609
+ <div
610
+ ref="mermaidContainer"
611
+ class="mermaid"
612
+ >
283
613
  <slot />
284
614
  </div>
285
615
  </div>
@@ -297,46 +627,172 @@ watch(decodedCode, (newCode) => {
297
627
 
298
628
  <div
299
629
  v-else
300
- class="mermaid-wrapper"
301
- :class="{ 'ncm-is-loading': isLoading && !hasRenderedOnce }"
630
+ ref="mermaidBlock"
631
+ class="mermaid-block"
632
+ :style="{ '--ncm-icon-size': `${iconSize}px` }"
302
633
  >
303
- <div ref="mermaidContainer">
304
- <!-- Initially show the slot's <pre><code> for SSR; client-side rendering will replace it with the mermaid SVG -->
305
- <slot>
306
- <pre v-if="decodedCode"><code>{{ decodedCode }}</code></pre>
307
- </slot>
634
+ <!-- Toolbar: Title and Actions -->
635
+ <div class="mermaid-toolbar">
636
+ <div
637
+ class="mermaid-title"
638
+ :style="{ fontSize: effectiveToolbarFontSize }"
639
+ >
640
+ <span v-if="toolbarTitle">{{ toolbarTitle }}</span>
641
+ </div>
642
+ <div class="mermaid-actions">
643
+ <button
644
+ v-if="showCopyButton"
645
+ type="button"
646
+ class="mermaid-btn"
647
+ :title="copyLabel"
648
+ :aria-label="copyLabel"
649
+ :disabled="!hasCopySource"
650
+ @click="copyMermaidSource"
651
+ @mouseenter="isCopyHovered = true"
652
+ @mouseleave="isCopyHovered = false"
653
+ >
654
+ <component
655
+ :is="copyIcon"
656
+ :size="iconSize"
657
+ />
658
+ </button>
659
+ <button
660
+ v-if="showExpandToolbarButton"
661
+ type="button"
662
+ class="mermaid-btn"
663
+ :title="isExpandActive ? 'Collapse' : 'Expand'"
664
+ :aria-label="isExpandActive ? 'Collapse diagram' : 'Expand diagram'"
665
+ @click="toggleExpand"
666
+ >
667
+ <IconExpand
668
+ v-if="!isExpandActive"
669
+ :size="iconSize"
670
+ />
671
+ <IconCollapse
672
+ v-else
673
+ :size="iconSize"
674
+ />
675
+ </button>
676
+ <button
677
+ v-if="showFullscreenButton"
678
+ type="button"
679
+ class="mermaid-btn"
680
+ :title="fullscreenLabel"
681
+ :aria-label="fullscreenLabel"
682
+ @click="toggleFullscreen"
683
+ >
684
+ <IconFullScreen
685
+ v-if="!isFullscreen"
686
+ :size="iconSize"
687
+ />
688
+ <IconFullScreenExit
689
+ v-else
690
+ :size="iconSize"
691
+ />
692
+ </button>
693
+ </div>
308
694
  </div>
309
695
 
310
- <template v-if="isLoading && !hasRenderedOnce">
311
- <slot name="loading">
312
- <component :is="spinnerComponent" />
313
- </slot>
314
- </template>
315
- <template v-else-if="hasError">
316
- <slot
317
- name="error"
318
- :error="errorContent"
319
- :source="mermaidDefinition"
696
+ <MermaidZoomToolbar
697
+ v-if="isFullscreen"
698
+ variant="fullscreen"
699
+ :scale="fullscreenZoom.scale.value"
700
+ :icon-size="iconSize"
701
+ @zoom-out="fullscreenZoom.zoomOut()"
702
+ @zoom-in="fullscreenZoom.zoomIn()"
703
+ @reset="fullscreenZoom.reset()"
704
+ />
705
+
706
+ <!-- Fullscreen Zoom Hint -->
707
+ <Transition name="ncm-hint-fade">
708
+ <div
709
+ v-if="showFullscreenZoomHint && isFullscreen"
710
+ class="ncm-fullscreen-zoom-hint"
320
711
  >
321
- <component
322
- :is="errorComponent"
323
- v-if="errorComponent"
712
+ {{ isMac ? "\u2318" : "Ctrl" }} + Scroll to zoom
713
+ </div>
714
+ </Transition>
715
+
716
+ <!-- Diagram Container -->
717
+ <div
718
+ class="mermaid-wrapper"
719
+ :class="{
720
+ 'ncm-is-loading': isLoading && !hasRenderedOnce,
721
+ 'ncm-expand-hidden': isExpandActive,
722
+ 'ncm-expand-clickable': expandEnabled && allowOpenDiagramClick && !isFullscreen,
723
+ 'ncm-fullscreen-zoom': isFullscreen
724
+ }"
725
+ :style="cursorVariables"
726
+ @click="handleMermaidClick"
727
+ @mousedown="isFullscreen && fullscreenZoom.handleDragStart($event)"
728
+ @mousemove="isFullscreen && fullscreenZoom.handleDragMove($event)"
729
+ @touchstart="isFullscreen && fullscreenZoom.handleDragStart($event)"
730
+ @touchmove="isFullscreen && fullscreenZoom.handleDragMove($event)"
731
+ @touchend="isFullscreen && fullscreenZoom.handleDragEnd()"
732
+ >
733
+ <div
734
+ ref="mermaidContainer"
735
+ class="mermaid"
736
+ :style="isFullscreen ? {
737
+ transform: fullscreenZoom.transformStyle.value.transform,
738
+ transformOrigin: '0 0',
739
+ cursor: fullscreenZoom.cursor.value
740
+ } : void 0"
741
+ >
742
+ <!-- Initially show the slot's <pre><code> for SSR; client-side rendering will replace it with the mermaid SVG -->
743
+ <slot>
744
+ <pre v-if="decodedCode"><code>{{ decodedCode }}</code></pre>
745
+ </slot>
746
+ </div>
747
+
748
+ <template v-if="isLoading && !hasRenderedOnce">
749
+ <slot name="loading">
750
+ <component :is="spinnerComponent" />
751
+ </slot>
752
+ </template>
753
+ <template v-else-if="hasError">
754
+ <slot
755
+ name="error"
324
756
  :error="errorContent"
325
757
  :source="mermaidDefinition"
326
- />
327
-
328
- <div
329
- v-else
330
- class="mermaid-error-default"
331
758
  >
332
- ⚠️ Mermaid Diagram Error
333
- </div>
334
- </slot>
335
- </template>
759
+ <component
760
+ :is="errorComponent"
761
+ v-if="errorComponent"
762
+ :error="errorContent"
763
+ :source="mermaidDefinition"
764
+ />
765
+
766
+ <div
767
+ v-else
768
+ class="mermaid-error-default"
769
+ >
770
+ ⚠️ Mermaid Diagram Error
771
+ </div>
772
+ </slot>
773
+ </template>
774
+ </div>
775
+
776
+ <MermaidExpandOverlay
777
+ :active="isExpandActive"
778
+ :is-visable="isVisible"
779
+ :overlay-style="cursorVariables"
780
+ :content-style="cursorVariables"
781
+ :target-style="expandTargetStyle"
782
+ :target-ref="setExpandTargetWrap"
783
+ :allow-overlay-close="allowOverlayClose"
784
+ :allow-close-button="allowCloseButtonClose"
785
+ :target-has-margin="expandMargin > 0"
786
+ :icon-size="iconSize"
787
+ :zoom="zoom"
788
+ :show-hint="showZoomHint"
789
+ @close="closeExpand"
790
+ @transitionend="handleExpandTransitionEnd"
791
+ />
336
792
  </div>
337
793
  </div>
338
794
  </template>
339
795
 
340
796
  <style scoped>
341
- .mermaid-wrapper{--ncm-loading-min-height:160px;position:relative;text-align:center}.mermaid-wrapper.ncm-is-loading{min-height:var(--ncm-loading-min-height,160px)}.mermaid-wrapper :deep(code),.mermaid-wrapper :deep(pre){height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);border:0;clip-path:inset(50%);white-space:nowrap}.mermaid-wrapper :deep(svg){display:inline-block;vertical-align:middle}.mermaid-error-default{text-align:center}.mermaid:not([data-processed]){color:transparent;min-height:10px}.mermaid-wrapper :deep(.edgeLabel),.mermaid-wrapper :deep(.label),.mermaid-wrapper :deep(.nodeLabel){overflow:visible!important;white-space:normal!important}.mermaid-wrapper :deep(foreignObject){overflow:visible!important}
797
+ .mermaid-block{border:var(--ncm-border);border-radius:4px;flex-direction:column;margin:1rem 0;overflow:hidden;--ncm-toolbar-height:calc(var(--ncm-icon-size, 17px) + 20px)}.mermaid-block,.mermaid-toolbar{background-color:var(--ncm-code-bg);display:flex}.mermaid-toolbar{align-items:center;border-bottom:var(--ncm-border-bottom);border-top-left-radius:4px;border-top-right-radius:4px;justify-content:space-between;padding:.375rem .375rem .375rem .75rem;z-index:20}.mermaid-title{color:var(--ncm-text-muted);font-weight:500;letter-spacing:0;line-height:1.25;max-width:80%;overflow:hidden;padding:4px 0;text-overflow:ellipsis;white-space:nowrap}:fullscreen .mermaid-title{max-width:80%}.mermaid-actions{display:flex;gap:0;justify-self:flex-end}.mermaid-btn{align-items:center;background:var(--ncm-code-bg);border:none;border-radius:4px;color:var(--ncm-text-xmuted);cursor:pointer;display:flex;justify-content:center;line-height:1.25;padding:4px;transition:color .2s,background-color .2s}.mermaid-btn:hover{background-color:var(--ncm-code-bg-hover);color:var(--ncm-text)}.mermaid-btn:disabled{color:var(--ncm-text-xmuted);cursor:not-allowed;opacity:.6}.mermaid-wrapper{align-items:center;display:flex;justify-content:center;overflow:auto;padding:1rem;position:relative;--sb-thumb:rgba(0,0,0,.2);--sb-thumb-hover:rgba(0,0,0,.35)}.mermaid-wrapper>div{height:100%;width:100%}.mermaid-wrapper>div>:deep(svg){margin:0 auto}.mermaid-wrapper :deep(code),.mermaid-wrapper :deep(pre){height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);border:0;clip-path:inset(50%);white-space:nowrap}.mermaid:not([data-processed]){color:transparent;min-height:100px}:fullscreen .mermaid-wrapper{flex:1;overflow:hidden;touch-action:none}:fullscreen .mermaid-wrapper::-webkit-scrollbar{display:none}:fullscreen .mermaid-wrapper :deep(svg){height:100%!important;max-height:none!important;max-width:none!important;width:100%!important}:global(.dark .mermaid-wrapper){--sb-thumb:hsla(0,0%,100%,.2);--sb-thumb-hover:hsla(0,0%,100%,.35)}.mermaid-wrapper::-webkit-scrollbar{width:14px}.mermaid-wrapper::-webkit-scrollbar-track{background:hsla(0,0%,94%,.08)}:global(.dark .mermaid-wrapper::-webkit-scrollbar-track){background:hsla(0,0%,9%,.08)}.mermaid-wrapper::-webkit-scrollbar-corner{background:transparent}.mermaid-wrapper::-webkit-scrollbar-thumb{background-clip:content-box;background-color:var(--sb-thumb);border:3px solid transparent;border-radius:10px}.mermaid-wrapper::-webkit-scrollbar-thumb:hover{background-color:var(--sb-thumb-hover)}.mermaid-wrapper.ncm-is-loading{align-items:center;display:flex;justify-content:center;min-height:150px}.mermaid-wrapper.ncm-expand-hidden{opacity:0;pointer-events:none}.mermaid-wrapper.ncm-expand-clickable :deep(svg){cursor:var(--ncm-cursor-expand)}.ncm-fullscreen-zoom-hint{display:none}:fullscreen .ncm-fullscreen-zoom-hint{background-color:var(--ncm-hint-bg,rgba(0,0,0,.75));border-radius:var(--ncm-hint-radius,8px);color:var(--ncm-hint-text,#fff);display:block;font-size:20px;font-weight:500;left:50%;padding:12px 24px;pointer-events:none;position:absolute;top:50%;transform:translate(-50%,-50%);-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;z-index:20}.ncm-hint-fade-enter-active,.ncm-hint-fade-leave-active{transition:opacity .2s ease}.ncm-hint-fade-enter-from,.ncm-hint-fade-leave-to{opacity:0}@media (prefers-reduced-motion:reduce){.ncm-hint-fade-enter-active,.ncm-hint-fade-leave-active{transition-duration:.01ms!important}}.mermaid-error-default{color:#f55;font-family:monospace;padding:1rem;white-space:pre-wrap}
342
798
  </style>
@@ -1,9 +1,11 @@
1
1
  import type { MermaidConfig } from 'mermaid';
2
+ import type { MermaidToolbarOptions } from '~/src/types/mermaid';
2
3
  type __VLS_Props = {
3
4
  config?: MermaidConfig | string | unknown;
5
+ toolbar?: MermaidToolbarOptions;
4
6
  code?: string;
5
7
  };
6
- declare var __VLS_1: {}, __VLS_9: {}, __VLS_11: {}, __VLS_13: {}, __VLS_21: {
8
+ declare var __VLS_1: {}, __VLS_9: {}, __VLS_52: {}, __VLS_54: {}, __VLS_62: {
7
9
  error: unknown;
8
10
  source: string;
9
11
  };
@@ -12,11 +14,11 @@ type __VLS_Slots = {} & {
12
14
  } & {
13
15
  default?: (props: typeof __VLS_9) => any;
14
16
  } & {
15
- default?: (props: typeof __VLS_11) => any;
17
+ default?: (props: typeof __VLS_52) => any;
16
18
  } & {
17
- loading?: (props: typeof __VLS_13) => any;
19
+ loading?: (props: typeof __VLS_54) => any;
18
20
  } & {
19
- error?: (props: typeof __VLS_21) => any;
21
+ error?: (props: typeof __VLS_62) => any;
20
22
  };
21
23
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
22
24
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;