Skip to content

Commit d6838fe

Browse files
dwelleigorwessel
andcommitted
Add state diagram support
Co-authored-by: Igor Wessel <igor_wessel@hotmail.com>
1 parent fe990f9 commit d6838fe

135 files changed

Lines changed: 2083 additions & 86 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

playground/ExcalidrawSvgPreview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { DEFAULT_FONT_SIZE } from "../src/constants";
77
import { graphToExcalidraw } from "../src/graphToExcalidraw";
88
import { parseMermaid } from "../src/parseMermaid";
9+
import { ensureExcalidrawFontsLoaded } from "./loadExcalidrawFonts";
910

1011
interface ExcalidrawSvgPreviewProps {
1112
definition: string;
@@ -49,6 +50,7 @@ const generateExcalidrawSvg = async (definition: string): Promise<string> => {
4950
const { elements, files } = graphToExcalidraw(parsedMermaid, {
5051
fontSize: DEFAULT_FONT_SIZE,
5152
});
53+
await ensureExcalidrawFontsLoaded(elements);
5254

5355
const svgElement = await exportToSvg({
5456
elements: convertToExcalidrawElements(elements),

playground/ExcalidrawWrapper.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
77
import { graphToExcalidraw } from "../src/graphToExcalidraw";
88
import { DEFAULT_FONT_SIZE } from "../src/constants";
99
import type { MermaidData } from "./";
10+
import { ensureExcalidrawFontsLoaded } from "./loadExcalidrawFonts";
1011

1112
interface ExcalidrawWrapperProps {
1213
mermaidDefinition: MermaidData["definition"];
@@ -25,29 +26,45 @@ const ExcalidrawWrapper = ({
2526
useState<ExcalidrawImperativeAPI | null>(null);
2627

2728
useEffect(() => {
29+
let isCancelled = false;
30+
2831
if (!readyExcalidrawAPI || readyExcalidrawAPI.isDestroyed) {
29-
return;
32+
return undefined;
3033
}
3134

3235
if (mermaidDefinition === "" || mermaidOutput === null) {
3336
readyExcalidrawAPI.resetScene();
34-
return;
37+
return undefined;
3538
}
3639

37-
const { elements, files } = graphToExcalidraw(mermaidOutput, {
38-
fontSize: DEFAULT_FONT_SIZE,
39-
});
40+
void (async () => {
41+
await ensureExcalidrawFontsLoaded();
42+
if (isCancelled || readyExcalidrawAPI.isDestroyed) {
43+
return;
44+
}
4045

41-
readyExcalidrawAPI.updateScene({
42-
elements: convertToExcalidrawElements(elements),
43-
});
44-
readyExcalidrawAPI.scrollToContent(readyExcalidrawAPI.getSceneElements(), {
45-
fitToContent: true,
46-
});
46+
const { elements, files } = graphToExcalidraw(mermaidOutput, {
47+
fontSize: DEFAULT_FONT_SIZE,
48+
});
4749

48-
if (files) {
49-
readyExcalidrawAPI.addFiles(Object.values(files));
50-
}
50+
readyExcalidrawAPI.updateScene({
51+
elements: convertToExcalidrawElements(elements),
52+
});
53+
readyExcalidrawAPI.scrollToContent(
54+
readyExcalidrawAPI.getSceneElements(),
55+
{
56+
fitToContent: true,
57+
}
58+
);
59+
60+
if (files) {
61+
readyExcalidrawAPI.addFiles(Object.values(files));
62+
}
63+
})();
64+
65+
return () => {
66+
isCancelled = true;
67+
};
5168
}, [mermaidDefinition, mermaidOutput, readyExcalidrawAPI]);
5269

5370
return (

playground/SingleTestCase.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MermaidDiagram } from "./MermaidDiagram";
33
import { ExcalidrawSvgPreview } from "./ExcalidrawSvgPreview";
44

55
export interface TestCase {
6-
type: "class" | "erd" | "flowchart" | "sequence" | "unsupported";
6+
type: "class" | "erd" | "flowchart" | "sequence" | "state" | "unsupported";
77
name: string;
88
definition: string;
99
}

playground/Testcases.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { FLOWCHART_DIAGRAM_TESTCASES } from "./testcases/flowchart";
22
import { SEQUENCE_DIAGRAM_TESTCASES } from "./testcases/sequence.ts";
33
import { CLASS_DIAGRAM_TESTCASES } from "./testcases/class.ts";
44
import { ERD_DIAGRAM_TESTCASES } from "./testcases/er.ts";
5+
import { STATE_DIAGRAM_TESTCASES } from "./testcases/state.ts";
56
import { UNSUPPORTED_DIAGRAM_TESTCASES } from "./testcases/unsupported.ts";
67

78
import SingleTestCase, { TestCase } from "./SingleTestCase.tsx";
@@ -119,6 +120,11 @@ const Testcases = ({ onChange, onInsertMermaidSvg }: TestcasesProps) => {
119120
documentationHref:
120121
"https://mermaid.js.org/syntax/entityRelationshipDiagram.html",
121122
},
123+
{
124+
name: "State",
125+
testcases: STATE_DIAGRAM_TESTCASES,
126+
documentationHref: "https://mermaid.js.org/syntax/stateDiagram.html",
127+
},
122128
{
123129
name: "Unsupported",
124130
testcases: UNSUPPORTED_DIAGRAM_TESTCASES,

playground/loadExcalidrawFonts.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { getFontString } from "@excalidraw/common";
2+
import { FONT_FAMILY, Fonts } from "@excalidraw/excalidraw";
3+
4+
let excalidrawFontsReadyPromise: Promise<void> | null = null;
5+
let fontMeasureContext: CanvasRenderingContext2D | null | undefined;
6+
7+
const EXCALIFONT_PROBE_TEXT = "This is the note to the left.";
8+
const EXCALIFONT_PROBE_SIZE = 18;
9+
const EXCALIFONT_METRICS_WAIT_TIMEOUT_MS = 2000;
10+
const EXCALIFONT_METRICS_POLL_INTERVAL_MS = 16;
11+
12+
const getFontMeasureContext = () => {
13+
if (fontMeasureContext !== undefined) {
14+
return fontMeasureContext;
15+
}
16+
17+
try {
18+
fontMeasureContext = document.createElement("canvas").getContext("2d");
19+
} catch {
20+
fontMeasureContext = null;
21+
}
22+
23+
return fontMeasureContext;
24+
};
25+
26+
const measureTextWidth = (font: string, text: string) => {
27+
const context = getFontMeasureContext();
28+
if (!context) {
29+
return null;
30+
}
31+
32+
context.font = font;
33+
return context.measureText(text).width;
34+
};
35+
36+
const wait = (ms: number) =>
37+
new Promise<void>((resolve) => {
38+
window.setTimeout(resolve, ms);
39+
});
40+
41+
const waitForExcalifontMetrics = async () => {
42+
const font = getFontString({
43+
fontSize: EXCALIFONT_PROBE_SIZE,
44+
fontFamily: FONT_FAMILY.Excalifont,
45+
});
46+
47+
await document.fonts.load(font, EXCALIFONT_PROBE_TEXT);
48+
49+
const fallbackWidth = measureTextWidth(
50+
`${EXCALIFONT_PROBE_SIZE}px sans-serif`,
51+
EXCALIFONT_PROBE_TEXT
52+
);
53+
54+
if (fallbackWidth === null) {
55+
return;
56+
}
57+
58+
const deadline = Date.now() + EXCALIFONT_METRICS_WAIT_TIMEOUT_MS;
59+
while (Date.now() < deadline) {
60+
const excalifontWidth = measureTextWidth(font, EXCALIFONT_PROBE_TEXT);
61+
if (
62+
document.fonts.check(font, EXCALIFONT_PROBE_TEXT) &&
63+
excalifontWidth !== null &&
64+
Math.abs(excalifontWidth - fallbackWidth) > 0.5
65+
) {
66+
return;
67+
}
68+
69+
// `requestAnimationFrame` can stop firing in headless/background tabs,
70+
// which would wedge Playwright visual runs. Poll on wall-clock time instead.
71+
await wait(EXCALIFONT_METRICS_POLL_INTERVAL_MS);
72+
}
73+
};
74+
75+
export const ensureExcalidrawFontsLoaded = () => {
76+
if (typeof window === "undefined") {
77+
return Promise.resolve();
78+
}
79+
80+
if (window.EXCALIDRAW_ASSET_PATH === undefined) {
81+
window.EXCALIDRAW_ASSET_PATH = "/";
82+
}
83+
84+
if (!excalidrawFontsReadyPromise) {
85+
excalidrawFontsReadyPromise = (async () => {
86+
await Fonts.loadElementsFonts([
87+
{
88+
type: "text",
89+
fontFamily: FONT_FAMILY.Excalifont,
90+
text: "preload",
91+
originalText: "preload",
92+
} as any,
93+
]);
94+
await document.fonts.ready;
95+
await waitForExcalifontMetrics();
96+
})();
97+
}
98+
99+
return excalidrawFontsReadyPromise;
100+
};

playground/main.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
33
import App from "./index.tsx";
44
import mermaid from "mermaid";
55
import { DEFAULT_FONT_SIZE, MERMAID_CONFIG } from "../src/constants.ts";
6+
import { ensureExcalidrawFontsLoaded } from "./loadExcalidrawFonts.ts";
67

78
// Initialize Mermaid
89
mermaid.initialize({
@@ -14,8 +15,10 @@ mermaid.initialize({
1415

1516
const root = ReactDOM.createRoot(document.getElementById("root")!);
1617

17-
root.render(
18-
<React.StrictMode>
19-
<App />
20-
</React.StrictMode>
21-
);
18+
void ensureExcalidrawFontsLoaded().finally(() => {
19+
root.render(
20+
<React.StrictMode>
21+
<App />
22+
</React.StrictMode>
23+
);
24+
});

playground/testcases/state.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import type { TestCase } from "../SingleTestCase";
2+
3+
export const STATE_DIAGRAM_TESTCASES: TestCase[] = [
4+
{
5+
name: "Simple Transition",
6+
definition: `stateDiagram-v2
7+
[*] --> Idle
8+
Idle --> Active: Start
9+
Active --> [*]
10+
`,
11+
type: "state",
12+
},
13+
{
14+
name: "Choice and Notes",
15+
definition: `stateDiagram-v2
16+
state Decision <<choice>>
17+
[*] --> Input
18+
Input --> Decision
19+
note right of Input: Capture payload
20+
Decision --> Accept: valid
21+
Decision --> Reject: invalid
22+
`,
23+
type: "state",
24+
},
25+
{
26+
name: "Composite State",
27+
definition: `stateDiagram-v2
28+
[*] --> Session
29+
state Session {
30+
[*] --> Ready
31+
Ready --> Busy
32+
Busy --> Ready
33+
}
34+
`,
35+
type: "state",
36+
},
37+
{
38+
name: "Composite State Transitions",
39+
definition: `stateDiagram-v2
40+
[*] --> First
41+
First --> Second
42+
First --> Third
43+
state First {
44+
[*] --> fir
45+
fir --> [*]
46+
}
47+
state Second {
48+
[*] --> sec
49+
sec --> [*]
50+
}
51+
state Third {
52+
[*] --> thi
53+
thi --> [*]
54+
}
55+
`,
56+
type: "state",
57+
},
58+
{
59+
name: "Nested Composite States",
60+
definition: `stateDiagram-v2
61+
[*] --> First
62+
state First {
63+
[*] --> Second
64+
state Second {
65+
[*] --> second
66+
second --> Third
67+
state Third {
68+
[*] --> third
69+
third --> [*]
70+
}
71+
}
72+
}
73+
`,
74+
type: "state",
75+
},
76+
{
77+
name: "Concurrency",
78+
definition: `stateDiagram-v2
79+
state Active {
80+
[*] --> Left
81+
--
82+
[*] --> Right
83+
}
84+
`,
85+
type: "state",
86+
},
87+
{
88+
name: "Fork and Join",
89+
definition: `stateDiagram-v2
90+
state fork_state <<fork>>
91+
[*] --> fork_state
92+
fork_state --> State2
93+
fork_state --> State3
94+
95+
state join_state <<join>>
96+
State2 --> join_state
97+
State3 --> join_state
98+
join_state --> State4
99+
State4 --> [*]
100+
`,
101+
type: "state",
102+
},
103+
{
104+
name: "Multiline Notes",
105+
definition: `stateDiagram-v2
106+
State1: The state with a really long note that should wrap notes
107+
note right of State1
108+
Important information!
109+
You can write notes.
110+
end note
111+
State1 --> State2
112+
note left of State2 : This is the note to the left.
113+
`,
114+
type: "state",
115+
},
116+
{
117+
name: "Styling",
118+
definition: `stateDiagram-v2
119+
classDef movement fill:#f00,color:white,stroke-width:2px,stroke:yellow
120+
classDef stopped fill:#fff,stroke:#1f1f1f
121+
Still --> Moving
122+
Moving --> Still
123+
class Moving movement
124+
class Still stopped
125+
style Still color:#1f1f1f
126+
`,
127+
type: "state",
128+
},
129+
{
130+
name: "Direction and Comments",
131+
definition: `stateDiagram-v2
132+
direction LR
133+
[*] --> A
134+
A --> B
135+
%% nested states may set their own direction
136+
state B {
137+
direction LR
138+
a --> b
139+
}
140+
B --> D
141+
`,
142+
type: "state",
143+
},
144+
{
145+
name: "Spaces and Inline Styles",
146+
definition: `stateDiagram-v2
147+
classDef yourState fill:#ffec99,stroke:#c92a2a,color:#1864ab,stroke-width:2px
148+
yswsii: Your state with spaces in it
149+
[*] --> yswsii:::yourState
150+
[*] --> SomeOtherState
151+
SomeOtherState --> YetAnotherState
152+
yswsii --> YetAnotherState
153+
YetAnotherState --> [*]
154+
`,
155+
type: "state",
156+
},
157+
];

0 commit comments

Comments
 (0)