Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*

.claude
CLAUDE.md
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Unreleased

### v2.1.0

- Upgrade mermaid to 11.12.1
- Add ERD support

### Features

- Support ClassDef for styling the nodes by [@ad1992](https://github.com/ad1992) in https://github.com/excalidraw/mermaid-to-excalidraw/pull/71
Expand Down
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/mermaid-to-excalidraw",
"version": "1.1.4",
"version": "2.1.1",
"description": "Mermaid to Excalidraw Diagrams",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -11,7 +11,8 @@
"scripts": {
"build": "rimraf -rf ./dist && cross-env tsc -b src",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
"start": "vite playground",
"dev": "vite playground --mode development",
"start": "vite playground --mode development",
"build:playground": "tsc --noEmit --project ./playground/tsconfig.json && vite build playground",
"preview": "yarn run build:playground && vite preview --outDir ./public",
"test": "vitest",
Expand All @@ -20,15 +21,15 @@
},
"dependencies": {
"@excalidraw/markdown-to-text": "0.1.2",
"mermaid": "10.9.4",
"nanoid": "4.0.2",
"react-split": "^2.0.14"
"@mermaid-js/parser": "^0.6.3",
"mermaid": "^11.12.1",
"nanoid": "4.0.2"
},
"devDependencies": {
"@babel/core": "7.12.0",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/excalidraw": "0.17.1-7381-cdf6d3e",
"@types/mermaid": "9.2.0",
"@excalidraw/excalidraw": "^0.18.0-816c81c",
"@types/mermaid": "^9.2.0",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.4",
"@typescript-eslint/eslint-plugin": "5.59.9",
Expand Down
91 changes: 74 additions & 17 deletions playground/CustomTest.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, useEffect } from "react";
import { MermaidDiagram } from "./MermaidDiagram.tsx";
import type { ActiveTestCaseIndex, MermaidData } from "./index.tsx";
import { usePersistedSectionState } from "./usePersistedSectionState.ts";

interface CustomTestProps {
onChange: (
Expand All @@ -10,57 +12,112 @@ interface CustomTestProps {
activeTestCaseIndex: ActiveTestCaseIndex;
}

const STORAGE_KEY = "mermaid-to-excalidraw-definition";

const CustomTest = ({
onChange,
mermaidData,
activeTestCaseIndex,
}: CustomTestProps) => {
const isActive = activeTestCaseIndex === "custom";
const {
isExpanded: isParsedDataExpanded,
handleToggle: handleParsedDataToggle,
} = usePersistedSectionState("custom:parsed-data");
const [textareaValue, setTextareaValue] = useState(() => {
// Load from localStorage on initial mount
try {
return localStorage.getItem(STORAGE_KEY) || "";
} catch {
return "";
}
});

// Update textarea when mermaidData changes from external source
useEffect(() => {
if (mermaidData.definition && activeTestCaseIndex !== "custom") {
setTextareaValue(mermaidData.definition);
}
}, [mermaidData.definition, activeTestCaseIndex]);

return (
<>
<form
className="custom-test-form"
onSubmit={(e) => {
e.preventDefault();

const formData = new FormData(e.target as HTMLFormElement);
const definition = formData.get("mermaid-input")?.toString() || "";

onChange(formData.get("mermaid-input")?.toString() || "", "custom");
// Save to localStorage
try {
localStorage.setItem(STORAGE_KEY, definition);
} catch (error) {
console.error("Failed to save to localStorage:", error);
}

onChange(definition, "custom");
}}
>
<label className="field-label" htmlFor="mermaid-input">
{"Mermaid definition"}
</label>
<textarea
id="mermaid-input"
rows={10}
cols={50}
name="mermaid-input"
value={textareaValue}
onChange={(e) => {
const value = e.target.value;
setTextareaValue(value);

if (!isActive) {
return;
}

onChange(e.target.value, "custom");
onChange(value, "custom");
}}
style={{ marginTop: "1rem" }}
placeholder="Input Mermaid Syntax"
placeholder="Paste or type Mermaid syntax here"
/>
<br />
<button type="submit" id="render-excalidraw-btn">
{"Render to Excalidraw"}
</button>
<div className="custom-test-actions">
<button
className="playground-button"
type="submit"
id="render-excalidraw-btn"
>
{"Render to Excalidraw"}
</button>
<p className="custom-test-hint">
{"The live Excalidraw canvas updates on the right."}
</p>
</div>
</form>

{isActive && (
<>
<MermaidDiagram
definition={mermaidData.definition}
id="custom-diagram"
/>
<section className="custom-preview-card">
<div className="preview-badge">{"Live Mermaid SVG"}</div>
<div className="diagram-preview-surface custom-diagram-surface">
<MermaidDiagram definition={textareaValue} id="custom-diagram" />
</div>
</section>

<details id="parsed-data-details">
<details
id="parsed-data-details"
open={isParsedDataExpanded}
onToggle={handleParsedDataToggle}
>
<summary>{"Parsed data from parseMermaid"}</summary>
<pre id="custom-parsed-data">
{JSON.stringify(mermaidData.output, null, 2)}
</pre>
{mermaidData.error && <div id="error">{mermaidData.error}</div>}
{isParsedDataExpanded ? (
<>
<pre id="custom-parsed-data">
{JSON.stringify(mermaidData.output, null, 2)}
</pre>
{mermaidData.error && <div id="error">{mermaidData.error}</div>}
</>
) : null}
</details>
</>
)}
Expand Down
132 changes: 132 additions & 0 deletions playground/ExcalidrawSvgPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import {
convertToExcalidrawElements,
exportToSvg,
} from "@excalidraw/excalidraw";
import { DEFAULT_FONT_SIZE } from "../src/constants";
import { graphToExcalidraw } from "../src/graphToExcalidraw";
import { parseMermaid } from "../src/parseMermaid";

interface ExcalidrawSvgPreviewProps {
definition: string;
}

let previewRenderQueue: Promise<void> = Promise.resolve();
const svgCache = new Map<string, string>();
const inFlightSvgCache = new Map<string, Promise<string>>();

const runSequentially = <T,>(task: () => Promise<T>) => {
const run = previewRenderQueue.then(task, task);
previewRenderQueue = run.then(
() => undefined,
() => undefined
);
return run;
};

const cleanupMermaidTempContainers = () => {
document
.querySelectorAll<HTMLDivElement>(".mermaid-to-excalidraw-svg-container")
.forEach((container) => {
container.remove();
});
};

const generateExcalidrawSvg = async (definition: string): Promise<string> => {
const cachedSvg = svgCache.get(definition);
if (cachedSvg) {
return cachedSvg;
}

const pendingRender = inFlightSvgCache.get(definition);
if (pendingRender) {
return pendingRender;
}

const renderPromise = runSequentially(async () => {
try {
const parsedMermaid = await parseMermaid(definition);
const { elements, files } = graphToExcalidraw(parsedMermaid, {
fontSize: DEFAULT_FONT_SIZE,
});

const svgElement = await exportToSvg({
elements: convertToExcalidrawElements(elements),
appState: {
exportBackground: true,
viewBackgroundColor: "#ffffff",
},
files: files ?? null,
exportPadding: 16,
});

return svgElement.outerHTML;
} finally {
cleanupMermaidTempContainers();
}
})
.then((svg) => {
svgCache.set(definition, svg);
return svg;
})
.finally(() => {
inFlightSvgCache.delete(definition);
});

inFlightSvgCache.set(definition, renderPromise);
return renderPromise;
};

export const ExcalidrawSvgPreview = ({
definition,
}: ExcalidrawSvgPreviewProps) => {
const [svg, setSvg] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
let isCancelled = false;

setIsLoading(true);
setError(null);

generateExcalidrawSvg(definition)
.then((renderedSvg) => {
if (!isCancelled) {
setSvg(renderedSvg);
}
})
.catch((err) => {
if (!isCancelled) {
setSvg("");
setError(String(err));
}
})
.finally(() => {
if (!isCancelled) {
setIsLoading(false);
}
});

return () => {
isCancelled = true;
};
}, [definition]);

if (error) {
return <div className="preview-error">{error}</div>;
}

if (isLoading && !svg) {
return (
<div className="preview-loading">{"Rendering Excalidraw SVG..."}</div>
);
}

return (
<div
className="excalidraw-svg-preview"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
};
Loading
Loading