diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 87889a724f..9fae303f01 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -49,6 +49,7 @@ import { import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; import type { EXPORT_IMAGE_TYPES } from "../constants"; +import { DEFAULT_FONT_SIZE } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -435,6 +436,7 @@ import { import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; +import { isMaybeMermaidDefinition } from "../mermaid"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -3050,6 +3052,33 @@ class App extends React.Component { retainSeed: isPlainPaste, }); } else if (data.text) { + if (data.text && isMaybeMermaidDefinition(data.text)) { + const api = await import("@excalidraw/mermaid-to-excalidraw"); + + try { + const { elements: skeletonElements, files } = + await api.parseMermaidToExcalidraw(data.text, { + fontSize: DEFAULT_FONT_SIZE, + }); + + const elements = convertToExcalidrawElements(skeletonElements, { + regenerateIds: true, + }); + + this.addElementsFromPasteOrLibrary({ + elements, + files, + position: "cursor", + }); + + return; + } catch (err: any) { + console.warn( + `parsing pasted text as mermaid definition failed: ${err.message}`, + ); + } + } + const nonEmptyLines = normalizeEOL(data.text) .split(/\n+/) .map((s) => s.trim()) diff --git a/packages/excalidraw/mermaid.ts b/packages/excalidraw/mermaid.ts new file mode 100644 index 0000000000..6114cd002a --- /dev/null +++ b/packages/excalidraw/mermaid.ts @@ -0,0 +1,32 @@ +/** heuristically checks whether the text may be a mermaid diagram definition */ +export const isMaybeMermaidDefinition = (text: string) => { + const chartTypes = [ + "flowchart", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "stateDiagram-v2", + "erDiagram", + "journey", + "gantt", + "pie", + "quadrantChart", + "requirementDiagram", + "gitGraph", + "C4Context", + "mindmap", + "timeline", + "zenuml", + "sankey", + "xychart", + "block", + ]; + + const re = new RegExp( + `^(?:%%{.*?}%%[\\s\\n]*)?\\b${chartTypes + .map((x) => `${x}(-beta)?`) + .join("|")}\\b`, + ); + + return re.test(text.trim()); +}; diff --git a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx index 1e782cfb2b..8c5a2acd73 100644 --- a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx +++ b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx @@ -1,28 +1,12 @@ import { act, render, waitFor } from "./test-utils"; import { Excalidraw } from "../index"; -import React from "react"; -import { expect, vi } from "vitest"; -import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw"; +import { expect } from "vitest"; import { getTextEditor, updateTextEditor } from "./queries/dom"; +import { mockMermaidToExcalidraw } from "./helpers/mocks"; -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, - ) => { +mockMermaidToExcalidraw({ + mockRef: true, + parseMermaidToExcalidraw: async (definition) => { const firstLine = definition.split("\n")[0]; return new Promise((resolve, reject) => { if (firstLine === "flowchart TD") { @@ -88,12 +72,6 @@ parseMermaidToExcalidrawSpy.mockImplementation( } }); }, -); - -vi.spyOn(React, "useRef").mockReturnValue({ - current: { - parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy, - }, }); describe("Test ", () => { diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 4d4717063e..89f25f7ef6 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -13,6 +13,7 @@ import type { NormalizedZoomValue } from "../types"; import { API } from "./helpers/api"; import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard"; import { arrayToMap } from "../utils"; +import { mockMermaidToExcalidraw } from "./helpers/mocks"; const { h } = window; @@ -435,3 +436,83 @@ describe("pasting & frames", () => { }); }); }); + +describe("clipboard - pasting mermaid definition", () => { + beforeAll(() => { + mockMermaidToExcalidraw({ + parseMermaidToExcalidraw: async (definition) => { + const lines = definition.split("\n"); + return new Promise((resolve, reject) => { + if (lines.some((line) => line === "flowchart TD")) { + resolve({ + elements: [ + { + id: "rect1", + type: "rectangle", + groupIds: [], + x: 0, + y: 0, + width: 69.703125, + height: 44, + strokeWidth: 2, + label: { + groupIds: [], + text: "A", + fontSize: 20, + }, + link: null, + }, + ], + }); + } else { + reject(new Error("ERROR")); + } + }); + }, + }); + }); + + it("should detect and paste as mermaid", async () => { + const text = "flowchart TD\nA"; + + pasteWithCtrlCmdV(text); + await waitFor(() => { + expect(h.elements.length).toEqual(2); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "rectangle" }), + expect.objectContaining({ type: "text", text: "A" }), + ]), + ); + }); + }); + + it("should support directives", async () => { + const text = "%%{init: { **config** } }%%\nflowchart TD\nA"; + + pasteWithCtrlCmdV(text); + await waitFor(() => { + expect(h.elements.length).toEqual(2); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "rectangle" }), + expect.objectContaining({ type: "text", text: "A" }), + ]), + ); + }); + }); + + it("should paste as normal text if invalid mermaid", async () => { + const text = "flowchart TD xx\nA"; + pasteWithCtrlCmdV(text); + await waitFor(() => { + expect(h.elements.length).toEqual(2); + expect(h.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "flowchart TD xx" }), + expect.objectContaining({ type: "text", text: "A" }), + ]), + ); + }); + }); +}); diff --git a/packages/excalidraw/tests/helpers/mocks.ts b/packages/excalidraw/tests/helpers/mocks.ts new file mode 100644 index 0000000000..a87523ec19 --- /dev/null +++ b/packages/excalidraw/tests/helpers/mocks.ts @@ -0,0 +1,32 @@ +import { vi } from "vitest"; +import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw"; +import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; +import React from "react"; + +export const mockMermaidToExcalidraw = (opts: { + parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw; + mockRef?: boolean; +}) => { + 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(opts.parseMermaidToExcalidraw); + + if (opts.mockRef) { + vi.spyOn(React, "useRef").mockReturnValue({ + current: { + parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy, + }, + }); + } +};