@djangocfg/ui-nextjs 2.1.27 → 2.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/hooks/useLocalStorage.ts +1 -8
- package/src/tools/Mermaid/Mermaid.client.tsx +42 -306
- package/src/tools/Mermaid/components/MermaidCodeViewer.tsx +95 -0
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +102 -0
- package/src/tools/Mermaid/hooks/index.ts +4 -0
- package/src/tools/Mermaid/hooks/useMermaidCleanup.ts +27 -0
- package/src/tools/Mermaid/hooks/useMermaidFullscreen.ts +46 -0
- package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +224 -0
- package/src/tools/Mermaid/hooks/useMermaidValidation.ts +29 -0
- package/src/tools/Mermaid/utils/mermaid-helpers.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.28",
|
|
4
4
|
"description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"check": "tsc --noEmit"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@djangocfg/api": "^2.1.
|
|
62
|
-
"@djangocfg/ui-core": "^2.1.
|
|
61
|
+
"@djangocfg/api": "^2.1.28",
|
|
62
|
+
"@djangocfg/ui-core": "^2.1.28",
|
|
63
63
|
"@types/react": "^19.1.0",
|
|
64
64
|
"@types/react-dom": "^19.1.0",
|
|
65
65
|
"consola": "^3.4.2",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"vidstack": "next"
|
|
105
105
|
},
|
|
106
106
|
"devDependencies": {
|
|
107
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
107
|
+
"@djangocfg/typescript-config": "^2.1.28",
|
|
108
108
|
"@types/node": "^24.7.2",
|
|
109
109
|
"eslint": "^9.37.0",
|
|
110
110
|
"tailwindcss-animate": "1.0.7",
|
|
@@ -74,7 +74,6 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
74
74
|
const key = keys[i];
|
|
75
75
|
if (key) {
|
|
76
76
|
localStorage.removeItem(key);
|
|
77
|
-
localStorage.removeItem(`${key}_timestamp`);
|
|
78
77
|
}
|
|
79
78
|
} catch {
|
|
80
79
|
// Ignore errors when removing items
|
|
@@ -133,8 +132,6 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
133
132
|
} else {
|
|
134
133
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
135
134
|
}
|
|
136
|
-
// Add timestamp for cleanup
|
|
137
|
-
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
138
135
|
} catch (storageError: any) {
|
|
139
136
|
// If quota exceeded, clear old data and try again
|
|
140
137
|
if (storageError.name === 'QuotaExceededError' ||
|
|
@@ -151,7 +148,6 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
151
148
|
} else {
|
|
152
149
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
153
150
|
}
|
|
154
|
-
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
155
151
|
} catch (retryError) {
|
|
156
152
|
console.error(`Failed to set localStorage key "${key}" after clearing old data:`, retryError);
|
|
157
153
|
// If still fails, force clear all and try one more time
|
|
@@ -163,7 +159,6 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
163
159
|
} else {
|
|
164
160
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
165
161
|
}
|
|
166
|
-
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
167
162
|
} catch (finalError) {
|
|
168
163
|
console.error(`Failed to set localStorage key "${key}" after force clearing:`, finalError);
|
|
169
164
|
// If still fails, just update the state without localStorage
|
|
@@ -190,7 +185,6 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
190
185
|
if (typeof window !== 'undefined') {
|
|
191
186
|
try {
|
|
192
187
|
window.localStorage.removeItem(key);
|
|
193
|
-
window.localStorage.removeItem(`${key}_timestamp`);
|
|
194
188
|
} catch (removeError: any) {
|
|
195
189
|
// If removal fails due to quota, try to clear some data first
|
|
196
190
|
if (removeError.name === 'QuotaExceededError' ||
|
|
@@ -198,10 +192,9 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
198
192
|
removeError.message?.includes('quota')) {
|
|
199
193
|
console.warn('localStorage quota exceeded during removal, clearing old data...');
|
|
200
194
|
clearOldData();
|
|
201
|
-
|
|
195
|
+
|
|
202
196
|
try {
|
|
203
197
|
window.localStorage.removeItem(key);
|
|
204
|
-
window.localStorage.removeItem(`${key}_timestamp`);
|
|
205
198
|
} catch (retryError) {
|
|
206
199
|
console.error(`Failed to remove localStorage key "${key}" after clearing:`, retryError);
|
|
207
200
|
// If still fails, force clear all
|
|
@@ -1,288 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
5
|
-
import { createPortal } from 'react-dom';
|
|
3
|
+
import React from 'react';
|
|
6
4
|
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
|
|
5
|
+
import { useMermaidRenderer } from './hooks/useMermaidRenderer';
|
|
6
|
+
import { useMermaidFullscreen } from './hooks/useMermaidFullscreen';
|
|
7
|
+
import { MermaidFullscreenModal } from './components/MermaidFullscreenModal';
|
|
7
8
|
|
|
8
9
|
interface MermaidProps {
|
|
9
10
|
chart: string;
|
|
10
11
|
className?: string;
|
|
11
|
-
isCompact?: boolean;
|
|
12
|
+
isCompact?: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
// Utility function to apply text colors to Mermaid SVG
|
|
15
|
-
const applyMermaidTextColors = (container: HTMLElement, textColor: string) => {
|
|
16
|
-
const svgElement = container.querySelector('svg');
|
|
17
|
-
if (svgElement) {
|
|
18
|
-
// SVG text elements use 'fill'
|
|
19
|
-
svgElement.querySelectorAll('text').forEach((el) => {
|
|
20
|
-
(el as SVGElement).style.fill = textColor;
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
// HTML elements inside foreignObject use 'color'
|
|
24
|
-
svgElement.querySelectorAll('.nodeLabel, .edgeLabel').forEach((el) => {
|
|
25
|
-
(el as HTMLElement).style.color = textColor;
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// Detect if diagram is vertical (tall and narrow)
|
|
31
|
-
const isVerticalDiagram = (svgElement: SVGSVGElement): boolean => {
|
|
32
|
-
const viewBox = svgElement.getAttribute('viewBox');
|
|
33
|
-
if (viewBox) {
|
|
34
|
-
const [, , width, height] = viewBox.split(' ').map(Number);
|
|
35
|
-
// Consider vertical if height is more than 1.5x the width
|
|
36
|
-
return height > width * 1.5;
|
|
37
|
-
}
|
|
38
|
-
// Fallback to computed dimensions
|
|
39
|
-
const bbox = svgElement.getBBox?.();
|
|
40
|
-
if (bbox) {
|
|
41
|
-
return bbox.height > bbox.width * 1.5;
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
15
|
const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', isCompact = false }) => {
|
|
47
|
-
const mermaidRef = useRef<HTMLDivElement>(null);
|
|
48
|
-
const fullscreenRef = useRef<HTMLDivElement>(null);
|
|
49
|
-
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
50
|
-
const [svgContent, setSvgContent] = useState<string>('');
|
|
51
|
-
const [isVertical, setIsVertical] = useState(false);
|
|
52
16
|
const theme = useResolvedTheme();
|
|
53
17
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// Dark theme - vibrant and clear
|
|
70
|
-
primaryColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
71
|
-
primaryTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
72
|
-
primaryBorderColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
73
|
-
|
|
74
|
-
secondaryColor: getCSSVariable('--muted') || 'hsl(217.2 32.6% 17.5%)',
|
|
75
|
-
secondaryTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
76
|
-
secondaryBorderColor: getCSSVariable('--border') || 'hsl(217.2 32.6% 27.5%)',
|
|
77
|
-
|
|
78
|
-
tertiaryColor: getCSSVariable('--accent') || 'hsl(217.2 32.6% 20%)',
|
|
79
|
-
tertiaryTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
80
|
-
tertiaryBorderColor: getCSSVariable('--border') || 'hsl(217.2 32.6% 27.5%)',
|
|
81
|
-
|
|
82
|
-
// Main elements - darker with good contrast
|
|
83
|
-
mainBkg: getCSSVariable('--card') || 'hsl(222.2 84% 8%)',
|
|
84
|
-
textColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
85
|
-
nodeBorder: getCSSVariable('--border') || 'hsl(217.2 32.6% 27.5%)',
|
|
86
|
-
nodeTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
87
|
-
|
|
88
|
-
// Alternative backgrounds
|
|
89
|
-
secondBkg: getCSSVariable('--muted') || 'hsl(217.2 32.6% 17.5%)',
|
|
90
|
-
|
|
91
|
-
// Lines and edges - lighter for visibility
|
|
92
|
-
lineColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
93
|
-
edgeLabelBackground: getCSSVariable('--card') || 'hsl(222.2 84% 8%)',
|
|
94
|
-
|
|
95
|
-
// Clusters
|
|
96
|
-
clusterBkg: getCSSVariable('--muted') || 'hsl(217.2 32.6% 12%)',
|
|
97
|
-
clusterBorder: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
98
|
-
|
|
99
|
-
// Background
|
|
100
|
-
background: getCSSVariable('--background') || 'hsl(222.2 84% 4.9%)',
|
|
101
|
-
|
|
102
|
-
// Labels
|
|
103
|
-
labelBackground: getCSSVariable('--card') || 'hsl(222.2 84% 8%)',
|
|
104
|
-
labelTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
105
|
-
|
|
106
|
-
// Special states
|
|
107
|
-
errorBkgColor: getCSSVariable('--destructive') || 'hsl(0 62.8% 30.6%)',
|
|
108
|
-
errorTextColor: 'hsl(210 40% 98%)',
|
|
109
|
-
|
|
110
|
-
fontSize: diagramFontSize,
|
|
111
|
-
fontFamily: 'Inter, system-ui, sans-serif',
|
|
112
|
-
} : {
|
|
113
|
-
// Light theme - clean and professional
|
|
114
|
-
primaryColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
115
|
-
primaryTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
116
|
-
primaryBorderColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
117
|
-
|
|
118
|
-
secondaryColor: getCSSVariable('--secondary') || 'hsl(210 40% 96.1%)',
|
|
119
|
-
secondaryTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
120
|
-
secondaryBorderColor: getCSSVariable('--border') || 'hsl(214.3 31.8% 91.4%)',
|
|
121
|
-
|
|
122
|
-
tertiaryColor: getCSSVariable('--muted') || 'hsl(210 40% 96.1%)',
|
|
123
|
-
tertiaryTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
124
|
-
tertiaryBorderColor: getCSSVariable('--border') || 'hsl(214.3 31.8% 91.4%)',
|
|
125
|
-
|
|
126
|
-
// Main elements - white with good contrast
|
|
127
|
-
mainBkg: getCSSVariable('--card') || 'hsl(0 0% 100%)',
|
|
128
|
-
textColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
129
|
-
nodeBorder: getCSSVariable('--border') || 'hsl(214.3 31.8% 91.4%)',
|
|
130
|
-
nodeTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
131
|
-
|
|
132
|
-
// Alternative backgrounds
|
|
133
|
-
secondBkg: getCSSVariable('--muted') || 'hsl(210 40% 96.1%)',
|
|
134
|
-
|
|
135
|
-
// Lines and edges - vibrant primary color
|
|
136
|
-
lineColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
137
|
-
edgeLabelBackground: getCSSVariable('--card') || 'hsl(0 0% 100%)',
|
|
138
|
-
|
|
139
|
-
// Clusters - subtle background
|
|
140
|
-
clusterBkg: getCSSVariable('--accent') || 'hsl(210 40% 98%)',
|
|
141
|
-
clusterBorder: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
142
|
-
|
|
143
|
-
// Background
|
|
144
|
-
background: getCSSVariable('--background') || 'hsl(0 0% 100%)',
|
|
145
|
-
|
|
146
|
-
// Labels
|
|
147
|
-
labelBackground: getCSSVariable('--card') || 'hsl(0 0% 100%)',
|
|
148
|
-
labelTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
149
|
-
|
|
150
|
-
// Special states
|
|
151
|
-
errorBkgColor: getCSSVariable('--destructive') || 'hsl(0 84.2% 60.2%)',
|
|
152
|
-
errorTextColor: 'hsl(210 40% 98%)',
|
|
153
|
-
|
|
154
|
-
fontSize: diagramFontSize,
|
|
155
|
-
fontFamily: 'Inter, system-ui, sans-serif',
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
// Initialize mermaid with dynamic theme configuration
|
|
159
|
-
mermaid.initialize({
|
|
160
|
-
startOnLoad: false,
|
|
161
|
-
theme: 'base', // Use 'base' theme for better custom variable support
|
|
162
|
-
securityLevel: 'loose',
|
|
163
|
-
fontFamily: 'Inter, system-ui, sans-serif',
|
|
164
|
-
flowchart: {
|
|
165
|
-
useMaxWidth: true,
|
|
166
|
-
htmlLabels: true,
|
|
167
|
-
curve: 'basis',
|
|
168
|
-
},
|
|
169
|
-
themeVariables,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Render the chart
|
|
173
|
-
// Mermaid v11+ requires unique IDs - use random suffix to avoid conflicts
|
|
174
|
-
const renderChart = async () => {
|
|
175
|
-
if (!mermaidRef.current || !chart) return;
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const id = `mermaid-${Math.random().toString(36).substring(2, 9)}`;
|
|
179
|
-
const { svg } = await mermaid.render(id, chart);
|
|
180
|
-
|
|
181
|
-
if (mermaidRef.current) {
|
|
182
|
-
// Post-process SVG to force correct text colors
|
|
183
|
-
const textColor = theme === 'dark'
|
|
184
|
-
? getCSSVariable('--foreground') || 'hsl(0 0% 90%)'
|
|
185
|
-
: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)';
|
|
186
|
-
|
|
187
|
-
// Add inline style to override any conflicting styles
|
|
188
|
-
const processedSvg = svg.replace(
|
|
189
|
-
/<svg /,
|
|
190
|
-
`<svg style="--mermaid-text-color: ${textColor};" `
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
mermaidRef.current.innerHTML = processedSvg;
|
|
194
|
-
setSvgContent(processedSvg);
|
|
195
|
-
|
|
196
|
-
// Apply text colors and responsive styles using utility function
|
|
197
|
-
applyMermaidTextColors(mermaidRef.current, textColor);
|
|
198
|
-
|
|
199
|
-
// Make inline SVG responsive and detect orientation
|
|
200
|
-
const svgElement = mermaidRef.current.querySelector('svg');
|
|
201
|
-
if (svgElement) {
|
|
202
|
-
svgElement.style.maxWidth = '100%';
|
|
203
|
-
svgElement.style.height = 'auto';
|
|
204
|
-
svgElement.style.display = 'block';
|
|
205
|
-
|
|
206
|
-
// Detect if diagram is vertical
|
|
207
|
-
setIsVertical(isVerticalDiagram(svgElement));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
console.error('Mermaid rendering error:', error);
|
|
212
|
-
if (mermaidRef.current) {
|
|
213
|
-
mermaidRef.current.innerHTML = `
|
|
214
|
-
<div class="p-4 text-destructive bg-destructive/10 border border-destructive/20 rounded-sm">
|
|
215
|
-
<p class="font-semibold">Mermaid Diagram Error</p>
|
|
216
|
-
<p class="text-sm">${error instanceof Error ? error.message : 'Unknown error'}</p>
|
|
217
|
-
</div>
|
|
218
|
-
`;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
renderChart();
|
|
224
|
-
}, [chart, theme, isCompact]);
|
|
18
|
+
// Rendering logic
|
|
19
|
+
const { mermaidRef, svgContent, isVertical, isRendering } = useMermaidRenderer({
|
|
20
|
+
chart,
|
|
21
|
+
theme,
|
|
22
|
+
isCompact,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Fullscreen modal logic
|
|
26
|
+
const {
|
|
27
|
+
isFullscreen,
|
|
28
|
+
fullscreenRef,
|
|
29
|
+
openFullscreen,
|
|
30
|
+
closeFullscreen,
|
|
31
|
+
handleBackdropClick,
|
|
32
|
+
} = useMermaidFullscreen();
|
|
225
33
|
|
|
226
34
|
const handleClick = () => {
|
|
227
35
|
if (svgContent) {
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const handleClose = () => {
|
|
233
|
-
setIsFullscreen(false);
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
237
|
-
if (e.target === e.currentTarget) {
|
|
238
|
-
handleClose();
|
|
36
|
+
openFullscreen();
|
|
239
37
|
}
|
|
240
38
|
};
|
|
241
39
|
|
|
242
|
-
// Handle ESC key
|
|
243
|
-
useEffect(() => {
|
|
244
|
-
const handleEscKey = (event: KeyboardEvent) => {
|
|
245
|
-
if (event.key === 'Escape' && isFullscreen) {
|
|
246
|
-
handleClose();
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
if (isFullscreen) {
|
|
251
|
-
document.addEventListener('keydown', handleEscKey);
|
|
252
|
-
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return () => {
|
|
256
|
-
document.removeEventListener('keydown', handleEscKey);
|
|
257
|
-
document.body.style.overflow = 'unset';
|
|
258
|
-
};
|
|
259
|
-
}, [isFullscreen]);
|
|
260
|
-
|
|
261
|
-
// Apply text colors to fullscreen modal after render
|
|
262
|
-
useEffect(() => {
|
|
263
|
-
if (isFullscreen && fullscreenRef.current) {
|
|
264
|
-
const getCSSVariable = (variable: string) => {
|
|
265
|
-
if (typeof document === 'undefined') return '';
|
|
266
|
-
const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
|
|
267
|
-
return value ? `hsl(${value})` : '';
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const textColor = theme === 'dark'
|
|
271
|
-
? getCSSVariable('--foreground') || 'hsl(0 0% 90%)'
|
|
272
|
-
: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)';
|
|
273
|
-
|
|
274
|
-
applyMermaidTextColors(fullscreenRef.current, textColor);
|
|
275
|
-
|
|
276
|
-
// Make SVG responsive
|
|
277
|
-
const svgElement = fullscreenRef.current.querySelector('svg');
|
|
278
|
-
if (svgElement) {
|
|
279
|
-
svgElement.style.display = 'block';
|
|
280
|
-
svgElement.style.height = 'auto';
|
|
281
|
-
svgElement.style.maxWidth = '100%';
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}, [isFullscreen, theme]);
|
|
285
|
-
|
|
286
40
|
return (
|
|
287
41
|
<>
|
|
288
42
|
<div
|
|
@@ -293,53 +47,35 @@ const Mermaid: React.FC<MermaidProps> = ({ chart, className = '', isCompact = fa
|
|
|
293
47
|
<h6 className="text-sm font-semibold text-foreground">Diagram</h6>
|
|
294
48
|
<p className="text-xs text-muted-foreground mt-1">Click to view fullscreen</p>
|
|
295
49
|
</div>
|
|
296
|
-
<div className="p-4">
|
|
50
|
+
<div className="relative p-4 overflow-hidden">
|
|
297
51
|
<div
|
|
298
52
|
ref={mermaidRef}
|
|
299
53
|
className="flex justify-center items-center min-h-[200px]"
|
|
54
|
+
style={{ isolation: 'isolate' }}
|
|
300
55
|
/>
|
|
56
|
+
{isRendering && (
|
|
57
|
+
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm">
|
|
58
|
+
<div className="flex flex-col items-center gap-2">
|
|
59
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
60
|
+
<p className="text-xs text-muted-foreground">Rendering diagram...</p>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
301
64
|
</div>
|
|
302
65
|
</div>
|
|
303
66
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
: 'max-w-[95vw] w-full h-full'
|
|
315
|
-
}`}>
|
|
316
|
-
{/* Header */}
|
|
317
|
-
<div className="flex items-center justify-between py-4 px-6 border-b border-border">
|
|
318
|
-
<h3 className="text-sm font-medium text-foreground py-0 my-0">Diagram</h3>
|
|
319
|
-
<button
|
|
320
|
-
onClick={handleClose}
|
|
321
|
-
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
322
|
-
>
|
|
323
|
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
324
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
325
|
-
</svg>
|
|
326
|
-
</button>
|
|
327
|
-
</div>
|
|
328
|
-
|
|
329
|
-
{/* Content with scroll */}
|
|
330
|
-
<div className="flex-1 overflow-auto p-6">
|
|
331
|
-
<div
|
|
332
|
-
ref={fullscreenRef}
|
|
333
|
-
className="min-h-full flex items-start justify-center"
|
|
334
|
-
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
335
|
-
/>
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
</div>,
|
|
339
|
-
document.body
|
|
340
|
-
)}
|
|
67
|
+
<MermaidFullscreenModal
|
|
68
|
+
isOpen={isFullscreen}
|
|
69
|
+
svgContent={svgContent}
|
|
70
|
+
isVertical={isVertical}
|
|
71
|
+
theme={theme}
|
|
72
|
+
chart={chart}
|
|
73
|
+
fullscreenRef={fullscreenRef}
|
|
74
|
+
onClose={closeFullscreen}
|
|
75
|
+
onBackdropClick={handleBackdropClick}
|
|
76
|
+
/>
|
|
341
77
|
</>
|
|
342
78
|
);
|
|
343
79
|
};
|
|
344
80
|
|
|
345
|
-
export default Mermaid;
|
|
81
|
+
export default Mermaid;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface MermaidCodeViewerProps {
|
|
6
|
+
chart: string;
|
|
7
|
+
renderPreview: () => React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const MermaidCodeViewer: React.FC<MermaidCodeViewerProps> = ({
|
|
11
|
+
chart,
|
|
12
|
+
renderPreview,
|
|
13
|
+
}) => {
|
|
14
|
+
const [activeTab, setActiveTab] = useState<'preview' | 'code'>('preview');
|
|
15
|
+
const [copied, setCopied] = useState(false);
|
|
16
|
+
|
|
17
|
+
const handleCopy = async () => {
|
|
18
|
+
await navigator.clipboard.writeText(chart);
|
|
19
|
+
setCopied(true);
|
|
20
|
+
setTimeout(() => setCopied(false), 2000);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex flex-col h-full">
|
|
25
|
+
{/* Tabs */}
|
|
26
|
+
<div className="flex items-center justify-between border-b border-border px-4">
|
|
27
|
+
<div className="flex">
|
|
28
|
+
<button
|
|
29
|
+
onClick={() => setActiveTab('preview')}
|
|
30
|
+
className={`px-4 py-3 text-sm font-medium transition-colors relative ${
|
|
31
|
+
activeTab === 'preview'
|
|
32
|
+
? 'text-foreground'
|
|
33
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
34
|
+
}`}
|
|
35
|
+
>
|
|
36
|
+
Preview
|
|
37
|
+
{activeTab === 'preview' && (
|
|
38
|
+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
|
39
|
+
)}
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
onClick={() => setActiveTab('code')}
|
|
43
|
+
className={`px-4 py-3 text-sm font-medium transition-colors relative ${
|
|
44
|
+
activeTab === 'code'
|
|
45
|
+
? 'text-foreground'
|
|
46
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
47
|
+
}`}
|
|
48
|
+
>
|
|
49
|
+
Code
|
|
50
|
+
{activeTab === 'code' && (
|
|
51
|
+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
|
52
|
+
)}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Copy button - show only on Code tab */}
|
|
57
|
+
{activeTab === 'code' && (
|
|
58
|
+
<button
|
|
59
|
+
onClick={handleCopy}
|
|
60
|
+
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-primary/10 hover:bg-primary/20 text-primary rounded transition-colors"
|
|
61
|
+
>
|
|
62
|
+
{copied ? (
|
|
63
|
+
<>
|
|
64
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
65
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
66
|
+
</svg>
|
|
67
|
+
Copied!
|
|
68
|
+
</>
|
|
69
|
+
) : (
|
|
70
|
+
<>
|
|
71
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
72
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
73
|
+
</svg>
|
|
74
|
+
Copy
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Content */}
|
|
82
|
+
<div className="flex-1 overflow-auto">
|
|
83
|
+
{activeTab === 'preview' ? (
|
|
84
|
+
<div className="p-6 flex items-center justify-center min-h-full">
|
|
85
|
+
{renderPreview()}
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<pre className="p-6 text-sm font-mono text-foreground bg-muted/30 h-full overflow-auto">
|
|
89
|
+
<code>{chart}</code>
|
|
90
|
+
</pre>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { applyMermaidTextColors } from '../utils/mermaid-helpers';
|
|
6
|
+
import { MermaidCodeViewer } from './MermaidCodeViewer';
|
|
7
|
+
|
|
8
|
+
interface MermaidFullscreenModalProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
svgContent: string;
|
|
11
|
+
isVertical: boolean;
|
|
12
|
+
theme: string;
|
|
13
|
+
chart: string;
|
|
14
|
+
fullscreenRef: React.RefObject<HTMLDivElement>;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onBackdropClick: (e: React.MouseEvent) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const MermaidFullscreenModal: React.FC<MermaidFullscreenModalProps> = ({
|
|
20
|
+
isOpen,
|
|
21
|
+
svgContent,
|
|
22
|
+
isVertical,
|
|
23
|
+
theme,
|
|
24
|
+
chart,
|
|
25
|
+
fullscreenRef,
|
|
26
|
+
onClose,
|
|
27
|
+
onBackdropClick,
|
|
28
|
+
}) => {
|
|
29
|
+
// Apply text colors to fullscreen modal after render
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isOpen && fullscreenRef.current) {
|
|
32
|
+
const getCSSVariable = (variable: string) => {
|
|
33
|
+
if (typeof document === 'undefined') return '';
|
|
34
|
+
const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
|
|
35
|
+
return value ? `hsl(${value})` : '';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const textColor = theme === 'dark'
|
|
39
|
+
? getCSSVariable('--foreground') || 'hsl(0 0% 90%)'
|
|
40
|
+
: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)';
|
|
41
|
+
|
|
42
|
+
applyMermaidTextColors(fullscreenRef.current, textColor);
|
|
43
|
+
|
|
44
|
+
// Make SVG responsive
|
|
45
|
+
const svgElement = fullscreenRef.current.querySelector('svg');
|
|
46
|
+
if (svgElement) {
|
|
47
|
+
svgElement.style.display = 'block';
|
|
48
|
+
svgElement.style.height = 'auto';
|
|
49
|
+
svgElement.style.maxWidth = '100%';
|
|
50
|
+
|
|
51
|
+
// For vertical diagrams, limit width
|
|
52
|
+
if (isVertical) {
|
|
53
|
+
svgElement.style.maxWidth = '600px';
|
|
54
|
+
svgElement.style.margin = '0 auto';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}, [isOpen, theme, isVertical, fullscreenRef]);
|
|
59
|
+
|
|
60
|
+
if (!isOpen || typeof document === 'undefined') return null;
|
|
61
|
+
|
|
62
|
+
return createPortal(
|
|
63
|
+
<div
|
|
64
|
+
className="fixed inset-0 z-9999 flex items-center justify-center p-4"
|
|
65
|
+
style={{ backgroundColor: 'rgb(0 0 0 / 0.75)' }}
|
|
66
|
+
onClick={onBackdropClick}
|
|
67
|
+
>
|
|
68
|
+
<div className={`relative bg-card rounded-sm shadow-xl max-h-[95vh] flex flex-col border border-border ${
|
|
69
|
+
isVertical
|
|
70
|
+
? 'w-auto max-w-[600px]'
|
|
71
|
+
: 'max-w-[90vw] w-auto'
|
|
72
|
+
}`}>
|
|
73
|
+
{/* Header */}
|
|
74
|
+
<div className="flex items-center justify-between py-4 px-6 border-b border-border">
|
|
75
|
+
<h3 className="text-sm font-medium text-foreground py-0 my-0">Diagram</h3>
|
|
76
|
+
<button
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
79
|
+
>
|
|
80
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
81
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
82
|
+
</svg>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Content - Code Viewer with tabs */}
|
|
87
|
+
<div className="flex-1 overflow-hidden">
|
|
88
|
+
<MermaidCodeViewer
|
|
89
|
+
chart={chart}
|
|
90
|
+
renderPreview={() => (
|
|
91
|
+
<div
|
|
92
|
+
ref={fullscreenRef}
|
|
93
|
+
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>,
|
|
100
|
+
document.body
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for cleaning up orphaned Mermaid DOM nodes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useEffect } from 'react';
|
|
6
|
+
|
|
7
|
+
export function useMermaidCleanup() {
|
|
8
|
+
const cleanupMermaidErrors = () => {
|
|
9
|
+
if (typeof document === 'undefined') return;
|
|
10
|
+
|
|
11
|
+
// Remove error text nodes that Mermaid might append to body
|
|
12
|
+
document.querySelectorAll('[id^="mermaid-"]').forEach((node) => {
|
|
13
|
+
if (node.parentNode === document.body) {
|
|
14
|
+
node.remove();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Cleanup on unmount
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
return () => {
|
|
22
|
+
cleanupMermaidErrors();
|
|
23
|
+
};
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return { cleanupMermaidErrors };
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing Mermaid fullscreen modal
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useEffect, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
export function useMermaidFullscreen() {
|
|
8
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
9
|
+
const fullscreenRef = useRef<HTMLDivElement>(null);
|
|
10
|
+
|
|
11
|
+
const openFullscreen = () => setIsFullscreen(true);
|
|
12
|
+
const closeFullscreen = () => setIsFullscreen(false);
|
|
13
|
+
|
|
14
|
+
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
15
|
+
if (e.target === e.currentTarget) {
|
|
16
|
+
closeFullscreen();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Handle ESC key
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const handleEscKey = (event: KeyboardEvent) => {
|
|
23
|
+
if (event.key === 'Escape' && isFullscreen) {
|
|
24
|
+
closeFullscreen();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (isFullscreen) {
|
|
29
|
+
document.addEventListener('keydown', handleEscKey);
|
|
30
|
+
document.body.style.overflow = 'hidden';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
document.removeEventListener('keydown', handleEscKey);
|
|
35
|
+
document.body.style.overflow = 'unset';
|
|
36
|
+
};
|
|
37
|
+
}, [isFullscreen]);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
isFullscreen,
|
|
41
|
+
fullscreenRef,
|
|
42
|
+
openFullscreen,
|
|
43
|
+
closeFullscreen,
|
|
44
|
+
handleBackdropClick,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for rendering Mermaid diagrams with debounce and validation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import mermaid from 'mermaid';
|
|
7
|
+
import { useMermaidValidation } from './useMermaidValidation';
|
|
8
|
+
import { useMermaidCleanup } from './useMermaidCleanup';
|
|
9
|
+
|
|
10
|
+
interface UseMermaidRendererProps {
|
|
11
|
+
chart: string;
|
|
12
|
+
theme: string;
|
|
13
|
+
isCompact?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface MermaidRenderResult {
|
|
17
|
+
mermaidRef: React.RefObject<HTMLDivElement>;
|
|
18
|
+
svgContent: string;
|
|
19
|
+
isVertical: boolean;
|
|
20
|
+
isRendering: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Utility function to apply text colors to Mermaid SVG
|
|
24
|
+
const applyMermaidTextColors = (container: HTMLElement, textColor: string) => {
|
|
25
|
+
const svgElement = container.querySelector('svg');
|
|
26
|
+
if (svgElement) {
|
|
27
|
+
// SVG text elements use 'fill'
|
|
28
|
+
svgElement.querySelectorAll('text').forEach((el) => {
|
|
29
|
+
(el as SVGElement).style.fill = textColor;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// HTML elements inside foreignObject use 'color'
|
|
33
|
+
svgElement.querySelectorAll('.nodeLabel, .edgeLabel').forEach((el) => {
|
|
34
|
+
(el as HTMLElement).style.color = textColor;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Detect if diagram is vertical (tall and narrow)
|
|
40
|
+
const isVerticalDiagram = (svgElement: SVGSVGElement): boolean => {
|
|
41
|
+
const viewBox = svgElement.getAttribute('viewBox');
|
|
42
|
+
if (viewBox) {
|
|
43
|
+
const [, , width, height] = viewBox.split(' ').map(Number);
|
|
44
|
+
return height > width * 1.5;
|
|
45
|
+
}
|
|
46
|
+
const bbox = svgElement.getBBox?.();
|
|
47
|
+
if (bbox) {
|
|
48
|
+
return bbox.height > bbox.width * 1.5;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function useMermaidRenderer({ chart, theme, isCompact = false }: UseMermaidRendererProps): MermaidRenderResult {
|
|
54
|
+
const mermaidRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const renderTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
56
|
+
const [svgContent, setSvgContent] = useState<string>('');
|
|
57
|
+
const [isVertical, setIsVertical] = useState(false);
|
|
58
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
59
|
+
|
|
60
|
+
const { isMermaidCodeComplete } = useMermaidValidation();
|
|
61
|
+
const { cleanupMermaidErrors } = useMermaidCleanup();
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// Get CSS variables for semantic colors
|
|
65
|
+
const getCSSVariable = (variable: string) => {
|
|
66
|
+
if (typeof document === 'undefined') return '';
|
|
67
|
+
const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
|
|
68
|
+
return value ? `hsl(${value})` : '';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const diagramFontSize = isCompact ? '12px' : '14px';
|
|
72
|
+
|
|
73
|
+
const themeVariables = theme === 'dark' ? {
|
|
74
|
+
primaryColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
75
|
+
primaryTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
76
|
+
primaryBorderColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
77
|
+
secondaryColor: getCSSVariable('--muted') || 'hsl(217.2 32.6% 17.5%)',
|
|
78
|
+
secondaryTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
79
|
+
secondaryBorderColor: getCSSVariable('--border') || 'hsl(217.2 32.6% 27.5%)',
|
|
80
|
+
tertiaryColor: getCSSVariable('--accent') || 'hsl(217.2 32.6% 20%)',
|
|
81
|
+
tertiaryTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
82
|
+
tertiaryBorderColor: getCSSVariable('--border') || 'hsl(217.2 32.6% 27.5%)',
|
|
83
|
+
mainBkg: getCSSVariable('--card') || 'hsl(222.2 84% 8%)',
|
|
84
|
+
textColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
85
|
+
nodeBorder: getCSSVariable('--border') || 'hsl(217.2 32.6% 27.5%)',
|
|
86
|
+
nodeTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
87
|
+
secondBkg: getCSSVariable('--muted') || 'hsl(217.2 32.6% 17.5%)',
|
|
88
|
+
lineColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
89
|
+
edgeLabelBackground: getCSSVariable('--card') || 'hsl(222.2 84% 8%)',
|
|
90
|
+
clusterBkg: getCSSVariable('--muted') || 'hsl(217.2 32.6% 12%)',
|
|
91
|
+
clusterBorder: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
92
|
+
background: getCSSVariable('--background') || 'hsl(222.2 84% 4.9%)',
|
|
93
|
+
labelBackground: getCSSVariable('--card') || 'hsl(222.2 84% 8%)',
|
|
94
|
+
labelTextColor: getCSSVariable('--foreground') || 'hsl(210 40% 98%)',
|
|
95
|
+
errorBkgColor: getCSSVariable('--destructive') || 'hsl(0 62.8% 30.6%)',
|
|
96
|
+
errorTextColor: 'hsl(210 40% 98%)',
|
|
97
|
+
fontSize: diagramFontSize,
|
|
98
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
99
|
+
} : {
|
|
100
|
+
primaryColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
101
|
+
primaryTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
102
|
+
primaryBorderColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
103
|
+
secondaryColor: getCSSVariable('--secondary') || 'hsl(210 40% 96.1%)',
|
|
104
|
+
secondaryTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
105
|
+
secondaryBorderColor: getCSSVariable('--border') || 'hsl(214.3 31.8% 91.4%)',
|
|
106
|
+
tertiaryColor: getCSSVariable('--muted') || 'hsl(210 40% 96.1%)',
|
|
107
|
+
tertiaryTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
108
|
+
tertiaryBorderColor: getCSSVariable('--border') || 'hsl(214.3 31.8% 91.4%)',
|
|
109
|
+
mainBkg: getCSSVariable('--card') || 'hsl(0 0% 100%)',
|
|
110
|
+
textColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
111
|
+
nodeBorder: getCSSVariable('--border') || 'hsl(214.3 31.8% 91.4%)',
|
|
112
|
+
nodeTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
113
|
+
secondBkg: getCSSVariable('--muted') || 'hsl(210 40% 96.1%)',
|
|
114
|
+
lineColor: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
115
|
+
edgeLabelBackground: getCSSVariable('--card') || 'hsl(0 0% 100%)',
|
|
116
|
+
clusterBkg: getCSSVariable('--accent') || 'hsl(210 40% 98%)',
|
|
117
|
+
clusterBorder: getCSSVariable('--primary') || 'hsl(221.2 83.2% 53.3%)',
|
|
118
|
+
background: getCSSVariable('--background') || 'hsl(0 0% 100%)',
|
|
119
|
+
labelBackground: getCSSVariable('--card') || 'hsl(0 0% 100%)',
|
|
120
|
+
labelTextColor: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)',
|
|
121
|
+
errorBkgColor: getCSSVariable('--destructive') || 'hsl(0 84.2% 60.2%)',
|
|
122
|
+
errorTextColor: 'hsl(210 40% 98%)',
|
|
123
|
+
fontSize: diagramFontSize,
|
|
124
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
mermaid.initialize({
|
|
128
|
+
startOnLoad: false,
|
|
129
|
+
theme: 'base',
|
|
130
|
+
securityLevel: 'loose',
|
|
131
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
132
|
+
flowchart: {
|
|
133
|
+
useMaxWidth: true,
|
|
134
|
+
htmlLabels: true,
|
|
135
|
+
curve: 'basis',
|
|
136
|
+
},
|
|
137
|
+
themeVariables,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const renderChart = async () => {
|
|
141
|
+
if (!mermaidRef.current || !chart) return;
|
|
142
|
+
|
|
143
|
+
// Validate code completeness
|
|
144
|
+
if (!isMermaidCodeComplete(chart)) {
|
|
145
|
+
setIsRendering(true);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
setIsRendering(true);
|
|
151
|
+
|
|
152
|
+
// Clear container
|
|
153
|
+
if (mermaidRef.current) {
|
|
154
|
+
mermaidRef.current.innerHTML = '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const id = `mermaid-${Math.random().toString(36).substring(2, 9)}`;
|
|
158
|
+
const { svg } = await mermaid.render(id, chart);
|
|
159
|
+
|
|
160
|
+
if (mermaidRef.current) {
|
|
161
|
+
const textColor = theme === 'dark'
|
|
162
|
+
? getCSSVariable('--foreground') || 'hsl(0 0% 90%)'
|
|
163
|
+
: getCSSVariable('--foreground') || 'hsl(222.2 84% 4.9%)';
|
|
164
|
+
|
|
165
|
+
const processedSvg = svg.replace(
|
|
166
|
+
/<svg /,
|
|
167
|
+
`<svg style="--mermaid-text-color: ${textColor};" `
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
mermaidRef.current.innerHTML = processedSvg;
|
|
171
|
+
setSvgContent(processedSvg);
|
|
172
|
+
|
|
173
|
+
applyMermaidTextColors(mermaidRef.current, textColor);
|
|
174
|
+
|
|
175
|
+
const svgElement = mermaidRef.current.querySelector('svg');
|
|
176
|
+
if (svgElement) {
|
|
177
|
+
svgElement.style.maxWidth = '100%';
|
|
178
|
+
svgElement.style.height = 'auto';
|
|
179
|
+
svgElement.style.display = 'block';
|
|
180
|
+
setIsVertical(isVerticalDiagram(svgElement));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setIsRendering(false);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error('Mermaid rendering error:', error);
|
|
187
|
+
setIsRendering(false);
|
|
188
|
+
cleanupMermaidErrors();
|
|
189
|
+
|
|
190
|
+
if (mermaidRef.current) {
|
|
191
|
+
mermaidRef.current.innerHTML = `
|
|
192
|
+
<div class="p-4 text-destructive bg-destructive/10 border border-destructive/20 rounded-sm">
|
|
193
|
+
<p class="font-semibold">Mermaid Diagram Error</p>
|
|
194
|
+
<p class="text-sm">${error instanceof Error ? error.message : 'Unknown error'}</p>
|
|
195
|
+
</div>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Clear previous timer
|
|
202
|
+
if (renderTimerRef.current) {
|
|
203
|
+
clearTimeout(renderTimerRef.current);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Debounce: wait 500ms after last update
|
|
207
|
+
renderTimerRef.current = setTimeout(() => {
|
|
208
|
+
renderChart();
|
|
209
|
+
}, 500);
|
|
210
|
+
|
|
211
|
+
return () => {
|
|
212
|
+
if (renderTimerRef.current) {
|
|
213
|
+
clearTimeout(renderTimerRef.current);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}, [chart, theme, isCompact, isMermaidCodeComplete, cleanupMermaidErrors]);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
mermaidRef,
|
|
220
|
+
svgContent,
|
|
221
|
+
isVertical,
|
|
222
|
+
isRendering,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for validating Mermaid code completeness
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function useMermaidValidation() {
|
|
6
|
+
const isMermaidCodeComplete = (code: string): boolean => {
|
|
7
|
+
if (!code || code.trim().length === 0) return false;
|
|
8
|
+
|
|
9
|
+
const trimmed = code.trim();
|
|
10
|
+
|
|
11
|
+
// Check if code has basic structure
|
|
12
|
+
const lines = trimmed.split('\n');
|
|
13
|
+
if (lines.length < 2) return false; // Need at least diagram type + one element
|
|
14
|
+
|
|
15
|
+
// Check for common incomplete patterns
|
|
16
|
+
const lastLine = lines[lines.length - 1].trim();
|
|
17
|
+
|
|
18
|
+
// Incomplete if last line ends with arrow without destination
|
|
19
|
+
if (lastLine.match(/-->?\s*$/)) return false;
|
|
20
|
+
if (lastLine.match(/-->\|[^|]*\|\s*$/)) return false;
|
|
21
|
+
|
|
22
|
+
// Incomplete if last line ends with opening bracket/parenthesis
|
|
23
|
+
if (lastLine.match(/[\[({]\s*$/)) return false;
|
|
24
|
+
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return { isMermaidCodeComplete };
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper utilities for Mermaid diagram rendering
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Utility function to apply text colors to Mermaid SVG
|
|
6
|
+
export const applyMermaidTextColors = (container: HTMLElement, textColor: string) => {
|
|
7
|
+
const svgElement = container.querySelector('svg');
|
|
8
|
+
if (svgElement) {
|
|
9
|
+
// SVG text elements use 'fill'
|
|
10
|
+
svgElement.querySelectorAll('text').forEach((el) => {
|
|
11
|
+
(el as SVGElement).style.fill = textColor;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// HTML elements inside foreignObject use 'color'
|
|
15
|
+
svgElement.querySelectorAll('.nodeLabel, .edgeLabel').forEach((el) => {
|
|
16
|
+
(el as HTMLElement).style.color = textColor;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Detect if diagram is vertical (tall and narrow)
|
|
22
|
+
export const isVerticalDiagram = (svgElement: SVGSVGElement): boolean => {
|
|
23
|
+
const viewBox = svgElement.getAttribute('viewBox');
|
|
24
|
+
if (viewBox) {
|
|
25
|
+
const [, , width, height] = viewBox.split(' ').map(Number);
|
|
26
|
+
return height > width * 1.5;
|
|
27
|
+
}
|
|
28
|
+
const bbox = svgElement.getBBox?.();
|
|
29
|
+
if (bbox) {
|
|
30
|
+
return bbox.height > bbox.width * 1.5;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|