@windrun-huaiin/third-ui 13.1.2 → 13.1.3

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.
@@ -4,8 +4,10 @@
4
4
  var tslib_es6 = require('../../node_modules/.pnpm/@rollup_plugin-typescript@12.1.4_rollup@4.46.2_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.js');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
  var server = require('@windrun-huaiin/base-ui/components/server');
7
+ var utils = require('@windrun-huaiin/lib/utils');
7
8
  var nextThemes = require('next-themes');
8
9
  var React = require('react');
10
+ var lib = require('@windrun-huaiin/base-ui/lib');
9
11
 
10
12
  function sanitizeFilename(name) {
11
13
  return name
@@ -24,6 +26,9 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
24
26
  const isPanningRef = React.useRef(false);
25
27
  const startPointRef = React.useRef({ x: 0, y: 0 });
26
28
  const startTranslateRef = React.useRef({ x: 0, y: 0 });
29
+ const activePointersRef = React.useRef(new Map());
30
+ const pinchStartDistanceRef = React.useRef(0);
31
+ const pinchStartScaleRef = React.useRef(1);
27
32
  React.useEffect(() => {
28
33
  let isMounted = true;
29
34
  void renderChart();
@@ -42,7 +47,7 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
42
47
  const { svg } = yield mermaid.render(id.replaceAll(':', ''), chart.replaceAll('\\n', '\n'));
43
48
  let svgWithWatermark = svg;
44
49
  if (watermarkEnabled && watermarkText) {
45
- svgWithWatermark = addWatermarkToSvg(svg, watermarkText);
50
+ svgWithWatermark = addWatermarkToSvg(svg, watermarkText, lib.themeSvgIconColor);
46
51
  }
47
52
  if (isMounted)
48
53
  setSvg(svgWithWatermark);
@@ -83,12 +88,32 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
83
88
  }
84
89
  }, []);
85
90
  const onPointerDown = React.useCallback((e) => {
86
- isPanningRef.current = true;
87
- startPointRef.current = { x: e.clientX, y: e.clientY };
88
- startTranslateRef.current = Object.assign({}, translate);
91
+ activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
92
+ if (activePointersRef.current.size === 2) {
93
+ const [first, second] = Array.from(activePointersRef.current.values());
94
+ pinchStartDistanceRef.current = Math.hypot(second.x - first.x, second.y - first.y);
95
+ pinchStartScaleRef.current = scale;
96
+ isPanningRef.current = false;
97
+ }
98
+ else {
99
+ isPanningRef.current = true;
100
+ startPointRef.current = { x: e.clientX, y: e.clientY };
101
+ startTranslateRef.current = Object.assign({}, translate);
102
+ }
89
103
  e.currentTarget.setPointerCapture(e.pointerId);
90
- }, [translate]);
104
+ }, [scale, translate]);
91
105
  const onPointerMove = React.useCallback((e) => {
106
+ if (!activePointersRef.current.has(e.pointerId))
107
+ return;
108
+ activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
109
+ if (activePointersRef.current.size === 2) {
110
+ const [first, second] = Array.from(activePointersRef.current.values());
111
+ const distance = Math.hypot(second.x - first.x, second.y - first.y);
112
+ if (pinchStartDistanceRef.current > 0) {
113
+ setScale(clamp((distance / pinchStartDistanceRef.current) * pinchStartScaleRef.current, 0.25, 10));
114
+ }
115
+ return;
116
+ }
92
117
  if (!isPanningRef.current)
93
118
  return;
94
119
  const dx = e.clientX - startPointRef.current.x;
@@ -96,8 +121,24 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
96
121
  setTranslate({ x: startTranslateRef.current.x + dx, y: startTranslateRef.current.y + dy });
97
122
  }, []);
98
123
  const onPointerUp = React.useCallback((e) => {
124
+ activePointersRef.current.delete(e.pointerId);
125
+ isPanningRef.current = false;
126
+ if (activePointersRef.current.size === 1) {
127
+ const remaining = Array.from(activePointersRef.current.values())[0];
128
+ startPointRef.current = remaining;
129
+ startTranslateRef.current = Object.assign({}, translate);
130
+ isPanningRef.current = true;
131
+ }
132
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
133
+ e.currentTarget.releasePointerCapture(e.pointerId);
134
+ }
135
+ }, [translate]);
136
+ const onPointerCancel = React.useCallback((e) => {
137
+ activePointersRef.current.delete(e.pointerId);
99
138
  isPanningRef.current = false;
100
- e.currentTarget.releasePointerCapture(e.pointerId);
139
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
140
+ e.currentTarget.releasePointerCapture(e.pointerId);
141
+ }
101
142
  }, []);
102
143
  const handleDownload = React.useCallback(() => {
103
144
  if (!svg)
@@ -172,9 +213,9 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
172
213
  window.scrollTo(0, scrollY);
173
214
  };
174
215
  }, [open]);
175
- return (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: enablePreview ? 'group relative cursor-zoom-in' : undefined, onClick: () => enablePreview && svg && setOpen(true), children: [jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: svg } }), enablePreview && svg && (jsxRuntime.jsx("div", { className: "pointer-events-none absolute right-2 top-2 hidden rounded bg-black/50 px-2 py-0.5 text-[12px] text-white group-hover:block", children: "Preview Chart" }))] }), title && (jsxRuntime.jsxs("div", { className: "mt-2 flex items-center justify-center text-center text-[13px] font-italic text-[#AC62FD]", children: [jsxRuntime.jsx(server.globalLucideIcons.Mmd, { className: 'mr-1 h-4 w-4' }), jsxRuntime.jsx("span", { children: title })] })), enablePreview && open && (jsxRuntime.jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": typeof title === 'string' ? title : 'Mermaid Preview', className: "fixed inset-0 z-9999 flex items-center justify-center", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 bg-black/60", onClick: () => { setOpen(false); resetTransform(); }, onWheel: (e) => { e.preventDefault(); e.stopPropagation(); }, onTouchMove: (e) => { e.preventDefault(); e.stopPropagation(); } }), jsxRuntime.jsxs("div", { className: "relative z-1 max-w-[95vw] w-[95vw] h-[88vh] p-0 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-md shadow-2xl overflow-hidden", children: [jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-3 py-2 border-b border-neutral-200 dark:border-neutral-700", children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300", children: [jsxRuntime.jsx(server.globalLucideIcons.Mmd, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "truncate max-w-[50vw]", children: title !== null && title !== void 0 ? title : 'Mermaid Preview' })] }), jsxRuntime.jsxs("div", { className: "flex items-center gap-0.5", children: [jsxRuntime.jsx("button", { "aria-label": "Zoom out", className: "flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => zoomBy(-0.5), children: "\uFF0D" }), jsxRuntime.jsxs("span", { className: "mx-0.5 text-[12px] w-12 text-center select-none", children: [Math.round(scale * 100), "%"] }), jsxRuntime.jsx("button", { "aria-label": "Zoom in", className: "flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => zoomBy(0.5), children: "\uFF0B" }), jsxRuntime.jsx("div", { className: "mx-1 h-4 w-px bg-neutral-300 dark:bg-neutral-700" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 100%", className: "inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(1), children: "X1" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 200%", className: "ml-1 inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(2), children: "X2" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 300%", className: "ml-1 inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(3), children: "X3" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 1000%", className: "ml-1 inline-flex h-6 min-w-10 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(10), children: "X10" }), jsxRuntime.jsx("button", { "aria-label": "Reset", className: "ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30", onClick: resetTransform, children: jsxRuntime.jsx(server.globalLucideIcons.RefreshCcw, { className: "h-3.5 w-3.5" }) }), jsxRuntime.jsx("button", { "aria-label": "Download SVG", className: "ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30", onClick: handleDownload, children: jsxRuntime.jsx(server.globalLucideIcons.Download, { className: "h-3.5 w-3.5" }) }), jsxRuntime.jsx("button", { "aria-label": "Close", className: "ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30", onClick: () => { setOpen(false); resetTransform(); }, children: jsxRuntime.jsx(server.globalLucideIcons.X, { className: "h-3.5 w-3.5" }) })] })] }), jsxRuntime.jsxs("div", { className: "relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900 touch-none overscroll-contain", onWheel: onWheel, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, children: [jsxRuntime.jsx("div", { className: "absolute left-1/2 top-1/2", style: { transform: `translate(-50%, -50%) translate(${translate.x}px, ${translate.y}px)` }, children: jsxRuntime.jsx("div", { style: { transform: `scale(${scale})`, transformOrigin: '50% 50%' }, dangerouslySetInnerHTML: { __html: svg } }) }), jsxRuntime.jsx("div", { className: "pointer-events-none absolute bottom-2 right-3 rounded bg-black/40 px-2 py-1 text-xs text-white", children: "Drag to pan, hold Cmd/Ctrl + scroll to zoom" })] })] })] }))] }));
216
+ return (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: enablePreview ? 'group relative cursor-zoom-in' : undefined, onClick: () => enablePreview && svg && setOpen(true), children: [jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: svg } }), enablePreview && svg && (jsxRuntime.jsx("div", { className: "pointer-events-none absolute right-2 top-2 hidden rounded bg-black/50 px-2 py-0.5 text-[12px] text-white group-hover:block", children: "Preview Chart" }))] }), title && (jsxRuntime.jsxs("div", { className: utils.cn("mt-2 flex items-center justify-center text-center text-[13px] font-italic", lib.themeIconColor), children: [jsxRuntime.jsx(server.globalLucideIcons.Mmd, { className: 'mr-1 h-4 w-4' }), jsxRuntime.jsx("span", { children: title })] })), enablePreview && open && (jsxRuntime.jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": typeof title === 'string' ? title : 'Mermaid Preview', className: "fixed inset-0 z-9999 flex items-center justify-center", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 bg-black/60", onClick: () => { setOpen(false); resetTransform(); }, onWheel: (e) => { e.preventDefault(); e.stopPropagation(); }, onTouchMove: (e) => { e.preventDefault(); e.stopPropagation(); } }), jsxRuntime.jsxs("div", { className: "relative z-1 max-w-[95vw] w-[95vw] h-[88vh] p-0 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-md shadow-2xl overflow-hidden", children: [jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-3 px-3 py-2 border-b border-neutral-200 dark:border-neutral-700", children: [jsxRuntime.jsxs("div", { className: utils.cn("min-w-0 flex items-center gap-2 text-sm", lib.themeIconColor), children: [jsxRuntime.jsx(server.globalLucideIcons.Mmd, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "truncate max-w-[50vw]", children: title !== null && title !== void 0 ? title : 'Mermaid Preview' })] }), jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-0.5", children: [jsxRuntime.jsx("button", { "aria-label": "Zoom out", className: "hidden h-6 w-6 items-center justify-center rounded border border-neutral-300 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:flex", onClick: () => zoomBy(-0.5), children: "\uFF0D" }), jsxRuntime.jsxs("span", { className: "mx-0.5 w-12 text-center text-[12px] select-none", children: [Math.round(scale * 100), "%"] }), jsxRuntime.jsx("button", { "aria-label": "Zoom in", className: "hidden h-6 w-6 items-center justify-center rounded border border-neutral-300 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:flex", onClick: () => zoomBy(0.5), children: "\uFF0B" }), jsxRuntime.jsx("div", { className: "mx-1 hidden h-4 w-px bg-neutral-300 dark:bg-neutral-700 sm:block" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 100%", className: "hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(1), children: "X1" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 200%", className: "ml-1 hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(2), children: "X2" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 300%", className: "ml-1 hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(3), children: "X3" }), jsxRuntime.jsx("button", { "aria-label": "Zoom 1000%", className: "ml-1 hidden h-6 min-w-10 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(10), children: "X10" }), jsxRuntime.jsx("button", { "aria-label": "Reset", className: utils.cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", lib.themeIconColor), onClick: resetTransform, children: jsxRuntime.jsx(server.globalLucideIcons.RefreshCcw, { className: "h-3.5 w-3.5" }) }), jsxRuntime.jsx("button", { "aria-label": "Download SVG", className: utils.cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", lib.themeIconColor), onClick: handleDownload, children: jsxRuntime.jsx(server.globalLucideIcons.Download, { className: "h-3.5 w-3.5" }) }), jsxRuntime.jsx("button", { "aria-label": "Close", className: utils.cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", lib.themeIconColor), onClick: () => { setOpen(false); resetTransform(); }, children: jsxRuntime.jsx(server.globalLucideIcons.X, { className: "h-3.5 w-3.5" }) })] })] }), jsxRuntime.jsxs("div", { className: "relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900 overscroll-contain touch-none", onWheel: onWheel, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, children: [jsxRuntime.jsx("div", { className: "absolute left-1/2 top-1/2", style: { transform: `translate(-50%, -50%) translate(${translate.x}px, ${translate.y}px)` }, children: jsxRuntime.jsx("div", { style: { transform: `scale(${scale})`, transformOrigin: '50% 50%' }, dangerouslySetInnerHTML: { __html: svg } }) }), jsxRuntime.jsxs("div", { className: "absolute inset-x-3 bottom-3 rounded-md bg-white/92 px-3 py-2 shadow-sm backdrop-blur sm:hidden dark:bg-neutral-900/92", children: [jsxRuntime.jsxs("label", { className: "mb-1 flex items-center justify-between text-[11px] text-neutral-600 dark:text-neutral-300", children: [jsxRuntime.jsx("span", { children: "Zoom" }), jsxRuntime.jsxs("span", { children: [Math.round(scale * 100), "%"] })] }), jsxRuntime.jsx("input", { "aria-label": "Zoom slider", className: "block w-full", type: "range", min: "25", max: "1000", step: "5", value: Math.round(scale * 100), style: { accentColor: lib.themeSvgIconColor }, onChange: (e) => setScale(clamp(Number(e.target.value) / 100, 0.25, 10)) })] }), jsxRuntime.jsx("div", { className: "pointer-events-none absolute bottom-2 right-3 hidden rounded bg-black/40 px-2 py-1 text-xs text-white sm:block", children: "Drag to pan, click button to zoom-out or zoom-in" }), jsxRuntime.jsx("div", { className: "pointer-events-none absolute left-3 top-3 rounded bg-black/40 px-2 py-1 text-[11px] text-white sm:hidden", children: "Drag to pan, pinch to zoom-out or zoom-in" })] })] })] }))] }));
176
217
  }
177
- function addWatermarkToSvg(svg, watermark) {
218
+ function addWatermarkToSvg(svg, watermark, watermarkColor) {
178
219
  const watermarkText = `
179
220
  <text
180
221
  x="100%"
@@ -182,7 +223,7 @@ function addWatermarkToSvg(svg, watermark) {
182
223
  text-anchor="end"
183
224
  font-size="12"
184
225
  font-style="italic"
185
- fill="#AC62FD"
226
+ fill="${watermarkColor}"
186
227
  opacity="0.40"
187
228
  class="pointer-events-none"
188
229
  dx="-8"
@@ -2,8 +2,10 @@
2
2
  import { __awaiter } from '../../node_modules/.pnpm/@rollup_plugin-typescript@12.1.4_rollup@4.46.2_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.mjs';
3
3
  import { jsxs, jsx } from 'react/jsx-runtime';
4
4
  import { globalLucideIcons } from '@windrun-huaiin/base-ui/components/server';
5
+ import { cn } from '@windrun-huaiin/lib/utils';
5
6
  import { useTheme } from 'next-themes';
6
7
  import { useId, useState, useRef, useEffect, useCallback } from 'react';
8
+ import { themeIconColor, themeSvgIconColor } from '@windrun-huaiin/base-ui/lib';
7
9
 
8
10
  function sanitizeFilename(name) {
9
11
  return name
@@ -22,6 +24,9 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
22
24
  const isPanningRef = useRef(false);
23
25
  const startPointRef = useRef({ x: 0, y: 0 });
24
26
  const startTranslateRef = useRef({ x: 0, y: 0 });
27
+ const activePointersRef = useRef(new Map());
28
+ const pinchStartDistanceRef = useRef(0);
29
+ const pinchStartScaleRef = useRef(1);
25
30
  useEffect(() => {
26
31
  let isMounted = true;
27
32
  void renderChart();
@@ -40,7 +45,7 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
40
45
  const { svg } = yield mermaid.render(id.replaceAll(':', ''), chart.replaceAll('\\n', '\n'));
41
46
  let svgWithWatermark = svg;
42
47
  if (watermarkEnabled && watermarkText) {
43
- svgWithWatermark = addWatermarkToSvg(svg, watermarkText);
48
+ svgWithWatermark = addWatermarkToSvg(svg, watermarkText, themeSvgIconColor);
44
49
  }
45
50
  if (isMounted)
46
51
  setSvg(svgWithWatermark);
@@ -81,12 +86,32 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
81
86
  }
82
87
  }, []);
83
88
  const onPointerDown = useCallback((e) => {
84
- isPanningRef.current = true;
85
- startPointRef.current = { x: e.clientX, y: e.clientY };
86
- startTranslateRef.current = Object.assign({}, translate);
89
+ activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
90
+ if (activePointersRef.current.size === 2) {
91
+ const [first, second] = Array.from(activePointersRef.current.values());
92
+ pinchStartDistanceRef.current = Math.hypot(second.x - first.x, second.y - first.y);
93
+ pinchStartScaleRef.current = scale;
94
+ isPanningRef.current = false;
95
+ }
96
+ else {
97
+ isPanningRef.current = true;
98
+ startPointRef.current = { x: e.clientX, y: e.clientY };
99
+ startTranslateRef.current = Object.assign({}, translate);
100
+ }
87
101
  e.currentTarget.setPointerCapture(e.pointerId);
88
- }, [translate]);
102
+ }, [scale, translate]);
89
103
  const onPointerMove = useCallback((e) => {
104
+ if (!activePointersRef.current.has(e.pointerId))
105
+ return;
106
+ activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
107
+ if (activePointersRef.current.size === 2) {
108
+ const [first, second] = Array.from(activePointersRef.current.values());
109
+ const distance = Math.hypot(second.x - first.x, second.y - first.y);
110
+ if (pinchStartDistanceRef.current > 0) {
111
+ setScale(clamp((distance / pinchStartDistanceRef.current) * pinchStartScaleRef.current, 0.25, 10));
112
+ }
113
+ return;
114
+ }
90
115
  if (!isPanningRef.current)
91
116
  return;
92
117
  const dx = e.clientX - startPointRef.current.x;
@@ -94,8 +119,24 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
94
119
  setTranslate({ x: startTranslateRef.current.x + dx, y: startTranslateRef.current.y + dy });
95
120
  }, []);
96
121
  const onPointerUp = useCallback((e) => {
122
+ activePointersRef.current.delete(e.pointerId);
123
+ isPanningRef.current = false;
124
+ if (activePointersRef.current.size === 1) {
125
+ const remaining = Array.from(activePointersRef.current.values())[0];
126
+ startPointRef.current = remaining;
127
+ startTranslateRef.current = Object.assign({}, translate);
128
+ isPanningRef.current = true;
129
+ }
130
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
131
+ e.currentTarget.releasePointerCapture(e.pointerId);
132
+ }
133
+ }, [translate]);
134
+ const onPointerCancel = useCallback((e) => {
135
+ activePointersRef.current.delete(e.pointerId);
97
136
  isPanningRef.current = false;
98
- e.currentTarget.releasePointerCapture(e.pointerId);
137
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
138
+ e.currentTarget.releasePointerCapture(e.pointerId);
139
+ }
99
140
  }, []);
100
141
  const handleDownload = useCallback(() => {
101
142
  if (!svg)
@@ -170,9 +211,9 @@ function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview
170
211
  window.scrollTo(0, scrollY);
171
212
  };
172
213
  }, [open]);
173
- return (jsxs("div", { children: [jsxs("div", { className: enablePreview ? 'group relative cursor-zoom-in' : undefined, onClick: () => enablePreview && svg && setOpen(true), children: [jsx("div", { dangerouslySetInnerHTML: { __html: svg } }), enablePreview && svg && (jsx("div", { className: "pointer-events-none absolute right-2 top-2 hidden rounded bg-black/50 px-2 py-0.5 text-[12px] text-white group-hover:block", children: "Preview Chart" }))] }), title && (jsxs("div", { className: "mt-2 flex items-center justify-center text-center text-[13px] font-italic text-[#AC62FD]", children: [jsx(globalLucideIcons.Mmd, { className: 'mr-1 h-4 w-4' }), jsx("span", { children: title })] })), enablePreview && open && (jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": typeof title === 'string' ? title : 'Mermaid Preview', className: "fixed inset-0 z-9999 flex items-center justify-center", children: [jsx("div", { className: "absolute inset-0 bg-black/60", onClick: () => { setOpen(false); resetTransform(); }, onWheel: (e) => { e.preventDefault(); e.stopPropagation(); }, onTouchMove: (e) => { e.preventDefault(); e.stopPropagation(); } }), jsxs("div", { className: "relative z-1 max-w-[95vw] w-[95vw] h-[88vh] p-0 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-md shadow-2xl overflow-hidden", children: [jsxs("div", { className: "flex items-center justify-between px-3 py-2 border-b border-neutral-200 dark:border-neutral-700", children: [jsxs("div", { className: "flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300", children: [jsx(globalLucideIcons.Mmd, { className: "h-4 w-4" }), jsx("span", { className: "truncate max-w-[50vw]", children: title !== null && title !== void 0 ? title : 'Mermaid Preview' })] }), jsxs("div", { className: "flex items-center gap-0.5", children: [jsx("button", { "aria-label": "Zoom out", className: "flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => zoomBy(-0.5), children: "\uFF0D" }), jsxs("span", { className: "mx-0.5 text-[12px] w-12 text-center select-none", children: [Math.round(scale * 100), "%"] }), jsx("button", { "aria-label": "Zoom in", className: "flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => zoomBy(0.5), children: "\uFF0B" }), jsx("div", { className: "mx-1 h-4 w-px bg-neutral-300 dark:bg-neutral-700" }), jsx("button", { "aria-label": "Zoom 100%", className: "inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(1), children: "X1" }), jsx("button", { "aria-label": "Zoom 200%", className: "ml-1 inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(2), children: "X2" }), jsx("button", { "aria-label": "Zoom 300%", className: "ml-1 inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(3), children: "X3" }), jsx("button", { "aria-label": "Zoom 1000%", className: "ml-1 inline-flex h-6 min-w-10 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400", onClick: () => setScale(10), children: "X10" }), jsx("button", { "aria-label": "Reset", className: "ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30", onClick: resetTransform, children: jsx(globalLucideIcons.RefreshCcw, { className: "h-3.5 w-3.5" }) }), jsx("button", { "aria-label": "Download SVG", className: "ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30", onClick: handleDownload, children: jsx(globalLucideIcons.Download, { className: "h-3.5 w-3.5" }) }), jsx("button", { "aria-label": "Close", className: "ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30", onClick: () => { setOpen(false); resetTransform(); }, children: jsx(globalLucideIcons.X, { className: "h-3.5 w-3.5" }) })] })] }), jsxs("div", { className: "relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900 touch-none overscroll-contain", onWheel: onWheel, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, children: [jsx("div", { className: "absolute left-1/2 top-1/2", style: { transform: `translate(-50%, -50%) translate(${translate.x}px, ${translate.y}px)` }, children: jsx("div", { style: { transform: `scale(${scale})`, transformOrigin: '50% 50%' }, dangerouslySetInnerHTML: { __html: svg } }) }), jsx("div", { className: "pointer-events-none absolute bottom-2 right-3 rounded bg-black/40 px-2 py-1 text-xs text-white", children: "Drag to pan, hold Cmd/Ctrl + scroll to zoom" })] })] })] }))] }));
214
+ return (jsxs("div", { children: [jsxs("div", { className: enablePreview ? 'group relative cursor-zoom-in' : undefined, onClick: () => enablePreview && svg && setOpen(true), children: [jsx("div", { dangerouslySetInnerHTML: { __html: svg } }), enablePreview && svg && (jsx("div", { className: "pointer-events-none absolute right-2 top-2 hidden rounded bg-black/50 px-2 py-0.5 text-[12px] text-white group-hover:block", children: "Preview Chart" }))] }), title && (jsxs("div", { className: cn("mt-2 flex items-center justify-center text-center text-[13px] font-italic", themeIconColor), children: [jsx(globalLucideIcons.Mmd, { className: 'mr-1 h-4 w-4' }), jsx("span", { children: title })] })), enablePreview && open && (jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": typeof title === 'string' ? title : 'Mermaid Preview', className: "fixed inset-0 z-9999 flex items-center justify-center", children: [jsx("div", { className: "absolute inset-0 bg-black/60", onClick: () => { setOpen(false); resetTransform(); }, onWheel: (e) => { e.preventDefault(); e.stopPropagation(); }, onTouchMove: (e) => { e.preventDefault(); e.stopPropagation(); } }), jsxs("div", { className: "relative z-1 max-w-[95vw] w-[95vw] h-[88vh] p-0 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-md shadow-2xl overflow-hidden", children: [jsxs("div", { className: "flex items-center justify-between gap-3 px-3 py-2 border-b border-neutral-200 dark:border-neutral-700", children: [jsxs("div", { className: cn("min-w-0 flex items-center gap-2 text-sm", themeIconColor), children: [jsx(globalLucideIcons.Mmd, { className: "h-4 w-4" }), jsx("span", { className: "truncate max-w-[50vw]", children: title !== null && title !== void 0 ? title : 'Mermaid Preview' })] }), jsxs("div", { className: "flex shrink-0 items-center gap-0.5", children: [jsx("button", { "aria-label": "Zoom out", className: "hidden h-6 w-6 items-center justify-center rounded border border-neutral-300 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:flex", onClick: () => zoomBy(-0.5), children: "\uFF0D" }), jsxs("span", { className: "mx-0.5 w-12 text-center text-[12px] select-none", children: [Math.round(scale * 100), "%"] }), jsx("button", { "aria-label": "Zoom in", className: "hidden h-6 w-6 items-center justify-center rounded border border-neutral-300 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:flex", onClick: () => zoomBy(0.5), children: "\uFF0B" }), jsx("div", { className: "mx-1 hidden h-4 w-px bg-neutral-300 dark:bg-neutral-700 sm:block" }), jsx("button", { "aria-label": "Zoom 100%", className: "hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(1), children: "X1" }), jsx("button", { "aria-label": "Zoom 200%", className: "ml-1 hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(2), children: "X2" }), jsx("button", { "aria-label": "Zoom 300%", className: "ml-1 hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(3), children: "X3" }), jsx("button", { "aria-label": "Zoom 1000%", className: "ml-1 hidden h-6 min-w-10 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex", onClick: () => setScale(10), children: "X10" }), jsx("button", { "aria-label": "Reset", className: cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", themeIconColor), onClick: resetTransform, children: jsx(globalLucideIcons.RefreshCcw, { className: "h-3.5 w-3.5" }) }), jsx("button", { "aria-label": "Download SVG", className: cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", themeIconColor), onClick: handleDownload, children: jsx(globalLucideIcons.Download, { className: "h-3.5 w-3.5" }) }), jsx("button", { "aria-label": "Close", className: cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", themeIconColor), onClick: () => { setOpen(false); resetTransform(); }, children: jsx(globalLucideIcons.X, { className: "h-3.5 w-3.5" }) })] })] }), jsxs("div", { className: "relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900 overscroll-contain touch-none", onWheel: onWheel, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, children: [jsx("div", { className: "absolute left-1/2 top-1/2", style: { transform: `translate(-50%, -50%) translate(${translate.x}px, ${translate.y}px)` }, children: jsx("div", { style: { transform: `scale(${scale})`, transformOrigin: '50% 50%' }, dangerouslySetInnerHTML: { __html: svg } }) }), jsxs("div", { className: "absolute inset-x-3 bottom-3 rounded-md bg-white/92 px-3 py-2 shadow-sm backdrop-blur sm:hidden dark:bg-neutral-900/92", children: [jsxs("label", { className: "mb-1 flex items-center justify-between text-[11px] text-neutral-600 dark:text-neutral-300", children: [jsx("span", { children: "Zoom" }), jsxs("span", { children: [Math.round(scale * 100), "%"] })] }), jsx("input", { "aria-label": "Zoom slider", className: "block w-full", type: "range", min: "25", max: "1000", step: "5", value: Math.round(scale * 100), style: { accentColor: themeSvgIconColor }, onChange: (e) => setScale(clamp(Number(e.target.value) / 100, 0.25, 10)) })] }), jsx("div", { className: "pointer-events-none absolute bottom-2 right-3 hidden rounded bg-black/40 px-2 py-1 text-xs text-white sm:block", children: "Drag to pan, click button to zoom-out or zoom-in" }), jsx("div", { className: "pointer-events-none absolute left-3 top-3 rounded bg-black/40 px-2 py-1 text-[11px] text-white sm:hidden", children: "Drag to pan, pinch to zoom-out or zoom-in" })] })] })] }))] }));
174
215
  }
175
- function addWatermarkToSvg(svg, watermark) {
216
+ function addWatermarkToSvg(svg, watermark, watermarkColor) {
176
217
  const watermarkText = `
177
218
  <text
178
219
  x="100%"
@@ -180,7 +221,7 @@ function addWatermarkToSvg(svg, watermark) {
180
221
  text-anchor="end"
181
222
  font-size="12"
182
223
  font-style="italic"
183
- fill="#AC62FD"
224
+ fill="${watermarkColor}"
184
225
  opacity="0.40"
185
226
  class="pointer-events-none"
186
227
  dx="-8"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windrun-huaiin/third-ui",
3
- "version": "13.1.2",
3
+ "version": "13.1.3",
4
4
  "description": "Third-party integrated UI components for windrun-huaiin projects",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -3,8 +3,10 @@
3
3
  import { globalLucideIcons as icons } from '@windrun-huaiin/base-ui/components/server';
4
4
  // Attention: do not use external dialog library, avoid react context conflict when building third-party applications
5
5
  import type { MermaidConfig } from 'mermaid';
6
+ import { cn } from '@windrun-huaiin/lib/utils';
6
7
  import { useTheme } from 'next-themes';
7
8
  import { useCallback, useEffect, useId, useRef, useState } from 'react';
9
+ import { themeIconColor, themeSvgIconColor } from '@windrun-huaiin/base-ui/lib';
8
10
 
9
11
  function sanitizeFilename(name: string) {
10
12
  return name
@@ -35,6 +37,9 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
35
37
  const isPanningRef = useRef(false);
36
38
  const startPointRef = useRef({ x: 0, y: 0 });
37
39
  const startTranslateRef = useRef({ x: 0, y: 0 });
40
+ const activePointersRef = useRef(new Map<number, { x: number; y: number }>());
41
+ const pinchStartDistanceRef = useRef(0);
42
+ const pinchStartScaleRef = useRef(1);
38
43
 
39
44
  useEffect(() => {
40
45
  let isMounted = true;
@@ -59,7 +64,7 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
59
64
  );
60
65
  let svgWithWatermark = svg;
61
66
  if (watermarkEnabled && watermarkText) {
62
- svgWithWatermark = addWatermarkToSvg(svg, watermarkText);
67
+ svgWithWatermark = addWatermarkToSvg(svg, watermarkText, themeSvgIconColor);
63
68
  }
64
69
  if (isMounted) setSvg(svgWithWatermark);
65
70
  } catch (error) {
@@ -100,13 +105,33 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
100
105
  }, []);
101
106
 
102
107
  const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
108
+ activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
109
+ if (activePointersRef.current.size === 2) {
110
+ const [first, second] = Array.from(activePointersRef.current.values());
111
+ pinchStartDistanceRef.current = Math.hypot(second.x - first.x, second.y - first.y);
112
+ pinchStartScaleRef.current = scale;
113
+ isPanningRef.current = false;
114
+ } else {
103
115
  isPanningRef.current = true;
104
116
  startPointRef.current = { x: e.clientX, y: e.clientY };
105
117
  startTranslateRef.current = { ...translate };
118
+ }
106
119
  (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
107
- }, [translate]);
120
+ }, [scale, translate]);
108
121
 
109
122
  const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
123
+ if (!activePointersRef.current.has(e.pointerId)) return;
124
+ activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
125
+
126
+ if (activePointersRef.current.size === 2) {
127
+ const [first, second] = Array.from(activePointersRef.current.values());
128
+ const distance = Math.hypot(second.x - first.x, second.y - first.y);
129
+ if (pinchStartDistanceRef.current > 0) {
130
+ setScale(clamp((distance / pinchStartDistanceRef.current) * pinchStartScaleRef.current, 0.25, 10));
131
+ }
132
+ return;
133
+ }
134
+
110
135
  if (!isPanningRef.current) return;
111
136
  const dx = e.clientX - startPointRef.current.x;
112
137
  const dy = e.clientY - startPointRef.current.y;
@@ -114,8 +139,25 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
114
139
  }, []);
115
140
 
116
141
  const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
142
+ activePointersRef.current.delete(e.pointerId);
117
143
  isPanningRef.current = false;
118
- (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
144
+ if (activePointersRef.current.size === 1) {
145
+ const remaining = Array.from(activePointersRef.current.values())[0];
146
+ startPointRef.current = remaining;
147
+ startTranslateRef.current = { ...translate };
148
+ isPanningRef.current = true;
149
+ }
150
+ if ((e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) {
151
+ (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
152
+ }
153
+ }, [translate]);
154
+
155
+ const onPointerCancel = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
156
+ activePointersRef.current.delete(e.pointerId);
157
+ isPanningRef.current = false;
158
+ if ((e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) {
159
+ (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
160
+ }
119
161
  }, []);
120
162
 
121
163
  const handleDownload = useCallback(() => {
@@ -203,7 +245,7 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
203
245
  </div>
204
246
  {title && (
205
247
  <div
206
- className="mt-2 flex items-center justify-center text-center text-[13px] font-italic text-[#AC62FD]"
248
+ className={cn("mt-2 flex items-center justify-center text-center text-[13px] font-italic", themeIconColor)}
207
249
  >
208
250
  <icons.Mmd className='mr-1 h-4 w-4' />
209
251
  <span>{title}</span>
@@ -226,74 +268,74 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
226
268
  />
227
269
  <div className="relative z-1 max-w-[95vw] w-[95vw] h-[88vh] p-0 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-md shadow-2xl overflow-hidden">
228
270
  {/* Top bar */}
229
- <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-200 dark:border-neutral-700">
230
- <div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300">
271
+ <div className="flex items-center justify-between gap-3 px-3 py-2 border-b border-neutral-200 dark:border-neutral-700">
272
+ <div className={cn("min-w-0 flex items-center gap-2 text-sm", themeIconColor)}>
231
273
  <icons.Mmd className="h-4 w-4" />
232
274
  <span className="truncate max-w-[50vw]">{title ?? 'Mermaid Preview'}</span>
233
275
  </div>
234
- <div className="flex items-center gap-0.5">
276
+ <div className="flex shrink-0 items-center gap-0.5">
235
277
  <button
236
278
  aria-label="Zoom out"
237
- className="flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400"
279
+ className="hidden h-6 w-6 items-center justify-center rounded border border-neutral-300 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:flex"
238
280
  onClick={() => zoomBy(-0.5)}
239
281
  >
240
282
 
241
283
  </button>
242
- <span className="mx-0.5 text-[12px] w-12 text-center select-none">{Math.round(scale * 100)}%</span>
284
+ <span className="mx-0.5 w-12 text-center text-[12px] select-none">{Math.round(scale * 100)}%</span>
243
285
  <button
244
286
  aria-label="Zoom in"
245
- className="flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400"
287
+ className="hidden h-6 w-6 items-center justify-center rounded border border-neutral-300 text-[13px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:flex"
246
288
  onClick={() => zoomBy(0.5)}
247
289
  >
248
290
 
249
291
  </button>
250
292
  {/* quick zoom shortcuts */}
251
- <div className="mx-1 h-4 w-px bg-neutral-300 dark:bg-neutral-700" />
293
+ <div className="mx-1 hidden h-4 w-px bg-neutral-300 dark:bg-neutral-700 sm:block" />
252
294
  <button
253
295
  aria-label="Zoom 100%"
254
- className="inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400"
296
+ className="hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex"
255
297
  onClick={() => setScale(1)}
256
298
  >
257
299
  X1
258
300
  </button>
259
301
  <button
260
302
  aria-label="Zoom 200%"
261
- className="ml-1 inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400"
303
+ className="ml-1 hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex"
262
304
  onClick={() => setScale(2)}
263
305
  >
264
306
  X2
265
307
  </button>
266
308
  <button
267
309
  aria-label="Zoom 300%"
268
- className="ml-1 inline-flex h-6 min-w-8 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400"
310
+ className="ml-1 hidden h-6 min-w-8 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex"
269
311
  onClick={() => setScale(3)}
270
312
  >
271
313
  X3
272
314
  </button>
273
315
  <button
274
316
  aria-label="Zoom 1000%"
275
- className="ml-1 inline-flex h-6 min-w-10 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400"
317
+ className="ml-1 hidden h-6 min-w-10 items-center justify-center rounded border border-neutral-300 px-1.5 text-[12px] transition-colors hover:bg-neutral-100 active:bg-neutral-200 hover:border-neutral-400 active:border-neutral-500 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 dark:hover:border-neutral-500 dark:active:border-neutral-400 sm:inline-flex"
276
318
  onClick={() => setScale(10)}
277
319
  >
278
320
  X10
279
321
  </button>
280
322
  <button
281
323
  aria-label="Reset"
282
- className="ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30"
324
+ className={cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", themeIconColor)}
283
325
  onClick={resetTransform}
284
326
  >
285
327
  <icons.RefreshCcw className="h-3.5 w-3.5" />
286
328
  </button>
287
329
  <button
288
330
  aria-label="Download SVG"
289
- className="ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30"
331
+ className={cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", themeIconColor)}
290
332
  onClick={handleDownload}
291
333
  >
292
334
  <icons.Download className="h-3.5 w-3.5" />
293
335
  </button>
294
336
  <button
295
337
  aria-label="Close"
296
- className="ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600 transition-colors hover:bg-purple-50 active:bg-purple-100 dark:hover:bg-purple-500/20 dark:active:bg-purple-500/30"
338
+ className={cn("ml-1 flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600", themeIconColor)}
297
339
  onClick={() => { setOpen(false); resetTransform(); }}
298
340
  >
299
341
  <icons.X className="h-3.5 w-3.5" />
@@ -303,11 +345,12 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
303
345
 
304
346
  {/* Canvas */}
305
347
  <div
306
- className="relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900 touch-none overscroll-contain"
348
+ className="relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900 overscroll-contain touch-none"
307
349
  onWheel={onWheel}
308
350
  onPointerDown={onPointerDown}
309
351
  onPointerMove={onPointerMove}
310
352
  onPointerUp={onPointerUp}
353
+ onPointerCancel={onPointerCancel}
311
354
  >
312
355
  <div
313
356
  className="absolute left-1/2 top-1/2"
@@ -318,9 +361,29 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
318
361
  dangerouslySetInnerHTML={{ __html: svg }}
319
362
  />
320
363
  </div>
364
+ <div className="absolute inset-x-3 bottom-3 rounded-md bg-white/92 px-3 py-2 shadow-sm backdrop-blur sm:hidden dark:bg-neutral-900/92">
365
+ <label className="mb-1 flex items-center justify-between text-[11px] text-neutral-600 dark:text-neutral-300">
366
+ <span>Zoom</span>
367
+ <span>{Math.round(scale * 100)}%</span>
368
+ </label>
369
+ <input
370
+ aria-label="Zoom slider"
371
+ className="block w-full"
372
+ type="range"
373
+ min="25"
374
+ max="1000"
375
+ step="5"
376
+ value={Math.round(scale * 100)}
377
+ style={{ accentColor: themeSvgIconColor }}
378
+ onChange={(e) => setScale(clamp(Number(e.target.value) / 100, 0.25, 10))}
379
+ />
380
+ </div>
321
381
  {/* helper text */}
322
- <div className="pointer-events-none absolute bottom-2 right-3 rounded bg-black/40 px-2 py-1 text-xs text-white">
323
- Drag to pan, hold Cmd/Ctrl + scroll to zoom
382
+ <div className="pointer-events-none absolute bottom-2 right-3 hidden rounded bg-black/40 px-2 py-1 text-xs text-white sm:block">
383
+ Drag to pan, click button to zoom-out or zoom-in
384
+ </div>
385
+ <div className="pointer-events-none absolute left-3 top-3 rounded bg-black/40 px-2 py-1 text-[11px] text-white sm:hidden">
386
+ Drag to pan, pinch to zoom-out or zoom-in
324
387
  </div>
325
388
  </div>
326
389
  </div>
@@ -330,7 +393,7 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enableP
330
393
  );
331
394
  }
332
395
 
333
- function addWatermarkToSvg(svg: string, watermark: string) {
396
+ function addWatermarkToSvg(svg: string, watermark: string, watermarkColor: string) {
334
397
  const watermarkText = `
335
398
  <text
336
399
  x="100%"
@@ -338,7 +401,7 @@ function addWatermarkToSvg(svg: string, watermark: string) {
338
401
  text-anchor="end"
339
402
  font-size="12"
340
403
  font-style="italic"
341
- fill="#AC62FD"
404
+ fill="${watermarkColor}"
342
405
  opacity="0.40"
343
406
  class="pointer-events-none"
344
407
  dx="-8"
@@ -346,4 +409,4 @@ function addWatermarkToSvg(svg: string, watermark: string) {
346
409
  >${watermark}</text>
347
410
  `;
348
411
  return svg.replace('</svg>', `${watermarkText}</svg>`);
349
- }
412
+ }