Browse Source

Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

Daniel J. Geiger 1 năm trước cách đây
mục cha
commit
1dfadb4d26
100 tập tin đã thay đổi với 3654 bổ sung919 xóa
  1. 2 0
      .env.development
  2. 2 0
      .env.production
  3. 60 0
      excalidraw-app/index.tsx
  4. 2 1
      src/actions/actionAlign.tsx
  5. 2 2
      src/actions/actionDeleteSelected.tsx
  6. 2 1
      src/actions/actionDistribute.tsx
  7. 5 5
      src/actions/actionDuplicateSelection.tsx
  8. 2 1
      src/actions/actionElementLock.ts
  9. 17 8
      src/actions/actionFrame.ts
  10. 4 4
      src/actions/actionGroup.tsx
  11. 7 2
      src/actions/actionMenu.tsx
  12. 2 2
      src/actions/actionStyles.ts
  13. 15 7
      src/analytics.ts
  14. 5 2
      src/clipboard.ts
  15. 41 4
      src/components/Actions.tsx
  16. 631 62
      src/components/App.tsx
  17. 5 1
      src/components/Button.tsx
  18. 15 0
      src/components/InlineIcon.tsx
  19. 1 1
      src/components/JSONExportDialog.tsx
  20. 46 5
      src/components/LayerUI.tsx
  21. 38 0
      src/components/MagicButton.tsx
  22. 18 0
      src/components/MagicSettings.scss
  23. 160 0
      src/components/MagicSettings.tsx
  24. 0 221
      src/components/MermaidToExcalidraw.scss
  25. 0 243
      src/components/MermaidToExcalidraw.tsx
  26. 4 1
      src/components/Modal.scss
  27. 1 1
      src/components/OverwriteConfirm/OverwriteConfirmActions.tsx
  28. 10 0
      src/components/Paragraph.tsx
  29. 1 1
      src/components/PasteChartDialog.tsx
  30. 7 31
      src/components/PublishLibrary.tsx
  31. 2 1
      src/components/Subtypes.tsx
  32. 10 0
      src/components/TTDDialog/MermaidToExcalidraw.scss
  33. 133 0
      src/components/TTDDialog/MermaidToExcalidraw.tsx
  34. 301 0
      src/components/TTDDialog/TTDDialog.scss
  35. 350 0
      src/components/TTDDialog/TTDDialog.tsx
  36. 52 0
      src/components/TTDDialog/TTDDialogInput.tsx
  37. 39 0
      src/components/TTDDialog/TTDDialogOutput.tsx
  38. 58 0
      src/components/TTDDialog/TTDDialogPanel.tsx
  39. 5 0
      src/components/TTDDialog/TTDDialogPanels.tsx
  40. 17 0
      src/components/TTDDialog/TTDDialogTab.tsx
  41. 21 0
      src/components/TTDDialog/TTDDialogTabTrigger.tsx
  42. 13 0
      src/components/TTDDialog/TTDDialogTabTriggers.tsx
  43. 64 0
      src/components/TTDDialog/TTDDialogTabs.tsx
  44. 34 0
      src/components/TTDDialog/TTDDialogTrigger.tsx
  45. 164 0
      src/components/TTDDialog/common.ts
  46. 22 2
      src/components/TextField.tsx
  47. 2 1
      src/components/ToolIcon.scss
  48. 0 2
      src/components/canvases/InteractiveCanvas.tsx
  49. 4 0
      src/components/dropdownMenu/DropdownMenu.scss
  50. 27 1
      src/components/dropdownMenu/DropdownMenuItem.tsx
  51. 67 0
      src/components/icons.tsx
  52. 2 2
      src/components/main-menu/DefaultItems.tsx
  53. 31 1
      src/constants.ts
  54. 2 0
      src/context/tunnels.ts
  55. 22 0
      src/css/styles.scss
  56. 51 0
      src/data/EditorLocalStorage.ts
  57. 300 0
      src/data/ai/types.ts
  58. 9 5
      src/data/index.ts
  59. 104 0
      src/data/magic.ts
  60. 9 6
      src/data/restore.ts
  61. 44 8
      src/data/transform.ts
  62. 14 0
      src/element/ElementCanvasButtons.scss
  63. 60 0
      src/element/ElementCanvasButtons.tsx
  64. 1 1
      src/element/Hyperlink.scss
  65. 2 1
      src/element/Hyperlink.tsx
  66. 20 5
      src/element/bounds.ts
  67. 25 12
      src/element/collision.ts
  68. 2 2
      src/element/dragElements.ts
  69. 56 38
      src/element/embeddable.ts
  70. 4 16
      src/element/index.ts
  71. 29 0
      src/element/newElement.ts
  72. 3 3
      src/element/resizeElements.ts
  73. 2 2
      src/element/subtypes/index.ts
  74. 2 1
      src/element/textElement.ts
  75. 2 2
      src/element/transformHandles.ts
  76. 44 20
      src/element/typeChecks.ts
  77. 37 1
      src/element/types.ts
  78. 49 35
      src/frame.ts
  79. 9 2
      src/locales/en.json
  80. 6 0
      src/packages/excalidraw/CHANGELOG.md
  81. 17 1
      src/packages/excalidraw/example/App.tsx
  82. 4 0
      src/packages/excalidraw/index.tsx
  83. 2 2
      src/packages/utils.ts
  84. 13 1
      src/renderer/renderElement.ts
  85. 16 12
      src/renderer/renderScene.ts
  86. 14 17
      src/scene/Scene.ts
  87. 24 6
      src/scene/Shape.ts
  88. 18 13
      src/scene/comparisons.ts
  89. 25 12
      src/scene/export.ts
  90. 3 3
      src/scene/selection.ts
  91. 2 0
      src/scene/types.ts
  92. 0 8
      src/shapes.tsx
  93. 9 7
      src/snapping.ts
  94. 13 19
      src/tests/MermaidToExcalidraw.test.tsx
  95. 2 2
      src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
  96. 16 1
      src/tests/helpers/api.ts
  97. 5 6
      src/tests/helpers/ui.ts
  98. 4 4
      src/tests/linearElementEditor.test.tsx
  99. 5 19
      src/tests/queries/toolQueries.ts
  100. 31 10
      src/types.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

@@ -26,6 +26,8 @@ import {
   Excalidraw,
   defaultLang,
   LiveCollaborationTrigger,
+  TTDDialog,
+  TTDDialogTrigger,
 } from "../src/packages/excalidraw/index";
 import {
   AppState,
@@ -776,6 +778,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")}

+ 2 - 1
src/actions/actionAlign.tsx

@@ -9,6 +9,7 @@ import {
 } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
 import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
@@ -28,7 +29,7 @@ const alignActionsPredicate = (
   return (
     selectedElements.length > 1 &&
     // TODO enable aligning frames when implemented properly
-    !selectedElements.some((el) => el.type === "frame")
+    !selectedElements.some((el) => isFrameLikeElement(el))
   );
 };
 

+ 2 - 2
src/actions/actionDeleteSelected.tsx

@@ -10,7 +10,7 @@ import { newElementWith } from "../element/mutateElement";
 import { getElementsInGroup } from "../groups";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { fixBindingsAfterDeletion } from "../element/binding";
-import { isBoundToContainer } from "../element/typeChecks";
+import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
 import { updateActiveTool } from "../utils";
 import { TrashIcon } from "../components/icons";
 
@@ -20,7 +20,7 @@ const deleteSelectedElements = (
 ) => {
   const framesToBeDeleted = new Set(
     getSelectedElements(
-      elements.filter((el) => el.type === "frame"),
+      elements.filter((el) => isFrameLikeElement(el)),
       appState,
     ).map((el) => el.id),
   );

+ 2 - 1
src/actions/actionDistribute.tsx

@@ -5,6 +5,7 @@ import {
 import { ToolButton } from "../components/ToolButton";
 import { distributeElements, Distribution } from "../distribute";
 import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
 import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
@@ -19,7 +20,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
   return (
     selectedElements.length > 1 &&
     // TODO enable distributing frames when implemented properly
-    !selectedElements.some((el) => el.type === "frame")
+    !selectedElements.some((el) => isFrameLikeElement(el))
   );
 };
 

+ 5 - 5
src/actions/actionDuplicateSelection.tsx

@@ -20,7 +20,7 @@ import {
   bindTextToShapeAfterDuplication,
   getBoundTextElement,
 } from "../element/textElement";
-import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
+import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
 import { normalizeElementOrder } from "../element/sortElements";
 import { DuplicateIcon } from "../components/icons";
 import {
@@ -140,11 +140,11 @@ const duplicateElements = (
     }
 
     const boundTextElement = getBoundTextElement(element);
-    const isElementAFrame = isFrameElement(element);
+    const isElementAFrameLike = isFrameLikeElement(element);
 
     if (idsOfElementsToDuplicate.get(element.id)) {
       // if a group or a container/bound-text or frame, duplicate atomically
-      if (element.groupIds.length || boundTextElement || isElementAFrame) {
+      if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
         const groupId = getSelectedGroupForElement(appState, element);
         if (groupId) {
           // TODO:
@@ -154,7 +154,7 @@ const duplicateElements = (
             sortedElements,
             groupId,
           ).flatMap((element) =>
-            isFrameElement(element)
+            isFrameLikeElement(element)
               ? [...getFrameChildren(elements, element.id), element]
               : [element],
           );
@@ -180,7 +180,7 @@ const duplicateElements = (
           );
           continue;
         }
-        if (isElementAFrame) {
+        if (isElementAFrameLike) {
           const elementsInFrame = getFrameChildren(sortedElements, element.id);
 
           elementsWithClones.push(

+ 2 - 1
src/actions/actionElementLock.ts

@@ -1,4 +1,5 @@
 import { newElementWith } from "../element/mutateElement";
+import { isFrameLikeElement } from "../element/typeChecks";
 import { ExcalidrawElement } from "../element/types";
 import { KEYS } from "../keys";
 import { arrayToMap } from "../utils";
@@ -51,7 +52,7 @@ export const actionToggleElementLock = register({
       selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: false,
     });
-    if (selected.length === 1 && selected[0].type !== "frame") {
+    if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
       return selected[0].locked
         ? "labels.elementLock.unlock"
         : "labels.elementLock.lock";

+ 17 - 8
src/actions/actionFrame.ts

@@ -7,23 +7,27 @@ import { AppClassProperties, AppState } from "../types";
 import { updateActiveTool } from "../utils";
 import { setCursorForShape } from "../cursor";
 import { register } from "./register";
+import { isFrameLikeElement } from "../element/typeChecks";
 
 const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
   const selectedElements = app.scene.getSelectedElements(appState);
 
-  return selectedElements.length === 1 && selectedElements[0].type === "frame";
+  return (
+    selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
+  );
 };
 
 export const actionSelectAllElementsInFrame = register({
   name: "selectAllElementsInFrame",
   trackEvent: { category: "canvas" },
   perform: (elements, appState, _, app) => {
-    const selectedFrame = app.scene.getSelectedElements(appState)[0];
+    const selectedElement =
+      app.scene.getSelectedElements(appState).at(0) || null;
 
-    if (selectedFrame && selectedFrame.type === "frame") {
+    if (isFrameLikeElement(selectedElement)) {
       const elementsInFrame = getFrameChildren(
         getNonDeletedElements(elements),
-        selectedFrame.id,
+        selectedElement.id,
       ).filter((element) => !(element.type === "text" && element.containerId));
 
       return {
@@ -54,15 +58,20 @@ export const actionRemoveAllElementsFromFrame = register({
   name: "removeAllElementsFromFrame",
   trackEvent: { category: "history" },
   perform: (elements, appState, _, app) => {
-    const selectedFrame = app.scene.getSelectedElements(appState)[0];
+    const selectedElement =
+      app.scene.getSelectedElements(appState).at(0) || null;
 
-    if (selectedFrame && selectedFrame.type === "frame") {
+    if (isFrameLikeElement(selectedElement)) {
       return {
-        elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
+        elements: removeAllElementsFromFrame(
+          elements,
+          selectedElement,
+          appState,
+        ),
         appState: {
           ...appState,
           selectedElementIds: {
-            [selectedFrame.id]: true,
+            [selectedElement.id]: true,
           },
         },
         commitToHistory: true,

+ 4 - 4
src/actions/actionGroup.tsx

@@ -22,8 +22,8 @@ import { AppClassProperties, AppState } from "../types";
 import { isBoundToContainer } from "../element/typeChecks";
 import {
   getElementsInResizingFrame,
-  getFrameElements,
-  groupByFrames,
+  getFrameLikeElements,
+  groupByFrameLikes,
   removeElementsFromFrame,
   replaceAllElementsInFrame,
 } from "../frame";
@@ -102,7 +102,7 @@ export const actionGroup = register({
     // when it happens, we want to remove elements that are in the frame
     // and are going to be grouped from the frame (mouthful, I know)
     if (groupingElementsFromDifferentFrames) {
-      const frameElementsMap = groupByFrames(selectedElements);
+      const frameElementsMap = groupByFrameLikes(selectedElements);
 
       frameElementsMap.forEach((elementsInFrame, frameId) => {
         nextElements = removeElementsFromFrame(
@@ -219,7 +219,7 @@ export const actionUngroup = register({
         .map((element) => element.frameId!),
     );
 
-    const targetFrames = getFrameElements(elements).filter((frame) =>
+    const targetFrames = getFrameLikeElements(elements).filter((frame) =>
       selectedElementFrameIds.has(frame.id),
     );
 

+ 7 - 2
src/actions/actionMenu.tsx

@@ -56,13 +56,18 @@ export const actionShortcuts = register({
   viewMode: true,
   trackEvent: { category: "menu", action: "toggleHelpDialog" },
   perform: (_elements, appState, _, { focusContainer }) => {
-    if (appState.openDialog === "help") {
+    if (appState.openDialog?.name === "help") {
       focusContainer();
     }
     return {
       appState: {
         ...appState,
-        openDialog: appState.openDialog === "help" ? null : "help",
+        openDialog:
+          appState.openDialog?.name === "help"
+            ? null
+            : {
+                name: "help",
+              },
       },
       commitToHistory: false,
     };

+ 2 - 2
src/actions/actionStyles.ts

@@ -20,7 +20,7 @@ import {
   hasBoundTextElement,
   canApplyRoundnessTypeToElement,
   getDefaultRoundnessTypeForElement,
-  isFrameElement,
+  isFrameLikeElement,
   isArrowElement,
 } from "../element/typeChecks";
 import { getSelectedElements } from "../scene";
@@ -138,7 +138,7 @@ export const actionPasteStyles = register({
             });
           }
 
-          if (isFrameElement(element)) {
+          if (isFrameLikeElement(element)) {
             newElement = newElementWith(newElement, {
               roundness: null,
               backgroundColor: "transparent",

+ 15 - 7
src/analytics.ts

@@ -1,3 +1,7 @@
+// place here categories that you want to track. We want to track just a
+// small subset of categories at a given time.
+const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
+
 export const trackEvent = (
   category: string,
   action: string,
@@ -5,13 +9,13 @@ export const trackEvent = (
   value?: number,
 ) => {
   try {
-    // place here categories that you want to track as events
-    // KEEP IN MIND THE PRICING
-    const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
-    // Uncomment the next line to track locally
-    // console.log("Track Event", { category, action, label, value });
-
-    if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
+    // prettier-ignore
+    if (
+      typeof window === "undefined"
+      || import.meta.env.VITE_WORKER_ID
+      // comment out to debug locally
+      || import.meta.env.PROD
+    ) {
       return;
     }
 
@@ -19,6 +23,10 @@ export const trackEvent = (
       return;
     }
 
+    if (!import.meta.env.PROD) {
+      console.info("trackEvent", { category, action, label, value });
+    }
+
     if (window.sa_event) {
       window.sa_event(action, {
         category,

+ 5 - 2
src/clipboard.ts

@@ -9,7 +9,10 @@ import {
   EXPORT_DATA_TYPES,
   MIME_TYPES,
 } from "./constants";
-import { isInitializedImageElement } from "./element/typeChecks";
+import {
+  isFrameLikeElement,
+  isInitializedImageElement,
+} from "./element/typeChecks";
 import { deepCopyElement } from "./element/newElement";
 import { mutateElement } from "./element/mutateElement";
 import { getContainingFrame } from "./frame";
@@ -124,7 +127,7 @@ export const serializeAsClipboardJSON = ({
   files: BinaryFiles | null;
 }) => {
   const framesToCopy = new Set(
-    elements.filter((element) => element.type === "frame"),
+    elements.filter((element) => isFrameLikeElement(element)),
   );
   let foundFile = false;
 

+ 41 - 4
src/components/Actions.tsx

@@ -1,7 +1,7 @@
 import React, { useState } from "react";
 import { ActionManager } from "../actions/manager";
 import { getNonDeletedElements } from "../element";
-import { ExcalidrawElement } from "../element/types";
+import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
 import { t } from "../i18n";
 import { useDevice } from "../components/App";
 import {
@@ -37,8 +37,11 @@ import {
   frameToolIcon,
   mermaidLogoIcon,
   laserPointerToolIcon,
+  OpenAIIcon,
+  MagicIcon,
 } from "./icons";
 import { KEYS } from "../keys";
+import { useTunnels } from "../context/tunnels";
 
 export const SelectedShapeActions = ({
   appState,
@@ -80,7 +83,8 @@ export const SelectedShapeActions = ({
   const showLinkIcon =
     targetElements.length === 1 || isSingleElementBoundContainer;
 
-  let commonSelectedType: string | null = targetElements[0]?.type || null;
+  let commonSelectedType: ExcalidrawElementType | null =
+    targetElements[0]?.type || null;
 
   for (const element of targetElements) {
     if (element.type !== commonSelectedType) {
@@ -95,7 +99,8 @@ export const SelectedShapeActions = ({
         {((hasStrokeColor(appState.activeTool.type) &&
           appState.activeTool.type !== "image" &&
           commonSelectedType !== "image" &&
-          commonSelectedType !== "frame") ||
+          commonSelectedType !== "frame" &&
+          commonSelectedType !== "magicframe") ||
           targetElements.some((element) => hasStrokeColor(element.type))) &&
           renderAction("changeStrokeColor")}
       </div>
@@ -233,6 +238,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) => {
@@ -333,13 +340,43 @@ export const ShapesSwitcher = ({
           >
             {t("toolBar.laser")}
           </DropdownMenu.Item>
+          <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
+            Generate
+          </div>
+          {app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
           <DropdownMenu.Item
-            onSelect={() => app.setOpenDialog("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
+                onSelect={() => app.onMagicframeToolSelect()}
+                icon={MagicIcon}
+                data-testid="toolbar-magicframe"
+              >
+                {t("toolBar.magicframe")}
+                <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item
+                onSelect={() => {
+                  trackEvent("ai", "open-settings", "d2c");
+                  app.setOpenDialog({
+                    name: "settings",
+                    source: "settings",
+                    tab: "diagram-to-code",
+                  });
+                }}
+                icon={OpenAIIcon}
+                data-testid="toolbar-magicSettings"
+              >
+                {t("toolBar.magicSettings")}
+              </DropdownMenu.Item>
+            </>
+          )}
         </DropdownMenu.Content>
       </DropdownMenu>
     </>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 631 - 62
src/components/App.tsx


+ 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 */

+ 15 - 0
src/components/InlineIcon.tsx

@@ -0,0 +1,15 @@
+export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
+  return (
+    <span
+      style={{
+        width: "1em",
+        margin: "0 0.5ex 0 0.5ex",
+        display: "inline-block",
+        lineHeight: 0,
+        verticalAlign: "middle",
+      }}
+    >
+      {icon}
+    </span>
+  );
+};

+ 1 - 1
src/components/JSONExportDialog.tsx

@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
 
   return (
     <>
-      {appState.openDialog === "jsonExport" && (
+      {appState.openDialog?.name === "jsonExport" && (
         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
           <JSONExportModal
             elements={elements}

+ 46 - 5
src/components/LayerUI.tsx

@@ -1,7 +1,12 @@
 import clsx from "clsx";
 import React from "react";
 import { ActionManager } from "../actions/manager";
-import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
+import {
+  CLASSES,
+  DEFAULT_SIDEBAR,
+  LIBRARY_SIDEBAR_WIDTH,
+  TOOL_TYPE,
+} from "../constants";
 import { showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
@@ -57,6 +62,8 @@ import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
 import { SubtypeToggles } from "./Subtypes";
 import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
+import { MagicSettings } from "./MagicSettings";
+import { TTDDialog } from "./TTDDialog/TTDDialog";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -78,6 +85,14 @@ interface LayerUIProps {
   children?: React.ReactNode;
   app: AppClassProperties;
   isCollaborating: boolean;
+  openAIKey: string | null;
+  isOpenAIKeyPersisted: boolean;
+  onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
+  onMagicSettingsConfirm: (
+    apiKey: string,
+    shouldPersist: boolean,
+    source: "tool" | "generation" | "settings",
+  ) => void;
 }
 
 const DefaultMainMenu: React.FC<{
@@ -134,6 +149,10 @@ const LayerUI = ({
   children,
   app,
   isCollaborating,
+  openAIKey,
+  isOpenAIKeyPersisted,
+  onOpenAIAPIKeyChange,
+  onMagicSettingsConfirm,
 }: LayerUIProps) => {
   const device = useDevice();
   const tunnels = useInitializeTunnels();
@@ -164,7 +183,7 @@ const LayerUI = ({
   const renderImageExportDialog = () => {
     if (
       !UIOptions.canvasActions.saveAsImage ||
-      appState.openDialog !== "imageExport"
+      appState.openDialog?.name !== "imageExport"
     ) {
       return null;
     }
@@ -297,9 +316,11 @@ const LayerUI = ({
                         >
                           <LaserPointerButton
                             title={t("toolBar.laser")}
-                            checked={appState.activeTool.type === "laser"}
+                            checked={
+                              appState.activeTool.type === TOOL_TYPE.laser
+                            }
                             onChange={() =>
-                              app.setActiveTool({ type: "laser" })
+                              app.setActiveTool({ type: TOOL_TYPE.laser })
                             }
                             isMobile
                           />
@@ -378,6 +399,7 @@ const LayerUI = ({
         {t("toolBar.library")}
       </DefaultSidebar.Trigger>
       <DefaultOverwriteConfirmDialog />
+      {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
       {/* ------------------------------------------------------------------ */}
 
       {appState.isLoading && <LoadingMessage delay={250} />}
@@ -434,13 +456,32 @@ const LayerUI = ({
           }}
         />
       )}
-      {appState.openDialog === "help" && (
+      {appState.openDialog?.name === "help" && (
         <HelpDialog
           onClose={() => {
             setAppState({ openDialog: null });
           }}
         />
       )}
+      {appState.openDialog?.name === "settings" && (
+        <MagicSettings
+          openAIKey={openAIKey}
+          isPersisted={isOpenAIKeyPersisted}
+          onChange={onOpenAIAPIKeyChange}
+          onConfirm={(apiKey, shouldPersist) => {
+            const source =
+              appState.openDialog?.name === "settings"
+                ? appState.openDialog?.source
+                : "settings";
+            setAppState({ openDialog: null }, () => {
+              onMagicSettingsConfirm(apiKey, shouldPersist, source);
+            });
+          }}
+          onClose={() => {
+            setAppState({ openDialog: null });
+          }}
+        />
+      )}
       <ActiveConfirmDialog />
       <tunnels.OverwriteConfirmDialogTunnel.Out />
       {renderImageExportDialog()}

+ 38 - 0
src/components/MagicButton.tsx

@@ -0,0 +1,38 @@
+import "./ToolIcon.scss";
+
+import clsx from "clsx";
+import { ToolButtonSize } from "./ToolButton";
+
+const DEFAULT_SIZE: ToolButtonSize = "small";
+
+export const ElementCanvasButton = (props: {
+  title?: string;
+  icon: JSX.Element;
+  name?: string;
+  checked: boolean;
+  onChange?(): void;
+  isMobile?: boolean;
+}) => {
+  return (
+    <label
+      className={clsx(
+        "ToolIcon ToolIcon__MagicButton",
+        `ToolIcon_size_${DEFAULT_SIZE}`,
+        {
+          "is-mobile": props.isMobile,
+        },
+      )}
+      title={`${props.title}`}
+    >
+      <input
+        className="ToolIcon_type_checkbox"
+        type="checkbox"
+        name={props.name}
+        onChange={props.onChange}
+        checked={props.checked}
+        aria-label={props.title}
+      />
+      <div className="ToolIcon__icon">{props.icon}</div>
+    </label>
+  );
+};

+ 18 - 0
src/components/MagicSettings.scss

@@ -0,0 +1,18 @@
+.excalidraw {
+  .MagicSettings {
+    .Island {
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+    }
+  }
+
+  .MagicSettings-confirm {
+    padding: 0.5rem 1rem;
+  }
+
+  .MagicSettings__confirm {
+    margin-top: 2rem;
+    margin-right: auto;
+  }
+}

+ 160 - 0
src/components/MagicSettings.tsx

@@ -0,0 +1,160 @@
+import { useState } from "react";
+import { Dialog } from "./Dialog";
+import { TextField } from "./TextField";
+import { MagicIcon, OpenAIIcon } from "./icons";
+import { FilledButton } from "./FilledButton";
+import { CheckboxItem } from "./CheckboxItem";
+import { KEYS } from "../keys";
+import { useUIAppState } from "../context/ui-appState";
+import { InlineIcon } from "./InlineIcon";
+import { Paragraph } from "./Paragraph";
+
+import "./MagicSettings.scss";
+import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
+import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
+
+export const MagicSettings = (props: {
+  openAIKey: string | null;
+  isPersisted: boolean;
+  onChange: (key: string, shouldPersist: boolean) => void;
+  onConfirm: (key: string, shouldPersist: boolean) => void;
+  onClose: () => void;
+}) => {
+  const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
+  const [shouldPersist, setShouldPersist] = useState<boolean>(
+    props.isPersisted,
+  );
+
+  const appState = useUIAppState();
+
+  const onConfirm = () => {
+    props.onConfirm(keyInputValue.trim(), shouldPersist);
+  };
+
+  if (appState.openDialog?.name !== "settings") {
+    return null;
+  }
+
+  return (
+    <Dialog
+      onCloseRequest={() => {
+        props.onClose();
+        props.onConfirm(keyInputValue.trim(), shouldPersist);
+      }}
+      title={
+        <div style={{ display: "flex" }}>
+          Wireframe to Code (AI){" "}
+          <div
+            style={{
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+              padding: "0.1rem 0.5rem",
+              marginLeft: "1rem",
+              fontSize: 14,
+              borderRadius: "12px",
+              color: "#000",
+              background: "pink",
+            }}
+          >
+            Experimental
+          </div>
+        </div>
+      }
+      className="MagicSettings"
+      autofocus={false}
+    >
+      {/*  <h2
+        style={{
+          margin: 0,
+          fontSize: "1.25rem",
+          paddingLeft: "2.5rem",
+        }}
+      >
+        AI Settings
+      </h2> */}
+      <TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
+        {/* <TTDDialogTabTriggers>
+          <TTDDialogTabTrigger tab="text-to-diagram">
+            <InlineIcon icon={brainIcon} /> Text to diagram
+          </TTDDialogTabTrigger>
+          <TTDDialogTabTrigger tab="diagram-to-code">
+            <InlineIcon icon={MagicIcon} /> Wireframe to code
+          </TTDDialogTabTrigger>
+        </TTDDialogTabTriggers> */}
+        {/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
+          TODO
+        </TTDDialogTab> */}
+        <TTDDialogTab
+          //  className="ttd-dialog-content"
+          tab="diagram-to-code"
+        >
+          <Paragraph>
+            For the diagram-to-code feature we use{" "}
+            <InlineIcon icon={OpenAIIcon} />
+            OpenAI.
+          </Paragraph>
+          <Paragraph>
+            While the OpenAI API is in beta, its use is strictly limited — as
+            such we require you use your own API key. You can create an{" "}
+            <a
+              href="https://platform.openai.com/login?launch"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              OpenAI account
+            </a>
+            , add a small credit (5 USD minimum), and{" "}
+            <a
+              href="https://platform.openai.com/api-keys"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              generate your own API key
+            </a>
+            .
+          </Paragraph>
+          <Paragraph>
+            Your OpenAI key does not leave the browser, and you can also set
+            your own limit in your OpenAI account dashboard if needed.
+          </Paragraph>
+          <TextField
+            isRedacted
+            value={keyInputValue}
+            placeholder="Paste your API key here"
+            label="OpenAI API key"
+            onChange={(value) => {
+              setKeyInputValue(value);
+              props.onChange(value.trim(), shouldPersist);
+            }}
+            selectOnRender
+            onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
+          />
+          <Paragraph>
+            By default, your API token is not persisted anywhere so you'll need
+            to insert it again after reload. But, you can persist locally in
+            your browser below.
+          </Paragraph>
+
+          <CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
+            Persist API key in browser storage
+          </CheckboxItem>
+
+          <Paragraph>
+            Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
+            tool to wrap your elements in a frame that will then allow you to
+            turn it into code. This dialog can be accessed using the{" "}
+            <b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
+          </Paragraph>
+
+          <FilledButton
+            className="MagicSettings__confirm"
+            size="large"
+            label="Confirm"
+            onClick={onConfirm}
+          />
+        </TTDDialogTab>
+      </TTDDialogTabs>
+    </Dialog>
+  );
+};

+ 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;

+ 4 - 1
src/components/Modal.scss

@@ -18,8 +18,11 @@
     overflow: auto;
     padding: calc(var(--space-factor) * 10);
 
+    display: flex;
+    flex-direction: column;
+
     .Island {
-      padding: 2.5rem !important;
+      padding: 2.5rem;
     }
   }
 

+ 1 - 1
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx

@@ -47,7 +47,7 @@ export const ExportToImage = () => {
       actionLabel={t("overwriteConfirm.action.exportToImage.button")}
       onClick={() => {
         actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
-        setAppState({ openDialog: "imageExport" });
+        setAppState({ openDialog: { name: "imageExport" } });
       }}
     >
       {t("overwriteConfirm.action.exportToImage.description")}

+ 10 - 0
src/components/Paragraph.tsx

@@ -0,0 +1,10 @@
+export const Paragraph = (props: {
+  children: React.ReactNode;
+  style?: React.CSSProperties;
+}) => {
+  return (
+    <p className="excalidraw__paragraph" style={props.style}>
+      {props.children}
+    </p>
+  );
+};

+ 1 - 1
src/components/PasteChartDialog.tsx

@@ -113,7 +113,7 @@ export const PasteChartDialog = ({
 
   const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
     onInsertElements(elements);
-    trackEvent("magic", "chart", chartType);
+    trackEvent("paste", "chart", chartType);
     setAppState({
       currentChartType: chartType,
       pasteDialog: {

+ 7 - 31
src/components/PublishLibrary.tsx

@@ -8,6 +8,7 @@ import Trans from "./Trans";
 import { LibraryItems, LibraryItem, UIAppState } from "../types";
 import { exportToCanvas, exportToSvg } from "../packages/utils";
 import {
+  EDITOR_LS_KEYS,
   EXPORT_DATA_TYPES,
   EXPORT_SOURCE,
   MIME_TYPES,
@@ -19,6 +20,7 @@ import { chunk } from "../utils";
 import DialogActionButton from "./DialogActionButton";
 import { CloseIcon } from "./icons";
 import { ToolButton } from "./ToolButton";
+import { EditorLocalStorage } from "../data/EditorLocalStorage";
 
 import "./PublishLibrary.scss";
 
@@ -31,34 +33,6 @@ interface PublishLibraryDataParams {
   website: string;
 }
 
-const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
-
-const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
-  try {
-    localStorage.setItem(
-      LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
-      JSON.stringify(data),
-    );
-  } catch (error: any) {
-    // Unable to access window.localStorage
-    console.error(error);
-  }
-};
-
-const importPublishLibDataFromStorage = () => {
-  try {
-    const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
-    if (data) {
-      return JSON.parse(data);
-    }
-  } catch (error: any) {
-    // Unable to access localStorage
-    console.error(error);
-  }
-
-  return null;
-};
-
 const generatePreviewImage = async (libraryItems: LibraryItems) => {
   const MAX_ITEMS_PER_ROW = 6;
   const BOX_SIZE = 128;
@@ -255,7 +229,9 @@ const PublishLibrary = ({
   const [isSubmitting, setIsSubmitting] = useState(false);
 
   useEffect(() => {
-    const data = importPublishLibDataFromStorage();
+    const data = EditorLocalStorage.get<PublishLibraryDataParams>(
+      EDITOR_LS_KEYS.PUBLISH_LIBRARY,
+    );
     if (data) {
       setLibraryData(data);
     }
@@ -328,7 +304,7 @@ const PublishLibrary = ({
           if (response.ok) {
             return response.json().then(({ url }) => {
               // flush data from local storage
-              localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
+              EditorLocalStorage.delete(EDITOR_LS_KEYS.PUBLISH_LIBRARY);
               onSuccess({
                 url,
                 authorName: libraryData.authorName,
@@ -384,7 +360,7 @@ const PublishLibrary = ({
 
   const onDialogClose = useCallback(() => {
     updateItemsInStorage(clonedLibItems);
-    savePublishLibDataToStorage(libraryData);
+    EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, libraryData);
     onClose();
   }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
 

+ 2 - 1
src/components/Subtypes.tsx

@@ -4,6 +4,7 @@ import { Action, makeCustomActionName } from "../actions/types";
 import clsx from "clsx";
 import {
   Subtype,
+  SubtypeRecord,
   getSubtypeNames,
   hasAlwaysEnabledActions,
   isSubtypeAction,
@@ -21,7 +22,7 @@ import { Island } from "./Island";
 
 export const SubtypeButton = (
   subtype: Subtype,
-  parentType: ExcalidrawElement["type"],
+  parentType: SubtypeRecord["parents"][number],
   icon: ({ theme }: { theme: Theme }) => JSX.Element,
   key?: string,
 ) => {

+ 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,
+      mermaidDefinition: 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;
+      }
+    }
+  }
+}

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

@@ -0,0 +1,350 @@
+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: "text-to-diagram" | "mermaid";
+  } & (
+    | {
+        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,
+            mermaidDefinition: generatedResponse,
+          });
+          trackEvent("ai", "mermaid parse success", "ttd");
+          saveMermaidDataToStorage(generatedResponse);
+        } catch (error: any) {
+          console.info(
+            `%cTTD mermaid render errror: ${error.message}`,
+            "color: red",
+          );
+          console.info(
+            `>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
+            "color: yellow",
+          );
+          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={false}
+        {...rest}
+        autofocus={false}
+      >
+        <TTDDialogTabs dialog="ttd" tab={tab}>
+          {"__fallback" in rest && rest.__fallback ? (
+            <p className="dialog-mermaid-title">{t("mermaid.title")}</p>
+          ) : (
+            <TTDDialogTabTriggers>
+              <TTDDialogTabTrigger tab="text-to-diagram">
+                <div style={{ display: "flex", alignItems: "center" }}>
+                  {t("labels.textToDiagram")}
+                  <div
+                    style={{
+                      display: "flex",
+                      alignItems: "center",
+                      justifyContent: "center",
+                      padding: "1px 6px",
+                      marginLeft: "10px",
+                      fontSize: 10,
+                      borderRadius: "12px",
+                      background: "pink",
+                      color: "#000",
+                    }}
+                  >
+                    AI Beta
+                  </div>
+                </div>
+              </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";

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

@@ -0,0 +1,64 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { ReactNode, useRef } from "react";
+import { useExcalidrawSetAppState } from "../App";
+import { isMemberOf } from "../../utils";
+
+const TTDDialogTabs = (
+  props: {
+    children: ReactNode;
+  } & (
+    | { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
+    | { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
+  ),
+) => {
+  const setAppState = useExcalidrawSetAppState();
+
+  const rootRef = useRef<HTMLDivElement>(null);
+  const minHeightRef = useRef<number>(0);
+
+  return (
+    <RadixTabs.Root
+      ref={rootRef}
+      className="ttd-dialog-tabs-root"
+      value={props.tab}
+      onValueChange={(
+        // at least in test enviros, `tab` can be `undefined`
+        tab: string | undefined,
+      ) => {
+        if (!tab) {
+          return;
+        }
+        const modalContentNode =
+          rootRef.current?.closest<HTMLElement>(".Modal__content");
+        if (modalContentNode) {
+          const currHeight = modalContentNode.offsetHeight || 0;
+          if (currHeight > minHeightRef.current) {
+            minHeightRef.current = currHeight;
+            modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
+          }
+        }
+        if (
+          props.dialog === "settings" &&
+          isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
+        ) {
+          setAppState({
+            openDialog: { name: props.dialog, tab, source: "settings" },
+          });
+        } else if (
+          props.dialog === "ttd" &&
+          isMemberOf(["text-to-diagram", "mermaid"], tab)
+        ) {
+          setAppState({
+            openDialog: { name: props.dialog, tab },
+          });
+        }
+      }}
+    >
+      {props.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";

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

@@ -0,0 +1,164 @@
+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;
+  mermaidDefinition: string;
+  setError: (error: Error | null) => void;
+  data: React.MutableRefObject<{
+    elements: readonly NonDeletedExcalidrawElement[];
+    files: BinaryFiles | null;
+  }>;
+}
+
+export const convertMermaidToExcalidraw = async ({
+  canvasRef,
+  mermaidToExcalidrawLib,
+  mermaidDefinition,
+  setError,
+  data,
+}: ConvertMermaidToExcalidrawFormatProps) => {
+  const canvasNode = canvasRef.current;
+  const parent = canvasNode?.parentElement;
+
+  if (!canvasNode || !parent) {
+    return;
+  }
+
+  if (!mermaidDefinition) {
+    resetPreview({ canvasRef, setError });
+    return;
+  }
+
+  try {
+    const api = await mermaidToExcalidrawLib.api;
+
+    let ret;
+    try {
+      ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
+        fontSize: DEFAULT_FONT_SIZE,
+      });
+    } catch (err: any) {
+      ret = await api.parseMermaidToExcalidraw(
+        mermaidDefinition.replace(/"/g, "'"),
+        {
+          fontSize: DEFAULT_FONT_SIZE,
+        },
+      );
+    }
+    const { elements, files } = ret;
+    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 (mermaidDefinition) {
+      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);
+  }
+};

+ 22 - 2
src/components/TextField.tsx

@@ -4,12 +4,15 @@ import {
   useImperativeHandle,
   KeyboardEvent,
   useLayoutEffect,
+  useState,
 } from "react";
 import clsx from "clsx";
 
 import "./TextField.scss";
+import { Button } from "./Button";
+import { eyeIcon, eyeClosedIcon } from "./icons";
 
-export type TextFieldProps = {
+type TextFieldProps = {
   value?: string;
 
   onChange?: (value: string) => void;
@@ -22,6 +25,7 @@ export type TextFieldProps = {
 
   label?: string;
   placeholder?: string;
+  isRedacted?: boolean;
 };
 
 export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       readonly,
       selectOnRender,
       onKeyDown,
+      isRedacted = false,
     },
     ref,
   ) => {
@@ -48,6 +53,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       }
     }, [selectOnRender]);
 
+    const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
+      useState<boolean>(false);
+
     return (
       <div
         className={clsx("ExcTextField", {
@@ -64,14 +72,26 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
           })}
         >
           <input
+            className={clsx({
+              "is-redacted": value && isRedacted && !isTemporarilyUnredacted,
+            })}
             readOnly={readonly}
-            type="text"
             value={value}
             placeholder={placeholder}
             ref={innerRef}
             onChange={(event) => onChange?.(event.target.value)}
             onKeyDown={onKeyDown}
           />
+          {isRedacted && (
+            <Button
+              onSelect={() =>
+                setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
+              }
+              style={{ border: 0, userSelect: "none" }}
+            >
+              {isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
+            </Button>
+          )}
         </div>
       </div>
     );

+ 2 - 1
src/components/ToolIcon.scss

@@ -175,7 +175,8 @@
       }
     }
 
-    .ToolIcon__LaserPointer .ToolIcon__icon {
+    .ToolIcon__LaserPointer .ToolIcon__icon,
+    .ToolIcon__MagicButton .ToolIcon__icon {
       width: var(--default-button-size);
       height: var(--default-button-size);
     }

+ 0 - 2
src/components/canvases/InteractiveCanvas.tsx

@@ -189,8 +189,6 @@ const getRelevantAppStateProps = (
   suggestedBindings: appState.suggestedBindings,
   isRotating: appState.isRotating,
   elementsToHighlight: appState.elementsToHighlight,
-  openSidebar: appState.openSidebar,
-  showHyperlinkPopup: appState.showHyperlinkPopup,
   collaborators: appState.collaborators, // Necessary for collab. sessions
   activeEmbeddable: appState.activeEmbeddable,
   snapLines: appState.snapLines,

+ 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";

+ 67 - 0
src/components/icons.tsx

@@ -1688,3 +1688,70 @@ export const laserPointerToolIcon = createIcon(
 
   20,
 );
+
+export const MagicIcon = createIcon(
+  <g stroke="currentColor" fill="none">
+    <path stroke="none" d="M0 0h24v24H0z" />
+    <path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
+    <path d="M15 6l3 3" />
+    <path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
+    <path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
+  </g>,
+  tablerIconProps,
+);
+
+export const OpenAIIcon = createIcon(
+  <g stroke="currentColor" fill="none">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M11.217 19.384a3.501 3.501 0 0 0 6.783 -1.217v-5.167l-6 -3.35" />
+    <path d="M5.214 15.014a3.501 3.501 0 0 0 4.446 5.266l4.34 -2.534v-6.946" />
+    <path d="M6 7.63c-1.391 -.236 -2.787 .395 -3.534 1.689a3.474 3.474 0 0 0 1.271 4.745l4.263 2.514l6 -3.348" />
+    <path d="M12.783 4.616a3.501 3.501 0 0 0 -6.783 1.217v5.067l6 3.45" />
+    <path d="M18.786 8.986a3.501 3.501 0 0 0 -4.446 -5.266l-4.34 2.534v6.946" />
+    <path d="M18 16.302c1.391 .236 2.787 -.395 3.534 -1.689a3.474 3.474 0 0 0 -1.271 -4.745l-4.308 -2.514l-5.955 3.42" />
+  </g>,
+  tablerIconProps,
+);
+
+export const fullscreenIcon = createIcon(
+  <g stroke="currentColor" fill="none">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
+    <path d="M4 16v2a2 2 0 0 0 2 2h2" />
+    <path d="M16 4h2a2 2 0 0 1 2 2v2" />
+    <path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
+  </g>,
+  tablerIconProps,
+);
+
+export const eyeIcon = createIcon(
+  <g stroke="currentColor" fill="none">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
+    <path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
+  </g>,
+  tablerIconProps,
+);
+
+export const eyeClosedIcon = createIcon(
+  <g stroke="currentColor" fill="none">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
+    <path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
+    <path d="M3 3l18 18" />
+  </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 - 2
src/components/main-menu/DefaultItems.tsx

@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
     <DropdownMenuItem
       icon={ExportImageIcon}
       data-testid="image-export-button"
-      onSelect={() => setAppState({ openDialog: "imageExport" })}
+      onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
       shortcut={getShortcutFromShortcutName("imageExport")}
       aria-label={t("buttons.exportImage")}
     >
@@ -230,7 +230,7 @@ export const Export = () => {
     <DropdownMenuItem
       icon={ExportIcon}
       onSelect={() => {
-        setAppState({ openDialog: "jsonExport" });
+        setAppState({ openDialog: { name: "jsonExport" } });
       }}
       data-testid="json-export-button"
       aria-label={t("buttons.export")}

+ 31 - 1
src/constants.ts

@@ -80,6 +80,7 @@ export enum EVENT {
   EXCALIDRAW_LINK = "excalidraw-link",
   MENU_ITEM_SELECT = "menu.itemSelect",
   MESSAGE = "message",
+  FULLSCREENCHANGE = "fullscreenchange",
 }
 
 export const YOUTUBE_STATES = {
@@ -344,4 +345,33 @@ export const DEFAULT_SIDEBAR = {
   defaultTab: LIBRARY_SIDEBAR_TAB,
 } as const;
 
-export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);
+export const LIBRARY_DISABLED_TYPES = new Set([
+  "iframe",
+  "embeddable",
+  "image",
+] as const);
+
+// use these constants to easily identify reference sites
+export const TOOL_TYPE = {
+  selection: "selection",
+  rectangle: "rectangle",
+  diamond: "diamond",
+  ellipse: "ellipse",
+  arrow: "arrow",
+  line: "line",
+  freedraw: "freedraw",
+  text: "text",
+  image: "image",
+  eraser: "eraser",
+  hand: "hand",
+  frame: "frame",
+  magicframe: "magicframe",
+  embeddable: "embeddable",
+  laser: "laser",
+} as const;
+
+export const EDITOR_LS_KEYS = {
+  OAI_API_KEY: "excalidraw-oai-api-key",
+  // legacy naming (non)scheme
+  PUBLISH_LIBRARY: "publish-library-data",
+} as const;

+ 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(),
     };
   }, []);

+ 22 - 0
src/css/styles.scss

@@ -5,9 +5,11 @@
   --zIndex-canvas: 1;
   --zIndex-interactiveCanvas: 2;
   --zIndex-wysiwyg: 3;
+  --zIndex-canvasButtons: 3;
   --zIndex-layerUI: 4;
   --zIndex-eyeDropperBackdrop: 5;
   --zIndex-eyeDropperPreview: 6;
+  --zIndex-hyperlinkContainer: 7;
 
   --zIndex-modal: 1000;
   --zIndex-popup: 1001;
@@ -37,6 +39,7 @@
 
   button {
     cursor: pointer;
+    user-select: none;
   }
 
   &:focus {
@@ -531,6 +534,12 @@
     }
   }
 
+  input.is-redacted {
+    // we don't use type=password because browsers (chrome?) prompt
+    // you to save it which is annoying
+    -webkit-text-security: disc;
+  }
+
   input[type="text"],
   textarea:not(.excalidraw-wysiwyg) {
     color: var(--text-primary-color);
@@ -643,6 +652,19 @@
       --button-bg: var(--color-surface-high);
     }
   }
+
+  .excalidraw__paragraph {
+    margin: 1rem 0;
+  }
+
+  .Modal__content {
+    .excalidraw__paragraph:first-child {
+      margin-top: 0;
+    }
+    .excalidraw__paragraph + .excalidraw__paragraph {
+      margin-top: 0rem;
+    }
+  }
 }
 
 .ErrorSplash.excalidraw {

+ 51 - 0
src/data/EditorLocalStorage.ts

@@ -0,0 +1,51 @@
+import { EDITOR_LS_KEYS } from "../constants";
+import { JSONValue } from "../types";
+
+export class EditorLocalStorage {
+  static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
+    try {
+      return !!window.localStorage.getItem(key);
+    } catch (error: any) {
+      console.warn(`localStorage.getItem error: ${error.message}`);
+      return false;
+    }
+  }
+
+  static get<T extends JSONValue>(
+    key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
+  ) {
+    try {
+      const value = window.localStorage.getItem(key);
+      if (value) {
+        return JSON.parse(value) as T;
+      }
+      return null;
+    } catch (error: any) {
+      console.warn(`localStorage.getItem error: ${error.message}`);
+      return null;
+    }
+  }
+
+  static set = (
+    key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
+    value: JSONValue,
+  ) => {
+    try {
+      window.localStorage.setItem(key, JSON.stringify(value));
+      return true;
+    } catch (error: any) {
+      console.warn(`localStorage.setItem error: ${error.message}`);
+      return false;
+    }
+  };
+
+  static delete = (
+    name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
+  ) => {
+    try {
+      window.localStorage.removeItem(name);
+    } catch (error: any) {
+      console.warn(`localStorage.removeItem error: ${error.message}`);
+    }
+  };
+}

+ 300 - 0
src/data/ai/types.ts

@@ -0,0 +1,300 @@
+export namespace OpenAIInput {
+  type ChatCompletionContentPart =
+    | ChatCompletionContentPartText
+    | ChatCompletionContentPartImage;
+
+  interface ChatCompletionContentPartImage {
+    image_url: ChatCompletionContentPartImage.ImageURL;
+
+    /**
+     * The type of the content part.
+     */
+    type: "image_url";
+  }
+
+  namespace ChatCompletionContentPartImage {
+    export interface ImageURL {
+      /**
+       * Either a URL of the image or the base64 encoded image data.
+       */
+      url: string;
+
+      /**
+       * Specifies the detail level of the image.
+       */
+      detail?: "auto" | "low" | "high";
+    }
+  }
+
+  interface ChatCompletionContentPartText {
+    /**
+     * The text content.
+     */
+    text: string;
+
+    /**
+     * The type of the content part.
+     */
+    type: "text";
+  }
+
+  interface ChatCompletionUserMessageParam {
+    /**
+     * The contents of the user message.
+     */
+    content: string | Array<ChatCompletionContentPart> | null;
+
+    /**
+     * The role of the messages author, in this case `user`.
+     */
+    role: "user";
+  }
+
+  interface ChatCompletionSystemMessageParam {
+    /**
+     * The contents of the system message.
+     */
+    content: string | null;
+
+    /**
+     * The role of the messages author, in this case `system`.
+     */
+    role: "system";
+  }
+
+  export interface ChatCompletionCreateParamsBase {
+    /**
+     * A list of messages comprising the conversation so far.
+     * [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
+     */
+    messages: Array<
+      ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
+    >;
+
+    /**
+     * ID of the model to use. See the
+     * [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
+     * table for details on which models work with the Chat API.
+     */
+    model:
+      | (string & {})
+      | "gpt-4-1106-preview"
+      | "gpt-4-vision-preview"
+      | "gpt-4"
+      | "gpt-4-0314"
+      | "gpt-4-0613"
+      | "gpt-4-32k"
+      | "gpt-4-32k-0314"
+      | "gpt-4-32k-0613"
+      | "gpt-3.5-turbo"
+      | "gpt-3.5-turbo-16k"
+      | "gpt-3.5-turbo-0301"
+      | "gpt-3.5-turbo-0613"
+      | "gpt-3.5-turbo-16k-0613";
+
+    /**
+     * Number between -2.0 and 2.0. Positive values penalize new tokens based on their
+     * existing frequency in the text so far, decreasing the model's likelihood to
+     * repeat the same line verbatim.
+     *
+     * [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
+     */
+    frequency_penalty?: number | null;
+
+    /**
+     * Modify the likelihood of specified tokens appearing in the completion.
+     *
+     * Accepts a JSON object that maps tokens (specified by their token ID in the
+     * tokenizer) to an associated bias value from -100 to 100. Mathematically, the
+     * bias is added to the logits generated by the model prior to sampling. The exact
+     * effect will vary per model, but values between -1 and 1 should decrease or
+     * increase likelihood of selection; values like -100 or 100 should result in a ban
+     * or exclusive selection of the relevant token.
+     */
+    logit_bias?: Record<string, number> | null;
+
+    /**
+     * The maximum number of [tokens](/tokenizer) to generate in the chat completion.
+     *
+     * The total length of input tokens and generated tokens is limited by the model's
+     * context length.
+     * [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
+     * for counting tokens.
+     */
+    max_tokens?: number | null;
+
+    /**
+     * How many chat completion choices to generate for each input message.
+     */
+    n?: number | null;
+
+    /**
+     * Number between -2.0 and 2.0. Positive values penalize new tokens based on
+     * whether they appear in the text so far, increasing the model's likelihood to
+     * talk about new topics.
+     *
+     * [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
+     */
+    presence_penalty?: number | null;
+
+    /**
+     * This feature is in Beta. If specified, our system will make a best effort to
+     * sample deterministically, such that repeated requests with the same `seed` and
+     * parameters should return the same result. Determinism is not guaranteed, and you
+     * should refer to the `system_fingerprint` response parameter to monitor changes
+     * in the backend.
+     */
+    seed?: number | null;
+
+    /**
+     * Up to 4 sequences where the API will stop generating further tokens.
+     */
+    stop?: string | null | Array<string>;
+
+    /**
+     * If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
+     * sent as data-only
+     * [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
+     * as they become available, with the stream terminated by a `data: [DONE]`
+     * message.
+     * [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
+     */
+    stream?: boolean | null;
+
+    /**
+     * What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
+     * make the output more random, while lower values like 0.2 will make it more
+     * focused and deterministic.
+     *
+     * We generally recommend altering this or `top_p` but not both.
+     */
+    temperature?: number | null;
+
+    /**
+     * An alternative to sampling with temperature, called nucleus sampling, where the
+     * model considers the results of the tokens with top_p probability mass. So 0.1
+     * means only the tokens comprising the top 10% probability mass are considered.
+     *
+     * We generally recommend altering this or `temperature` but not both.
+     */
+    top_p?: number | null;
+
+    /**
+     * A unique identifier representing your end-user, which can help OpenAI to monitor
+     * and detect abuse.
+     * [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
+     */
+    user?: string;
+  }
+}
+
+export namespace OpenAIOutput {
+  export interface ChatCompletion {
+    /**
+     * A unique identifier for the chat completion.
+     */
+    id: string;
+
+    /**
+     * A list of chat completion choices. Can be more than one if `n` is greater
+     * than 1.
+     */
+    choices: Array<Choice>;
+
+    /**
+     * The Unix timestamp (in seconds) of when the chat completion was created.
+     */
+    created: number;
+
+    /**
+     * The model used for the chat completion.
+     */
+    model: string;
+
+    /**
+     * The object type, which is always `chat.completion`.
+     */
+    object: "chat.completion";
+
+    /**
+     * This fingerprint represents the backend configuration that the model runs with.
+     *
+     * Can be used in conjunction with the `seed` request parameter to understand when
+     * backend changes have been made that might impact determinism.
+     */
+    system_fingerprint?: string;
+
+    /**
+     * Usage statistics for the completion request.
+     */
+    usage?: CompletionUsage;
+  }
+  export interface Choice {
+    /**
+     * The reason the model stopped generating tokens. This will be `stop` if the model
+     * hit a natural stop point or a provided stop sequence, `length` if the maximum
+     * number of tokens specified in the request was reached, `content_filter` if
+     * content was omitted due to a flag from our content filters, `tool_calls` if the
+     * model called a tool, or `function_call` (deprecated) if the model called a
+     * function.
+     */
+    finish_reason:
+      | "stop"
+      | "length"
+      | "tool_calls"
+      | "content_filter"
+      | "function_call";
+
+    /**
+     * The index of the choice in the list of choices.
+     */
+    index: number;
+
+    /**
+     * A chat completion message generated by the model.
+     */
+    message: ChatCompletionMessage;
+  }
+
+  interface ChatCompletionMessage {
+    /**
+     * The contents of the message.
+     */
+    content: string | null;
+
+    /**
+     * The role of the author of this message.
+     */
+    role: "assistant";
+  }
+
+  /**
+   * Usage statistics for the completion request.
+   */
+  interface CompletionUsage {
+    /**
+     * Number of tokens in the generated completion.
+     */
+    completion_tokens: number;
+
+    /**
+     * Number of tokens in the prompt.
+     */
+    prompt_tokens: number;
+
+    /**
+     * Total number of tokens used in the request (prompt + completion).
+     */
+    total_tokens: number;
+  }
+
+  export interface APIError {
+    readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
+    readonly headers: Headers | undefined;
+    readonly error: { message: string } | undefined;
+
+    readonly code: string | null | undefined;
+    readonly param: string | null | undefined;
+    readonly type: string | undefined;
+  }
+}

+ 9 - 5
src/data/index.ts

@@ -3,10 +3,11 @@ import {
   copyTextToSystemClipboard,
 } from "../clipboard";
 import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
-import { getNonDeletedElements, isFrameElement } from "../element";
+import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
 import {
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
 import { t } from "../i18n";
@@ -38,7 +39,7 @@ export const prepareElementsForExport = (
     exportSelectionOnly &&
     isSomeElementSelected(elements, { selectedElementIds });
 
-  let exportingFrame: ExcalidrawFrameElement | null = null;
+  let exportingFrame: ExcalidrawFrameLikeElement | null = null;
   let exportedElements = isExportingSelection
     ? getSelectedElements(
         elements,
@@ -50,7 +51,10 @@ export const prepareElementsForExport = (
     : elements;
 
   if (isExportingSelection) {
-    if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
+    if (
+      exportedElements.length === 1 &&
+      isFrameLikeElement(exportedElements[0])
+    ) {
       exportingFrame = exportedElements[0];
       exportedElements = elementsOverlappingBBox({
         elements,
@@ -93,7 +97,7 @@ export const exportCanvas = async (
     viewBackgroundColor: string;
     name: string;
     fileHandle?: FileSystemHandle | null;
-    exportingFrame: ExcalidrawFrameElement | null;
+    exportingFrame: ExcalidrawFrameLikeElement | null;
   },
 ) => {
   if (elements.length === 0) {

+ 104 - 0
src/data/magic.ts

@@ -0,0 +1,104 @@
+import { Theme } from "../element/types";
+import { DataURL } from "../types";
+import { OpenAIInput, OpenAIOutput } from "./ai/types";
+
+export type MagicCacheData =
+  | {
+      status: "pending";
+    }
+  | { status: "done"; html: string }
+  | {
+      status: "error";
+      message?: string;
+      code: "ERR_GENERATION_INTERRUPTED" | string;
+    };
+
+const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
+Your role is to transform low-fidelity wireframes into working front-end HTML code.
+
+YOU MUST FOLLOW FOLLOWING RULES:
+
+- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
+- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
+- Inline JavaScript when needed
+- Fetch dependencies from CDNs when needed (using unpkg or skypack)
+- Source images from Unsplash or create applicable placeholders
+- Interpret annotations as intended vs literal UI
+- Fill gaps using your expertise in UX and business logic
+- generate primarily for desktop UI, but make it responsive.
+- Use grid and flexbox wherever applicable.
+- Convert the wireframe in its entirety, don't omit elements if possible.
+
+If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
+
+Your goal is a production-ready prototype that brings the wireframes to life.
+
+Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
+
+export async function diagramToHTML({
+  image,
+  apiKey,
+  text,
+  theme = "light",
+}: {
+  image: DataURL;
+  apiKey: string;
+  text: string;
+  theme?: Theme;
+}) {
+  const body: OpenAIInput.ChatCompletionCreateParamsBase = {
+    model: "gpt-4-vision-preview",
+    // 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
+    max_tokens: 4096,
+    temperature: 0.1,
+    messages: [
+      {
+        role: "system",
+        content: SYSTEM_PROMPT,
+      },
+      {
+        role: "user",
+        content: [
+          {
+            type: "image_url",
+            image_url: {
+              url: image,
+              detail: "high",
+            },
+          },
+          {
+            type: "text",
+            text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
+          },
+          {
+            type: "text",
+            text,
+          },
+        ],
+      },
+    ],
+  };
+
+  let result:
+    | ({ ok: true } & OpenAIOutput.ChatCompletion)
+    | ({ ok: false } & OpenAIOutput.APIError);
+
+  const resp = await fetch("https://api.openai.com/v1/chat/completions", {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${apiKey}`,
+    },
+    body: JSON.stringify(body),
+  });
+
+  if (resp.ok) {
+    const json: OpenAIOutput.ChatCompletion = await resp.json();
+    result = { ...json, ok: true };
+  } else {
+    const json: OpenAIOutput.APIError = await resp.json();
+    result = { ...json, ok: false };
+  }
+
+  return result;
+}

+ 9 - 6
src/data/restore.ts

@@ -1,5 +1,6 @@
 import {
   ExcalidrawElement,
+  ExcalidrawElementType,
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
   FontFamilyValues,
@@ -68,6 +69,7 @@ export const AllowedExcalidrawActiveTools: Record<
   embeddable: true,
   hand: true,
   laser: false,
+  magicframe: false,
 };
 
 export type RestoredDataState = {
@@ -112,7 +114,7 @@ const restoreElementWithProperties = <
     // @ts-ignore TS complains here but type checks the call sites fine.
     keyof K
   > &
-    Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
+    Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
 ): T => {
   const base: Pick<T, keyof ExcalidrawElement> & {
     [PRECEDING_ELEMENT_KEY]?: string;
@@ -163,8 +165,9 @@ const restoreElementWithProperties = <
   if ("subtype" in element) {
     base.subtype = element.subtype;
   }
-  if ("customData" in element) {
-    base.customData = element.customData;
+  if ("customData" in element || "customData" in extra) {
+    base.customData =
+      "customData" in extra ? extra.customData : element.customData;
   }
 
   if (PRECEDING_ELEMENT_KEY in element) {
@@ -273,7 +276,7 @@ const restoreElement = (
 
       return restoreElementWithProperties(element, {
         type:
-          (element.type as ExcalidrawElement["type"] | "draw") === "draw"
+          (element.type as ExcalidrawElementType | "draw") === "draw"
             ? "line"
             : element.type,
         startBinding: repairBinding(element.startBinding),
@@ -289,15 +292,15 @@ const restoreElement = (
 
     // generic elements
     case "ellipse":
-      return restoreElementWithProperties(element, {});
     case "rectangle":
-      return restoreElementWithProperties(element, {});
     case "diamond":
+    case "iframe":
       return restoreElementWithProperties(element, {});
     case "embeddable":
       return restoreElementWithProperties(element, {
         validated: null,
       });
+    case "magicframe":
     case "frame":
       return restoreElementWithProperties(element, {
         name: element.name ?? null,

+ 44 - 8
src/data/transform.ts

@@ -15,6 +15,7 @@ import {
   ElementConstructorOpts,
   newFrameElement,
   newImageElement,
+  newMagicFrameElement,
   newTextElement,
 } from "../element/newElement";
 import {
@@ -26,12 +27,13 @@ import {
   ExcalidrawArrowElement,
   ExcalidrawBindableElement,
   ExcalidrawElement,
-  ExcalidrawEmbeddableElement,
   ExcalidrawFrameElement,
   ExcalidrawFreeDrawElement,
   ExcalidrawGenericElement,
+  ExcalidrawIframeLikeElement,
   ExcalidrawImageElement,
   ExcalidrawLinearElement,
+  ExcalidrawMagicFrameElement,
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
   FileId,
@@ -61,7 +63,12 @@ export type ValidLinearElement = {
             | {
                 type: Exclude<
                   ExcalidrawBindableElement["type"],
-                  "image" | "text" | "frame" | "embeddable"
+                  | "image"
+                  | "text"
+                  | "frame"
+                  | "magicframe"
+                  | "embeddable"
+                  | "iframe"
                 >;
                 id?: ExcalidrawGenericElement["id"];
               }
@@ -69,7 +76,12 @@ export type ValidLinearElement = {
                 id: ExcalidrawGenericElement["id"];
                 type?: Exclude<
                   ExcalidrawBindableElement["type"],
-                  "image" | "text" | "frame" | "embeddable"
+                  | "image"
+                  | "text"
+                  | "frame"
+                  | "magicframe"
+                  | "embeddable"
+                  | "iframe"
                 >;
               }
           )
@@ -93,7 +105,12 @@ export type ValidLinearElement = {
             | {
                 type: Exclude<
                   ExcalidrawBindableElement["type"],
-                  "image" | "text" | "frame" | "embeddable"
+                  | "image"
+                  | "text"
+                  | "frame"
+                  | "magicframe"
+                  | "embeddable"
+                  | "iframe"
                 >;
                 id?: ExcalidrawGenericElement["id"];
               }
@@ -101,7 +118,12 @@ export type ValidLinearElement = {
                 id: ExcalidrawGenericElement["id"];
                 type?: Exclude<
                   ExcalidrawBindableElement["type"],
-                  "image" | "text" | "frame" | "embeddable"
+                  | "image"
+                  | "text"
+                  | "frame"
+                  | "magicframe"
+                  | "embeddable"
+                  | "iframe"
                 >;
               }
           )
@@ -137,7 +159,7 @@ export type ValidContainer =
 export type ExcalidrawElementSkeleton =
   | Extract<
       Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
-      ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
+      ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
     >
   | ({
       type: Extract<ExcalidrawLinearElement["type"], "line">;
@@ -163,7 +185,12 @@ export type ExcalidrawElementSkeleton =
       type: "frame";
       children: readonly ExcalidrawElement["id"][];
       name?: string;
-    } & Partial<ExcalidrawFrameElement>);
+    } & Partial<ExcalidrawFrameElement>)
+  | ({
+      type: "magicframe";
+      children: readonly ExcalidrawElement["id"][];
+      name?: string;
+    } & Partial<ExcalidrawMagicFrameElement>);
 
 const DEFAULT_LINEAR_ELEMENT_PROPS = {
   width: 100,
@@ -547,7 +574,16 @@ export const convertToExcalidrawElements = (
         });
         break;
       }
+      case "magicframe": {
+        excalidrawElement = newMagicFrameElement({
+          x: 0,
+          y: 0,
+          ...element,
+        });
+        break;
+      }
       case "freedraw":
+      case "iframe":
       case "embeddable": {
         excalidrawElement = element;
         break;
@@ -656,7 +692,7 @@ export const convertToExcalidrawElements = (
   // need to calculate coordinates and dimensions of frame which is possibe after all
   // frame children are processed.
   for (const [id, element] of elementsWithIds) {
-    if (element.type !== "frame") {
+    if (element.type !== "frame" && element.type !== "magicframe") {
       continue;
     }
     const frame = elementStore.getElement(id);

+ 14 - 0
src/element/ElementCanvasButtons.scss

@@ -0,0 +1,14 @@
+.excalidraw {
+  .excalidraw-canvas-buttons {
+    position: absolute;
+
+    box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
+    z-index: var(--zIndex-canvasButtons);
+    background: var(--island-bg-color);
+    border-radius: var(--border-radius-lg);
+
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+  }
+}

+ 60 - 0
src/element/ElementCanvasButtons.tsx

@@ -0,0 +1,60 @@
+import { AppState } from "../types";
+import { sceneCoordsToViewportCoords } from "../utils";
+import { NonDeletedExcalidrawElement } from "./types";
+import { getElementAbsoluteCoords } from ".";
+import { useExcalidrawAppState } from "../components/App";
+
+import "./ElementCanvasButtons.scss";
+
+const CONTAINER_PADDING = 5;
+
+const getContainerCoords = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+) => {
+  const [x1, y1] = getElementAbsoluteCoords(element);
+  const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
+    { sceneX: x1 + element.width, sceneY: y1 },
+    appState,
+  );
+  const x = viewportX - appState.offsetLeft + 10;
+  const y = viewportY - appState.offsetTop;
+  return { x, y };
+};
+
+export const ElementCanvasButtons = ({
+  children,
+  element,
+}: {
+  children: React.ReactNode;
+  element: NonDeletedExcalidrawElement;
+}) => {
+  const appState = useExcalidrawAppState();
+
+  if (
+    appState.contextMenu ||
+    appState.draggingElement ||
+    appState.resizingElement ||
+    appState.isRotating ||
+    appState.openMenu ||
+    appState.viewModeEnabled
+  ) {
+    return null;
+  }
+
+  const { x, y } = getContainerCoords(element, appState);
+
+  return (
+    <div
+      className="excalidraw-canvas-buttons"
+      style={{
+        top: `${y}px`,
+        left: `${x}px`,
+        // width: CONTAINER_WIDTH,
+        padding: CONTAINER_PADDING,
+      }}
+    >
+      {children}
+    </div>
+  );
+};

+ 1 - 1
src/element/Hyperlink.scss

@@ -6,7 +6,7 @@
   justify-content: space-between;
   position: absolute;
   box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
-  z-index: 100;
+  z-index: var(--zIndex-hyperlinkContainer);
   background: var(--island-bg-color);
   border-radius: var(--border-radius-md);
   box-sizing: border-box;

+ 2 - 1
src/element/Hyperlink.tsx

@@ -121,7 +121,7 @@ export const Hyperlink = ({
           setToast({ message: embedLink.warning, closable: true });
         }
         const ar = embedLink
-          ? embedLink.aspectRatio.w / embedLink.aspectRatio.h
+          ? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
           : 1;
         const hasLinkChanged =
           embeddableLinkCache.get(element.id) !== element.link;
@@ -210,6 +210,7 @@ export const Hyperlink = ({
   };
   const { x, y } = getCoordsForPopover(element, appState);
   if (
+    appState.contextMenu ||
     appState.draggingElement ||
     appState.resizingElement ||
     appState.isRotating ||

+ 20 - 5
src/element/bounds.ts

@@ -13,6 +13,7 @@ import { Point } from "../types";
 import { generateRoughOptions } from "../scene/Shape";
 import {
   isArrowElement,
+  isBoundToContainer,
   isFreeDrawElement,
   isLinearElement,
   isTextElement,
@@ -22,6 +23,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
+import Scene from "../scene/Scene";
 
 export type RectangleBox = {
   x: number;
@@ -53,16 +55,29 @@ export class ElementBounds {
   static getBounds(element: ExcalidrawElement) {
     const cachedBounds = ElementBounds.boundsCache.get(element);
 
-    if (cachedBounds?.version && cachedBounds.version === element.version) {
+    if (
+      cachedBounds?.version &&
+      cachedBounds.version === element.version &&
+      // we don't invalidate cache when we update containers and not labels,
+      // which is causing problems down the line. Fix TBA.
+      !isBoundToContainer(element)
+    ) {
       return cachedBounds.bounds;
     }
 
     const bounds = ElementBounds.calculateBounds(element);
 
-    ElementBounds.boundsCache.set(element, {
-      version: element.version,
-      bounds,
-    });
+    // hack to ensure that downstream checks could retrieve element Scene
+    // so as to have correctly calculated bounds
+    // FIXME remove when we get rid of all the id:Scene / element:Scene mapping
+    const shouldCache = Scene.getScene(element);
+
+    if (shouldCache) {
+      ElementBounds.boundsCache.set(element, {
+        version: element.version,
+        bounds,
+      });
+    }
 
     return bounds;
   }

+ 25 - 12
src/element/collision.ts

@@ -18,7 +18,6 @@ import {
   ExcalidrawBindableElement,
   ExcalidrawElement,
   ExcalidrawRectangleElement,
-  ExcalidrawEmbeddableElement,
   ExcalidrawDiamondElement,
   ExcalidrawTextElement,
   ExcalidrawEllipseElement,
@@ -27,7 +26,8 @@ import {
   ExcalidrawImageElement,
   ExcalidrawLinearElement,
   StrokeRoundness,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
+  ExcalidrawIframeLikeElement,
 } from "./types";
 
 import {
@@ -41,7 +41,8 @@ import { Drawable } from "roughjs/bin/core";
 import { AppState } from "../types";
 import {
   hasBoundTextElement,
-  isEmbeddableElement,
+  isFrameLikeElement,
+  isIframeLikeElement,
   isImageElement,
 } from "./typeChecks";
 import { isTextElement } from ".";
@@ -64,7 +65,7 @@ const isElementDraggableFromInside = (
   const isDraggableFromInside =
     !isTransparent(element.backgroundColor) ||
     hasBoundTextElement(element) ||
-    isEmbeddableElement(element);
+    isIframeLikeElement(element);
   if (element.type === "line") {
     return isDraggableFromInside && isPathALoop(element.points);
   }
@@ -186,7 +187,7 @@ export const isPointHittingElementBoundingBox = (
   // by its frame, whether it has been selected or not
   // this logic here is not ideal
   // TODO: refactor it later...
-  if (element.type === "frame") {
+  if (isFrameLikeElement(element)) {
     return hitTestPointAgainstElement({
       element,
       point: [x, y],
@@ -255,6 +256,7 @@ type HitTestArgs = {
 const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
   switch (args.element.type) {
     case "rectangle":
+    case "iframe":
     case "embeddable":
     case "image":
     case "text":
@@ -282,7 +284,8 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
         "This should not happen, we need to investigate why it does.",
       );
       return false;
-    case "frame": {
+    case "frame":
+    case "magicframe": {
       // check distance to frame element first
       if (
         args.check(
@@ -314,8 +317,10 @@ export const distanceToBindableElement = (
     case "rectangle":
     case "image":
     case "text":
+    case "iframe":
     case "embeddable":
     case "frame":
+    case "magicframe":
       return distanceToRectangle(element, point);
     case "diamond":
       return distanceToDiamond(element, point);
@@ -346,8 +351,8 @@ const distanceToRectangle = (
     | ExcalidrawTextElement
     | ExcalidrawFreeDrawElement
     | ExcalidrawImageElement
-    | ExcalidrawEmbeddableElement
-    | ExcalidrawFrameElement,
+    | ExcalidrawIframeLikeElement
+    | ExcalidrawFrameLikeElement,
   point: Point,
 ): number => {
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -662,8 +667,10 @@ export const determineFocusDistance = (
     case "rectangle":
     case "image":
     case "text":
+    case "iframe":
     case "embeddable":
     case "frame":
+    case "magicframe":
       ret = c / (hwidth * (nabs + q * mabs));
       break;
     case "diamond":
@@ -700,8 +707,10 @@ export const determineFocusPoint = (
     case "image":
     case "text":
     case "diamond":
+    case "iframe":
     case "embeddable":
     case "frame":
+    case "magicframe":
       point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
       break;
     case "ellipse":
@@ -752,8 +761,10 @@ const getSortedElementLineIntersections = (
     case "image":
     case "text":
     case "diamond":
+    case "iframe":
     case "embeddable":
     case "frame":
+    case "magicframe":
       const corners = getCorners(element);
       intersections = corners
         .flatMap((point, i) => {
@@ -788,8 +799,8 @@ const getCorners = (
     | ExcalidrawImageElement
     | ExcalidrawDiamondElement
     | ExcalidrawTextElement
-    | ExcalidrawEmbeddableElement
-    | ExcalidrawFrameElement,
+    | ExcalidrawIframeLikeElement
+    | ExcalidrawFrameLikeElement,
   scale: number = 1,
 ): GA.Point[] => {
   const hx = (scale * element.width) / 2;
@@ -798,8 +809,10 @@ const getCorners = (
     case "rectangle":
     case "image":
     case "text":
+    case "iframe":
     case "embeddable":
     case "frame":
+    case "magicframe":
       return [
         GA.point(hx, hy),
         GA.point(hx, -hy),
@@ -948,8 +961,8 @@ export const findFocusPointForRectangulars = (
     | ExcalidrawImageElement
     | ExcalidrawDiamondElement
     | ExcalidrawTextElement
-    | ExcalidrawEmbeddableElement
-    | ExcalidrawFrameElement,
+    | ExcalidrawIframeLikeElement
+    | ExcalidrawFrameLikeElement,
   // Between -1 and 1 for how far away should the focus point be relative
   // to the size of the element. Sign determines orientation.
   relativeDistance: number,

+ 2 - 2
src/element/dragElements.ts

@@ -11,7 +11,7 @@ import Scene from "../scene/Scene";
 import {
   isArrowElement,
   isBoundToContainer,
-  isFrameElement,
+  isFrameLikeElement,
 } from "./typeChecks";
 
 export const dragSelectedElements = (
@@ -33,7 +33,7 @@ export const dragSelectedElements = (
     selectedElements,
   );
   const frames = selectedElements
-    .filter((e) => isFrameElement(e))
+    .filter((e) => isFrameLikeElement(e))
     .map((f) => f.id);
 
   if (frames.length > 0) {

+ 56 - 38
src/element/embeddable.ts

@@ -6,25 +6,19 @@ import { getFontString, updateActiveTool } from "../utils";
 import { setCursorForShape } from "../cursor";
 import { newTextElement } from "./newElement";
 import { getContainerElement, wrapText } from "./textElement";
-import { isEmbeddableElement } from "./typeChecks";
+import {
+  isFrameLikeElement,
+  isIframeElement,
+  isIframeLikeElement,
+} from "./typeChecks";
 import {
   ExcalidrawElement,
-  ExcalidrawEmbeddableElement,
+  ExcalidrawIframeLikeElement,
+  IframeData,
   NonDeletedExcalidrawElement,
-  Theme,
 } from "./types";
 
-type EmbeddedLink =
-  | ({
-      aspectRatio: { w: number; h: number };
-      warning?: string;
-    } & (
-      | { type: "video" | "generic"; link: string }
-      | { type: "document"; srcdoc: (theme: Theme) => string }
-    ))
-  | null;
-
-const embeddedLinkCache = new Map<string, EmbeddedLink>();
+const embeddedLinkCache = new Map<string, IframeData>();
 
 const RE_YOUTUBE =
   /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
@@ -67,11 +61,13 @@ const ALLOWED_DOMAINS = new Set([
   "dddice.com",
 ]);
 
-const createSrcDoc = (body: string) => {
+export const createSrcDoc = (body: string) => {
   return `<html><body>${body}</body></html>`;
 };
 
-export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
+export const getEmbedLink = (
+  link: string | null | undefined,
+): IframeData | null => {
   if (!link) {
     return null;
   }
@@ -104,8 +100,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
         break;
     }
     aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
-    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
-    return { link, aspectRatio, type };
+    embeddedLinkCache.set(originalLink, {
+      link,
+      intrinsicSize: aspectRatio,
+      type,
+    });
+    return { link, intrinsicSize: aspectRatio, type };
   }
 
   const vimeoLink = link.match(RE_VIMEO);
@@ -119,8 +119,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
     aspectRatio = { w: 560, h: 315 };
     //warning deliberately ommited so it is displayed only once per link
     //same link next time will be served from cache
-    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
-    return { link, aspectRatio, type, warning };
+    embeddedLinkCache.set(originalLink, {
+      link,
+      intrinsicSize: aspectRatio,
+      type,
+    });
+    return { link, intrinsicSize: aspectRatio, type, warning };
   }
 
   const figmaLink = link.match(RE_FIGMA);
@@ -130,27 +134,35 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
       link,
     )}`;
     aspectRatio = { w: 550, h: 550 };
-    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
-    return { link, aspectRatio, type };
+    embeddedLinkCache.set(originalLink, {
+      link,
+      intrinsicSize: aspectRatio,
+      type,
+    });
+    return { link, intrinsicSize: aspectRatio, type };
   }
 
   const valLink = link.match(RE_VALTOWN);
   if (valLink) {
     link =
       valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
-    embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
-    return { link, aspectRatio, type };
+    embeddedLinkCache.set(originalLink, {
+      link,
+      intrinsicSize: aspectRatio,
+      type,
+    });
+    return { link, intrinsicSize: aspectRatio, type };
   }
 
   if (RE_TWITTER.test(link)) {
-    let ret: EmbeddedLink;
+    let ret: IframeData;
     // assume embed code
     if (/<blockquote/.test(link)) {
       const srcDoc = createSrcDoc(link);
       ret = {
         type: "document",
         srcdoc: () => srcDoc,
-        aspectRatio: { w: 480, h: 480 },
+        intrinsicSize: { w: 480, h: 480 },
       };
       // assume regular tweet url
     } else {
@@ -160,7 +172,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
           createSrcDoc(
             `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
           ),
-        aspectRatio: { w: 480, h: 480 },
+        intrinsicSize: { w: 480, h: 480 },
       };
     }
     embeddedLinkCache.set(originalLink, ret);
@@ -168,14 +180,14 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
   }
 
   if (RE_GH_GIST.test(link)) {
-    let ret: EmbeddedLink;
+    let ret: IframeData;
     // assume embed code
     if (/<script>/.test(link)) {
       const srcDoc = createSrcDoc(link);
       ret = {
         type: "document",
         srcdoc: () => srcDoc,
-        aspectRatio: { w: 550, h: 720 },
+        intrinsicSize: { w: 550, h: 720 },
       };
       // assume regular url
     } else {
@@ -190,26 +202,26 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
             .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
           </style>
         `),
-        aspectRatio: { w: 550, h: 720 },
+        intrinsicSize: { w: 550, h: 720 },
       };
     }
     embeddedLinkCache.set(link, ret);
     return ret;
   }
 
-  embeddedLinkCache.set(link, { link, aspectRatio, type });
-  return { link, aspectRatio, type };
+  embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
+  return { link, intrinsicSize: aspectRatio, type };
 };
 
-export const isEmbeddableOrLabel = (
+export const isIframeLikeOrItsLabel = (
   element: NonDeletedExcalidrawElement,
 ): Boolean => {
-  if (isEmbeddableElement(element)) {
+  if (isIframeLikeElement(element)) {
     return true;
   }
   if (element.type === "text") {
     const container = getContainerElement(element);
-    if (container && isEmbeddableElement(container)) {
+    if (container && isFrameLikeElement(container)) {
       return true;
     }
   }
@@ -217,10 +229,16 @@ export const isEmbeddableOrLabel = (
 };
 
 export const createPlaceholderEmbeddableLabel = (
-  element: ExcalidrawEmbeddableElement,
+  element: ExcalidrawIframeLikeElement,
 ): ExcalidrawElement => {
-  const text =
-    !element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
+  let text: string;
+  if (isIframeElement(element)) {
+    text = "IFrame element";
+  } else {
+    text =
+      !element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
+  }
+
   const fontSize = Math.max(
     Math.min(element.width / 2, element.width / text.length),
     element.width / 30,

+ 4 - 16
src/element/index.ts

@@ -2,7 +2,6 @@ import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
-  ExcalidrawFrameElement,
 } from "./types";
 import { isInvisiblySmallElement } from "./sizeHelpers";
 import { isLinearElementType } from "./typeChecks";
@@ -50,11 +49,7 @@ export {
   getDragOffsetXY,
   dragNewElement,
 } from "./dragElements";
-export {
-  isTextElement,
-  isExcalidrawElement,
-  isFrameElement,
-} from "./typeChecks";
+export { isTextElement, isExcalidrawElement } from "./typeChecks";
 export { textWysiwyg } from "./textWysiwyg";
 export { redrawTextBoundingBox } from "./textElement";
 export {
@@ -74,17 +69,10 @@ export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
     (el) => !el.isDeleted && !isInvisiblySmallElement(el),
   ) as readonly NonDeletedExcalidrawElement[];
 
-export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
-  elements.filter(
-    (element) => !element.isDeleted,
-  ) as readonly NonDeletedExcalidrawElement[];
-
-export const getNonDeletedFrames = (
-  frames: readonly ExcalidrawFrameElement[],
+export const getNonDeletedElements = <T extends ExcalidrawElement>(
+  elements: readonly T[],
 ) =>
-  frames.filter(
-    (frame) => !frame.isDeleted,
-  ) as readonly NonDeleted<ExcalidrawFrameElement>[];
+  elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
 
 export const isNonDeletedElement = <T extends ExcalidrawElement>(
   element: T,

+ 29 - 0
src/element/newElement.ts

@@ -14,6 +14,8 @@ import {
   ExcalidrawTextContainer,
   ExcalidrawFrameElement,
   ExcalidrawEmbeddableElement,
+  ExcalidrawMagicFrameElement,
+  ExcalidrawIframeElement,
 } from "../element/types";
 import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
 import { randomInteger, randomId } from "../random";
@@ -169,6 +171,16 @@ export const newEmbeddableElement = (
   };
 };
 
+export const newIframeElement = (
+  opts: {
+    type: "iframe";
+  } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawIframeElement> => {
+  return {
+    ..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
+  };
+};
+
 export const newFrameElement = (
   opts: {
     name?: string;
@@ -186,6 +198,23 @@ export const newFrameElement = (
   return frameElement;
 };
 
+export const newMagicFrameElement = (
+  opts: {
+    name?: string;
+  } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawMagicFrameElement> => {
+  const frameElement = newElementWith(
+    {
+      ..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
+      type: "magicframe",
+      name: opts?.name || null,
+    },
+    {},
+  );
+
+  return frameElement;
+};
+
 /** computes element x/y offset based on textAlign/verticalAlign */
 const getTextElementPositionOffsets = (
   opts: {

+ 3 - 3
src/element/resizeElements.ts

@@ -27,7 +27,7 @@ import {
 import {
   isArrowElement,
   isBoundToContainer,
-  isFrameElement,
+  isFrameLikeElement,
   isFreeDrawElement,
   isImageElement,
   isLinearElement,
@@ -163,7 +163,7 @@ const rotateSingleElement = (
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
   let angle: number;
-  if (isFrameElement(element)) {
+  if (isFrameLikeElement(element)) {
     angle = 0;
   } else {
     angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
@@ -896,7 +896,7 @@ const rotateMultipleElements = (
   }
 
   elements
-    .filter((element) => element.type !== "frame")
+    .filter((element) => !isFrameLikeElement(element))
     .forEach((element) => {
       const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
       const cx = (x1 + x2) / 2;

+ 2 - 2
src/element/subtypes/index.ts

@@ -2,7 +2,7 @@ import { useEffect } from "react";
 import { ExcalidrawElement, ExcalidrawTextElement, NonDeleted } from "../types";
 import { getNonDeletedElements } from "../";
 import { getSelectedElements } from "../../scene";
-import { AppState, ExcalidrawImperativeAPI } from "../../types";
+import { AppState, ExcalidrawImperativeAPI, ToolType } from "../../types";
 import { registerAuxLangData } from "../../i18n";
 
 import {
@@ -44,7 +44,7 @@ let alwaysEnabledMap: readonly {
 
 export type SubtypeRecord = Readonly<{
   subtype: Subtype;
-  parents: readonly ExcalidrawElement["type"][];
+  parents: readonly (ExcalidrawElement["type"] & ToolType)[];
   actionNames?: readonly SubtypeActionName[];
   disabledNames?: readonly DisabledActionName[];
   shortcutMap?: Record<string, string[]>;

+ 2 - 1
src/element/textElement.ts

@@ -2,6 +2,7 @@ import { getSubtypeMethods, SubtypeMethods } from "./subtypes";
 import { getFontString, arrayToMap, isTestEnv } from "../utils";
 import {
   ExcalidrawElement,
+  ExcalidrawElementType,
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
   ExcalidrawTextElementWithContainer,
@@ -886,7 +887,7 @@ const VALID_CONTAINER_TYPES = new Set([
 ]);
 
 export const isValidTextContainer = (element: {
-  type: ExcalidrawElement["type"];
+  type: ExcalidrawElementType;
 }) => VALID_CONTAINER_TYPES.has(element.type);
 
 export const computeContainerDimensionForBoundText = (

+ 2 - 2
src/element/transformHandles.ts

@@ -8,7 +8,7 @@ import { Bounds, getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
 import { InteractiveCanvasAppState, Zoom } from "../types";
 import { isTextElement } from ".";
-import { isFrameElement, isLinearElement } from "./typeChecks";
+import { isFrameLikeElement, isLinearElement } from "./typeChecks";
 import { DEFAULT_SPACING } from "../renderer/renderScene";
 
 export type TransformHandleDirection =
@@ -257,7 +257,7 @@ export const getTransformHandles = (
     }
   } else if (isTextElement(element)) {
     omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
-  } else if (isFrameElement(element)) {
+  } else if (isFrameLikeElement(element)) {
     omitSides = {
       rotation: true,
     };

+ 44 - 20
src/element/typeChecks.ts

@@ -1,5 +1,5 @@
 import { ROUNDNESS } from "../constants";
-import { AppState } from "../types";
+import { ElementOrToolType } from "../types";
 import { MarkNonNullable } from "../utility-types";
 import { assertNever } from "../utils";
 import {
@@ -8,7 +8,6 @@ import {
   ExcalidrawEmbeddableElement,
   ExcalidrawLinearElement,
   ExcalidrawBindableElement,
-  ExcalidrawGenericElement,
   ExcalidrawFreeDrawElement,
   InitializedExcalidrawImageElement,
   ExcalidrawImageElement,
@@ -16,21 +15,13 @@ import {
   ExcalidrawTextContainer,
   ExcalidrawFrameElement,
   RoundnessType,
+  ExcalidrawFrameLikeElement,
+  ExcalidrawElementType,
+  ExcalidrawIframeElement,
+  ExcalidrawIframeLikeElement,
+  ExcalidrawMagicFrameElement,
 } from "./types";
 
-export const isGenericElement = (
-  element: ExcalidrawElement | null,
-): element is ExcalidrawGenericElement => {
-  return (
-    element != null &&
-    (element.type === "selection" ||
-      element.type === "rectangle" ||
-      element.type === "diamond" ||
-      element.type === "ellipse" ||
-      element.type === "embeddable")
-  );
-};
-
 export const isInitializedImageElement = (
   element: ExcalidrawElement | null,
 ): element is InitializedExcalidrawImageElement => {
@@ -49,6 +40,20 @@ export const isEmbeddableElement = (
   return !!element && element.type === "embeddable";
 };
 
+export const isIframeElement = (
+  element: ExcalidrawElement | null,
+): element is ExcalidrawIframeElement => {
+  return !!element && element.type === "iframe";
+};
+
+export const isIframeLikeElement = (
+  element: ExcalidrawElement | null,
+): element is ExcalidrawIframeLikeElement => {
+  return (
+    !!element && (element.type === "iframe" || element.type === "embeddable")
+  );
+};
+
 export const isTextElement = (
   element: ExcalidrawElement | null,
 ): element is ExcalidrawTextElement => {
@@ -61,6 +66,21 @@ export const isFrameElement = (
   return element != null && element.type === "frame";
 };
 
+export const isMagicFrameElement = (
+  element: ExcalidrawElement | null,
+): element is ExcalidrawMagicFrameElement => {
+  return element != null && element.type === "magicframe";
+};
+
+export const isFrameLikeElement = (
+  element: ExcalidrawElement | null,
+): element is ExcalidrawFrameLikeElement => {
+  return (
+    element != null &&
+    (element.type === "frame" || element.type === "magicframe")
+  );
+};
+
 export const isFreeDrawElement = (
   element?: ExcalidrawElement | null,
 ): element is ExcalidrawFreeDrawElement => {
@@ -68,7 +88,7 @@ export const isFreeDrawElement = (
 };
 
 export const isFreeDrawElementType = (
-  elementType: ExcalidrawElement["type"],
+  elementType: ExcalidrawElementType,
 ): boolean => {
   return elementType === "freedraw";
 };
@@ -86,7 +106,7 @@ export const isArrowElement = (
 };
 
 export const isLinearElementType = (
-  elementType: AppState["activeTool"]["type"],
+  elementType: ElementOrToolType,
 ): boolean => {
   return (
     elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@@ -105,7 +125,7 @@ export const isBindingElement = (
 };
 
 export const isBindingElementType = (
-  elementType: AppState["activeTool"]["type"],
+  elementType: ElementOrToolType,
 ): boolean => {
   return elementType === "arrow";
 };
@@ -121,8 +141,10 @@ export const isBindableElement = (
       element.type === "diamond" ||
       element.type === "ellipse" ||
       element.type === "image" ||
+      element.type === "iframe" ||
       element.type === "embeddable" ||
       element.type === "frame" ||
+      element.type === "magicframe" ||
       (element.type === "text" && !element.containerId))
   );
 };
@@ -144,7 +166,7 @@ export const isTextBindableContainer = (
 export const isExcalidrawElement = (
   element: any,
 ): element is ExcalidrawElement => {
-  const type: ExcalidrawElement["type"] | undefined = element?.type;
+  const type: ExcalidrawElementType | undefined = element?.type;
   if (!type) {
     return false;
   }
@@ -152,12 +174,14 @@ export const isExcalidrawElement = (
     case "text":
     case "diamond":
     case "rectangle":
+    case "iframe":
     case "embeddable":
     case "ellipse":
     case "arrow":
     case "freedraw":
     case "line":
     case "frame":
+    case "magicframe":
     case "image":
     case "selection": {
       return true;
@@ -190,7 +214,7 @@ export const isBoundToContainer = (
 };
 
 export const isUsingAdaptiveRadius = (type: string) =>
-  type === "rectangle" || type === "embeddable";
+  type === "rectangle" || type === "embeddable" || type === "iframe";
 
 export const isUsingProportionalRadius = (type: string) =>
   type === "line" || type === "arrow" || type === "diamond";

+ 37 - 1
src/element/types.ts

@@ -7,6 +7,7 @@ import {
   VERTICAL_ALIGN,
 } from "../constants";
 import { MarkNonNullable, ValueOf } from "../utility-types";
+import { MagicCacheData } from "../data/magic";
 
 export type ChartType = "bar" | "line";
 export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@@ -98,6 +99,26 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
     validated: boolean | null;
   }>;
 
+export type ExcalidrawIframeElement = _ExcalidrawElementBase &
+  Readonly<{
+    type: "iframe";
+    // TODO move later to AI-specific frame
+    customData?: { generationData?: MagicCacheData };
+  }>;
+
+export type ExcalidrawIframeLikeElement =
+  | ExcalidrawIframeElement
+  | ExcalidrawEmbeddableElement;
+
+export type IframeData =
+  | {
+      intrinsicSize: { w: number; h: number };
+      warning?: string;
+    } & (
+      | { type: "video" | "generic"; link: string }
+      | { type: "document"; srcdoc: (theme: Theme) => string }
+    );
+
 export type ExcalidrawImageElement = _ExcalidrawElementBase &
   Readonly<{
     type: "image";
@@ -118,6 +139,15 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
   name: string | null;
 };
 
+export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
+  type: "magicframe";
+  name: string | null;
+};
+
+export type ExcalidrawFrameLikeElement =
+  | ExcalidrawFrameElement
+  | ExcalidrawMagicFrameElement;
+
 /**
  * These are elements that don't have any additional properties.
  */
@@ -139,6 +169,8 @@ export type ExcalidrawElement =
   | ExcalidrawFreeDrawElement
   | ExcalidrawImageElement
   | ExcalidrawFrameElement
+  | ExcalidrawMagicFrameElement
+  | ExcalidrawIframeElement
   | ExcalidrawEmbeddableElement;
 
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
@@ -171,8 +203,10 @@ export type ExcalidrawBindableElement =
   | ExcalidrawEllipseElement
   | ExcalidrawTextElement
   | ExcalidrawImageElement
+  | ExcalidrawIframeElement
   | ExcalidrawEmbeddableElement
-  | ExcalidrawFrameElement;
+  | ExcalidrawFrameElement
+  | ExcalidrawMagicFrameElement;
 
 export type ExcalidrawTextContainer =
   | ExcalidrawRectangleElement
@@ -218,3 +252,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
   }>;
 
 export type FileId = string & { _brand: "FileId" };
+
+export type ExcalidrawElementType = ExcalidrawElement["type"];

+ 49 - 35
src/frame.ts

@@ -5,7 +5,7 @@ import {
 } from "./element";
 import {
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   NonDeleted,
   NonDeletedExcalidrawElement,
 } from "./element/types";
@@ -18,11 +18,11 @@ import { arrayToMap } from "./utils";
 import { mutateElement } from "./element/mutateElement";
 import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
 import { getElementsWithinSelection, getSelectedElements } from "./scene";
-import { isFrameElement } from "./element";
 import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
 import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
 import { getElementLineSegments } from "./element/bounds";
 import { doLineSegmentsIntersect } from "./packages/utils";
+import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
 
 // --------------------------- Frame State ------------------------------------
 export const bindElementsToFramesAfterDuplication = (
@@ -58,7 +58,7 @@ export const bindElementsToFramesAfterDuplication = (
 
 export function isElementIntersectingFrame(
   element: ExcalidrawElement,
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) {
   const frameLineSegments = getElementLineSegments(frame);
 
@@ -75,20 +75,20 @@ export function isElementIntersectingFrame(
 
 export const getElementsCompletelyInFrame = (
   elements: readonly ExcalidrawElement[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) =>
-  omitGroupsContainingFrames(
+  omitGroupsContainingFrameLikes(
     getElementsWithinSelection(elements, frame, false),
   ).filter(
     (element) =>
-      (element.type !== "frame" && !element.frameId) ||
+      (!isFrameLikeElement(element) && !element.frameId) ||
       element.frameId === frame.id,
   );
 
 export const isElementContainingFrame = (
   elements: readonly ExcalidrawElement[],
   element: ExcalidrawElement,
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
   return getElementsWithinSelection(elements, element).some(
     (e) => e.id === frame.id,
@@ -97,12 +97,12 @@ export const isElementContainingFrame = (
 
 export const getElementsIntersectingFrame = (
   elements: readonly ExcalidrawElement[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => elements.filter((element) => isElementIntersectingFrame(element, frame));
 
 export const elementsAreInFrameBounds = (
   elements: readonly ExcalidrawElement[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
   const [selectionX1, selectionY1, selectionX2, selectionY2] =
     getElementAbsoluteCoords(frame);
@@ -120,7 +120,7 @@ export const elementsAreInFrameBounds = (
 
 export const elementOverlapsWithFrame = (
   element: ExcalidrawElement,
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
   return (
     elementsAreInFrameBounds([element], frame) ||
@@ -134,7 +134,7 @@ export const isCursorInFrame = (
     x: number;
     y: number;
   },
-  frame: NonDeleted<ExcalidrawFrameElement>,
+  frame: NonDeleted<ExcalidrawFrameLikeElement>,
 ) => {
   const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
 
@@ -148,7 +148,7 @@ export const isCursorInFrame = (
 export const groupsAreAtLeastIntersectingTheFrame = (
   elements: readonly NonDeletedExcalidrawElement[],
   groupIds: readonly string[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
   const elementsInGroup = groupIds.flatMap((groupId) =>
     getElementsInGroup(elements, groupId),
@@ -168,7 +168,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
 export const groupsAreCompletelyOutOfFrame = (
   elements: readonly NonDeletedExcalidrawElement[],
   groupIds: readonly string[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
   const elementsInGroup = groupIds.flatMap((groupId) =>
     getElementsInGroup(elements, groupId),
@@ -192,14 +192,14 @@ export const groupsAreCompletelyOutOfFrame = (
 /**
  * Returns a map of frameId to frame elements. Includes empty frames.
  */
-export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
+export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
   const frameElementsMap = new Map<
     ExcalidrawElement["id"],
     ExcalidrawElement[]
   >();
 
   for (const element of elements) {
-    const frameId = isFrameElement(element) ? element.id : element.frameId;
+    const frameId = isFrameLikeElement(element) ? element.id : element.frameId;
     if (frameId && !frameElementsMap.has(frameId)) {
       frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
     }
@@ -213,12 +213,12 @@ export const getFrameChildren = (
   frameId: string,
 ) => allElements.filter((element) => element.frameId === frameId);
 
-export const getFrameElements = (
+export const getFrameLikeElements = (
   allElements: ExcalidrawElementsIncludingDeleted,
-): ExcalidrawFrameElement[] => {
-  return allElements.filter((element) =>
-    isFrameElement(element),
-  ) as ExcalidrawFrameElement[];
+): ExcalidrawFrameLikeElement[] => {
+  return allElements.filter((element): element is ExcalidrawFrameLikeElement =>
+    isFrameLikeElement(element),
+  );
 };
 
 /**
@@ -232,7 +232,7 @@ export const getFrameElements = (
 export const getRootElements = (
   allElements: ExcalidrawElementsIncludingDeleted,
 ) => {
-  const frameElements = arrayToMap(getFrameElements(allElements));
+  const frameElements = arrayToMap(getFrameLikeElements(allElements));
   return allElements.filter(
     (element) =>
       frameElements.has(element.id) ||
@@ -243,7 +243,7 @@ export const getRootElements = (
 
 export const getElementsInResizingFrame = (
   allElements: ExcalidrawElementsIncludingDeleted,
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
   appState: AppState,
 ): ExcalidrawElement[] => {
   const prevElementsInFrame = getFrameChildren(allElements, frame.id);
@@ -336,9 +336,9 @@ export const getElementsInResizingFrame = (
 
 export const getElementsInNewFrame = (
   allElements: ExcalidrawElementsIncludingDeleted,
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
-  return omitGroupsContainingFrames(
+  return omitGroupsContainingFrameLikes(
     allElements,
     getElementsCompletelyInFrame(allElements, frame),
   );
@@ -356,12 +356,12 @@ export const getContainingFrame = (
   if (element.frameId) {
     if (elementsMap) {
       return (elementsMap.get(element.frameId) ||
-        null) as null | ExcalidrawFrameElement;
+        null) as null | ExcalidrawFrameLikeElement;
     }
     return (
       (Scene.getScene(element)?.getElement(
         element.frameId,
-      ) as ExcalidrawFrameElement) || null
+      ) as ExcalidrawFrameLikeElement) || null
     );
   }
   return null;
@@ -377,7 +377,7 @@ export const getContainingFrame = (
 export const addElementsToFrame = (
   allElements: ExcalidrawElementsIncludingDeleted,
   elementsToAdd: NonDeletedExcalidrawElement[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
 ) => {
   const { currTargetFrameChildrenMap } = allElements.reduce(
     (acc, element, index) => {
@@ -397,7 +397,7 @@ export const addElementsToFrame = (
 
   // - add bound text elements if not already in the array
   // - filter out elements that are already in the frame
-  for (const element of omitGroupsContainingFrames(
+  for (const element of omitGroupsContainingFrameLikes(
     allElements,
     elementsToAdd,
   )) {
@@ -438,7 +438,7 @@ export const removeElementsFromFrame = (
   >();
 
   const toRemoveElementsByFrame = new Map<
-    ExcalidrawFrameElement["id"],
+    ExcalidrawFrameLikeElement["id"],
     ExcalidrawElement[]
   >();
 
@@ -474,7 +474,7 @@ export const removeElementsFromFrame = (
 
 export const removeAllElementsFromFrame = (
   allElements: ExcalidrawElementsIncludingDeleted,
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
   appState: AppState,
 ) => {
   const elementsInFrame = getFrameChildren(allElements, frame.id);
@@ -484,7 +484,7 @@ export const removeAllElementsFromFrame = (
 export const replaceAllElementsInFrame = (
   allElements: ExcalidrawElementsIncludingDeleted,
   nextElementsInFrame: ExcalidrawElement[],
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
   appState: AppState,
 ) => {
   return addElementsToFrame(
@@ -524,7 +524,7 @@ export const updateFrameMembershipOfSelectedElements = (
   elementsToFilter.forEach((element) => {
     if (
       element.frameId &&
-      !isFrameElement(element) &&
+      !isFrameLikeElement(element) &&
       !isElementInFrame(element, allElements, appState)
     ) {
       elementsToRemove.add(element);
@@ -540,7 +540,7 @@ export const updateFrameMembershipOfSelectedElements = (
  * filters out elements that are inside groups that contain a frame element
  * anywhere in the group tree
  */
-export const omitGroupsContainingFrames = (
+export const omitGroupsContainingFrameLikes = (
   allElements: ExcalidrawElementsIncludingDeleted,
   /** subset of elements you want to filter. Optional perf optimization so we
    * don't have to filter all elements unnecessarily
@@ -558,7 +558,9 @@ export const omitGroupsContainingFrames = (
   const rejectedGroupIds = new Set<string>();
   for (const groupId of uniqueGroupIds) {
     if (
-      getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
+      getElementsInGroup(allElements, groupId).some((el) =>
+        isFrameLikeElement(el),
+      )
     ) {
       rejectedGroupIds.add(groupId);
     }
@@ -636,7 +638,7 @@ export const isElementInFrame = (
     }
 
     for (const elementInGroup of allElementsInGroup) {
-      if (isFrameElement(elementInGroup)) {
+      if (isFrameLikeElement(elementInGroup)) {
         return false;
       }
     }
@@ -650,3 +652,15 @@ export const isElementInFrame = (
 
   return false;
 };
+
+export const getFrameLikeTitle = (
+  element: ExcalidrawFrameLikeElement,
+  frameIdx: number,
+) => {
+  const existingName = element.name?.trim();
+  if (existingName) {
+    return existingName;
+  }
+  // TODO name frames AI only is specific to AI frames
+  return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`;
+};

+ 9 - 2
src/locales/en.json

@@ -11,6 +11,8 @@
     "copyAsPng": "Copy to clipboard as PNG",
     "copyAsSvg": "Copy to clipboard as SVG",
     "copyText": "Copy to clipboard as text",
+    "copySource": "Copy source to clipboard",
+    "convertToCode": "Convert to code",
     "bringForward": "Bring forward",
     "sendToBack": "Send to back",
     "bringToFront": "Bring to front",
@@ -130,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...",
@@ -218,6 +222,7 @@
     },
     "libraryElementTypeError": {
       "embeddable": "Embeddable elements cannot be added to the library.",
+      "iframe": "IFrame elements cannot be added to the library.",
       "image": "Support for adding images to the library coming soon!"
     },
     "asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
@@ -240,11 +245,13 @@
     "link": "Add/ Update link for a selected shape",
     "eraser": "Eraser",
     "frame": "Frame tool",
+    "magicframe": "Wireframe to code",
     "embeddable": "Web Embed",
     "laser": "Laser pointer",
     "hand": "Hand (panning tool)",
     "extraTools": "More tools",
-    "mermaidToExcalidraw": "Mermaid to Excalidraw"
+    "mermaidToExcalidraw": "Mermaid to Excalidraw",
+    "magicSettings": "AI settings"
   },
   "headings": {
     "canvasActions": "Canvas actions",

+ 6 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -11,6 +11,12 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 -->
 
+## Unreleased
+
+### Breaking Changes
+
+- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
+
 ## 0.17.0 (2023-11-14)
 
 ### Features

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

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

@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
     children,
     validateEmbeddable,
     renderEmbeddable,
+    aiEnabled,
   } = props;
 
   const canvasActions = props.UIOptions?.canvasActions;
@@ -122,6 +123,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
           onScrollChange={onScrollChange}
           validateEmbeddable={validateEmbeddable}
           renderEmbeddable={renderEmbeddable}
+          aiEnabled={aiEnabled !== false}
         >
           {children}
         </App>
@@ -244,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";

+ 2 - 2
src/packages/utils.ts

@@ -6,7 +6,7 @@ import { getDefaultAppState } from "../appState";
 import { AppState, BinaryFiles } from "../types";
 import {
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   NonDeleted,
 } from "../element/types";
 import { restore } from "../data/restore";
@@ -26,7 +26,7 @@ type ExportOpts = {
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
   files: BinaryFiles | null;
   maxWidthOrHeight?: number;
-  exportingFrame?: ExcalidrawFrameElement | null;
+  exportingFrame?: ExcalidrawFrameLikeElement | null;
   getDimensions?: (
     width: number,
     height: number,

+ 13 - 1
src/renderer/renderElement.ts

@@ -13,6 +13,7 @@ import {
   isInitializedImageElement,
   isArrowElement,
   hasBoundTextElement,
+  isMagicFrameElement,
 } from "../element/typeChecks";
 import { getElementAbsoluteCoords } from "../element/bounds";
 import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -279,6 +280,7 @@ const drawElementOnCanvas = (
   }
   switch (element.type) {
     case "rectangle":
+    case "iframe":
     case "embeddable":
     case "diamond":
     case "ellipse": {
@@ -601,6 +603,7 @@ export const renderElement = (
   appState: StaticCanvasAppState,
 ) => {
   switch (element.type) {
+    case "magicframe":
     case "frame": {
       if (appState.frameRendering.enabled && appState.frameRendering.outline) {
         context.save();
@@ -613,6 +616,12 @@ export const renderElement = (
         context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
         context.strokeStyle = FRAME_STYLE.strokeColor;
 
+        // TODO change later to only affect AI frames
+        if (isMagicFrameElement(element)) {
+          context.strokeStyle =
+            appState.theme === "light" ? "#7affd7" : "#1d8264";
+        }
+
         if (FRAME_STYLE.radius && context.roundRect) {
           context.beginPath();
           context.roundRect(
@@ -673,6 +682,7 @@ export const renderElement = (
     case "arrow":
     case "image":
     case "text":
+    case "iframe":
     case "embeddable": {
       // TODO investigate if we can do this in situ. Right now we need to call
       // beforehand because math helpers (such as getElementAbsoluteCoords)
@@ -964,6 +974,7 @@ export const renderElementToSvg = (
       addToRoot(g || node, element);
       break;
     }
+    case "iframe":
     case "embeddable": {
       // render placeholder rectangle
       const shape = ShapeCache.generateElementShape(element, true);
@@ -1265,7 +1276,8 @@ export const renderElementToSvg = (
       break;
     }
     // frames are not rendered and only acts as a container
-    case "frame": {
+    case "frame":
+    case "magicframe": {
       if (
         renderConfig.frameRendering.enabled &&
         renderConfig.frameRendering.outline

+ 16 - 12
src/renderer/renderScene.ts

@@ -16,7 +16,7 @@ import {
   NonDeleted,
   GroupId,
   ExcalidrawBindableElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
 } from "../element/types";
 import {
   getElementAbsoluteCoords,
@@ -70,11 +70,12 @@ import {
 import { renderSnaps } from "./renderSnaps";
 import {
   isEmbeddableElement,
-  isFrameElement,
+  isFrameLikeElement,
+  isIframeLikeElement,
   isLinearElement,
 } from "../element/typeChecks";
 import {
-  isEmbeddableOrLabel,
+  isIframeLikeOrItsLabel,
   createPlaceholderEmbeddableLabel,
 } from "../element/embeddable";
 import {
@@ -362,7 +363,7 @@ const renderLinearElementPointHighlight = (
 };
 
 const frameClip = (
-  frame: ExcalidrawFrameElement,
+  frame: ExcalidrawFrameLikeElement,
   context: CanvasRenderingContext2D,
   renderConfig: StaticCanvasRenderConfig,
   appState: StaticCanvasAppState,
@@ -515,7 +516,7 @@ const _renderInteractiveScene = ({
   }
 
   const isFrameSelected = selectedElements.some((element) =>
-    isFrameElement(element),
+    isFrameLikeElement(element),
   );
 
   // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
@@ -963,7 +964,7 @@ const _renderStaticScene = ({
 
   // Paint visible elements
   visibleElements
-    .filter((el) => !isEmbeddableOrLabel(el))
+    .filter((el) => !isIframeLikeOrItsLabel(el))
     .forEach((element) => {
       try {
         const frameId = element.frameId || appState.frameToHighlight?.id;
@@ -996,15 +997,16 @@ const _renderStaticScene = ({
 
   // render embeddables on top
   visibleElements
-    .filter((el) => isEmbeddableOrLabel(el))
+    .filter((el) => isIframeLikeOrItsLabel(el))
     .forEach((element) => {
       try {
         const render = () => {
           renderElement(element, rc, context, renderConfig, appState);
 
           if (
-            isEmbeddableElement(element) &&
-            (isExporting || !element.validated) &&
+            isIframeLikeElement(element) &&
+            (isExporting ||
+              (isEmbeddableElement(element) && !element.validated)) &&
             element.width &&
             element.height
           ) {
@@ -1242,8 +1244,10 @@ const renderBindingHighlightForBindableElement = (
     case "rectangle":
     case "text":
     case "image":
+    case "iframe":
     case "embeddable":
     case "frame":
+    case "magicframe":
       strokeRectWithRotation(
         context,
         x1 - padding,
@@ -1284,7 +1288,7 @@ const renderBindingHighlightForBindableElement = (
 const renderFrameHighlight = (
   context: CanvasRenderingContext2D,
   appState: InteractiveCanvasAppState,
-  frame: NonDeleted<ExcalidrawFrameElement>,
+  frame: NonDeleted<ExcalidrawFrameLikeElement>,
 ) => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
   const width = x2 - x1;
@@ -1469,7 +1473,7 @@ export const renderSceneToSvg = (
   };
   // render elements
   elements
-    .filter((el) => !isEmbeddableOrLabel(el))
+    .filter((el) => !isIframeLikeOrItsLabel(el))
     .forEach((element) => {
       if (!element.isDeleted) {
         try {
@@ -1490,7 +1494,7 @@ export const renderSceneToSvg = (
 
   // render embeddables on top
   elements
-    .filter((el) => isEmbeddableElement(el))
+    .filter((el) => isIframeLikeElement(el))
     .forEach((element) => {
       if (!element.isDeleted) {
         try {

+ 14 - 17
src/scene/Scene.ts

@@ -2,15 +2,11 @@ import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
 } from "../element/types";
-import {
-  getNonDeletedElements,
-  getNonDeletedFrames,
-  isNonDeletedElement,
-} from "../element";
+import { getNonDeletedElements, isNonDeletedElement } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import { isFrameElement } from "../element/typeChecks";
+import { isFrameLikeElement } from "../element/typeChecks";
 import { getSelectedElements } from "./selection";
 import { AppState } from "../types";
 import { Assert, SameType } from "../utility-types";
@@ -107,8 +103,9 @@ class Scene {
 
   private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
   private elements: readonly ExcalidrawElement[] = [];
-  private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
-  private frames: readonly ExcalidrawFrameElement[] = [];
+  private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
+    [];
+  private frames: readonly ExcalidrawFrameLikeElement[] = [];
   private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
   private selectedElementsCache: {
     selectedElementIds: AppState["selectedElementIds"] | null;
@@ -179,8 +176,8 @@ class Scene {
     return selectedElements;
   }
 
-  getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
-    return this.nonDeletedFrames;
+  getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
+    return this.nonDeletedFramesLikes;
   }
 
   getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
@@ -235,18 +232,18 @@ class Scene {
     mapElementIds = true,
   ) {
     this.elements = nextElements;
-    const nextFrames: ExcalidrawFrameElement[] = [];
+    const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
     this.elementsMap.clear();
     nextElements.forEach((element) => {
-      if (isFrameElement(element)) {
-        nextFrames.push(element);
+      if (isFrameLikeElement(element)) {
+        nextFrameLikes.push(element);
       }
       this.elementsMap.set(element.id, element);
       Scene.mapElementToScene(element, this);
     });
     this.nonDeletedElements = getNonDeletedElements(this.elements);
-    this.frames = nextFrames;
-    this.nonDeletedFrames = getNonDeletedFrames(this.frames);
+    this.frames = nextFrameLikes;
+    this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
 
     this.informMutation();
   }
@@ -277,7 +274,7 @@ class Scene {
   destroy() {
     this.nonDeletedElements = [];
     this.elements = [];
-    this.nonDeletedFrames = [];
+    this.nonDeletedFramesLikes = [];
     this.frames = [];
     this.elementsMap.clear();
     this.selectedElementsCache.selectedElementIds = null;

+ 24 - 6
src/scene/Shape.ts

@@ -14,7 +14,12 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
 import { isTransparent, assertNever } from "../utils";
 import { simplify } from "points-on-curve";
 import { ROUGHNESS } from "../constants";
-import { isLinearElement } from "../element/typeChecks";
+import {
+  isEmbeddableElement,
+  isIframeElement,
+  isIframeLikeElement,
+  isLinearElement,
+} from "../element/typeChecks";
 import { canChangeRoundness } from "./comparisons";
 
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -78,6 +83,7 @@ export const generateRoughOptions = (
 
   switch (element.type) {
     case "rectangle":
+    case "iframe":
     case "embeddable":
     case "diamond":
     case "ellipse": {
@@ -109,13 +115,13 @@ export const generateRoughOptions = (
   }
 };
 
-const modifyEmbeddableForRoughOptions = (
+const modifyIframeLikeForRoughOptions = (
   element: NonDeletedExcalidrawElement,
   isExporting: boolean,
 ) => {
   if (
-    element.type === "embeddable" &&
-    (isExporting || !element.validated) &&
+    isIframeLikeElement(element) &&
+    (isExporting || (isEmbeddableElement(element) && !element.validated)) &&
     isTransparent(element.backgroundColor) &&
     isTransparent(element.strokeColor)
   ) {
@@ -125,6 +131,16 @@ const modifyEmbeddableForRoughOptions = (
       backgroundColor: "#d3d3d3",
       fillStyle: "solid",
     } as const;
+  } else if (isIframeElement(element)) {
+    return {
+      ...element,
+      strokeColor: isTransparent(element.strokeColor)
+        ? "#000000"
+        : element.strokeColor,
+      backgroundColor: isTransparent(element.backgroundColor)
+        ? "#f4f4f6"
+        : element.backgroundColor,
+    };
   }
   return element;
 };
@@ -143,6 +159,7 @@ export const _generateElementShape = (
 ): Drawable | Drawable[] | null => {
   switch (element.type) {
     case "rectangle":
+    case "iframe":
     case "embeddable": {
       let shape: ElementShapes[typeof element.type];
       // this is for rendering the stroke/bg of the embeddable, especially
@@ -159,7 +176,7 @@ export const _generateElementShape = (
             h - r
           } L 0 ${r} Q 0 0, ${r} 0`,
           generateRoughOptions(
-            modifyEmbeddableForRoughOptions(element, isExporting),
+            modifyIframeLikeForRoughOptions(element, isExporting),
             true,
           ),
         );
@@ -170,7 +187,7 @@ export const _generateElementShape = (
           element.width,
           element.height,
           generateRoughOptions(
-            modifyEmbeddableForRoughOptions(element, isExporting),
+            modifyIframeLikeForRoughOptions(element, isExporting),
             false,
           ),
         );
@@ -373,6 +390,7 @@ export const _generateElementShape = (
       return shape;
     }
     case "frame":
+    case "magicframe":
     case "text":
     case "image": {
       const shape: ElementShapes[typeof element.type] = null;

+ 18 - 13
src/scene/comparisons.ts

@@ -1,22 +1,25 @@
-import { isEmbeddableElement } from "../element/typeChecks";
+import { isIframeElement } from "../element/typeChecks";
 import {
-  ExcalidrawEmbeddableElement,
+  ExcalidrawIframeElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
+import { ElementOrToolType } from "../types";
 
-export const hasBackground = (type: string) =>
+export const hasBackground = (type: ElementOrToolType) =>
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "ellipse" ||
   type === "diamond" ||
   type === "line" ||
   type === "freedraw";
 
-export const hasStrokeColor = (type: string) =>
-  type !== "image" && type !== "frame";
+export const hasStrokeColor = (type: ElementOrToolType) =>
+  type !== "image" && type !== "frame" && type !== "magicframe";
 
-export const hasStrokeWidth = (type: string) =>
+export const hasStrokeWidth = (type: ElementOrToolType) =>
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "ellipse" ||
   type === "diamond" ||
@@ -24,22 +27,24 @@ export const hasStrokeWidth = (type: string) =>
   type === "arrow" ||
   type === "line";
 
-export const hasStrokeStyle = (type: string) =>
+export const hasStrokeStyle = (type: ElementOrToolType) =>
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "ellipse" ||
   type === "diamond" ||
   type === "arrow" ||
   type === "line";
 
-export const canChangeRoundness = (type: string) =>
+export const canChangeRoundness = (type: ElementOrToolType) =>
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "arrow" ||
   type === "line" ||
   type === "diamond";
 
-export const canHaveArrowheads = (type: string) => type === "arrow";
+export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
 
 export const getElementAtPosition = (
   elements: readonly NonDeletedExcalidrawElement[],
@@ -67,7 +72,7 @@ export const getElementsAtPosition = (
   elements: readonly NonDeletedExcalidrawElement[],
   isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
 ) => {
-  const embeddables: ExcalidrawEmbeddableElement[] = [];
+  const iframeLikes: ExcalidrawIframeElement[] = [];
   // The parameter elements comes ordered from lower z-index to higher.
   // We want to preserve that order on the returned array.
   // Exception being embeddables which should be on top of everything else in
@@ -75,13 +80,13 @@ export const getElementsAtPosition = (
   const elsAtPos = elements.filter((element) => {
     const hit = !element.isDeleted && isAtPositionFn(element);
     if (hit) {
-      if (isEmbeddableElement(element)) {
-        embeddables.push(element);
+      if (isIframeElement(element)) {
+        iframeLikes.push(element);
         return false;
       }
       return true;
     }
     return false;
   });
-  return elsAtPos.concat(embeddables);
+  return elsAtPos.concat(iframeLikes);
 };

+ 25 - 12
src/scene/export.ts

@@ -1,7 +1,7 @@
 import rough from "roughjs/bin/rough";
 import {
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   ExcalidrawTextElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
@@ -27,11 +27,16 @@ import {
   updateImageCache,
 } from "../element/image";
 import { elementsOverlappingBBox } from "../packages/withinBounds";
-import { getFrameElements, getRootElements } from "../frame";
-import { isFrameElement, newTextElement } from "../element";
+import {
+  getFrameLikeElements,
+  getFrameLikeTitle,
+  getRootElements,
+} from "../frame";
+import { newTextElement } from "../element";
 import { Mutable } from "../utility-types";
 import { newElementWith } from "../element/mutateElement";
 import Scene from "./Scene";
+import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
 
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
@@ -100,10 +105,15 @@ const addFrameLabelsAsTextElements = (
   opts: Pick<AppState, "exportWithDarkMode">,
 ) => {
   const nextElements: NonDeletedExcalidrawElement[] = [];
-  let frameIdx = 0;
+  let frameIndex = 0;
+  let magicFrameIndex = 0;
   for (const element of elements) {
-    if (isFrameElement(element)) {
-      frameIdx++;
+    if (isFrameLikeElement(element)) {
+      if (isFrameElement(element)) {
+        frameIndex++;
+      } else {
+        magicFrameIndex++;
+      }
       let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
         x: element.x,
         y: element.y - FRAME_STYLE.nameOffsetY,
@@ -114,7 +124,10 @@ const addFrameLabelsAsTextElements = (
         strokeColor: opts.exportWithDarkMode
           ? FRAME_STYLE.nameColorDarkTheme
           : FRAME_STYLE.nameColorLightTheme,
-        text: element.name || `Frame ${frameIdx}`,
+        text: getFrameLikeTitle(
+          element,
+          isFrameElement(element) ? frameIndex : magicFrameIndex,
+        ),
       });
       textElement.y -= textElement.height;
 
@@ -129,7 +142,7 @@ const addFrameLabelsAsTextElements = (
 };
 
 const getFrameRenderingConfig = (
-  exportingFrame: ExcalidrawFrameElement | null,
+  exportingFrame: ExcalidrawFrameLikeElement | null,
   frameRendering: AppState["frameRendering"] | null,
 ): AppState["frameRendering"] => {
   frameRendering = frameRendering || getDefaultAppState().frameRendering;
@@ -148,7 +161,7 @@ const prepareElementsForRender = ({
   exportWithDarkMode,
 }: {
   elements: readonly ExcalidrawElement[];
-  exportingFrame: ExcalidrawFrameElement | null | undefined;
+  exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
   frameRendering: AppState["frameRendering"];
   exportWithDarkMode: AppState["exportWithDarkMode"];
 }) => {
@@ -184,7 +197,7 @@ export const exportToCanvas = async (
     exportBackground: boolean;
     exportPadding?: number;
     viewBackgroundColor: string;
-    exportingFrame?: ExcalidrawFrameElement | null;
+    exportingFrame?: ExcalidrawFrameLikeElement | null;
   },
   createCanvas: (
     width: number,
@@ -274,7 +287,7 @@ export const exportToSvg = async (
   files: BinaryFiles | null,
   opts?: {
     renderEmbeddables?: boolean;
-    exportingFrame?: ExcalidrawFrameElement | null;
+    exportingFrame?: ExcalidrawFrameLikeElement | null;
   },
 ): Promise<SVGSVGElement> => {
   const tempScene = __createSceneForElementsHack__(elements);
@@ -360,7 +373,7 @@ export const exportToSvg = async (
   const offsetX = -minX + exportPadding;
   const offsetY = -minY + exportPadding;
 
-  const frameElements = getFrameElements(elements);
+  const frameElements = getFrameLikeElements(elements);
 
   let exportingFrameClipPath = "";
   for (const frame of frameElements) {

+ 3 - 3
src/scene/selection.ts

@@ -4,7 +4,7 @@ import {
 } from "../element/types";
 import { getElementAbsoluteCoords, getElementBounds } from "../element";
 import { AppState, InteractiveCanvasAppState } from "../types";
-import { isBoundToContainer } from "../element/typeChecks";
+import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
 import {
   elementOverlapsWithFrame,
   getContainingFrame,
@@ -27,7 +27,7 @@ export const excludeElementsInFramesFromSelection = <
   const framesInSelection = new Set<T["id"]>();
 
   selectedElements.forEach((element) => {
-    if (element.type === "frame") {
+    if (isFrameLikeElement(element)) {
       framesInSelection.add(element.id);
     }
   });
@@ -190,7 +190,7 @@ export const getSelectedElements = (
   if (opts?.includeElementsInFrames) {
     const elementsToInclude: ExcalidrawElement[] = [];
     selectedElements.forEach((element) => {
-      if (element.type === "frame") {
+      if (isFrameLikeElement(element)) {
         getFrameChildren(elements, element.id).forEach((e) =>
           elementsToInclude.push(e),
         );

+ 2 - 0
src/scene/types.ts

@@ -98,6 +98,7 @@ export type ElementShapes = {
   rectangle: Drawable;
   ellipse: Drawable;
   diamond: Drawable;
+  iframe: Drawable;
   embeddable: Drawable;
   freedraw: Drawable | null;
   arrow: Drawable[];
@@ -105,4 +106,5 @@ export type ElementShapes = {
   text: null;
   image: null;
   frame: null;
+  magicframe: null;
 };

+ 0 - 8
src/shapes.tsx

@@ -83,14 +83,6 @@ export const SHAPES = [
     numericKey: KEYS["0"],
     fillable: false,
   },
-  // TODO: frame, create icon and set up numeric key
-  // {
-  //   icon: RectangleIcon,
-  //   value: "frame",
-  //   key: KEYS.F,
-  //   numericKey: KEYS.SUBTRACT,
-  //   fillable: false,
-  // },
 ] as const;
 
 export const findShapeByKey = (key: string) => {

+ 9 - 7
src/snapping.ts

@@ -1,3 +1,4 @@
+import { TOOL_TYPE } from "./constants";
 import {
   Bounds,
   getCommonBounds,
@@ -5,7 +6,7 @@ import {
   getElementAbsoluteCoords,
 } from "./element/bounds";
 import { MaybeTransformHandleType } from "./element/transformHandles";
-import { isBoundToContainer, isFrameElement } from "./element/typeChecks";
+import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
 import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
@@ -262,7 +263,7 @@ const getReferenceElements = (
   appState: AppState,
 ) => {
   const selectedFrames = selectedElements
-    .filter((element) => isFrameElement(element))
+    .filter((element) => isFrameLikeElement(element))
     .map((frame) => frame.id);
 
   return getVisibleAndNonSelectedElements(
@@ -1352,10 +1353,11 @@ export const isActiveToolNonLinearSnappable = (
   activeToolType: AppState["activeTool"]["type"],
 ) => {
   return (
-    activeToolType === "rectangle" ||
-    activeToolType === "ellipse" ||
-    activeToolType === "diamond" ||
-    activeToolType === "frame" ||
-    activeToolType === "image"
+    activeToolType === TOOL_TYPE.rectangle ||
+    activeToolType === TOOL_TYPE.ellipse ||
+    activeToolType === TOOL_TYPE.diamond ||
+    activeToolType === TOOL_TYPE.frame ||
+    activeToolType === TOOL_TYPE.magicframe ||
+    activeToolType === TOOL_TYPE.image
   );
 };

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

@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
       <Excalidraw
         initialData={{
           appState: {
-            openDialog: "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>"
 `;

+ 16 - 1
src/tests/helpers/api.ts

@@ -7,6 +7,8 @@ import {
   ExcalidrawImageElement,
   FileId,
   ExcalidrawFrameElement,
+  ExcalidrawElementType,
+  ExcalidrawMagicFrameElement,
 } from "../../element/types";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@@ -29,7 +31,9 @@ import {
   newEmbeddableElement,
   newFrameElement,
   newFreeDrawElement,
+  newIframeElement,
   newImageElement,
+  newMagicFrameElement,
 } from "../../element/newElement";
 import { Point } from "../../types";
 import { getSelectedElements } from "../../scene/selection";
@@ -96,7 +100,7 @@ export class API {
   };
 
   static createElement = <
-    T extends Exclude<ExcalidrawElement["type"], "selection"> = "rectangle",
+    T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
   >({
     // @ts-ignore
     type = "rectangle",
@@ -163,6 +167,8 @@ export class API {
     ? ExcalidrawImageElement
     : T extends "frame"
     ? ExcalidrawFrameElement
+    : T extends "magicframe"
+    ? ExcalidrawMagicFrameElement
     : ExcalidrawGenericElement => {
     let element: Mutable<ExcalidrawElement> = null!;
 
@@ -235,6 +241,12 @@ export class API {
           validated: null,
         });
         break;
+      case "iframe":
+        element = newIframeElement({
+          type: "iframe",
+          ...base,
+        });
+        break;
       case "text":
         const fontSize = rest.fontSize ?? appState.currentItemFontSize;
         const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
@@ -286,6 +298,9 @@ export class API {
       case "frame":
         element = newFrameElement({ ...base, width, height });
         break;
+      case "magicframe":
+        element = newMagicFrameElement({ ...base, width, height });
+        break;
       default:
         assertNever(
           type,

+ 5 - 6
src/tests/helpers/ui.ts

@@ -1,4 +1,4 @@
-import type { Point } from "../../types";
+import type { Point, ToolType } from "../../types";
 import type {
   ExcalidrawElement,
   ExcalidrawLinearElement,
@@ -20,15 +20,14 @@ import {
   type TransformHandleDirection,
 } from "../../element/transformHandles";
 import { KEYS } from "../../keys";
-import { type ToolName } from "../queries/toolQueries";
 import { fireEvent, GlobalTestState, screen } from "../test-utils";
 import { mutateElement } from "../../element/mutateElement";
 import { API } from "./api";
 import {
-  isFrameElement,
   isLinearElement,
   isFreeDrawElement,
   isTextElement,
+  isFrameLikeElement,
 } from "../../element/typeChecks";
 import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
 import { rotatePoint } from "../../math";
@@ -290,7 +289,7 @@ const transform = (
     ];
   } else {
     const [x1, y1, x2, y2] = getCommonBounds(elements);
-    const isFrameSelected = elements.some(isFrameElement);
+    const isFrameSelected = elements.some(isFrameLikeElement);
     const transformHandles = getTransformHandlesFromCoords(
       [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
       0,
@@ -345,7 +344,7 @@ const proxy = <T extends ExcalidrawElement>(
 };
 
 /** Tools that can be used to draw shapes */
-type DrawingToolName = Exclude<ToolName, "lock" | "selection" | "eraser">;
+type DrawingToolName = Exclude<ToolType, "lock" | "selection" | "eraser">;
 
 type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
   ? ExcalidrawLinearElement
@@ -362,7 +361,7 @@ type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
   : ExcalidrawElement;
 
 export class UI {
-  static clickTool = (toolName: ToolName) => {
+  static clickTool = (toolName: ToolType | "lock") => {
     fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
   };
 

+ 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);
 

+ 5 - 19
src/tests/queries/toolQueries.ts

@@ -1,23 +1,9 @@
 import { queries, buildQueries } from "@testing-library/react";
+import { ToolType } from "../../types";
+import { TOOL_TYPE } from "../../constants";
 
-const toolMap = {
-  lock: "lock",
-  selection: "selection",
-  rectangle: "rectangle",
-  diamond: "diamond",
-  ellipse: "ellipse",
-  arrow: "arrow",
-  line: "line",
-  freedraw: "freedraw",
-  text: "text",
-  eraser: "eraser",
-  frame: "frame",
-};
-
-export type ToolName = keyof typeof toolMap;
-
-const _getAllByToolName = (container: HTMLElement, tool: string) => {
-  const toolTitle = toolMap[tool as ToolName];
+const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => {
+  const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool];
   return queries.getAllByTestId(container, `toolbar-${toolTitle}`);
 };
 
@@ -32,7 +18,7 @@ export const [
   getByToolName,
   findAllByToolName,
   findByToolName,
-] = buildQueries<string[]>(
+] = buildQueries<(ToolType | "lock")[]>(
   _getAllByToolName,
   getMultipleError,
   getMissingError,

+ 31 - 10
src/types.ts

@@ -15,8 +15,10 @@ import {
   ExcalidrawImageElement,
   Theme,
   StrokeRoundness,
-  ExcalidrawFrameElement,
   ExcalidrawEmbeddableElement,
+  ExcalidrawMagicFrameElement,
+  ExcalidrawFrameLikeElement,
+  ExcalidrawElementType,
 } from "./element/types";
 import { Action } from "./actions/types";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -108,9 +110,12 @@ export type ToolType =
   | "eraser"
   | "hand"
   | "frame"
+  | "magicframe"
   | "embeddable"
   | "laser";
 
+export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
+
 export type ActiveTool =
   | {
       type: ToolType;
@@ -165,9 +170,6 @@ export type InteractiveCanvasAppState = Readonly<
     suggestedBindings: AppState["suggestedBindings"];
     isRotating: AppState["isRotating"];
     elementsToHighlight: AppState["elementsToHighlight"];
-    // App
-    openSidebar: AppState["openSidebar"];
-    showHyperlinkPopup: AppState["showHyperlinkPopup"];
     // Collaborators
     collaborators: AppState["collaborators"];
     // SnapLines
@@ -176,7 +178,7 @@ export type InteractiveCanvasAppState = Readonly<
   }
 >;
 
-export type AppState = {
+export interface AppState {
   contextMenu: {
     items: ContextMenuItems;
     top: number;
@@ -196,7 +198,7 @@ export type AppState = {
   isBindingEnabled: boolean;
   startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
   suggestedBindings: SuggestedBinding[];
-  frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
+  frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
   frameRendering: {
     enabled: boolean;
     name: boolean;
@@ -252,7 +254,18 @@ export type AppState = {
   openMenu: "canvas" | "shape" | null;
   openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
-  openDialog: "imageExport" | "help" | "jsonExport" | "mermaid" | null;
+  openDialog:
+    | null
+    | { name: "imageExport" | "help" | "jsonExport" }
+    | {
+        name: "settings";
+        source:
+          | "tool" // when magicframe tool is selected
+          | "generation" // when magicframe generate button is clicked
+          | "settings"; // when AI settings dialog is explicitly invoked
+        tab: "text-to-diagram" | "diagram-to-code";
+      }
+    | { name: "ttd"; tab: "text-to-diagram" | "mermaid" };
   /**
    * Reflects user preference for whether the default sidebar should be docked.
    *
@@ -307,7 +320,7 @@ export type AppState = {
     y: number;
   } | null;
   objectsSnapModeEnabled: boolean;
-};
+}
 
 export type UIAppState = Omit<
   AppState,
@@ -447,6 +460,7 @@ export interface ExcalidrawProps {
     element: NonDeleted<ExcalidrawEmbeddableElement>,
     appState: AppState,
   ) => JSX.Element | null;
+  aiEnabled?: boolean;
 }
 
 export type SceneData = {
@@ -515,6 +529,7 @@ export type AppProps = Merge<
     handleKeyboardGlobally: boolean;
     isCollaborating: boolean;
     children?: React.ReactNode;
+    aiEnabled: boolean;
   }
 >;
 
@@ -548,6 +563,8 @@ export type AppClassProperties = {
   togglePenMode: App["togglePenMode"];
   setActiveTool: App["setActiveTool"];
   setOpenDialog: App["setOpenDialog"];
+  insertEmbeddableElement: App["insertEmbeddableElement"];
+  onMagicframeToolSelect: App["onMagicframeToolSelect"];
 };
 
 export type PointerDownState = Readonly<{
@@ -695,12 +712,14 @@ type FrameNameBounds = {
 };
 
 export type FrameNameBoundsCache = {
-  get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null;
+  get: (
+    frameElement: ExcalidrawFrameLikeElement | ExcalidrawMagicFrameElement,
+  ) => FrameNameBounds | null;
   _cache: Map<
     string,
     FrameNameBounds & {
       zoom: AppState["zoom"]["value"];
-      versionNonce: ExcalidrawFrameElement["versionNonce"];
+      versionNonce: ExcalidrawFrameLikeElement["versionNonce"];
     }
   >;
 };
@@ -720,3 +739,5 @@ export type Primitive =
   | symbol
   | null
   | undefined;
+
+export type JSONValue = string | number | boolean | null | object;

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác