Procházet zdrojové kódy

feat: Support labels for arrow 🔥 (#5723)

* feat: support arrow with text

* render arrow -> clear rect-> render text

* move bound text when linear elements move

* fix centering cursor when linear element rotated

* fix y coord when new line added and container has 3 points

* update text position when 2nd point moved

* support adding label on top of 2nd point when 3 points are present

* change linear element editor shortcut to cmd+enter and fix tests

* scale bound text points when resizing via bounding box

* ohh yeah rotation works :)

* fix coords when updating text properties

* calculate new position after rotation always from original position

* rotate the bound text by same angle as parent

* don't rotate text and make sure dimensions and coords are always calculated from original point

* hardcoding the text width for now

* Move the linear element when bound text hit

* Rotation working yaay

* consider text element angle when editing

* refactor

* update x2 coords if needed when text updated

* simplify

* consider bound text to be part of bounding box when hit

* show bounding box correctly when multiple element selected

* fix typo

* support rotating multiple elements

* support multiple element resizing

* shift bound text to mid point when odd points

* Always render linear element handles inside editor after element rendered so point is visible for bound text

* Delete bound text when point attached to it deleted

* move bound to mid segement mid point when points are even

* shift bound text when points nearby deleted and handle segment deletion

* Resize working :)

* more resize fixes

* don't update cache-its breaking delete points, look for better soln

* update mid point cache for bound elements when updated

* introduce wrapping when resizing

* wrap when resize for 2 pointer linear elements

* support adding text for linear elements with more than 3 points

* export to svg  working :)

* clip from nearest enclosing element with non transparent color if present when exporting and fill with correct color in canvas

* fix snap

* use visible elements

* Make export to svg work with Mask :)

* remove id

* mask canvas linear element area where label is added

* decide the position of bound text during render

* fix coords when editing

* fix multiple resize

* update cache when bound text version changes

* fix masking when rotated

* render text in correct position in preview

* remove unnecessary code

* fix masking when rotating linear element

* fix masking with zoom

* fix mask in preview for export

* fix offsets in export view

* fix coords on svg export

* fix mask when element rotated in svg

* enable double-click to enter text

* fix hint

* Position cursor correctly and text dimensiosn when height of element is negative

* don't allow 2 pointer linear element with bound text width to go beyond min width

* code cleanup

* fix freedraw

* Add padding

* don't show vertical align action for linear element containers

* Add specs for getBoundTextElementPosition

* more specs

* move some utils to linearElementEditor.ts

* remove only :p

* check absoulte coods in test

* Add test to hide vertical align for linear eleemnt with bound text

* improve export preview

* support labels only for arrows

* spec

* fix large texts

* fix tests

* fix zooming

* enter line editor with cmd+double click

* Allow points to move beyond min width/height for 2 pointer arrow with bound text

* fix hint for line editing

* attempt to fix arrow getting deselected

* fix hint and shortcut

* Add padding of 5px when creating bound text and add spec

* Wrap bound text when arrow binding containers moved

* Add spec

* remove

* set boundTextElementVersion to null if not present

* dont use cache when version mismatch

* Add a padding of 5px vertically when creating text

* Add box sizing content box

* Set bound elements when text element created to fix the padding

* fix zooming in editor

* fix zoom in export

* remove globalCompositeOperation and use clearRect instead of fillRect
Aakansha Doshi před 2 roky
rodič
revize
760fd7b3a6

+ 3 - 0
src/actions/actionProperties.tsx

@@ -816,16 +816,19 @@ export const actionChangeVerticalAlign = register({
               value: VERTICAL_ALIGN.TOP,
               text: t("labels.alignTop"),
               icon: <TextAlignTopIcon theme={appState.theme} />,
+              testId: "align-top",
             },
             {
               value: VERTICAL_ALIGN.MIDDLE,
               text: t("labels.centerVertically"),
               icon: <TextAlignMiddleIcon theme={appState.theme} />,
+              testId: "align-middle",
             },
             {
               value: VERTICAL_ALIGN.BOTTOM,
               text: t("labels.alignBottom"),
               icon: <TextAlignBottomIcon theme={appState.theme} />,
+              testId: "align-bottom",
             },
           ]}
           value={getFormValue(elements, appState, (element) => {

+ 4 - 5
src/components/Actions.tsx

@@ -25,11 +25,12 @@ import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import { hasStrokeColor } from "../scene/comparisons";
 import { trackEvent } from "../analytics";
-import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
+import { hasBoundTextElement } from "../element/typeChecks";
 import clsx from "clsx";
 import { actionToggleZenMode } from "../actions";
 import "./Actions.scss";
 import { Tooltip } from "./Tooltip";
+import { shouldAllowVerticalAlign } from "../element/textElement";
 
 export const SelectedShapeActions = ({
   appState,
@@ -125,10 +126,8 @@ export const SelectedShapeActions = ({
         </>
       )}
 
-      {targetElements.some(
-        (element) =>
-          hasBoundTextElement(element) || isBoundToContainer(element),
-      ) && renderAction("changeVerticalAlign")}
+      {shouldAllowVerticalAlign(targetElements) &&
+        renderAction("changeVerticalAlign")}
       {(canHaveArrowheads(appState.activeTool.type) ||
         targetElements.some((element) => canHaveArrowheads(element.type))) && (
         <>{renderAction("changeArrowhead")}</>

+ 77 - 34
src/components/App.tsx

@@ -126,6 +126,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
 import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
 import {
   hasBoundTextElement,
+  isArrowElement,
   isBindingElement,
   isBindingElementType,
   isBoundToContainer,
@@ -254,6 +255,7 @@ import {
   getApproxMinLineHeight,
   getApproxMinLineWidth,
   getBoundTextElement,
+  getContainerCenter,
   getContainerDims,
   getTextBindableContainerAtPosition,
   isValidTextContainer,
@@ -2049,23 +2051,23 @@ class App extends React.Component<AppProps, AppState> {
           this.scene.getNonDeletedElements(),
           this.state,
         );
-
         if (selectedElements.length === 1) {
           const selectedElement = selectedElements[0];
-
-          if (isLinearElement(selectedElement)) {
-            if (
-              !this.state.editingLinearElement ||
-              this.state.editingLinearElement.elementId !==
-                selectedElements[0].id
-            ) {
-              this.history.resumeRecording();
-              this.setState({
-                editingLinearElement: new LinearElementEditor(
-                  selectedElement,
-                  this.scene,
-                ),
-              });
+          if (event[KEYS.CTRL_OR_CMD]) {
+            if (isLinearElement(selectedElement)) {
+              if (
+                !this.state.editingLinearElement ||
+                this.state.editingLinearElement.elementId !==
+                  selectedElements[0].id
+              ) {
+                this.history.resumeRecording();
+                this.setState({
+                  editingLinearElement: new LinearElementEditor(
+                    selectedElement,
+                    this.scene,
+                  ),
+                });
+              }
             }
           } else if (
             isTextElement(selectedElement) ||
@@ -2075,9 +2077,12 @@ class App extends React.Component<AppProps, AppState> {
             if (!isTextElement(selectedElement)) {
               container = selectedElement as ExcalidrawTextContainer;
             }
+            const midPoint = getContainerCenter(selectedElement, this.state);
+            const sceneX = midPoint.x;
+            const sceneY = midPoint.y;
             this.startTextEditing({
-              sceneX: selectedElement.x + selectedElement.width / 2,
-              sceneY: selectedElement.y + selectedElement.height / 2,
+              sceneX,
+              sceneY,
               container,
             });
             event.preventDefault();
@@ -2521,7 +2526,12 @@ class App extends React.Component<AppProps, AppState> {
       existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
     }
 
-    if (!existingTextElement && shouldBindToContainer && container) {
+    if (
+      !existingTextElement &&
+      shouldBindToContainer &&
+      container &&
+      !isArrowElement(container)
+    ) {
       const fontString = {
         fontSize: this.state.currentItemFontSize,
         fontFamily: this.state.currentItemFontFamily,
@@ -2574,6 +2584,14 @@ class App extends React.Component<AppProps, AppState> {
           locked: false,
         });
 
+    if (!existingTextElement && shouldBindToContainer && container) {
+      mutateElement(container, {
+        boundElements: (container.boundElements || []).concat({
+          type: "text",
+          id: element.id,
+        }),
+      });
+    }
     this.setState({ editingElement: element });
 
     if (!existingTextElement) {
@@ -2625,8 +2643,9 @@ class App extends React.Component<AppProps, AppState> {
 
     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
       if (
-        !this.state.editingLinearElement ||
-        this.state.editingLinearElement.elementId !== selectedElements[0].id
+        event[KEYS.CTRL_OR_CMD] &&
+        (!this.state.editingLinearElement ||
+          this.state.editingLinearElement.elementId !== selectedElements[0].id)
       ) {
         this.history.resumeRecording();
         this.setState({
@@ -2635,8 +2654,13 @@ class App extends React.Component<AppProps, AppState> {
             this.scene,
           ),
         });
+        return;
+      } else if (
+        this.state.editingLinearElement &&
+        this.state.editingLinearElement.elementId === selectedElements[0].id
+      ) {
+        return;
       }
-      return;
     }
 
     resetCursor(this.canvas);
@@ -2680,9 +2704,11 @@ class App extends React.Component<AppProps, AppState> {
         sceneY,
       );
       if (container) {
-        if (hasBoundTextElement(container)) {
-          sceneX = container.x + container.width / 2;
-          sceneY = container.y + container.height / 2;
+        if (isArrowElement(container) || hasBoundTextElement(container)) {
+          const midPoint = getContainerCenter(container, this.state);
+
+          sceneX = midPoint.x;
+          sceneY = midPoint.y;
         }
       }
       this.startTextEditing({
@@ -2783,6 +2809,7 @@ class App extends React.Component<AppProps, AppState> {
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
     this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
+
     if (gesture.pointers.has(event.pointerId)) {
       gesture.pointers.set(event.pointerId, {
         x: event.clientX,
@@ -3091,15 +3118,18 @@ class App extends React.Component<AppProps, AppState> {
         );
       } else if (
         // if using cmd/ctrl, we're not dragging
-        !event[KEYS.CTRL_OR_CMD] &&
-        (hitElement ||
-          this.isHittingCommonBoundingBoxOfSelectedElements(
-            scenePointer,
-            selectedElements,
-          )) &&
-        !hitElement?.locked
+        !event[KEYS.CTRL_OR_CMD]
       ) {
-        setCursor(this.canvas, CURSOR_TYPE.MOVE);
+        if (
+          (hitElement ||
+            this.isHittingCommonBoundingBoxOfSelectedElements(
+              scenePointer,
+              selectedElements,
+            )) &&
+          !hitElement?.locked
+        ) {
+          setCursor(this.canvas, CURSOR_TYPE.MOVE);
+        }
       } else {
         setCursor(this.canvas, CURSOR_TYPE.AUTO);
       }
@@ -3209,6 +3239,8 @@ class App extends React.Component<AppProps, AppState> {
       linearElementEditor.elementId,
     );
 
+    const boundTextElement = getBoundTextElement(element);
+
     if (!element) {
       return;
     }
@@ -3249,6 +3281,11 @@ class App extends React.Component<AppProps, AppState> {
         )
       ) {
         setCursor(this.canvas, CURSOR_TYPE.MOVE);
+      } else if (
+        boundTextElement &&
+        hitTest(boundTextElement, this.state, scenePointerX, scenePointerY)
+      ) {
+        setCursor(this.canvas, CURSOR_TYPE.MOVE);
       }
 
       if (
@@ -6305,8 +6342,14 @@ class App extends React.Component<AppProps, AppState> {
     container?: ExcalidrawTextContainer | null,
   ) {
     if (container) {
-      const elementCenterX = container.x + container.width / 2;
-      const elementCenterY = container.y + container.height / 2;
+      let elementCenterX = container.x + container.width / 2;
+      let elementCenterY = container.y + container.height / 2;
+
+      const elementCenter = getContainerCenter(container, appState);
+      if (elementCenter) {
+        elementCenterX = elementCenter.x;
+        elementCenterY = elementCenter.y;
+      }
       const distanceToCenter = Math.hypot(
         x - elementCenterX,
         y - elementCenterY,

+ 4 - 1
src/components/HelpDialog.tsx

@@ -157,7 +157,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
             />
             <Shortcut
               label={t("helpDialog.editSelectedShape")}
-              shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
+              shortcuts={[
+                getShortcutKey("CtrlOrCmd+Enter"),
+                getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
+              ]}
             />
             <Shortcut
               label={t("helpDialog.textNewLine")}

+ 5 - 0
src/element/binding.ts

@@ -26,6 +26,7 @@ import Scene from "../scene/Scene";
 import { LinearElementEditor } from "./linearElementEditor";
 import { arrayToMap, tupleToCoors } from "../utils";
 import { KEYS } from "../keys";
+import { getBoundTextElement, handleBindTextResize } from "./textElement";
 
 export type SuggestedBinding =
   | NonDeleted<ExcalidrawBindableElement>
@@ -361,6 +362,10 @@ export const updateBoundElements = (
       endBinding,
       changedElement as ExcalidrawBindableElement,
     );
+    const boundText = getBoundTextElement(element);
+    if (boundText) {
+      handleBindTextResize(element, false);
+    }
   });
 };
 

+ 80 - 62
src/element/bounds.ts

@@ -4,6 +4,7 @@ import {
   Arrowhead,
   ExcalidrawFreeDrawElement,
   NonDeleted,
+  ExcalidrawTextElementWithContainer,
 } from "./types";
 import { distance2d, rotate } from "../math";
 import rough from "roughjs/bin/rough";
@@ -13,8 +14,15 @@ import {
   getShapeForElement,
   generateRoughOptions,
 } from "../renderer/renderElement";
-import { isFreeDrawElement, isLinearElement } from "./typeChecks";
+import {
+  isArrowElement,
+  isFreeDrawElement,
+  isLinearElement,
+  isTextElement,
+} from "./typeChecks";
 import { rescalePoints } from "../points";
+import { getBoundTextElement, getContainerElement } from "./textElement";
+import { LinearElementEditor } from "./linearElementEditor";
 
 // x and y position of top left corner, x and y position of bottom right corner
 export type Bounds = readonly [number, number, number, number];
@@ -24,17 +32,39 @@ type MaybeQuadraticSolution = [number | null, number | null] | false;
 // This set of functions retrieves the absolute position of the 4 points.
 export const getElementAbsoluteCoords = (
   element: ExcalidrawElement,
-): Bounds => {
+  includeBoundText: boolean = false,
+): [number, number, number, number, number, number] => {
   if (isFreeDrawElement(element)) {
     return getFreeDrawElementAbsoluteCoords(element);
   } else if (isLinearElement(element)) {
-    return getLinearElementAbsoluteCoords(element);
+    return LinearElementEditor.getElementAbsoluteCoords(
+      element,
+      includeBoundText,
+    );
+  } else if (isTextElement(element)) {
+    const container = getContainerElement(element);
+    if (isArrowElement(container)) {
+      const coords = LinearElementEditor.getBoundTextElementPosition(
+        container,
+        element as ExcalidrawTextElementWithContainer,
+      );
+      return [
+        coords.x,
+        coords.y,
+        coords.x + element.width,
+        coords.y + element.height,
+        coords.x + element.width / 2,
+        coords.y + element.height / 2,
+      ];
+    }
   }
   return [
     element.x,
     element.y,
     element.x + element.width,
     element.y + element.height,
+    element.x + element.width / 2,
+    element.y + element.height / 2,
   ];
 };
 
@@ -159,7 +189,7 @@ const getCubicBezierCurveBound = (
   return [minX, minY, maxX, maxY];
 };
 
-const getMinMaxXYFromCurvePathOps = (
+export const getMinMaxXYFromCurvePathOps = (
   ops: Op[],
   transformXY?: (x: number, y: number) => [number, number],
 ): [number, number, number, number] => {
@@ -230,59 +260,13 @@ const getBoundsFromPoints = (
 
 const getFreeDrawElementAbsoluteCoords = (
   element: ExcalidrawFreeDrawElement,
-): [number, number, number, number] => {
+): [number, number, number, number, number, number] => {
   const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
-
-  return [
-    minX + element.x,
-    minY + element.y,
-    maxX + element.x,
-    maxY + element.y,
-  ];
-};
-
-const getLinearElementAbsoluteCoords = (
-  element: ExcalidrawLinearElement,
-): [number, number, number, number] => {
-  let coords: [number, number, number, number];
-
-  if (element.points.length < 2 || !getShapeForElement(element)) {
-    // XXX this is just a poor estimate and not very useful
-    const { minX, minY, maxX, maxY } = element.points.reduce(
-      (limits, [x, y]) => {
-        limits.minY = Math.min(limits.minY, y);
-        limits.minX = Math.min(limits.minX, x);
-
-        limits.maxX = Math.max(limits.maxX, x);
-        limits.maxY = Math.max(limits.maxY, y);
-
-        return limits;
-      },
-      { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
-    );
-    coords = [
-      minX + element.x,
-      minY + element.y,
-      maxX + element.x,
-      maxY + element.y,
-    ];
-  } else {
-    const shape = getShapeForElement(element)!;
-
-    // first element is always the curve
-    const ops = getCurvePathOps(shape[0]);
-
-    const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
-
-    coords = [
-      minX + element.x,
-      minY + element.y,
-      maxX + element.x,
-      maxY + element.y,
-    ];
-  }
-
-  return coords;
+  const x1 = minX + element.x;
+  const y1 = minY + element.y;
+  const x2 = maxX + element.x;
+  const y2 = maxY + element.y;
+  return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
 };
 
 export const getArrowheadPoints = (
@@ -420,7 +404,23 @@ const getLinearElementRotatedBounds = (
       cy,
       element.angle,
     );
-    return [x, y, x, y];
+
+    let coords: [number, number, number, number] = [x, y, x, y];
+    const boundTextElement = getBoundTextElement(element);
+    if (boundTextElement) {
+      const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
+        element,
+        [x, y, x, y],
+        boundTextElement,
+      );
+      coords = [
+        coordsWithBoundText[0],
+        coordsWithBoundText[1],
+        coordsWithBoundText[2],
+        coordsWithBoundText[3],
+      ];
+    }
+    return coords;
   }
 
   // first element is always the curve
@@ -429,8 +429,28 @@ const getLinearElementRotatedBounds = (
   const ops = getCurvePathOps(shape);
   const transformXY = (x: number, y: number) =>
     rotate(element.x + x, element.y + y, cx, cy, element.angle);
-
-  return getMinMaxXYFromCurvePathOps(ops, transformXY);
+  const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
+  let coords: [number, number, number, number] = [
+    res[0],
+    res[1],
+    res[2],
+    res[3],
+  ];
+  const boundTextElement = getBoundTextElement(element);
+  if (boundTextElement) {
+    const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
+      element,
+      coords,
+      boundTextElement,
+    );
+    coords = [
+      coordsWithBoundText[0],
+      coordsWithBoundText[1],
+      coordsWithBoundText[2],
+      coordsWithBoundText[3],
+    ];
+  }
+  return coords;
 };
 
 // We could cache this stuff
@@ -439,9 +459,7 @@ export const getElementBounds = (
 ): [number, number, number, number] => {
   let bounds: [number, number, number, number];
 
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  const cx = (x1 + x2) / 2;
-  const cy = (y1 + y2) / 2;
+  const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
   if (isFreeDrawElement(element)) {
     const [minX, minY, maxX, maxY] = getBoundsFromPoints(
       element.points.map(([x, y]) =>

+ 22 - 6
src/element/collision.ts

@@ -36,6 +36,7 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
 import { isTextElement } from ".";
 import { isTransparent } from "../utils";
 import { shouldShowBoundingBox } from "./transformHandles";
+import { getBoundTextElement } from "./textElement";
 
 const isElementDraggableFromInside = (
   element: NonDeletedExcalidrawElement,
@@ -72,6 +73,13 @@ export const hitTest = (
     return isPointHittingElementBoundingBox(element, point, threshold);
   }
 
+  const boundTextElement = getBoundTextElement(element);
+  if (boundTextElement) {
+    const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
+    if (isHittingBoundTextElement) {
+      return true;
+    }
+  }
   return isHittingElementNotConsideringBoundingBox(element, appState, point);
 };
 
@@ -83,6 +91,13 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
 ): boolean => {
   const threshold = 10 / appState.zoom.value;
 
+  // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
+  // eg for linear elements text can be outside the element bounding box
+  const boundTextElement = getBoundTextElement(element);
+  if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
+    return false;
+  }
+
   return (
     !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
     isPointHittingElementBoundingBox(element, [x, y], threshold)
@@ -95,7 +110,6 @@ export const isHittingElementNotConsideringBoundingBox = (
   point: Point,
 ): boolean => {
   const threshold = 10 / appState.zoom.value;
-
   const check = isTextElement(element)
     ? isStrictlyInside
     : isElementDraggableFromInside(element)
@@ -382,6 +396,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
   if (!getShapeForElement(element)) {
     return false;
   }
+
   const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
     args.element,
     args.point,
@@ -434,8 +449,9 @@ const pointRelativeToElement = (
   pointTuple: Point,
 ): [GA.Point, GA.Point, number, number] => {
   const point = GAPoint.from(pointTuple);
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const elementCoords = getElementAbsoluteCoords(element);
-  const center = coordsCenter(elementCoords);
+  const center = coordsCenter([x1, y1, x2, y2]);
   // GA has angle orientation opposite to `rotate`
   const rotate = GATransform.rotation(center, element.angle);
   const pointRotated = GATransform.apply(rotate, point);
@@ -466,8 +482,8 @@ export const pointInAbsoluteCoords = (
 const relativizationToElementCenter = (
   element: ExcalidrawElement,
 ): GA.Transform => {
-  const elementCoords = getElementAbsoluteCoords(element);
-  const center = coordsCenter(elementCoords);
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const center = coordsCenter([x1, y1, x2, y2]);
   // GA has angle orientation opposite to `rotate`
   const rotate = GATransform.rotation(center, element.angle);
   const translate = GA.reverse(
@@ -524,8 +540,8 @@ export const determineFocusPoint = (
   adjecentPoint: Point,
 ): Point => {
   if (focus === 0) {
-    const elementCoords = getElementAbsoluteCoords(element);
-    const center = coordsCenter(elementCoords);
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    const center = coordsCenter([x1, y1, x2, y2]);
     return GAPoint.toTuple(center);
   }
   const relateToCenter = relativizationToElementCenter(element);

+ 222 - 7
src/element/linearElementEditor.ts

@@ -4,6 +4,7 @@ import {
   ExcalidrawElement,
   PointBinding,
   ExcalidrawBindableElement,
+  ExcalidrawTextElementWithContainer,
 } from "./types";
 import {
   distance2d,
@@ -19,7 +20,11 @@ import {
   arePointsEqual,
 } from "../math";
 import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
-import { getElementPointsCoords } from "./bounds";
+import {
+  getCurvePathOps,
+  getElementPointsCoords,
+  getMinMaxXYFromCurvePathOps,
+} from "./bounds";
 import { Point, AppState, PointerCoords } from "../types";
 import { mutateElement } from "./mutateElement";
 import History from "../history";
@@ -33,6 +38,8 @@ import {
 import { tupleToCoors } from "../utils";
 import { isBindingElement } from "./typeChecks";
 import { shouldRotateWithDiscreteAngle } from "../keys";
+import { getBoundTextElement, handleBindTextResize } from "./textElement";
+import { getShapeForElement } from "../renderer/renderElement";
 import { DRAGGING_THRESHOLD } from "../constants";
 
 const editorMidPointsCache: {
@@ -40,7 +47,6 @@ const editorMidPointsCache: {
   points: (Point | null)[];
   zoom: number | null;
 } = { version: null, points: [], zoom: null };
-
 export class LinearElementEditor {
   public readonly elementId: ExcalidrawElement["id"] & {
     _brand: "excalidrawLinearElementId";
@@ -257,6 +263,11 @@ export class LinearElementEditor {
             };
           }),
         );
+
+        const boundTextElement = getBoundTextElement(element);
+        if (boundTextElement) {
+          handleBindTextResize(element, false);
+        }
       }
 
       // suggest bindings for first and last point if selected
@@ -388,8 +399,14 @@ export class LinearElementEditor {
     element: NonDeleted<ExcalidrawLinearElement>,
     appState: AppState,
   ): typeof editorMidPointsCache["points"] => {
-    // Since its not needed outside editor unless 2 pointer lines
-    if (!appState.editingLinearElement && element.points.length > 2) {
+    const boundText = getBoundTextElement(element);
+
+    // Since its not needed outside editor unless 2 pointer lines or bound text
+    if (
+      !appState.editingLinearElement &&
+      element.points.length > 2 &&
+      !boundText
+    ) {
       return [];
     }
     if (
@@ -661,7 +678,6 @@ export class LinearElementEditor {
       scenePointer.x,
       scenePointer.y,
     );
-
     // if we clicked on a point, set the element as hitElement otherwise
     // it would get deselected if the point is outside the hitbox area
     if (clickedPointIndex >= 0 || segmentMidpoint) {
@@ -1055,7 +1071,6 @@ export class LinearElementEditor {
     const offsetY = 0;
 
     const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
-
     LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
   }
 
@@ -1223,7 +1238,6 @@ export class LinearElementEditor {
     const dX = prevCenterX - nextCenterX;
     const dY = prevCenterY - nextCenterY;
     const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
-
     mutateElement(element, {
       ...otherUpdates,
       points: nextPoints,
@@ -1258,6 +1272,207 @@ export class LinearElementEditor {
 
     return rotatePoint([width, height], [0, 0], -element.angle);
   }
+
+  static getBoundTextElementPosition = (
+    element: ExcalidrawLinearElement,
+    boundTextElement: ExcalidrawTextElementWithContainer,
+  ): { x: number; y: number } => {
+    const points = LinearElementEditor.getPointsGlobalCoordinates(element);
+    if (points.length < 2) {
+      mutateElement(boundTextElement, { isDeleted: true });
+    }
+    let x = 0;
+    let y = 0;
+    if (element.points.length % 2 === 1) {
+      const index = Math.floor(element.points.length / 2);
+      const midPoint = LinearElementEditor.getPointGlobalCoordinates(
+        element,
+        element.points[index],
+      );
+      x = midPoint[0] - boundTextElement.width / 2;
+      y = midPoint[1] - boundTextElement.height / 2;
+    } else {
+      const index = element.points.length / 2 - 1;
+
+      let midSegmentMidpoint = editorMidPointsCache.points[index];
+      if (element.points.length === 2) {
+        midSegmentMidpoint = centerPoint(points[0], points[1]);
+      }
+      if (
+        !midSegmentMidpoint ||
+        editorMidPointsCache.version !== element.version
+      ) {
+        midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
+          element,
+          points[index],
+          points[index + 1],
+          index + 1,
+        );
+      }
+      x = midSegmentMidpoint[0] - boundTextElement.width / 2;
+      y = midSegmentMidpoint[1] - boundTextElement.height / 2;
+    }
+    return { x, y };
+  };
+
+  static getMinMaxXYWithBoundText = (
+    element: ExcalidrawLinearElement,
+    elementBounds: [number, number, number, number],
+    boundTextElement: ExcalidrawTextElementWithContainer,
+  ): [number, number, number, number, number, number] => {
+    let [x1, y1, x2, y2] = elementBounds;
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+    const { x: boundTextX1, y: boundTextY1 } =
+      LinearElementEditor.getBoundTextElementPosition(
+        element,
+        boundTextElement,
+      );
+    const boundTextX2 = boundTextX1 + boundTextElement.width;
+    const boundTextY2 = boundTextY1 + boundTextElement.height;
+
+    const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
+    const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
+
+    const counterRotateBoundTextTopLeft = rotatePoint(
+      [boundTextX1, boundTextY1],
+
+      [cx, cy],
+
+      -element.angle,
+    );
+    const counterRotateBoundTextTopRight = rotatePoint(
+      [boundTextX2, boundTextY1],
+
+      [cx, cy],
+
+      -element.angle,
+    );
+    const counterRotateBoundTextBottomLeft = rotatePoint(
+      [boundTextX1, boundTextY2],
+
+      [cx, cy],
+
+      -element.angle,
+    );
+    const counterRotateBoundTextBottomRight = rotatePoint(
+      [boundTextX2, boundTextY2],
+
+      [cx, cy],
+
+      -element.angle,
+    );
+
+    if (
+      topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
+      topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
+    ) {
+      x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
+      x2 = Math.max(
+        x2,
+        Math.max(
+          counterRotateBoundTextTopRight[0],
+          counterRotateBoundTextBottomRight[0],
+        ),
+      );
+      y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
+
+      y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
+    } else if (
+      topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
+      topLeftRotatedPoint[1] > topRightRotatedPoint[1]
+    ) {
+      x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
+      x2 = Math.max(
+        x2,
+        Math.max(
+          counterRotateBoundTextTopLeft[0],
+          counterRotateBoundTextTopRight[0],
+        ),
+      );
+      y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
+
+      y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
+    } else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
+      x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
+      x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
+      y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
+
+      y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
+    } else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
+      x1 = Math.min(
+        x1,
+        Math.min(
+          counterRotateBoundTextTopRight[0],
+          counterRotateBoundTextTopLeft[0],
+        ),
+      );
+
+      x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
+      y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
+      y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
+    }
+
+    return [x1, y1, x2, y2, cx, cy];
+  };
+
+  static getElementAbsoluteCoords = (
+    element: ExcalidrawLinearElement,
+    includeBoundText: boolean = false,
+  ): [number, number, number, number, number, number] => {
+    let coords: [number, number, number, number, number, number];
+    let x1;
+    let y1;
+    let x2;
+    let y2;
+    if (element.points.length < 2 || !getShapeForElement(element)) {
+      // XXX this is just a poor estimate and not very useful
+      const { minX, minY, maxX, maxY } = element.points.reduce(
+        (limits, [x, y]) => {
+          limits.minY = Math.min(limits.minY, y);
+          limits.minX = Math.min(limits.minX, x);
+
+          limits.maxX = Math.max(limits.maxX, x);
+          limits.maxY = Math.max(limits.maxY, y);
+
+          return limits;
+        },
+        { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+      );
+      x1 = minX + element.x;
+      y1 = minY + element.y;
+      x2 = maxX + element.x;
+      y2 = maxY + element.y;
+    } else {
+      const shape = getShapeForElement(element)!;
+
+      // first element is always the curve
+      const ops = getCurvePathOps(shape[0]);
+
+      const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+      x1 = minX + element.x;
+      y1 = minY + element.y;
+      x2 = maxX + element.x;
+      y2 = maxY + element.y;
+    }
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+    coords = [x1, y1, x2, y2, cx, cy];
+
+    if (!includeBoundText) {
+      return coords;
+    }
+    const boundTextElement = getBoundTextElement(element);
+    if (boundTextElement) {
+      coords = LinearElementEditor.getMinMaxXYWithBoundText(
+        element,
+        [x1, y1, x2, y2],
+        boundTextElement,
+      );
+    }
+
+    return coords;
+  };
 }
 
 const normalizeSelectedPoints = (

+ 41 - 9
src/element/newElement.ts

@@ -11,7 +11,7 @@ import {
   Arrowhead,
   ExcalidrawFreeDrawElement,
   FontFamilyValues,
-  ExcalidrawRectangleElement,
+  ExcalidrawTextContainer,
 } from "../element/types";
 import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
 import { randomInteger, randomId } from "../random";
@@ -22,6 +22,8 @@ import { getElementAbsoluteCoords } from ".";
 import { adjustXYWithRotation } from "../math";
 import { getResizedElementAbsoluteCoords } from "./bounds";
 import {
+  getBoundTextElement,
+  getBoundTextElementOffset,
   getContainerDims,
   getContainerElement,
   measureText,
@@ -29,6 +31,7 @@ import {
   wrapText,
 } from "./textElement";
 import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
+import { isArrowElement } from "./typeChecks";
 
 type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -131,7 +134,7 @@ export const newTextElement = (
     fontFamily: FontFamilyValues;
     textAlign: TextAlign;
     verticalAlign: VerticalAlign;
-    containerId?: ExcalidrawRectangleElement["id"];
+    containerId?: ExcalidrawTextContainer["id"];
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawTextElement> => {
   const text = normalizeText(opts.text);
@@ -231,16 +234,21 @@ const getAdjustedDimensions = (
   // make sure container dimensions are set properly when
   // text editor overflows beyond viewport dimensions
   if (container) {
+    const boundTextElementPadding = getBoundTextElementOffset(element);
+
     const containerDims = getContainerDims(container);
     let height = containerDims.height;
     let width = containerDims.width;
-    if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
-      height = nextHeight + BOUND_TEXT_PADDING * 2;
+    if (nextHeight > height - boundTextElementPadding * 2) {
+      height = nextHeight + boundTextElementPadding * 2;
     }
-    if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
-      width = nextWidth + BOUND_TEXT_PADDING * 2;
+    if (nextWidth > width - boundTextElementPadding * 2) {
+      width = nextWidth + boundTextElementPadding * 2;
     }
-    if (height !== containerDims.height || width !== containerDims.width) {
+    if (
+      !isArrowElement(container) &&
+      (height !== containerDims.height || width !== containerDims.width)
+    ) {
       mutateElement(container, { height, width });
     }
   }
@@ -270,11 +278,35 @@ export const refreshTextDimensions = (
 };
 
 export const getMaxContainerWidth = (container: ExcalidrawElement) => {
-  return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
+  const width = getContainerDims(container).width;
+  if (isArrowElement(container)) {
+    const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
+    if (containerWidth <= 0) {
+      const boundText = getBoundTextElement(container);
+      if (boundText) {
+        return boundText.width;
+      }
+      return BOUND_TEXT_PADDING * 8 * 2;
+    }
+    return containerWidth;
+  }
+  return width - BOUND_TEXT_PADDING * 2;
 };
 
 export const getMaxContainerHeight = (container: ExcalidrawElement) => {
-  return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
+  const height = getContainerDims(container).height;
+  if (isArrowElement(container)) {
+    const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
+    if (containerHeight <= 0) {
+      const boundText = getBoundTextElement(container);
+      if (boundText) {
+        return boundText.height;
+      }
+      return BOUND_TEXT_PADDING * 8 * 2;
+    }
+    return height;
+  }
+  return height - BOUND_TEXT_PADDING * 2;
 };
 
 export const updateTextElement = (

+ 54 - 23
src/element/resizeElements.ts

@@ -1,4 +1,4 @@
-import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants";
+import { SHIFT_LOCKING_ANGLE } from "../constants";
 import { rescalePoints } from "../points";
 
 import {
@@ -12,6 +12,8 @@ import {
   ExcalidrawTextElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
+  ExcalidrawElement,
+  ExcalidrawTextElementWithContainer,
 } from "./types";
 import {
   getElementAbsoluteCoords,
@@ -20,6 +22,7 @@ import {
   getCommonBoundingBox,
 } from "./bounds";
 import {
+  isArrowElement,
   isBoundToContainer,
   isFreeDrawElement,
   isLinearElement,
@@ -40,6 +43,7 @@ import {
   getApproxMinLineWidth,
   getBoundTextElement,
   getBoundTextElementId,
+  getBoundTextElementOffset,
   getContainerElement,
   handleBindTextResize,
   measureText,
@@ -75,6 +79,7 @@ export const transformElements = (
         pointerX,
         pointerY,
         shouldRotateWithDiscreteAngle,
+        pointerDownState.originalElements,
       );
       updateBoundElements(element);
     } else if (
@@ -142,6 +147,7 @@ const rotateSingleElement = (
   pointerX: number,
   pointerY: number,
   shouldRotateWithDiscreteAngle: boolean,
+  originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
 ) => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const cx = (x1 + x2) / 2;
@@ -152,11 +158,17 @@ const rotateSingleElement = (
     angle -= angle % SHIFT_LOCKING_ANGLE;
   }
   angle = normalizeAngle(angle);
-  mutateElement(element, { angle });
   const boundTextElementId = getBoundTextElementId(element);
+
+  mutateElement(element, { angle });
   if (boundTextElementId) {
-    const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
-    mutateElement(textElement!, { angle });
+    const textElement = Scene.getScene(element)!.getElement(
+      boundTextElementId,
+    ) as ExcalidrawTextElementWithContainer;
+
+    if (!isArrowElement(element)) {
+      mutateElement(textElement, { angle });
+    }
   }
 };
 
@@ -412,10 +424,12 @@ export const resizeSingleElement = (
       };
     }
     if (shouldMaintainAspectRatio) {
+      const boundTextElementPadding =
+        getBoundTextElementOffset(boundTextElement);
       const nextFont = measureFontSizeFromWH(
         boundTextElement,
-        eleNewWidth - BOUND_TEXT_PADDING * 2,
-        eleNewHeight - BOUND_TEXT_PADDING * 2,
+        eleNewWidth - boundTextElementPadding * 2,
+        eleNewHeight - boundTextElementPadding * 2,
       );
       if (nextFont === null) {
         return;
@@ -504,24 +518,36 @@ export const resizeSingleElement = (
   newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
 
   // Readjust points for linear elements
-  const rescaledPoints = rescalePointsInElement(
-    stateAtResizeStart,
-    eleNewWidth,
-    eleNewHeight,
-    true,
-  );
+  let rescaledElementPointsY;
+  let rescaledPoints;
+
+  if (isLinearElement(element) || isFreeDrawElement(element)) {
+    rescaledElementPointsY = rescalePoints(
+      1,
+      eleNewHeight,
+      (stateAtResizeStart as ExcalidrawLinearElement).points,
+      true,
+    );
+
+    rescaledPoints = rescalePoints(
+      0,
+      eleNewWidth,
+      rescaledElementPointsY,
+      true,
+    );
+  }
+
   // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
   // So we need to readjust (x,y) to be where the first point should be
   const newOrigin = [...newTopLeft];
   newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
   newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
-
   const resizedElement = {
     width: Math.abs(eleNewWidth),
     height: Math.abs(eleNewHeight),
     x: newOrigin[0],
     y: newOrigin[1],
-    ...rescaledPoints,
+    points: rescaledPoints,
   };
 
   if ("scale" in element && "scale" in stateAtResizeStart) {
@@ -545,6 +571,7 @@ export const resizeSingleElement = (
     updateBoundElements(element, {
       newSize: { width: resizedElement.width, height: resizedElement.height },
     });
+
     mutateElement(element, resizedElement);
     if (boundTextElement && boundTextFont) {
       mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
@@ -667,7 +694,7 @@ const resizeMultipleElements = (
     const boundTextElement = getBoundTextElement(element.latest);
 
     if (boundTextElement || isTextElement(element.orig)) {
-      const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
+      const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
       const textMeasurements = measureFontSizeFromWH(
         boundTextElement ?? (element.orig as ExcalidrawTextElement),
         width - optionalPadding,
@@ -697,6 +724,7 @@ const resizeMultipleElements = (
 
     if (boundTextElement && boundTextUpdates) {
       mutateElement(boundTextElement, boundTextUpdates);
+
       handleBindTextResize(element.latest, transformHandleType);
     }
   });
@@ -717,7 +745,7 @@ const rotateMultipleElements = (
     centerAngle += SHIFT_LOCKING_ANGLE / 2;
     centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
   }
-  elements.forEach((element, index) => {
+  elements.forEach((element) => {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
@@ -737,13 +765,16 @@ const rotateMultipleElements = (
     });
     const boundTextElementId = getBoundTextElementId(element);
     if (boundTextElementId) {
-      const textElement =
-        Scene.getScene(element)!.getElement(boundTextElementId)!;
-      mutateElement(textElement, {
-        x: textElement.x + (rotatedCX - cx),
-        y: textElement.y + (rotatedCY - cy),
-        angle: normalizeAngle(centerAngle + origAngle),
-      });
+      const textElement = Scene.getScene(element)!.getElement(
+        boundTextElementId,
+      ) as ExcalidrawTextElementWithContainer;
+      if (!isArrowElement(element)) {
+        mutateElement(textElement, {
+          x: textElement.x + (rotatedCX - cx),
+          y: textElement.y + (rotatedCY - cy),
+          angle: normalizeAngle(centerAngle + origAngle),
+        });
+      }
     }
   });
 };

+ 1 - 1
src/element/resizeTest.ts

@@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
   pointerType: PointerType,
 ): MaybeTransformHandleType => {
   const transformHandles = getTransformHandlesFromCoords(
-    [x1, y1, x2, y2],
+    [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
     0,
     zoom,
     pointerType,

+ 253 - 95
src/element/textElement.ts

@@ -13,11 +13,17 @@ import { MaybeTransformHandleType } from "./transformHandles";
 import Scene from "../scene/Scene";
 import { isTextElement } from ".";
 import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
+import {
+  isBoundToContainer,
+  isImageElement,
+  isArrowElement,
+} from "./typeChecks";
+import { LinearElementEditor } from "./linearElementEditor";
+import { AppState } from "../types";
 import { isTextBindableContainer } from "./typeChecks";
 import { getElementAbsoluteCoords } from "../element";
-import { AppState } from "../types";
 import { getSelectedElements } from "../scene";
-import { isImageElement } from "./typeChecks";
+import { isHittingElementNotConsideringBoundingBox } from "./collision";
 
 export const normalizeText = (text: string) => {
   return (
@@ -52,36 +58,47 @@ export const redrawTextBoundingBox = (
   let coordX = textElement.x;
   // Resize container and vertically center align the text
   if (container) {
-    const containerDims = getContainerDims(container);
-    let nextHeight = containerDims.height;
-    if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
-      coordY = container.y + BOUND_TEXT_PADDING;
-    } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
-      coordY =
-        container.y +
-        containerDims.height -
-        metrics.height -
-        BOUND_TEXT_PADDING;
-    } else {
-      coordY = container.y + containerDims.height / 2 - metrics.height / 2;
-      if (metrics.height > getMaxContainerHeight(container)) {
-        nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
-        coordY = container.y + nextHeight / 2 - metrics.height / 2;
+    if (!isArrowElement(container)) {
+      const containerDims = getContainerDims(container);
+      let nextHeight = containerDims.height;
+      const boundTextElementPadding = getBoundTextElementOffset(textElement);
+      if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
+        coordY = container.y + boundTextElementPadding;
+      } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+        coordY =
+          container.y +
+          containerDims.height -
+          metrics.height -
+          boundTextElementPadding;
+      } else {
+        coordY = container.y + containerDims.height / 2 - metrics.height / 2;
+        if (metrics.height > getMaxContainerHeight(container)) {
+          nextHeight = metrics.height + boundTextElementPadding * 2;
+          coordY = container.y + nextHeight / 2 - metrics.height / 2;
+        }
+      }
+      if (textElement.textAlign === TEXT_ALIGN.LEFT) {
+        coordX = container.x + boundTextElementPadding;
+      } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
+        coordX =
+          container.x +
+          containerDims.width -
+          metrics.width -
+          boundTextElementPadding;
+      } else {
+        coordX = container.x + containerDims.width / 2 - metrics.width / 2;
       }
-    }
 
-    if (textElement.textAlign === TEXT_ALIGN.LEFT) {
-      coordX = container.x + BOUND_TEXT_PADDING;
-    } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
-      coordX =
-        container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
+      mutateElement(container, { height: nextHeight });
     } else {
-      coordX = container.x + container.width / 2 - metrics.width / 2;
+      const centerX = textElement.x + textElement.width / 2;
+      const centerY = textElement.y + textElement.height / 2;
+      const diffWidth = metrics.width - textElement.width;
+      const diffHeight = metrics.height - textElement.height;
+      coordY = centerY - (textElement.height + diffHeight) / 2;
+      coordX = centerX - (textElement.width + diffWidth) / 2;
     }
-
-    mutateElement(container, { height: nextHeight });
   }
-
   mutateElement(textElement, {
     width: metrics.width,
     height: metrics.height,
@@ -129,84 +146,113 @@ export const bindTextToShapeAfterDuplication = (
 };
 
 export const handleBindTextResize = (
-  element: NonDeletedExcalidrawElement,
+  container: NonDeletedExcalidrawElement,
   transformHandleType: MaybeTransformHandleType,
 ) => {
-  const boundTextElementId = getBoundTextElementId(element);
-  if (boundTextElementId) {
-    const textElement = Scene.getScene(element)!.getElement(
+  const boundTextElementId = getBoundTextElementId(container);
+  if (!boundTextElementId) {
+    return;
+  }
+  let textElement = Scene.getScene(container)!.getElement(
+    boundTextElementId,
+  ) as ExcalidrawTextElement;
+  if (textElement && textElement.text) {
+    if (!container) {
+      return;
+    }
+
+    textElement = Scene.getScene(container)!.getElement(
       boundTextElementId,
     ) as ExcalidrawTextElement;
-    if (textElement && textElement.text) {
-      if (!element) {
-        return;
-      }
-      let text = textElement.text;
-      let nextHeight = textElement.height;
-      let nextWidth = textElement.width;
-      let containerHeight = element.height;
-      let nextBaseLine = textElement.baseline;
-      if (transformHandleType !== "n" && transformHandleType !== "s") {
-        if (text) {
-          text = wrapText(
-            textElement.originalText,
-            getFontString(textElement),
-            getMaxContainerWidth(element),
-          );
-        }
-
-        const dimensions = measureText(
-          text,
+    let text = textElement.text;
+    let nextHeight = textElement.height;
+    let nextWidth = textElement.width;
+    const containerDims = getContainerDims(container);
+    const maxWidth = getMaxContainerWidth(container);
+    const maxHeight = getMaxContainerHeight(container);
+    let containerHeight = containerDims.height;
+    let nextBaseLine = textElement.baseline;
+    if (transformHandleType !== "n" && transformHandleType !== "s") {
+      if (text) {
+        text = wrapText(
+          textElement.originalText,
           getFontString(textElement),
-          element.width,
+          maxWidth,
         );
-        nextHeight = dimensions.height;
-        nextWidth = dimensions.width;
-        nextBaseLine = dimensions.baseline;
-      }
-      // increase height in case text element height exceeds
-      if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
-        containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
-        const diff = containerHeight - element.height;
-        // fix the y coord when resizing from ne/nw/n
-        const updatedY =
-          transformHandleType === "ne" ||
-          transformHandleType === "nw" ||
-          transformHandleType === "n"
-            ? element.y - diff
-            : element.y;
-        mutateElement(element, {
-          height: containerHeight,
-          y: updatedY,
-        });
-      }
-
-      let updatedY;
-      if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
-        updatedY = element.y + BOUND_TEXT_PADDING;
-      } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
-        updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
-      } else {
-        updatedY = element.y + element.height / 2 - nextHeight / 2;
       }
-      const updatedX =
-        textElement.textAlign === TEXT_ALIGN.LEFT
-          ? element.x + BOUND_TEXT_PADDING
-          : textElement.textAlign === TEXT_ALIGN.RIGHT
-          ? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
-          : element.x + element.width / 2 - nextWidth / 2;
-      mutateElement(textElement, {
+      const dimensions = measureText(
         text,
-        width: nextWidth,
-        height: nextHeight,
-        x: updatedX,
+        getFontString(textElement),
+        maxWidth,
+      );
+      nextHeight = dimensions.height;
+      nextWidth = dimensions.width;
+      nextBaseLine = dimensions.baseline;
+    }
+    // increase height in case text element height exceeds
+    if (nextHeight > maxHeight) {
+      containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
+      const diff = containerHeight - containerDims.height;
+      // fix the y coord when resizing from ne/nw/n
+      const updatedY =
+        !isArrowElement(container) &&
+        (transformHandleType === "ne" ||
+          transformHandleType === "nw" ||
+          transformHandleType === "n")
+          ? container.y - diff
+          : container.y;
+      mutateElement(container, {
+        height: containerHeight,
         y: updatedY,
-        baseline: nextBaseLine,
       });
     }
+
+    mutateElement(textElement, {
+      text,
+      width: nextWidth,
+      height: nextHeight,
+
+      baseline: nextBaseLine,
+    });
+    if (!isArrowElement(container)) {
+      updateBoundTextPosition(
+        container,
+        textElement as ExcalidrawTextElementWithContainer,
+      );
+    }
   }
 };
 
+const updateBoundTextPosition = (
+  container: ExcalidrawElement,
+  boundTextElement: ExcalidrawTextElementWithContainer,
+) => {
+  const containerDims = getContainerDims(container);
+  const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
+  let y;
+  if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
+    y = container.y + boundTextElementPadding;
+  } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+    y =
+      container.y +
+      containerDims.height -
+      boundTextElement.height -
+      boundTextElementPadding;
+  } else {
+    y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
+  }
+  const x =
+    boundTextElement.textAlign === TEXT_ALIGN.LEFT
+      ? container.x + boundTextElementPadding
+      : boundTextElement.textAlign === TEXT_ALIGN.RIGHT
+      ? container.x +
+        containerDims.width -
+        boundTextElement.width -
+        boundTextElementPadding
+      : container.x + containerDims.width / 2 - boundTextElement.width / 2;
+
+  mutateElement(boundTextElement, { x, y });
+};
 // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
 export const measureText = (
   text: string,
@@ -411,6 +457,7 @@ export const charWidth = (() => {
 })();
 export const getApproxMinLineWidth = (font: FontString) => {
   const maxCharWidth = getMaxCharWidth(font);
+
   if (maxCharWidth === 0) {
     return (
       measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@@ -491,7 +538,9 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
 
 export const getContainerElement = (
   element:
-    | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
+    | (ExcalidrawElement & {
+        containerId: ExcalidrawElement["id"] | null;
+      })
     | null,
 ) => {
   if (!element) {
@@ -504,9 +553,106 @@ export const getContainerElement = (
 };
 
 export const getContainerDims = (element: ExcalidrawElement) => {
+  const MIN_WIDTH = 300;
+  if (isArrowElement(element)) {
+    const width = Math.max(element.width, MIN_WIDTH);
+    const height = element.height;
+    return { width, height };
+  }
   return { width: element.width, height: element.height };
 };
 
+export const getContainerCenter = (
+  container: ExcalidrawElement,
+  appState: AppState,
+) => {
+  if (!isArrowElement(container)) {
+    return {
+      x: container.x + container.width / 2,
+      y: container.y + container.height / 2,
+    };
+  }
+  const points = LinearElementEditor.getPointsGlobalCoordinates(container);
+  if (points.length % 2 === 1) {
+    const index = Math.floor(container.points.length / 2);
+    const midPoint = LinearElementEditor.getPointGlobalCoordinates(
+      container,
+      container.points[index],
+    );
+    return { x: midPoint[0], y: midPoint[1] };
+  }
+  const index = container.points.length / 2 - 1;
+  let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
+    container,
+    appState,
+  )[index];
+  if (!midSegmentMidpoint) {
+    midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
+      container,
+      points[index],
+      points[index + 1],
+      index + 1,
+    );
+  }
+  return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
+};
+
+export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
+  const container = getContainerElement(textElement);
+  if (!container || isArrowElement(container)) {
+    return textElement.angle;
+  }
+  return container.angle;
+};
+
+export const getBoundTextElementOffset = (
+  boundTextElement: ExcalidrawTextElement | null,
+) => {
+  const container = getContainerElement(boundTextElement);
+  if (!container) {
+    return 0;
+  }
+  if (isArrowElement(container)) {
+    return BOUND_TEXT_PADDING * 8;
+  }
+  return BOUND_TEXT_PADDING;
+};
+
+export const getBoundTextElementPosition = (
+  container: ExcalidrawElement,
+  boundTextElement: ExcalidrawTextElementWithContainer,
+) => {
+  if (isArrowElement(container)) {
+    return LinearElementEditor.getBoundTextElementPosition(
+      container,
+      boundTextElement,
+    );
+  }
+};
+
+export const shouldAllowVerticalAlign = (
+  selectedElements: NonDeletedExcalidrawElement[],
+) => {
+  return selectedElements.some((element) => {
+    const hasBoundContainer = isBoundToContainer(element);
+    if (hasBoundContainer) {
+      const container = getContainerElement(element);
+      if (isTextElement(element) && isArrowElement(container)) {
+        return false;
+      }
+      return true;
+    }
+    const boundTextElement = getBoundTextElement(element);
+    if (boundTextElement) {
+      if (isArrowElement(element)) {
+        return false;
+      }
+      return true;
+    }
+    return false;
+  });
+};
+
 export const getTextBindableContainerAtPosition = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
@@ -515,7 +661,9 @@ export const getTextBindableContainerAtPosition = (
 ): ExcalidrawTextContainer | null => {
   const selectedElements = getSelectedElements(elements, appState);
   if (selectedElements.length === 1) {
-    return selectedElements[0] as ExcalidrawTextContainer;
+    return isTextBindableContainer(selectedElements[0], false)
+      ? selectedElements[0]
+      : null;
   }
   let hitElement = null;
   // We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -524,7 +672,16 @@ export const getTextBindableContainerAtPosition = (
       continue;
     }
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
-    if (x1 < x && x < x2 && y1 < y && y < y2) {
+    if (
+      isArrowElement(elements[index]) &&
+      isHittingElementNotConsideringBoundingBox(elements[index], appState, [
+        x,
+        y,
+      ])
+    ) {
+      hitElement = elements[index];
+      break;
+    } else if (x1 < x && x < x2 && y1 < y && y < y2) {
       hitElement = elements[index];
       break;
     }
@@ -538,6 +695,7 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
     element.type === "rectangle" ||
     element.type === "ellipse" ||
     element.type === "diamond" ||
-    isImageElement(element)
+    isImageElement(element) ||
+    isArrowElement(element)
   );
 };

+ 22 - 18
src/element/textWysiwyg.test.tsx

@@ -513,6 +513,9 @@ describe("textWysiwyg", () => {
       const text = h.elements[1] as ExcalidrawTextElementWithContainer;
       expect(text.type).toBe("text");
       expect(text.containerId).toBe(rectangle.id);
+      expect(rectangle.boundElements).toStrictEqual([
+        { id: text.id, type: "text" },
+      ]);
       mouse.down();
       const editor = document.querySelector(
         ".excalidraw-textEditorContainer > textarea",
@@ -586,20 +589,19 @@ describe("textWysiwyg", () => {
     });
 
     it("shouldn't bind to non-text-bindable containers", async () => {
-      const line = API.createElement({
-        type: "line",
+      const freedraw = API.createElement({
+        type: "freedraw",
         width: 100,
         height: 0,
-        points: [
-          [0, 0],
-          [100, 0],
-        ],
       });
-      h.elements = [line];
+      h.elements = [freedraw];
 
       UI.clickTool("text");
 
-      mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2);
+      mouse.clickAt(
+        freedraw.x + freedraw.width / 2,
+        freedraw.y + freedraw.height / 2,
+      );
 
       const editor = document.querySelector(
         ".excalidraw-textEditorContainer > textarea",
@@ -613,20 +615,22 @@ describe("textWysiwyg", () => {
       fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
       editor.dispatchEvent(new Event("input"));
 
-      expect(line.boundElements).toBe(null);
+      expect(freedraw.boundElements).toBe(null);
       expect(h.elements[1].type).toBe("text");
       expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
     });
 
-    it("shouldn't create text element when pressing 'Enter' key on non text bindable container", async () => {
-      h.elements = [];
-      const freeDraw = UI.createElement("freedraw", {
-        width: 100,
-        height: 50,
+    ["freedraw", "line"].forEach((type: any) => {
+      it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
+        h.elements = [];
+        const elemnet = UI.createElement(type, {
+          width: 100,
+          height: 50,
+        });
+        API.setSelectedElements([elemnet]);
+        Keyboard.keyPress(KEYS.ENTER);
+        expect(h.elements.length).toBe(1);
       });
-      API.setSelectedElements([freeDraw]);
-      Keyboard.keyPress(KEYS.ENTER);
-      expect(h.elements.length).toBe(1);
     });
 
     it("should'nt bind text to container when not double clicked on center", async () => {
@@ -1206,7 +1210,7 @@ describe("textWysiwyg", () => {
 
       fireEvent.change(editor, { target: { value: "   " } });
       editor.blur();
-      expect(rectangle.boundElements).toBeNull();
+      expect(rectangle.boundElements).toStrictEqual([]);
       expect(h.elements[1].isDeleted).toBe(true);
     });
   });

+ 40 - 12
src/element/textWysiwyg.tsx

@@ -6,11 +6,16 @@ import {
   isTestEnv,
 } from "../utils";
 import Scene from "../scene/Scene";
-import { isBoundToContainer, isTextElement } from "./typeChecks";
-import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
+import {
+  isArrowElement,
+  isBoundToContainer,
+  isTextElement,
+} from "./typeChecks";
+import { CLASSES, VERTICAL_ALIGN } from "../constants";
 import {
   ExcalidrawElement,
   ExcalidrawLinearElement,
+  ExcalidrawTextElementWithContainer,
   ExcalidrawTextElement,
 } from "./types";
 import { AppState } from "../types";
@@ -18,8 +23,10 @@ import { mutateElement } from "./mutateElement";
 import {
   getApproxLineHeight,
   getBoundTextElementId,
+  getBoundTextElementOffset,
   getContainerDims,
   getContainerElement,
+  getTextElementAngle,
   measureText,
   normalizeText,
   wrapText,
@@ -30,7 +37,8 @@ import {
 } from "../actions/actionProperties";
 import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
 import App from "../components/App";
-import { getMaxContainerWidth } from "./newElement";
+import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
+import { LinearElementEditor } from "./linearElementEditor";
 import { parseClipboard } from "../clipboard";
 
 const getTransform = (
@@ -108,7 +116,7 @@ export const textWysiwyg = ({
       getFontString(updatedTextElement),
     );
     if (updatedTextElement && isTextElement(updatedTextElement)) {
-      const coordX = updatedTextElement.x;
+      let coordX = updatedTextElement.x;
       let coordY = updatedTextElement.y;
       const container = getContainerElement(updatedTextElement);
       let maxWidth = updatedTextElement.width;
@@ -119,6 +127,15 @@ export const textWysiwyg = ({
       // what is going to be used for unbounded text
       let height = updatedTextElement.height;
       if (container && updatedTextElement.containerId) {
+        if (isArrowElement(container)) {
+          const boundTextCoords =
+            LinearElementEditor.getBoundTextElementPosition(
+              container,
+              updatedTextElement as ExcalidrawTextElementWithContainer,
+            );
+          coordX = boundTextCoords.x;
+          coordY = boundTextCoords.y;
+        }
         const propertiesUpdated = textPropertiesUpdated(
           updatedTextElement,
           editable,
@@ -138,16 +155,19 @@ export const textWysiwyg = ({
         if (!originalContainerHeight) {
           originalContainerHeight = containerDims.height;
         }
-        maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
-        maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
+        maxWidth = getMaxContainerWidth(container);
+        maxHeight = getMaxContainerHeight(container);
+
         // autogrow container height if text exceeds
-        if (height > maxHeight) {
+
+        if (!isArrowElement(container) && height > maxHeight) {
           const diff = Math.min(height - maxHeight, approxLineHeight);
           mutateElement(container, { height: containerDims.height + diff });
           return;
         } else if (
           // autoshrink container height until original container height
           // is reached when text is removed
+          !isArrowElement(container) &&
           containerDims.height > originalContainerHeight &&
           height < maxHeight
         ) {
@@ -159,11 +179,16 @@ export const textWysiwyg = ({
         else {
           // vertically center align the text
           if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
-            coordY = container.y + containerDims.height / 2 - height / 2;
+            if (!isArrowElement(container)) {
+              coordY = container.y + containerDims.height / 2 - height / 2;
+            }
           }
           if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
             coordY =
-              container.y + containerDims.height - height - BOUND_TEXT_PADDING;
+              container.y +
+              containerDims.height -
+              height -
+              getBoundTextElementOffset(updatedTextElement);
           }
         }
       }
@@ -197,7 +222,7 @@ export const textWysiwyg = ({
       // Make sure text editor height doesn't go beyond viewport
       const editorMaxHeight =
         (appState.height - viewportY) / appState.zoom.value;
-      const angle = container ? container.angle : updatedTextElement.angle;
+
       Object.assign(editable.style, {
         font: getFontString(updatedTextElement),
         // must be defined *after* font ¯\_(ツ)_/¯
@@ -209,7 +234,7 @@ export const textWysiwyg = ({
         transform: getTransform(
           width,
           height,
-          angle,
+          getTextElementAngle(updatedTextElement),
           appState,
           maxWidth,
           editorMaxHeight,
@@ -246,6 +271,8 @@ export const textWysiwyg = ({
     whiteSpace = "pre-wrap";
     wordBreak = "break-word";
   }
+  const isContainerArrow = isArrowElement(getContainerElement(element));
+  const background = isContainerArrow ? "#fff" : "transparent";
   Object.assign(editable.style, {
     position: "absolute",
     display: "inline-block",
@@ -256,7 +283,7 @@ export const textWysiwyg = ({
     border: 0,
     outline: 0,
     resize: "none",
-    background: "transparent",
+    background,
     overflow: "hidden",
     // must be specified because in dark mode canvas creates a stacking context
     zIndex: "var(--zIndex-wysiwyg)",
@@ -264,6 +291,7 @@ export const textWysiwyg = ({
     // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
     whiteSpace,
     overflowWrap: "break-word",
+    boxSizing: "content-box",
   });
   updateWysiwygStyle();
 

+ 3 - 5
src/element/transformHandles.ts

@@ -4,7 +4,7 @@ import {
   PointerType,
 } from "./types";
 
-import { getElementAbsoluteCoords, Bounds } from "./bounds";
+import { getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
 import { AppState, Zoom } from "../types";
 import { isTextElement } from ".";
@@ -81,7 +81,7 @@ const generateTransformHandle = (
 };
 
 export const getTransformHandlesFromCoords = (
-  [x1, y1, x2, y2]: Bounds,
+  [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
   angle: number,
   zoom: Zoom,
   pointerType: PointerType,
@@ -97,8 +97,6 @@ export const getTransformHandlesFromCoords = (
 
   const width = x2 - x1;
   const height = y2 - y1;
-  const cx = (x1 + x2) / 2;
-  const cy = (y1 + y2) / 2;
   const dashedLineMargin = margin / zoom.value;
   const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
 
@@ -256,7 +254,7 @@ export const getTransformHandles = (
     ? DEFAULT_SPACING + 8
     : DEFAULT_SPACING;
   return getTransformHandlesFromCoords(
-    getElementAbsoluteCoords(element),
+    getElementAbsoluteCoords(element, true),
     element.angle,
     zoom,
     pointerType,

+ 8 - 1
src/element/typeChecks.ts

@@ -60,6 +60,12 @@ export const isLinearElement = (
   return element != null && isLinearElementType(element.type);
 };
 
+export const isArrowElement = (
+  element?: ExcalidrawElement | null,
+): element is ExcalidrawLinearElement => {
+  return element != null && element.type === "arrow";
+};
+
 export const isLinearElementType = (
   elementType: AppState["activeTool"]["type"],
 ): boolean => {
@@ -110,7 +116,8 @@ export const isTextBindableContainer = (
     (element.type === "rectangle" ||
       element.type === "diamond" ||
       element.type === "ellipse" ||
-      element.type === "image")
+      element.type === "image" ||
+      isArrowElement(element))
   );
 };
 

+ 7 - 1
src/element/types.ts

@@ -141,7 +141,8 @@ export type ExcalidrawTextContainer =
   | ExcalidrawRectangleElement
   | ExcalidrawDiamondElement
   | ExcalidrawEllipseElement
-  | ExcalidrawImageElement;
+  | ExcalidrawImageElement
+  | ExcalidrawArrowEleement;
 
 export type ExcalidrawTextElementWithContainer = {
   containerId: ExcalidrawTextContainer["id"];
@@ -166,6 +167,11 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
     endArrowhead: Arrowhead | null;
   }>;
 
+export type ExcalidrawArrowEleement = ExcalidrawLinearElement &
+  Readonly<{
+    type: "arrow";
+  }>;
+
 export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
   Readonly<{
     type: "freedraw";

+ 1 - 1
src/locales/en.json

@@ -237,7 +237,7 @@
     "resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
     "resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
     "rotate": "You can constrain angles by holding SHIFT while rotating",
-    "lineEditor_info": "Double-click or press Enter to edit points",
+    "lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
     "lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
     "lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
     "placeImage": "Click to place the image, or click and drag to set its size manually",

+ 0 - 1
src/points.ts

@@ -51,6 +51,5 @@ export const rescalePoints = (
         return currentDimension === dimension ? value + translation : value;
       }) as [number, number],
   );
-
   return nextPoints;
 };

+ 248 - 23
src/renderer/renderElement.ts

@@ -6,12 +6,14 @@ import {
   NonDeletedExcalidrawElement,
   ExcalidrawFreeDrawElement,
   ExcalidrawImageElement,
+  ExcalidrawTextElementWithContainer,
 } from "../element/types";
 import {
   isTextElement,
   isLinearElement,
   isFreeDrawElement,
   isInitializedImageElement,
+  isArrowElement,
 } from "../element/typeChecks";
 import {
   getDiamondPoints,
@@ -37,7 +39,13 @@ import {
   VERTICAL_ALIGN,
 } from "../constants";
 import { getStroke, StrokeOptions } from "perfect-freehand";
-import { getApproxLineHeight } from "../element/textElement";
+import {
+  getApproxLineHeight,
+  getBoundTextElement,
+  getBoundTextElementOffset,
+  getContainerElement,
+} from "../element/textElement";
+import { LinearElementEditor } from "../element/linearElementEditor";
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original
@@ -80,6 +88,7 @@ export interface ExcalidrawElementWithCanvas {
   canvasZoom: Zoom["value"];
   canvasOffsetX: number;
   canvasOffsetY: number;
+  boundTextElementVersion: number | null;
 }
 
 const generateElementCanvas = (
@@ -148,6 +157,7 @@ const generateElementCanvas = (
     canvasZoom: zoom.value,
     canvasOffsetX,
     canvasOffsetY,
+    boundTextElementVersion: getBoundTextElement(element)?.version || null,
   };
 };
 
@@ -272,7 +282,7 @@ const drawElementOnCanvas = (
           : element.height / lines.length;
         let verticalOffset = element.height - element.baseline;
         if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
-          verticalOffset = BOUND_TEXT_PADDING;
+          verticalOffset = getBoundTextElementOffset(element);
         }
 
         const horizontalOffset =
@@ -656,11 +666,13 @@ const generateElementWithCanvas = (
     prevElementWithCanvas &&
     prevElementWithCanvas.canvasZoom !== zoom.value &&
     !renderConfig?.shouldCacheIgnoreZoom;
+  const boundTextElementVersion = getBoundTextElement(element)?.version || null;
 
   if (
     !prevElementWithCanvas ||
     shouldRegenerateBecauseZoom ||
-    prevElementWithCanvas.theme !== renderConfig.theme
+    prevElementWithCanvas.theme !== renderConfig.theme ||
+    prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
   ) {
     const elementWithCanvas = generateElementCanvas(
       element,
@@ -683,6 +695,7 @@ const drawElementFromCanvas = (
 ) => {
   const element = elementWithCanvas.element;
   const padding = getCanvasPadding(element);
+  const zoom = elementWithCanvas.canvasZoom;
   let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
 
   // Free draw elements will otherwise "shuffle" as the min x and y change
@@ -712,18 +725,93 @@ const drawElementFromCanvas = (
     (1 / window.devicePixelRatio) * scaleXFactor,
     (1 / window.devicePixelRatio) * scaleYFactor,
   );
-  context.translate(cx * scaleXFactor, cy * scaleYFactor);
-  context.rotate(element.angle * scaleXFactor * scaleYFactor);
+  const boundTextElement = getBoundTextElement(element);
+
+  if (isArrowElement(element) && boundTextElement) {
+    const tempCanvas = document.createElement("canvas");
+    const tempCanvasContext = tempCanvas.getContext("2d")!;
+
+    // Take max dimensions of arrow canvas so that when canvas is rotated
+    // the arrow doesn't get clipped
+    const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+    tempCanvas.width =
+      maxDim * window.devicePixelRatio * zoom +
+      padding * elementWithCanvas.canvasZoom * 10;
+    tempCanvas.height =
+      maxDim * window.devicePixelRatio * zoom +
+      padding * elementWithCanvas.canvasZoom * 10;
+    const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
+    const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
+
+    tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2);
+    tempCanvasContext.rotate(element.angle);
+
+    tempCanvasContext.drawImage(
+      elementWithCanvas.canvas!,
+      -elementWithCanvas.canvas.width / 2,
+      -elementWithCanvas.canvas.height / 2,
+      elementWithCanvas.canvas.width,
+      elementWithCanvas.canvas.height,
+    );
 
-  context.drawImage(
-    elementWithCanvas.canvas!,
-    (-(x2 - x1) / 2) * window.devicePixelRatio -
-      (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
-    (-(y2 - y1) / 2) * window.devicePixelRatio -
-      (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
-    elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
-    elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
-  );
+    const [, , , , boundTextCx, boundTextCy] =
+      getElementAbsoluteCoords(boundTextElement);
+
+    tempCanvasContext.rotate(-element.angle);
+
+    // Shift the canvas to the center of the bound text element
+    const shiftX =
+      tempCanvas.width / 2 -
+      (boundTextCx - x1) * window.devicePixelRatio * zoom -
+      offsetX -
+      padding * zoom;
+
+    const shiftY =
+      tempCanvas.height / 2 -
+      (boundTextCy - y1) * window.devicePixelRatio * zoom -
+      offsetY -
+      padding * zoom;
+    tempCanvasContext.translate(-shiftX, -shiftY);
+
+    // Clear the bound text area
+    tempCanvasContext.clearRect(
+      -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
+        window.devicePixelRatio *
+        zoom,
+      -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
+        window.devicePixelRatio *
+        zoom,
+      (boundTextElement.width + BOUND_TEXT_PADDING * 2) *
+        window.devicePixelRatio *
+        zoom,
+      (boundTextElement.height + BOUND_TEXT_PADDING * 2) *
+        window.devicePixelRatio *
+        zoom,
+    );
+
+    context.translate(cx * scaleXFactor, cy * scaleYFactor);
+    context.drawImage(
+      tempCanvas,
+      (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
+      (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
+      tempCanvas.width / zoom,
+      tempCanvas.height / zoom,
+    );
+  } else {
+    context.translate(cx * scaleXFactor, cy * scaleYFactor);
+
+    context.rotate(element.angle * scaleXFactor * scaleYFactor);
+
+    context.drawImage(
+      elementWithCanvas.canvas!,
+      (-(x2 - x1) / 2) * window.devicePixelRatio -
+        (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
+      (-(y2 - y1) / 2) * window.devicePixelRatio -
+        (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
+      elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
+      elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
+    );
+  }
   context.restore();
 
   // Clear the nested element we appended to the DOM
@@ -734,6 +822,7 @@ export const renderElement = (
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
   renderConfig: RenderConfig,
+  appState: AppState,
 ) => {
   const generator = rc.generator;
   switch (element.type) {
@@ -796,21 +885,94 @@ export const renderElement = (
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
         const cx = (x1 + x2) / 2 + renderConfig.scrollX;
         const cy = (y1 + y2) / 2 + renderConfig.scrollY;
-        const shiftX = (x2 - x1) / 2 - (element.x - x1);
-        const shiftY = (y2 - y1) / 2 - (element.y - y1);
+        let shiftX = (x2 - x1) / 2 - (element.x - x1);
+        let shiftY = (y2 - y1) / 2 - (element.y - y1);
+        if (isTextElement(element)) {
+          const container = getContainerElement(element);
+          if (isArrowElement(container)) {
+            const boundTextCoords =
+              LinearElementEditor.getBoundTextElementPosition(
+                container,
+                element as ExcalidrawTextElementWithContainer,
+              );
+            shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
+            shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
+          }
+        }
         context.save();
         context.translate(cx, cy);
-        context.rotate(element.angle);
         if (element.type === "image") {
           context.scale(element.scale[0], element.scale[1]);
         }
-        context.translate(-shiftX, -shiftY);
 
         if (shouldResetImageFilter(element, renderConfig)) {
           context.filter = "none";
         }
+        const boundTextElement = getBoundTextElement(element);
+
+        if (isArrowElement(element) && boundTextElement) {
+          const tempCanvas = document.createElement("canvas");
+
+          const tempCanvasContext = tempCanvas.getContext("2d")!;
+
+          // Take max dimensions of arrow canvas so that when canvas is rotated
+          // the arrow doesn't get clipped
+          const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+          const padding = getCanvasPadding(element);
+          tempCanvas.width =
+            maxDim * appState.exportScale + padding * 10 * appState.exportScale;
+          tempCanvas.height =
+            maxDim * appState.exportScale + padding * 10 * appState.exportScale;
+
+          tempCanvasContext.translate(
+            tempCanvas.width / 2,
+            tempCanvas.height / 2,
+          );
+          tempCanvasContext.scale(appState.exportScale, appState.exportScale);
+
+          // Shift the canvas to left most point of the arrow
+          shiftX = element.width / 2 - (element.x - x1);
+          shiftY = element.height / 2 - (element.y - y1);
+
+          tempCanvasContext.rotate(element.angle);
+          const tempRc = rough.canvas(tempCanvas);
+
+          tempCanvasContext.translate(-shiftX, -shiftY);
+
+          drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
+
+          tempCanvasContext.translate(shiftX, shiftY);
+
+          tempCanvasContext.rotate(-element.angle);
+
+          // Shift the canvas to center of bound text
+          const [, , , , boundTextCx, boundTextCy] =
+            getElementAbsoluteCoords(boundTextElement);
+          const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
+          const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
+          tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
+
+          // Clear the bound text area
+          tempCanvasContext.clearRect(
+            -boundTextElement.width / 2,
+            -boundTextElement.height / 2,
+            boundTextElement.width,
+            boundTextElement.height,
+          );
+          context.scale(1 / appState.exportScale, 1 / appState.exportScale);
+          context.drawImage(
+            tempCanvas,
+            -tempCanvas.width / 2,
+            -tempCanvas.height / 2,
+            tempCanvas.width,
+            tempCanvas.height,
+          );
+        } else {
+          context.rotate(element.angle);
+          context.translate(-shiftX, -shiftY);
+          drawElementOnCanvas(element, rc, context, renderConfig);
+        }
 
-        drawElementOnCanvas(element, rc, context, renderConfig);
         context.restore();
         // not exporting → optimized rendering (cache & render from element
         // canvases)
@@ -851,13 +1013,28 @@ export const renderElementToSvg = (
   rsvg: RoughSVG,
   svgRoot: SVGElement,
   files: BinaryFiles,
-  offsetX?: number,
-  offsetY?: number,
+  offsetX: number,
+  offsetY: number,
   exportWithDarkMode?: boolean,
 ) => {
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  const cx = (x2 - x1) / 2 - (element.x - x1);
-  const cy = (y2 - y1) / 2 - (element.y - y1);
+  let cx = (x2 - x1) / 2 - (element.x - x1);
+  let cy = (y2 - y1) / 2 - (element.y - y1);
+  if (isTextElement(element)) {
+    const container = getContainerElement(element);
+    if (isArrowElement(container)) {
+      const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
+
+      const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
+        container,
+        element as ExcalidrawTextElementWithContainer,
+      );
+      cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
+      cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
+      offsetX = offsetX + boundTextCoords.x - element.x;
+      offsetY = offsetY + boundTextCoords.y - element.y;
+    }
+  }
   const degree = (180 * element.angle) / Math.PI;
   const generator = rsvg.generator;
 
@@ -904,8 +1081,54 @@ export const renderElementToSvg = (
     }
     case "line":
     case "arrow": {
+      const boundText = getBoundTextElement(element);
+      const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
+      if (boundText) {
+        maskPath.setAttribute("id", `mask-${element.id}`);
+        const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
+          SVG_NS,
+          "rect",
+        );
+        offsetX = offsetX || 0;
+        offsetY = offsetY || 0;
+        maskRectVisible.setAttribute("x", "0");
+        maskRectVisible.setAttribute("y", "0");
+        maskRectVisible.setAttribute("fill", "#fff");
+        maskRectVisible.setAttribute(
+          "width",
+          `${element.width + 100 + offsetX}`,
+        );
+        maskRectVisible.setAttribute(
+          "height",
+          `${element.height + 100 + offsetY}`,
+        );
+
+        maskPath.appendChild(maskRectVisible);
+        const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
+          SVG_NS,
+          "rect",
+        );
+        const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
+          element,
+          boundText,
+        );
+
+        const maskX = offsetX + boundTextCoords.x - element.x;
+        const maskY = offsetY + boundTextCoords.y - element.y;
+
+        maskRectInvisible.setAttribute("x", maskX.toString());
+        maskRectInvisible.setAttribute("y", maskY.toString());
+        maskRectInvisible.setAttribute("fill", "#000");
+        maskRectInvisible.setAttribute("width", `${boundText.width}`);
+        maskRectInvisible.setAttribute("height", `${boundText.height}`);
+        maskRectInvisible.setAttribute("opacity", "1");
+        maskPath.appendChild(maskRectInvisible);
+      }
       generateElementShape(element, generator);
       const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+      if (boundText) {
+        group.setAttribute("mask", `url(#mask-${element.id})`);
+      }
       const opacity = element.opacity / 100;
       group.setAttribute("stroke-linecap", "round");
 
@@ -935,6 +1158,7 @@ export const renderElementToSvg = (
         group.appendChild(node);
       });
       root.appendChild(group);
+      root.append(maskPath);
       break;
     }
     case "freedraw": {
@@ -1033,6 +1257,7 @@ export const renderElementToSvg = (
           node.setAttribute("stroke-opacity", `${opacity}`);
           node.setAttribute("fill-opacity", `${opacity}`);
         }
+
         node.setAttribute(
           "transform",
           `translate(${offsetX || 0} ${

+ 40 - 12
src/renderer/renderScene.ts

@@ -348,7 +348,6 @@ export const _renderScene = ({
     context.setTransform(1, 0, 0, 1, 0, 0);
     context.save();
     context.scale(scale, scale);
-
     // When doing calculations based on canvas width we should used normalized one
     const normalizedCanvasWidth = canvas.width / scale;
     const normalizedCanvasHeight = canvas.height / scale;
@@ -410,7 +409,7 @@ export const _renderScene = ({
       undefined;
     visibleElements.forEach((element) => {
       try {
-        renderElement(element, rc, context, renderConfig);
+        renderElement(element, rc, context, renderConfig, appState);
         // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
         // ShapeCache returns empty hence making sure that we get the
         // correct element from visible elements
@@ -440,7 +439,13 @@ export const _renderScene = ({
     // Paint selection element
     if (appState.selectionElement) {
       try {
-        renderElement(appState.selectionElement, rc, context, renderConfig);
+        renderElement(
+          appState.selectionElement,
+          rc,
+          context,
+          renderConfig,
+          appState,
+        );
       } catch (error: any) {
         console.error(error);
       }
@@ -453,6 +458,22 @@ export const _renderScene = ({
           renderBindingHighlight(context, renderConfig, suggestedBinding!);
         });
     }
+    const locallySelectedElements = getSelectedElements(elements, appState);
+
+    // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
+    // ShapeCache returns empty hence making sure that we get the
+    // correct element from visible elements
+    if (
+      locallySelectedElements.length === 1 &&
+      appState.editingLinearElement?.elementId === locallySelectedElements[0].id
+    ) {
+      renderLinearPointHandles(
+        context,
+        appState,
+        renderConfig,
+        locallySelectedElements[0] as NonDeleted<ExcalidrawLinearElement>,
+      );
+    }
 
     if (
       appState.selectedLinearElement &&
@@ -466,7 +487,6 @@ export const _renderScene = ({
       !appState.multiElement &&
       !appState.editingLinearElement
     ) {
-      const locallySelectedElements = getSelectedElements(elements, appState);
       const showBoundingBox = shouldShowBoundingBox(
         locallySelectedElements,
         appState,
@@ -515,8 +535,8 @@ export const _renderScene = ({
           }
 
           if (selectionColors.length) {
-            const [elementX1, elementY1, elementX2, elementY2] =
-              getElementAbsoluteCoords(element);
+            const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
+              getElementAbsoluteCoords(element, true);
             acc.push({
               angle: element.angle,
               elementX1,
@@ -525,10 +545,12 @@ export const _renderScene = ({
               elementY2,
               selectionColors,
               dashed: !!renderConfig.remoteSelectedElementIds[element.id],
+              cx,
+              cy,
             });
           }
           return acc;
-        }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean }[]);
+        }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean; cx: number; cy: number }[]);
 
         const addSelectionForGroupId = (groupId: GroupId) => {
           const groupElements = getElementsInGroup(elements, groupId);
@@ -540,8 +562,10 @@ export const _renderScene = ({
             elementX2,
             elementY1,
             elementY2,
-            selectionColors: [selectionColor],
+            selectionColors: [oc.black],
             dashed: true,
+            cx: elementX1 + (elementX2 - elementX1) / 2,
+            cy: elementY1 + (elementY2 - elementY1) / 2,
           });
         };
 
@@ -600,7 +624,7 @@ export const _renderScene = ({
         context.lineWidth = lineWidth;
         context.setLineDash(initialLineDash);
         const transformHandles = getTransformHandlesFromCoords(
-          [x1, y1, x2, y2],
+          [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
           0,
           renderConfig.zoom,
           "mouse",
@@ -861,6 +885,8 @@ const renderSelectionBorder = (
     elementY2: number;
     selectionColors: string[];
     dashed?: boolean;
+    cx: number;
+    cy: number;
   },
   padding = DEFAULT_SPACING * 2,
 ) => {
@@ -871,6 +897,8 @@ const renderSelectionBorder = (
     elementX2,
     elementY2,
     selectionColors,
+    cx,
+    cy,
     dashed,
   } = elementProperties;
   const elementWidth = elementX2 - elementX1;
@@ -900,8 +928,8 @@ const renderSelectionBorder = (
       elementY1 - linePadding,
       elementWidth + linePadding * 2,
       elementHeight + linePadding * 2,
-      elementX1 + elementWidth / 2,
-      elementY1 + elementHeight / 2,
+      cx,
+      cy,
       angle,
     );
   }
@@ -1117,7 +1145,7 @@ export const renderSceneToSvg = (
     return;
   }
   // render elements
-  elements.forEach((element) => {
+  elements.forEach((element, index) => {
     if (!element.isDeleted) {
       try {
         renderElementToSvg(

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

@@ -109,6 +109,9 @@ export class API {
     fileId?: T extends "image" ? string : never;
     scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
     status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
+    endBinding?: T extends "arrow"
+      ? ExcalidrawLinearElement["endBinding"]
+      : never;
   }): T extends "arrow" | "line"
     ? ExcalidrawLinearElement
     : T extends "freedraw"

+ 509 - 43
src/tests/linearElementEditor.test.tsx

@@ -1,20 +1,30 @@
 import ReactDOM from "react-dom";
-import { ExcalidrawLinearElement } from "../element/types";
+import {
+  ExcalidrawElement,
+  ExcalidrawLinearElement,
+  ExcalidrawTextElementWithContainer,
+  FontString,
+} from "../element/types";
 import ExcalidrawApp from "../excalidraw-app";
 import { centerPoint } from "../math";
 import { reseed } from "../random";
 import * as Renderer from "../renderer/renderScene";
-import { Keyboard, Pointer } from "./helpers/ui";
+import { Keyboard, Pointer, UI } from "./helpers/ui";
 import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
 import { API } from "../tests/helpers/api";
 import { Point } from "../types";
 import { KEYS } from "../keys";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import { queryByText } from "@testing-library/react";
+import { queryByTestId, queryByText } from "@testing-library/react";
+import { resize, rotate } from "./utils";
+import { getBoundTextElementPosition, wrapText } from "../element/textElement";
+import { getMaxContainerWidth } from "../element/newElement";
+import * as textElementUtils from "../element/textElement";
 
 const renderScene = jest.spyOn(Renderer, "renderScene");
 
 const { h } = window;
+const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
 
 describe("Test Linear Elements", () => {
   let container: HTMLElement;
@@ -44,23 +54,23 @@ describe("Test Linear Elements", () => {
     strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp",
     roughness: ExcalidrawLinearElement["roughness"] = 0,
   ) => {
-    h.elements = [
-      API.createElement({
-        x: p1[0],
-        y: p1[1],
-        width: p2[0] - p1[0],
-        height: 0,
-        type,
-        roughness,
-        points: [
-          [0, 0],
-          [p2[0] - p1[0], p2[1] - p1[1]],
-        ],
-        strokeSharpness,
-      }),
-    ];
+    const line = API.createElement({
+      x: p1[0],
+      y: p1[1],
+      width: p2[0] - p1[0],
+      height: 0,
+      type,
+      roughness,
+      points: [
+        [0, 0],
+        [p2[0] - p1[0], p2[1] - p1[1]],
+      ],
+      strokeSharpness,
+    });
+    h.elements = [line];
 
     mouse.clickAt(p1[0], p1[1]);
+    return line;
   };
 
   const createThreePointerLinearElement = (
@@ -70,23 +80,23 @@ describe("Test Linear Elements", () => {
   ) => {
     //dragging line from midpoint
     const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
-    h.elements = [
-      API.createElement({
-        x: p1[0],
-        y: p1[1],
-        width: p3[0] - p1[0],
-        height: 0,
-        type,
-        roughness,
-        points: [
-          [0, 0],
-          [p3[0], p3[1]],
-          [p2[0] - p1[0], p2[1] - p1[1]],
-        ],
-        strokeSharpness,
-      }),
-    ];
+    const line = API.createElement({
+      x: p1[0],
+      y: p1[1],
+      width: p3[0] - p1[0],
+      height: 0,
+      type,
+      roughness,
+      points: [
+        [0, 0],
+        [p3[0], p3[1]],
+        [p2[0] - p1[0], p2[1] - p1[1]],
+      ],
+      strokeSharpness,
+    });
+    h.elements = [line];
     mouse.clickAt(p1[0], p1[1]);
+    return line;
   };
 
   const enterLineEditingMode = (
@@ -98,7 +108,9 @@ describe("Test Linear Elements", () => {
     } else {
       mouse.clickAt(p1[0], p1[1]);
     }
-    Keyboard.keyPress(KEYS.ENTER);
+    Keyboard.withModifierKeys({ ctrl: true }, () => {
+      Keyboard.keyPress(KEYS.ENTER);
+    });
     expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
   };
 
@@ -216,6 +228,16 @@ describe("Test Linear Elements", () => {
     expect(h.state.editingLinearElement?.elementId).toBeUndefined();
   });
 
+  it("should enter line editor when using double clicked with ctrl key", () => {
+    createTwoPointerLinearElement("line");
+    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+
+    Keyboard.withModifierKeys({ ctrl: true }, () => {
+      mouse.doubleClick();
+    });
+    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+  });
+
   describe("Inside editor", () => {
     it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
       createTwoPointerLinearElement("line");
@@ -358,8 +380,8 @@ describe("Test Linear Elements", () => {
       let line: ExcalidrawLinearElement;
 
       beforeEach(() => {
-        createThreePointerLinearElement("line");
-        line = h.elements[0] as ExcalidrawLinearElement;
+        line = createThreePointerLinearElement("line");
+
         expect(line.points.length).toEqual(3);
 
         enterLineEditingMode(line);
@@ -478,7 +500,7 @@ describe("Test Linear Elements", () => {
         // delete 3rd point
         deletePoint(points[2]);
         expect(line.points.length).toEqual(3);
-        expect(renderScene).toHaveBeenCalledTimes(21);
+        expect(renderScene).toHaveBeenCalledTimes(22);
 
         const newMidPoints = LinearElementEditor.getEditorMidPoints(
           line,
@@ -503,8 +525,7 @@ describe("Test Linear Elements", () => {
       let line: ExcalidrawLinearElement;
 
       beforeEach(() => {
-        createThreePointerLinearElement("line", "round");
-        line = h.elements[0] as ExcalidrawLinearElement;
+        line = createThreePointerLinearElement("line", "round");
         expect(line.points.length).toEqual(3);
 
         enterLineEditingMode(line);
@@ -667,7 +688,6 @@ describe("Test Linear Elements", () => {
           fillStyle: "solid",
         }),
       ];
-      const origPoints = line.points.map((point) => [...point]);
       const dragEndPositionOffset = [100, 100] as const;
       API.setSelectedElements([line]);
       enterLineEditingMode(line, true);
@@ -682,11 +702,457 @@ describe("Test Linear Elements", () => {
             0,
           ],
           Array [
-            ${origPoints[1][0] - dragEndPositionOffset[0]},
-            ${origPoints[1][1] - dragEndPositionOffset[1]},
+            -60,
+            -100,
           ],
         ]
       `);
     });
   });
+
+  describe("Test bound text element", () => {
+    const DEFAULT_TEXT = "Online whiteboard collaboration made easy";
+
+    const createBoundTextElement = (
+      text: string,
+      container: ExcalidrawLinearElement,
+    ) => {
+      const textElement = API.createElement({
+        type: "text",
+        x: 0,
+        y: 0,
+        text: wrapText(text, font, getMaxContainerWidth(container)),
+        containerId: container.id,
+        width: 30,
+        height: 20,
+      }) as ExcalidrawTextElementWithContainer;
+
+      container = {
+        ...container,
+        boundElements: (container.boundElements || []).concat({
+          type: "text",
+          id: textElement.id,
+        }),
+      };
+      const elements: ExcalidrawElement[] = [];
+      h.elements.forEach((element) => {
+        if (element.id === container.id) {
+          elements.push(container);
+        } else {
+          elements.push(element);
+        }
+      });
+      const updatedTextElement = { ...textElement, originalText: text };
+      h.elements = [...elements, updatedTextElement];
+      return { textElement: updatedTextElement, container };
+    };
+
+    describe("Test getBoundTextElementPosition", () => {
+      it("should return correct position for 2 pointer arrow", () => {
+        createTwoPointerLinearElement("arrow");
+        const arrow = h.elements[0] as ExcalidrawLinearElement;
+        const { textElement, container } = createBoundTextElement(
+          DEFAULT_TEXT,
+          arrow,
+        );
+        const position = LinearElementEditor.getBoundTextElementPosition(
+          container,
+          textElement,
+        );
+        expect(position).toMatchInlineSnapshot(`
+          Object {
+            "x": 25,
+            "y": 10,
+          }
+        `);
+      });
+
+      it("should return correct position for arrow with odd points", () => {
+        createThreePointerLinearElement("arrow", "round");
+        const arrow = h.elements[0] as ExcalidrawLinearElement;
+        const { textElement, container } = createBoundTextElement(
+          DEFAULT_TEXT,
+          arrow,
+        );
+
+        const position = LinearElementEditor.getBoundTextElementPosition(
+          container,
+          textElement,
+        );
+        expect(position).toMatchInlineSnapshot(`
+          Object {
+            "x": 75,
+            "y": 60,
+          }
+        `);
+      });
+
+      it("should return correct position for arrow with even points", () => {
+        createThreePointerLinearElement("arrow", "round");
+        const arrow = h.elements[0] as ExcalidrawLinearElement;
+        const { textElement, container } = createBoundTextElement(
+          DEFAULT_TEXT,
+          arrow,
+        );
+        enterLineEditingMode(container);
+        // This is the expected midpoint for line with round edge
+        // hence hardcoding it so if later some bug is introduced
+        // this will fail and we can fix it
+        const firstSegmentMidpoint: Point = [
+          55.9697848965255, 47.442326230998205,
+        ];
+        // drag line from first segment midpoint
+        drag(firstSegmentMidpoint, [
+          firstSegmentMidpoint[0] + delta,
+          firstSegmentMidpoint[1] + delta,
+        ]);
+
+        const position = LinearElementEditor.getBoundTextElementPosition(
+          container,
+          textElement,
+        );
+        expect(position).toMatchInlineSnapshot(`
+          Object {
+            "x": 85.82201843191861,
+            "y": 75.63461309860818,
+          }
+        `);
+      });
+    });
+
+    it("should bind text to arrow when double clicked", async () => {
+      createTwoPointerLinearElement("arrow");
+      const arrow = h.elements[0] as ExcalidrawLinearElement;
+
+      expect(h.elements.length).toBe(1);
+      expect(h.elements[0].id).toBe(arrow.id);
+      mouse.doubleClickAt(arrow.x, arrow.y);
+      expect(h.elements.length).toBe(2);
+
+      const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+      expect(text.type).toBe("text");
+      expect(text.containerId).toBe(arrow.id);
+      mouse.down();
+      const editor = document.querySelector(
+        ".excalidraw-textEditorContainer > textarea",
+      ) as HTMLTextAreaElement;
+
+      fireEvent.change(editor, {
+        target: { value: DEFAULT_TEXT },
+      });
+
+      await new Promise((r) => setTimeout(r, 0));
+      editor.blur();
+      expect(arrow.boundElements).toStrictEqual([
+        { id: text.id, type: "text" },
+      ]);
+      expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
+        .toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+    });
+
+    it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
+      const arrow = createTwoPointerLinearElement("arrow");
+
+      expect(h.elements.length).toBe(1);
+      expect(h.elements[0].id).toBe(arrow.id);
+
+      Keyboard.keyPress(KEYS.ENTER);
+
+      expect(h.elements.length).toBe(2);
+
+      const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
+      expect(textElement.type).toBe("text");
+      expect(textElement.containerId).toBe(arrow.id);
+      const editor = document.querySelector(
+        ".excalidraw-textEditorContainer > textarea",
+      ) as HTMLTextAreaElement;
+
+      await new Promise((r) => setTimeout(r, 0));
+
+      fireEvent.change(editor, {
+        target: { value: DEFAULT_TEXT },
+      });
+      editor.blur();
+      expect(arrow.boundElements).toStrictEqual([
+        { id: textElement.id, type: "text" },
+      ]);
+      expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
+        .toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+    });
+
+    it("should not bind text to line when double clicked", async () => {
+      const line = createTwoPointerLinearElement("line");
+
+      expect(h.elements.length).toBe(1);
+      mouse.doubleClickAt(line.x, line.y);
+
+      expect(h.elements.length).toBe(2);
+
+      const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+      expect(text.type).toBe("text");
+      expect(text.containerId).toBeNull();
+      expect(line.boundElements).toBeNull();
+    });
+
+    it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => {
+      createThreePointerLinearElement("arrow", "round");
+
+      const arrow = h.elements[0] as ExcalidrawLinearElement;
+
+      const { textElement, container } = createBoundTextElement(
+        DEFAULT_TEXT,
+        arrow,
+      );
+
+      expect(container.angle).toBe(0);
+      expect(textElement.angle).toBe(0);
+      expect(getBoundTextElementPosition(arrow, textElement))
+        .toMatchInlineSnapshot(`
+        Object {
+          "x": 75,
+          "y": 60,
+        }
+      `);
+      expect(textElement.text).toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+      expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
+        .toMatchInlineSnapshot(`
+        Array [
+          20,
+          20,
+          105,
+          80,
+          55.45893770831013,
+          45,
+        ]
+      `);
+
+      rotate(container, -35, 55);
+      expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
+      expect(textElement.angle).toBe(0);
+      expect(getBoundTextElementPosition(container, textElement))
+        .toMatchInlineSnapshot(`
+        Object {
+          "x": 21.73926141863671,
+          "y": 73.31003398390868,
+        }
+      `);
+      expect(textElement.text).toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+      expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
+        .toMatchInlineSnapshot(`
+        Array [
+          20,
+          20,
+          102.41961302274555,
+          86.49012635273976,
+          55.45893770831013,
+          45,
+        ]
+      `);
+    });
+
+    it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
+      createThreePointerLinearElement("arrow", "round");
+
+      const arrow = h.elements[0] as ExcalidrawLinearElement;
+
+      const { textElement, container } = createBoundTextElement(
+        DEFAULT_TEXT,
+        arrow,
+      );
+      expect(container.width).toBe(70);
+      expect(container.height).toBe(50);
+      expect(getBoundTextElementPosition(container, textElement))
+        .toMatchInlineSnapshot(`
+        Object {
+          "x": 75,
+          "y": 60,
+        }
+      `);
+      expect(textElement.text).toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+      expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
+        .toMatchInlineSnapshot(`
+        Array [
+          20,
+          20,
+          105,
+          80,
+          55.45893770831013,
+          45,
+        ]
+      `);
+
+      resize(container, "ne", [300, 200]);
+
+      expect({ width: container.width, height: container.height })
+        .toMatchInlineSnapshot(`
+        Object {
+          "height": 10,
+          "width": 367,
+        }
+      `);
+
+      expect(getBoundTextElementPosition(container, textElement))
+        .toMatchInlineSnapshot(`
+        Object {
+          "x": 386.5,
+          "y": 70,
+        }
+      `);
+      expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
+        .toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made easy"
+      `);
+      expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
+        .toMatchInlineSnapshot(`
+        Array [
+          20,
+          60,
+          391.8122896842806,
+          70,
+          205.9061448421403,
+          65,
+        ]
+      `);
+    });
+
+    it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
+      createTwoPointerLinearElement("arrow");
+
+      const arrow = h.elements[0] as ExcalidrawLinearElement;
+      const { textElement, container } = createBoundTextElement(
+        DEFAULT_TEXT,
+        arrow,
+      );
+      expect(container.width).toBe(40);
+      expect(getBoundTextElementPosition(container, textElement))
+        .toMatchInlineSnapshot(`
+        Object {
+          "x": 25,
+          "y": 10,
+        }
+      `);
+      expect(textElement.text).toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+      const points = LinearElementEditor.getPointsGlobalCoordinates(container);
+
+      // Drag from last point
+      drag(points[1], [points[1][0] + 300, points[1][1]]);
+
+      expect({ width: container.width, height: container.height })
+        .toMatchInlineSnapshot(`
+        Object {
+          "height": 0,
+          "width": 340,
+        }
+      `);
+
+      expect(getBoundTextElementPosition(container, textElement))
+        .toMatchInlineSnapshot(`
+        Object {
+          "x": 189.5,
+          "y": 20,
+        }
+      `);
+      expect(textElement.text).toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made easy"
+      `);
+    });
+
+    it("should not render vertical align tool when element selected", () => {
+      createTwoPointerLinearElement("arrow");
+      const arrow = h.elements[0] as ExcalidrawLinearElement;
+
+      createBoundTextElement(DEFAULT_TEXT, arrow);
+      API.setSelectedElements([arrow]);
+
+      expect(queryByTestId(container, "align-top")).toBeNull();
+      expect(queryByTestId(container, "align-middle")).toBeNull();
+      expect(queryByTestId(container, "align-bottom")).toBeNull();
+    });
+
+    it("should wrap the bound text when arrow bound container moves", async () => {
+      const rect = UI.createElement("rectangle", {
+        x: 400,
+        width: 200,
+        height: 500,
+      });
+      const arrow = UI.createElement("arrow", {
+        x: 210,
+        y: 250,
+        width: 400,
+        height: 1,
+      });
+
+      mouse.select(arrow);
+      Keyboard.keyPress(KEYS.ENTER);
+      const editor = document.querySelector(
+        ".excalidraw-textEditorContainer > textarea",
+      ) as HTMLTextAreaElement;
+      await new Promise((r) => setTimeout(r, 0));
+      fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
+      editor.blur();
+
+      const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
+
+      expect(arrow.endBinding?.elementId).toBe(rect.id);
+      expect(arrow.width).toBe(400);
+      expect(rect.x).toBe(400);
+      expect(rect.y).toBe(0);
+      expect(
+        wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
+      ).toMatchInlineSnapshot(`
+        "Online whiteboard collaboration
+        made easy"
+      `);
+      const handleBindTextResizeSpy = jest.spyOn(
+        textElementUtils,
+        "handleBindTextResize",
+      );
+
+      mouse.select(rect);
+      mouse.downAt(rect.x, rect.y);
+      mouse.moveTo(200, 0);
+      mouse.upAt(200, 0);
+
+      expect(arrow.width).toBe(170);
+      expect(rect.x).toBe(200);
+      expect(rect.y).toBe(0);
+      expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+        h.elements[1],
+        false,
+      );
+      expect(
+        wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
+      ).toMatchInlineSnapshot(`
+        "Online whiteboard 
+        collaboration made 
+        easy"
+      `);
+    });
+  });
 });

+ 19 - 0
src/tests/utils.ts

@@ -27,3 +27,22 @@ export const resize = (
     mouse.up();
   });
 };
+
+export const rotate = (
+  element: ExcalidrawElement,
+  deltaX: number,
+  deltaY: number,
+  keyboardModifiers: KeyboardModifiers = {},
+) => {
+  mouse.select(element);
+  const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!;
+  const clientX = handle[0] + handle[2] / 2;
+  const clientY = handle[1] + handle[3] / 2;
+
+  Keyboard.withModifierKeys(keyboardModifiers, () => {
+    mouse.reset();
+    mouse.down(clientX, clientY);
+    mouse.move(clientX + deltaX, clientY + deltaY);
+    mouse.up();
+  });
+};

+ 2 - 3
src/utils.ts

@@ -327,13 +327,12 @@ export const getShortcutKey = (shortcut: string): string => {
     .replace(/\bAlt\b/i, "Alt")
     .replace(/\bShift\b/i, "Shift")
     .replace(/\b(Enter|Return)\b/i, "Enter");
-
   if (isDarwin) {
     return shortcut
-      .replace(/\bCtrlOrCmd\b/i, "Cmd")
+      .replace(/\bCtrlOrCmd\b/gi, "Cmd")
       .replace(/\bAlt\b/i, "Option");
   }
-  return shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl");
+  return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
 };
 
 export const viewportCoordsToSceneCoords = (