@windrun-huaiin/third-ui 5.13.4 → 5.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,37 @@
1
1
  'use client';
2
2
 
3
3
  import { globalLucideIcons as icons } from '@base-ui/components/global-icon';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogContent,
7
+ AlertDialogTitle,
8
+ } from '@base-ui/ui/alert-dialog';
4
9
  import type { MermaidConfig } from 'mermaid';
5
10
  import { useTheme } from 'next-themes';
6
- import { useEffect, useId, useState } from 'react';
11
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
7
12
 
8
13
  interface MermaidProps {
9
14
  chart: string;
10
15
  title?: string;
11
16
  watermarkEnabled?: boolean;
12
17
  watermarkText?: string;
18
+ /**
19
+ * enable preview dialog by clicking the chart, default is true
20
+ */
21
+ enablePreview?: boolean;
13
22
  }
14
23
 
15
- export function Mermaid({ chart, title, watermarkEnabled, watermarkText }: MermaidProps) {
24
+ export function Mermaid({ chart, title, watermarkEnabled, watermarkText, enablePreview = true }: MermaidProps) {
16
25
  const id = useId();
17
26
  const [svg, setSvg] = useState('');
18
27
  const { resolvedTheme } = useTheme();
28
+ const [open, setOpen] = useState(false);
29
+ // zoom & pan states for preview dialog
30
+ const [scale, setScale] = useState(1);
31
+ const [translate, setTranslate] = useState({ x: 0, y: 0 });
32
+ const isPanningRef = useRef(false);
33
+ const startPointRef = useRef({ x: 0, y: 0 });
34
+ const startTranslateRef = useRef({ x: 0, y: 0 });
19
35
 
20
36
  useEffect(() => {
21
37
  let isMounted = true;
@@ -52,10 +68,62 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText }: Merma
52
68
  setSvg('');
53
69
  };
54
70
  }, [chart, id, resolvedTheme, watermarkEnabled, watermarkText]);
71
+
72
+ // helpers for preview zoom
73
+ const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
74
+ const resetTransform = useCallback(() => {
75
+ setScale(1);
76
+ setTranslate({ x: 0, y: 0 });
77
+ }, []);
78
+
79
+ const zoomBy = useCallback((delta: number) => {
80
+ // 基于中心缩放:保持缩放中心在画布中点,不引入位移
81
+ setScale((prev) => clamp(prev + delta, 0.25, 6));
82
+ }, []);
83
+
84
+ const onWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
85
+ // Cmd/Ctrl + 滚轮缩放(围绕中心点),否则上下平移
86
+ if (e.metaKey || e.ctrlKey) {
87
+ e.preventDefault();
88
+ const delta = e.deltaY > 0 ? -0.1 : 0.1;
89
+ setScale((prev) => clamp(prev + delta, 0.25, 6));
90
+ } else {
91
+ setTranslate((prev) => ({ x: prev.x, y: prev.y - e.deltaY }));
92
+ }
93
+ }, []);
94
+
95
+ const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
96
+ isPanningRef.current = true;
97
+ startPointRef.current = { x: e.clientX, y: e.clientY };
98
+ startTranslateRef.current = { ...translate };
99
+ (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
100
+ }, [translate]);
101
+
102
+ const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
103
+ if (!isPanningRef.current) return;
104
+ const dx = e.clientX - startPointRef.current.x;
105
+ const dy = e.clientY - startPointRef.current.y;
106
+ setTranslate({ x: startTranslateRef.current.x + dx, y: startTranslateRef.current.y + dy });
107
+ }, []);
108
+
109
+ const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
110
+ isPanningRef.current = false;
111
+ (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
112
+ }, []);
55
113
 
56
114
  return (
57
115
  <div>
58
- <div dangerouslySetInnerHTML={{ __html: svg }} />
116
+ <div
117
+ className={enablePreview ? 'group relative cursor-zoom-in' : undefined}
118
+ onClick={() => enablePreview && svg && setOpen(true)}
119
+ >
120
+ <div dangerouslySetInnerHTML={{ __html: svg }} />
121
+ {enablePreview && svg && (
122
+ <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">
123
+ Preview Chart
124
+ </div>
125
+ )}
126
+ </div>
59
127
  {title && (
60
128
  <div
61
129
  className="mt-2 flex items-center justify-center text-center text-[13px] font-italic text-[#AC62FD]"
@@ -64,6 +132,76 @@ export function Mermaid({ chart, title, watermarkEnabled, watermarkText }: Merma
64
132
  <span>{title}</span>
65
133
  </div>
66
134
  )}
135
+
136
+ {/* Preview Dialog */}
137
+ {enablePreview && (
138
+ <AlertDialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetTransform(); }}>
139
+ <AlertDialogContent className="z-50 max-w-[95vw] w-[95vw] h-[88vh] p-0 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700">
140
+ <AlertDialogTitle className="sr-only">{title ?? 'Mermaid Preview'}</AlertDialogTitle>
141
+ {/* Top bar */}
142
+ <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-200 dark:border-neutral-700">
143
+ <div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300">
144
+ <icons.Mmd className="h-4 w-4" />
145
+ <span className="truncate max-w-[50vw]">{title ?? 'Mermaid Preview'}</span>
146
+ </div>
147
+ <div className="flex items-center gap-0.5">
148
+ <button
149
+ aria-label="Zoom out"
150
+ className="flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px]"
151
+ onClick={() => zoomBy(-0.2)}
152
+ >
153
+
154
+ </button>
155
+ <span className="mx-0.5 text-[12px] w-12 text-center select-none">{Math.round(scale * 100)}%</span>
156
+ <button
157
+ aria-label="Zoom in"
158
+ className="flex h-6 w-6 items-center justify-center rounded border border-neutral-300 dark:border-neutral-600 text-[13px]"
159
+ onClick={() => zoomBy(0.2)}
160
+ >
161
+
162
+ </button>
163
+ <button
164
+ aria-label="Reset"
165
+ className="ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600"
166
+ onClick={resetTransform}
167
+ >
168
+ <icons.RefreshCcw className="h-3.5 w-3.5" />
169
+ </button>
170
+ <button
171
+ aria-label="Close"
172
+ className="ml-1 flex h-6 w-6 items-center justify-center rounded text-purple-500 hover:text-purple-600"
173
+ onClick={() => setOpen(false)}
174
+ >
175
+ <icons.X className="h-3.5 w-3.5" />
176
+ </button>
177
+ </div>
178
+ </div>
179
+
180
+ {/* Canvas */}
181
+ <div
182
+ className="relative h-[calc(88vh-40px)] w-full overflow-hidden bg-white dark:bg-neutral-900"
183
+ onWheel={onWheel}
184
+ onPointerDown={onPointerDown}
185
+ onPointerMove={onPointerMove}
186
+ onPointerUp={onPointerUp}
187
+ >
188
+ <div
189
+ className="absolute left-1/2 top-1/2"
190
+ style={{ transform: `translate(-50%, -50%) translate(${translate.x}px, ${translate.y}px)` }}
191
+ >
192
+ <div
193
+ style={{ transform: `scale(${scale})`, transformOrigin: '50% 50%' }}
194
+ dangerouslySetInnerHTML={{ __html: svg }}
195
+ />
196
+ </div>
197
+ {/* helper text */}
198
+ <div className="pointer-events-none absolute bottom-2 right-3 rounded bg-black/40 px-2 py-1 text-xs text-white">
199
+ Drag to pan, hold Cmd/Ctrl + scroll to zoom
200
+ </div>
201
+ </div>
202
+ </AlertDialogContent>
203
+ </AlertDialog>
204
+ )}
67
205
  </div>
68
206
  );
69
207
  }