Ver Fonte

Merge remote-tracking branch 'origin/master' into aakansha-disable-scaling-boundtext

Aakansha Doshi há 2 anos atrás
pai
commit
357a1c47f6

+ 5 - 0
.env.development

@@ -22,3 +22,8 @@ REACT_APP_DEV_ENABLE_SW=
 REACT_APP_DEV_DISABLE_LIVE_RELOAD=
 REACT_APP_DEV_DISABLE_LIVE_RELOAD=
 
 
 FAST_REFRESH=false
 FAST_REFRESH=false
+
+#Debug flags
+
+# To enable bounding box for text containers
+REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=

+ 6 - 6
dev-docs/yarn.lock

@@ -1785,9 +1785,9 @@
     "@hapi/hoek" "^9.0.0"
     "@hapi/hoek" "^9.0.0"
 
 
 "@sideway/formula@^3.0.0":
 "@sideway/formula@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
-  integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
+  integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
 
 
 "@sideway/pinpoint@^2.0.0":
 "@sideway/pinpoint@^2.0.0":
   version "2.0.0"
   version "2.0.0"
@@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1:
     entities "^4.3.0"
     entities "^4.3.0"
 
 
 http-cache-semantics@^4.0.0:
 http-cache-semantics@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
-  integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
+  integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
 
 
 http-deceiver@^1.2.7:
 http-deceiver@^1.2.7:
   version "1.2.7"
   version "1.2.7"

+ 147 - 11
src/actions/actionBoundText.tsx

@@ -1,7 +1,8 @@
-import { VERTICAL_ALIGN } from "../constants";
-import { getNonDeletedElements, isTextElement } from "../element";
+import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants";
+import { getNonDeletedElements, isTextElement, newElement } from "../element";
 import { mutateElement } from "../element/mutateElement";
 import { mutateElement } from "../element/mutateElement";
 import {
 import {
+  computeContainerDimensionForBoundText,
   getBoundTextElement,
   getBoundTextElement,
   measureText,
   measureText,
   redrawTextBoundingBox,
   redrawTextBoundingBox,
@@ -13,8 +14,11 @@ import {
 import {
 import {
   hasBoundTextElement,
   hasBoundTextElement,
   isTextBindableContainer,
   isTextBindableContainer,
+  isUsingAdaptiveRadius,
 } from "../element/typeChecks";
 } from "../element/typeChecks";
 import {
 import {
+  ExcalidrawElement,
+  ExcalidrawLinearElement,
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
 } from "../element/types";
 } from "../element/types";
@@ -129,18 +133,150 @@ export const actionBindText = register({
       }),
       }),
     });
     });
     redrawTextBoundingBox(textElement, container);
     redrawTextBoundingBox(textElement, container);
-    const updatedElements = elements.slice();
-    const textElementIndex = updatedElements.findIndex(
-      (ele) => ele.id === textElement.id,
-    );
-    updatedElements.splice(textElementIndex, 1);
-    const containerIndex = updatedElements.findIndex(
-      (ele) => ele.id === container.id,
+
+    return {
+      elements: pushTextAboveContainer(elements, container, textElement),
+      appState: { ...appState, selectedElementIds: { [container.id]: true } },
+      commitToHistory: true,
+    };
+  },
+});
+
+const pushTextAboveContainer = (
+  elements: readonly ExcalidrawElement[],
+  container: ExcalidrawElement,
+  textElement: ExcalidrawTextElement,
+) => {
+  const updatedElements = elements.slice();
+  const textElementIndex = updatedElements.findIndex(
+    (ele) => ele.id === textElement.id,
+  );
+  updatedElements.splice(textElementIndex, 1);
+
+  const containerIndex = updatedElements.findIndex(
+    (ele) => ele.id === container.id,
+  );
+  updatedElements.splice(containerIndex + 1, 0, textElement);
+  return updatedElements;
+};
+
+const pushContainerBelowText = (
+  elements: readonly ExcalidrawElement[],
+  container: ExcalidrawElement,
+  textElement: ExcalidrawTextElement,
+) => {
+  const updatedElements = elements.slice();
+  const containerIndex = updatedElements.findIndex(
+    (ele) => ele.id === container.id,
+  );
+  updatedElements.splice(containerIndex, 1);
+
+  const textElementIndex = updatedElements.findIndex(
+    (ele) => ele.id === textElement.id,
+  );
+  updatedElements.splice(textElementIndex, 0, container);
+  return updatedElements;
+};
+
+export const actionCreateContainerFromText = register({
+  name: "createContainerFromText",
+  contextItemLabel: "labels.createContainerFromText",
+  trackEvent: { category: "element" },
+  predicate: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return selectedElements.length === 1 && isTextElement(selectedElements[0]);
+  },
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
     );
     );
-    updatedElements.splice(containerIndex + 1, 0, textElement);
+    const updatedElements = elements.slice();
+    if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
+      const textElement = selectedElements[0];
+      const container = newElement({
+        type: "rectangle",
+        backgroundColor: appState.currentItemBackgroundColor,
+        boundElements: [
+          ...(textElement.boundElements || []),
+          { id: textElement.id, type: "text" },
+        ],
+        angle: textElement.angle,
+        fillStyle: appState.currentItemFillStyle,
+        strokeColor: appState.currentItemStrokeColor,
+        roughness: appState.currentItemRoughness,
+        strokeWidth: appState.currentItemStrokeWidth,
+        strokeStyle: appState.currentItemStrokeStyle,
+        roundness:
+          appState.currentItemRoundness === "round"
+            ? {
+                type: isUsingAdaptiveRadius("rectangle")
+                  ? ROUNDNESS.ADAPTIVE_RADIUS
+                  : ROUNDNESS.PROPORTIONAL_RADIUS,
+              }
+            : null,
+        opacity: 100,
+        locked: false,
+        x: textElement.x - BOUND_TEXT_PADDING,
+        y: textElement.y - BOUND_TEXT_PADDING,
+        width: computeContainerDimensionForBoundText(
+          textElement.width,
+          "rectangle",
+        ),
+        height: computeContainerDimensionForBoundText(
+          textElement.height,
+          "rectangle",
+        ),
+        groupIds: textElement.groupIds,
+      });
+
+      // update bindings
+      if (textElement.boundElements?.length) {
+        const linearElementIds = textElement.boundElements
+          .filter((ele) => ele.type === "arrow")
+          .map((el) => el.id);
+        const linearElements = updatedElements.filter((ele) =>
+          linearElementIds.includes(ele.id),
+        ) as ExcalidrawLinearElement[];
+        linearElements.forEach((ele) => {
+          let startBinding = null;
+          let endBinding = null;
+          if (ele.startBinding) {
+            startBinding = { ...ele.startBinding, elementId: container.id };
+          }
+          if (ele.endBinding) {
+            endBinding = { ...ele.endBinding, elementId: container.id };
+          }
+          mutateElement(ele, { startBinding, endBinding });
+        });
+      }
+
+      mutateElement(textElement, {
+        containerId: container.id,
+        verticalAlign: VERTICAL_ALIGN.MIDDLE,
+        boundElements: null,
+      });
+      redrawTextBoundingBox(textElement, container);
+
+      return {
+        elements: pushContainerBelowText(
+          [...elements, container],
+          container,
+          textElement,
+        ),
+        appState: {
+          ...appState,
+          selectedElementIds: {
+            [container.id]: true,
+            [textElement.id]: false,
+          },
+        },
+        commitToHistory: true,
+      };
+    }
     return {
     return {
       elements: updatedElements,
       elements: updatedElements,
-      appState: { ...appState, selectedElementIds: { [container.id]: true } },
+      appState,
       commitToHistory: true,
       commitToHistory: true,
     };
     };
   },
   },

+ 1 - 0
src/actions/shortcuts.ts

@@ -1,5 +1,6 @@
 import { isDarwin } from "../constants";
 import { isDarwin } from "../constants";
 import { t } from "../i18n";
 import { t } from "../i18n";
+import { SubtypeOf } from "../utility-types";
 import { getShortcutKey } from "../utils";
 import { getShortcutKey } from "../utils";
 import { ActionName } from "./types";
 import { ActionName } from "./types";
 
 

+ 3 - 1
src/actions/types.ts

@@ -6,6 +6,7 @@ import {
   ExcalidrawProps,
   ExcalidrawProps,
   BinaryFiles,
   BinaryFiles,
 } from "../types";
 } from "../types";
+import { MarkOptional } from "../utility-types";
 
 
 export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
 export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
 
 
@@ -113,7 +114,8 @@ export type ActionName =
   | "toggleLock"
   | "toggleLock"
   | "toggleLinearEditor"
   | "toggleLinearEditor"
   | "toggleEraserTool"
   | "toggleEraserTool"
-  | "toggleHandTool";
+  | "toggleHandTool"
+  | "createContainerFromText";
 
 
 export type PanelComponentProps = {
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];

+ 2 - 0
src/components/ActiveConfirmDialog.tsx

@@ -1,6 +1,7 @@
 import { atom, useAtom } from "jotai";
 import { atom, useAtom } from "jotai";
 import { actionClearCanvas } from "../actions";
 import { actionClearCanvas } from "../actions";
 import { t } from "../i18n";
 import { t } from "../i18n";
+import { jotaiScope } from "../jotai";
 import { useExcalidrawActionManager } from "./App";
 import { useExcalidrawActionManager } from "./App";
 import ConfirmDialog from "./ConfirmDialog";
 import ConfirmDialog from "./ConfirmDialog";
 
 
@@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
 export const ActiveConfirmDialog = () => {
 export const ActiveConfirmDialog = () => {
   const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
   const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
     activeConfirmDialogAtom,
     activeConfirmDialogAtom,
+    jotaiScope,
   );
   );
   const actionManager = useExcalidrawActionManager();
   const actionManager = useExcalidrawActionManager();
 
 

+ 2 - 0
src/components/App.tsx

@@ -284,6 +284,7 @@ import { actionPaste } from "../actions/actionClipboard";
 import { actionToggleHandTool } from "../actions/actionCanvas";
 import { actionToggleHandTool } from "../actions/actionCanvas";
 import { jotaiStore } from "../jotai";
 import { jotaiStore } from "../jotai";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
+import { actionCreateContainerFromText } from "../actions/actionBoundText";
 
 
 const deviceContextInitialValue = {
 const deviceContextInitialValue = {
   isSmScreen: false,
   isSmScreen: false,
@@ -6237,6 +6238,7 @@ class App extends React.Component<AppProps, AppState> {
       actionGroup,
       actionGroup,
       actionUnbindText,
       actionUnbindText,
       actionBindText,
       actionBindText,
+      actionCreateContainerFromText,
       actionUngroup,
       actionUngroup,
       CONTEXT_MENU_SEPARATOR,
       CONTEXT_MENU_SEPARATOR,
       actionAddToLibrary,
       actionAddToLibrary,

+ 2 - 1
src/components/ConfirmDialog.tsx

@@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton";
 import { useSetAtom } from "jotai";
 import { useSetAtom } from "jotai";
 import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
 import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
 import { useExcalidrawSetAppState } from "./App";
 import { useExcalidrawSetAppState } from "./App";
+import { jotaiScope } from "../jotai";
 
 
 interface Props extends Omit<DialogProps, "onCloseRequest"> {
 interface Props extends Omit<DialogProps, "onCloseRequest"> {
   onConfirm: () => void;
   onConfirm: () => void;
@@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => {
     ...rest
     ...rest
   } = props;
   } = props;
   const setAppState = useExcalidrawSetAppState();
   const setAppState = useExcalidrawSetAppState();
-  const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
+  const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
 
 
   return (
   return (
     <Dialog
     <Dialog

+ 2 - 1
src/components/Dialog.tsx

@@ -16,6 +16,7 @@ import { AppState } from "../types";
 import { queryFocusableElements } from "../utils";
 import { queryFocusableElements } from "../utils";
 import { useSetAtom } from "jotai";
 import { useSetAtom } from "jotai";
 import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
 import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
+import { jotaiScope } from "../jotai";
 
 
 export interface DialogProps {
 export interface DialogProps {
   children: React.ReactNode;
   children: React.ReactNode;
@@ -72,7 +73,7 @@ export const Dialog = (props: DialogProps) => {
   }, [islandNode, props.autofocus]);
   }, [islandNode, props.autofocus]);
 
 
   const setAppState = useExcalidrawSetAppState();
   const setAppState = useExcalidrawSetAppState();
-  const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
+  const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
 
 
   const onClose = () => {
   const onClose = () => {
     setAppState({ openMenu: null });
     setAppState({ openMenu: null });

+ 3 - 3
src/components/HelpButton.tsx

@@ -1,7 +1,7 @@
+import { t } from "../i18n";
 import { HelpIcon } from "./icons";
 import { HelpIcon } from "./icons";
 
 
 type HelpButtonProps = {
 type HelpButtonProps = {
-  title?: string;
   name?: string;
   name?: string;
   id?: string;
   id?: string;
   onClick?(): void;
   onClick?(): void;
@@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => (
     className="help-icon"
     className="help-icon"
     onClick={props.onClick}
     onClick={props.onClick}
     type="button"
     type="button"
-    title={`${props.title} — ?`}
-    aria-label={props.title}
+    title={`${t("helpDialog.title")} — ?`}
+    aria-label={t("helpDialog.title")}
   >
   >
     {HelpIcon}
     {HelpIcon}
   </button>
   </button>

+ 1 - 0
src/components/LibraryMenuHeaderContent.tsx

@@ -48,6 +48,7 @@ export const LibraryMenuHeader: React.FC<{
   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
   const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
   const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
     isLibraryMenuOpenAtom,
     isLibraryMenuOpenAtom,
+    jotaiScope,
   );
   );
   const renderRemoveLibAlert = useCallback(() => {
   const renderRemoveLibAlert = useCallback(() => {
     const content = selectedItems.length
     const content = selectedItems.length

+ 5 - 1
src/components/main-menu/DefaultItems.tsx

@@ -31,6 +31,7 @@ import "./DefaultItems.scss";
 import clsx from "clsx";
 import clsx from "clsx";
 import { useSetAtom } from "jotai";
 import { useSetAtom } from "jotai";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
+import { jotaiScope } from "../../jotai";
 
 
 export const LoadScene = () => {
 export const LoadScene = () => {
   const { t } = useI18n();
   const { t } = useI18n();
@@ -113,7 +114,10 @@ Help.displayName = "Help";
 export const ClearCanvas = () => {
 export const ClearCanvas = () => {
   const { t } = useI18n();
   const { t } = useI18n();
 
 
-  const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
+  const setActiveConfirmDialog = useSetAtom(
+    activeConfirmDialogAtom,
+    jotaiScope,
+  );
   const actionManager = useExcalidrawActionManager();
   const actionManager = useExcalidrawActionManager();
 
 
   if (!actionManager.isActionEnabled(actionClearCanvas)) {
   if (!actionManager.isActionEnabled(actionClearCanvas)) {

+ 1 - 0
src/data/blob.ts

@@ -7,6 +7,7 @@ import { CanvasError } from "../errors";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 import { calculateScrollCenter } from "../scene";
 import { AppState, DataURL, LibraryItem } from "../types";
 import { AppState, DataURL, LibraryItem } from "../types";
+import { ValueOf } from "../utility-types";
 import { bytesToHexString } from "../utils";
 import { bytesToHexString } from "../utils";
 import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
 import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
 import { isValidExcalidrawData, isValidLibrary } from "./json";
 import { isValidExcalidrawData, isValidLibrary } from "./json";

+ 1 - 0
src/data/restore.ts

@@ -34,6 +34,7 @@ import { bumpVersion } from "../element/mutateElement";
 import { getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { arrayToMap } from "../utils";
 import { arrayToMap } from "../utils";
 import oc from "open-color";
 import oc from "open-color";
+import { MarkOptional, Mutable } from "../utility-types";
 
 
 type RestoredAppState = Omit<
 type RestoredAppState = Omit<
   AppState,
   AppState,

+ 1 - 0
src/element/bounds.ts

@@ -23,6 +23,7 @@ import {
 import { rescalePoints } from "../points";
 import { rescalePoints } from "../points";
 import { getBoundTextElement, getContainerElement } from "./textElement";
 import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
+import { Mutable } from "../utility-types";
 
 
 // x and y position of top left corner, x and y position of bottom right corner
 // x and y position of top left corner, x and y position of bottom right corner
 export type Bounds = readonly [number, number, number, number];
 export type Bounds = readonly [number, number, number, number];

+ 1 - 0
src/element/collision.ts

@@ -38,6 +38,7 @@ import { isTextElement } from ".";
 import { isTransparent } from "../utils";
 import { isTransparent } from "../utils";
 import { shouldShowBoundingBox } from "./transformHandles";
 import { shouldShowBoundingBox } from "./transformHandles";
 import { getBoundTextElement } from "./textElement";
 import { getBoundTextElement } from "./textElement";
+import { Mutable } from "../utility-types";
 
 
 const isElementDraggableFromInside = (
 const isElementDraggableFromInside = (
   element: NonDeletedExcalidrawElement,
   element: NonDeletedExcalidrawElement,

+ 1 - 0
src/element/linearElementEditor.ts

@@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys";
 import { getBoundTextElement, handleBindTextResize } from "./textElement";
 import { getBoundTextElement, handleBindTextResize } from "./textElement";
 import { getShapeForElement } from "../renderer/renderElement";
 import { getShapeForElement } from "../renderer/renderElement";
 import { DRAGGING_THRESHOLD } from "../constants";
 import { DRAGGING_THRESHOLD } from "../constants";
+import { Mutable } from "../utility-types";
 
 
 const editorMidPointsCache: {
 const editorMidPointsCache: {
   version: number | null;
   version: number | null;

+ 1 - 0
src/element/mutateElement.ts

@@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points";
 import { randomInteger } from "../random";
 import { randomInteger } from "../random";
 import { Point } from "../types";
 import { Point } from "../types";
 import { getUpdatedTimestamp } from "../utils";
 import { getUpdatedTimestamp } from "../utils";
+import { Mutable } from "../utility-types";
 
 
 type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
 type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
   Partial<TElement>,
   Partial<TElement>,

+ 1 - 0
src/element/newElement.ts

@@ -32,6 +32,7 @@ import {
 } from "./textElement";
 } from "./textElement";
 import { VERTICAL_ALIGN } from "../constants";
 import { VERTICAL_ALIGN } from "../constants";
 import { isArrowElement } from "./typeChecks";
 import { isArrowElement } from "./typeChecks";
+import { MarkOptional, Merge, Mutable } from "../utility-types";
 
 
 type ElementConstructorOpts = MarkOptional<
 type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,

+ 3 - 1
src/element/resizeElements.ts

@@ -679,7 +679,9 @@ const resizeMultipleElements = (
       };
       };
       const fontSize = measureFontSizeFromWidth(
       const fontSize = measureFontSizeFromWidth(
         boundTextElement ?? (element.orig as ExcalidrawTextElement),
         boundTextElement ?? (element.orig as ExcalidrawTextElement),
-        getMaxContainerWidth(updatedElement),
+        boundTextElement
+          ? getMaxContainerWidth(updatedElement)
+          : updatedElement.width,
       );
       );
 
 
       if (!fontSize) {
       if (!fontSize) {

+ 15 - 8
src/element/textElement.test.ts

@@ -1,7 +1,7 @@
 import { BOUND_TEXT_PADDING } from "../constants";
 import { BOUND_TEXT_PADDING } from "../constants";
 import { API } from "../tests/helpers/api";
 import { API } from "../tests/helpers/api";
 import {
 import {
-  computeContainerHeightForBoundText,
+  computeContainerDimensionForBoundText,
   computeBoundTextElementCoords,
   computeBoundTextElementCoords,
   getMaxContainerWidth,
   getMaxContainerWidth,
   getMaxContainerHeight,
   getMaxContainerHeight,
@@ -35,10 +35,11 @@ describe("Test wrapText", () => {
 
 
   describe("When text doesn't contain new lines", () => {
   describe("When text doesn't contain new lines", () => {
     const text = "Hello whats up";
     const text = "Hello whats up";
+
     [
     [
       {
       {
         desc: "break all words when width of each word is less than container width",
         desc: "break all words when width of each word is less than container width",
-        width: 90,
+        width: 80,
         res: `Hello 
         res: `Hello 
 whats 
 whats 
 up`,
 up`,
@@ -62,7 +63,7 @@ p`,
       {
       {
         desc: "break words as per the width",
         desc: "break words as per the width",
 
 
-        width: 150,
+        width: 140,
         res: `Hello whats 
         res: `Hello whats 
 up`,
 up`,
       },
       },
@@ -93,7 +94,7 @@ whats up`;
     [
     [
       {
       {
         desc: "break all words when width of each word is less than container width",
         desc: "break all words when width of each word is less than container width",
-        width: 90,
+        width: 80,
         res: `Hello
         res: `Hello
 whats 
 whats 
 up`,
 up`,
@@ -214,7 +215,7 @@ describe("Test measureText", () => {
     });
     });
   });
   });
 
 
-  describe("Test computeContainerHeightForBoundText", () => {
+  describe("Test computeContainerDimensionForBoundText", () => {
     const params = {
     const params = {
       width: 178,
       width: 178,
       height: 194,
       height: 194,
@@ -225,7 +226,9 @@ describe("Test measureText", () => {
         type: "rectangle",
         type: "rectangle",
         ...params,
         ...params,
       });
       });
-      expect(computeContainerHeightForBoundText(element, 150)).toEqual(160);
+      expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
+        160,
+      );
     });
     });
 
 
     it("should compute container height correctly for ellipse", () => {
     it("should compute container height correctly for ellipse", () => {
@@ -233,7 +236,9 @@ describe("Test measureText", () => {
         type: "ellipse",
         type: "ellipse",
         ...params,
         ...params,
       });
       });
-      expect(computeContainerHeightForBoundText(element, 150)).toEqual(226);
+      expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
+        226,
+      );
     });
     });
 
 
     it("should compute container height correctly for diamond", () => {
     it("should compute container height correctly for diamond", () => {
@@ -241,7 +246,9 @@ describe("Test measureText", () => {
         type: "diamond",
         type: "diamond",
         ...params,
         ...params,
       });
       });
-      expect(computeContainerHeightForBoundText(element, 150)).toEqual(320);
+      expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
+        320,
+      );
     });
     });
   });
   });
 
 

+ 32 - 34
src/element/textElement.ts

@@ -12,11 +12,7 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
 import { MaybeTransformHandleType } from "./transformHandles";
 import { MaybeTransformHandleType } from "./transformHandles";
 import Scene from "../scene/Scene";
 import Scene from "../scene/Scene";
 import { isTextElement } from ".";
 import { isTextElement } from ".";
-import {
-  isBoundToContainer,
-  isImageElement,
-  isArrowElement,
-} from "./typeChecks";
+import { isBoundToContainer, isArrowElement } from "./typeChecks";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { isTextBindableContainer } from "./typeChecks";
 import { isTextBindableContainer } from "./typeChecks";
@@ -27,6 +23,7 @@ import {
   resetOriginalContainerCache,
   resetOriginalContainerCache,
   updateOriginalContainerCache,
   updateOriginalContainerCache,
 } from "./textWysiwyg";
 } from "./textWysiwyg";
+import { ExtractSetType } from "../utility-types";
 
 
 export const normalizeText = (text: string) => {
 export const normalizeText = (text: string) => {
   return (
   return (
@@ -84,9 +81,9 @@ export const redrawTextBoundingBox = (
 
 
       let nextHeight = containerDims.height;
       let nextHeight = containerDims.height;
       if (metrics.height > maxContainerHeight) {
       if (metrics.height > maxContainerHeight) {
-        nextHeight = computeContainerHeightForBoundText(
-          container,
+        nextHeight = computeContainerDimensionForBoundText(
           metrics.height,
           metrics.height,
+          container.type,
         );
         );
         mutateElement(container, { height: nextHeight });
         mutateElement(container, { height: nextHeight });
         maxContainerHeight = getMaxContainerHeight(container);
         maxContainerHeight = getMaxContainerHeight(container);
@@ -192,9 +189,9 @@ export const handleBindTextResize = (
     }
     }
     // increase height in case text element height exceeds
     // increase height in case text element height exceeds
     if (!shouldMaintainAspectRatio && nextHeight > maxHeight) {
     if (!shouldMaintainAspectRatio && nextHeight > maxHeight) {
-      containerHeight = computeContainerHeightForBoundText(
-        container,
+      containerHeight = computeContainerDimensionForBoundText(
         nextHeight,
         nextHeight,
+        container.type,
       );
       );
 
 
       const diff = containerHeight - containerDims.height;
       const diff = containerHeight - containerDims.height;
@@ -227,9 +224,9 @@ export const handleBindTextResize = (
         ),
         ),
       );
       );
       if (shouldMaintainAspectRatio && nextHeight > maxHeight) {
       if (shouldMaintainAspectRatio && nextHeight > maxHeight) {
-        containerHeight = computeContainerHeightForBoundText(
-          container,
+        containerHeight = computeContainerDimensionForBoundText(
           nextHeight,
           nextHeight,
+          container.type,
         );
         );
         const { y } = computeContainerCoords(textElement, container.type);
         const { y } = computeContainerCoords(textElement, container.type);
         mutateElement(container, {
         mutateElement(container, {
@@ -339,7 +336,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
   const lines: Array<string> = [];
   const lines: Array<string> = [];
   const originalLines = text.split("\n");
   const originalLines = text.split("\n");
   const spaceWidth = getLineWidth(" ", font);
   const spaceWidth = getLineWidth(" ", font);
-
   const push = (str: string) => {
   const push = (str: string) => {
     if (str.trim()) {
     if (str.trim()) {
       lines.push(str);
       lines.push(str);
@@ -413,7 +409,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
           const word = words[index];
           const word = words[index];
           currentLineWidthTillNow = getLineWidth(currentLine + word, font);
           currentLineWidthTillNow = getLineWidth(currentLine + word, font);
 
 
-          if (currentLineWidthTillNow >= maxWidth) {
+          if (currentLineWidthTillNow > maxWidth) {
             push(currentLine);
             push(currentLine);
             currentLineWidthTillNow = 0;
             currentLineWidthTillNow = 0;
             currentLine = "";
             currentLine = "";
@@ -752,32 +748,34 @@ export const getTextBindableContainerAtPosition = (
   return isTextBindableContainer(hitElement, false) ? hitElement : null;
   return isTextBindableContainer(hitElement, false) ? hitElement : null;
 };
 };
 
 
-export const isValidTextContainer = (element: ExcalidrawElement) => {
-  return (
-    element.type === "rectangle" ||
-    element.type === "ellipse" ||
-    element.type === "diamond" ||
-    isImageElement(element) ||
-    isArrowElement(element)
-  );
-};
+const VALID_CONTAINER_TYPES = new Set([
+  "rectangle",
+  "ellipse",
+  "diamond",
+  "image",
+  "arrow",
+]);
 
 
-export const computeContainerHeightForBoundText = (
-  container: NonDeletedExcalidrawElement,
-  boundTextElementHeight: number,
+export const isValidTextContainer = (element: ExcalidrawElement) =>
+  VALID_CONTAINER_TYPES.has(element.type);
+
+export const computeContainerDimensionForBoundText = (
+  dimension: number,
+  containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
 ) => {
 ) => {
-  if (container.type === "ellipse") {
-    return Math.round(
-      ((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2,
-    );
+  dimension = Math.ceil(dimension);
+  const padding = BOUND_TEXT_PADDING * 2;
+
+  if (containerType === "ellipse") {
+    return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
   }
   }
-  if (isArrowElement(container)) {
-    return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2;
+  if (containerType === "arrow") {
+    return dimension + padding * 8;
   }
   }
-  if (container.type === "diamond") {
-    return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2);
+  if (containerType === "diamond") {
+    return 2 * (dimension + padding);
   }
   }
-  return boundTextElementHeight + BOUND_TEXT_PADDING * 2;
+  return dimension + padding;
 };
 };
 
 
 export const getMaxContainerWidth = (container: ExcalidrawElement) => {
 export const getMaxContainerWidth = (container: ExcalidrawElement) => {

+ 88 - 1
src/element/textWysiwyg.test.tsx

@@ -10,7 +10,7 @@ import {
 } from "../tests/test-utils";
 } from "../tests/test-utils";
 import { queryByText } from "@testing-library/react";
 import { queryByText } from "@testing-library/react";
 
 
-import { FONT_FAMILY } from "../constants";
+import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
 import {
 import {
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   ExcalidrawTextElementWithContainer,
   ExcalidrawTextElementWithContainer,
@@ -19,6 +19,7 @@ import { API } from "../tests/helpers/api";
 import { mutateElement } from "./mutateElement";
 import { mutateElement } from "./mutateElement";
 import { resize } from "../tests/utils";
 import { resize } from "../tests/utils";
 import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
 import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
+
 // Unmount ReactDOM from root
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
 
@@ -1285,5 +1286,91 @@ describe("textWysiwyg", () => {
         `);
         `);
       });
       });
     });
     });
+
+    it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
+      UI.clickTool("text");
+      mouse.clickAt(20, 30);
+      const editor = document.querySelector(
+        ".excalidraw-textEditorContainer > textarea",
+      ) as HTMLTextAreaElement;
+
+      fireEvent.change(editor, {
+        target: {
+          value: "Excalidraw is an opensource virtual collaborative whiteboard",
+        },
+      });
+
+      editor.dispatchEvent(new Event("input"));
+      await new Promise((cb) => setTimeout(cb, 0));
+
+      editor.select();
+      fireEvent.click(screen.getByTitle("Left"));
+      await new Promise((r) => setTimeout(r, 0));
+
+      editor.blur();
+
+      const textElement = h.elements[1] as ExcalidrawTextElement;
+      expect(textElement.width).toBe(600);
+      expect(textElement.height).toBe(24);
+      expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
+      expect((textElement as ExcalidrawTextElement).text).toBe(
+        "Excalidraw is an opensource virtual collaborative whiteboard",
+      );
+
+      API.setSelectedElements([textElement]);
+
+      fireEvent.contextMenu(GlobalTestState.canvas, {
+        button: 2,
+        clientX: 20,
+        clientY: 30,
+      });
+
+      const contextMenu = document.querySelector(".context-menu");
+      fireEvent.click(
+        queryByText(contextMenu as HTMLElement, "Wrap text in a container")!,
+      );
+      expect(h.elements.length).toBe(3);
+
+      expect(h.elements[1]).toEqual(
+        expect.objectContaining({
+          angle: 0,
+          backgroundColor: "transparent",
+          boundElements: [
+            {
+              id: h.elements[2].id,
+              type: "text",
+            },
+          ],
+          fillStyle: "hachure",
+          groupIds: [],
+          height: 34,
+          isDeleted: false,
+          link: null,
+          locked: false,
+          opacity: 100,
+          roughness: 1,
+          roundness: {
+            type: 3,
+          },
+          strokeColor: "#000000",
+          strokeStyle: "solid",
+          strokeWidth: 1,
+          type: "rectangle",
+          updated: 1,
+          version: 1,
+          width: 610,
+          x: 15,
+          y: 25,
+        }),
+      );
+      expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+        expect.objectContaining({
+          text: "Excalidraw is an opensource virtual collaborative whiteboard",
+          verticalAlign: VERTICAL_ALIGN.MIDDLE,
+          textAlign: TEXT_ALIGN.LEFT,
+          boundElements: null,
+        }),
+      );
+    });
   });
   });
 });
 });

+ 1 - 0
src/element/typeChecks.ts

@@ -1,5 +1,6 @@
 import { ROUNDNESS } from "../constants";
 import { ROUNDNESS } from "../constants";
 import { AppState } from "../types";
 import { AppState } from "../types";
+import { MarkNonNullable } from "../utility-types";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,

+ 1 - 0
src/element/types.ts

@@ -6,6 +6,7 @@ import {
   THEME,
   THEME,
   VERTICAL_ALIGN,
   VERTICAL_ALIGN,
 } from "../constants";
 } from "../constants";
+import { MarkNonNullable, ValueOf } from "../utility-types";
 
 
 export type ChartType = "bar" | "line";
 export type ChartType = "bar" | "line";
 export type FillStyle = "hachure" | "cross-hatch" | "solid";
 export type FillStyle = "hachure" | "cross-hatch" | "solid";

+ 1 - 0
src/excalidraw-app/data/firebase.ts

@@ -14,6 +14,7 @@ import { encryptData, decryptData } from "../../data/encryption";
 import { MIME_TYPES } from "../../constants";
 import { MIME_TYPES } from "../../constants";
 import { reconcileElements } from "../collab/reconciliation";
 import { reconcileElements } from "../collab/reconciliation";
 import { getSyncableElements, SyncableExcalidrawElement } from ".";
 import { getSyncableElements, SyncableExcalidrawElement } from ".";
+import { ResolutionType } from "../../utility-types";
 
 
 // private
 // private
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------

+ 1 - 0
src/excalidraw-app/index.tsx

@@ -85,6 +85,7 @@ import { useAtomWithInitialValue } from "../jotai";
 import { appJotaiStore } from "./app-jotai";
 import { appJotaiStore } from "./app-jotai";
 
 
 import "./index.scss";
 import "./index.scss";
+import { ResolutionType } from "../utility-types";
 
 
 polyfill();
 polyfill();
 
 

+ 0 - 47
src/global.d.ts

@@ -50,36 +50,6 @@ interface Clipboard extends EventTarget {
   write(data: any[]): Promise<void>;
   write(data: any[]): Promise<void>;
 }
 }
 
 
-type Mutable<T> = {
-  -readonly [P in keyof T]: T[P];
-};
-
-type ValueOf<T> = T[keyof T];
-
-type Merge<M, N> = Omit<M, keyof N> & N;
-
-/** utility type to assert that the second type is a subtype of the first type.
- * Returns the subtype. */
-type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
-
-type ResolutionType<T extends (...args: any) => any> = T extends (
-  ...args: any
-) => Promise<infer R>
-  ? R
-  : any;
-
-// https://github.com/krzkaczor/ts-essentials
-type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
-
-type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
-  Required<Pick<T, RK>>;
-
-type MarkNonNullable<T, K extends keyof T> = {
-  [P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
-} & { [P in keyof T]: T[P] };
-
-type NonOptional<T> = Exclude<T, undefined>;
-
 // PNG encoding/decoding
 // PNG encoding/decoding
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };
@@ -101,23 +71,6 @@ declare module "png-chunks-extract" {
 }
 }
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------
 
 
-// -----------------------------------------------------------------------------
-// type getter for interface's callable type
-// src: https://stackoverflow.com/a/58658851/927631
-// -----------------------------------------------------------------------------
-type SignatureType<T> = T extends (...args: infer R) => any ? R : never;
-type CallableType<T extends (...args: any[]) => any> = (
-  ...args: SignatureType<T>
-) => ReturnType<T>;
-// --------------------------------------------------------------------------—
-
-// Type for React.forwardRef --- supply only the first generic argument T
-type ForwardRef<T, P = any> = Parameters<
-  CallableType<React.ForwardRefRenderFunction<T, P>>
->[1];
-
-// --------------------------------------------------------------------------—
-
 interface Blob {
 interface Blob {
   handle?: import("browser-fs-acces").FileSystemHandle;
   handle?: import("browser-fs-acces").FileSystemHandle;
   name?: string;
   name?: string;

+ 1 - 0
src/history.ts

@@ -2,6 +2,7 @@ import { AppState } from "./types";
 import { ExcalidrawElement } from "./element/types";
 import { ExcalidrawElement } from "./element/types";
 import { isLinearElement } from "./element/typeChecks";
 import { isLinearElement } from "./element/typeChecks";
 import { deepCopyElement } from "./element/newElement";
 import { deepCopyElement } from "./element/newElement";
+import { Mutable } from "./utility-types";
 
 
 export interface HistoryEntry {
 export interface HistoryEntry {
   appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
   appState: ReturnType<typeof clearAppStatePropertiesForHistory>;

+ 1 - 0
src/locales/en.json

@@ -110,6 +110,7 @@
     "increaseFontSize": "Increase font size",
     "increaseFontSize": "Increase font size",
     "unbindText": "Unbind text",
     "unbindText": "Unbind text",
     "bindText": "Bind text to the container",
     "bindText": "Bind text to the container",
+    "createContainerFromText": "Wrap text in a container",
     "link": {
     "link": {
       "edit": "Edit link",
       "edit": "Edit link",
       "create": "Create link",
       "create": "Create link",

+ 1 - 0
src/math.ts

@@ -12,6 +12,7 @@ import {
 } from "./element/types";
 } from "./element/types";
 import { getShapeForElement } from "./renderer/renderElement";
 import { getShapeForElement } from "./renderer/renderElement";
 import { getCurvePathOps } from "./element/bounds";
 import { getCurvePathOps } from "./element/bounds";
+import { Mutable } from "./utility-types";
 
 
 export const rotate = (
 export const rotate = (
   x1: number,
   x1: number,

+ 3 - 3
src/packages/excalidraw/yarn.lock

@@ -2032,9 +2032,9 @@ dns-equal@^1.0.0:
   integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0=
   integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0=
 
 
 dns-packet@^5.2.2:
 dns-packet@^5.2.2:
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.3.1.tgz#eb94413789daec0f0ebe2fcc230bdc9d7c91b43d"
-  integrity sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==
+  version "5.4.0"
+  resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.4.0.tgz#1f88477cf9f27e78a213fb6d118ae38e759a879b"
+  integrity sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==
   dependencies:
   dependencies:
     "@leichtgewicht/ip-codec" "^2.0.1"
     "@leichtgewicht/ip-codec" "^2.0.1"
 
 

+ 19 - 0
src/renderer/renderElement.ts

@@ -14,6 +14,7 @@ import {
   isFreeDrawElement,
   isFreeDrawElement,
   isInitializedImageElement,
   isInitializedImageElement,
   isArrowElement,
   isArrowElement,
+  hasBoundTextElement,
 } from "../element/typeChecks";
 } from "../element/typeChecks";
 import {
 import {
   getDiamondPoints,
   getDiamondPoints,
@@ -41,7 +42,10 @@ import { getStroke, StrokeOptions } from "perfect-freehand";
 import {
 import {
   getApproxLineHeight,
   getApproxLineHeight,
   getBoundTextElement,
   getBoundTextElement,
+  computeBoundTextElementCoords,
   getContainerElement,
   getContainerElement,
+  getMaxContainerHeight,
+  getMaxContainerWidth,
 } from "../element/textElement";
 } from "../element/textElement";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
 
 
@@ -811,6 +815,21 @@ const drawElementFromCanvas = (
       elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
       elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
       elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
       elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
     );
     );
+
+    if (
+      process.env.REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX &&
+      hasBoundTextElement(element)
+    ) {
+      const coords = computeBoundTextElementCoords(element);
+      context.strokeStyle = "#c92a2a";
+      context.lineWidth = 3;
+      context.strokeRect(
+        (coords.x + renderConfig.scrollX) * window.devicePixelRatio,
+        (coords.y + renderConfig.scrollY) * window.devicePixelRatio,
+        getMaxContainerWidth(element) * window.devicePixelRatio,
+        getMaxContainerHeight(element) * window.devicePixelRatio,
+      );
+    }
   }
   }
   context.restore();
   context.restore();
 
 

+ 45 - 0
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -119,6 +119,15 @@ Object {
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      Object {
+        "contextItemLabel": "labels.createContainerFromText",
+        "name": "createContainerFromText",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": Object {
+          "category": "element",
+        },
+      },
       Object {
       Object {
         "PanelComponent": [Function],
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
         "contextItemLabel": "labels.ungroup",
@@ -4507,6 +4516,15 @@ Object {
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      Object {
+        "contextItemLabel": "labels.createContainerFromText",
+        "name": "createContainerFromText",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": Object {
+          "category": "element",
+        },
+      },
       Object {
       Object {
         "PanelComponent": [Function],
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
         "contextItemLabel": "labels.ungroup",
@@ -5048,6 +5066,15 @@ Object {
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      Object {
+        "contextItemLabel": "labels.createContainerFromText",
+        "name": "createContainerFromText",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": Object {
+          "category": "element",
+        },
+      },
       Object {
       Object {
         "PanelComponent": [Function],
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
         "contextItemLabel": "labels.ungroup",
@@ -5888,6 +5915,15 @@ Object {
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      Object {
+        "contextItemLabel": "labels.createContainerFromText",
+        "name": "createContainerFromText",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": Object {
+          "category": "element",
+        },
+      },
       Object {
       Object {
         "PanelComponent": [Function],
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
         "contextItemLabel": "labels.ungroup",
@@ -6225,6 +6261,15 @@ Object {
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      Object {
+        "contextItemLabel": "labels.createContainerFromText",
+        "name": "createContainerFromText",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": Object {
+          "category": "element",
+        },
+      },
       Object {
       Object {
         "PanelComponent": [Function],
         "PanelComponent": [Function],
         "contextItemLabel": "labels.ungroup",
         "contextItemLabel": "labels.ungroup",

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

@@ -19,6 +19,7 @@ import { newFreeDrawElement, newImageElement } from "../../element/newElement";
 import { Point } from "../../types";
 import { Point } from "../../types";
 import { getSelectedElements } from "../../scene/selection";
 import { getSelectedElements } from "../../scene/selection";
 import { isLinearElementType } from "../../element/typeChecks";
 import { isLinearElementType } from "../../element/typeChecks";
+import { Mutable } from "../../utility-types";
 
 
 const readFile = util.promisify(fs.readFile);
 const readFile = util.promisify(fs.readFile);
 
 

+ 30 - 0
src/tests/shortcuts.test.tsx

@@ -0,0 +1,30 @@
+import { KEYS } from "../keys";
+import { Excalidraw } from "../packages/excalidraw/entry";
+import { API } from "./helpers/api";
+import { Keyboard } from "./helpers/ui";
+import { fireEvent, render, waitFor } from "./test-utils";
+
+describe("shortcuts", () => {
+  it("Clear canvas shortcut should display confirm dialog", async () => {
+    await render(
+      <Excalidraw
+        initialData={{ elements: [API.createElement({ type: "rectangle" })] }}
+        handleKeyboardGlobally
+      />,
+    );
+
+    expect(window.h.elements.length).toBe(1);
+
+    Keyboard.withModifierKeys({ ctrl: true }, () => {
+      Keyboard.keyDown(KEYS.DELETE);
+    });
+    const confirmDialog = document.querySelector(".confirm-dialog")!;
+    expect(confirmDialog).not.toBe(null);
+
+    fireEvent.click(confirmDialog.querySelector('[aria-label="Confirm"]')!);
+
+    await waitFor(() => {
+      expect(window.h.elements[0].isDeleted).toBe(true);
+    });
+  });
+});

+ 1 - 0
src/types.ts

@@ -31,6 +31,7 @@ import Library from "./data/library";
 import type { FileSystemHandle } from "./data/filesystem";
 import type { FileSystemHandle } from "./data/filesystem";
 import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
 import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
 import { ContextMenuItems } from "./components/ContextMenu";
 import { ContextMenuItems } from "./components/ContextMenu";
+import { Merge, ForwardRef } from "./utility-types";
 
 
 export type Point = Readonly<RoughPoint>;
 export type Point = Readonly<RoughPoint>;
 
 

+ 49 - 0
src/utility-types.ts

@@ -0,0 +1,49 @@
+export type Mutable<T> = {
+  -readonly [P in keyof T]: T[P];
+};
+
+export type ValueOf<T> = T[keyof T];
+
+export type Merge<M, N> = Omit<M, keyof N> & N;
+
+/** utility type to assert that the second type is a subtype of the first type.
+ * Returns the subtype. */
+export type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
+
+export type ResolutionType<T extends (...args: any) => any> = T extends (
+  ...args: any
+) => Promise<infer R>
+  ? R
+  : any;
+
+// https://github.com/krzkaczor/ts-essentials
+export type MarkOptional<T, K extends keyof T> = Omit<T, K> &
+  Partial<Pick<T, K>>;
+
+export type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
+  Required<Pick<T, RK>>;
+
+export type MarkNonNullable<T, K extends keyof T> = {
+  [P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
+} & { [P in keyof T]: T[P] };
+
+export type NonOptional<T> = Exclude<T, undefined>;
+
+// -----------------------------------------------------------------------------
+// type getter for interface's callable type
+// src: https://stackoverflow.com/a/58658851/927631
+// -----------------------------------------------------------------------------
+export type SignatureType<T> = T extends (...args: infer R) => any ? R : never;
+export type CallableType<T extends (...args: any[]) => any> = (
+  ...args: SignatureType<T>
+) => ReturnType<T>;
+// --------------------------------------------------------------------------—
+
+// Type for React.forwardRef --- supply only the first generic argument T
+export type ForwardRef<T, P = any> = Parameters<
+  CallableType<React.ForwardRefRenderFunction<T, P>>
+>[1];
+
+export type ExtractSetType<T extends Set<any>> = T extends Set<infer U>
+  ? U
+  : never;

+ 1 - 0
src/utils.ts

@@ -16,6 +16,7 @@ import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { unstable_batchedUpdates } from "react-dom";
 import { SHAPES } from "./shapes";
 import { SHAPES } from "./shapes";
 import { isEraserActive, isHandToolActive } from "./appState";
 import { isEraserActive, isHandToolActive } from "./appState";
+import { ResolutionType } from "./utility-types";
 
 
 let mockDateTime: string | null = null;
 let mockDateTime: string | null = null;