Browse Source

feat: text-to-diagram (#7325)

Co-authored-by: dwelle <[email protected]>
Barnabás Molnár 1 năm trước cách đây
mục cha
commit
14845a343b
37 tập tin đã thay đổi với 1381 bổ sung510 xóa
  1. 2 0
      .env.development
  2. 2 0
      .env.production
  3. 60 0
      excalidraw-app/index.tsx
  4. 7 3
      src/components/Actions.tsx
  5. 8 12
      src/components/App.tsx
  6. 5 1
      src/components/Button.tsx
  7. 2 0
      src/components/LayerUI.tsx
  8. 0 221
      src/components/MermaidToExcalidraw.scss
  9. 0 243
      src/components/MermaidToExcalidraw.tsx
  10. 10 0
      src/components/TTDDialog/MermaidToExcalidraw.scss
  11. 133 0
      src/components/TTDDialog/MermaidToExcalidraw.tsx
  12. 301 0
      src/components/TTDDialog/TTDDialog.scss
  13. 325 0
      src/components/TTDDialog/TTDDialog.tsx
  14. 52 0
      src/components/TTDDialog/TTDDialogInput.tsx
  15. 39 0
      src/components/TTDDialog/TTDDialogOutput.tsx
  16. 58 0
      src/components/TTDDialog/TTDDialogPanel.tsx
  17. 5 0
      src/components/TTDDialog/TTDDialogPanels.tsx
  18. 17 0
      src/components/TTDDialog/TTDDialogTab.tsx
  19. 21 0
      src/components/TTDDialog/TTDDialogTabTrigger.tsx
  20. 13 0
      src/components/TTDDialog/TTDDialogTabTriggers.tsx
  21. 38 0
      src/components/TTDDialog/TTDDialogTabs.tsx
  22. 34 0
      src/components/TTDDialog/TTDDialogTrigger.tsx
  23. 153 0
      src/components/TTDDialog/common.ts
  24. 4 0
      src/components/dropdownMenu/DropdownMenu.scss
  25. 27 1
      src/components/dropdownMenu/DropdownMenuItem.tsx
  26. 13 0
      src/components/icons.tsx
  27. 2 0
      src/context/tunnels.ts
  28. 1 0
      src/css/styles.scss
  29. 3 1
      src/locales/en.json
  30. 17 1
      src/packages/excalidraw/example/App.tsx
  31. 2 0
      src/packages/excalidraw/index.tsx
  32. 13 19
      src/tests/MermaidToExcalidraw.test.tsx
  33. 2 2
      src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
  34. 4 4
      src/tests/linearElementEditor.test.tsx
  35. 3 2
      src/types.ts
  36. 4 0
      src/utils.ts
  37. 1 0
      src/vite-env.d.ts

+ 2 - 0
.env.development

@@ -13,6 +13,8 @@ VITE_APP_PORTAL_URL=
 VITE_APP_PLUS_LP=https://plus.excalidraw.com
 VITE_APP_PLUS_APP=https://app.excalidraw.com
 
+VITE_APP_AI_BACKEND=http://localhost:3015
+
 VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
 
 # put these in your .env.local, or make sure you don't commit!

+ 2 - 0
.env.production

@@ -9,6 +9,8 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
 VITE_APP_PLUS_LP=https://plus.excalidraw.com
 VITE_APP_PLUS_APP=https://app.excalidraw.com
 
+VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
+
 # Fill to set socket server URL used for collaboration.
 # Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
 VITE_APP_WS_SERVER_URL=

+ 60 - 0
excalidraw-app/index.tsx

@@ -25,6 +25,8 @@ import {
   Excalidraw,
   defaultLang,
   LiveCollaborationTrigger,
+  TTDDialog,
+  TTDDialogTrigger,
 } from "../src/packages/excalidraw/index";
 import {
   AppState,
@@ -773,6 +775,64 @@ const ExcalidrawWrapper = () => {
           )}
         </OverwriteConfirmDialog>
         <AppFooter />
+        <TTDDialog
+          onTextSubmit={async (input) => {
+            try {
+              const response = await fetch(
+                `${
+                  import.meta.env.VITE_APP_AI_BACKEND
+                }/v1/ai/text-to-diagram/generate`,
+                {
+                  method: "POST",
+                  headers: {
+                    Accept: "application/json",
+                    "Content-Type": "application/json",
+                  },
+                  body: JSON.stringify({ prompt: input }),
+                },
+              );
+
+              const rateLimit = response.headers.has("X-Ratelimit-Limit")
+                ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
+                : undefined;
+
+              const rateLimitRemaining = response.headers.has(
+                "X-Ratelimit-Remaining",
+              )
+                ? parseInt(
+                    response.headers.get("X-Ratelimit-Remaining") || "0",
+                    10,
+                  )
+                : undefined;
+
+              const json = await response.json();
+
+              if (!response.ok) {
+                if (response.status === 429) {
+                  return {
+                    rateLimit,
+                    rateLimitRemaining,
+                    error: new Error(
+                      "Too many requests today, please try again tomorrow!",
+                    ),
+                  };
+                }
+
+                throw new Error(json.message || "Generation failed...");
+              }
+
+              const generatedResponse = json.generatedResponse;
+              if (!generatedResponse) {
+                throw new Error("Generation failed...");
+              }
+
+              return { generatedResponse, rateLimit, rateLimitRemaining };
+            } catch (err: any) {
+              throw new Error("Request failed");
+            }
+          }}
+        />
+        <TTDDialogTrigger />
         {isCollaborating && isOffline && (
           <div className="collab-offline-warning">
             {t("alerts.collabOfflineWarning")}

+ 7 - 3
src/components/Actions.tsx

@@ -40,6 +40,7 @@ import {
   MagicIcon,
 } from "./icons";
 import { KEYS } from "../keys";
+import { useTunnels } from "../context/tunnels";
 
 export const SelectedShapeActions = ({
   appState,
@@ -235,6 +236,8 @@ export const ShapesSwitcher = ({
   const laserToolSelected = activeTool.type === "laser";
   const embeddableToolSelected = activeTool.type === "embeddable";
 
+  const { TTDDialogTriggerTunnel } = useTunnels();
+
   return (
     <>
       {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -338,14 +341,14 @@ export const ShapesSwitcher = ({
           <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
             Generate
           </div>
+          {app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
           <DropdownMenu.Item
-            onSelect={() => app.setOpenDialog({ name: "mermaid" })}
+            onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
             icon={mermaidLogoIcon}
             data-testid="toolbar-embeddable"
           >
             {t("toolBar.mermaidToExcalidraw")}
           </DropdownMenu.Item>
-
           {app.props.aiEnabled !== false && (
             <>
               <DropdownMenu.Item
@@ -354,10 +357,11 @@ export const ShapesSwitcher = ({
                 data-testid="toolbar-magicframe"
               >
                 {t("toolBar.magicframe")}
+                <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
               </DropdownMenu.Item>
               <DropdownMenu.Item
                 onSelect={() => {
-                  trackEvent("ai", "d2c-settings", "settings");
+                  trackEvent("ai", "open-settings", "d2c");
                   app.setOpenDialog({
                     name: "magicSettings",
                     source: "settings",

+ 8 - 12
src/components/App.tsx

@@ -381,7 +381,6 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
 import { StaticCanvas, InteractiveCanvas } from "./canvases";
 import { Renderer } from "../scene/Renderer";
 import { ShapeCache } from "../scene/ShapeCache";
-import MermaidToExcalidraw from "./MermaidToExcalidraw";
 import { LaserToolOverlay } from "./LaserTool/LaserTool";
 import { LaserPathManager } from "./LaserTool/LaserPathManager";
 import {
@@ -1435,9 +1434,6 @@ class App extends React.Component<AppProps, AppState> {
                           onMagicSettingsConfirm={this.onMagicSettingsConfirm}
                         >
                           {this.props.children}
-                          {this.state.openDialog?.name === "mermaid" && (
-                            <MermaidToExcalidraw />
-                          )}
                         </LayerUI>
 
                         <div className="excalidraw-textEditorContainer" />
@@ -1706,7 +1702,7 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({
         openDialog: { name: "magicSettings", source: "generation" },
       });
-      trackEvent("ai", "d2c-generate", "missing-key");
+      trackEvent("ai", "generate (missing key)", "d2c");
       return;
     }
 
@@ -1719,7 +1715,7 @@ class App extends React.Component<AppProps, AppState> {
     if (!magicFrameChildren.length) {
       if (source === "button") {
         this.setState({ errorMessage: "Cannot generate from an empty frame" });
-        trackEvent("ai", "d2c-generate", "no-children");
+        trackEvent("ai", "generate (no-children)", "d2c");
       } else {
         this.setActiveTool({ type: "magicframe" });
       }
@@ -1761,7 +1757,7 @@ class App extends React.Component<AppProps, AppState> {
 
     const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
 
-    trackEvent("ai", "d2c-generate", "generating");
+    trackEvent("ai", "generate (start)", "d2c");
 
     const result = await diagramToHTML({
       image: dataURL,
@@ -1771,7 +1767,7 @@ class App extends React.Component<AppProps, AppState> {
     });
 
     if (!result.ok) {
-      trackEvent("ai", "d2c-generate", "generating-failed");
+      trackEvent("ai", "generate (failed)", "d2c");
       console.error(result.error);
       this.updateMagicGeneration({
         frameElement,
@@ -1783,7 +1779,7 @@ class App extends React.Component<AppProps, AppState> {
       });
       return;
     }
-    trackEvent("ai", "d2c-generate", "generating-done");
+    trackEvent("ai", "generate (success)", "d2c");
 
     if (result.choices[0].message.content == null) {
       this.updateMagicGeneration({
@@ -1877,7 +1873,7 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({
         openDialog: { name: "magicSettings", source: "tool" },
       });
-      trackEvent("ai", "d2c-tool", "missing-key");
+      trackEvent("ai", "tool-select (missing key)", "d2c");
       return;
     }
 
@@ -1887,7 +1883,7 @@ class App extends React.Component<AppProps, AppState> {
 
     if (selectedElements.length === 0) {
       this.setActiveTool({ type: TOOL_TYPE.magicframe });
-      trackEvent("ai", "d2c-tool", "empty-selection");
+      trackEvent("ai", "tool-select (empty-selection)", "d2c");
     } else {
       const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
         selectedElements.length === 1 &&
@@ -1905,7 +1901,7 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
-      trackEvent("ai", "d2c-tool", "existing-selection");
+      trackEvent("ai", "tool-select (existing selection)", "d2c");
 
       let frame: ExcalidrawMagicFrameElement;
       if (selectedMagicFrame) {

+ 5 - 1
src/components/Button.tsx

@@ -2,7 +2,11 @@ import clsx from "clsx";
 import { composeEventHandlers } from "../utils";
 import "./Button.scss";
 
-interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
+interface ButtonProps
+  extends React.DetailedHTMLProps<
+    React.ButtonHTMLAttributes<HTMLButtonElement>,
+    HTMLButtonElement
+  > {
   type?: "button" | "submit" | "reset";
   onSelect: () => any;
   /** whether button is in active state */

+ 2 - 0
src/components/LayerUI.tsx

@@ -62,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
 import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
 import { MagicSettings } from "./MagicSettings";
+import { TTDDialog } from "./TTDDialog/TTDDialog";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -396,6 +397,7 @@ const LayerUI = ({
         {t("toolBar.library")}
       </DefaultSidebar.Trigger>
       <DefaultOverwriteConfirmDialog />
+      {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
       {/* ------------------------------------------------------------------ */}
 
       {appState.isLoading && <LoadingMessage delay={250} />}

+ 0 - 221
src/components/MermaidToExcalidraw.scss

@@ -1,221 +0,0 @@
-@import "../css/variables.module";
-
-$verticalBreakpoint: 860px;
-
-.excalidraw {
-  .dialog-mermaid {
-    &-title {
-      margin-bottom: 5px;
-      margin-top: 2px;
-    }
-    &-desc {
-      font-size: 15px;
-      font-style: italic;
-      font-weight: 500;
-    }
-
-    .Modal__content .Island {
-      box-shadow: none;
-    }
-
-    @at-root .excalidraw:not(.excalidraw--mobile)#{&} {
-      padding: 1.25rem;
-
-      .Modal__content {
-        height: 100%;
-        max-height: 750px;
-
-        @media screen and (max-width: $verticalBreakpoint) {
-          height: auto;
-          // When vertical, we want the height to span whole viewport.
-          // This is also important for the children not to overflow the
-          // modal/viewport (for some reason).
-          max-height: 100%;
-        }
-
-        .Island {
-          height: 100%;
-          display: flex;
-          flex-direction: column;
-          flex: 1 1 auto;
-
-          .Dialog__content {
-            display: flex;
-            flex: 1 1 auto;
-          }
-        }
-      }
-    }
-  }
-
-  .dialog-mermaid-body {
-    width: 100%;
-    display: grid;
-    grid-template-columns: 1fr 1fr;
-    grid-template-rows: 1fr auto;
-    height: 100%;
-    column-gap: 4rem;
-
-    @media screen and (max-width: $verticalBreakpoint) {
-      flex-direction: column;
-      display: flex;
-      gap: 1rem;
-    }
-  }
-
-  .dialog-mermaid-panels {
-    display: grid;
-    width: 100%;
-    grid-template-columns: 1fr 1fr;
-    justify-content: space-between;
-    gap: 4rem;
-
-    grid-row: 1;
-    grid-column: 1 / 3;
-
-    @media screen and (max-width: $verticalBreakpoint) {
-      flex-direction: column;
-      display: flex;
-      gap: 1rem;
-    }
-
-    label {
-      font-size: 14px;
-      font-style: normal;
-      font-weight: 600;
-      margin-bottom: 4px;
-      margin-left: 4px;
-
-      @media screen and (max-width: $verticalBreakpoint) {
-        margin-top: 4px;
-      }
-    }
-
-    &-text {
-      display: flex;
-      flex-direction: column;
-
-      textarea {
-        width: 20rem;
-        height: 100%;
-        resize: none;
-        border-radius: var(--border-radius-lg);
-        border: 1px solid var(--dialog-border-color);
-        white-space: pre-wrap;
-        padding: 0.85rem;
-        box-sizing: border-box;
-        width: 100%;
-        font-family: monospace;
-
-        @media screen and (max-width: $verticalBreakpoint) {
-          width: auto;
-          height: 10rem;
-        }
-      }
-    }
-
-    &-preview-wrapper {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      padding: 0.85rem;
-      box-sizing: border-box;
-      width: 100%;
-      // acts as min-height
-      height: 200px;
-      flex-grow: 1;
-      position: relative;
-
-      background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
-        left center;
-      border-radius: var(--border-radius-lg);
-      border: 1px solid var(--dialog-border-color);
-
-      @media screen and (max-width: $verticalBreakpoint) {
-        // acts as min-height
-        height: 400px;
-        width: auto;
-      }
-
-      canvas {
-        max-width: 100%;
-        max-height: 100%;
-      }
-    }
-
-    &-preview-canvas-container {
-      display: flex;
-      width: 100%;
-      height: 100%;
-      align-items: center;
-      justify-content: center;
-      flex-grow: 1;
-    }
-
-    &-preview {
-      display: flex;
-      flex-direction: column;
-    }
-
-    .mermaid-error {
-      color: red;
-      font-weight: 800;
-      font-size: 30px;
-      word-break: break-word;
-      overflow: auto;
-      max-height: 100%;
-      height: 100%;
-      width: 100%;
-      text-align: center;
-      position: absolute;
-      z-index: 10;
-
-      p {
-        font-weight: 500;
-        font-family: Cascadia;
-        text-align: left;
-        white-space: pre-wrap;
-        font-size: 0.875rem;
-        padding: 0 10px;
-      }
-    }
-  }
-
-  .dialog-mermaid-buttons {
-    grid-column: 2;
-
-    .dialog-mermaid-insert {
-      &.excalidraw-button {
-        font-family: "Assistant";
-        font-weight: 600;
-        height: 2.5rem;
-        margin-top: 1em;
-        margin-bottom: 0.3em;
-        width: 7.5rem;
-        font-size: 12px;
-        color: $oc-white;
-        background-color: var(--color-primary);
-
-        &:hover {
-          background-color: var(--color-primary-darker);
-        }
-        &:active {
-          background-color: var(--color-primary-darkest);
-        }
-
-        @media screen and (max-width: $verticalBreakpoint) {
-          width: 100%;
-        }
-
-        @at-root .excalidraw.theme--dark#{&} {
-          color: var(--color-gray-100);
-        }
-      }
-
-      span {
-        padding-left: 0.5rem;
-        display: flex;
-      }
-    }
-  }
-}

+ 0 - 243
src/components/MermaidToExcalidraw.tsx

@@ -1,243 +0,0 @@
-import { useState, useRef, useEffect, useDeferredValue } from "react";
-import { BinaryFiles } from "../types";
-import { useApp } from "./App";
-import { Button } from "./Button";
-import { Dialog } from "./Dialog";
-import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
-import {
-  convertToExcalidrawElements,
-  exportToCanvas,
-} from "../packages/excalidraw/index";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { canvasToBlob } from "../data/blob";
-import { ArrowRightIcon } from "./icons";
-import Spinner from "./Spinner";
-import "./MermaidToExcalidraw.scss";
-
-import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
-import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
-import { t } from "../i18n";
-import Trans from "./Trans";
-
-const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
-const MERMAID_EXAMPLE =
-  "flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
-
-const saveMermaidDataToStorage = (data: string) => {
-  try {
-    localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
-  } catch (error: any) {
-    // Unable to access window.localStorage
-    console.error(error);
-  }
-};
-
-const importMermaidDataFromStorage = () => {
-  try {
-    const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
-    if (data) {
-      return data;
-    }
-  } catch (error: any) {
-    // Unable to access localStorage
-    console.error(error);
-  }
-
-  return null;
-};
-
-const ErrorComp = ({ error }: { error: string }) => {
-  return (
-    <div data-testid="mermaid-error" className="mermaid-error">
-      Error! <p>{error}</p>
-    </div>
-  );
-};
-
-const MermaidToExcalidraw = () => {
-  const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
-    loaded: boolean;
-    api: {
-      parseMermaidToExcalidraw: (
-        defination: string,
-        options: MermaidOptions,
-      ) => Promise<MermaidToExcalidrawResult>;
-    } | null;
-  }>({ loaded: false, api: null });
-
-  const [text, setText] = useState("");
-  const deferredText = useDeferredValue(text.trim());
-  const [error, setError] = useState(null);
-
-  const canvasRef = useRef<HTMLDivElement>(null);
-  const data = useRef<{
-    elements: readonly NonDeletedExcalidrawElement[];
-    files: BinaryFiles | null;
-  }>({ elements: [], files: null });
-
-  const app = useApp();
-
-  const resetPreview = () => {
-    const canvasNode = canvasRef.current;
-
-    if (!canvasNode) {
-      return;
-    }
-    const parent = canvasNode.parentElement;
-    if (!parent) {
-      return;
-    }
-    parent.style.background = "";
-    setError(null);
-    canvasNode.replaceChildren();
-  };
-
-  useEffect(() => {
-    const loadMermaidToExcalidrawLib = async () => {
-      const api = await import(
-        /* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
-      );
-      setMermaidToExcalidrawLib({ loaded: true, api });
-    };
-    loadMermaidToExcalidrawLib();
-  }, []);
-
-  useEffect(() => {
-    const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
-    setText(data);
-  }, []);
-
-  useEffect(() => {
-    const renderExcalidrawPreview = async () => {
-      const canvasNode = canvasRef.current;
-      const parent = canvasNode?.parentElement;
-      if (
-        !mermaidToExcalidrawLib.loaded ||
-        !canvasNode ||
-        !parent ||
-        !mermaidToExcalidrawLib.api
-      ) {
-        return;
-      }
-      if (!deferredText) {
-        resetPreview();
-        return;
-      }
-      try {
-        const { elements, files } =
-          await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
-            deferredText,
-            {
-              fontSize: DEFAULT_FONT_SIZE,
-            },
-          );
-        setError(null);
-
-        data.current = {
-          elements: convertToExcalidrawElements(elements, {
-            regenerateIds: true,
-          }),
-          files,
-        };
-
-        const canvas = await exportToCanvas({
-          elements: data.current.elements,
-          files: data.current.files,
-          exportPadding: DEFAULT_EXPORT_PADDING,
-          maxWidthOrHeight:
-            Math.max(parent.offsetWidth, parent.offsetHeight) *
-            window.devicePixelRatio,
-        });
-        // if converting to blob fails, there's some problem that will
-        // likely prevent preview and export (e.g. canvas too big)
-        await canvasToBlob(canvas);
-        parent.style.background = "var(--default-bg-color)";
-        canvasNode.replaceChildren(canvas);
-      } catch (e: any) {
-        parent.style.background = "var(--default-bg-color)";
-        if (deferredText) {
-          setError(e.message);
-        }
-      }
-    };
-    renderExcalidrawPreview();
-  }, [deferredText, mermaidToExcalidrawLib]);
-
-  const onClose = () => {
-    app.setOpenDialog(null);
-    saveMermaidDataToStorage(text);
-  };
-
-  const onSelect = () => {
-    const { elements: newElements, files } = data.current;
-    app.addElementsFromPasteOrLibrary({
-      elements: newElements,
-      files,
-      position: "center",
-      fitToContent: true,
-    });
-    onClose();
-  };
-
-  return (
-    <Dialog
-      className="dialog-mermaid"
-      onCloseRequest={onClose}
-      size={1200}
-      title={
-        <>
-          <p className="dialog-mermaid-title">{t("mermaid.title")}</p>
-          <span className="dialog-mermaid-desc">
-            <Trans
-              i18nKey="mermaid.description"
-              flowchartLink={(el) => (
-                <a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
-              )}
-              sequenceLink={(el) => (
-                <a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
-                  {el}
-                </a>
-              )}
-            />
-            <br />
-          </span>
-        </>
-      }
-    >
-      <div className="dialog-mermaid-body">
-        <div className="dialog-mermaid-panels">
-          <div className="dialog-mermaid-panels-text">
-            <label>{t("mermaid.syntax")}</label>
-
-            <textarea
-              onChange={(event) => setText(event.target.value)}
-              value={text}
-            />
-          </div>
-          <div className="dialog-mermaid-panels-preview">
-            <label>{t("mermaid.preview")}</label>
-            <div className="dialog-mermaid-panels-preview-wrapper">
-              {error && <ErrorComp error={error} />}
-              {mermaidToExcalidrawLib.loaded ? (
-                <div
-                  ref={canvasRef}
-                  style={{ opacity: error ? "0.15" : 1 }}
-                  className="dialog-mermaid-panels-preview-canvas-container"
-                />
-              ) : (
-                <Spinner size="2rem" />
-              )}
-            </div>
-          </div>
-        </div>
-        <div className="dialog-mermaid-buttons">
-          <Button className="dialog-mermaid-insert" onSelect={onSelect}>
-            {t("mermaid.button")}
-            <span>{ArrowRightIcon}</span>
-          </Button>
-        </div>
-      </div>
-    </Dialog>
-  );
-};
-export default MermaidToExcalidraw;

+ 10 - 0
src/components/TTDDialog/MermaidToExcalidraw.scss

@@ -0,0 +1,10 @@
+.excalidraw {
+  .dialog-mermaid {
+    &-title {
+      margin-block: 0.25rem;
+      font-size: 1.25rem;
+      font-weight: 700;
+      padding-inline: 2.5rem;
+    }
+  }
+}

+ 133 - 0
src/components/TTDDialog/MermaidToExcalidraw.tsx

@@ -0,0 +1,133 @@
+import { useState, useRef, useEffect, useDeferredValue } from "react";
+import { BinaryFiles } from "../../types";
+import { useApp } from "../App";
+import { NonDeletedExcalidrawElement } from "../../element/types";
+import { ArrowRightIcon } from "../icons";
+import "./MermaidToExcalidraw.scss";
+import { t } from "../../i18n";
+import Trans from "../Trans";
+import {
+  LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW,
+  MermaidToExcalidrawLibProps,
+  convertMermaidToExcalidraw,
+  insertToEditor,
+  saveMermaidDataToStorage,
+} from "./common";
+import { TTDDialogPanels } from "./TTDDialogPanels";
+import { TTDDialogPanel } from "./TTDDialogPanel";
+import { TTDDialogInput } from "./TTDDialogInput";
+import { TTDDialogOutput } from "./TTDDialogOutput";
+
+const MERMAID_EXAMPLE =
+  "flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
+
+const importMermaidDataFromStorage = () => {
+  try {
+    const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
+    if (data) {
+      return data;
+    }
+  } catch (error: any) {
+    // Unable to access localStorage
+    console.error(error);
+  }
+
+  return null;
+};
+
+const MermaidToExcalidraw = ({
+  mermaidToExcalidrawLib,
+}: {
+  mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
+}) => {
+  const [text, setText] = useState("");
+  const deferredText = useDeferredValue(text.trim());
+  const [error, setError] = useState<Error | null>(null);
+
+  const canvasRef = useRef<HTMLDivElement>(null);
+  const data = useRef<{
+    elements: readonly NonDeletedExcalidrawElement[];
+    files: BinaryFiles | null;
+  }>({ elements: [], files: null });
+
+  const app = useApp();
+
+  useEffect(() => {
+    const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
+    setText(data);
+  }, []);
+
+  useEffect(() => {
+    convertMermaidToExcalidraw({
+      canvasRef,
+      data,
+      mermaidToExcalidrawLib,
+      setError,
+      text: deferredText,
+    }).catch(() => {});
+  }, [deferredText, mermaidToExcalidrawLib]);
+
+  const textRef = useRef(text);
+
+  // slightly hacky but really quite simple
+  // essentially, we want to save the text to LS when the component unmounts
+  useEffect(() => {
+    textRef.current = text;
+  }, [text]);
+  useEffect(() => {
+    return () => {
+      if (textRef.current) {
+        saveMermaidDataToStorage(textRef.current);
+      }
+    };
+  }, []);
+
+  return (
+    <>
+      <div className="ttd-dialog-desc">
+        <Trans
+          i18nKey="mermaid.description"
+          flowchartLink={(el) => (
+            <a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
+          )}
+          sequenceLink={(el) => (
+            <a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
+              {el}
+            </a>
+          )}
+        />
+      </div>
+      <TTDDialogPanels>
+        <TTDDialogPanel label={t("mermaid.syntax")}>
+          <TTDDialogInput
+            input={text}
+            placeholder={"Write Mermaid diagram defintion here..."}
+            onChange={(event) => setText(event.target.value)}
+          />
+        </TTDDialogPanel>
+        <TTDDialogPanel
+          label={t("mermaid.preview")}
+          panelAction={{
+            action: () => {
+              insertToEditor({
+                app,
+                data,
+                text,
+                shouldSaveMermaidDataToStorage: true,
+              });
+            },
+            label: t("mermaid.button"),
+            icon: ArrowRightIcon,
+          }}
+        >
+          <TTDDialogOutput
+            canvasRef={canvasRef}
+            loaded={mermaidToExcalidrawLib.loaded}
+            error={error}
+          />
+        </TTDDialogPanel>
+      </TTDDialogPanels>
+    </>
+  );
+};
+export default MermaidToExcalidraw;

+ 301 - 0
src/components/TTDDialog/TTDDialog.scss

@@ -0,0 +1,301 @@
+@import "../../css/variables.module";
+
+$verticalBreakpoint: 861px;
+
+.excalidraw {
+  .Modal.Dialog.ttd-dialog {
+    padding: 1.25rem;
+
+    &.Dialog--fullscreen {
+      margin-top: 0;
+    }
+
+    .Island {
+      padding-inline: 0 !important;
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      flex: 1 1 auto;
+      box-shadow: none;
+    }
+
+    .Modal__content {
+      height: auto;
+      max-height: 100%;
+
+      @media screen and (min-width: $verticalBreakpoint) {
+        max-height: 750px;
+        height: 100%;
+      }
+    }
+
+    .Dialog__content {
+      flex: 1 1 auto;
+    }
+  }
+
+  .ttd-dialog-desc {
+    font-size: 15px;
+    font-style: italic;
+    font-weight: 500;
+    margin-bottom: 1.5rem;
+  }
+
+  .ttd-dialog-tabs-root {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .ttd-dialog-tab-trigger {
+    color: var(--color-on-surface);
+    font-size: 0.875rem;
+    margin: 0;
+    padding: 0 1rem;
+    background-color: transparent;
+    border: 0;
+    height: 2.875rem;
+    font-weight: 600;
+    font-family: inherit;
+    letter-spacing: 0.4px;
+
+    &[data-state="active"] {
+      border-bottom: 2px solid var(--color-primary);
+    }
+  }
+
+  .ttd-dialog-triggers {
+    border-bottom: 1px solid var(--color-surface-high);
+    margin-bottom: 1.5rem;
+    padding-inline: 2.5rem;
+  }
+
+  .ttd-dialog-content {
+    padding-inline: 2.5rem;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+
+    &[hidden] {
+      display: none;
+    }
+  }
+
+  .ttd-dialog-input {
+    width: auto;
+    height: 10rem;
+    resize: none;
+    border-radius: var(--border-radius-lg);
+    border: 1px solid var(--dialog-border-color);
+    white-space: pre-wrap;
+    padding: 0.85rem;
+    box-sizing: border-box;
+    font-family: monospace;
+
+    @media screen and (min-width: $verticalBreakpoint) {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  .ttd-dialog-output-wrapper {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0.85rem;
+    box-sizing: border-box;
+    flex-grow: 1;
+    position: relative;
+
+    background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
+      left center;
+    border-radius: var(--border-radius-lg);
+    border: 1px solid var(--dialog-border-color);
+
+    height: 400px;
+    width: auto;
+
+    @media screen and (min-width: $verticalBreakpoint) {
+      width: 100%;
+      // acts as min-height
+      height: 200px;
+    }
+
+    canvas {
+      max-width: 100%;
+      max-height: 100%;
+    }
+  }
+
+  .ttd-dialog-output-canvas-container {
+    display: flex;
+    width: 100%;
+    height: 100%;
+    align-items: center;
+    justify-content: center;
+    flex-grow: 1;
+  }
+
+  .ttd-dialog-output-error {
+    color: red;
+    font-weight: 800;
+    font-size: 30px;
+    word-break: break-word;
+    overflow: auto;
+    max-height: 100%;
+    height: 100%;
+    width: 100%;
+    text-align: center;
+    position: absolute;
+    z-index: 10;
+
+    p {
+      font-weight: 500;
+      font-family: Cascadia;
+      text-align: left;
+      white-space: pre-wrap;
+      font-size: 0.875rem;
+      padding: 0 10px;
+    }
+  }
+
+  .ttd-dialog-panels {
+    height: 100%;
+
+    @media screen and (min-width: $verticalBreakpoint) {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 4rem;
+    }
+  }
+
+  .ttd-dialog-panel {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+
+    &__header {
+      display: flex;
+      margin: 0px 4px 4px 4px;
+      align-items: center;
+      gap: 1rem;
+
+      label {
+        font-size: 14px;
+        font-style: normal;
+        font-weight: 600;
+      }
+    }
+
+    &:first-child {
+      .ttd-dialog-panel-button-container:not(.invisible) {
+        margin-bottom: 4rem;
+      }
+    }
+
+    @media screen and (min-width: $verticalBreakpoint) {
+      .ttd-dialog-panel-button-container:not(.invisible) {
+        margin-bottom: 0.5rem !important;
+      }
+    }
+
+    textarea {
+      height: 100%;
+      resize: none;
+      border-radius: var(--border-radius-lg);
+      border: 1px solid var(--dialog-border-color);
+      white-space: pre-wrap;
+      padding: 0.85rem;
+      box-sizing: border-box;
+      width: 100%;
+      font-family: monospace;
+
+      @media screen and (max-width: $verticalBreakpoint) {
+        width: auto;
+        height: 10rem;
+      }
+    }
+  }
+
+  .ttd-dialog-panel-button-container {
+    margin-top: 1rem;
+    margin-bottom: 0.5rem;
+
+    &.invisible {
+      .ttd-dialog-panel-button {
+        display: none;
+
+        @media screen and (min-width: $verticalBreakpoint) {
+          display: block;
+          visibility: hidden;
+        }
+      }
+    }
+  }
+
+  .ttd-dialog-panel-button {
+    &.excalidraw-button {
+      font-family: inherit;
+      font-weight: 600;
+      height: 2.5rem;
+
+      font-size: 12px;
+      color: $oc-white;
+      background-color: var(--color-primary);
+      width: 100%;
+
+      &:hover {
+        background-color: var(--color-primary-darker);
+      }
+      &:active {
+        background-color: var(--color-primary-darkest);
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+
+        &:hover {
+          background-color: var(--color-primary);
+        }
+      }
+
+      @media screen and (min-width: $verticalBreakpoint) {
+        width: auto;
+        min-width: 7.5rem;
+      }
+
+      @at-root .excalidraw.theme--dark#{&} {
+        color: var(--color-gray-100);
+      }
+    }
+
+    position: relative;
+
+    div {
+      display: contents;
+
+      &.invisible {
+        visibility: hidden;
+      }
+
+      &.Spinner {
+        display: flex !important;
+        position: absolute;
+        inset: 0;
+
+        --spinner-color: white;
+
+        @at-root .excalidraw.theme--dark#{&} {
+          --spinner-color: var(--color-gray-100);
+        }
+      }
+
+      span {
+        padding-left: 0.5rem;
+        display: flex;
+      }
+    }
+  }
+}

+ 325 - 0
src/components/TTDDialog/TTDDialog.tsx

@@ -0,0 +1,325 @@
+import { Dialog } from "../Dialog";
+import { useApp } from "../App";
+import MermaidToExcalidraw from "./MermaidToExcalidraw";
+import TTDDialogTabs from "./TTDDialogTabs";
+import { ChangeEventHandler, useEffect, useRef, useState } from "react";
+import { useUIAppState } from "../../context/ui-appState";
+import { withInternalFallback } from "../hoc/withInternalFallback";
+import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
+import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
+import { TTDDialogTab } from "./TTDDialogTab";
+import { t } from "../../i18n";
+import { TTDDialogInput } from "./TTDDialogInput";
+import { TTDDialogOutput } from "./TTDDialogOutput";
+import { TTDDialogPanel } from "./TTDDialogPanel";
+import { TTDDialogPanels } from "./TTDDialogPanels";
+import {
+  MermaidToExcalidrawLibProps,
+  convertMermaidToExcalidraw,
+  insertToEditor,
+  saveMermaidDataToStorage,
+} from "./common";
+import { NonDeletedExcalidrawElement } from "../../element/types";
+import { BinaryFiles } from "../../types";
+import { ArrowRightIcon } from "../icons";
+
+import "./TTDDialog.scss";
+import { isFiniteNumber } from "../../utils";
+import { atom, useAtom } from "jotai";
+import { trackEvent } from "../../analytics";
+
+const MIN_PROMPT_LENGTH = 3;
+const MAX_PROMPT_LENGTH = 1000;
+
+const rateLimitsAtom = atom<{
+  rateLimit: number;
+  rateLimitRemaining: number;
+} | null>(null);
+
+type OnTestSubmitRetValue = {
+  rateLimit?: number | null;
+  rateLimitRemaining?: number | null;
+} & (
+  | { generatedResponse: string | undefined; error?: null | undefined }
+  | {
+      error: Error;
+      generatedResponse?: null | undefined;
+    }
+);
+
+export const TTDDialog = (
+  props:
+    | {
+        onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
+      }
+    | { __fallback: true },
+) => {
+  const appState = useUIAppState();
+
+  if (appState.openDialog?.name !== "ttd") {
+    return null;
+  }
+
+  return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
+};
+
+/**
+ * Text to diagram (TTD) dialog
+ */
+export const TTDDialogBase = withInternalFallback(
+  "TTDDialogBase",
+  ({
+    tab,
+    ...rest
+  }: {
+    tab: string;
+  } & (
+    | {
+        onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
+      }
+    | { __fallback: true }
+  )) => {
+    const app = useApp();
+
+    const someRandomDivRef = useRef<HTMLDivElement>(null);
+
+    const [text, setText] = useState("");
+
+    const prompt = text.trim();
+
+    const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
+      event,
+    ) => {
+      setText(event.target.value);
+    };
+
+    const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
+    const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
+
+    const onGenerate = async () => {
+      if (
+        prompt.length > MAX_PROMPT_LENGTH ||
+        prompt.length < MIN_PROMPT_LENGTH ||
+        onTextSubmitInProgess ||
+        rateLimits?.rateLimitRemaining === 0 ||
+        // means this is not a text-to-diagram dialog (needed for TS only)
+        "__fallback" in rest
+      ) {
+        if (prompt.length < MIN_PROMPT_LENGTH) {
+          setError(
+            new Error(
+              `Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
+            ),
+          );
+        }
+        if (prompt.length > MAX_PROMPT_LENGTH) {
+          setError(
+            new Error(
+              `Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
+            ),
+          );
+        }
+
+        return;
+      }
+
+      try {
+        setOnTextSubmitInProgess(true);
+
+        trackEvent("ai", "generate", "ttd");
+
+        const { generatedResponse, error, rateLimit, rateLimitRemaining } =
+          await rest.onTextSubmit(prompt);
+
+        if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
+          setRateLimits({ rateLimit, rateLimitRemaining });
+        }
+
+        if (error) {
+          setError(error);
+          return;
+        }
+        if (!generatedResponse) {
+          setError(new Error("Generation failed"));
+          return;
+        }
+
+        try {
+          await convertMermaidToExcalidraw({
+            canvasRef: someRandomDivRef,
+            data,
+            mermaidToExcalidrawLib,
+            setError,
+            text: generatedResponse,
+          });
+          trackEvent("ai", "mermaid parse success", "ttd");
+          saveMermaidDataToStorage(generatedResponse);
+        } catch (error: any) {
+          trackEvent("ai", "mermaid parse failed", "ttd");
+          setError(
+            new Error(
+              "Generated an invalid diagram :(. You may also try a different prompt.",
+            ),
+          );
+        }
+      } catch (error: any) {
+        let message: string | undefined = error.message;
+        if (!message || message === "Failed to fetch") {
+          message = "Request failed";
+        }
+        setError(new Error(message));
+      } finally {
+        setOnTextSubmitInProgess(false);
+      }
+    };
+
+    const refOnGenerate = useRef(onGenerate);
+    refOnGenerate.current = onGenerate;
+
+    const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
+      useState<MermaidToExcalidrawLibProps>({
+        loaded: false,
+        api: import(
+          /* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
+        ),
+      });
+
+    useEffect(() => {
+      const fn = async () => {
+        await mermaidToExcalidrawLib.api;
+        setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
+      };
+      fn();
+    }, [mermaidToExcalidrawLib.api]);
+
+    const data = useRef<{
+      elements: readonly NonDeletedExcalidrawElement[];
+      files: BinaryFiles | null;
+    }>({ elements: [], files: null });
+
+    const [error, setError] = useState<Error | null>(null);
+
+    return (
+      <Dialog
+        className="ttd-dialog"
+        onCloseRequest={() => {
+          app.setOpenDialog(null);
+        }}
+        size={1200}
+        title=""
+        {...rest}
+        autofocus={false}
+      >
+        <TTDDialogTabs tab={tab}>
+          {"__fallback" in rest && rest.__fallback ? (
+            <p className="dialog-mermaid-title">{t("mermaid.title")}</p>
+          ) : (
+            <TTDDialogTabTriggers>
+              <TTDDialogTabTrigger tab="text-to-diagram">
+                {t("labels.textToDiagram")}
+              </TTDDialogTabTrigger>
+              <TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
+            </TTDDialogTabTriggers>
+          )}
+
+          <TTDDialogTab className="ttd-dialog-content" tab="mermaid">
+            <MermaidToExcalidraw
+              mermaidToExcalidrawLib={mermaidToExcalidrawLib}
+            />
+          </TTDDialogTab>
+          {!("__fallback" in rest) && (
+            <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
+              <div className="ttd-dialog-desc">
+                Currently we use Mermaid as a middle step, so you'll get best
+                results if you describe a diagram, workflow, flow chart, and
+                similar.
+              </div>
+              <TTDDialogPanels>
+                <TTDDialogPanel
+                  label={t("labels.prompt")}
+                  panelAction={{
+                    action: onGenerate,
+                    label: "Generate",
+                    icon: ArrowRightIcon,
+                  }}
+                  onTextSubmitInProgess={onTextSubmitInProgess}
+                  panelActionDisabled={
+                    prompt.length > MAX_PROMPT_LENGTH ||
+                    rateLimits?.rateLimitRemaining === 0
+                  }
+                  renderTopRight={() => {
+                    if (!rateLimits) {
+                      return null;
+                    }
+
+                    return (
+                      <div
+                        className="ttd-dialog-rate-limit"
+                        style={{
+                          fontSize: 12,
+                          marginLeft: "auto",
+                          color:
+                            rateLimits.rateLimitRemaining === 0
+                              ? "var(--color-danger)"
+                              : undefined,
+                        }}
+                      >
+                        {rateLimits.rateLimitRemaining} requests left today
+                      </div>
+                    );
+                  }}
+                  renderBottomRight={() => {
+                    const ratio = prompt.length / MAX_PROMPT_LENGTH;
+                    if (ratio > 0.8) {
+                      return (
+                        <div
+                          style={{
+                            marginLeft: "auto",
+                            fontSize: 12,
+                            fontFamily: "monospace",
+                            color:
+                              ratio > 1 ? "var(--color-danger)" : undefined,
+                          }}
+                        >
+                          Length: {prompt.length}/{MAX_PROMPT_LENGTH}
+                        </div>
+                      );
+                    }
+
+                    return null;
+                  }}
+                >
+                  <TTDDialogInput
+                    onChange={handleTextChange}
+                    input={text}
+                    placeholder={"Describe what you want to see..."}
+                    onKeyboardSubmit={() => {
+                      refOnGenerate.current();
+                    }}
+                  />
+                </TTDDialogPanel>
+                <TTDDialogPanel
+                  label="Preview"
+                  panelAction={{
+                    action: () => {
+                      console.info("Panel action clicked");
+                      insertToEditor({ app, data });
+                    },
+                    label: "Insert",
+                    icon: ArrowRightIcon,
+                  }}
+                >
+                  <TTDDialogOutput
+                    canvasRef={someRandomDivRef}
+                    error={error}
+                    loaded={mermaidToExcalidrawLib.loaded}
+                  />
+                </TTDDialogPanel>
+              </TTDDialogPanels>
+            </TTDDialogTab>
+          )}
+        </TTDDialogTabs>
+      </Dialog>
+    );
+  },
+);

+ 52 - 0
src/components/TTDDialog/TTDDialogInput.tsx

@@ -0,0 +1,52 @@
+import { ChangeEventHandler, useEffect, useRef } from "react";
+import { EVENT } from "../../constants";
+import { KEYS } from "../../keys";
+
+interface TTDDialogInputProps {
+  input: string;
+  placeholder: string;
+  onChange: ChangeEventHandler<HTMLTextAreaElement>;
+  onKeyboardSubmit?: () => void;
+}
+
+export const TTDDialogInput = ({
+  input,
+  placeholder,
+  onChange,
+  onKeyboardSubmit,
+}: TTDDialogInputProps) => {
+  const ref = useRef<HTMLTextAreaElement>(null);
+
+  const callbackRef = useRef(onKeyboardSubmit);
+  callbackRef.current = onKeyboardSubmit;
+
+  useEffect(() => {
+    if (!callbackRef.current) {
+      return;
+    }
+    const textarea = ref.current;
+    if (textarea) {
+      const handleKeyDown = (event: KeyboardEvent) => {
+        if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
+          event.preventDefault();
+          callbackRef.current?.();
+        }
+      };
+      textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
+      return () => {
+        textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
+      };
+    }
+  }, []);
+
+  return (
+    <textarea
+      className="ttd-dialog-input"
+      onChange={onChange}
+      value={input}
+      placeholder={placeholder}
+      autoFocus
+      ref={ref}
+    />
+  );
+};

+ 39 - 0
src/components/TTDDialog/TTDDialogOutput.tsx

@@ -0,0 +1,39 @@
+import Spinner from "../Spinner";
+
+const ErrorComp = ({ error }: { error: string }) => {
+  return (
+    <div
+      data-testid="ttd-dialog-output-error"
+      className="ttd-dialog-output-error"
+    >
+      Error! <p>{error}</p>
+    </div>
+  );
+};
+
+interface TTDDialogOutputProps {
+  error: Error | null;
+  canvasRef: React.RefObject<HTMLDivElement>;
+  loaded: boolean;
+}
+
+export const TTDDialogOutput = ({
+  error,
+  canvasRef,
+  loaded,
+}: TTDDialogOutputProps) => {
+  return (
+    <div className="ttd-dialog-output-wrapper">
+      {error && <ErrorComp error={error.message} />}
+      {loaded ? (
+        <div
+          ref={canvasRef}
+          style={{ opacity: error ? "0.15" : 1 }}
+          className="ttd-dialog-output-canvas-container"
+        />
+      ) : (
+        <Spinner size="2rem" />
+      )}
+    </div>
+  );
+};

+ 58 - 0
src/components/TTDDialog/TTDDialogPanel.tsx

@@ -0,0 +1,58 @@
+import { ReactNode } from "react";
+import { Button } from "../Button";
+import clsx from "clsx";
+import Spinner from "../Spinner";
+
+interface TTDDialogPanelProps {
+  label: string;
+  children: ReactNode;
+  panelAction?: {
+    label: string;
+    action: () => void;
+    icon?: ReactNode;
+  };
+  panelActionDisabled?: boolean;
+  onTextSubmitInProgess?: boolean;
+  renderTopRight?: () => ReactNode;
+  renderBottomRight?: () => ReactNode;
+}
+
+export const TTDDialogPanel = ({
+  label,
+  children,
+  panelAction,
+  panelActionDisabled = false,
+  onTextSubmitInProgess,
+  renderTopRight,
+  renderBottomRight,
+}: TTDDialogPanelProps) => {
+  return (
+    <div className="ttd-dialog-panel">
+      <div className="ttd-dialog-panel__header">
+        <label>{label}</label>
+        {renderTopRight?.()}
+      </div>
+
+      {children}
+      <div
+        className={clsx("ttd-dialog-panel-button-container", {
+          invisible: !panelAction,
+        })}
+        style={{ display: "flex", alignItems: "center" }}
+      >
+        <Button
+          className="ttd-dialog-panel-button"
+          onSelect={panelAction ? panelAction.action : () => {}}
+          disabled={panelActionDisabled || onTextSubmitInProgess}
+        >
+          <div className={clsx({ invisible: onTextSubmitInProgess })}>
+            {panelAction?.label}
+            {panelAction?.icon && <span>{panelAction.icon}</span>}
+          </div>
+          {onTextSubmitInProgess && <Spinner />}
+        </Button>
+        {renderBottomRight?.()}
+      </div>
+    </div>
+  );
+};

+ 5 - 0
src/components/TTDDialog/TTDDialogPanels.tsx

@@ -0,0 +1,5 @@
+import { ReactNode } from "react";
+
+export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
+  return <div className="ttd-dialog-panels">{children}</div>;
+};

+ 17 - 0
src/components/TTDDialog/TTDDialogTab.tsx

@@ -0,0 +1,17 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+
+export const TTDDialogTab = ({
+  tab,
+  children,
+  ...rest
+}: {
+  tab: string;
+  children: React.ReactNode;
+} & React.HTMLAttributes<HTMLDivElement>) => {
+  return (
+    <RadixTabs.Content {...rest} value={tab}>
+      {children}
+    </RadixTabs.Content>
+  );
+};
+TTDDialogTab.displayName = "TTDDialogTab";

+ 21 - 0
src/components/TTDDialog/TTDDialogTabTrigger.tsx

@@ -0,0 +1,21 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+
+export const TTDDialogTabTrigger = ({
+  children,
+  tab,
+  onSelect,
+  ...rest
+}: {
+  children: React.ReactNode;
+  tab: string;
+  onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
+} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
+  return (
+    <RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
+      <button type="button" className="ttd-dialog-tab-trigger" {...rest}>
+        {children}
+      </button>
+    </RadixTabs.Trigger>
+  );
+};
+TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";

+ 13 - 0
src/components/TTDDialog/TTDDialogTabTriggers.tsx

@@ -0,0 +1,13 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+
+export const TTDDialogTabTriggers = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
+  return (
+    <RadixTabs.List className="ttd-dialog-triggers" {...rest}>
+      {children}
+    </RadixTabs.List>
+  );
+};
+TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";

+ 38 - 0
src/components/TTDDialog/TTDDialogTabs.tsx

@@ -0,0 +1,38 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { ReactNode } from "react";
+import { useExcalidrawSetAppState } from "../App";
+
+const TTDDialogTabs = ({
+  children,
+  tab,
+  ...rest
+}: {
+  children: ReactNode;
+  tab: string;
+}) => {
+  const setAppState = useExcalidrawSetAppState();
+
+  return (
+    <RadixTabs.Root
+      className="ttd-dialog-tabs-root"
+      value={tab}
+      onValueChange={(
+        // at least in test enviros, `tab` can be `undefined`
+        tab: string | undefined,
+      ) => {
+        if (tab) {
+          setAppState({
+            openDialog: { name: "ttd", tab },
+          });
+        }
+      }}
+      {...rest}
+    >
+      {children}
+    </RadixTabs.Root>
+  );
+};
+
+TTDDialogTabs.displayName = "TTDDialogTabs";
+
+export default TTDDialogTabs;

+ 34 - 0
src/components/TTDDialog/TTDDialogTrigger.tsx

@@ -0,0 +1,34 @@
+import { ReactNode } from "react";
+import { useTunnels } from "../../context/tunnels";
+import DropdownMenu from "../dropdownMenu/DropdownMenu";
+import { useExcalidrawSetAppState } from "../App";
+import { brainIcon } from "../icons";
+import { t } from "../../i18n";
+import { trackEvent } from "../../analytics";
+
+export const TTDDialogTrigger = ({
+  children,
+  icon,
+}: {
+  children?: ReactNode;
+  icon?: JSX.Element;
+}) => {
+  const { TTDDialogTriggerTunnel } = useTunnels();
+  const setAppState = useExcalidrawSetAppState();
+
+  return (
+    <TTDDialogTriggerTunnel.In>
+      <DropdownMenu.Item
+        onSelect={() => {
+          trackEvent("ai", "dialog open", "ttd");
+          setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
+        }}
+        icon={icon ?? brainIcon}
+      >
+        {children ?? t("labels.textToDiagram")}
+        <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
+      </DropdownMenu.Item>
+    </TTDDialogTriggerTunnel.In>
+  );
+};
+TTDDialogTrigger.displayName = "TTDDialogTrigger";

+ 153 - 0
src/components/TTDDialog/common.ts

@@ -0,0 +1,153 @@
+import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
+import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
+import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../../constants";
+import {
+  convertToExcalidrawElements,
+  exportToCanvas,
+} from "../../packages/excalidraw/index";
+import { NonDeletedExcalidrawElement } from "../../element/types";
+import { AppClassProperties, BinaryFiles } from "../../types";
+import { canvasToBlob } from "../../data/blob";
+
+const resetPreview = ({
+  canvasRef,
+  setError,
+}: {
+  canvasRef: React.RefObject<HTMLDivElement>;
+  setError: (error: Error | null) => void;
+}) => {
+  const canvasNode = canvasRef.current;
+
+  if (!canvasNode) {
+    return;
+  }
+  const parent = canvasNode.parentElement;
+  if (!parent) {
+    return;
+  }
+  parent.style.background = "";
+  setError(null);
+  canvasNode.replaceChildren();
+};
+
+export interface MermaidToExcalidrawLibProps {
+  loaded: boolean;
+  api: Promise<{
+    parseMermaidToExcalidraw: (
+      definition: string,
+      options: MermaidOptions,
+    ) => Promise<MermaidToExcalidrawResult>;
+  }>;
+}
+
+interface ConvertMermaidToExcalidrawFormatProps {
+  canvasRef: React.RefObject<HTMLDivElement>;
+  mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
+  text: string;
+  setError: (error: Error | null) => void;
+  data: React.MutableRefObject<{
+    elements: readonly NonDeletedExcalidrawElement[];
+    files: BinaryFiles | null;
+  }>;
+}
+
+export const convertMermaidToExcalidraw = async ({
+  canvasRef,
+  mermaidToExcalidrawLib,
+  text,
+  setError,
+  data,
+}: ConvertMermaidToExcalidrawFormatProps) => {
+  const canvasNode = canvasRef.current;
+  const parent = canvasNode?.parentElement;
+
+  if (!canvasNode || !parent) {
+    return;
+  }
+
+  if (!text) {
+    resetPreview({ canvasRef, setError });
+    return;
+  }
+
+  try {
+    const api = await mermaidToExcalidrawLib.api;
+
+    const { elements, files } = await api.parseMermaidToExcalidraw(text, {
+      fontSize: DEFAULT_FONT_SIZE,
+    });
+    setError(null);
+
+    data.current = {
+      elements: convertToExcalidrawElements(elements, {
+        regenerateIds: true,
+      }),
+      files,
+    };
+
+    const canvas = await exportToCanvas({
+      elements: data.current.elements,
+      files: data.current.files,
+      exportPadding: DEFAULT_EXPORT_PADDING,
+      maxWidthOrHeight:
+        Math.max(parent.offsetWidth, parent.offsetHeight) *
+        window.devicePixelRatio,
+    });
+    // if converting to blob fails, there's some problem that will
+    // likely prevent preview and export (e.g. canvas too big)
+    await canvasToBlob(canvas);
+    parent.style.background = "var(--default-bg-color)";
+    canvasNode.replaceChildren(canvas);
+  } catch (err: any) {
+    console.error(err);
+    parent.style.background = "var(--default-bg-color)";
+    if (text) {
+      setError(err);
+    }
+
+    throw err;
+  }
+};
+
+export const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
+export const saveMermaidDataToStorage = (data: string) => {
+  try {
+    localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
+  } catch (error: any) {
+    // Unable to access window.localStorage
+    console.error(error);
+  }
+};
+
+export const insertToEditor = ({
+  app,
+  data,
+  text,
+  shouldSaveMermaidDataToStorage,
+}: {
+  app: AppClassProperties;
+  data: React.MutableRefObject<{
+    elements: readonly NonDeletedExcalidrawElement[];
+    files: BinaryFiles | null;
+  }>;
+  text?: string;
+  shouldSaveMermaidDataToStorage?: boolean;
+}) => {
+  const { elements: newElements, files } = data.current;
+
+  if (!newElements.length) {
+    return;
+  }
+
+  app.addElementsFromPasteOrLibrary({
+    elements: newElements,
+    files,
+    position: "center",
+    fitToContent: true,
+  });
+  app.setOpenDialog(null);
+
+  if (shouldSaveMermaidDataToStorage && text) {
+    saveMermaidDataToStorage(text);
+  }
+};

+ 4 - 0
src/components/dropdownMenu/DropdownMenu.scss

@@ -63,9 +63,13 @@
       }
 
       &__text {
+        display: flex;
+        align-items: center;
+        width: 100%;
         text-overflow: ellipsis;
         overflow: hidden;
         white-space: nowrap;
+        gap: 0.75rem;
       }
 
       &__shortcut {

+ 27 - 1
src/components/dropdownMenu/DropdownMenuItem.tsx

@@ -37,6 +37,32 @@ const DropdownMenuItem = ({
     </button>
   );
 };
+DropdownMenuItem.displayName = "DropdownMenuItem";
+
+export const DropDownMenuItemBadge = ({
+  children,
+}: {
+  children: React.ReactNode;
+}) => {
+  return (
+    <div
+      style={{
+        display: "inline-flex",
+        marginLeft: "auto",
+        padding: "1px 4px",
+        background: "pink",
+        borderRadius: 6,
+        fontSize: 11,
+        color: "black",
+        fontFamily: "monospace",
+      }}
+    >
+      {children}
+    </div>
+  );
+};
+DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
+
+DropdownMenuItem.Badge = DropDownMenuItemBadge;
 
 export default DropdownMenuItem;
-DropdownMenuItem.displayName = "DropdownMenuItem";

+ 13 - 0
src/components/icons.tsx

@@ -1742,3 +1742,16 @@ export const eyeClosedIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const brainIcon = createIcon(
+  <g stroke="currentColor" fill="none">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
+    <path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
+    <path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
+    <path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
+    <path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
+    <path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
+  </g>,
+  tablerIconProps,
+);

+ 2 - 0
src/context/tunnels.ts

@@ -13,6 +13,7 @@ type TunnelsContextValue = {
   DefaultSidebarTriggerTunnel: Tunnel;
   DefaultSidebarTabTriggersTunnel: Tunnel;
   OverwriteConfirmDialogTunnel: Tunnel;
+  TTDDialogTriggerTunnel: Tunnel;
   jotaiScope: symbol;
 };
 
@@ -32,6 +33,7 @@ export const useInitializeTunnels = () => {
       DefaultSidebarTriggerTunnel: tunnel(),
       DefaultSidebarTabTriggersTunnel: tunnel(),
       OverwriteConfirmDialogTunnel: tunnel(),
+      TTDDialogTriggerTunnel: tunnel(),
       jotaiScope: Symbol(),
     };
   }, []);

+ 1 - 0
src/css/styles.scss

@@ -39,6 +39,7 @@
 
   button {
     cursor: pointer;
+    user-select: none;
   }
 
   &:focus {

+ 3 - 1
src/locales/en.json

@@ -132,7 +132,9 @@
     "sidebarLock": "Keep sidebar open",
     "selectAllElementsInFrame": "Select all elements in frame",
     "removeAllElementsFromFrame": "Remove all elements from frame",
-    "eyeDropper": "Pick color from canvas"
+    "eyeDropper": "Pick color from canvas",
+    "textToDiagram": "Text to diagram",
+    "prompt": "Prompt"
   },
   "library": {
     "noItems": "No items added yet...",

+ 17 - 1
src/packages/excalidraw/example/App.tsx

@@ -76,6 +76,8 @@ const {
   MainMenu,
   LiveCollaborationTrigger,
   convertToExcalidrawElements,
+  TTDDialog,
+  TTDDialogTrigger,
 } = window.ExcalidrawLib;
 
 const COMMENT_ICON_DIMENSION = 32;
@@ -681,7 +683,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
             }
             initialData={initialStatePromiseRef.current.promise}
             onChange={(elements, state) => {
-              console.info("Elements :", elements, "State : ", state);
+              // console.info("Elements :", elements, "State : ", state);
             }}
             onPointerUpdate={(payload: {
               pointer: { x: number; y: number };
@@ -737,6 +739,20 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
               Toggle Custom Sidebar
             </Sidebar.Trigger>
             {renderMenu()}
+            {excalidrawAPI && (
+              <TTDDialogTrigger icon={<span>😀</span>}>
+                Text to diagram
+              </TTDDialogTrigger>
+            )}
+            <TTDDialog
+              onTextSubmit={async (_) => {
+                console.info("submit");
+                // sleep for 2s
+                await new Promise((resolve) => setTimeout(resolve, 2000));
+                throw new Error("error, go away now");
+                // return "dummy";
+              }}
+            />
           </Excalidraw>
           {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
           {comment && renderComment()}

+ 2 - 0
src/packages/excalidraw/index.tsx

@@ -246,6 +246,8 @@ export { WelcomeScreen };
 export { LiveCollaborationTrigger };
 
 export { DefaultSidebar } from "../../components/DefaultSidebar";
+export { TTDDialog } from "../../components/TTDDialog/TTDDialog";
+export { TTDDialogTrigger } from "../../components/TTDDialog/TTDDialogTrigger";
 
 export { normalizeLink } from "../../data/url";
 export { convertToExcalidrawElements } from "../../data/transform";

+ 13 - 19
src/tests/MermaidToExcalidraw.test.tsx

@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
       <Excalidraw
         initialData={{
           appState: {
-            openDialog: { name: "mermaid" },
+            openDialog: { name: "ttd", tab: "mermaid" },
           },
         }}
       />,
@@ -110,16 +110,16 @@ describe("Test <MermaidToExcalidraw/>", () => {
   });
 
   it("should open mermaid popup when active tool is mermaid", async () => {
-    const dialog = document.querySelector(".dialog-mermaid")!;
+    const dialog = document.querySelector(".ttd-dialog")!;
     await waitFor(() => dialog.querySelector("canvas"));
     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 dialog = document.querySelector(".ttd-dialog")!;
     const closeBtn = dialog.querySelector(".Dialog__close")!;
     fireEvent.click(closeBtn);
-    expect(document.querySelector(".dialog-mermaid")).toBe(null);
+    expect(document.querySelector(".ttd-dialog")).toBe(null);
     expect(window.h.state.activeTool).toStrictEqual({
       customType: null,
       lastActiveTool: null,
@@ -129,9 +129,12 @@ describe("Test <MermaidToExcalidraw/>", () => {
   });
 
   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);
+    const dialog = document.querySelector(".ttd-dialog")!;
+
+    expect(dialog).not.toBeNull();
+
+    const selector = ".ttd-dialog-input";
+    let editor = await getTextEditor(selector, true);
 
     expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
 
@@ -151,17 +154,8 @@ describe("Test <MermaidToExcalidraw/>", () => {
     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>
-      `);
+    expect(
+      dialog.querySelector('[data-testid="mermaid-error"]'),
+    ).toMatchInlineSnapshot("null");
   });
 });

+ 2 - 2
src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap

@@ -1,10 +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
+"<div class=\\"Modal Dialog ttd-dialog\\" 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\\"><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 dir=\\"ltr\\" data-orientation=\\"horizontal\\" class=\\"ttd-dialog-tabs-root\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><div data-state=\\"active\\" data-orientation=\\"horizontal\\" role=\\"tabpanel\\" aria-labelledby=\\"radix-:r0:-trigger-mermaid\\" id=\\"radix-:r0:-content-mermaid\\" tabindex=\\"0\\" class=\\"ttd-dialog-content\\" style=\\"animation-duration: 0s;\\"><div class=\\"ttd-dialog-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.</div><div class=\\"ttd-dialog-panels\\"><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Mermaid Syntax</label></div><textarea class=\\"ttd-dialog-input\\" placeholder=\\"Write Mermaid diagram defintion here...\\">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\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></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>"
+ C --&gt;|Three| F[Car]</textarea><div class=\\"ttd-dialog-panel-button-container invisible\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\"></div></button></div></div><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Preview</label></div><div class=\\"ttd-dialog-output-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"ttd-dialog-output-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div><div class=\\"ttd-dialog-panel-button-container\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\">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></div></button></div></div></div></div></div></div></div></div></div>"
 `;

+ 4 - 4
src/tests/linearElementEditor.test.tsx

@@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
 
       // drag line from midpoint
       drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
-      expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
+      expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
       expect(renderStaticScene).toHaveBeenCalledTimes(6);
 
       expect(line.points.length).toEqual(3);
@@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
           lastSegmentMidpoint[1] + delta,
         ]);
 
-        expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
         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(20);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
         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(19);
+        expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
         expect(renderStaticScene).toHaveBeenCalledTimes(9);
         expect(line.points.length).toEqual(5);
 

+ 3 - 2
src/types.ts

@@ -246,14 +246,15 @@ export interface AppState {
   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
   openDialog:
     | null
-    | { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
+    | { name: "imageExport" | "help" | "jsonExport" }
     | {
         name: "magicSettings";
         source:
           | "tool" // when magicframe tool is selected
           | "generation" // when magicframe generate button is clicked
           | "settings"; // when AI settings dialog is explicitly invoked
-      };
+      }
+    | { name: "ttd"; tab: string };
   /**
    * Reflects user preference for whether the default sidebar should be docked.
    *

+ 4 - 0
src/utils.ts

@@ -925,3 +925,7 @@ export const isMemberOf = <T extends string>(
 };
 
 export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
+
+export const isFiniteNumber = (value: any): value is number => {
+  return typeof value === "number" && Number.isFinite(value);
+};

+ 1 - 0
src/vite-env.d.ts

@@ -17,6 +17,7 @@ interface ImportMetaEnv {
 
   // set this only if using the collaboration workflow we use on excalidraw.com
   VITE_APP_PORTAL_URL: string;
+  VITE_APP_AI_BACKEND: string;
 
   VITE_APP_FIREBASE_CONFIG: string;