Skip to content

Commit a6ed2e9

Browse files
author
Yann Leflour
committed
Add mermaid editor
1 parent c766f3e commit a6ed2e9

File tree

9 files changed

+201
-33
lines changed

9 files changed

+201
-33
lines changed

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui-sketcher-webview/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"build-storybook": "storybook build"
1313
},
1414
"dependencies": {
15+
"@tldraw/editor": "2.0.0-canary.ba4091c59418",
1516
"@tldraw/tldraw": "2.0.0-canary.ba4091c59418",
17+
"@tldraw/validate": "2.0.0-canary.ba4091c59418",
1618
"@types/react-syntax-highlighter": "^15.5.11",
1719
"canvas-size": "^1.2.6",
1820
"daisyui": "^4.4.19",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useEditor, ShapeUtil, TLUnknownShape } from "@tldraw/editor";
2+
import { useEffect, useState } from "react";
3+
import {
4+
MermaidShape,
5+
MermaidShapeUtil,
6+
} from "../tools/mermaid/mermaid.shape-util";
7+
8+
const useSingleShape = <
9+
S extends TLUnknownShape,
10+
U extends typeof ShapeUtil<S> = typeof ShapeUtil<S>,
11+
>(
12+
Shape: U,
13+
): S | null => {
14+
const editor = useEditor();
15+
const [mermaidShape, setMermaidShape] = useState<null | S>(null);
16+
17+
useEffect(() => {
18+
const onEvent = () => {
19+
const selectedShapes = editor.getSelectedShapes();
20+
if (
21+
selectedShapes.length === 1 &&
22+
selectedShapes[0].type === Shape.type
23+
) {
24+
setMermaidShape(selectedShapes[0] as S);
25+
} else {
26+
setMermaidShape(null);
27+
}
28+
};
29+
30+
editor.addListener("update", onEvent);
31+
32+
return () => {
33+
editor.removeListener("event", onEvent);
34+
};
35+
}, [editor, setMermaidShape, Shape.type]);
36+
37+
return mermaidShape;
38+
};
39+
40+
export const MermaidEditor = () => {
41+
const editor = useEditor();
42+
const mermaidShape = useSingleShape<MermaidShape>(MermaidShapeUtil);
43+
const [value, setValue] = useState<string>("");
44+
45+
useEffect(() => {
46+
setValue(mermaidShape ? mermaidShape.props.source : "");
47+
}, [mermaidShape]);
48+
49+
if (!mermaidShape) return null;
50+
51+
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
52+
setValue(e.target.value);
53+
editor.updateShape({
54+
id: mermaidShape.id,
55+
type: mermaidShape.type,
56+
props: { source: e.target.value },
57+
});
58+
};
59+
60+
return (
61+
<div className="shadow-tl-2 m-w-1/2 pointer-events-auto rounded-lg p-3">
62+
<textarea
63+
className="textarea rounded-none"
64+
rows={12}
65+
value={value}
66+
onChange={onChange}
67+
/>
68+
</div>
69+
);
70+
};
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { MakeRealButton } from "./make-real-button";
2+
import { MermaidEditor } from "./mermaid-editor";
23

3-
export const ShareZone = () => (
4-
<div className="z-300 flex gap-2 p-2">
5-
<MakeRealButton />
6-
</div>
7-
);
4+
export const ShareZone = () => {
5+
return (
6+
<div className="z-300 flex flex-col gap-2 p-2">
7+
<div className="flex justify-end gap-2">
8+
<MakeRealButton />
9+
</div>
10+
<MermaidEditor />
11+
</div>
12+
);
13+
};
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MermaidConfig } from 'mermaid';
1+
import { MermaidConfig } from "mermaid";
22
import mermaidStyles from "./mermaid.css?raw";
33

44
export const mermaidConfig: MermaidConfig = {
@@ -7,4 +7,4 @@ export const mermaidConfig: MermaidConfig = {
77
securityLevel: "loose",
88
themeCSS: mermaidStyles,
99
fontFamily: "Fira Code",
10-
}
10+
};

ui-sketcher-webview/src/tools/mermaid/mermaid.shape-util.tsx

Lines changed: 91 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import {
22
BaseBoxShapeUtil,
33
Editor,
4+
Geometry2d,
45
HTMLContainer,
6+
Rectangle2d,
57
SvgExportContext,
68
TLBaseShape,
9+
TLUnknownShape,
710
useIsEditing,
811
} from "@tldraw/tldraw";
912
import { useBoxShadow } from "../use-box-shadow.hook";
1013

1114
import mermaid from "mermaid";
12-
import { useEffect, useRef } from "react";
15+
import { useEffect, useRef, useState } from "react";
1316
import { mermaidConfig } from "./mermaid.config";
17+
import { SourceStyleProp } from "../style-props";
18+
import { T } from "@tldraw/validate";
1419

1520
mermaid.initialize(mermaidConfig);
1621

@@ -24,7 +29,9 @@ export type MermaidShape = TLBaseShape<
2429
>;
2530

2631
export class MermaidShapeUtil extends BaseBoxShapeUtil<MermaidShape> {
27-
static override type = "mermaid" as const;
32+
static override type = "mermaid" as const satisfies string;
33+
34+
svgNode: SVGElement | null = null;
2835

2936
getDefaultProps(): MermaidShape["props"] {
3037
return {
@@ -34,60 +41,119 @@ export class MermaidShapeUtil extends BaseBoxShapeUtil<MermaidShape> {
3441
};
3542
}
3643

44+
static override props = {
45+
source: SourceStyleProp,
46+
w: T.number,
47+
h: T.number,
48+
};
49+
3750
override canEdit = () => true;
38-
override isAspectRatioLocked = (_shape: MermaidShape) => false;
39-
override canResize = (_shape: MermaidShape) => false;
40-
override canBind = (_shape: MermaidShape) => false;
51+
override isAspectRatioLocked = (_shape: TLUnknownShape) => false;
52+
override canResize = (_shape: TLUnknownShape) => false;
53+
override canBind = (_shape: TLUnknownShape) => true;
4154
override canUnmount = () => true;
55+
override canSnap = (_shape: TLUnknownShape) => true;
56+
57+
override getGeometry(shape: MermaidShape): Geometry2d {
58+
return new Rectangle2d({
59+
width: shape.props.w,
60+
height: shape.props.h,
61+
isFilled: true,
62+
});
63+
}
64+
4265
override toSvg(
4366
_shape: MermaidShape,
4467
_ctx: SvgExportContext,
4568
): SVGElement | Promise<SVGElement> {
46-
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
47-
return g;
69+
if (!this.svgNode)
70+
return document.createElementNS("http://www.w3.org/2000/svg", "g");
71+
72+
return this.svgNode.cloneNode(true) as SVGElement;
4873
}
4974

5075
constructor(editor: Editor) {
5176
super(editor);
5277
}
5378

5479
override component(shape: MermaidShape) {
80+
const renderOnce = useRef(false);
5581
const diagramRef = useRef<HTMLDivElement>(null);
5682
const boxShadow = useBoxShadow(this.editor, shape);
5783
const isEditing = useIsEditing(shape.id);
5884
const mermaidDivId = `mermaid-${shape.id.replace(":", "-")}`;
85+
const [svg, setSvg] = useState<null | string>(null);
5986

6087
const { source } = shape.props;
6188

89+
// Render mermaid diagram
6290
useEffect(() => {
6391
(async () => {
6492
if (isEditing || !diagramRef.current) return;
65-
console.debug("rendering mermaid", source);
66-
await mermaid.run({
67-
nodes: [diagramRef.current],
68-
});
69-
const svg = diagramRef.current.querySelector("svg");
70-
if (!svg) return;
71-
this.editor.updateShape({
72-
id: shape.id,
73-
type: "mermaid",
74-
props: {
75-
w: diagramRef.current.offsetWidth,
76-
h: diagramRef.current.offsetHeight,
77-
},
78-
});
93+
94+
// This is a hack to get arround https://github.com/mermaid-js/mermaid/issues/2651
95+
if (!renderOnce.current) {
96+
renderOnce.current = true;
97+
await mermaid.render(mermaidDivId, source);
98+
await new Promise((resolve) => setTimeout(resolve, 1));
99+
}
100+
101+
const { svg: renderedSvg2 } = await mermaid.render(
102+
mermaidDivId,
103+
source,
104+
);
105+
106+
setSvg(renderedSvg2);
107+
108+
const svgNode = diagramRef.current.querySelector("svg");
109+
110+
if (!svgNode) return;
111+
112+
this.svgNode = svgNode;
79113
})();
80-
}, [source, isEditing, shape.id]);
114+
}, [source, isEditing, shape.id, setSvg, mermaidDivId]);
115+
116+
// Resize bounding box to fit diagram
117+
useEffect(() => {
118+
if (!diagramRef.current) return;
119+
120+
const current = diagramRef.current;
121+
122+
const onResize = () => {
123+
if (
124+
current.offsetWidth !== shape.props.w ||
125+
current.offsetHeight !== shape.props.h
126+
) {
127+
this.editor.updateShape({
128+
id: shape.id,
129+
type: shape.type,
130+
props: {
131+
w: current.offsetWidth,
132+
h: current.offsetHeight,
133+
},
134+
});
135+
}
136+
};
137+
138+
const observer = new ResizeObserver(onResize);
139+
observer.observe(current);
140+
return () => {
141+
observer.unobserve(current);
142+
observer.disconnect();
143+
};
144+
}, [diagramRef, shape.props.w, shape.props.h, shape.id, shape.type]);
81145

82146
return (
83147
<HTMLContainer
84148
className="tl-mermaid-container"
85149
id={shape.id}
86150
style={{ boxShadow }}
87151
>
88-
<div ref={diagramRef} className="mermaid " id={mermaidDivId}>
89-
{source}
90-
</div>
152+
<div
153+
ref={diagramRef}
154+
className="mermaid"
155+
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
156+
></div>
91157
</HTMLContainer>
92158
);
93159
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { StyleProp } from "@tldraw/tldraw";
2+
3+
const validateString = (text: unknown): string => {
4+
if (typeof text !== "string") {
5+
throw new Error("Expected string");
6+
}
7+
return text;
8+
};
9+
10+
export const SourceStyleProp = StyleProp.define("tldraw:TextStyle", {
11+
defaultValue: "",
12+
type: { validate: validateString },
13+
});

ui-sketcher-webview/src/vite-env.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
interface ImportMetaEnv {
44
readonly VITE_OPENAI_API_KEY: string;
5-
// more env variables...
65
}
76

87
interface ImportMeta {

ui-sketcher-webview/tailwind.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export default {
1010
300: 300,
1111
1000: 1000,
1212
},
13+
boxShadow: {
14+
"tl-2": "var(--shadow-2)",
15+
},
1316
},
1417
},
1518
daisyui: {
@@ -34,6 +37,9 @@ export default {
3437
".text-success": {
3538
color: "#448361",
3639
},
40+
"--rounded-box": "0.5rem", // border radius rounded-box utility class, used in card and other large boxes
41+
"--rounded-btn": "0.5rem", // border radius rounded-btn utility class, used in buttons and similar element
42+
"--rounded-badge": "1rem", // border radius rounded-badge utility class, used in badges and similar
3743
},
3844
},
3945
],

0 commit comments

Comments
 (0)