Переглянути джерело

feat: text wrapping (#7999)

* resize single elements from the side

* fix lint

* do not resize texts from the sides (for we want to wrap/unwrap)

* omit side handles for frames too

* upgrade types

* enable resizing from the sides for multiple elements as well

* fix lint

* maintain aspect ratio when elements are not of the same angle

* lint

* always resize proportionally for multiple elements

* increase side resizing padding

* code cleanup

* adaptive handles

* do not resize for linear elements with only two points

* prioritize point dragging over edge resizing

* lint

* allow free resizing for multiple elements at degree 0

* always resize from the sides

* reduce hit threshold

* make small multiple elements movable

* lint

* show side handles on touch screen and mobile devices

* differentiate touchscreens

* keep proportional with text in multi-element resizing

* update snapshot

* update multi elements resizing logic

* lint

* reduce side resizing padding

* bound texts do not scale in normal cases

* lint

* test sides for texts

* wrap text

* do not update text size when changing its alignment

* keep text wrapped/unwrapped when editing

* change wrapped size to auto size from context menu

* fix test

* lint

* increase min width for wrapped texts

* wrap wrapped text in container

* unwrap when binding text to container

* rename `wrapped` to `autoResize`

* fix lint

* revert: use `center` align when wrapping text in container

* update snaps

* fix lint

* simplify logic on autoResize

* lint and test

* snapshots

* remove unnecessary code

* snapshots

* fix: defaults not set correctly

* tests for wrapping texts when resized

* tests for text wrapping when edited

* fix autoResize refactor

* include autoResize flag check

* refactor

* feat: rename action label & change contextmenu position

* fix: update version on `autoResize` action

* fix infinite loop when editing text in a container

* simplify

* always maintain `width` if `!autoResize`

* maintain `x` if `!autoResize`

* maintain `y` pos after fontSize change if `!autoResize`

* refactor

* when editing, do not wrap text in textWysiwyg

* simplify text editor

* make test more readable

* comment

* rename action to match file name

* revert function signature change

* only update  in app

---------

Co-authored-by: dwelle <[email protected]>
Ryan Di 1 рік тому
батько
коміт
971b4d4ae6

+ 3 - 1
packages/excalidraw/actions/actionBoundText.tsx

@@ -1,8 +1,8 @@
 import {
 import {
   BOUND_TEXT_PADDING,
   BOUND_TEXT_PADDING,
   ROUNDNESS,
   ROUNDNESS,
-  VERTICAL_ALIGN,
   TEXT_ALIGN,
   TEXT_ALIGN,
+  VERTICAL_ALIGN,
 } from "../constants";
 } from "../constants";
 import { isTextElement, newElement } from "../element";
 import { isTextElement, newElement } from "../element";
 import { mutateElement } from "../element/mutateElement";
 import { mutateElement } from "../element/mutateElement";
@@ -142,6 +142,7 @@ export const actionBindText = register({
       containerId: container.id,
       containerId: container.id,
       verticalAlign: VERTICAL_ALIGN.MIDDLE,
       verticalAlign: VERTICAL_ALIGN.MIDDLE,
       textAlign: TEXT_ALIGN.CENTER,
       textAlign: TEXT_ALIGN.CENTER,
+      autoResize: true,
     });
     });
     mutateElement(container, {
     mutateElement(container, {
       boundElements: (container.boundElements || []).concat({
       boundElements: (container.boundElements || []).concat({
@@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({
             verticalAlign: VERTICAL_ALIGN.MIDDLE,
             verticalAlign: VERTICAL_ALIGN.MIDDLE,
             boundElements: null,
             boundElements: null,
             textAlign: TEXT_ALIGN.CENTER,
             textAlign: TEXT_ALIGN.CENTER,
+            autoResize: true,
           },
           },
           false,
           false,
         );
         );

+ 1 - 1
packages/excalidraw/actions/actionProperties.tsx

@@ -167,7 +167,7 @@ const offsetElementAfterFontResize = (
   prevElement: ExcalidrawTextElement,
   prevElement: ExcalidrawTextElement,
   nextElement: ExcalidrawTextElement,
   nextElement: ExcalidrawTextElement,
 ) => {
 ) => {
-  if (isBoundToContainer(nextElement)) {
+  if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
     return nextElement;
     return nextElement;
   }
   }
   return mutateElement(
   return mutateElement(

+ 48 - 0
packages/excalidraw/actions/actionTextAutoResize.ts

@@ -0,0 +1,48 @@
+import { isTextElement } from "../element";
+import { newElementWith } from "../element/mutateElement";
+import { measureText } from "../element/textElement";
+import { getSelectedElements } from "../scene";
+import { StoreAction } from "../store";
+import type { AppClassProperties } from "../types";
+import { getFontString } from "../utils";
+import { register } from "./register";
+
+export const actionTextAutoResize = register({
+  name: "autoResize",
+  label: "labels.autoResize",
+  icon: null,
+  trackEvent: { category: "element" },
+  predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return (
+      selectedElements.length === 1 &&
+      isTextElement(selectedElements[0]) &&
+      !selectedElements[0].autoResize
+    );
+  },
+  perform: (elements, appState, _, app) => {
+    const selectedElements = getSelectedElements(elements, appState);
+
+    return {
+      appState,
+      elements: elements.map((element) => {
+        if (element.id === selectedElements[0].id && isTextElement(element)) {
+          const metrics = measureText(
+            element.originalText,
+            getFontString(element),
+            element.lineHeight,
+          );
+
+          return newElementWith(element, {
+            autoResize: true,
+            width: metrics.width,
+            height: metrics.height,
+            text: element.originalText,
+          });
+        }
+        return element;
+      }),
+      storeAction: StoreAction.CAPTURE,
+    };
+  },
+});

+ 2 - 1
packages/excalidraw/actions/types.ts

@@ -134,7 +134,8 @@ export type ActionName =
   | "setEmbeddableAsActiveTool"
   | "setEmbeddableAsActiveTool"
   | "createContainerFromText"
   | "createContainerFromText"
   | "wrapTextInContainer"
   | "wrapTextInContainer"
-  | "commandPalette";
+  | "commandPalette"
+  | "autoResize";
 
 
 export type PanelComponentProps = {
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];

+ 22 - 22
packages/excalidraw/components/App.tsx

@@ -114,7 +114,7 @@ import {
   newTextElement,
   newTextElement,
   newImageElement,
   newImageElement,
   transformElements,
   transformElements,
-  updateTextElement,
+  refreshTextDimensions,
   redrawTextBoundingBox,
   redrawTextBoundingBox,
   getElementAbsoluteCoords,
   getElementAbsoluteCoords,
 } from "../element";
 } from "../element";
@@ -429,6 +429,7 @@ import {
   isPointHittingLinkIcon,
   isPointHittingLinkIcon,
 } from "./hyperlink/helpers";
 } from "./hyperlink/helpers";
 import { getShortcutFromShortcutName } from "../actions/shortcuts";
 import { getShortcutFromShortcutName } from "../actions/shortcuts";
+import { actionTextAutoResize } from "../actions/actionTextAutoResize";
 
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -4298,25 +4299,22 @@ class App extends React.Component<AppProps, AppState> {
   ) {
   ) {
     const elementsMap = this.scene.getElementsMapIncludingDeleted();
     const elementsMap = this.scene.getElementsMapIncludingDeleted();
 
 
-    const updateElement = (
-      text: string,
-      originalText: string,
-      isDeleted: boolean,
-    ) => {
+    const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
       this.scene.replaceAllElements([
       this.scene.replaceAllElements([
         // Not sure why we include deleted elements as well hence using deleted elements map
         // Not sure why we include deleted elements as well hence using deleted elements map
         ...this.scene.getElementsIncludingDeleted().map((_element) => {
         ...this.scene.getElementsIncludingDeleted().map((_element) => {
           if (_element.id === element.id && isTextElement(_element)) {
           if (_element.id === element.id && isTextElement(_element)) {
-            return updateTextElement(
-              _element,
-              getContainerElement(_element, elementsMap),
-              elementsMap,
-              {
-                text,
-                isDeleted,
-                originalText,
-              },
-            );
+            return newElementWith(_element, {
+              originalText: nextOriginalText,
+              isDeleted: isDeleted ?? _element.isDeleted,
+              // returns (wrapped) text and new dimensions
+              ...refreshTextDimensions(
+                _element,
+                getContainerElement(_element, elementsMap),
+                elementsMap,
+                nextOriginalText,
+              ),
+            });
           }
           }
           return _element;
           return _element;
         }),
         }),
@@ -4339,15 +4337,15 @@ class App extends React.Component<AppProps, AppState> {
           viewportY - this.state.offsetTop,
           viewportY - this.state.offsetTop,
         ];
         ];
       },
       },
-      onChange: withBatchedUpdates((text) => {
-        updateElement(text, text, false);
+      onChange: withBatchedUpdates((nextOriginalText) => {
+        updateElement(nextOriginalText, false);
         if (isNonDeletedElement(element)) {
         if (isNonDeletedElement(element)) {
           updateBoundElements(element, elementsMap);
           updateBoundElements(element, elementsMap);
         }
         }
       }),
       }),
-      onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => {
-        const isDeleted = !text.trim();
-        updateElement(text, originalText, isDeleted);
+      onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
+        const isDeleted = !nextOriginalText.trim();
+        updateElement(nextOriginalText, isDeleted);
         // select the created text element only if submitting via keyboard
         // select the created text element only if submitting via keyboard
         // (when submitting via click it should act as signal to deselect)
         // (when submitting via click it should act as signal to deselect)
         if (!isDeleted && viaKeyboard) {
         if (!isDeleted && viaKeyboard) {
@@ -4392,7 +4390,7 @@ class App extends React.Component<AppProps, AppState> {
 
 
     // do an initial update to re-initialize element position since we were
     // do an initial update to re-initialize element position since we were
     // modifying element's x/y for sake of editor (case: syncing to remote)
     // modifying element's x/y for sake of editor (case: syncing to remote)
-    updateElement(element.text, element.originalText, false);
+    updateElement(element.originalText, false);
   }
   }
 
 
   private deselectElements() {
   private deselectElements() {
@@ -9631,6 +9629,7 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
 
 
     return [
     return [
+      CONTEXT_MENU_SEPARATOR,
       actionCut,
       actionCut,
       actionCopy,
       actionCopy,
       actionPaste,
       actionPaste,
@@ -9643,6 +9642,7 @@ class App extends React.Component<AppProps, AppState> {
       actionPasteStyles,
       actionPasteStyles,
       CONTEXT_MENU_SEPARATOR,
       CONTEXT_MENU_SEPARATOR,
       actionGroup,
       actionGroup,
+      actionTextAutoResize,
       actionUnbindText,
       actionUnbindText,
       actionBindText,
       actionBindText,
       actionWrapTextInContainer,
       actionWrapTextInContainer,

+ 25 - 0
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -228,6 +228,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
 exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": [
   "boundElements": [
     {
     {
@@ -273,6 +274,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
 exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": [
   "boundElements": [
     {
     {
@@ -378,6 +380,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
 exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id48",
   "containerId": "id48",
@@ -478,6 +481,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
 exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id37",
   "containerId": "id37",
@@ -652,6 +656,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
 exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id41",
   "containerId": "id41",
@@ -692,6 +697,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
 exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": [
   "boundElements": [
     {
     {
@@ -737,6 +743,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
 exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
 exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": [
   "boundElements": [
     {
     {
@@ -1194,6 +1201,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
 exports[`Test Transform > should transform text element 1`] = `
 exports[`Test Transform > should transform text element 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": null,
   "containerId": null,
@@ -1234,6 +1242,7 @@ exports[`Test Transform > should transform text element 1`] = `
 exports[`Test Transform > should transform text element 2`] = `
 exports[`Test Transform > should transform text element 2`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": null,
   "containerId": null,
@@ -1566,6 +1575,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "B",
   "containerId": "B",
@@ -1608,6 +1618,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "A",
   "containerId": "A",
@@ -1650,6 +1661,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "Alice",
   "containerId": "Alice",
@@ -1692,6 +1704,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "Bob",
   "containerId": "Bob",
@@ -1734,6 +1747,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "Bob_Alice",
   "containerId": "Bob_Alice",
@@ -1774,6 +1788,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
 exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "Bob_B",
   "containerId": "Bob_B",
@@ -2022,6 +2037,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id25",
   "containerId": "id25",
@@ -2062,6 +2078,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id26",
   "containerId": "id26",
@@ -2102,6 +2119,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id27",
   "containerId": "id27",
@@ -2143,6 +2161,7 @@ LABELLED ARROW",
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
 exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id28",
   "containerId": "id28",
@@ -2406,6 +2425,7 @@ exports[`Test Transform > should transform to text containers when label provide
 exports[`Test Transform > should transform to text containers when label provided 7`] = `
 exports[`Test Transform > should transform to text containers when label provided 7`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id13",
   "containerId": "id13",
@@ -2446,6 +2466,7 @@ exports[`Test Transform > should transform to text containers when label provide
 exports[`Test Transform > should transform to text containers when label provided 8`] = `
 exports[`Test Transform > should transform to text containers when label provided 8`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id14",
   "containerId": "id14",
@@ -2487,6 +2508,7 @@ CONTAINER",
 exports[`Test Transform > should transform to text containers when label provided 9`] = `
 exports[`Test Transform > should transform to text containers when label provided 9`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id15",
   "containerId": "id15",
@@ -2530,6 +2552,7 @@ CONTAINER",
 exports[`Test Transform > should transform to text containers when label provided 10`] = `
 exports[`Test Transform > should transform to text containers when label provided 10`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id16",
   "containerId": "id16",
@@ -2571,6 +2594,7 @@ TEXT CONTAINER",
 exports[`Test Transform > should transform to text containers when label provided 11`] = `
 exports[`Test Transform > should transform to text containers when label provided 11`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id17",
   "containerId": "id17",
@@ -2613,6 +2637,7 @@ CONTAINER",
 exports[`Test Transform > should transform to text containers when label provided 12`] = `
 exports[`Test Transform > should transform to text containers when label provided 12`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id18",
   "containerId": "id18",

+ 1 - 1
packages/excalidraw/data/restore.ts

@@ -208,7 +208,7 @@ const restoreElement = (
         verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
         verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
         containerId: element.containerId ?? null,
         containerId: element.containerId ?? null,
         originalText: element.originalText || text,
         originalText: element.originalText || text,
-
+        autoResize: element.autoResize ?? true,
         lineHeight,
         lineHeight,
       });
       });
 
 

+ 0 - 1
packages/excalidraw/element/index.ts

@@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks";
 export {
 export {
   newElement,
   newElement,
   newTextElement,
   newTextElement,
-  updateTextElement,
   refreshTextDimensions,
   refreshTextDimensions,
   newLinearElement,
   newLinearElement,
   newImageElement,
   newImageElement,

+ 33 - 41
packages/excalidraw/element/newElement.ts

@@ -240,24 +240,28 @@ export const newTextElement = (
     metrics,
     metrics,
   );
   );
 
 
-  const textElement = newElementWith(
-    {
-      ..._newElementBase<ExcalidrawTextElement>("text", opts),
-      text,
-      fontSize,
-      fontFamily,
-      textAlign,
-      verticalAlign,
-      x: opts.x - offsets.x,
-      y: opts.y - offsets.y,
-      width: metrics.width,
-      height: metrics.height,
-      containerId: opts.containerId || null,
-      originalText: text,
-      lineHeight,
-    },
+  const textElementProps: ExcalidrawTextElement = {
+    ..._newElementBase<ExcalidrawTextElement>("text", opts),
+    text,
+    fontSize,
+    fontFamily,
+    textAlign,
+    verticalAlign,
+    x: opts.x - offsets.x,
+    y: opts.y - offsets.y,
+    width: metrics.width,
+    height: metrics.height,
+    containerId: opts.containerId || null,
+    originalText: text,
+    autoResize: true,
+    lineHeight,
+  };
+
+  const textElement: ExcalidrawTextElement = newElementWith(
+    textElementProps,
     {},
     {},
   );
   );
+
   return textElement;
   return textElement;
 };
 };
 
 
@@ -271,18 +275,25 @@ const getAdjustedDimensions = (
   width: number;
   width: number;
   height: number;
   height: number;
 } => {
 } => {
-  const { width: nextWidth, height: nextHeight } = measureText(
+  let { width: nextWidth, height: nextHeight } = measureText(
     nextText,
     nextText,
     getFontString(element),
     getFontString(element),
     element.lineHeight,
     element.lineHeight,
   );
   );
+
+  // wrapped text
+  if (!element.autoResize) {
+    nextWidth = element.width;
+  }
+
   const { textAlign, verticalAlign } = element;
   const { textAlign, verticalAlign } = element;
   let x: number;
   let x: number;
   let y: number;
   let y: number;
   if (
   if (
     textAlign === "center" &&
     textAlign === "center" &&
     verticalAlign === VERTICAL_ALIGN.MIDDLE &&
     verticalAlign === VERTICAL_ALIGN.MIDDLE &&
-    !element.containerId
+    !element.containerId &&
+    element.autoResize
   ) {
   ) {
     const prevMetrics = measureText(
     const prevMetrics = measureText(
       element.text,
       element.text,
@@ -343,38 +354,19 @@ export const refreshTextDimensions = (
   if (textElement.isDeleted) {
   if (textElement.isDeleted) {
     return;
     return;
   }
   }
-  if (container) {
+  if (container || !textElement.autoResize) {
     text = wrapText(
     text = wrapText(
       text,
       text,
       getFontString(textElement),
       getFontString(textElement),
-      getBoundTextMaxWidth(container, textElement),
+      container
+        ? getBoundTextMaxWidth(container, textElement)
+        : textElement.width,
     );
     );
   }
   }
   const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
   const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
   return { text, ...dimensions };
   return { text, ...dimensions };
 };
 };
 
 
-export const updateTextElement = (
-  textElement: ExcalidrawTextElement,
-  container: ExcalidrawTextContainer | null,
-  elementsMap: ElementsMap,
-  {
-    text,
-    isDeleted,
-    originalText,
-  }: {
-    text: string;
-    isDeleted?: boolean;
-    originalText: string;
-  },
-): ExcalidrawTextElement => {
-  return newElementWith(textElement, {
-    originalText,
-    isDeleted: isDeleted ?? textElement.isDeleted,
-    ...refreshTextDimensions(textElement, container, elementsMap, originalText),
-  });
-};
-
 export const newFreeDrawElement = (
 export const newFreeDrawElement = (
   opts: {
   opts: {
     type: "freedraw";
     type: "freedraw";

+ 121 - 20
packages/excalidraw/element/resizeElements.ts

@@ -1,4 +1,8 @@
-import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
+import {
+  BOUND_TEXT_PADDING,
+  MIN_FONT_SIZE,
+  SHIFT_LOCKING_ANGLE,
+} from "../constants";
 import { rescalePoints } from "../points";
 import { rescalePoints } from "../points";
 
 
 import { rotate, centerPoint, rotatePoint } from "../math";
 import { rotate, centerPoint, rotatePoint } from "../math";
@@ -45,6 +49,9 @@ import {
   handleBindTextResize,
   handleBindTextResize,
   getBoundTextMaxWidth,
   getBoundTextMaxWidth,
   getApproxMinLineHeight,
   getApproxMinLineHeight,
+  wrapText,
+  measureText,
+  getMinCharWidth,
 } from "./textElement";
 } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
 import { isInGroup } from "../groups";
 import { isInGroup } from "../groups";
@@ -84,14 +91,9 @@ export const transformElements = (
         shouldRotateWithDiscreteAngle,
         shouldRotateWithDiscreteAngle,
       );
       );
       updateBoundElements(element, elementsMap);
       updateBoundElements(element, elementsMap);
-    } else if (
-      isTextElement(element) &&
-      (transformHandleType === "nw" ||
-        transformHandleType === "ne" ||
-        transformHandleType === "sw" ||
-        transformHandleType === "se")
-    ) {
+    } else if (isTextElement(element) && transformHandleType) {
       resizeSingleTextElement(
       resizeSingleTextElement(
+        originalElements,
         element,
         element,
         elementsMap,
         elementsMap,
         transformHandleType,
         transformHandleType,
@@ -223,9 +225,10 @@ const measureFontSizeFromWidth = (
 };
 };
 
 
 const resizeSingleTextElement = (
 const resizeSingleTextElement = (
+  originalElements: PointerDownState["originalElements"],
   element: NonDeleted<ExcalidrawTextElement>,
   element: NonDeleted<ExcalidrawTextElement>,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-  transformHandleType: "nw" | "ne" | "sw" | "se",
+  transformHandleType: TransformHandleDirection,
   shouldResizeFromCenter: boolean,
   shouldResizeFromCenter: boolean,
   pointerX: number,
   pointerX: number,
   pointerY: number,
   pointerY: number,
@@ -245,17 +248,19 @@ const resizeSingleTextElement = (
   let scaleX = 0;
   let scaleX = 0;
   let scaleY = 0;
   let scaleY = 0;
 
 
-  if (transformHandleType.includes("e")) {
-    scaleX = (rotatedX - x1) / (x2 - x1);
-  }
-  if (transformHandleType.includes("w")) {
-    scaleX = (x2 - rotatedX) / (x2 - x1);
-  }
-  if (transformHandleType.includes("n")) {
-    scaleY = (y2 - rotatedY) / (y2 - y1);
-  }
-  if (transformHandleType.includes("s")) {
-    scaleY = (rotatedY - y1) / (y2 - y1);
+  if (transformHandleType !== "e" && transformHandleType !== "w") {
+    if (transformHandleType.includes("e")) {
+      scaleX = (rotatedX - x1) / (x2 - x1);
+    }
+    if (transformHandleType.includes("w")) {
+      scaleX = (x2 - rotatedX) / (x2 - x1);
+    }
+    if (transformHandleType.includes("n")) {
+      scaleY = (y2 - rotatedY) / (y2 - y1);
+    }
+    if (transformHandleType.includes("s")) {
+      scaleY = (rotatedY - y1) / (y2 - y1);
+    }
   }
   }
 
 
   const scale = Math.max(scaleX, scaleY);
   const scale = Math.max(scaleX, scaleY);
@@ -318,6 +323,102 @@ const resizeSingleTextElement = (
       y: nextY,
       y: nextY,
     });
     });
   }
   }
+
+  if (transformHandleType === "e" || transformHandleType === "w") {
+    const stateAtResizeStart = originalElements.get(element.id)!;
+    const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+      stateAtResizeStart,
+      stateAtResizeStart.width,
+      stateAtResizeStart.height,
+      true,
+    );
+    const startTopLeft: Point = [x1, y1];
+    const startBottomRight: Point = [x2, y2];
+    const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
+
+    const rotatedPointer = rotatePoint(
+      [pointerX, pointerY],
+      startCenter,
+      -stateAtResizeStart.angle,
+    );
+
+    const [esx1, , esx2] = getResizedElementAbsoluteCoords(
+      element,
+      element.width,
+      element.height,
+      true,
+    );
+
+    const boundsCurrentWidth = esx2 - esx1;
+
+    const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
+    const minWidth =
+      getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2;
+
+    let scaleX = atStartBoundsWidth / boundsCurrentWidth;
+
+    if (transformHandleType.includes("e")) {
+      scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
+    }
+    if (transformHandleType.includes("w")) {
+      scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
+    }
+
+    const newWidth =
+      element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
+
+    const text = wrapText(
+      element.originalText,
+      getFontString(element),
+      Math.abs(newWidth),
+    );
+    const metrics = measureText(
+      text,
+      getFontString(element),
+      element.lineHeight,
+    );
+
+    const eleNewHeight = metrics.height;
+
+    const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
+      getResizedElementAbsoluteCoords(
+        stateAtResizeStart,
+        newWidth,
+        eleNewHeight,
+        true,
+      );
+    const newBoundsWidth = newBoundsX2 - newBoundsX1;
+    const newBoundsHeight = newBoundsY2 - newBoundsY1;
+
+    let newTopLeft = [...startTopLeft] as [number, number];
+    if (["n", "w", "nw"].includes(transformHandleType)) {
+      newTopLeft = [
+        startBottomRight[0] - Math.abs(newBoundsWidth),
+        startTopLeft[1],
+      ];
+    }
+
+    // adjust topLeft to new rotation point
+    const angle = stateAtResizeStart.angle;
+    const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
+    const newCenter: Point = [
+      newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
+      newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
+    ];
+    const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
+    newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+
+    const resizedElement: Partial<ExcalidrawTextElement> = {
+      width: Math.abs(newWidth),
+      height: Math.abs(metrics.height),
+      x: newTopLeft[0],
+      y: newTopLeft[1],
+      text,
+      autoResize: false,
+    };
+
+    mutateElement(element, resizedElement);
+  }
 };
 };
 
 
 export const resizeSingleElement = (
 export const resizeSingleElement = (

+ 2 - 6
packages/excalidraw/element/resizeTest.ts

@@ -87,12 +87,8 @@ export const resizeTest = (
       elementsMap,
       elementsMap,
     );
     );
 
 
-    // Note that for a text element, when "resized" from the side
-    // we should make it wrap/unwrap
-    if (
-      element.type !== "text" &&
-      !(isLinearElement(element) && element.points.length <= 2)
-    ) {
+    // do not resize from the sides for linear elements with only two points
+    if (!(isLinearElement(element) && element.points.length <= 2)) {
       const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
       const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
       const sides = getSelectionBorders(
       const sides = getSelectionBorders(
         [x1 - SPACING, y1 - SPACING],
         [x1 - SPACING, y1 - SPACING],

+ 10 - 4
packages/excalidraw/element/textElement.ts

@@ -48,7 +48,7 @@ export const redrawTextBoundingBox = (
   textElement: ExcalidrawTextElement,
   textElement: ExcalidrawTextElement,
   container: ExcalidrawElement | null,
   container: ExcalidrawElement | null,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-  informMutation: boolean = true,
+  informMutation = true,
 ) => {
 ) => {
   let maxWidth = undefined;
   let maxWidth = undefined;
   const boundTextUpdates = {
   const boundTextUpdates = {
@@ -62,21 +62,27 @@ export const redrawTextBoundingBox = (
 
 
   boundTextUpdates.text = textElement.text;
   boundTextUpdates.text = textElement.text;
 
 
-  if (container) {
-    maxWidth = getBoundTextMaxWidth(container, textElement);
+  if (container || !textElement.autoResize) {
+    maxWidth = container
+      ? getBoundTextMaxWidth(container, textElement)
+      : textElement.width;
     boundTextUpdates.text = wrapText(
     boundTextUpdates.text = wrapText(
       textElement.originalText,
       textElement.originalText,
       getFontString(textElement),
       getFontString(textElement),
       maxWidth,
       maxWidth,
     );
     );
   }
   }
+
   const metrics = measureText(
   const metrics = measureText(
     boundTextUpdates.text,
     boundTextUpdates.text,
     getFontString(textElement),
     getFontString(textElement),
     textElement.lineHeight,
     textElement.lineHeight,
   );
   );
 
 
-  boundTextUpdates.width = metrics.width;
+  // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
+  if (textElement.autoResize) {
+    boundTextUpdates.width = metrics.width;
+  }
   boundTextUpdates.height = metrics.height;
   boundTextUpdates.height = metrics.height;
 
 
   if (container) {
   if (container) {

+ 112 - 12
packages/excalidraw/element/textWysiwyg.test.tsx

@@ -236,6 +236,117 @@ describe("textWysiwyg", () => {
     });
     });
   });
   });
 
 
+  describe("Test text wrapping", () => {
+    const { h } = window;
+    const dimensions = { height: 400, width: 800 };
+
+    beforeAll(() => {
+      mockBoundingClientRect(dimensions);
+    });
+
+    beforeEach(async () => {
+      await render(<Excalidraw handleKeyboardGlobally={true} />);
+      // @ts-ignore
+      h.app.refreshViewportBreakpoints();
+      // @ts-ignore
+      h.app.refreshEditorBreakpoints();
+
+      h.elements = [];
+    });
+
+    afterAll(() => {
+      restoreOriginalGetBoundingClientRect();
+    });
+
+    it("should keep width when editing a wrapped text", async () => {
+      const text = API.createElement({
+        type: "text",
+        text: "Excalidraw\nEditor",
+      });
+
+      h.elements = [text];
+
+      const prevWidth = text.width;
+      const prevHeight = text.height;
+      const prevText = text.text;
+
+      // text is wrapped
+      UI.resize(text, "e", [-20, 0]);
+      expect(text.width).not.toEqual(prevWidth);
+      expect(text.height).not.toEqual(prevHeight);
+      expect(text.text).not.toEqual(prevText);
+      expect(text.autoResize).toBe(false);
+
+      const wrappedWidth = text.width;
+      const wrappedHeight = text.height;
+      const wrappedText = text.text;
+
+      // edit text
+      UI.clickTool("selection");
+      mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+      const editor = await getTextEditor(textEditorSelector);
+      expect(editor).not.toBe(null);
+      expect(h.state.editingElement?.id).toBe(text.id);
+      expect(h.elements.length).toBe(1);
+
+      const nextText = `${wrappedText} is great!`;
+      updateTextEditor(editor, nextText);
+      await new Promise((cb) => setTimeout(cb, 0));
+      editor.blur();
+
+      expect(h.elements[0].width).toEqual(wrappedWidth);
+      expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
+
+      // remove all texts and then add it back editing
+      updateTextEditor(editor, "");
+      await new Promise((cb) => setTimeout(cb, 0));
+      updateTextEditor(editor, nextText);
+      await new Promise((cb) => setTimeout(cb, 0));
+      editor.blur();
+
+      expect(h.elements[0].width).toEqual(wrappedWidth);
+    });
+
+    it("should restore original text after unwrapping a wrapped text", async () => {
+      const originalText = "Excalidraw\neditor\nis great!";
+      const text = API.createElement({
+        type: "text",
+        text: originalText,
+      });
+      h.elements = [text];
+
+      // wrap
+      UI.resize(text, "e", [-40, 0]);
+      // enter text editing mode
+      UI.clickTool("selection");
+      mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+      const editor = await getTextEditor(textEditorSelector);
+      editor.blur();
+      // restore after unwrapping
+      UI.resize(text, "e", [40, 0]);
+      expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
+
+      // wrap again and add a new line
+      UI.resize(text, "e", [-30, 0]);
+      const wrappedText = text.text;
+      UI.clickTool("selection");
+      mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+      updateTextEditor(editor, `${wrappedText}\nA new line!`);
+      await new Promise((cb) => setTimeout(cb, 0));
+      editor.blur();
+      // remove the newly added line
+      UI.clickTool("selection");
+      mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+      updateTextEditor(editor, wrappedText);
+      await new Promise((cb) => setTimeout(cb, 0));
+      editor.blur();
+      // unwrap
+      UI.resize(text, "e", [30, 0]);
+      // expect the text to be restored the same
+      expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
+    });
+  });
+
   describe("Test container-unbound text", () => {
   describe("Test container-unbound text", () => {
     const { h } = window;
     const { h } = window;
     const dimensions = { height: 400, width: 800 };
     const dimensions = { height: 400, width: 800 };
@@ -800,26 +911,15 @@ describe("textWysiwyg", () => {
       mouse.down();
       mouse.down();
 
 
       const text = h.elements[1] as ExcalidrawTextElementWithContainer;
       const text = h.elements[1] as ExcalidrawTextElementWithContainer;
-      let editor = await getTextEditor(textEditorSelector, true);
+      const editor = await getTextEditor(textEditorSelector, true);
 
 
       await new Promise((r) => setTimeout(r, 0));
       await new Promise((r) => setTimeout(r, 0));
       updateTextEditor(editor, "Hello World!");
       updateTextEditor(editor, "Hello World!");
       editor.blur();
       editor.blur();
       expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
       expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
-      UI.clickTool("text");
-
-      mouse.clickAt(
-        rectangle.x + rectangle.width / 2,
-        rectangle.y + rectangle.height / 2,
-      );
-      mouse.down();
-      editor = await getTextEditor(textEditorSelector, true);
 
 
-      editor.select();
       fireEvent.click(screen.getByTitle(/code/i));
       fireEvent.click(screen.getByTitle(/code/i));
 
 
-      await new Promise((r) => setTimeout(r, 0));
-      editor.blur();
       expect(
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
       ).toEqual(FONT_FAMILY.Cascadia);
       ).toEqual(FONT_FAMILY.Cascadia);

+ 11 - 14
packages/excalidraw/element/textWysiwyg.tsx

@@ -79,12 +79,14 @@ export const textWysiwyg = ({
   app,
   app,
 }: {
 }: {
   id: ExcalidrawElement["id"];
   id: ExcalidrawElement["id"];
-  onChange?: (text: string) => void;
-  onSubmit: (data: {
-    text: string;
-    viaKeyboard: boolean;
-    originalText: string;
-  }) => void;
+  /**
+   * textWysiwyg only deals with `originalText`
+   *
+   * Note: `text`, which can be wrapped and therefore different from `originalText`,
+   *       is derived from `originalText`
+   */
+  onChange?: (nextOriginalText: string) => void;
+  onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
   getViewportCoords: (x: number, y: number) => [number, number];
   getViewportCoords: (x: number, y: number) => [number, number];
   element: ExcalidrawTextElement;
   element: ExcalidrawTextElement;
   canvas: HTMLCanvasElement;
   canvas: HTMLCanvasElement;
@@ -129,11 +131,8 @@ export const textWysiwyg = ({
         app.scene.getNonDeletedElementsMap(),
         app.scene.getNonDeletedElementsMap(),
       );
       );
       let maxWidth = updatedTextElement.width;
       let maxWidth = updatedTextElement.width;
-
       let maxHeight = updatedTextElement.height;
       let maxHeight = updatedTextElement.height;
       let textElementWidth = updatedTextElement.width;
       let textElementWidth = updatedTextElement.width;
-      // Set to element height by default since that's
-      // what is going to be used for unbounded text
       const textElementHeight = updatedTextElement.height;
       const textElementHeight = updatedTextElement.height;
 
 
       if (container && updatedTextElement.containerId) {
       if (container && updatedTextElement.containerId) {
@@ -262,6 +261,7 @@ export const textWysiwyg = ({
       if (isTestEnv()) {
       if (isTestEnv()) {
         editable.style.fontFamily = getFontFamilyString(updatedTextElement);
         editable.style.fontFamily = getFontFamilyString(updatedTextElement);
       }
       }
+
       mutateElement(updatedTextElement, { x: coordX, y: coordY });
       mutateElement(updatedTextElement, { x: coordX, y: coordY });
     }
     }
   };
   };
@@ -278,7 +278,7 @@ export const textWysiwyg = ({
   let whiteSpace = "pre";
   let whiteSpace = "pre";
   let wordBreak = "normal";
   let wordBreak = "normal";
 
 
-  if (isBoundToContainer(element)) {
+  if (isBoundToContainer(element) || !element.autoResize) {
     whiteSpace = "pre-wrap";
     whiteSpace = "pre-wrap";
     wordBreak = "break-word";
     wordBreak = "break-word";
   }
   }
@@ -501,14 +501,12 @@ export const textWysiwyg = ({
     if (!updateElement) {
     if (!updateElement) {
       return;
       return;
     }
     }
-    let text = editable.value;
     const container = getContainerElement(
     const container = getContainerElement(
       updateElement,
       updateElement,
       app.scene.getNonDeletedElementsMap(),
       app.scene.getNonDeletedElementsMap(),
     );
     );
 
 
     if (container) {
     if (container) {
-      text = updateElement.text;
       if (editable.value.trim()) {
       if (editable.value.trim()) {
         const boundTextElementId = getBoundTextElementId(container);
         const boundTextElementId = getBoundTextElementId(container);
         if (!boundTextElementId || boundTextElementId !== element.id) {
         if (!boundTextElementId || boundTextElementId !== element.id) {
@@ -540,9 +538,8 @@ export const textWysiwyg = ({
     }
     }
 
 
     onSubmit({
     onSubmit({
-      text,
       viaKeyboard: submittedViaKeyboard,
       viaKeyboard: submittedViaKeyboard,
-      originalText: editable.value,
+      nextOriginalText: editable.value,
     });
     });
   };
   };
 
 

+ 0 - 10
packages/excalidraw/element/transformHandles.ts

@@ -9,7 +9,6 @@ import type { Bounds } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
 import { rotate } from "../math";
 import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
 import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
-import { isTextElement } from ".";
 import { isFrameLikeElement, isLinearElement } from "./typeChecks";
 import { isFrameLikeElement, isLinearElement } from "./typeChecks";
 import {
 import {
   DEFAULT_TRANSFORM_HANDLE_SPACING,
   DEFAULT_TRANSFORM_HANDLE_SPACING,
@@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = {
   rotation: true,
   rotation: true,
 };
 };
 
 
-const OMIT_SIDES_FOR_TEXT_ELEMENT = {
-  e: true,
-  s: true,
-  n: true,
-  w: true,
-};
-
 const OMIT_SIDES_FOR_LINE_SLASH = {
 const OMIT_SIDES_FOR_LINE_SLASH = {
   e: true,
   e: true,
   s: true,
   s: true,
@@ -290,8 +282,6 @@ export const getTransformHandles = (
         omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
         omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
       }
       }
     }
     }
-  } else if (isTextElement(element)) {
-    omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
   } else if (isFrameLikeElement(element)) {
   } else if (isFrameLikeElement(element)) {
     omitSides = {
     omitSides = {
       ...omitSides,
       ...omitSides,

+ 7 - 0
packages/excalidraw/element/types.ts

@@ -193,6 +193,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
     verticalAlign: VerticalAlign;
     verticalAlign: VerticalAlign;
     containerId: ExcalidrawGenericElement["id"] | null;
     containerId: ExcalidrawGenericElement["id"] | null;
     originalText: string;
     originalText: string;
+    /**
+     * If `true` the width will fit the text. If `false`, the text will
+     * wrap to fit the width.
+     *
+     * @default true
+     */
+    autoResize: boolean;
     /**
     /**
      * Unitless line height (aligned to W3C). To get line height in px, multiply
      * Unitless line height (aligned to W3C). To get line height in px, multiply
      *  with font size (using `getLineHeightInPx` helper).
      *  with font size (using `getLineHeightInPx` helper).

+ 2 - 1
packages/excalidraw/locales/en.json

@@ -149,7 +149,8 @@
     "zoomToFitViewport": "Zoom to fit in viewport",
     "zoomToFitViewport": "Zoom to fit in viewport",
     "zoomToFitSelection": "Zoom to fit selection",
     "zoomToFitSelection": "Zoom to fit selection",
     "zoomToFit": "Zoom to fit all elements",
     "zoomToFit": "Zoom to fit all elements",
-    "installPWA": "Install Excalidraw locally (PWA)"
+    "installPWA": "Install Excalidraw locally (PWA)",
+    "autoResize": "Enable text auto-resizing"
   },
   },
   "library": {
   "library": {
     "noItems": "No items added yet...",
     "noItems": "No items added yet...",

+ 55 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "collaborators": Map {},
   "collaborators": Map {},
   "contextMenu": {
   "contextMenu": {
     "items": [
     "items": [
+      "separator",
       {
       {
         "icon": <svg
         "icon": <svg
           aria-hidden="true"
           aria-hidden="true"
@@ -326,6 +327,16 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      {
+        "icon": null,
+        "label": "labels.autoResize",
+        "name": "autoResize",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       {
       {
         "label": "labels.unbindText",
         "label": "labels.unbindText",
         "name": "unbindText",
         "name": "unbindText",
@@ -4414,6 +4425,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "collaborators": Map {},
   "collaborators": Map {},
   "contextMenu": {
   "contextMenu": {
     "items": [
     "items": [
+      "separator",
       {
       {
         "icon": <svg
         "icon": <svg
           aria-hidden="true"
           aria-hidden="true"
@@ -4728,6 +4740,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      {
+        "icon": null,
+        "label": "labels.autoResize",
+        "name": "autoResize",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       {
       {
         "label": "labels.unbindText",
         "label": "labels.unbindText",
         "name": "unbindText",
         "name": "unbindText",
@@ -5514,6 +5536,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "collaborators": Map {},
   "collaborators": Map {},
   "contextMenu": {
   "contextMenu": {
     "items": [
     "items": [
+      "separator",
       {
       {
         "icon": <svg
         "icon": <svg
           aria-hidden="true"
           aria-hidden="true"
@@ -5828,6 +5851,16 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      {
+        "icon": null,
+        "label": "labels.autoResize",
+        "name": "autoResize",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       {
       {
         "label": "labels.unbindText",
         "label": "labels.unbindText",
         "name": "unbindText",
         "name": "unbindText",
@@ -7321,6 +7354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "collaborators": Map {},
   "collaborators": Map {},
   "contextMenu": {
   "contextMenu": {
     "items": [
     "items": [
+      "separator",
       {
       {
         "icon": <svg
         "icon": <svg
           aria-hidden="true"
           aria-hidden="true"
@@ -7635,6 +7669,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      {
+        "icon": null,
+        "label": "labels.autoResize",
+        "name": "autoResize",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       {
       {
         "label": "labels.unbindText",
         "label": "labels.unbindText",
         "name": "unbindText",
         "name": "unbindText",
@@ -8188,6 +8232,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "collaborators": Map {},
   "collaborators": Map {},
   "contextMenu": {
   "contextMenu": {
     "items": [
     "items": [
+      "separator",
       {
       {
         "icon": <svg
         "icon": <svg
           aria-hidden="true"
           aria-hidden="true"
@@ -8502,6 +8547,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
           "category": "element",
           "category": "element",
         },
         },
       },
       },
+      {
+        "icon": null,
+        "label": "labels.autoResize",
+        "name": "autoResize",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "element",
+        },
+      },
       {
       {
         "label": "labels.unbindText",
         "label": "labels.unbindText",
         "name": "unbindText",
         "name": "unbindText",

+ 25 - 0
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -2584,6 +2584,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": null,
   "containerId": null,
@@ -2624,6 +2625,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 2 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 2 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id138",
   "containerId": "id138",
@@ -2873,6 +2875,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id141",
   "containerId": "id141",
@@ -2913,6 +2916,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 2 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 2 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id141",
   "containerId": "id141",
@@ -3147,6 +3151,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id128",
   "containerId": "id128",
@@ -3187,6 +3192,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 2 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 2 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": null,
   "containerId": null,
@@ -3463,6 +3469,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the text got bound to a different container in the meantime > [end of test] element 2 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the text got bound to a different container in the meantime > [end of test] element 2 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id133",
   "containerId": "id133",
@@ -3703,6 +3710,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and there no conflicting updates in the meantime > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and there no conflicting updates in the meantime > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": null,
   "containerId": null,
@@ -3934,6 +3942,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added bound text when it's container is added through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added bound text when it's container is added through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id134",
   "containerId": "id134",
@@ -4184,6 +4193,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added container when it's bound text is added through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added container when it's bound text is added through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id136",
   "containerId": "id136",
@@ -4244,6 +4254,7 @@ History {
           "id137" => Delta {
           "id137" => Delta {
             "deleted": {
             "deleted": {
               "angle": 0,
               "angle": 0,
+              "autoResize": true,
               "backgroundColor": "transparent",
               "backgroundColor": "transparent",
               "boundElements": null,
               "boundElements": null,
               "containerId": "id136",
               "containerId": "id136",
@@ -4447,6 +4458,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw bound text to match container dimensions when the bound text is updated through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw bound text to match container dimensions when the bound text is updated through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id150",
   "containerId": "id150",
@@ -4669,6 +4681,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw remotely added bound text when it's container is updated through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw remotely added bound text when it's container is updated through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 90,
   "angle": 90,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id148",
   "containerId": "id148",
@@ -4886,6 +4899,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted bound text from container when the container is added through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted bound text from container when the container is added through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id144",
   "containerId": "id144",
@@ -5111,6 +5125,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted container from bound text when the text is added through the history > [end of test] element 1 1`] = `
 exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted container from bound text when the text is added through the history > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": null,
   "containerId": null,
@@ -13502,6 +13517,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] element 1 1`] = `
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id50",
   "containerId": "id50",
@@ -13761,6 +13777,7 @@ History {
           "id51" => Delta {
           "id51" => Delta {
             "deleted": {
             "deleted": {
               "angle": 0,
               "angle": 0,
+              "autoResize": true,
               "backgroundColor": "transparent",
               "backgroundColor": "transparent",
               "boundElements": null,
               "boundElements": null,
               "containerId": null,
               "containerId": null,
@@ -14182,6 +14199,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] element 1 1`] = `
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id44",
   "containerId": "id44",
@@ -14365,6 +14383,7 @@ History {
           "id45" => Delta {
           "id45" => Delta {
             "deleted": {
             "deleted": {
               "angle": 0,
               "angle": 0,
+              "autoResize": true,
               "backgroundColor": "transparent",
               "backgroundColor": "transparent",
               "boundElements": null,
               "boundElements": null,
               "containerId": null,
               "containerId": null,
@@ -14786,6 +14805,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] element 1 1`] = `
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id56",
   "containerId": "id56",
@@ -14969,6 +14989,7 @@ History {
           "id57" => Delta {
           "id57" => Delta {
             "deleted": {
             "deleted": {
               "angle": 0,
               "angle": 0,
+              "autoResize": true,
               "backgroundColor": "transparent",
               "backgroundColor": "transparent",
               "boundElements": null,
               "boundElements": null,
               "containerId": null,
               "containerId": null,
@@ -15388,6 +15409,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] element 1 1`] = `
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id62",
   "containerId": "id62",
@@ -15641,6 +15663,7 @@ History {
           "id63" => Delta {
           "id63" => Delta {
             "deleted": {
             "deleted": {
               "angle": 0,
               "angle": 0,
+              "autoResize": true,
               "backgroundColor": "transparent",
               "backgroundColor": "transparent",
               "boundElements": null,
               "boundElements": null,
               "containerId": null,
               "containerId": null,
@@ -16086,6 +16109,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] element 1 1`] = `
 exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] element 1 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": null,
   "boundElements": null,
   "containerId": "id69",
   "containerId": "id69",
@@ -16354,6 +16378,7 @@ History {
           "id70" => Delta {
           "id70" => Delta {
             "deleted": {
             "deleted": {
               "angle": 0,
               "angle": 0,
+              "autoResize": true,
               "backgroundColor": "transparent",
               "backgroundColor": "transparent",
               "boundElements": null,
               "boundElements": null,
               "containerId": null,
               "containerId": null,

+ 2 - 0
packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

@@ -302,6 +302,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
 exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
 exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": [],
   "boundElements": [],
   "containerId": null,
   "containerId": null,
@@ -344,6 +345,7 @@ exports[`restoreElements > should restore text element correctly passing value f
 exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
 exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
 {
 {
   "angle": 0,
   "angle": 0,
+  "autoResize": true,
   "backgroundColor": "transparent",
   "backgroundColor": "transparent",
   "boundElements": [],
   "boundElements": [],
   "containerId": null,
   "containerId": null,

+ 8 - 8
packages/excalidraw/tests/linearElementEditor.test.tsx

@@ -972,10 +972,10 @@ describe("Test Linear Elements", () => {
       ]);
       ]);
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
         .toMatchInlineSnapshot(`
         .toMatchInlineSnapshot(`
-          "Online whiteboard 
-          collaboration made 
-          easy"
-        `);
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
     });
     });
 
 
     it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
     it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
@@ -1006,10 +1006,10 @@ describe("Test Linear Elements", () => {
       ]);
       ]);
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
         .toMatchInlineSnapshot(`
         .toMatchInlineSnapshot(`
-          "Online whiteboard 
-          collaboration made 
-          easy"
-        `);
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
     });
     });
 
 
     it("should not bind text to line when double clicked", async () => {
     it("should not bind text to line when double clicked", async () => {

+ 106 - 0
packages/excalidraw/tests/resize.test.tsx

@@ -426,6 +426,112 @@ describe("text element", () => {
       expect(text.fontSize).toBe(fontSize);
       expect(text.fontSize).toBe(fontSize);
     });
     });
   });
   });
+
+  // text can be resized from sides
+  it("can be resized from e", async () => {
+    const text = UI.createElement("text");
+    await UI.editText(text, "Excalidraw\nEditor");
+
+    const width = text.width;
+    const height = text.height;
+
+    UI.resize(text, "e", [30, 0]);
+    expect(text.width).toBe(width + 30);
+    expect(text.height).toBe(height);
+
+    UI.resize(text, "e", [-30, 0]);
+    expect(text.width).toBe(width);
+    expect(text.height).toBe(height);
+  });
+
+  it("can be resized from w", async () => {
+    const text = UI.createElement("text");
+    await UI.editText(text, "Excalidraw\nEditor");
+
+    const width = text.width;
+    const height = text.height;
+
+    UI.resize(text, "w", [-50, 0]);
+    expect(text.width).toBe(width + 50);
+    expect(text.height).toBe(height);
+
+    UI.resize(text, "w", [50, 0]);
+    expect(text.width).toBe(width);
+    expect(text.height).toBe(height);
+  });
+
+  it("wraps when width is narrower than texts inside", async () => {
+    const text = UI.createElement("text");
+    await UI.editText(text, "Excalidraw\nEditor");
+
+    const prevWidth = text.width;
+    const prevHeight = text.height;
+    const prevText = text.text;
+
+    UI.resize(text, "w", [50, 0]);
+    expect(text.width).toBe(prevWidth - 50);
+    expect(text.height).toBeGreaterThan(prevHeight);
+    expect(text.text).not.toEqual(prevText);
+    expect(text.autoResize).toBe(false);
+
+    UI.resize(text, "w", [-50, 0]);
+    expect(text.width).toBe(prevWidth);
+    expect(text.height).toEqual(prevHeight);
+    expect(text.text).toEqual(prevText);
+    expect(text.autoResize).toBe(false);
+
+    UI.resize(text, "e", [-20, 0]);
+    expect(text.width).toBe(prevWidth - 20);
+    expect(text.height).toBeGreaterThan(prevHeight);
+    expect(text.text).not.toEqual(prevText);
+    expect(text.autoResize).toBe(false);
+
+    UI.resize(text, "e", [20, 0]);
+    expect(text.width).toBe(prevWidth);
+    expect(text.height).toEqual(prevHeight);
+    expect(text.text).toEqual(prevText);
+    expect(text.autoResize).toBe(false);
+  });
+
+  it("keeps properties when wrapped", async () => {
+    const text = UI.createElement("text");
+    await UI.editText(text, "Excalidraw\nEditor");
+
+    const alignment = text.textAlign;
+    const fontSize = text.fontSize;
+    const fontFamily = text.fontFamily;
+
+    UI.resize(text, "e", [-60, 0]);
+    expect(text.textAlign).toBe(alignment);
+    expect(text.fontSize).toBe(fontSize);
+    expect(text.fontFamily).toBe(fontFamily);
+    expect(text.autoResize).toBe(false);
+
+    UI.resize(text, "e", [60, 0]);
+    expect(text.textAlign).toBe(alignment);
+    expect(text.fontSize).toBe(fontSize);
+    expect(text.fontFamily).toBe(fontFamily);
+    expect(text.autoResize).toBe(false);
+  });
+
+  it("has a minimum width when wrapped", async () => {
+    const text = UI.createElement("text");
+    await UI.editText(text, "Excalidraw\nEditor");
+
+    const width = text.width;
+
+    UI.resize(text, "e", [-width, 0]);
+    expect(text.width).not.toEqual(0);
+    UI.resize(text, "e", [width - text.width, 0]);
+    expect(text.width).toEqual(width);
+    expect(text.autoResize).toBe(false);
+
+    UI.resize(text, "w", [width, 0]);
+    expect(text.width).not.toEqual(0);
+    UI.resize(text, "w", [text.width - width, 0]);
+    expect(text.width).toEqual(width);
+    expect(text.autoResize).toBe(false);
+  });
 });
 });
 
 
 describe("image element", () => {
 describe("image element", () => {