Browse Source

Merge branch 'master' into zsviczian-embeddable-scaling

zsviczian 1 year ago
parent
commit
9da3e47877
75 changed files with 2199 additions and 459 deletions
  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"
   },
   "engines": {
-    "node": "^18.0.0"
+    "node": "18.0.0 - 20.x.x"
   },
   "homepage": ".",
   "name": "excalidraw",

+ 2 - 1
src/actions/actionAlign.tsx

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

+ 15 - 1
src/actions/actionCanvas.tsx

@@ -265,7 +265,21 @@ export const zoomToFit = ({
       30.0,
     ) 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;
   } else {
     newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {

+ 2 - 2
src/actions/actionDeleteSelected.tsx

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

+ 2 - 1
src/actions/actionDistribute.tsx

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

+ 5 - 5
src/actions/actionDuplicateSelection.tsx

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

+ 2 - 1
src/actions/actionElementLock.ts

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

+ 17 - 8
src/actions/actionFrame.ts

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

+ 4 - 4
src/actions/actionGroup.tsx

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

+ 7 - 2
src/actions/actionMenu.tsx

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

+ 2 - 2
src/actions/actionStyles.ts

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

+ 15 - 7
src/analytics.ts

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

+ 5 - 2
src/clipboard.ts

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

+ 36 - 4
src/components/Actions.tsx

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

File diff suppressed because it is too large
+ 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 (
     <>
-      {appState.openDialog === "jsonExport" && (
+      {appState.openDialog?.name === "jsonExport" && (
         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
           <JSONExportModal
             elements={elements}

+ 44 - 5
src/components/LayerUI.tsx

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

+ 38 - 0
src/components/MagicButton.tsx

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

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

+ 10 - 0
src/components/Paragraph.tsx

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

+ 1 - 1
src/components/PasteChartDialog.tsx

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

+ 7 - 31
src/components/PublishLibrary.tsx

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

+ 22 - 2
src/components/TextField.tsx

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

+ 2 - 1
src/components/ToolIcon.scss

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

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

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

+ 54 - 0
src/components/icons.tsx

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

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

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

+ 31 - 1
src/constants.ts

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

+ 12 - 0
src/css/styles.scss

@@ -5,9 +5,11 @@
   --zIndex-canvas: 1;
   --zIndex-interactiveCanvas: 2;
   --zIndex-wysiwyg: 3;
+  --zIndex-canvasButtons: 3;
   --zIndex-layerUI: 4;
   --zIndex-eyeDropperBackdrop: 5;
   --zIndex-eyeDropperPreview: 6;
+  --zIndex-hyperlinkContainer: 7;
 
   --zIndex-modal: 1000;
   --zIndex-popup: 1001;
@@ -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"],
   textarea:not(.excalidraw-wysiwyg) {
     color: var(--text-primary-color);
@@ -726,4 +734,8 @@
     letter-spacing: 0.6px;
     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,
 } from "../clipboard";
 import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
-import { getNonDeletedElements, isFrameElement } from "../element";
+import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
 import {
   ExcalidrawElement,
-  ExcalidrawFrameElement,
+  ExcalidrawFrameLikeElement,
   NonDeletedExcalidrawElement,
 } from "../element/types";
 import { t } from "../i18n";
@@ -38,7 +39,7 @@ export const prepareElementsForExport = (
     exportSelectionOnly &&
     isSomeElementSelected(elements, { selectedElementIds });
 
-  let exportingFrame: ExcalidrawFrameElement | null = null;
+  let exportingFrame: ExcalidrawFrameLikeElement | null = null;
   let exportedElements = isExportingSelection
     ? getSelectedElements(
         elements,
@@ -50,7 +51,10 @@ export const prepareElementsForExport = (
     : elements;
 
   if (isExportingSelection) {
-    if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
+    if (
+      exportedElements.length === 1 &&
+      isFrameLikeElement(exportedElements[0])
+    ) {
       exportingFrame = exportedElements[0];
       exportedElements = elementsOverlappingBBox({
         elements,
@@ -93,7 +97,7 @@ export const exportCanvas = async (
     viewBackgroundColor: string;
     name: string;
     fileHandle?: FileSystemHandle | null;
-    exportingFrame: ExcalidrawFrameElement | null;
+    exportingFrame: ExcalidrawFrameLikeElement | null;
   },
 ) => {
   if (elements.length === 0) {

+ 104 - 0
src/data/magic.ts

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

+ 9 - 6
src/data/restore.ts

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

+ 44 - 8
src/data/transform.ts

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

+ 14 - 0
src/element/ElementCanvasButtons.scss

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

+ 60 - 0
src/element/ElementCanvasButtons.tsx

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

+ 1 - 1
src/element/Hyperlink.scss

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

+ 2 - 1
src/element/Hyperlink.tsx

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

+ 12 - 4
src/element/bounds.ts

@@ -22,6 +22,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
+import Scene from "../scene/Scene";
 
 export type RectangleBox = {
   x: number;
@@ -59,10 +60,17 @@ export class ElementBounds {
 
     const bounds = ElementBounds.calculateBounds(element);
 
-    ElementBounds.boundsCache.set(element, {
-      version: element.version,
-      bounds,
-    });
+    // hack to ensure that downstream checks could retrieve element Scene
+    // so as to have correctly calculated bounds
+    // FIXME remove when we get rid of all the id:Scene / element:Scene mapping
+    const shouldCache = Scene.getScene(element);
+
+    if (shouldCache) {
+      ElementBounds.boundsCache.set(element, {
+        version: element.version,
+        bounds,
+      });
+    }
 
     return bounds;
   }

+ 25 - 12
src/element/collision.ts

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

+ 2 - 2
src/element/dragElements.ts

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

+ 56 - 38
src/element/embeddable.ts

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

+ 4 - 16
src/element/index.ts

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

+ 29 - 0
src/element/newElement.ts

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

+ 4 - 5
src/element/resizeElements.ts

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

+ 2 - 1
src/element/textElement.ts

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

+ 2 - 2
src/element/transformHandles.ts

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

+ 44 - 20
src/element/typeChecks.ts

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

+ 37 - 1
src/element/types.ts

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

+ 49 - 35
src/frame.ts

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

+ 6 - 1
src/locales/en.json

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

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

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

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

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

+ 2 - 2
src/packages/utils.ts

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

+ 15 - 3
src/renderer/renderElement.ts

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

+ 16 - 12
src/renderer/renderScene.ts

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

+ 14 - 17
src/scene/Scene.ts

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

+ 24 - 6
src/scene/Shape.ts

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

+ 18 - 13
src/scene/comparisons.ts

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

+ 25 - 12
src/scene/export.ts

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

+ 3 - 3
src/scene/selection.ts

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

+ 2 - 0
src/scene/types.ts

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

+ 0 - 8
src/shapes.tsx

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

+ 9 - 7
src/snapping.ts

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

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

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

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

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

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

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

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

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

+ 31 - 10
src/types.ts

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

+ 4 - 19
src/utils.ts

@@ -6,11 +6,7 @@ import {
   isDarwin,
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
-import {
-  FontFamilyValues,
-  FontString,
-  NonDeletedExcalidrawElement,
-} from "./element/types";
+import { FontFamilyValues, FontString } from "./element/types";
 import { ActiveTool, AppState, ToolType, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { ResolutionType } from "./utility-types";
@@ -77,7 +73,9 @@ export const isWritableElement = (
   target instanceof HTMLBRElement || // newline in wysiwyg
   target instanceof HTMLTextAreaElement ||
   (target instanceof HTMLInputElement &&
-    (target.type === "text" || target.type === "number"));
+    (target.type === "text" ||
+      target.type === "number" ||
+      target.type === "password"));
 
 export const getFontFamilyString = ({
   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
  * typecheck against it

+ 13 - 13
src/zindex.ts

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

Some files were not shown because too many files changed in this diff