feat: Support mermaid flowchart and sequence diagrams to excalidraw diagrams 🥳 (#6920)

* feat: integrate mermaidToExcalidraw

* create mermaid to excal dialog

* allow mermaid syntax and export in preview

* fix

* fix webpack config

* fix markdown error by using named export

* center preview

* set elements as selected when inserted onto canvas

* persist mermaid data to storage

* store canvas data in refs

* load mermaid lazily

* tweak design

* compute width, height correctly for arrows

* fix undefined vertex issue

* add mermaid icon in dropdown

* add a note in dialog

* reset preview when error

* show error in preview when error

* show mermaid error messgae react way

* design tweaks

* add example and docs link

* fix

* tweak design to remove scroll bar

* show a spinner unless mermaid loaded

* regenerate ids when needed via programmatic api, this makes sure for mermaid diagrams ids are regenerated

* tweak

* add option to transform viewport to scene coords in transform api

* make opts optional and use 100% zoom when inserting to canvas

* fix arrow bindings in safari and firefox

* fix elements insert position and viewport centering

* fix: Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.

* defer rendering the preview

* tweak text

* fix tests

* remove only

* make design responsive

* fix: show extra tools dropdown in mobile

* fix mobile css

* width auto

* upgrade mermaid-to-excalidraw

* don't pass appState in deps as its not used

* upgrade mermaid-to-excalidraw to fix firefox issue

* use types from mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* use stable version of mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* fix width of shapes toolbar for smaller screen size and also fix regression of mobile menu

* use i18n

* better api

* enable test coverage in ui

* Add tests

* use common utils to update and get text editor

* updgrade mermaid-to-excalidraw to support sequence diagrams

* fix test

* don't update arrow container height anytime in when redrawing text bounding box

* increase size limit

* increase size limit of vendor to 900kb

* use openDialog for mermaid

* upgrade mermaid-to-excalidraw

* update frame id post generation

* upgrade mermaid-to-excalidraw to add entity codes support

* update size limit

* upgrade mermaid-to-excalidraw package with frame api changes

* upgrade mermaid-to-excalidraw to remove directive and use config

* don't highlight mermaid tool and remove unused api setSelection

* stop using loading state to update text area

* move some styling to scss

* review fixes

* use modifiedTableIcon props and remove stale snap

* css

* dialog css

* fix snap

* use dialog border

* change mermaidToExcalidrawLib to state

* better styling of errors

* make modal bigger

* fix mobile

* update snaps

* fix icon color

* fix dark mode insert button color

* horizontally center spinner

* render canvas conditionally on loaded state

* rd tweaks

* tweak class names

* remove max height

* typo in example

* upgrade mermaid-to-excalidraw

* simplify error state

* fix height & overflow on vertical breakpoint

* fix lint

* show errors in overlay

* set textarea font family

* reduce opacity

* update snap

* upgrade to mermaid  0.1.2

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi
2023-11-03 17:41:34 +05:30
committed by GitHub
parent a7db41c5ba
commit e8def8da8d
26 changed files with 1579 additions and 1228 deletions

View File

@ -0,0 +1,167 @@
import { act, fireEvent, render } from "./test-utils";
import { Excalidraw } from "../packages/excalidraw/index";
import React from "react";
import { expect, vi } from "vitest";
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
import { getTextEditor, updateTextEditor } from "./queries/dom";
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
const module = (await importActual()) as any;
return {
__esModule: true,
...module,
};
});
const parseMermaidToExcalidrawSpy = vi.spyOn(
MermaidToExcalidraw,
"parseMermaidToExcalidraw",
);
parseMermaidToExcalidrawSpy.mockImplementation(
async (
definition: string,
options?: MermaidToExcalidraw.MermaidOptions | undefined,
) => {
const firstLine = definition.split("\n")[0];
return new Promise((resolve, reject) => {
if (firstLine === "flowchart TD") {
resolve({
elements: [
{
id: "Start",
type: "rectangle",
groupIds: [],
x: 0,
y: 0,
width: 69.703125,
height: 44,
strokeWidth: 2,
label: {
groupIds: [],
text: "Start",
fontSize: 20,
},
link: null,
},
{
id: "Stop",
type: "rectangle",
groupIds: [],
x: 2.7109375,
y: 94,
width: 64.28125,
height: 44,
strokeWidth: 2,
label: {
groupIds: [],
text: "Stop",
fontSize: 20,
},
link: null,
},
{
id: "Start_Stop",
type: "arrow",
groupIds: [],
x: 34.852,
y: 44,
strokeWidth: 2,
points: [
[0, 0],
[0, 50],
],
roundness: {
type: 2,
},
start: {
id: "Start",
},
end: {
id: "Stop",
},
},
],
});
} else {
reject(new Error("ERROR"));
}
});
},
);
vi.spyOn(React, "useRef").mockReturnValue({
current: {
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
},
});
describe("Test <MermaidToExcalidraw/>", () => {
beforeEach(async () => {
await render(
<Excalidraw
initialData={{
appState: {
openDialog: "mermaid",
},
}}
/>,
);
});
it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".dialog-mermaid")!;
expect(dialog.outerHTML).toMatchSnapshot();
});
it("should close the popup and set the tool to selection when close button clicked", () => {
const dialog = document.querySelector(".dialog-mermaid")!;
const closeBtn = dialog.querySelector(".Dialog__close")!;
fireEvent.click(closeBtn);
expect(document.querySelector(".dialog-mermaid")).toBe(null);
expect(window.h.state.activeTool).toStrictEqual({
customType: null,
lastActiveTool: null,
locked: false,
type: "selection",
});
});
it("should show error in preview when mermaid library throws error", async () => {
const dialog = document.querySelector(".dialog-mermaid")!;
const selector = ".dialog-mermaid-panels-text textarea";
let editor = await getTextEditor(selector, false);
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
expect(editor.textContent).toMatchInlineSnapshot(`
"flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[Car]"
`);
await act(async () => {
updateTextEditor(editor, "flowchart TD1");
await new Promise((cb) => setTimeout(cb, 0));
});
editor = await getTextEditor(selector, false);
expect(editor.textContent).toBe("flowchart TD1");
expect(dialog.querySelector('[data-testid="mermaid-error"]'))
.toMatchInlineSnapshot(`
<div
class="mermaid-error"
data-testid="mermaid-error"
>
Error!
<p>
ERROR
</p>
</div>
`);
});
});

View File

@ -0,0 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><span class=\\"dialog-mermaid-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.<br></span></span></h2><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div class=\\"dialog-mermaid-body\\"><div class=\\"dialog-mermaid-panels\\"><div class=\\"dialog-mermaid-panels-text\\"><label>Mermaid Syntax</label><textarea>flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
`;

View File

@ -468,16 +468,16 @@ export class UI {
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const openedEditor = document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector);
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}

View File

@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(3);
@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta,
]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5);
@ -519,7 +519,7 @@ describe("Test Linear Elements", () => {
// delete 3rd point
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(20);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
@ -566,7 +566,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5);

View File

@ -1,13 +1,19 @@
import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (waitForEditor = true) => {
const query = () =>
document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
export const getTextEditor = async (selector: string, waitForEditor = true) => {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));
return query();
}
return query();
};
export const updateTextEditor = (
editor: HTMLTextAreaElement,
value: string,
) => {
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};