浏览代码

feat: paste as mermaid if applicable (#8116)

David Luzar 1 年之前
父节点
当前提交
22b39277f5

+ 29 - 0
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<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -3050,6 +3052,33 @@ class App extends React.Component<AppProps, AppState> {
           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())

+ 32 - 0
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());
+};

+ 5 - 27
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 <MermaidToExcalidraw/>", () => {

+ 81 - 0
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" }),
+        ]),
+      );
+    });
+  });
+});

+ 32 - 0
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,
+      },
+    });
+  }
+};