@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,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for validating Mermaid code completeness.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: while a diagram is being *streamed* (token by token from an
|
|
5
|
+
* LLM, or typed) the source is briefly invalid. Rendering it would throw
|
|
6
|
+
* a parse error and flash an error panel on every keystroke. This
|
|
7
|
+
* heuristic detects "obviously still-being-written" source so the
|
|
8
|
+
* renderer can wait instead. It is intentionally conservative — false
|
|
9
|
+
* "complete" is fine (the real parser catches it), false "incomplete"
|
|
10
|
+
* just delays a render.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback } from 'react';
|
|
14
|
+
|
|
15
|
+
/** Diagram keywords that, alone, are a valid first line. */
|
|
16
|
+
const DIAGRAM_KEYWORDS = [
|
|
17
|
+
'graph',
|
|
18
|
+
'flowchart',
|
|
19
|
+
'sequenceDiagram',
|
|
20
|
+
'classDiagram',
|
|
21
|
+
'stateDiagram',
|
|
22
|
+
'stateDiagram-v2',
|
|
23
|
+
'erDiagram',
|
|
24
|
+
'journey',
|
|
25
|
+
'gantt',
|
|
26
|
+
'pie',
|
|
27
|
+
'mindmap',
|
|
28
|
+
'timeline',
|
|
29
|
+
'gitGraph',
|
|
30
|
+
'quadrantChart',
|
|
31
|
+
'requirementDiagram',
|
|
32
|
+
'C4Context',
|
|
33
|
+
'sankey-beta',
|
|
34
|
+
'xychart-beta',
|
|
35
|
+
'block-beta',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Count occurrences of a character that are not inside a quoted string. */
|
|
39
|
+
function countUnquoted(text: string, open: string, close: string): number {
|
|
40
|
+
let depth = 0;
|
|
41
|
+
let inQuote = false;
|
|
42
|
+
for (let i = 0; i < text.length; i++) {
|
|
43
|
+
const ch = text[i];
|
|
44
|
+
if (ch === '"') {
|
|
45
|
+
inQuote = !inQuote;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (inQuote) continue;
|
|
49
|
+
if (ch === open) depth++;
|
|
50
|
+
else if (ch === close) depth--;
|
|
51
|
+
}
|
|
52
|
+
return depth;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useMermaidValidation() {
|
|
56
|
+
const isMermaidCodeComplete = useCallback((code: string): boolean => {
|
|
57
|
+
if (!code || code.trim().length === 0) return false;
|
|
58
|
+
|
|
59
|
+
const trimmed = code.trim();
|
|
60
|
+
|
|
61
|
+
// Must start with a recognised diagram keyword. During streaming
|
|
62
|
+
// the very first tokens may be a partial keyword — wait for it.
|
|
63
|
+
const firstToken = trimmed.split(/[\s\n;]/)[0] ?? '';
|
|
64
|
+
const hasKnownType = DIAGRAM_KEYWORDS.some(
|
|
65
|
+
(kw) => firstToken === kw || trimmed.startsWith(kw),
|
|
66
|
+
);
|
|
67
|
+
if (!hasKnownType) return false;
|
|
68
|
+
|
|
69
|
+
const lines = trimmed.split('\n');
|
|
70
|
+
const lastLine = (lines[lines.length - 1] ?? '').trim();
|
|
71
|
+
|
|
72
|
+
// Trailing edge with no destination: `A -->` / `A --` / `A -.->`.
|
|
73
|
+
if (/(-{1,3}>?|={1,3}>?|-\.->?|\.\.>?|--[ox])\s*$/.test(lastLine)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
// Trailing edge label still open: `A -->|label` (no closing `|`).
|
|
77
|
+
if (/-{1,3}>?\s*\|[^|]*$/.test(lastLine)) return false;
|
|
78
|
+
|
|
79
|
+
// Unbalanced shape brackets across the whole source (open > close).
|
|
80
|
+
if (countUnquoted(trimmed, '[', ']') > 0) return false;
|
|
81
|
+
if (countUnquoted(trimmed, '(', ')') > 0) return false;
|
|
82
|
+
// ER diagrams use `{` / `}` for crow's-foot cardinality
|
|
83
|
+
// (`||--o{`, `}|--||`) — those braces are deliberately unbalanced
|
|
84
|
+
// and must not be counted, or the diagram never renders.
|
|
85
|
+
if (!trimmed.startsWith('erDiagram') && countUnquoted(trimmed, '{', '}') > 0) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Odd number of double quotes — an unterminated string literal.
|
|
90
|
+
const quoteCount = (trimmed.match(/"/g) ?? []).length;
|
|
91
|
+
if (quoteCount % 2 !== 0) return false;
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
return { isMermaidCodeComplete };
|
|
97
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two halves that belong together and ship apart.
|
|
3
|
+
*
|
|
4
|
+
* WRITING a diagram — `FlowDiagram`, `SequenceDiagram`, `JourneyDiagram` — is
|
|
5
|
+
* a pure string function a server pass, a test or a CLI can call, and it costs
|
|
6
|
+
* nothing. DRAWING one loads `mermaid`, which unpacks to ~84 MB, so `Mermaid`
|
|
7
|
+
* arrives behind `React.lazy` and the library only when a diagram mounts.
|
|
8
|
+
*
|
|
9
|
+
* `@djangocfg/widget-media/diagram` is the OTHER renderer and is not a
|
|
10
|
+
* duplicate of this one. It parses with `beautiful-mermaid` (~2 MB) and covers
|
|
11
|
+
* flowchart, sequence, state, class and ER, falling back to the source for the
|
|
12
|
+
* rest — the right trade for a chat transcript, where most messages carry no
|
|
13
|
+
* diagram at all. This one carries the full parser: gantt, mindmap, pie and
|
|
14
|
+
* everything else, for a surface whose job IS the diagram.
|
|
15
|
+
*
|
|
16
|
+
* Pick by what the surface is for, and do not import both into one app.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use client';
|
|
20
|
+
|
|
21
|
+
export type { MermaidProps } from './types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The renderer, behind its dynamic import.
|
|
25
|
+
*
|
|
26
|
+
* ONE lazy boundary, not two: this file used to wrap `Mermaid.client` in its
|
|
27
|
+
* own `React.lazy` with a hand-rolled spinner while `lazy.tsx` wrapped the same
|
|
28
|
+
* module again with the shared placeholder. Two boundaries around one module
|
|
29
|
+
* split nothing extra and gave the same component two different loading states
|
|
30
|
+
* depending on which name a consumer imported.
|
|
31
|
+
*/
|
|
32
|
+
export { LazyMermaid, LazyMermaid as Mermaid, LazyMermaid as default } from './lazy';
|
|
33
|
+
|
|
34
|
+
// Re-export builders for declarative diagram construction
|
|
35
|
+
export {
|
|
36
|
+
// Core
|
|
37
|
+
DiagramStore,
|
|
38
|
+
sanitizeLabel,
|
|
39
|
+
toNodeId,
|
|
40
|
+
// Theme hooks
|
|
41
|
+
useThemePalette,
|
|
42
|
+
useStylePresets,
|
|
43
|
+
useBoxColors,
|
|
44
|
+
// FlowDiagram
|
|
45
|
+
FlowDiagram,
|
|
46
|
+
STYLE_PRESETS,
|
|
47
|
+
// SequenceDiagram
|
|
48
|
+
SequenceDiagram,
|
|
49
|
+
// JourneyDiagram
|
|
50
|
+
JourneyDiagram,
|
|
51
|
+
} from './builders';
|
|
52
|
+
|
|
53
|
+
export type {
|
|
54
|
+
// Core types
|
|
55
|
+
DiagramStoreOptions,
|
|
56
|
+
FlowDirection,
|
|
57
|
+
NodeShape,
|
|
58
|
+
ParticipantType,
|
|
59
|
+
TaskScore,
|
|
60
|
+
// Theme types
|
|
61
|
+
ThemePalette,
|
|
62
|
+
StyleColors,
|
|
63
|
+
StylePresets,
|
|
64
|
+
BoxColors,
|
|
65
|
+
// FlowDiagram types
|
|
66
|
+
FlowDiagramOptions,
|
|
67
|
+
FlowDiagramBuilder,
|
|
68
|
+
NodeBuilder,
|
|
69
|
+
EdgeBuilder,
|
|
70
|
+
StyleBuilder,
|
|
71
|
+
// SequenceDiagram types
|
|
72
|
+
ParticipantsObject,
|
|
73
|
+
SequenceDiagramOptions,
|
|
74
|
+
SequenceDiagramBuilder,
|
|
75
|
+
// JourneyDiagram types
|
|
76
|
+
JourneyDiagramOptions,
|
|
77
|
+
JourneyDiagramBuilder,
|
|
78
|
+
SectionBuilder,
|
|
79
|
+
} from './builders';
|
package/src/lazy.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The renderer's single lazy boundary.
|
|
5
|
+
*
|
|
6
|
+
* `mermaid` unpacks to ~84 MB, so it must never be a static edge. The barrel
|
|
7
|
+
* re-exports what is here rather than wrapping it a second time.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createLazyComponent, CardLoadingFallback } from '@djangocfg/widget-kit/lazy';
|
|
11
|
+
import type { MermaidProps } from './types';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export type { MermaidProps };
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Lazy Component
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* LazyMermaid - Lazy-loaded Mermaid diagram renderer
|
|
25
|
+
*
|
|
26
|
+
* Automatically shows loading state while Mermaid loads (~800KB)
|
|
27
|
+
*/
|
|
28
|
+
export const LazyMermaid = createLazyComponent<MermaidProps>(
|
|
29
|
+
() => import('./Mermaid.client'),
|
|
30
|
+
{
|
|
31
|
+
displayName: 'LazyMermaid',
|
|
32
|
+
fallback: (
|
|
33
|
+
<CardLoadingFallback
|
|
34
|
+
title="Diagram"
|
|
35
|
+
description="Loading..."
|
|
36
|
+
minHeight={200}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
);
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { LazyMermaid as Mermaid } from '.';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Curated diagram payloads
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
const FLOWCHART = `graph TD
|
|
10
|
+
A[Client] -->|HTTP| B(Next.js)
|
|
11
|
+
B --> C{Auth?}
|
|
12
|
+
C -->|yes| D[Render]
|
|
13
|
+
C -->|no| E[/Redirect/]`;
|
|
14
|
+
|
|
15
|
+
const SEQUENCE = `sequenceDiagram
|
|
16
|
+
participant U as User
|
|
17
|
+
participant W as WebApp
|
|
18
|
+
participant API as Backend
|
|
19
|
+
U->>W: click "Save"
|
|
20
|
+
W->>API: PATCH /profile
|
|
21
|
+
API-->>W: 200 OK
|
|
22
|
+
W-->>U: toast "Saved"`;
|
|
23
|
+
|
|
24
|
+
const GANTT = `gantt
|
|
25
|
+
title djangocfg storybook rollout
|
|
26
|
+
dateFormat YYYY-MM-DD
|
|
27
|
+
section Phases
|
|
28
|
+
Scaffold :done, p1, 2026-05-18, 1d
|
|
29
|
+
Workspaces :done, p2, after p1, 1d
|
|
30
|
+
UI seed :done, p3, after p2, 2d
|
|
31
|
+
Demo migration :active, p4, after p3, 3d
|
|
32
|
+
Ship :p5, after p4, 2d`;
|
|
33
|
+
|
|
34
|
+
const CLASS_DIAGRAM = `classDiagram
|
|
35
|
+
class Animal {
|
|
36
|
+
+String name
|
|
37
|
+
+int age
|
|
38
|
+
+makeSound() void
|
|
39
|
+
}
|
|
40
|
+
class Dog {
|
|
41
|
+
+String breed
|
|
42
|
+
+bark() void
|
|
43
|
+
}
|
|
44
|
+
class Cat {
|
|
45
|
+
+bool indoor
|
|
46
|
+
+meow() void
|
|
47
|
+
}
|
|
48
|
+
Animal <|-- Dog
|
|
49
|
+
Animal <|-- Cat`;
|
|
50
|
+
|
|
51
|
+
const STATE_DIAGRAM = `stateDiagram-v2
|
|
52
|
+
[*] --> Idle
|
|
53
|
+
Idle --> Loading: fetch()
|
|
54
|
+
Loading --> Success: 200 OK
|
|
55
|
+
Loading --> Error: 5xx
|
|
56
|
+
Success --> Idle: reset
|
|
57
|
+
Error --> Idle: retry
|
|
58
|
+
Success --> [*]`;
|
|
59
|
+
|
|
60
|
+
const ER_DIAGRAM = `erDiagram
|
|
61
|
+
CUSTOMER ||--o{ ORDER : places
|
|
62
|
+
ORDER ||--|{ LINE_ITEM : contains
|
|
63
|
+
CUSTOMER {
|
|
64
|
+
string name
|
|
65
|
+
string email
|
|
66
|
+
}
|
|
67
|
+
ORDER {
|
|
68
|
+
int id
|
|
69
|
+
date created
|
|
70
|
+
}
|
|
71
|
+
LINE_ITEM {
|
|
72
|
+
string sku
|
|
73
|
+
int qty
|
|
74
|
+
float price
|
|
75
|
+
}`;
|
|
76
|
+
|
|
77
|
+
const MINDMAP = `mindmap
|
|
78
|
+
root((djangocfg))
|
|
79
|
+
Frontend
|
|
80
|
+
Next.js
|
|
81
|
+
Storybook
|
|
82
|
+
UI Tools
|
|
83
|
+
Backend
|
|
84
|
+
Django
|
|
85
|
+
DRF
|
|
86
|
+
Celery
|
|
87
|
+
Infra
|
|
88
|
+
Docker
|
|
89
|
+
Postgres
|
|
90
|
+
Redis`;
|
|
91
|
+
|
|
92
|
+
const PIE = `pie title Browser usage
|
|
93
|
+
"Chrome" : 62
|
|
94
|
+
"Safari" : 18
|
|
95
|
+
"Firefox" : 9
|
|
96
|
+
"Edge" : 7
|
|
97
|
+
"Other" : 4`;
|
|
98
|
+
|
|
99
|
+
const TIMELINE = `timeline
|
|
100
|
+
title djangocfg release timeline
|
|
101
|
+
2024 : Project kickoff : Core packages
|
|
102
|
+
2025 : Public beta : UI Tools v1
|
|
103
|
+
2026 : Storybook reorg : Mermaid stories`;
|
|
104
|
+
|
|
105
|
+
const JOURNEY = `journey
|
|
106
|
+
title User onboarding
|
|
107
|
+
section Discovery
|
|
108
|
+
Visit landing: 5: User
|
|
109
|
+
Read features: 4: User
|
|
110
|
+
section Sign up
|
|
111
|
+
Click signup: 5: User
|
|
112
|
+
Fill form: 2: User
|
|
113
|
+
Verify email: 4: User, System
|
|
114
|
+
section First run
|
|
115
|
+
Complete profile: 3: User
|
|
116
|
+
Create project: 5: User`;
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Meta
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
const Frame: React.FC<React.PropsWithChildren> = ({ children }) => (
|
|
123
|
+
<div className="w-[720px] rounded-lg border border-border bg-card p-4">
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const meta = {
|
|
129
|
+
title: 'Widgets/Diagram/Mermaid',
|
|
130
|
+
component: Mermaid,
|
|
131
|
+
tags: ['autodocs'],
|
|
132
|
+
argTypes: {
|
|
133
|
+
chart: { control: 'text', table: { category: 'Content' } },
|
|
134
|
+
fullscreen: {
|
|
135
|
+
control: 'boolean',
|
|
136
|
+
description: 'Enable click-to-fullscreen overlay',
|
|
137
|
+
table: { category: 'Behaviour', defaultValue: { summary: 'true' } },
|
|
138
|
+
},
|
|
139
|
+
isCompact: {
|
|
140
|
+
control: 'boolean',
|
|
141
|
+
description: 'Use compact layout',
|
|
142
|
+
table: { category: 'Behaviour', defaultValue: { summary: 'false' } },
|
|
143
|
+
},
|
|
144
|
+
scrollIsolation: {
|
|
145
|
+
control: 'boolean',
|
|
146
|
+
description: 'Enable the FloatingToolbar scroll-lock overlay',
|
|
147
|
+
table: { category: 'Behaviour', defaultValue: { summary: 'false' } },
|
|
148
|
+
},
|
|
149
|
+
className: { control: 'text', table: { category: 'Styling' } },
|
|
150
|
+
},
|
|
151
|
+
render: (args) => (
|
|
152
|
+
<Frame>
|
|
153
|
+
<Mermaid {...args} />
|
|
154
|
+
</Frame>
|
|
155
|
+
),
|
|
156
|
+
} satisfies Meta<typeof Mermaid>;
|
|
157
|
+
|
|
158
|
+
export default meta;
|
|
159
|
+
|
|
160
|
+
type Story = StoryObj<typeof meta>;
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Playground (Controls-driven)
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
export const Playground: Story = {
|
|
167
|
+
args: {
|
|
168
|
+
chart: FLOWCHART,
|
|
169
|
+
fullscreen: true,
|
|
170
|
+
isCompact: false,
|
|
171
|
+
scrollIsolation: false,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Curated presets
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
export const Flowchart: Story = {
|
|
180
|
+
args: { chart: FLOWCHART },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const Sequence: Story = {
|
|
184
|
+
args: { chart: SEQUENCE },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const Gantt: Story = {
|
|
188
|
+
args: { chart: GANTT },
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const Class: Story = {
|
|
192
|
+
args: { chart: CLASS_DIAGRAM },
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const State: Story = {
|
|
196
|
+
args: { chart: STATE_DIAGRAM },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const ER: Story = {
|
|
200
|
+
args: { chart: ER_DIAGRAM },
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const Mindmap: Story = {
|
|
204
|
+
args: { chart: MINDMAP },
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const Pie: Story = {
|
|
208
|
+
args: { chart: PIE },
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const Timeline: Story = {
|
|
212
|
+
args: { chart: TIMELINE },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const Journey: Story = {
|
|
216
|
+
args: { chart: JOURNEY },
|
|
217
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderer's props.
|
|
3
|
+
*
|
|
4
|
+
* In their own module because both the barrel and `lazy.tsx` need them, and
|
|
5
|
+
* having `lazy` import them from the barrel while the barrel re-exports `lazy`
|
|
6
|
+
* is a cycle — one that resolves at build time and bites at runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface MermaidProps {
|
|
10
|
+
chart: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
isCompact?: boolean;
|
|
13
|
+
/** Enable click-to-fullscreen functionality (default: true) */
|
|
14
|
+
fullscreen?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Enable the FloatingToolbar's "click to scroll" lock overlay.
|
|
17
|
+
* Defaults to `false` — Mermaid diagrams don't scroll internally,
|
|
18
|
+
* so the lock overlay just steals page wheel events. See the
|
|
19
|
+
* Mermaid.client implementation for the full rationale.
|
|
20
|
+
*/
|
|
21
|
+
scrollIsolation?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Debounce window (ms) before (re)rendering after `chart` changes.
|
|
24
|
+
* Lower feels snappier for static charts; higher reduces churn while
|
|
25
|
+
* a diagram is streamed in. Default 300.
|
|
26
|
+
*/
|
|
27
|
+
debounceMs?: number;
|
|
28
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper utilities for Mermaid diagram rendering
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read a semantic color token from CSS custom properties.
|
|
7
|
+
*
|
|
8
|
+
* Theme tokens (`light.css` / `dark.css`) ship fully-wrapped CSS colors
|
|
9
|
+
* (`hsl(0 0% 94%)`) — see `ui-core/src/styles/theme/tokens.css`. Older
|
|
10
|
+
* tokens stored bare HSL components (`0 0% 94%`), so this helper handles
|
|
11
|
+
* both: complete colors pass through untouched, bare components get
|
|
12
|
+
* wrapped once. Never double-wrap an already-complete color.
|
|
13
|
+
*
|
|
14
|
+
* @param variable CSS custom property name, e.g. `--foreground`.
|
|
15
|
+
* @param fallback Returned when the variable is empty / unavailable.
|
|
16
|
+
*/
|
|
17
|
+
export const getThemeColor = (variable: string, fallback = ''): string => {
|
|
18
|
+
if (typeof document === 'undefined') return fallback;
|
|
19
|
+
const value = getComputedStyle(document.documentElement)
|
|
20
|
+
.getPropertyValue(variable)
|
|
21
|
+
.trim();
|
|
22
|
+
if (!value) return fallback;
|
|
23
|
+
// Already a complete color (hex / rgb / hsl / oklch / named).
|
|
24
|
+
if (
|
|
25
|
+
value.startsWith('#') ||
|
|
26
|
+
value.startsWith('rgb') ||
|
|
27
|
+
value.startsWith('hsl(') ||
|
|
28
|
+
value.startsWith('oklch') ||
|
|
29
|
+
value.startsWith('oklab') ||
|
|
30
|
+
value.startsWith('color(') ||
|
|
31
|
+
value.startsWith('var(')
|
|
32
|
+
) {
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
// Bare HSL components — wrap once.
|
|
36
|
+
return `hsl(${value})`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Resolve the diagram text color for the given theme. */
|
|
40
|
+
export const getTextColor = (theme: string): string =>
|
|
41
|
+
theme === 'dark'
|
|
42
|
+
? getThemeColor('--foreground', 'hsl(0 0% 98%)')
|
|
43
|
+
: getThemeColor('--foreground', 'hsl(222.2 84% 4.9%)');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Diagram types whose labels sit on per-section colored backgrounds
|
|
47
|
+
* (timeline sections, journey tasks, pie slices, mindmap nodes, gitgraph
|
|
48
|
+
* commits). For these, Mermaid already picks a contrasting label color
|
|
49
|
+
* from the `cScaleLabel*` / `pie*` theme variables — re-asserting a single
|
|
50
|
+
* `--foreground` would put dark text on dark boxes (and vice versa).
|
|
51
|
+
*
|
|
52
|
+
* We detect them via the wrapper class Mermaid puts on the root `<g>` /
|
|
53
|
+
* `<svg>` and skip the blanket text override for those SVGs.
|
|
54
|
+
*/
|
|
55
|
+
const SECTION_COLORED_SELECTORS = [
|
|
56
|
+
'.timeline',
|
|
57
|
+
'.mindmap',
|
|
58
|
+
'[id^="mermaid"][aria-roledescription="timeline"]',
|
|
59
|
+
'[id^="mermaid"][aria-roledescription="journey"]',
|
|
60
|
+
'[id^="mermaid"][aria-roledescription="mindmap"]',
|
|
61
|
+
'[id^="mermaid"][aria-roledescription="pie"]',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** True when the SVG renders a diagram type that colors its own labels. */
|
|
65
|
+
const usesSectionColors = (svg: SVGSVGElement): boolean => {
|
|
66
|
+
const role = svg.getAttribute('aria-roledescription') ?? '';
|
|
67
|
+
if (['timeline', 'journey', 'mindmap', 'pie'].includes(role)) return true;
|
|
68
|
+
return SECTION_COLORED_SELECTORS.some((sel) => svg.querySelector(sel) !== null);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply theme text colors to a rendered Mermaid SVG.
|
|
73
|
+
*
|
|
74
|
+
* Mermaid's `base` theme bakes `themeVariables` at render time, but text
|
|
75
|
+
* fill on `<text>` nodes and `color` on foreignObject labels can drift
|
|
76
|
+
* from our tokens — this re-asserts them after render.
|
|
77
|
+
*
|
|
78
|
+
* Diagrams that color their own labels per section (timeline, journey,
|
|
79
|
+
* mindmap, pie) are skipped: their `themeVariables` already encode
|
|
80
|
+
* contrasting label colors, so a blanket override would re-introduce the
|
|
81
|
+
* dark-text-on-dark-box problem.
|
|
82
|
+
*/
|
|
83
|
+
export const applyMermaidTextColors = (container: HTMLElement, textColor: string): void => {
|
|
84
|
+
const svgElement = container.querySelector('svg');
|
|
85
|
+
if (!svgElement) return;
|
|
86
|
+
if (usesSectionColors(svgElement)) return;
|
|
87
|
+
// SVG text elements use 'fill'.
|
|
88
|
+
svgElement.querySelectorAll('text').forEach((el) => {
|
|
89
|
+
(el as SVGElement).style.fill = textColor;
|
|
90
|
+
});
|
|
91
|
+
// HTML elements inside foreignObject use 'color'.
|
|
92
|
+
svgElement.querySelectorAll('.nodeLabel, .edgeLabel').forEach((el) => {
|
|
93
|
+
(el as HTMLElement).style.color = textColor;
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Re-color ER diagram attribute-row backgrounds.
|
|
99
|
+
*
|
|
100
|
+
* Mermaid derives the zebra-stripe fills for `.row-rect-odd` /
|
|
101
|
+
* `.row-rect-even` by lightening `mainBkg` — in dark mode the odd stripe
|
|
102
|
+
* lands on a light gray (`hsl(0 0% 83%)`) while the attribute text stays
|
|
103
|
+
* white, so it vanishes. `themeVariables` has no hook for these, so we
|
|
104
|
+
* re-assert themed fills after render.
|
|
105
|
+
*
|
|
106
|
+
* @param container Host element holding the rendered SVG.
|
|
107
|
+
* @param oddFill Background for odd attribute rows.
|
|
108
|
+
* @param evenFill Background for even attribute rows.
|
|
109
|
+
*/
|
|
110
|
+
export const applyMermaidErRowColors = (
|
|
111
|
+
container: HTMLElement,
|
|
112
|
+
oddFill: string,
|
|
113
|
+
evenFill: string,
|
|
114
|
+
): void => {
|
|
115
|
+
const svgElement = container.querySelector('svg');
|
|
116
|
+
if (!svgElement) return;
|
|
117
|
+
svgElement.querySelectorAll('.row-rect-odd path').forEach((el) => {
|
|
118
|
+
const fill = (el as SVGElement).getAttribute('fill');
|
|
119
|
+
if (fill && fill !== 'none') (el as SVGElement).setAttribute('fill', oddFill);
|
|
120
|
+
});
|
|
121
|
+
svgElement.querySelectorAll('.row-rect-even path').forEach((el) => {
|
|
122
|
+
const fill = (el as SVGElement).getAttribute('fill');
|
|
123
|
+
if (fill && fill !== 'none') (el as SVGElement).setAttribute('fill', evenFill);
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Detect whether a diagram is vertical (tall and narrow).
|
|
129
|
+
*
|
|
130
|
+
* Used to pick a sensible fullscreen fit. Prefers the `viewBox` (stable,
|
|
131
|
+
* available immediately) and falls back to `getBBox()` only when needed.
|
|
132
|
+
*/
|
|
133
|
+
export const isVerticalDiagram = (svgElement: SVGSVGElement): boolean => {
|
|
134
|
+
const viewBox = svgElement.getAttribute('viewBox');
|
|
135
|
+
if (viewBox) {
|
|
136
|
+
const [, , width, height] = viewBox.split(/\s+/).map(Number);
|
|
137
|
+
if (
|
|
138
|
+
width === undefined ||
|
|
139
|
+
height === undefined ||
|
|
140
|
+
!Number.isFinite(width) ||
|
|
141
|
+
!Number.isFinite(height) ||
|
|
142
|
+
width <= 0
|
|
143
|
+
) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return height > width * 1.5;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const bbox = svgElement.getBBox?.();
|
|
150
|
+
if (bbox && bbox.width > 0) {
|
|
151
|
+
return bbox.height > bbox.width * 1.5;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// getBBox throws if the SVG is not attached / not rendered.
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
};
|