@djangocfg/widget-diagram 0.1.1
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/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +77 -0
- package/src/FloatingToolbar/FloatingToolbar.css +5 -0
- package/src/FloatingToolbar/actions/CopyAction.tsx +31 -0
- package/src/FloatingToolbar/actions/DownloadAction.tsx +51 -0
- package/src/FloatingToolbar/actions/ExpandAction.tsx +33 -0
- package/src/FloatingToolbar/actions/FullscreenAction.tsx +38 -0
- package/src/FloatingToolbar/actions/index.ts +4 -0
- package/src/FloatingToolbar/hooks/useScrollIsolation.ts +62 -0
- package/src/FloatingToolbar/index.tsx +184 -0
- package/src/Mermaid.client.tsx +97 -0
- package/src/builders/FlowDiagram/FlowDiagram.ts +96 -0
- package/src/builders/FlowDiagram/functions/getEdges.ts +50 -0
- package/src/builders/FlowDiagram/functions/getNodes.ts +43 -0
- package/src/builders/FlowDiagram/functions/getStyles.ts +90 -0
- package/src/builders/FlowDiagram/functions/index.ts +8 -0
- package/src/builders/FlowDiagram/index.ts +16 -0
- package/src/builders/FlowDiagram/types.ts +130 -0
- package/src/builders/JourneyDiagram/JourneyDiagram.ts +88 -0
- package/src/builders/JourneyDiagram/index.ts +12 -0
- package/src/builders/JourneyDiagram/types.ts +48 -0
- package/src/builders/SequenceDiagram/SequenceDiagram.ts +158 -0
- package/src/builders/SequenceDiagram/functions/getActivations.ts +30 -0
- package/src/builders/SequenceDiagram/functions/getBlocks.ts +112 -0
- package/src/builders/SequenceDiagram/functions/getMessages.ts +85 -0
- package/src/builders/SequenceDiagram/functions/getNotes.ts +94 -0
- package/src/builders/SequenceDiagram/functions/index.ts +16 -0
- package/src/builders/SequenceDiagram/index.ts +18 -0
- package/src/builders/SequenceDiagram/types.ts +192 -0
- package/src/builders/core/DiagramStore.ts +138 -0
- package/src/builders/core/index.ts +8 -0
- package/src/builders/core/sanitize.ts +83 -0
- package/src/builders/core/theme.ts +42 -0
- package/src/builders/core/types.ts +183 -0
- package/src/builders/index.ts +96 -0
- package/src/components/MermaidCodeViewer.tsx +95 -0
- package/src/components/MermaidErrorPanel.tsx +31 -0
- package/src/components/MermaidFullscreenModal.tsx +201 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useMermaidCleanup.ts +70 -0
- package/src/hooks/useMermaidFullscreen.ts +46 -0
- package/src/hooks/useMermaidRenderer.ts +329 -0
- package/src/hooks/useMermaidValidation.ts +97 -0
- package/src/index.tsx +79 -0
- package/src/lazy.tsx +40 -0
- package/src/mermaid.stories.tsx +217 -0
- package/src/types.ts +28 -0
- package/src/utils/mermaid-helpers.ts +157 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { X, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
|
6
|
+
import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
|
|
7
|
+
|
|
8
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
9
|
+
import { applyMermaidTextColors, getTextColor } from '../utils/mermaid-helpers';
|
|
10
|
+
|
|
11
|
+
interface MermaidFullscreenModalProps {
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
svgContent: string;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the source diagram is vertical. Kept for API compatibility
|
|
16
|
+
* with callers; the modal now auto-fits via measured bbox so it no
|
|
17
|
+
* longer needs an orientation hint.
|
|
18
|
+
*/
|
|
19
|
+
isVertical?: boolean;
|
|
20
|
+
theme: string;
|
|
21
|
+
/** Diagram source. Reserved for future modal actions (copy/export). */
|
|
22
|
+
chart?: string;
|
|
23
|
+
fullscreenRef: React.RefObject<HTMLDivElement | null>;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onBackdropClick: (e: React.MouseEvent) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Zoom controls component
|
|
29
|
+
function ZoomControls() {
|
|
30
|
+
const { zoomIn, zoomOut, resetTransform } = useControls();
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10">
|
|
34
|
+
<Button variant="secondary" size="icon" onClick={() => zoomOut()}>
|
|
35
|
+
<ZoomOut className="h-4 w-4" />
|
|
36
|
+
</Button>
|
|
37
|
+
<Button variant="secondary" size="icon" onClick={() => resetTransform()}>
|
|
38
|
+
<RotateCcw className="h-4 w-4" />
|
|
39
|
+
</Button>
|
|
40
|
+
<Button variant="secondary" size="icon" onClick={() => zoomIn()}>
|
|
41
|
+
<ZoomIn className="h-4 w-4" />
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const MermaidFullscreenModal: React.FC<MermaidFullscreenModalProps> = ({
|
|
48
|
+
isOpen,
|
|
49
|
+
svgContent,
|
|
50
|
+
theme,
|
|
51
|
+
fullscreenRef,
|
|
52
|
+
onClose,
|
|
53
|
+
onBackdropClick,
|
|
54
|
+
}) => {
|
|
55
|
+
// Auto-fit scale on open. Two failure modes drove this design:
|
|
56
|
+
//
|
|
57
|
+
// 1. Stale state across re-opens. Without a reset, the second
|
|
58
|
+
// open would still see the previous fit value in state; if
|
|
59
|
+
// the new SVG had the same dimensions the `key` swap below
|
|
60
|
+
// wouldn't fire and TransformWrapper would skip re-init.
|
|
61
|
+
// 2. SVG not in DOM yet at first rAF. Mermaid renders into
|
|
62
|
+
// `fullscreenRef` after the modal portal mounts; on a fast
|
|
63
|
+
// paint the first `querySelector('svg')` returned null and
|
|
64
|
+
// the scale stayed at the fallback `1`. Retry across a few
|
|
65
|
+
// frames until the bbox is real, then commit.
|
|
66
|
+
//
|
|
67
|
+
// `openSeq` increments on every open so the `key` always changes,
|
|
68
|
+
// forcing a fresh TransformWrapper instance even when the fit
|
|
69
|
+
// value happens to repeat.
|
|
70
|
+
const [initialScale, setInitialScale] = useState<number | null>(null);
|
|
71
|
+
const [openSeq, setOpenSeq] = useState(0);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!isOpen) {
|
|
74
|
+
// Reset so the next open recomputes from scratch.
|
|
75
|
+
setInitialScale(null);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setOpenSeq((n) => n + 1);
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
let attempts = 0;
|
|
81
|
+
const tick = () => {
|
|
82
|
+
if (cancelled) return;
|
|
83
|
+
attempts += 1;
|
|
84
|
+
const svg = fullscreenRef.current?.querySelector('svg');
|
|
85
|
+
const bbox = svg?.getBoundingClientRect();
|
|
86
|
+
if (svg && bbox && bbox.width > 1 && bbox.height > 1) {
|
|
87
|
+
const targetW = window.innerWidth * 0.9;
|
|
88
|
+
const targetH = window.innerHeight * 0.9;
|
|
89
|
+
const fit = Math.min(targetW / bbox.width, targetH / bbox.height);
|
|
90
|
+
setInitialScale(Math.max(1, Math.min(fit, 6)));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Give Mermaid up to ~30 frames (~0.5s @ 60fps) to paint
|
|
94
|
+
// before settling for the unscaled fallback.
|
|
95
|
+
if (attempts < 30) {
|
|
96
|
+
requestAnimationFrame(tick);
|
|
97
|
+
} else {
|
|
98
|
+
setInitialScale(1);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
requestAnimationFrame(tick);
|
|
102
|
+
return () => {
|
|
103
|
+
cancelled = true;
|
|
104
|
+
};
|
|
105
|
+
}, [isOpen, svgContent, fullscreenRef]);
|
|
106
|
+
|
|
107
|
+
// Re-assert theme text colors on the fullscreen SVG. The shared
|
|
108
|
+
// `getTextColor` reads the (already fully-wrapped) `--foreground`
|
|
109
|
+
// token — it never double-wraps `hsl(...)`.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (isOpen && fullscreenRef.current) {
|
|
112
|
+
applyMermaidTextColors(fullscreenRef.current, getTextColor(theme));
|
|
113
|
+
}
|
|
114
|
+
}, [isOpen, theme, fullscreenRef, svgContent]);
|
|
115
|
+
|
|
116
|
+
// Handle escape key
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!isOpen) return;
|
|
119
|
+
|
|
120
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
121
|
+
if (e.key === 'Escape') onClose();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
125
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
126
|
+
}, [isOpen, onClose]);
|
|
127
|
+
|
|
128
|
+
if (!isOpen || typeof document === 'undefined') return null;
|
|
129
|
+
|
|
130
|
+
// Hoist derived values out of JSX (COMPONENTS.md "Data Preparation
|
|
131
|
+
// Before Render"). Keeps the returned tree pure markup, makes it
|
|
132
|
+
// obvious at the top of the function which inputs feed which
|
|
133
|
+
// node, and surfaces every dependency to a reader at a glance.
|
|
134
|
+
const transformInitialScale = initialScale ?? 1;
|
|
135
|
+
const transformKey = `${openSeq}-${initialScale ?? 'pending'}`;
|
|
136
|
+
|
|
137
|
+
return createPortal(
|
|
138
|
+
<div
|
|
139
|
+
className="fixed inset-0 z-9999 bg-background/95 backdrop-blur-sm"
|
|
140
|
+
onClick={onBackdropClick}
|
|
141
|
+
>
|
|
142
|
+
{/* Close button */}
|
|
143
|
+
<Button
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="icon"
|
|
146
|
+
className="absolute top-4 right-4 z-10"
|
|
147
|
+
onClick={onClose}
|
|
148
|
+
>
|
|
149
|
+
<X className="h-5 w-5" />
|
|
150
|
+
</Button>
|
|
151
|
+
|
|
152
|
+
{/* Zoomable diagram. `key={openSeq}-${initialScale ?? 'pending'}`
|
|
153
|
+
forces a fresh TransformWrapper:
|
|
154
|
+
- on every modal open (openSeq increments) so the
|
|
155
|
+
re-opened modal never inherits the prior session's
|
|
156
|
+
transform;
|
|
157
|
+
- whenever the auto-fit value lands (null → number)
|
|
158
|
+
so the wrapper, which only reads `initialScale`
|
|
159
|
+
at mount time, picks up the freshly measured fit.
|
|
160
|
+
We can't gate the whole subtree on `initialScale != null`
|
|
161
|
+
because the SVG host (`fullscreenRef` div) lives inside
|
|
162
|
+
TransformComponent — without it in the DOM, the rAF
|
|
163
|
+
measure loop has nothing to read and we'd deadlock at
|
|
164
|
+
null forever. Mounting with placeholder `1` first and
|
|
165
|
+
re-mounting once we know the fit is the cheap fix. */}
|
|
166
|
+
<TransformWrapper
|
|
167
|
+
key={transformKey}
|
|
168
|
+
initialScale={transformInitialScale}
|
|
169
|
+
minScale={0.1}
|
|
170
|
+
maxScale={10}
|
|
171
|
+
centerOnInit
|
|
172
|
+
wheel={{ step: 0.1 }}
|
|
173
|
+
pinch={{ step: 5 }}
|
|
174
|
+
doubleClick={{ mode: 'reset' }}
|
|
175
|
+
>
|
|
176
|
+
<ZoomControls />
|
|
177
|
+
<TransformComponent
|
|
178
|
+
wrapperStyle={{
|
|
179
|
+
width: '100%',
|
|
180
|
+
height: '100%',
|
|
181
|
+
}}
|
|
182
|
+
contentStyle={{
|
|
183
|
+
width: '100%',
|
|
184
|
+
height: '100%',
|
|
185
|
+
display: 'flex',
|
|
186
|
+
alignItems: 'center',
|
|
187
|
+
justifyContent: 'center',
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<div
|
|
191
|
+
ref={fullscreenRef}
|
|
192
|
+
className="p-8"
|
|
193
|
+
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
194
|
+
onClick={(e) => e.stopPropagation()}
|
|
195
|
+
/>
|
|
196
|
+
</TransformComponent>
|
|
197
|
+
</TransformWrapper>
|
|
198
|
+
</div>,
|
|
199
|
+
document.body
|
|
200
|
+
);
|
|
201
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for cleaning up orphaned Mermaid DOM nodes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect } from 'react';
|
|
6
|
+
|
|
7
|
+
export function useMermaidCleanup() {
|
|
8
|
+
const cleanupMermaidErrors = useCallback(() => {
|
|
9
|
+
if (typeof document === 'undefined') return;
|
|
10
|
+
|
|
11
|
+
// Remove all orphaned mermaid elements from body
|
|
12
|
+
// Mermaid can append: SVGs, divs with errors, text nodes
|
|
13
|
+
|
|
14
|
+
// 1. Remove elements with mermaid-* IDs directly in body
|
|
15
|
+
document.querySelectorAll('[id^="mermaid-"]').forEach((node) => {
|
|
16
|
+
if (node.parentNode === document.body) {
|
|
17
|
+
node.remove();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 2. Remove elements with d prefix (mermaid diagram IDs) directly in body
|
|
22
|
+
document.querySelectorAll('[id^="d"]').forEach((node) => {
|
|
23
|
+
if (node.parentNode === document.body && node.id.match(/^d\d+$/)) {
|
|
24
|
+
node.remove();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 3. Remove orphaned SVG elements that mermaid creates in body
|
|
29
|
+
document.querySelectorAll('body > svg').forEach((node) => {
|
|
30
|
+
// Check if it's a mermaid SVG (has mermaid classes or aria-roledescription)
|
|
31
|
+
if (node.getAttribute('aria-roledescription') ||
|
|
32
|
+
node.classList.contains('mermaid') ||
|
|
33
|
+
node.querySelector('.mermaid') ||
|
|
34
|
+
node.id?.includes('mermaid')) {
|
|
35
|
+
node.remove();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 4. Remove any orphaned error divs with "Syntax error" text
|
|
40
|
+
document.querySelectorAll('body > div').forEach((node) => {
|
|
41
|
+
const text = node.textContent || '';
|
|
42
|
+
if (text.includes('Syntax error in text') ||
|
|
43
|
+
text.includes('mermaid version') ||
|
|
44
|
+
node.id?.startsWith('mermaid-') ||
|
|
45
|
+
node.id?.startsWith('d') && node.id.match(/^d\d+$/)) {
|
|
46
|
+
node.remove();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 5. Remove orphaned pre elements with error info
|
|
51
|
+
document.querySelectorAll('body > pre').forEach((node) => {
|
|
52
|
+
const text = node.textContent || '';
|
|
53
|
+
if (text.includes('Syntax error') || text.includes('mermaid')) {
|
|
54
|
+
node.remove();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Cleanup on unmount
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
return () => {
|
|
62
|
+
cleanupMermaidErrors();
|
|
63
|
+
};
|
|
64
|
+
}, [cleanupMermaidErrors]);
|
|
65
|
+
|
|
66
|
+
// Removed periodic cleanup - it causes unnecessary re-renders
|
|
67
|
+
// Cleanup only happens on unmount now
|
|
68
|
+
|
|
69
|
+
return { cleanupMermaidErrors };
|
|
70
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing Mermaid fullscreen modal
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState } 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,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for rendering Mermaid diagrams with debounced, race-safe rendering.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid (~800KB) is imported eagerly here because the whole component
|
|
5
|
+
* tree is already lazy-loaded behind `Mermaid.client` — splitting again
|
|
6
|
+
* would just add a second waterfall for no win.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import mermaid from 'mermaid';
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
applyMermaidErRowColors,
|
|
14
|
+
applyMermaidTextColors,
|
|
15
|
+
getThemeColor,
|
|
16
|
+
getTextColor,
|
|
17
|
+
isVerticalDiagram,
|
|
18
|
+
} from '../utils/mermaid-helpers';
|
|
19
|
+
import { useMermaidCleanup } from './useMermaidCleanup';
|
|
20
|
+
import { useMermaidValidation } from './useMermaidValidation';
|
|
21
|
+
|
|
22
|
+
interface UseMermaidRendererProps {
|
|
23
|
+
chart: string;
|
|
24
|
+
theme: string;
|
|
25
|
+
isCompact?: boolean;
|
|
26
|
+
/** Debounce window in ms before (re)rendering. Default 300. */
|
|
27
|
+
debounceMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface MermaidRenderResult {
|
|
31
|
+
mermaidRef: React.RefObject<HTMLDivElement | null>;
|
|
32
|
+
svgContent: string;
|
|
33
|
+
isVertical: boolean;
|
|
34
|
+
isRendering: boolean;
|
|
35
|
+
/** Set when the last render failed with a syntax / parse error. */
|
|
36
|
+
error: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Section color scales for timeline / journey / mindmap / pie diagrams.
|
|
41
|
+
*
|
|
42
|
+
* These diagram types do NOT use `mainBkg` for their boxes — they cycle
|
|
43
|
+
* through `cScale0..N` (and `pie1..N`) instead. Mermaid's `base` theme
|
|
44
|
+
* derives those scales from `primaryColor`, which lands far too dark in
|
|
45
|
+
* light mode (dark box) while the section label still inherits the
|
|
46
|
+
* default dark `textColor` — i.e. dark text on a dark box.
|
|
47
|
+
*
|
|
48
|
+
* We pin an explicit, theme-aware palette: mid-saturation backgrounds
|
|
49
|
+
* that read on either page background, each paired with an explicit
|
|
50
|
+
* contrasting label color via `cScaleLabel*`. `cScalePeer*` colors the
|
|
51
|
+
* sub-task boxes that sit under a section.
|
|
52
|
+
*/
|
|
53
|
+
const SECTION_SCALES = {
|
|
54
|
+
light: [
|
|
55
|
+
{ bg: '#1f6f8b', label: '#ffffff', peer: '#3a8ba6' },
|
|
56
|
+
{ bg: '#7b5ea7', label: '#ffffff', peer: '#977dc0' },
|
|
57
|
+
{ bg: '#2e8b57', label: '#ffffff', peer: '#4caf7d' },
|
|
58
|
+
{ bg: '#c25a3a', label: '#ffffff', peer: '#d6805f' },
|
|
59
|
+
{ bg: '#3a6ea5', label: '#ffffff', peer: '#5d8cc0' },
|
|
60
|
+
{ bg: '#a8456b', label: '#ffffff', peer: '#c06b8b' },
|
|
61
|
+
{ bg: '#5f8f3a', label: '#ffffff', peer: '#80aa5d' },
|
|
62
|
+
{ bg: '#9a7d2e', label: '#ffffff', peer: '#b89c50' },
|
|
63
|
+
],
|
|
64
|
+
dark: [
|
|
65
|
+
{ bg: '#3aa6c9', label: '#0b1620', peer: '#2b7d99' },
|
|
66
|
+
{ bg: '#b39ddb', label: '#1a142b', peer: '#8a72b5' },
|
|
67
|
+
{ bg: '#66c990', label: '#0c1f15', peer: '#479a6b' },
|
|
68
|
+
{ bg: '#e8956f', label: '#2a1409', peer: '#bd6f4c' },
|
|
69
|
+
{ bg: '#79a8d9', label: '#0d1726', peer: '#577fad' },
|
|
70
|
+
{ bg: '#d987a8', label: '#2a0f1b', peer: '#ad6082' },
|
|
71
|
+
{ bg: '#a4cf7a', label: '#142008', peer: '#7da352' },
|
|
72
|
+
{ bg: '#d4bb6e', label: '#241c08', peer: '#a8924c' },
|
|
73
|
+
],
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build Mermaid `themeVariables` from our semantic tokens.
|
|
78
|
+
*
|
|
79
|
+
* Tokens are read live from the DOM so the diagram tracks light/dark
|
|
80
|
+
* without a hard-coded palette. Fallbacks only fire during SSR or before
|
|
81
|
+
* stylesheets load.
|
|
82
|
+
*/
|
|
83
|
+
function buildThemeVariables(theme: string, fontSize: string) {
|
|
84
|
+
const isDark = theme === 'dark';
|
|
85
|
+
const fg = getThemeColor('--foreground', isDark ? 'hsl(0 0% 98%)' : 'hsl(0 0% 9%)');
|
|
86
|
+
const card = getThemeColor('--card', isDark ? 'hsl(0 0% 8%)' : 'hsl(0 0% 100%)');
|
|
87
|
+
const muted = getThemeColor('--muted', isDark ? 'hsl(0 0% 15%)' : 'hsl(0 0% 96%)');
|
|
88
|
+
const border = getThemeColor('--border', isDark ? 'hsl(0 0% 15%)' : 'hsl(0 0% 90%)');
|
|
89
|
+
const primary = getThemeColor('--primary', isDark ? 'hsl(189 100% 50%)' : 'hsl(192 90% 35%)');
|
|
90
|
+
const accent = getThemeColor('--accent', muted);
|
|
91
|
+
const secondary = getThemeColor('--secondary', muted);
|
|
92
|
+
const background = getThemeColor('--background', isDark ? 'hsl(0 0% 4%)' : 'hsl(0 0% 94%)');
|
|
93
|
+
const destructive = getThemeColor('--destructive', 'hsl(0 84% 60%)');
|
|
94
|
+
const destructiveFg = getThemeColor('--destructive-foreground', 'hsl(0 0% 98%)');
|
|
95
|
+
|
|
96
|
+
const scales = SECTION_SCALES[isDark ? 'dark' : 'light'];
|
|
97
|
+
// `cScale*` / `pie*` / `fillType*` are flat keys (cScale0, pie1, ...).
|
|
98
|
+
//
|
|
99
|
+
// `fillType*` is what the **journey** diagram actually paints its
|
|
100
|
+
// section / task rects with. Left unset, Mermaid derives it by
|
|
101
|
+
// rotating the hue of `cScale*` and forcing odd indexes to
|
|
102
|
+
// `hsl(H, 0%, 9%)` — a near-black box. Pinning `fillType*` to our
|
|
103
|
+
// explicit palette kills the dark-on-dark sections.
|
|
104
|
+
const sectionVars: Record<string, string> = {};
|
|
105
|
+
scales.forEach((s, i) => {
|
|
106
|
+
sectionVars[`cScale${i}`] = s.bg;
|
|
107
|
+
sectionVars[`cScaleLabel${i}`] = s.label;
|
|
108
|
+
sectionVars[`cScaleInv${i}`] = s.label;
|
|
109
|
+
sectionVars[`cScalePeer${i}`] = s.peer;
|
|
110
|
+
sectionVars[`fillType${i}`] = s.bg;
|
|
111
|
+
sectionVars[`surface${i}`] = s.bg;
|
|
112
|
+
// Pie slices are 1-indexed and have no separate label var — the
|
|
113
|
+
// slice text color is global (`pieSectionTextColor`).
|
|
114
|
+
sectionVars[`pie${i + 1}`] = s.bg;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
primaryColor: primary,
|
|
119
|
+
primaryTextColor: fg,
|
|
120
|
+
primaryBorderColor: primary,
|
|
121
|
+
secondaryColor: secondary,
|
|
122
|
+
secondaryTextColor: fg,
|
|
123
|
+
secondaryBorderColor: border,
|
|
124
|
+
tertiaryColor: accent,
|
|
125
|
+
tertiaryTextColor: fg,
|
|
126
|
+
tertiaryBorderColor: border,
|
|
127
|
+
mainBkg: card,
|
|
128
|
+
textColor: fg,
|
|
129
|
+
nodeBorder: border,
|
|
130
|
+
nodeTextColor: fg,
|
|
131
|
+
secondBkg: muted,
|
|
132
|
+
lineColor: primary,
|
|
133
|
+
edgeLabelBackground: card,
|
|
134
|
+
clusterBkg: muted,
|
|
135
|
+
clusterBorder: primary,
|
|
136
|
+
background,
|
|
137
|
+
labelBackground: card,
|
|
138
|
+
labelTextColor: fg,
|
|
139
|
+
errorBkgColor: destructive,
|
|
140
|
+
errorTextColor: destructiveFg,
|
|
141
|
+
|
|
142
|
+
// --- ER diagram ---
|
|
143
|
+
// ER attribute-row zebra fills are derived from `mainBkg` by
|
|
144
|
+
// Mermaid and ignore `themeVariables` — they are re-asserted
|
|
145
|
+
// post-render via `applyMermaidErRowColors`.
|
|
146
|
+
|
|
147
|
+
// --- Section-colored diagrams (timeline / journey / mindmap) ---
|
|
148
|
+
...sectionVars,
|
|
149
|
+
// Timeline / journey section title bars + the global label fallback.
|
|
150
|
+
cScaleLabel0: scales[0]?.label ?? fg,
|
|
151
|
+
// Journey actor faces / labels in the legend.
|
|
152
|
+
actorBkg: card,
|
|
153
|
+
actorBorder: border,
|
|
154
|
+
actorTextColor: fg,
|
|
155
|
+
actorLineColor: border,
|
|
156
|
+
// Mindmap nodes inherit `cScale*`; their text uses `nodeTextColor`
|
|
157
|
+
// on the outer ring — keep it readable on the page background.
|
|
158
|
+
|
|
159
|
+
// --- Pie chart ---
|
|
160
|
+
pieTitleTextColor: fg,
|
|
161
|
+
pieSectionTextColor: isDark ? '#0b1620' : '#ffffff',
|
|
162
|
+
pieSectionTextSize: '13px',
|
|
163
|
+
pieLegendTextColor: fg,
|
|
164
|
+
pieLegendTextSize: '13px',
|
|
165
|
+
pieStrokeColor: background,
|
|
166
|
+
pieStrokeWidth: '2px',
|
|
167
|
+
pieOuterStrokeColor: border,
|
|
168
|
+
pieOuterStrokeWidth: '1px',
|
|
169
|
+
pieOpacity: '1',
|
|
170
|
+
|
|
171
|
+
// --- Git graph ---
|
|
172
|
+
git0: scales[0]?.bg ?? primary,
|
|
173
|
+
git1: scales[1]?.bg ?? secondary,
|
|
174
|
+
git2: scales[2]?.bg ?? accent,
|
|
175
|
+
git3: scales[3]?.bg ?? primary,
|
|
176
|
+
git4: scales[4]?.bg ?? secondary,
|
|
177
|
+
git5: scales[5]?.bg ?? accent,
|
|
178
|
+
git6: scales[6]?.bg ?? primary,
|
|
179
|
+
git7: scales[7]?.bg ?? secondary,
|
|
180
|
+
gitBranchLabel0: scales[0]?.label ?? fg,
|
|
181
|
+
gitBranchLabel1: scales[1]?.label ?? fg,
|
|
182
|
+
gitBranchLabel2: scales[2]?.label ?? fg,
|
|
183
|
+
gitBranchLabel3: scales[3]?.label ?? fg,
|
|
184
|
+
gitBranchLabel4: scales[4]?.label ?? fg,
|
|
185
|
+
gitBranchLabel5: scales[5]?.label ?? fg,
|
|
186
|
+
gitBranchLabel6: scales[6]?.label ?? fg,
|
|
187
|
+
gitBranchLabel7: scales[7]?.label ?? fg,
|
|
188
|
+
gitInv0: scales[0]?.label ?? fg,
|
|
189
|
+
commitLabelColor: fg,
|
|
190
|
+
commitLabelBackground: card,
|
|
191
|
+
tagLabelColor: fg,
|
|
192
|
+
tagLabelBackground: muted,
|
|
193
|
+
tagLabelBorder: border,
|
|
194
|
+
|
|
195
|
+
fontSize,
|
|
196
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function useMermaidRenderer({
|
|
201
|
+
chart,
|
|
202
|
+
theme,
|
|
203
|
+
isCompact = false,
|
|
204
|
+
debounceMs = 300,
|
|
205
|
+
}: UseMermaidRendererProps): MermaidRenderResult {
|
|
206
|
+
const mermaidRef = useRef<HTMLDivElement>(null);
|
|
207
|
+
const renderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
208
|
+
// Monotonic token: every effect run bumps it, and an in-flight async
|
|
209
|
+
// render checks it before touching the DOM. Guards against a stale
|
|
210
|
+
// chart's `mermaid.render` resolving after a newer one started.
|
|
211
|
+
const renderSeqRef = useRef(0);
|
|
212
|
+
|
|
213
|
+
const [svgContent, setSvgContent] = useState<string>('');
|
|
214
|
+
const [isVertical, setIsVertical] = useState(false);
|
|
215
|
+
const [isRendering, setIsRendering] = useState(false);
|
|
216
|
+
const [error, setError] = useState<string | null>(null);
|
|
217
|
+
|
|
218
|
+
const { isMermaidCodeComplete } = useMermaidValidation();
|
|
219
|
+
const { cleanupMermaidErrors } = useMermaidCleanup();
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const seq = ++renderSeqRef.current;
|
|
223
|
+
const isStale = () => seq !== renderSeqRef.current;
|
|
224
|
+
|
|
225
|
+
const fontSize = isCompact ? '12px' : '14px';
|
|
226
|
+
|
|
227
|
+
const renderChart = async () => {
|
|
228
|
+
const host = mermaidRef.current;
|
|
229
|
+
if (!host || !chart) {
|
|
230
|
+
setIsRendering(false);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Streaming guard: an incomplete diagram (still being typed /
|
|
235
|
+
// streamed) would throw a parse error. Keep the previous SVG
|
|
236
|
+
// visible and just show the spinner — don't flash an error.
|
|
237
|
+
if (!isMermaidCodeComplete(chart)) {
|
|
238
|
+
setIsRendering(true);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
setIsRendering(true);
|
|
243
|
+
setError(null);
|
|
244
|
+
|
|
245
|
+
// `mermaid.initialize` is global + idempotent; re-running it
|
|
246
|
+
// per render keeps `themeVariables` in sync with the live
|
|
247
|
+
// light/dark tokens (cheap, no library reload).
|
|
248
|
+
mermaid.initialize({
|
|
249
|
+
startOnLoad: false,
|
|
250
|
+
theme: 'base',
|
|
251
|
+
securityLevel: 'loose',
|
|
252
|
+
suppressErrorRendering: true,
|
|
253
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
254
|
+
flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' },
|
|
255
|
+
themeVariables: buildThemeVariables(theme, fontSize),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// `mermaid.parse` validates without mutating the DOM —
|
|
262
|
+
// catches syntax errors before `render` appends anything.
|
|
263
|
+
await mermaid.parse(chart);
|
|
264
|
+
const { svg } = await mermaid.render(id, chart);
|
|
265
|
+
|
|
266
|
+
if (isStale() || !mermaidRef.current) return;
|
|
267
|
+
|
|
268
|
+
const textColor = getTextColor(theme);
|
|
269
|
+
const processedSvg = svg.replace(
|
|
270
|
+
/<svg /,
|
|
271
|
+
`<svg style="--mermaid-text-color: ${textColor};" `,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
mermaidRef.current.innerHTML = processedSvg;
|
|
275
|
+
applyMermaidTextColors(mermaidRef.current, textColor);
|
|
276
|
+
// ER attribute rows zebra-stripe off `mainBkg` and ignore
|
|
277
|
+
// `themeVariables` — re-assert themed, contrasting fills.
|
|
278
|
+
applyMermaidErRowColors(
|
|
279
|
+
mermaidRef.current,
|
|
280
|
+
getThemeColor('--card', theme === 'dark' ? 'hsl(0 0% 8%)' : 'hsl(0 0% 100%)'),
|
|
281
|
+
getThemeColor('--muted', theme === 'dark' ? 'hsl(0 0% 15%)' : 'hsl(0 0% 96%)'),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const svgElement = mermaidRef.current.querySelector('svg');
|
|
285
|
+
if (svgElement) {
|
|
286
|
+
svgElement.style.maxWidth = '100%';
|
|
287
|
+
svgElement.style.height = 'auto';
|
|
288
|
+
svgElement.style.display = 'block';
|
|
289
|
+
setIsVertical(isVerticalDiagram(svgElement));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
setSvgContent(processedSvg);
|
|
293
|
+
setError(null);
|
|
294
|
+
setIsRendering(false);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// `render` may still have appended an orphan node to
|
|
297
|
+
// <body> despite `suppressErrorRendering` — sweep it.
|
|
298
|
+
cleanupMermaidErrors();
|
|
299
|
+
if (isStale()) return;
|
|
300
|
+
|
|
301
|
+
const message =
|
|
302
|
+
err instanceof Error ? err.message : 'Failed to render diagram';
|
|
303
|
+
setError(message);
|
|
304
|
+
setSvgContent('');
|
|
305
|
+
setIsVertical(false);
|
|
306
|
+
setIsRendering(false);
|
|
307
|
+
if (mermaidRef.current) {
|
|
308
|
+
mermaidRef.current.innerHTML = '';
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (renderTimerRef.current) {
|
|
314
|
+
clearTimeout(renderTimerRef.current);
|
|
315
|
+
}
|
|
316
|
+
renderTimerRef.current = setTimeout(renderChart, debounceMs);
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
// Invalidate any in-flight async render from this effect run.
|
|
320
|
+
renderSeqRef.current++;
|
|
321
|
+
if (renderTimerRef.current) {
|
|
322
|
+
clearTimeout(renderTimerRef.current);
|
|
323
|
+
renderTimerRef.current = null;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}, [chart, theme, isCompact, debounceMs, isMermaidCodeComplete, cleanupMermaidErrors]);
|
|
327
|
+
|
|
328
|
+
return { mermaidRef, svgContent, isVertical, isRendering, error };
|
|
329
|
+
}
|