浏览代码

Merge branch 'master' into zsviczian-embeddable-scaling

zsviczian 1 年之前
父节点
当前提交
9da3e47877
共有 75 个文件被更改,包括 2199 次插入459 次删除
  1. 1 1
      package.json
  2. 2 1
      src/actions/actionAlign.tsx
  3. 15 1
      src/actions/actionCanvas.tsx
  4. 2 2
      src/actions/actionDeleteSelected.tsx
  5. 2 1
      src/actions/actionDistribute.tsx
  6. 5 5
      src/actions/actionDuplicateSelection.tsx
  7. 2 1
      src/actions/actionElementLock.ts
  8. 17 8
      src/actions/actionFrame.ts
  9. 4 4
      src/actions/actionGroup.tsx
  10. 7 2
      src/actions/actionMenu.tsx
  11. 2 2
      src/actions/actionStyles.ts
  12. 15 7
      src/analytics.ts
  13. 5 2
      src/clipboard.ts
  14. 36 4
      src/components/Actions.tsx
  15. 628 61
      src/components/App.tsx
  16. 15 0
      src/components/InlineIcon.tsx
  17. 1 1
      src/components/JSONExportDialog.tsx
  18. 44 5
      src/components/LayerUI.tsx
  19. 38 0
      src/components/MagicButton.tsx
  20. 9 0
      src/components/MagicSettings.scss
  21. 130 0
      src/components/MagicSettings.tsx
  22. 1 1
      src/components/OverwriteConfirm/OverwriteConfirmActions.tsx
  23. 10 0
      src/components/Paragraph.tsx
  24. 1 1
      src/components/PasteChartDialog.tsx
  25. 7 31
      src/components/PublishLibrary.tsx
  26. 22 2
      src/components/TextField.tsx
  27. 2 1
      src/components/ToolIcon.scss
  28. 0 2
      src/components/canvases/InteractiveCanvas.tsx
  29. 54 0
      src/components/icons.tsx
  30. 2 2
      src/components/main-menu/DefaultItems.tsx
  31. 31 1
      src/constants.ts
  32. 12 0
      src/css/styles.scss
  33. 51 0
      src/data/EditorLocalStorage.ts
  34. 300 0
      src/data/ai/types.ts
  35. 9 5
      src/data/index.ts
  36. 104 0
      src/data/magic.ts
  37. 9 6
      src/data/restore.ts
  38. 44 8
      src/data/transform.ts
  39. 14 0
      src/element/ElementCanvasButtons.scss
  40. 60 0
      src/element/ElementCanvasButtons.tsx
  41. 1 1
      src/element/Hyperlink.scss
  42. 2 1
      src/element/Hyperlink.tsx
  43. 12 4
      src/element/bounds.ts
  44. 25 12
      src/element/collision.ts
  45. 2 2
      src/element/dragElements.ts
  46. 56 38
      src/element/embeddable.ts
  47. 4 16
      src/element/index.ts
  48. 29 0
      src/element/newElement.ts
  49. 4 5
      src/element/resizeElements.ts
  50. 2 1
      src/element/textElement.ts
  51. 2 2
      src/element/transformHandles.ts
  52. 44 20
      src/element/typeChecks.ts
  53. 37 1
      src/element/types.ts
  54. 49 35
      src/frame.ts
  55. 6 1
      src/locales/en.json
  56. 6 0
      src/packages/excalidraw/CHANGELOG.md
  57. 2 0
      src/packages/excalidraw/index.tsx
  58. 2 2
      src/packages/utils.ts
  59. 15 3
      src/renderer/renderElement.ts
  60. 16 12
      src/renderer/renderScene.ts
  61. 14 17
      src/scene/Scene.ts
  62. 24 6
      src/scene/Shape.ts
  63. 18 13
      src/scene/comparisons.ts
  64. 25 12
      src/scene/export.ts
  65. 3 3
      src/scene/selection.ts
  66. 2 0
      src/scene/types.ts
  67. 0 8
      src/shapes.tsx
  68. 9 7
      src/snapping.ts
  69. 1 1
      src/tests/MermaidToExcalidraw.test.tsx
  70. 16 1
      src/tests/helpers/api.ts
  71. 5 6
      src/tests/helpers/ui.ts
  72. 5 19
      src/tests/queries/toolQueries.ts
  73. 31 10
      src/types.ts
  74. 4 19
      src/utils.ts
  75. 13 13
      src/zindex.ts

+ 1 - 1
package.json

@@ -96,7 +96,7 @@
     "vitest-canvas-mock": "0.3.2"
     "vitest-canvas-mock": "0.3.2"
   },
   },
   "engines": {
   "engines": {
-    "node": "^18.0.0"
+    "node": "18.0.0 - 20.x.x"
   },
   },
   "homepage": ".",
   "homepage": ".",
   "name": "excalidraw",
   "name": "excalidraw",

+ 2 - 1
src/actions/actionAlign.tsx

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

+ 15 - 1
src/actions/actionCanvas.tsx

@@ -265,7 +265,21 @@ export const zoomToFit = ({
       30.0,
       30.0,
     ) as NormalizedZoomValue;
     ) as NormalizedZoomValue;
 
 
-    scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
+    let appStateWidth = appState.width;
+
+    if (appState.openSidebar) {
+      const sidebarDOMElem = document.querySelector(
+        ".sidebar",
+      ) as HTMLElement | null;
+      const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
+      const isRTL = document.documentElement.getAttribute("dir") === "rtl";
+
+      appStateWidth = !isRTL
+        ? appState.width - sidebarWidth
+        : appState.width + sidebarWidth;
+    }
+
+    scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
     scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
     scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
   } else {
   } else {
     newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
     newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {

+ 2 - 2
src/actions/actionDeleteSelected.tsx

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

+ 2 - 1
src/actions/actionDistribute.tsx

@@ -5,6 +5,7 @@ import {
 import { ToolButton } from "../components/ToolButton";
 import { ToolButton } from "../components/ToolButton";
 import { distributeElements, Distribution } from "../distribute";
 import { distributeElements, Distribution } from "../distribute";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { t } from "../i18n";
@@ -19,7 +20,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
   return (
   return (
     selectedElements.length > 1 &&
     selectedElements.length > 1 &&
     // TODO enable distributing frames when implemented properly
     // 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,
   bindTextToShapeAfterDuplication,
   getBoundTextElement,
   getBoundTextElement,
 } from "../element/textElement";
 } from "../element/textElement";
-import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
+import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
 import { normalizeElementOrder } from "../element/sortElements";
 import { normalizeElementOrder } from "../element/sortElements";
 import { DuplicateIcon } from "../components/icons";
 import { DuplicateIcon } from "../components/icons";
 import {
 import {
@@ -140,11 +140,11 @@ const duplicateElements = (
     }
     }
 
 
     const boundTextElement = getBoundTextElement(element);
     const boundTextElement = getBoundTextElement(element);
-    const isElementAFrame = isFrameElement(element);
+    const isElementAFrameLike = isFrameLikeElement(element);
 
 
     if (idsOfElementsToDuplicate.get(element.id)) {
     if (idsOfElementsToDuplicate.get(element.id)) {
       // if a group or a container/bound-text or frame, duplicate atomically
       // 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);
         const groupId = getSelectedGroupForElement(appState, element);
         if (groupId) {
         if (groupId) {
           // TODO:
           // TODO:
@@ -154,7 +154,7 @@ const duplicateElements = (
             sortedElements,
             sortedElements,
             groupId,
             groupId,
           ).flatMap((element) =>
           ).flatMap((element) =>
-            isFrameElement(element)
+            isFrameLikeElement(element)
               ? [...getFrameChildren(elements, element.id), element]
               ? [...getFrameChildren(elements, element.id), element]
               : [element],
               : [element],
           );
           );
@@ -180,7 +180,7 @@ const duplicateElements = (
           );
           );
           continue;
           continue;
         }
         }
-        if (isElementAFrame) {
+        if (isElementAFrameLike) {
           const elementsInFrame = getFrameChildren(sortedElements, element.id);
           const elementsInFrame = getFrameChildren(sortedElements, element.id);
 
 
           elementsWithClones.push(
           elementsWithClones.push(

+ 2 - 1
src/actions/actionElementLock.ts

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

+ 17 - 8
src/actions/actionFrame.ts

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

+ 4 - 4
src/actions/actionGroup.tsx

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

+ 7 - 2
src/actions/actionMenu.tsx

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

+ 2 - 2
src/actions/actionStyles.ts

@@ -20,7 +20,7 @@ import {
   hasBoundTextElement,
   hasBoundTextElement,
   canApplyRoundnessTypeToElement,
   canApplyRoundnessTypeToElement,
   getDefaultRoundnessTypeForElement,
   getDefaultRoundnessTypeForElement,
-  isFrameElement,
+  isFrameLikeElement,
   isArrowElement,
   isArrowElement,
 } from "../element/typeChecks";
 } from "../element/typeChecks";
 import { getSelectedElements } from "../scene";
 import { getSelectedElements } from "../scene";
@@ -138,7 +138,7 @@ export const actionPasteStyles = register({
             });
             });
           }
           }
 
 
-          if (isFrameElement(element)) {
+          if (isFrameLikeElement(element)) {
             newElement = newElementWith(newElement, {
             newElement = newElementWith(newElement, {
               roundness: null,
               roundness: null,
               backgroundColor: "transparent",
               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 = (
 export const trackEvent = (
   category: string,
   category: string,
   action: string,
   action: string,
@@ -5,13 +9,13 @@ export const trackEvent = (
   value?: number,
   value?: number,
 ) => {
 ) => {
   try {
   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;
       return;
     }
     }
 
 
@@ -19,6 +23,10 @@ export const trackEvent = (
       return;
       return;
     }
     }
 
 
+    if (!import.meta.env.PROD) {
+      console.info("trackEvent", { category, action, label, value });
+    }
+
     if (window.sa_event) {
     if (window.sa_event) {
       window.sa_event(action, {
       window.sa_event(action, {
         category,
         category,

+ 5 - 2
src/clipboard.ts

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

+ 36 - 4
src/components/Actions.tsx

@@ -1,7 +1,7 @@
 import React, { useState } from "react";
 import React, { useState } from "react";
 import { ActionManager } from "../actions/manager";
 import { ActionManager } from "../actions/manager";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
-import { ExcalidrawElement } from "../element/types";
+import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { useDevice } from "../components/App";
 import { useDevice } from "../components/App";
 import {
 import {
@@ -36,6 +36,8 @@ import {
   frameToolIcon,
   frameToolIcon,
   mermaidLogoIcon,
   mermaidLogoIcon,
   laserPointerToolIcon,
   laserPointerToolIcon,
+  OpenAIIcon,
+  MagicIcon,
 } from "./icons";
 } from "./icons";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 
 
@@ -79,7 +81,8 @@ export const SelectedShapeActions = ({
   const showLinkIcon =
   const showLinkIcon =
     targetElements.length === 1 || isSingleElementBoundContainer;
     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) {
   for (const element of targetElements) {
     if (element.type !== commonSelectedType) {
     if (element.type !== commonSelectedType) {
@@ -94,7 +97,8 @@ export const SelectedShapeActions = ({
         {((hasStrokeColor(appState.activeTool.type) &&
         {((hasStrokeColor(appState.activeTool.type) &&
           appState.activeTool.type !== "image" &&
           appState.activeTool.type !== "image" &&
           commonSelectedType !== "image" &&
           commonSelectedType !== "image" &&
-          commonSelectedType !== "frame") ||
+          commonSelectedType !== "frame" &&
+          commonSelectedType !== "magicframe") ||
           targetElements.some((element) => hasStrokeColor(element.type))) &&
           targetElements.some((element) => hasStrokeColor(element.type))) &&
           renderAction("changeStrokeColor")}
           renderAction("changeStrokeColor")}
       </div>
       </div>
@@ -331,13 +335,41 @@ export const ShapesSwitcher = ({
           >
           >
             {t("toolBar.laser")}
             {t("toolBar.laser")}
           </DropdownMenu.Item>
           </DropdownMenu.Item>
+          <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
+            Generate
+          </div>
           <DropdownMenu.Item
           <DropdownMenu.Item
-            onSelect={() => app.setOpenDialog("mermaid")}
+            onSelect={() => app.setOpenDialog({ name: "mermaid" })}
             icon={mermaidLogoIcon}
             icon={mermaidLogoIcon}
             data-testid="toolbar-embeddable"
             data-testid="toolbar-embeddable"
           >
           >
             {t("toolBar.mermaidToExcalidraw")}
             {t("toolBar.mermaidToExcalidraw")}
           </DropdownMenu.Item>
           </DropdownMenu.Item>
+
+          {app.props.aiEnabled !== false && (
+            <>
+              <DropdownMenu.Item
+                onSelect={() => app.onMagicframeToolSelect()}
+                icon={MagicIcon}
+                data-testid="toolbar-magicframe"
+              >
+                {t("toolBar.magicframe")}
+              </DropdownMenu.Item>
+              <DropdownMenu.Item
+                onSelect={() => {
+                  trackEvent("ai", "d2c-settings", "settings");
+                  app.setOpenDialog({
+                    name: "magicSettings",
+                    source: "settings",
+                  });
+                }}
+                icon={OpenAIIcon}
+                data-testid="toolbar-magicSettings"
+              >
+                {t("toolBar.magicSettings")}
+              </DropdownMenu.Item>
+            </>
+          )}
         </DropdownMenu.Content>
         </DropdownMenu.Content>
       </DropdownMenu>
       </DropdownMenu>
     </>
     </>

文件差异内容过多而无法显示
+ 628 - 61
src/components/App.tsx


+ 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 (
   return (
     <>
     <>
-      {appState.openDialog === "jsonExport" && (
+      {appState.openDialog?.name === "jsonExport" && (
         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
           <JSONExportModal
           <JSONExportModal
             elements={elements}
             elements={elements}

+ 44 - 5
src/components/LayerUI.tsx

@@ -1,7 +1,12 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import React from "react";
 import React from "react";
 import { ActionManager } from "../actions/manager";
 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 { showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
 import { Language, t } from "../i18n";
@@ -56,6 +61,7 @@ import { mutateElement } from "../element/mutateElement";
 import { ShapeCache } from "../scene/ShapeCache";
 import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
 import Scene from "../scene/Scene";
 import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
 import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
+import { MagicSettings } from "./MagicSettings";
 
 
 interface LayerUIProps {
 interface LayerUIProps {
   actionManager: ActionManager;
   actionManager: ActionManager;
@@ -77,6 +83,14 @@ interface LayerUIProps {
   children?: React.ReactNode;
   children?: React.ReactNode;
   app: AppClassProperties;
   app: AppClassProperties;
   isCollaborating: boolean;
   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<{
 const DefaultMainMenu: React.FC<{
@@ -133,6 +147,10 @@ const LayerUI = ({
   children,
   children,
   app,
   app,
   isCollaborating,
   isCollaborating,
+  openAIKey,
+  isOpenAIKeyPersisted,
+  onOpenAIAPIKeyChange,
+  onMagicSettingsConfirm,
 }: LayerUIProps) => {
 }: LayerUIProps) => {
   const device = useDevice();
   const device = useDevice();
   const tunnels = useInitializeTunnels();
   const tunnels = useInitializeTunnels();
@@ -163,7 +181,7 @@ const LayerUI = ({
   const renderImageExportDialog = () => {
   const renderImageExportDialog = () => {
     if (
     if (
       !UIOptions.canvasActions.saveAsImage ||
       !UIOptions.canvasActions.saveAsImage ||
-      appState.openDialog !== "imageExport"
+      appState.openDialog?.name !== "imageExport"
     ) {
     ) {
       return null;
       return null;
     }
     }
@@ -295,9 +313,11 @@ const LayerUI = ({
                         >
                         >
                           <LaserPointerButton
                           <LaserPointerButton
                             title={t("toolBar.laser")}
                             title={t("toolBar.laser")}
-                            checked={appState.activeTool.type === "laser"}
+                            checked={
+                              appState.activeTool.type === TOOL_TYPE.laser
+                            }
                             onChange={() =>
                             onChange={() =>
-                              app.setActiveTool({ type: "laser" })
+                              app.setActiveTool({ type: TOOL_TYPE.laser })
                             }
                             }
                             isMobile
                             isMobile
                           />
                           />
@@ -432,13 +452,32 @@ const LayerUI = ({
           }}
           }}
         />
         />
       )}
       )}
-      {appState.openDialog === "help" && (
+      {appState.openDialog?.name === "help" && (
         <HelpDialog
         <HelpDialog
           onClose={() => {
           onClose={() => {
             setAppState({ openDialog: null });
             setAppState({ openDialog: null });
           }}
           }}
         />
         />
       )}
       )}
+      {appState.openDialog?.name === "magicSettings" && (
+        <MagicSettings
+          openAIKey={openAIKey}
+          isPersisted={isOpenAIKeyPersisted}
+          onChange={onOpenAIAPIKeyChange}
+          onConfirm={(apiKey, shouldPersist) => {
+            const source =
+              appState.openDialog?.name === "magicSettings"
+                ? appState.openDialog?.source
+                : "settings";
+            setAppState({ openDialog: null }, () => {
+              onMagicSettingsConfirm(apiKey, shouldPersist, source);
+            });
+          }}
+          onClose={() => {
+            setAppState({ openDialog: null });
+          }}
+        />
+      )}
       <ActiveConfirmDialog />
       <ActiveConfirmDialog />
       <tunnels.OverwriteConfirmDialogTunnel.Out />
       <tunnels.OverwriteConfirmDialogTunnel.Out />
       {renderImageExportDialog()}
       {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>
+  );
+};

+ 9 - 0
src/components/MagicSettings.scss

@@ -0,0 +1,9 @@
+.excalidraw {
+  .MagicSettings-confirm {
+    padding: 0.5rem 1rem;
+  }
+
+  .MagicSettings__confirm {
+    margin-top: 2rem;
+  }
+}

+ 130 - 0
src/components/MagicSettings.tsx

@@ -0,0 +1,130 @@
+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";
+
+export const MagicSettings = (props: {
+  openAIKey: string | null;
+  isPersisted: boolean;
+  onChange: (key: string, shouldPersist: boolean) => void;
+  onConfirm: (key: string, shouldPersist: boolean) => void;
+  onClose: () => void;
+}) => {
+  const { theme } = useUIAppState();
+  const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
+  const [shouldPersist, setShouldPersist] = useState<boolean>(
+    props.isPersisted,
+  );
+
+  const onConfirm = () => {
+    props.onConfirm(keyInputValue.trim(), shouldPersist);
+  };
+
+  return (
+    <Dialog
+      onCloseRequest={() => {
+        props.onClose();
+        props.onConfirm(keyInputValue.trim(), shouldPersist);
+      }}
+      title={
+        <div style={{ display: "flex" }}>
+          Diagram to Code (AI){" "}
+          <div
+            style={{
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+              padding: "0.1rem 0.5rem",
+              marginLeft: "1rem",
+              fontSize: 14,
+              borderRadius: "12px",
+              background: theme === "light" ? "#FFCCCC" : "#703333",
+            }}
+          >
+            Experimental
+          </div>
+        </div>
+      }
+      className="MagicSettings"
+      autofocus={false}
+    >
+      <Paragraph
+        style={{
+          display: "inline-flex",
+          alignItems: "center",
+          marginBottom: 0,
+        }}
+      >
+        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}
+      />
+    </Dialog>
+  );
+};

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

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

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

+ 7 - 31
src/components/PublishLibrary.tsx

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

+ 22 - 2
src/components/TextField.tsx

@@ -4,12 +4,15 @@ import {
   useImperativeHandle,
   useImperativeHandle,
   KeyboardEvent,
   KeyboardEvent,
   useLayoutEffect,
   useLayoutEffect,
+  useState,
 } from "react";
 } from "react";
 import clsx from "clsx";
 import clsx from "clsx";
 
 
 import "./TextField.scss";
 import "./TextField.scss";
+import { Button } from "./Button";
+import { eyeIcon, eyeClosedIcon } from "./icons";
 
 
-export type TextFieldProps = {
+type TextFieldProps = {
   value?: string;
   value?: string;
 
 
   onChange?: (value: string) => void;
   onChange?: (value: string) => void;
@@ -22,6 +25,7 @@ export type TextFieldProps = {
 
 
   label?: string;
   label?: string;
   placeholder?: string;
   placeholder?: string;
+  isRedacted?: boolean;
 };
 };
 
 
 export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
 export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       readonly,
       readonly,
       selectOnRender,
       selectOnRender,
       onKeyDown,
       onKeyDown,
+      isRedacted = false,
     },
     },
     ref,
     ref,
   ) => {
   ) => {
@@ -48,6 +53,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       }
       }
     }, [selectOnRender]);
     }, [selectOnRender]);
 
 
+    const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
+      useState<boolean>(false);
+
     return (
     return (
       <div
       <div
         className={clsx("ExcTextField", {
         className={clsx("ExcTextField", {
@@ -64,14 +72,26 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
           })}
           })}
         >
         >
           <input
           <input
+            className={clsx({
+              "is-redacted": value && isRedacted && !isTemporarilyUnredacted,
+            })}
             readOnly={readonly}
             readOnly={readonly}
-            type="text"
             value={value}
             value={value}
             placeholder={placeholder}
             placeholder={placeholder}
             ref={innerRef}
             ref={innerRef}
             onChange={(event) => onChange?.(event.target.value)}
             onChange={(event) => onChange?.(event.target.value)}
             onKeyDown={onKeyDown}
             onKeyDown={onKeyDown}
           />
           />
+          {isRedacted && (
+            <Button
+              onSelect={() =>
+                setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
+              }
+              style={{ border: 0, userSelect: "none" }}
+            >
+              {isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
+            </Button>
+          )}
         </div>
         </div>
       </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);
       width: var(--default-button-size);
       height: 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,
   suggestedBindings: appState.suggestedBindings,
   isRotating: appState.isRotating,
   isRotating: appState.isRotating,
   elementsToHighlight: appState.elementsToHighlight,
   elementsToHighlight: appState.elementsToHighlight,
-  openSidebar: appState.openSidebar,
-  showHyperlinkPopup: appState.showHyperlinkPopup,
   collaborators: appState.collaborators, // Necessary for collab. sessions
   collaborators: appState.collaborators, // Necessary for collab. sessions
   activeEmbeddable: appState.activeEmbeddable,
   activeEmbeddable: appState.activeEmbeddable,
   snapLines: appState.snapLines,
   snapLines: appState.snapLines,

+ 54 - 0
src/components/icons.tsx

@@ -1688,3 +1688,57 @@ export const laserPointerToolIcon = createIcon(
 
 
   20,
   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,
+);

+ 2 - 2
src/components/main-menu/DefaultItems.tsx

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

+ 31 - 1
src/constants.ts

@@ -80,6 +80,7 @@ export enum EVENT {
   EXCALIDRAW_LINK = "excalidraw-link",
   EXCALIDRAW_LINK = "excalidraw-link",
   MENU_ITEM_SELECT = "menu.itemSelect",
   MENU_ITEM_SELECT = "menu.itemSelect",
   MESSAGE = "message",
   MESSAGE = "message",
+  FULLSCREENCHANGE = "fullscreenchange",
 }
 }
 
 
 export const YOUTUBE_STATES = {
 export const YOUTUBE_STATES = {
@@ -344,4 +345,33 @@ export const DEFAULT_SIDEBAR = {
   defaultTab: LIBRARY_SIDEBAR_TAB,
   defaultTab: LIBRARY_SIDEBAR_TAB,
 } as const;
 } 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;

+ 12 - 0
src/css/styles.scss

@@ -5,9 +5,11 @@
   --zIndex-canvas: 1;
   --zIndex-canvas: 1;
   --zIndex-interactiveCanvas: 2;
   --zIndex-interactiveCanvas: 2;
   --zIndex-wysiwyg: 3;
   --zIndex-wysiwyg: 3;
+  --zIndex-canvasButtons: 3;
   --zIndex-layerUI: 4;
   --zIndex-layerUI: 4;
   --zIndex-eyeDropperBackdrop: 5;
   --zIndex-eyeDropperBackdrop: 5;
   --zIndex-eyeDropperPreview: 6;
   --zIndex-eyeDropperPreview: 6;
+  --zIndex-hyperlinkContainer: 7;
 
 
   --zIndex-modal: 1000;
   --zIndex-modal: 1000;
   --zIndex-popup: 1001;
   --zIndex-popup: 1001;
@@ -531,6 +533,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"],
   input[type="text"],
   textarea:not(.excalidraw-wysiwyg) {
   textarea:not(.excalidraw-wysiwyg) {
     color: var(--text-primary-color);
     color: var(--text-primary-color);
@@ -726,4 +734,8 @@
     letter-spacing: 0.6px;
     letter-spacing: 0.6px;
     font-family: "Assistant";
     font-family: "Assistant";
   }
   }
+
+  .excalidraw__paragraph {
+    margin: 1rem 0;
+  }
 }
 }

+ 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,
   copyTextToSystemClipboard,
 } from "../clipboard";
 } from "../clipboard";
 import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
 import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
-import { getNonDeletedElements, isFrameElement } from "../element";
+import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
 } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
@@ -38,7 +39,7 @@ export const prepareElementsForExport = (
     exportSelectionOnly &&
     exportSelectionOnly &&
     isSomeElementSelected(elements, { selectedElementIds });
     isSomeElementSelected(elements, { selectedElementIds });
 
 
-  let exportingFrame: ExcalidrawFrameElement | null = null;
+  let exportingFrame: ExcalidrawFrameLikeElement | null = null;
   let exportedElements = isExportingSelection
   let exportedElements = isExportingSelection
     ? getSelectedElements(
     ? getSelectedElements(
         elements,
         elements,
@@ -50,7 +51,10 @@ export const prepareElementsForExport = (
     : elements;
     : elements;
 
 
   if (isExportingSelection) {
   if (isExportingSelection) {
-    if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
+    if (
+      exportedElements.length === 1 &&
+      isFrameLikeElement(exportedElements[0])
+    ) {
       exportingFrame = exportedElements[0];
       exportingFrame = exportedElements[0];
       exportedElements = elementsOverlappingBBox({
       exportedElements = elementsOverlappingBBox({
         elements,
         elements,
@@ -93,7 +97,7 @@ export const exportCanvas = async (
     viewBackgroundColor: string;
     viewBackgroundColor: string;
     name: string;
     name: string;
     fileHandle?: FileSystemHandle | null;
     fileHandle?: FileSystemHandle | null;
-    exportingFrame: ExcalidrawFrameElement | null;
+    exportingFrame: ExcalidrawFrameLikeElement | null;
   },
   },
 ) => {
 ) => {
   if (elements.length === 0) {
   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 {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
+  ExcalidrawElementType,
   ExcalidrawSelectionElement,
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   FontFamilyValues,
   FontFamilyValues,
@@ -68,6 +69,7 @@ export const AllowedExcalidrawActiveTools: Record<
   embeddable: true,
   embeddable: true,
   hand: true,
   hand: true,
   laser: false,
   laser: false,
+  magicframe: false,
 };
 };
 
 
 export type RestoredDataState = {
 export type RestoredDataState = {
@@ -111,7 +113,7 @@ const restoreElementWithProperties = <
     // @ts-ignore TS complains here but type checks the call sites fine.
     // @ts-ignore TS complains here but type checks the call sites fine.
     keyof K
     keyof K
   > &
   > &
-    Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
+    Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
 ): T => {
 ): T => {
   const base: Pick<T, keyof ExcalidrawElement> & {
   const base: Pick<T, keyof ExcalidrawElement> & {
     [PRECEDING_ELEMENT_KEY]?: string;
     [PRECEDING_ELEMENT_KEY]?: string;
@@ -159,8 +161,9 @@ const restoreElementWithProperties = <
     locked: element.locked ?? false,
     locked: element.locked ?? false,
   };
   };
 
 
-  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) {
   if (PRECEDING_ELEMENT_KEY in element) {
@@ -273,7 +276,7 @@ const restoreElement = (
 
 
       return restoreElementWithProperties(element, {
       return restoreElementWithProperties(element, {
         type:
         type:
-          (element.type as ExcalidrawElement["type"] | "draw") === "draw"
+          (element.type as ExcalidrawElementType | "draw") === "draw"
             ? "line"
             ? "line"
             : element.type,
             : element.type,
         startBinding: repairBinding(element.startBinding),
         startBinding: repairBinding(element.startBinding),
@@ -289,16 +292,16 @@ const restoreElement = (
 
 
     // generic elements
     // generic elements
     case "ellipse":
     case "ellipse":
-      return restoreElementWithProperties(element, {});
     case "rectangle":
     case "rectangle":
-      return restoreElementWithProperties(element, {});
     case "diamond":
     case "diamond":
+    case "iframe":
       return restoreElementWithProperties(element, {});
       return restoreElementWithProperties(element, {});
     case "embeddable":
     case "embeddable":
       return restoreElementWithProperties(element, {
       return restoreElementWithProperties(element, {
         validated: null,
         validated: null,
         scale: element.scale ?? [1, 1],
         scale: element.scale ?? [1, 1],
       });
       });
+    case "magicframe":
     case "frame":
     case "frame":
       return restoreElementWithProperties(element, {
       return restoreElementWithProperties(element, {
         name: element.name ?? null,
         name: element.name ?? null,

+ 44 - 8
src/data/transform.ts

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

+ 2 - 1
src/element/Hyperlink.tsx

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

+ 12 - 4
src/element/bounds.ts

@@ -22,6 +22,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
 import { Mutable } from "../utility-types";
 import { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
 import { ShapeCache } from "../scene/ShapeCache";
+import Scene from "../scene/Scene";
 
 
 export type RectangleBox = {
 export type RectangleBox = {
   x: number;
   x: number;
@@ -59,10 +60,17 @@ export class ElementBounds {
 
 
     const bounds = ElementBounds.calculateBounds(element);
     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;
     return bounds;
   }
   }

+ 25 - 12
src/element/collision.ts

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

+ 2 - 2
src/element/dragElements.ts

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

+ 56 - 38
src/element/embeddable.ts

@@ -6,25 +6,19 @@ import { getFontString, updateActiveTool } from "../utils";
 import { setCursorForShape } from "../cursor";
 import { setCursorForShape } from "../cursor";
 import { newTextElement } from "./newElement";
 import { newTextElement } from "./newElement";
 import { getContainerElement, wrapText } from "./textElement";
 import { getContainerElement, wrapText } from "./textElement";
-import { isEmbeddableElement } from "./typeChecks";
+import {
+  isFrameLikeElement,
+  isIframeElement,
+  isIframeLikeElement,
+} from "./typeChecks";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
-  ExcalidrawEmbeddableElement,
+  ExcalidrawIframeLikeElement,
+  IframeData,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
-  Theme,
 } from "./types";
 } 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 =
 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]*$/;
   /^(?: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",
   "dddice.com",
 ]);
 ]);
 
 
-const createSrcDoc = (body: string) => {
+export const createSrcDoc = (body: string) => {
   return `<html><body>${body}</body></html>`;
   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) {
   if (!link) {
     return null;
     return null;
   }
   }
@@ -104,8 +100,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
         break;
         break;
     }
     }
     aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
     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);
   const vimeoLink = link.match(RE_VIMEO);
@@ -119,8 +119,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
     aspectRatio = { w: 560, h: 315 };
     aspectRatio = { w: 560, h: 315 };
     //warning deliberately ommited so it is displayed only once per link
     //warning deliberately ommited so it is displayed only once per link
     //same link next time will be served from cache
     //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);
   const figmaLink = link.match(RE_FIGMA);
@@ -130,27 +134,35 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
       link,
       link,
     )}`;
     )}`;
     aspectRatio = { w: 550, h: 550 };
     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);
   const valLink = link.match(RE_VALTOWN);
   if (valLink) {
   if (valLink) {
     link =
     link =
       valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
       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)) {
   if (RE_TWITTER.test(link)) {
-    let ret: EmbeddedLink;
+    let ret: IframeData;
     // assume embed code
     // assume embed code
     if (/<blockquote/.test(link)) {
     if (/<blockquote/.test(link)) {
       const srcDoc = createSrcDoc(link);
       const srcDoc = createSrcDoc(link);
       ret = {
       ret = {
         type: "document",
         type: "document",
         srcdoc: () => srcDoc,
         srcdoc: () => srcDoc,
-        aspectRatio: { w: 480, h: 480 },
+        intrinsicSize: { w: 480, h: 480 },
       };
       };
       // assume regular tweet url
       // assume regular tweet url
     } else {
     } else {
@@ -160,7 +172,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
           createSrcDoc(
           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>`,
             `<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);
     embeddedLinkCache.set(originalLink, ret);
@@ -168,14 +180,14 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
   }
   }
 
 
   if (RE_GH_GIST.test(link)) {
   if (RE_GH_GIST.test(link)) {
-    let ret: EmbeddedLink;
+    let ret: IframeData;
     // assume embed code
     // assume embed code
     if (/<script>/.test(link)) {
     if (/<script>/.test(link)) {
       const srcDoc = createSrcDoc(link);
       const srcDoc = createSrcDoc(link);
       ret = {
       ret = {
         type: "document",
         type: "document",
         srcdoc: () => srcDoc,
         srcdoc: () => srcDoc,
-        aspectRatio: { w: 550, h: 720 },
+        intrinsicSize: { w: 550, h: 720 },
       };
       };
       // assume regular url
       // assume regular url
     } else {
     } 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; }
             .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
           </style>
           </style>
         `),
         `),
-        aspectRatio: { w: 550, h: 720 },
+        intrinsicSize: { w: 550, h: 720 },
       };
       };
     }
     }
     embeddedLinkCache.set(link, ret);
     embeddedLinkCache.set(link, ret);
     return 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,
   element: NonDeletedExcalidrawElement,
 ): Boolean => {
 ): Boolean => {
-  if (isEmbeddableElement(element)) {
+  if (isIframeLikeElement(element)) {
     return true;
     return true;
   }
   }
   if (element.type === "text") {
   if (element.type === "text") {
     const container = getContainerElement(element);
     const container = getContainerElement(element);
-    if (container && isEmbeddableElement(container)) {
+    if (container && isFrameLikeElement(container)) {
       return true;
       return true;
     }
     }
   }
   }
@@ -217,10 +229,16 @@ export const isEmbeddableOrLabel = (
 };
 };
 
 
 export const createPlaceholderEmbeddableLabel = (
 export const createPlaceholderEmbeddableLabel = (
-  element: ExcalidrawEmbeddableElement,
+  element: ExcalidrawIframeLikeElement,
 ): ExcalidrawElement => {
 ): 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(
   const fontSize = Math.max(
     Math.min(element.width / 2, element.width / text.length),
     Math.min(element.width / 2, element.width / text.length),
     element.width / 30,
     element.width / 30,

+ 4 - 16
src/element/index.ts

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

+ 29 - 0
src/element/newElement.ts

@@ -14,6 +14,8 @@ import {
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
   ExcalidrawFrameElement,
   ExcalidrawFrameElement,
   ExcalidrawEmbeddableElement,
   ExcalidrawEmbeddableElement,
+  ExcalidrawMagicFrameElement,
+  ExcalidrawIframeElement,
 } from "../element/types";
 } from "../element/types";
 import {
 import {
   arrayToMap,
   arrayToMap,
@@ -144,6 +146,16 @@ export const newEmbeddableElement = (
   };
   };
 };
 };
 
 
+export const newIframeElement = (
+  opts: {
+    type: "iframe";
+  } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawIframeElement> => {
+  return {
+    ..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
+  };
+};
+
 export const newFrameElement = (
 export const newFrameElement = (
   opts: {
   opts: {
     name?: string;
     name?: string;
@@ -161,6 +173,23 @@ export const newFrameElement = (
   return frameElement;
   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 */
 /** computes element x/y offset based on textAlign/verticalAlign */
 const getTextElementPositionOffsets = (
 const getTextElementPositionOffsets = (
   opts: {
   opts: {

+ 4 - 5
src/element/resizeElements.ts

@@ -27,8 +27,7 @@ import {
 import {
 import {
   isArrowElement,
   isArrowElement,
   isBoundToContainer,
   isBoundToContainer,
-  isEmbeddableElement,
-  isFrameElement,
+  isFrameLikeElement,
   isFreeDrawElement,
   isFreeDrawElement,
   isImageElement,
   isImageElement,
   isLinearElement,
   isLinearElement,
@@ -164,7 +163,7 @@ const rotateSingleElement = (
   const cx = (x1 + x2) / 2;
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
   const cy = (y1 + y2) / 2;
   let angle: number;
   let angle: number;
-  if (isFrameElement(element)) {
+  if (isFrameLikeElement(element)) {
     angle = 0;
     angle = 0;
   } else {
   } else {
     angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
     angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
@@ -587,7 +586,7 @@ export const resizeSingleElement = (
   };
   };
 
 
   if ("scale" in element && "scale" in stateAtResizeStart) {
   if ("scale" in element && "scale" in stateAtResizeStart) {
-    if (isEmbeddableElement(element)) {
+    if (isFrameLikeElement(element)) {
       if (shouldMaintainAspectRatio) {
       if (shouldMaintainAspectRatio) {
         const scale: [number, number] = [
         const scale: [number, number] = [
           Math.abs(
           Math.abs(
@@ -917,7 +916,7 @@ const rotateMultipleElements = (
   }
   }
 
 
   elements
   elements
-    .filter((element) => element.type !== "frame")
+    .filter((element) => !isFrameLikeElement(element))
     .forEach((element) => {
     .forEach((element) => {
       const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
       const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
       const cx = (x1 + x2) / 2;
       const cx = (x1 + x2) / 2;

+ 2 - 1
src/element/textElement.ts

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

+ 2 - 2
src/element/transformHandles.ts

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

+ 44 - 20
src/element/typeChecks.ts

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

+ 37 - 1
src/element/types.ts

@@ -7,6 +7,7 @@ import {
   VERTICAL_ALIGN,
   VERTICAL_ALIGN,
 } from "../constants";
 } from "../constants";
 import { MarkNonNullable, ValueOf } from "../utility-types";
 import { MarkNonNullable, ValueOf } from "../utility-types";
+import { MagicCacheData } from "../data/magic";
 
 
 export type ChartType = "bar" | "line";
 export type ChartType = "bar" | "line";
 export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
 export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@@ -98,6 +99,26 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
     scale: [number, number];
     scale: [number, number];
   }>;
   }>;
 
 
+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 &
 export type ExcalidrawImageElement = _ExcalidrawElementBase &
   Readonly<{
   Readonly<{
     type: "image";
     type: "image";
@@ -118,6 +139,15 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
   name: string | null;
   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.
  * These are elements that don't have any additional properties.
  */
  */
@@ -139,6 +169,8 @@ export type ExcalidrawElement =
   | ExcalidrawFreeDrawElement
   | ExcalidrawFreeDrawElement
   | ExcalidrawImageElement
   | ExcalidrawImageElement
   | ExcalidrawFrameElement
   | ExcalidrawFrameElement
+  | ExcalidrawMagicFrameElement
+  | ExcalidrawIframeElement
   | ExcalidrawEmbeddableElement;
   | ExcalidrawEmbeddableElement;
 
 
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
@@ -171,8 +203,10 @@ export type ExcalidrawBindableElement =
   | ExcalidrawEllipseElement
   | ExcalidrawEllipseElement
   | ExcalidrawTextElement
   | ExcalidrawTextElement
   | ExcalidrawImageElement
   | ExcalidrawImageElement
+  | ExcalidrawIframeElement
   | ExcalidrawEmbeddableElement
   | ExcalidrawEmbeddableElement
-  | ExcalidrawFrameElement;
+  | ExcalidrawFrameElement
+  | ExcalidrawMagicFrameElement;
 
 
 export type ExcalidrawTextContainer =
 export type ExcalidrawTextContainer =
   | ExcalidrawRectangleElement
   | ExcalidrawRectangleElement
@@ -218,3 +252,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
   }>;
   }>;
 
 
 export type FileId = string & { _brand: "FileId" };
 export type FileId = string & { _brand: "FileId" };
+
+export type ExcalidrawElementType = ExcalidrawElement["type"];

+ 49 - 35
src/frame.ts

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

+ 6 - 1
src/locales/en.json

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

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

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

+ 2 - 2
src/packages/utils.ts

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

+ 15 - 3
src/renderer/renderElement.ts

@@ -13,7 +13,8 @@ import {
   isInitializedImageElement,
   isInitializedImageElement,
   isArrowElement,
   isArrowElement,
   hasBoundTextElement,
   hasBoundTextElement,
-  isEmbeddableElement,
+  isFrameLikeElement,
+  isMagicFrameElement,
 } from "../element/typeChecks";
 } from "../element/typeChecks";
 import { getElementAbsoluteCoords } from "../element/bounds";
 import { getElementAbsoluteCoords } from "../element/bounds";
 import type { RoughCanvas } from "roughjs/bin/canvas";
 import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -273,6 +274,7 @@ const drawElementOnCanvas = (
     ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
     ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
+    case "iframe":
     case "embeddable":
     case "embeddable":
     case "diamond":
     case "diamond":
     case "ellipse": {
     case "ellipse": {
@@ -520,7 +522,7 @@ const drawElementFromCanvas = (
     if (
     if (
       "scale" in elementWithCanvas.element &&
       "scale" in elementWithCanvas.element &&
       !isPendingImageElement(element, renderConfig) &&
       !isPendingImageElement(element, renderConfig) &&
-      !isEmbeddableElement(element)
+      !isFrameLikeElement(element)
     ) {
     ) {
       context.scale(
       context.scale(
         elementWithCanvas.element.scale[0],
         elementWithCanvas.element.scale[0],
@@ -596,6 +598,7 @@ export const renderElement = (
   appState: StaticCanvasAppState,
   appState: StaticCanvasAppState,
 ) => {
 ) => {
   switch (element.type) {
   switch (element.type) {
+    case "magicframe":
     case "frame": {
     case "frame": {
       if (appState.frameRendering.enabled && appState.frameRendering.outline) {
       if (appState.frameRendering.enabled && appState.frameRendering.outline) {
         context.save();
         context.save();
@@ -608,6 +611,12 @@ export const renderElement = (
         context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
         context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
         context.strokeStyle = FRAME_STYLE.strokeColor;
         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) {
         if (FRAME_STYLE.radius && context.roundRect) {
           context.beginPath();
           context.beginPath();
           context.roundRect(
           context.roundRect(
@@ -668,6 +677,7 @@ export const renderElement = (
     case "arrow":
     case "arrow":
     case "image":
     case "image":
     case "text":
     case "text":
+    case "iframe":
     case "embeddable": {
     case "embeddable": {
       // TODO investigate if we can do this in situ. Right now we need to call
       // TODO investigate if we can do this in situ. Right now we need to call
       // beforehand because math helpers (such as getElementAbsoluteCoords)
       // beforehand because math helpers (such as getElementAbsoluteCoords)
@@ -953,6 +963,7 @@ export const renderElementToSvg = (
       addToRoot(g || node, element);
       addToRoot(g || node, element);
       break;
       break;
     }
     }
+    case "iframe":
     case "embeddable": {
     case "embeddable": {
       // render placeholder rectangle
       // render placeholder rectangle
       const shape = ShapeCache.generateElementShape(element, true);
       const shape = ShapeCache.generateElementShape(element, true);
@@ -1256,7 +1267,8 @@ export const renderElementToSvg = (
       break;
       break;
     }
     }
     // frames are not rendered and only acts as a container
     // frames are not rendered and only acts as a container
-    case "frame": {
+    case "frame":
+    case "magicframe": {
       if (
       if (
         renderConfig.frameRendering.enabled &&
         renderConfig.frameRendering.enabled &&
         renderConfig.frameRendering.outline
         renderConfig.frameRendering.outline

+ 16 - 12
src/renderer/renderScene.ts

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

+ 14 - 17
src/scene/Scene.ts

@@ -2,15 +2,11 @@ import {
   ExcalidrawElement,
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
   NonDeleted,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
 } from "../element/types";
 } from "../element/types";
-import {
-  getNonDeletedElements,
-  getNonDeletedFrames,
-  isNonDeletedElement,
-} from "../element";
+import { getNonDeletedElements, isNonDeletedElement } from "../element";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import { isFrameElement } from "../element/typeChecks";
+import { isFrameLikeElement } from "../element/typeChecks";
 import { getSelectedElements } from "./selection";
 import { getSelectedElements } from "./selection";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { Assert, SameType } from "../utility-types";
 import { Assert, SameType } from "../utility-types";
@@ -107,8 +103,9 @@ class Scene {
 
 
   private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
   private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
   private elements: readonly ExcalidrawElement[] = [];
   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 elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
   private selectedElementsCache: {
   private selectedElementsCache: {
     selectedElementIds: AppState["selectedElementIds"] | null;
     selectedElementIds: AppState["selectedElementIds"] | null;
@@ -179,8 +176,8 @@ class Scene {
     return selectedElements;
     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 {
   getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
@@ -235,18 +232,18 @@ class Scene {
     mapElementIds = true,
     mapElementIds = true,
   ) {
   ) {
     this.elements = nextElements;
     this.elements = nextElements;
-    const nextFrames: ExcalidrawFrameElement[] = [];
+    const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
     this.elementsMap.clear();
     this.elementsMap.clear();
     nextElements.forEach((element) => {
     nextElements.forEach((element) => {
-      if (isFrameElement(element)) {
-        nextFrames.push(element);
+      if (isFrameLikeElement(element)) {
+        nextFrameLikes.push(element);
       }
       }
       this.elementsMap.set(element.id, element);
       this.elementsMap.set(element.id, element);
       Scene.mapElementToScene(element, this);
       Scene.mapElementToScene(element, this);
     });
     });
     this.nonDeletedElements = getNonDeletedElements(this.elements);
     this.nonDeletedElements = getNonDeletedElements(this.elements);
-    this.frames = nextFrames;
-    this.nonDeletedFrames = getNonDeletedFrames(this.frames);
+    this.frames = nextFrameLikes;
+    this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
 
 
     this.informMutation();
     this.informMutation();
   }
   }
@@ -277,7 +274,7 @@ class Scene {
   destroy() {
   destroy() {
     this.nonDeletedElements = [];
     this.nonDeletedElements = [];
     this.elements = [];
     this.elements = [];
-    this.nonDeletedFrames = [];
+    this.nonDeletedFramesLikes = [];
     this.frames = [];
     this.frames = [];
     this.elementsMap.clear();
     this.elementsMap.clear();
     this.selectedElementsCache.selectedElementIds = null;
     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 { isTransparent, assertNever } from "../utils";
 import { simplify } from "points-on-curve";
 import { simplify } from "points-on-curve";
 import { ROUGHNESS } from "../constants";
 import { ROUGHNESS } from "../constants";
-import { isLinearElement } from "../element/typeChecks";
+import {
+  isEmbeddableElement,
+  isIframeElement,
+  isIframeLikeElement,
+  isLinearElement,
+} from "../element/typeChecks";
 import { canChangeRoundness } from "./comparisons";
 import { canChangeRoundness } from "./comparisons";
 
 
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -78,6 +83,7 @@ export const generateRoughOptions = (
 
 
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
+    case "iframe":
     case "embeddable":
     case "embeddable":
     case "diamond":
     case "diamond":
     case "ellipse": {
     case "ellipse": {
@@ -109,13 +115,13 @@ export const generateRoughOptions = (
   }
   }
 };
 };
 
 
-const modifyEmbeddableForRoughOptions = (
+const modifyIframeLikeForRoughOptions = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,
   isExporting: boolean,
   isExporting: boolean,
 ) => {
 ) => {
   if (
   if (
-    element.type === "embeddable" &&
-    (isExporting || !element.validated) &&
+    isIframeLikeElement(element) &&
+    (isExporting || (isEmbeddableElement(element) && !element.validated)) &&
     isTransparent(element.backgroundColor) &&
     isTransparent(element.backgroundColor) &&
     isTransparent(element.strokeColor)
     isTransparent(element.strokeColor)
   ) {
   ) {
@@ -125,6 +131,16 @@ const modifyEmbeddableForRoughOptions = (
       backgroundColor: "#d3d3d3",
       backgroundColor: "#d3d3d3",
       fillStyle: "solid",
       fillStyle: "solid",
     } as const;
     } as const;
+  } else if (isIframeElement(element)) {
+    return {
+      ...element,
+      strokeColor: isTransparent(element.strokeColor)
+        ? "#000000"
+        : element.strokeColor,
+      backgroundColor: isTransparent(element.backgroundColor)
+        ? "#f4f4f6"
+        : element.backgroundColor,
+    };
   }
   }
   return element;
   return element;
 };
 };
@@ -143,6 +159,7 @@ export const _generateElementShape = (
 ): Drawable | Drawable[] | null => {
 ): Drawable | Drawable[] | null => {
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
+    case "iframe":
     case "embeddable": {
     case "embeddable": {
       let shape: ElementShapes[typeof element.type];
       let shape: ElementShapes[typeof element.type];
       // this is for rendering the stroke/bg of the embeddable, especially
       // this is for rendering the stroke/bg of the embeddable, especially
@@ -159,7 +176,7 @@ export const _generateElementShape = (
             h - r
             h - r
           } L 0 ${r} Q 0 0, ${r} 0`,
           } L 0 ${r} Q 0 0, ${r} 0`,
           generateRoughOptions(
           generateRoughOptions(
-            modifyEmbeddableForRoughOptions(element, isExporting),
+            modifyIframeLikeForRoughOptions(element, isExporting),
             true,
             true,
           ),
           ),
         );
         );
@@ -170,7 +187,7 @@ export const _generateElementShape = (
           element.width,
           element.width,
           element.height,
           element.height,
           generateRoughOptions(
           generateRoughOptions(
-            modifyEmbeddableForRoughOptions(element, isExporting),
+            modifyIframeLikeForRoughOptions(element, isExporting),
             false,
             false,
           ),
           ),
         );
         );
@@ -373,6 +390,7 @@ export const _generateElementShape = (
       return shape;
       return shape;
     }
     }
     case "frame":
     case "frame":
+    case "magicframe":
     case "text":
     case "text":
     case "image": {
     case "image": {
       const shape: ElementShapes[typeof element.type] = null;
       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 {
 import {
-  ExcalidrawEmbeddableElement,
+  ExcalidrawIframeElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
 } from "../element/types";
+import { ElementOrToolType } from "../types";
 
 
-export const hasBackground = (type: string) =>
+export const hasBackground = (type: ElementOrToolType) =>
   type === "rectangle" ||
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "embeddable" ||
   type === "ellipse" ||
   type === "ellipse" ||
   type === "diamond" ||
   type === "diamond" ||
   type === "line" ||
   type === "line" ||
   type === "freedraw";
   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 === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "embeddable" ||
   type === "ellipse" ||
   type === "ellipse" ||
   type === "diamond" ||
   type === "diamond" ||
@@ -24,22 +27,24 @@ export const hasStrokeWidth = (type: string) =>
   type === "arrow" ||
   type === "arrow" ||
   type === "line";
   type === "line";
 
 
-export const hasStrokeStyle = (type: string) =>
+export const hasStrokeStyle = (type: ElementOrToolType) =>
   type === "rectangle" ||
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "embeddable" ||
   type === "ellipse" ||
   type === "ellipse" ||
   type === "diamond" ||
   type === "diamond" ||
   type === "arrow" ||
   type === "arrow" ||
   type === "line";
   type === "line";
 
 
-export const canChangeRoundness = (type: string) =>
+export const canChangeRoundness = (type: ElementOrToolType) =>
   type === "rectangle" ||
   type === "rectangle" ||
+  type === "iframe" ||
   type === "embeddable" ||
   type === "embeddable" ||
   type === "arrow" ||
   type === "arrow" ||
   type === "line" ||
   type === "line" ||
   type === "diamond";
   type === "diamond";
 
 
-export const canHaveArrowheads = (type: string) => type === "arrow";
+export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
 
 
 export const getElementAtPosition = (
 export const getElementAtPosition = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
@@ -67,7 +72,7 @@ export const getElementsAtPosition = (
   elements: readonly NonDeletedExcalidrawElement[],
   elements: readonly NonDeletedExcalidrawElement[],
   isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
   isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
 ) => {
 ) => {
-  const embeddables: ExcalidrawEmbeddableElement[] = [];
+  const iframeLikes: ExcalidrawIframeElement[] = [];
   // The parameter elements comes ordered from lower z-index to higher.
   // The parameter elements comes ordered from lower z-index to higher.
   // We want to preserve that order on the returned array.
   // We want to preserve that order on the returned array.
   // Exception being embeddables which should be on top of everything else in
   // 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 elsAtPos = elements.filter((element) => {
     const hit = !element.isDeleted && isAtPositionFn(element);
     const hit = !element.isDeleted && isAtPositionFn(element);
     if (hit) {
     if (hit) {
-      if (isEmbeddableElement(element)) {
-        embeddables.push(element);
+      if (isIframeElement(element)) {
+        iframeLikes.push(element);
         return false;
         return false;
       }
       }
       return true;
       return true;
     }
     }
     return false;
     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 rough from "roughjs/bin/rough";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
 } from "../element/types";
@@ -27,11 +27,16 @@ import {
   updateImageCache,
   updateImageCache,
 } from "../element/image";
 } from "../element/image";
 import { elementsOverlappingBBox } from "../packages/withinBounds";
 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 { Mutable } from "../utility-types";
 import { newElementWith } from "../element/mutateElement";
 import { newElementWith } from "../element/mutateElement";
 import Scene from "./Scene";
 import Scene from "./Scene";
+import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
 
 
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
 
@@ -100,10 +105,15 @@ const addFrameLabelsAsTextElements = (
   opts: Pick<AppState, "exportWithDarkMode">,
   opts: Pick<AppState, "exportWithDarkMode">,
 ) => {
 ) => {
   const nextElements: NonDeletedExcalidrawElement[] = [];
   const nextElements: NonDeletedExcalidrawElement[] = [];
-  let frameIdx = 0;
+  let frameIndex = 0;
+  let magicFrameIndex = 0;
   for (const element of elements) {
   for (const element of elements) {
-    if (isFrameElement(element)) {
-      frameIdx++;
+    if (isFrameLikeElement(element)) {
+      if (isFrameElement(element)) {
+        frameIndex++;
+      } else {
+        magicFrameIndex++;
+      }
       let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
       let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
         x: element.x,
         x: element.x,
         y: element.y - FRAME_STYLE.nameOffsetY,
         y: element.y - FRAME_STYLE.nameOffsetY,
@@ -114,7 +124,10 @@ const addFrameLabelsAsTextElements = (
         strokeColor: opts.exportWithDarkMode
         strokeColor: opts.exportWithDarkMode
           ? FRAME_STYLE.nameColorDarkTheme
           ? FRAME_STYLE.nameColorDarkTheme
           : FRAME_STYLE.nameColorLightTheme,
           : FRAME_STYLE.nameColorLightTheme,
-        text: element.name || `Frame ${frameIdx}`,
+        text: getFrameLikeTitle(
+          element,
+          isFrameElement(element) ? frameIndex : magicFrameIndex,
+        ),
       });
       });
       textElement.y -= textElement.height;
       textElement.y -= textElement.height;
 
 
@@ -129,7 +142,7 @@ const addFrameLabelsAsTextElements = (
 };
 };
 
 
 const getFrameRenderingConfig = (
 const getFrameRenderingConfig = (
-  exportingFrame: ExcalidrawFrameElement | null,
+  exportingFrame: ExcalidrawFrameLikeElement | null,
   frameRendering: AppState["frameRendering"] | null,
   frameRendering: AppState["frameRendering"] | null,
 ): AppState["frameRendering"] => {
 ): AppState["frameRendering"] => {
   frameRendering = frameRendering || getDefaultAppState().frameRendering;
   frameRendering = frameRendering || getDefaultAppState().frameRendering;
@@ -148,7 +161,7 @@ const prepareElementsForRender = ({
   exportWithDarkMode,
   exportWithDarkMode,
 }: {
 }: {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
-  exportingFrame: ExcalidrawFrameElement | null | undefined;
+  exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
   frameRendering: AppState["frameRendering"];
   frameRendering: AppState["frameRendering"];
   exportWithDarkMode: AppState["exportWithDarkMode"];
   exportWithDarkMode: AppState["exportWithDarkMode"];
 }) => {
 }) => {
@@ -184,7 +197,7 @@ export const exportToCanvas = async (
     exportBackground: boolean;
     exportBackground: boolean;
     exportPadding?: number;
     exportPadding?: number;
     viewBackgroundColor: string;
     viewBackgroundColor: string;
-    exportingFrame?: ExcalidrawFrameElement | null;
+    exportingFrame?: ExcalidrawFrameLikeElement | null;
   },
   },
   createCanvas: (
   createCanvas: (
     width: number,
     width: number,
@@ -274,7 +287,7 @@ export const exportToSvg = async (
   files: BinaryFiles | null,
   files: BinaryFiles | null,
   opts?: {
   opts?: {
     renderEmbeddables?: boolean;
     renderEmbeddables?: boolean;
-    exportingFrame?: ExcalidrawFrameElement | null;
+    exportingFrame?: ExcalidrawFrameLikeElement | null;
   },
   },
 ): Promise<SVGSVGElement> => {
 ): Promise<SVGSVGElement> => {
   const tempScene = __createSceneForElementsHack__(elements);
   const tempScene = __createSceneForElementsHack__(elements);
@@ -360,7 +373,7 @@ export const exportToSvg = async (
   const offsetX = -minX + exportPadding;
   const offsetX = -minX + exportPadding;
   const offsetY = -minY + exportPadding;
   const offsetY = -minY + exportPadding;
 
 
-  const frameElements = getFrameElements(elements);
+  const frameElements = getFrameLikeElements(elements);
 
 
   let exportingFrameClipPath = "";
   let exportingFrameClipPath = "";
   for (const frame of frameElements) {
   for (const frame of frameElements) {

+ 3 - 3
src/scene/selection.ts

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

+ 2 - 0
src/scene/types.ts

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

+ 0 - 8
src/shapes.tsx

@@ -83,14 +83,6 @@ export const SHAPES = [
     numericKey: KEYS["0"],
     numericKey: KEYS["0"],
     fillable: false,
     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;
 ] as const;
 
 
 export const findShapeByKey = (key: string) => {
 export const findShapeByKey = (key: string) => {

+ 9 - 7
src/snapping.ts

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

+ 1 - 1
src/tests/MermaidToExcalidraw.test.tsx

@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
       <Excalidraw
       <Excalidraw
         initialData={{
         initialData={{
           appState: {
           appState: {
-            openDialog: "mermaid",
+            openDialog: { name: "mermaid" },
           },
           },
         }}
         }}
       />,
       />,

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

@@ -7,6 +7,8 @@ import {
   ExcalidrawImageElement,
   ExcalidrawImageElement,
   FileId,
   FileId,
   ExcalidrawFrameElement,
   ExcalidrawFrameElement,
+  ExcalidrawElementType,
+  ExcalidrawMagicFrameElement,
 } from "../../element/types";
 } from "../../element/types";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
 import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@@ -20,7 +22,9 @@ import {
   newEmbeddableElement,
   newEmbeddableElement,
   newFrameElement,
   newFrameElement,
   newFreeDrawElement,
   newFreeDrawElement,
+  newIframeElement,
   newImageElement,
   newImageElement,
+  newMagicFrameElement,
 } from "../../element/newElement";
 } from "../../element/newElement";
 import { Point } from "../../types";
 import { Point } from "../../types";
 import { getSelectedElements } from "../../scene/selection";
 import { getSelectedElements } from "../../scene/selection";
@@ -74,7 +78,7 @@ export class API {
   };
   };
 
 
   static createElement = <
   static createElement = <
-    T extends Exclude<ExcalidrawElement["type"], "selection"> = "rectangle",
+    T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
   >({
   >({
     // @ts-ignore
     // @ts-ignore
     type = "rectangle",
     type = "rectangle",
@@ -139,6 +143,8 @@ export class API {
     ? ExcalidrawImageElement
     ? ExcalidrawImageElement
     : T extends "frame"
     : T extends "frame"
     ? ExcalidrawFrameElement
     ? ExcalidrawFrameElement
+    : T extends "magicframe"
+    ? ExcalidrawMagicFrameElement
     : ExcalidrawGenericElement => {
     : ExcalidrawGenericElement => {
     let element: Mutable<ExcalidrawElement> = null!;
     let element: Mutable<ExcalidrawElement> = null!;
 
 
@@ -202,6 +208,12 @@ export class API {
           validated: null,
           validated: null,
         });
         });
         break;
         break;
+      case "iframe":
+        element = newIframeElement({
+          type: "iframe",
+          ...base,
+        });
+        break;
       case "text":
       case "text":
         const fontSize = rest.fontSize ?? appState.currentItemFontSize;
         const fontSize = rest.fontSize ?? appState.currentItemFontSize;
         const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
         const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
@@ -253,6 +265,9 @@ export class API {
       case "frame":
       case "frame":
         element = newFrameElement({ ...base, width, height });
         element = newFrameElement({ ...base, width, height });
         break;
         break;
+      case "magicframe":
+        element = newMagicFrameElement({ ...base, width, height });
+        break;
       default:
       default:
         assertNever(
         assertNever(
           type,
           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 {
 import type {
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
@@ -20,15 +20,14 @@ import {
   type TransformHandleDirection,
   type TransformHandleDirection,
 } from "../../element/transformHandles";
 } from "../../element/transformHandles";
 import { KEYS } from "../../keys";
 import { KEYS } from "../../keys";
-import { type ToolName } from "../queries/toolQueries";
 import { fireEvent, GlobalTestState, screen } from "../test-utils";
 import { fireEvent, GlobalTestState, screen } from "../test-utils";
 import { mutateElement } from "../../element/mutateElement";
 import { mutateElement } from "../../element/mutateElement";
 import { API } from "./api";
 import { API } from "./api";
 import {
 import {
-  isFrameElement,
   isLinearElement,
   isLinearElement,
   isFreeDrawElement,
   isFreeDrawElement,
   isTextElement,
   isTextElement,
+  isFrameLikeElement,
 } from "../../element/typeChecks";
 } from "../../element/typeChecks";
 import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
 import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
 import { rotatePoint } from "../../math";
 import { rotatePoint } from "../../math";
@@ -290,7 +289,7 @@ const transform = (
     ];
     ];
   } else {
   } else {
     const [x1, y1, x2, y2] = getCommonBounds(elements);
     const [x1, y1, x2, y2] = getCommonBounds(elements);
-    const isFrameSelected = elements.some(isFrameElement);
+    const isFrameSelected = elements.some(isFrameLikeElement);
     const transformHandles = getTransformHandlesFromCoords(
     const transformHandles = getTransformHandlesFromCoords(
       [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
       [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
       0,
       0,
@@ -345,7 +344,7 @@ const proxy = <T extends ExcalidrawElement>(
 };
 };
 
 
 /** Tools that can be used to draw shapes */
 /** 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"
 type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
   ? ExcalidrawLinearElement
   ? ExcalidrawLinearElement
@@ -362,7 +361,7 @@ type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
   : ExcalidrawElement;
   : ExcalidrawElement;
 
 
 export class UI {
 export class UI {
-  static clickTool = (toolName: ToolName) => {
+  static clickTool = (toolName: ToolType | "lock") => {
     fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
     fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
   };
   };
 
 

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

@@ -1,23 +1,9 @@
 import { queries, buildQueries } from "@testing-library/react";
 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}`);
   return queries.getAllByTestId(container, `toolbar-${toolTitle}`);
 };
 };
 
 
@@ -32,7 +18,7 @@ export const [
   getByToolName,
   getByToolName,
   findAllByToolName,
   findAllByToolName,
   findByToolName,
   findByToolName,
-] = buildQueries<string[]>(
+] = buildQueries<(ToolType | "lock")[]>(
   _getAllByToolName,
   _getAllByToolName,
   getMultipleError,
   getMultipleError,
   getMissingError,
   getMissingError,

+ 31 - 10
src/types.ts

@@ -15,9 +15,12 @@ import {
   ExcalidrawImageElement,
   ExcalidrawImageElement,
   Theme,
   Theme,
   StrokeRoundness,
   StrokeRoundness,
-  ExcalidrawFrameElement,
   ExcalidrawEmbeddableElement,
   ExcalidrawEmbeddableElement,
+  ExcalidrawMagicFrameElement,
+  ExcalidrawFrameLikeElement,
+  ExcalidrawElementType,
 } from "./element/types";
 } from "./element/types";
+import { Action } from "./actions/types";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
 import { LinearElementEditor } from "./element/linearElementEditor";
 import { LinearElementEditor } from "./element/linearElementEditor";
 import { SuggestedBinding } from "./element/binding";
 import { SuggestedBinding } from "./element/binding";
@@ -101,9 +104,12 @@ export type ToolType =
   | "eraser"
   | "eraser"
   | "hand"
   | "hand"
   | "frame"
   | "frame"
+  | "magicframe"
   | "embeddable"
   | "embeddable"
   | "laser";
   | "laser";
 
 
+export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
+
 export type ActiveTool =
 export type ActiveTool =
   | {
   | {
       type: ToolType;
       type: ToolType;
@@ -158,9 +164,6 @@ export type InteractiveCanvasAppState = Readonly<
     suggestedBindings: AppState["suggestedBindings"];
     suggestedBindings: AppState["suggestedBindings"];
     isRotating: AppState["isRotating"];
     isRotating: AppState["isRotating"];
     elementsToHighlight: AppState["elementsToHighlight"];
     elementsToHighlight: AppState["elementsToHighlight"];
-    // App
-    openSidebar: AppState["openSidebar"];
-    showHyperlinkPopup: AppState["showHyperlinkPopup"];
     // Collaborators
     // Collaborators
     collaborators: AppState["collaborators"];
     collaborators: AppState["collaborators"];
     // SnapLines
     // SnapLines
@@ -169,7 +172,7 @@ export type InteractiveCanvasAppState = Readonly<
   }
   }
 >;
 >;
 
 
-export type AppState = {
+export interface AppState {
   contextMenu: {
   contextMenu: {
     items: ContextMenuItems;
     items: ContextMenuItems;
     top: number;
     top: number;
@@ -189,7 +192,7 @@ export type AppState = {
   isBindingEnabled: boolean;
   isBindingEnabled: boolean;
   startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
   startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
   suggestedBindings: SuggestedBinding[];
   suggestedBindings: SuggestedBinding[];
-  frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
+  frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
   frameRendering: {
   frameRendering: {
     enabled: boolean;
     enabled: boolean;
     name: boolean;
     name: boolean;
@@ -241,7 +244,16 @@ export type AppState = {
   openMenu: "canvas" | "shape" | null;
   openMenu: "canvas" | "shape" | null;
   openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
   openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
-  openDialog: "imageExport" | "help" | "jsonExport" | "mermaid" | null;
+  openDialog:
+    | null
+    | { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
+    | {
+        name: "magicSettings";
+        source:
+          | "tool" // when magicframe tool is selected
+          | "generation" // when magicframe generate button is clicked
+          | "settings"; // when AI settings dialog is explicitly invoked
+      };
   /**
   /**
    * Reflects user preference for whether the default sidebar should be docked.
    * Reflects user preference for whether the default sidebar should be docked.
    *
    *
@@ -296,7 +308,7 @@ export type AppState = {
     y: number;
     y: number;
   } | null;
   } | null;
   objectsSnapModeEnabled: boolean;
   objectsSnapModeEnabled: boolean;
-};
+}
 
 
 export type UIAppState = Omit<
 export type UIAppState = Omit<
   AppState,
   AppState,
@@ -436,6 +448,7 @@ export interface ExcalidrawProps {
     element: NonDeleted<ExcalidrawEmbeddableElement>,
     element: NonDeleted<ExcalidrawEmbeddableElement>,
     appState: AppState,
     appState: AppState,
   ) => JSX.Element | null;
   ) => JSX.Element | null;
+  aiEnabled?: boolean;
 }
 }
 
 
 export type SceneData = {
 export type SceneData = {
@@ -504,6 +517,7 @@ export type AppProps = Merge<
     handleKeyboardGlobally: boolean;
     handleKeyboardGlobally: boolean;
     isCollaborating: boolean;
     isCollaborating: boolean;
     children?: React.ReactNode;
     children?: React.ReactNode;
+    aiEnabled: boolean;
   }
   }
 >;
 >;
 
 
@@ -537,6 +551,8 @@ export type AppClassProperties = {
   togglePenMode: App["togglePenMode"];
   togglePenMode: App["togglePenMode"];
   setActiveTool: App["setActiveTool"];
   setActiveTool: App["setActiveTool"];
   setOpenDialog: App["setOpenDialog"];
   setOpenDialog: App["setOpenDialog"];
+  insertEmbeddableElement: App["insertEmbeddableElement"];
+  onMagicframeToolSelect: App["onMagicframeToolSelect"];
 };
 };
 
 
 export type PointerDownState = Readonly<{
 export type PointerDownState = Readonly<{
@@ -621,6 +637,7 @@ export type ExcalidrawImperativeAPI = {
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
   getAppState: () => InstanceType<typeof App>["state"];
   getAppState: () => InstanceType<typeof App>["state"];
   getFiles: () => InstanceType<typeof App>["files"];
   getFiles: () => InstanceType<typeof App>["files"];
+  registerAction: (action: Action) => void;
   refresh: InstanceType<typeof App>["refresh"];
   refresh: InstanceType<typeof App>["refresh"];
   setToast: InstanceType<typeof App>["setToast"];
   setToast: InstanceType<typeof App>["setToast"];
   addFiles: (data: BinaryFileData[]) => void;
   addFiles: (data: BinaryFileData[]) => void;
@@ -679,12 +696,14 @@ type FrameNameBounds = {
 };
 };
 
 
 export type FrameNameBoundsCache = {
 export type FrameNameBoundsCache = {
-  get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null;
+  get: (
+    frameElement: ExcalidrawFrameLikeElement | ExcalidrawMagicFrameElement,
+  ) => FrameNameBounds | null;
   _cache: Map<
   _cache: Map<
     string,
     string,
     FrameNameBounds & {
     FrameNameBounds & {
       zoom: AppState["zoom"]["value"];
       zoom: AppState["zoom"]["value"];
-      versionNonce: ExcalidrawFrameElement["versionNonce"];
+      versionNonce: ExcalidrawFrameLikeElement["versionNonce"];
     }
     }
   >;
   >;
 };
 };
@@ -704,3 +723,5 @@ export type Primitive =
   | symbol
   | symbol
   | null
   | null
   | undefined;
   | undefined;
+
+export type JSONValue = string | number | boolean | null | object;

+ 4 - 19
src/utils.ts

@@ -6,11 +6,7 @@ import {
   isDarwin,
   isDarwin,
   WINDOWS_EMOJI_FALLBACK_FONT,
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
 } from "./constants";
-import {
-  FontFamilyValues,
-  FontString,
-  NonDeletedExcalidrawElement,
-} from "./element/types";
+import { FontFamilyValues, FontString } from "./element/types";
 import { ActiveTool, AppState, ToolType, Zoom } from "./types";
 import { ActiveTool, AppState, ToolType, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { unstable_batchedUpdates } from "react-dom";
 import { ResolutionType } from "./utility-types";
 import { ResolutionType } from "./utility-types";
@@ -77,7 +73,9 @@ export const isWritableElement = (
   target instanceof HTMLBRElement || // newline in wysiwyg
   target instanceof HTMLBRElement || // newline in wysiwyg
   target instanceof HTMLTextAreaElement ||
   target instanceof HTMLTextAreaElement ||
   (target instanceof HTMLInputElement &&
   (target instanceof HTMLInputElement &&
-    (target.type === "text" || target.type === "number"));
+    (target.type === "text" ||
+      target.type === "number" ||
+      target.type === "password"));
 
 
 export const getFontFamilyString = ({
 export const getFontFamilyString = ({
   fontFamily,
   fontFamily,
@@ -821,19 +819,6 @@ export const composeEventHandlers = <E>(
   };
   };
 };
 };
 
 
-export const isOnlyExportingSingleFrame = (
-  elements: readonly NonDeletedExcalidrawElement[],
-) => {
-  const frames = elements.filter((element) => element.type === "frame");
-
-  return (
-    frames.length === 1 &&
-    elements.every(
-      (element) => element.type === "frame" || element.frameId === frames[0].id,
-    )
-  );
-};
-
 /**
 /**
  * supply `null` as message if non-never value is valid, you just need to
  * supply `null` as message if non-never value is valid, you just need to
  * typecheck against it
  * typecheck against it

+ 13 - 13
src/zindex.ts

@@ -1,6 +1,6 @@
 import { bumpVersion } from "./element/mutateElement";
 import { bumpVersion } from "./element/mutateElement";
-import { isFrameElement } from "./element/typeChecks";
-import { ExcalidrawElement, ExcalidrawFrameElement } from "./element/types";
+import { isFrameLikeElement } from "./element/typeChecks";
+import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
 import { getElementsInGroup } from "./groups";
 import { getElementsInGroup } from "./groups";
 import { getSelectedElements } from "./scene";
 import { getSelectedElements } from "./scene";
 import Scene from "./scene/Scene";
 import Scene from "./scene/Scene";
@@ -107,7 +107,7 @@ const getTargetIndexAccountingForBinding = (
 
 
 const getContiguousFrameRangeElements = (
 const getContiguousFrameRangeElements = (
   allElements: readonly ExcalidrawElement[],
   allElements: readonly ExcalidrawElement[],
-  frameId: ExcalidrawFrameElement["id"],
+  frameId: ExcalidrawFrameLikeElement["id"],
 ) => {
 ) => {
   let rangeStart = -1;
   let rangeStart = -1;
   let rangeEnd = -1;
   let rangeEnd = -1;
@@ -138,7 +138,7 @@ const getTargetIndex = (
    * Frame id if moving frame children.
    * Frame id if moving frame children.
    * If whole frame (including all children) is being moved, supply `null`.
    * If whole frame (including all children) is being moved, supply `null`.
    */
    */
-  containingFrame: ExcalidrawFrameElement["id"] | null,
+  containingFrame: ExcalidrawFrameLikeElement["id"] | null,
 ) => {
 ) => {
   const sourceElement = elements[boundaryIndex];
   const sourceElement = elements[boundaryIndex];
 
 
@@ -189,7 +189,7 @@ const getTargetIndex = (
 
 
   if (
   if (
     !containingFrame &&
     !containingFrame &&
-    (nextElement.frameId || nextElement.type === "frame")
+    (nextElement.frameId || isFrameLikeElement(nextElement))
   ) {
   ) {
     const frameElements = getContiguousFrameRangeElements(
     const frameElements = getContiguousFrameRangeElements(
       elements,
       elements,
@@ -252,9 +252,9 @@ const shiftElementsByOne = (
     groupedIndices = groupedIndices.reverse();
     groupedIndices = groupedIndices.reverse();
   }
   }
 
 
-  const selectedFrames = new Set<ExcalidrawFrameElement["id"]>(
+  const selectedFrames = new Set<ExcalidrawFrameLikeElement["id"]>(
     indicesToMove
     indicesToMove
-      .filter((idx) => elements[idx].type === "frame")
+      .filter((idx) => isFrameLikeElement(elements[idx]))
       .map((idx) => elements[idx].id),
       .map((idx) => elements[idx].id),
   );
   );
 
 
@@ -324,7 +324,7 @@ const shiftElementsToEnd = (
   elements: readonly ExcalidrawElement[],
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   appState: AppState,
   direction: "left" | "right",
   direction: "left" | "right",
-  containingFrame: ExcalidrawFrameElement["id"] | null,
+  containingFrame: ExcalidrawFrameLikeElement["id"] | null,
   elementsToBeMoved?: readonly ExcalidrawElement[],
   elementsToBeMoved?: readonly ExcalidrawElement[],
 ) => {
 ) => {
   const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
   const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
@@ -413,7 +413,7 @@ function shiftElementsAccountingForFrames(
     elements: readonly ExcalidrawElement[],
     elements: readonly ExcalidrawElement[],
     appState: AppState,
     appState: AppState,
     direction: "left" | "right",
     direction: "left" | "right",
-    containingFrame: ExcalidrawFrameElement["id"] | null,
+    containingFrame: ExcalidrawFrameLikeElement["id"] | null,
     elementsToBeMoved?: readonly ExcalidrawElement[],
     elementsToBeMoved?: readonly ExcalidrawElement[],
   ) => ExcalidrawElement[] | readonly ExcalidrawElement[],
   ) => ExcalidrawElement[] | readonly ExcalidrawElement[],
 ) {
 ) {
@@ -426,13 +426,13 @@ function shiftElementsAccountingForFrames(
 
 
   const frameAwareContiguousElementsToMove: {
   const frameAwareContiguousElementsToMove: {
     regularElements: ExcalidrawElement[];
     regularElements: ExcalidrawElement[];
-    frameChildren: Map<ExcalidrawFrameElement["id"], ExcalidrawElement[]>;
+    frameChildren: Map<ExcalidrawFrameLikeElement["id"], ExcalidrawElement[]>;
   } = { regularElements: [], frameChildren: new Map() };
   } = { regularElements: [], frameChildren: new Map() };
 
 
-  const fullySelectedFrames = new Set<ExcalidrawFrameElement["id"]>();
+  const fullySelectedFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
 
 
   for (const element of allElements) {
   for (const element of allElements) {
-    if (elementsToMove.has(element.id) && isFrameElement(element)) {
+    if (elementsToMove.has(element.id) && isFrameLikeElement(element)) {
       fullySelectedFrames.add(element.id);
       fullySelectedFrames.add(element.id);
     }
     }
   }
   }
@@ -440,7 +440,7 @@ function shiftElementsAccountingForFrames(
   for (const element of allElements) {
   for (const element of allElements) {
     if (elementsToMove.has(element.id)) {
     if (elementsToMove.has(element.id)) {
       if (
       if (
-        isFrameElement(element) ||
+        isFrameLikeElement(element) ||
         (element.frameId && fullySelectedFrames.has(element.frameId))
         (element.frameId && fullySelectedFrames.has(element.frameId))
       ) {
       ) {
         frameAwareContiguousElementsToMove.regularElements.push(element);
         frameAwareContiguousElementsToMove.regularElements.push(element);

部分文件因为文件数量过多而无法显示