Skip to content

Commit

Permalink
Add mermaid editor
Browse files Browse the repository at this point in the history
  • Loading branch information
Yann Leflour committed Dec 15, 2023
1 parent c766f3e commit a6ed2e9
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 33 deletions.
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ui-sketcher-webview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@tldraw/editor": "2.0.0-canary.ba4091c59418",
"@tldraw/tldraw": "2.0.0-canary.ba4091c59418",
"@tldraw/validate": "2.0.0-canary.ba4091c59418",
"@types/react-syntax-highlighter": "^15.5.11",
"canvas-size": "^1.2.6",
"daisyui": "^4.4.19",
Expand Down
70 changes: 70 additions & 0 deletions ui-sketcher-webview/src/share-zone/mermaid-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useEditor, ShapeUtil, TLUnknownShape } from "@tldraw/editor";
import { useEffect, useState } from "react";
import {
MermaidShape,
MermaidShapeUtil,
} from "../tools/mermaid/mermaid.shape-util";

const useSingleShape = <
S extends TLUnknownShape,
U extends typeof ShapeUtil<S> = typeof ShapeUtil<S>,
>(
Shape: U,
): S | null => {
const editor = useEditor();
const [mermaidShape, setMermaidShape] = useState<null | S>(null);

useEffect(() => {
const onEvent = () => {
const selectedShapes = editor.getSelectedShapes();
if (
selectedShapes.length === 1 &&
selectedShapes[0].type === Shape.type
) {
setMermaidShape(selectedShapes[0] as S);
} else {
setMermaidShape(null);
}
};

editor.addListener("update", onEvent);

return () => {
editor.removeListener("event", onEvent);
};
}, [editor, setMermaidShape, Shape.type]);

return mermaidShape;
};

export const MermaidEditor = () => {
const editor = useEditor();
const mermaidShape = useSingleShape<MermaidShape>(MermaidShapeUtil);
const [value, setValue] = useState<string>("");

useEffect(() => {
setValue(mermaidShape ? mermaidShape.props.source : "");
}, [mermaidShape]);

if (!mermaidShape) return null;

const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
editor.updateShape({
id: mermaidShape.id,
type: mermaidShape.type,
props: { source: e.target.value },
});
};

return (
<div className="shadow-tl-2 m-w-1/2 pointer-events-auto rounded-lg p-3">
<textarea
className="textarea rounded-none"
rows={12}
value={value}
onChange={onChange}
/>
</div>
);
};
16 changes: 11 additions & 5 deletions ui-sketcher-webview/src/share-zone/share-zone.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { MakeRealButton } from "./make-real-button";
import { MermaidEditor } from "./mermaid-editor";

export const ShareZone = () => (
<div className="z-300 flex gap-2 p-2">
<MakeRealButton />
</div>
);
export const ShareZone = () => {
return (
<div className="z-300 flex flex-col gap-2 p-2">
<div className="flex justify-end gap-2">
<MakeRealButton />
</div>
<MermaidEditor />
</div>
);
};
4 changes: 2 additions & 2 deletions ui-sketcher-webview/src/tools/mermaid/mermaid.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MermaidConfig } from 'mermaid';
import { MermaidConfig } from "mermaid";
import mermaidStyles from "./mermaid.css?raw";

export const mermaidConfig: MermaidConfig = {
Expand All @@ -7,4 +7,4 @@ export const mermaidConfig: MermaidConfig = {
securityLevel: "loose",
themeCSS: mermaidStyles,
fontFamily: "Fira Code",
}
};
116 changes: 91 additions & 25 deletions ui-sketcher-webview/src/tools/mermaid/mermaid.shape-util.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import {
BaseBoxShapeUtil,
Editor,
Geometry2d,
HTMLContainer,
Rectangle2d,
SvgExportContext,
TLBaseShape,
TLUnknownShape,
useIsEditing,
} from "@tldraw/tldraw";
import { useBoxShadow } from "../use-box-shadow.hook";

import mermaid from "mermaid";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { mermaidConfig } from "./mermaid.config";
import { SourceStyleProp } from "../style-props";
import { T } from "@tldraw/validate";

mermaid.initialize(mermaidConfig);

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

export class MermaidShapeUtil extends BaseBoxShapeUtil<MermaidShape> {
static override type = "mermaid" as const;
static override type = "mermaid" as const satisfies string;

svgNode: SVGElement | null = null;

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

static override props = {
source: SourceStyleProp,
w: T.number,
h: T.number,
};

override canEdit = () => true;
override isAspectRatioLocked = (_shape: MermaidShape) => false;
override canResize = (_shape: MermaidShape) => false;
override canBind = (_shape: MermaidShape) => false;
override isAspectRatioLocked = (_shape: TLUnknownShape) => false;
override canResize = (_shape: TLUnknownShape) => false;
override canBind = (_shape: TLUnknownShape) => true;
override canUnmount = () => true;
override canSnap = (_shape: TLUnknownShape) => true;

override getGeometry(shape: MermaidShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
});
}

override toSvg(
_shape: MermaidShape,
_ctx: SvgExportContext,
): SVGElement | Promise<SVGElement> {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
return g;
if (!this.svgNode)
return document.createElementNS("http://www.w3.org/2000/svg", "g");

return this.svgNode.cloneNode(true) as SVGElement;
}

constructor(editor: Editor) {
super(editor);
}

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

const { source } = shape.props;

// Render mermaid diagram
useEffect(() => {
(async () => {
if (isEditing || !diagramRef.current) return;
console.debug("rendering mermaid", source);
await mermaid.run({
nodes: [diagramRef.current],
});
const svg = diagramRef.current.querySelector("svg");
if (!svg) return;
this.editor.updateShape({
id: shape.id,
type: "mermaid",
props: {
w: diagramRef.current.offsetWidth,
h: diagramRef.current.offsetHeight,
},
});

// This is a hack to get arround https://github.com/mermaid-js/mermaid/issues/2651
if (!renderOnce.current) {
renderOnce.current = true;
await mermaid.render(mermaidDivId, source);
await new Promise((resolve) => setTimeout(resolve, 1));
}

const { svg: renderedSvg2 } = await mermaid.render(
mermaidDivId,
source,
);

setSvg(renderedSvg2);

const svgNode = diagramRef.current.querySelector("svg");

if (!svgNode) return;

this.svgNode = svgNode;
})();
}, [source, isEditing, shape.id]);
}, [source, isEditing, shape.id, setSvg, mermaidDivId]);

// Resize bounding box to fit diagram
useEffect(() => {
if (!diagramRef.current) return;

const current = diagramRef.current;

const onResize = () => {
if (
current.offsetWidth !== shape.props.w ||
current.offsetHeight !== shape.props.h
) {
this.editor.updateShape({
id: shape.id,
type: shape.type,
props: {
w: current.offsetWidth,
h: current.offsetHeight,
},
});
}
};

const observer = new ResizeObserver(onResize);
observer.observe(current);
return () => {
observer.unobserve(current);
observer.disconnect();
};
}, [diagramRef, shape.props.w, shape.props.h, shape.id, shape.type]);

return (
<HTMLContainer
className="tl-mermaid-container"
id={shape.id}
style={{ boxShadow }}
>
<div ref={diagramRef} className="mermaid " id={mermaidDivId}>
{source}
</div>
<div
ref={diagramRef}
className="mermaid"
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
></div>
</HTMLContainer>
);
}
Expand Down
13 changes: 13 additions & 0 deletions ui-sketcher-webview/src/tools/style-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { StyleProp } from "@tldraw/tldraw";

const validateString = (text: unknown): string => {
if (typeof text !== "string") {
throw new Error("Expected string");
}
return text;
};

export const SourceStyleProp = StyleProp.define("tldraw:TextStyle", {
defaultValue: "",
type: { validate: validateString },
});
1 change: 0 additions & 1 deletion ui-sketcher-webview/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

interface ImportMetaEnv {
readonly VITE_OPENAI_API_KEY: string;
// more env variables...
}

interface ImportMeta {
Expand Down
6 changes: 6 additions & 0 deletions ui-sketcher-webview/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export default {
300: 300,
1000: 1000,
},
boxShadow: {
"tl-2": "var(--shadow-2)",
},
},
},
daisyui: {
Expand All @@ -34,6 +37,9 @@ export default {
".text-success": {
color: "#448361",
},
"--rounded-box": "0.5rem", // border radius rounded-box utility class, used in card and other large boxes
"--rounded-btn": "0.5rem", // border radius rounded-btn utility class, used in buttons and similar element
"--rounded-badge": "1rem", // border radius rounded-badge utility class, used in badges and similar
},
},
],
Expand Down

0 comments on commit a6ed2e9

Please sign in to comment.