@djangocfg/ui-nextjs 2.1.27 → 2.1.29

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-nextjs",
3
- "version": "2.1.27",
3
+ "version": "2.1.29",
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.27",
62
- "@djangocfg/ui-core": "^2.1.27",
61
+ "@djangocfg/api": "^2.1.29",
62
+ "@djangocfg/ui-core": "^2.1.29",
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.27",
107
+ "@djangocfg/typescript-config": "^2.1.29",
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 mermaid from 'mermaid';
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; // Compact mode for smaller font sizes
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
- useEffect(() => {
55
- // Get CSS variables for semantic colors
56
- const getCSSVariable = (variable: string) => {
57
- if (typeof document === 'undefined') return '';
58
- const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
59
- return value ? `hsl(${value})` : '';
60
- };
61
-
62
- // Font size based on compact mode
63
- const diagramFontSize = isCompact ? '12px' : '14px';
64
-
65
- // Note: In Mermaid v11+, mermaidAPI.reset() no longer exists
66
- // Re-initialization happens automatically via mermaid.initialize()
67
-
68
- const themeVariables = theme === 'dark' ? {
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
- setIsFullscreen(true);
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
- {/* Fullscreen Modal - rendered in portal */}
305
- {isFullscreen && typeof document !== 'undefined' && createPortal(
306
- <div
307
- className="fixed inset-0 z-9999 flex items-center justify-center p-4"
308
- style={{ backgroundColor: 'rgb(0 0 0 / 0.75)' }}
309
- onClick={handleBackdropClick}
310
- >
311
- <div className={`relative bg-card rounded-sm shadow-xl max-h-[95vh] flex flex-col border border-border ${
312
- isVertical
313
- ? 'w-auto max-w-[500px]'
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,4 @@
1
+ export { useMermaidRenderer } from './useMermaidRenderer';
2
+ export { useMermaidValidation } from './useMermaidValidation';
3
+ export { useMermaidCleanup } from './useMermaidCleanup';
4
+ export { useMermaidFullscreen } from './useMermaidFullscreen';
@@ -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
+ };