Browse Source

do not center text when not applicable (#1783)

David Luzar 4 years ago
parent
commit
cd87bd6901

+ 1 - 1
src/actions/actionProperties.tsx

@@ -20,7 +20,7 @@ import { AppState } from "../../src/types";
 import { t } from "../i18n";
 import { register } from "./register";
 import { newElementWith } from "../element/mutateElement";
-import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../appState";
+import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
 
 const changeProperty = (
   elements: readonly ExcalidrawElement[],

+ 3 - 3
src/actions/actionStyles.ts

@@ -4,13 +4,13 @@ import {
   redrawTextBoundingBox,
 } from "../element";
 import { KEYS } from "../keys";
+import { register } from "./register";
+import { mutateElement, newElementWith } from "../element/mutateElement";
 import {
   DEFAULT_FONT_SIZE,
   DEFAULT_FONT_FAMILY,
   DEFAULT_TEXT_ALIGN,
-} from "../appState";
-import { register } from "./register";
-import { mutateElement, newElementWith } from "../element/mutateElement";
+} from "../constants";
 
 let copiedStyles: string = "{}";
 

+ 5 - 5
src/appState.ts

@@ -2,11 +2,11 @@ import oc from "open-color";
 import { AppState, FlooredNumber } from "./types";
 import { getDateTime } from "./utils";
 import { t } from "./i18n";
-import { FontFamily } from "./element/types";
-
-export const DEFAULT_FONT_SIZE = 20;
-export const DEFAULT_FONT_FAMILY: FontFamily = 1;
-export const DEFAULT_TEXT_ALIGN = "left";
+import {
+  DEFAULT_FONT_SIZE,
+  DEFAULT_FONT_FAMILY,
+  DEFAULT_TEXT_ALIGN,
+} from "./constants";
 
 export const getDefaultAppState = (): AppState => {
   return {

+ 5 - 0
src/charts.ts

@@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./element/types";
 import { newElement, newTextElement } from "./element";
 import { AppState } from "./types";
 import { t } from "./i18n";
+import { DEFAULT_VERTICAL_ALIGN } from "./constants";
 
 interface Spreadsheet {
   yAxisLabel: string | null;
@@ -167,6 +168,7 @@ export function renderSpreadsheet(
     fontSize: 16,
     fontFamily: appState.currentItemFontFamily,
     textAlign: appState.currentItemTextAlign,
+    verticalAlign: DEFAULT_VERTICAL_ALIGN,
   });
 
   const maxYLabel = newTextElement({
@@ -183,6 +185,7 @@ export function renderSpreadsheet(
     fontSize: 16,
     fontFamily: appState.currentItemFontFamily,
     textAlign: appState.currentItemTextAlign,
+    verticalAlign: DEFAULT_VERTICAL_ALIGN,
   });
 
   const bars = spreadsheet.values.map((value, i) => {
@@ -226,6 +229,7 @@ export function renderSpreadsheet(
         fontSize: 16,
         fontFamily: appState.currentItemFontFamily,
         textAlign: "center",
+        verticalAlign: DEFAULT_VERTICAL_ALIGN,
         width: BAR_WIDTH,
         angle: ANGLE,
       });
@@ -246,6 +250,7 @@ export function renderSpreadsheet(
         fontSize: 20,
         fontFamily: appState.currentItemFontFamily,
         textAlign: "center",
+        verticalAlign: DEFAULT_VERTICAL_ALIGN,
         width: BAR_WIDTH,
         angle: ANGLE,
       })

+ 121 - 140
src/components/App.tsx

@@ -27,6 +27,7 @@ import {
   getResizeArrowDirection,
   getResizeHandlerFromCoords,
   isNonDeletedElement,
+  updateTextElement,
   dragSelectedElements,
   getDragOffsetXY,
   dragNewElement,
@@ -55,7 +56,11 @@ import Portal from "./Portal";
 
 import { renderScene } from "../renderer";
 import { AppState, GestureEvent, Gesture } from "../types";
-import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
+import {
+  ExcalidrawElement,
+  ExcalidrawTextElement,
+  NonDeleted,
+} from "../element/types";
 
 import { distance2d, isPathALoop, getGridPoint } from "../math";
 
@@ -113,6 +118,7 @@ import {
   EVENT,
   ENV,
   CANVAS_ONLY_ACTIONS,
+  DEFAULT_VERTICAL_ALIGN,
   GRID_SIZE,
 } from "../constants";
 import {
@@ -583,7 +589,11 @@ class App extends React.Component<any, AppState> {
     if (scrollBars) {
       currentScrollBars = scrollBars;
     }
-    const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
+    const scrolledOutside =
+      // hide when editing text
+      this.state.editingElement?.type === "text"
+        ? false
+        : !atLeastOneVisibleElement && elements.length > 0;
     if (this.state.scrolledOutside !== scrolledOutside) {
       this.setState({ scrolledOutside: scrolledOutside });
     }
@@ -790,6 +800,7 @@ class App extends React.Component<any, AppState> {
       fontSize: this.state.currentItemFontSize,
       fontFamily: this.state.currentItemFontFamily,
       textAlign: this.state.currentItemTextAlign,
+      verticalAlign: DEFAULT_VERTICAL_ALIGN,
     });
 
     globalSceneState.replaceAllElements([
@@ -1250,12 +1261,9 @@ class App extends React.Component<any, AppState> {
         !isLinearElement(selectedElements[0])
       ) {
         const selectedElement = selectedElements[0];
-        const x = selectedElement.x + selectedElement.width / 2;
-        const y = selectedElement.y + selectedElement.height / 2;
-
         this.startTextEditing({
-          x: x,
-          y: y,
+          sceneX: selectedElement.x + selectedElement.width / 2,
+          sceneY: selectedElement.y + selectedElement.height / 2,
         });
         event.preventDefault();
         return;
@@ -1346,10 +1354,10 @@ class App extends React.Component<any, AppState> {
   private handleTextWysiwyg(
     element: ExcalidrawTextElement,
     {
-      x,
-      y,
       isExistingElement = false,
-    }: { x: number; y: number; isExistingElement?: boolean },
+    }: {
+      isExistingElement?: boolean;
+    },
   ) {
     const resetSelection = () => {
       this.setState({
@@ -1358,26 +1366,13 @@ class App extends React.Component<any, AppState> {
       });
     };
 
-    const deleteElement = () => {
-      globalSceneState.replaceAllElements([
-        ...globalSceneState.getElementsIncludingDeleted().map((_element) => {
-          if (_element.id === element.id) {
-            return newElementWith(_element, { isDeleted: true });
-          }
-          return _element;
-        }),
-      ]);
-    };
-
     const updateElement = (text: string) => {
       globalSceneState.replaceAllElements([
         ...globalSceneState.getElementsIncludingDeleted().map((_element) => {
-          if (_element.id === element.id) {
-            return newTextElement({
-              ...(_element as ExcalidrawTextElement),
-              x: element.x,
-              y: element.y,
+          if (_element.id === element.id && isTextElement(_element)) {
+            return updateTextElement(_element, {
               text,
+              isDeleted: !text.trim(),
             });
           }
           return _element;
@@ -1387,22 +1382,18 @@ class App extends React.Component<any, AppState> {
 
     textWysiwyg({
       id: element.id,
-      x,
-      y,
-      initText: element.text,
-      strokeColor: element.strokeColor,
-      opacity: element.opacity,
-      fontSize: element.fontSize,
-      fontFamily: element.fontFamily,
-      angle: element.angle,
-      textAlign: element.textAlign,
       zoom: this.state.zoom,
+      getViewportCoords: (x, y) => {
+        const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
+          { sceneX: x, sceneY: y },
+          this.state,
+          this.canvas,
+          window.devicePixelRatio,
+        );
+        return [viewportX, viewportY];
+      },
       onChange: withBatchedUpdates((text) => {
-        if (text) {
-          updateElement(text);
-        } else {
-          deleteElement();
-        }
+        updateElement(text);
       }),
       onSubmit: withBatchedUpdates((text) => {
         updateElement(text);
@@ -1419,7 +1410,7 @@ class App extends React.Component<any, AppState> {
         resetSelection();
       }),
       onCancel: withBatchedUpdates(() => {
-        deleteElement();
+        updateElement("");
         if (isExistingElement) {
           history.resumeRecording();
         }
@@ -1438,20 +1429,11 @@ class App extends React.Component<any, AppState> {
     updateElement(element.text);
   }
 
-  private startTextEditing = ({
-    x,
-    y,
-    clientX,
-    clientY,
-    centerIfPossible = true,
-  }: {
-    x: number;
-    y: number;
-    clientX?: number;
-    clientY?: number;
-    centerIfPossible?: boolean;
-  }) => {
-    const elementAtPosition = getElementAtPosition(
+  private getTextElementAtPosition(
+    x: number,
+    y: number,
+  ): NonDeleted<ExcalidrawTextElement> | null {
+    const element = getElementAtPosition(
       globalSceneState.getElements(),
       this.state,
       x,
@@ -1459,78 +1441,83 @@ class App extends React.Component<any, AppState> {
       this.state.zoom,
     );
 
-    const element =
-      elementAtPosition && isTextElement(elementAtPosition)
-        ? elementAtPosition
-        : newTextElement({
-            x: x,
-            y: y,
-            strokeColor: this.state.currentItemStrokeColor,
-            backgroundColor: this.state.currentItemBackgroundColor,
-            fillStyle: this.state.currentItemFillStyle,
-            strokeWidth: this.state.currentItemStrokeWidth,
-            strokeStyle: this.state.currentItemStrokeStyle,
-            roughness: this.state.currentItemRoughness,
-            opacity: this.state.currentItemOpacity,
-            text: "",
-            fontSize: this.state.currentItemFontSize,
-            fontFamily: this.state.currentItemFontFamily,
-            textAlign: this.state.currentItemTextAlign,
-          });
-
-    this.setState({ editingElement: element });
-
-    let textX = clientX || x;
-    let textY = clientY || y;
-
-    let isExistingTextElement = false;
+    if (element && isTextElement(element) && !element.isDeleted) {
+      return element;
+    }
+    return null;
+  }
 
-    if (elementAtPosition && isTextElement(elementAtPosition)) {
-      isExistingTextElement = true;
-      const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
-      const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
+  private startTextEditing = ({
+    sceneX,
+    sceneY,
+    insertAtParentCenter = true,
+  }: {
+    /** X position to insert text at */
+    sceneX: number;
+    /** Y position to insert text at */
+    sceneY: number;
+    /** whether to attempt to insert at element center if applicable */
+    insertAtParentCenter?: boolean;
+  }) => {
+    const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
 
-      const {
-        x: centerElementXInViewport,
-        y: centerElementYInViewport,
-      } = sceneCoordsToViewportCoords(
-        { sceneX: centerElementX, sceneY: centerElementY },
+    const parentCenterPosition =
+      insertAtParentCenter &&
+      this.getTextWysiwygSnappedToCenterPosition(
+        sceneX,
+        sceneY,
         this.state,
         this.canvas,
         window.devicePixelRatio,
       );
 
-      textX = centerElementXInViewport;
-      textY = centerElementYInViewport;
+    const element = existingTextElement
+      ? existingTextElement
+      : newTextElement({
+          x: parentCenterPosition
+            ? parentCenterPosition.elementCenterX
+            : sceneX,
+          y: parentCenterPosition
+            ? parentCenterPosition.elementCenterY
+            : sceneY,
+          strokeColor: this.state.currentItemStrokeColor,
+          backgroundColor: this.state.currentItemBackgroundColor,
+          fillStyle: this.state.currentItemFillStyle,
+          strokeWidth: this.state.currentItemStrokeWidth,
+          strokeStyle: this.state.currentItemStrokeStyle,
+          roughness: this.state.currentItemRoughness,
+          opacity: this.state.currentItemOpacity,
+          text: "",
+          fontSize: this.state.currentItemFontSize,
+          fontFamily: this.state.currentItemFontFamily,
+          textAlign: parentCenterPosition
+            ? "center"
+            : this.state.currentItemTextAlign,
+          verticalAlign: parentCenterPosition
+            ? "middle"
+            : DEFAULT_VERTICAL_ALIGN,
+        });
 
-      // x and y will change after calling newTextElement function
-      mutateElement(element, {
-        x: centerElementX,
-        y: centerElementY,
-      });
+    this.setState({ editingElement: element });
+
+    if (existingTextElement) {
+      // if text element is no longer centered to a container, reset
+      //  verticalAlign to default because it's currently internal-only
+      if (!parentCenterPosition || element.textAlign !== "center") {
+        mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN });
+      }
     } else {
       globalSceneState.replaceAllElements([
         ...globalSceneState.getElementsIncludingDeleted(),
         element,
       ]);
 
-      if (centerIfPossible) {
-        const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
-          x,
-          y,
-          this.state,
-          this.canvas,
-          window.devicePixelRatio,
-        );
-
-        if (snappedToCenterPosition) {
-          mutateElement(element, {
-            x: snappedToCenterPosition.elementCenterX,
-            y: snappedToCenterPosition.elementCenterY,
-          });
-          textX = snappedToCenterPosition.wysiwygX;
-          textY = snappedToCenterPosition.wysiwygY;
-        }
+      // case: creating new text not centered to parent elemenent → offset Y
+      //  so that the text is centered to cursor position
+      if (!parentCenterPosition) {
+        mutateElement(element, {
+          y: element.y - element.baseline / 2,
+        });
       }
     }
 
@@ -1539,9 +1526,7 @@ class App extends React.Component<any, AppState> {
     });
 
     this.handleTextWysiwyg(element, {
-      x: textX,
-      y: textY,
-      isExistingElement: isExistingTextElement,
+      isExistingElement: !!existingTextElement,
     });
   };
 
@@ -1574,7 +1559,7 @@ class App extends React.Component<any, AppState> {
 
     resetCursor();
 
-    const { x, y } = viewportCoordsToSceneCoords(
+    const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
       event,
       this.state,
       this.canvas,
@@ -1588,8 +1573,8 @@ class App extends React.Component<any, AppState> {
       const hitElement = getElementAtPosition(
         elements,
         this.state,
-        x,
-        y,
+        sceneX,
+        sceneY,
         this.state.zoom,
       );
 
@@ -1616,11 +1601,9 @@ class App extends React.Component<any, AppState> {
     resetCursor();
 
     this.startTextEditing({
-      x: x,
-      y: y,
-      clientX: event.clientX,
-      clientY: event.clientY,
-      centerIfPossible: !event.altKey,
+      sceneX,
+      sceneY,
+      insertAtParentCenter: !event.altKey,
     });
   };
 
@@ -2213,19 +2196,10 @@ class App extends React.Component<any, AppState> {
         return;
       }
 
-      const { x, y } = viewportCoordsToSceneCoords(
-        event,
-        this.state,
-        this.canvas,
-        window.devicePixelRatio,
-      );
-
       this.startTextEditing({
-        x: x,
-        y: y,
-        clientX: event.clientX,
-        clientY: event.clientY,
-        centerIfPossible: !event.altKey,
+        sceneX: x,
+        sceneY: y,
+        insertAtParentCenter: !event.altKey,
       });
 
       resetCursor();
@@ -2640,7 +2614,12 @@ class App extends React.Component<any, AppState> {
         resizingElement: null,
         selectionElement: null,
         cursorButton: "up",
-        editingElement: multiElement ? this.state.editingElement : null,
+        // text elements are reset on finalize, and resetting on pointerup
+        //  may cause issues with double taps
+        editingElement:
+          multiElement || isTextElement(this.state.editingElement)
+            ? this.state.editingElement
+            : null,
       });
 
       this.savePointer(childEvent.clientX, childEvent.clientY, "up");
@@ -3006,7 +2985,9 @@ class App extends React.Component<any, AppState> {
     scale: number,
   ) {
     const elementClickedInside = getElementContainingPosition(
-      globalSceneState.getElementsIncludingDeleted(),
+      globalSceneState
+        .getElementsIncludingDeleted()
+        .filter((element) => !isTextElement(element)),
       x,
       y,
     );
@@ -3022,13 +3003,13 @@ class App extends React.Component<any, AppState> {
       const isSnappedToCenter =
         distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
       if (isSnappedToCenter) {
-        const { x: wysiwygX, y: wysiwygY } = sceneCoordsToViewportCoords(
+        const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
           { sceneX: elementCenterX, sceneY: elementCenterY },
           state,
           canvas,
           scale,
         );
-        return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
+        return { viewportX, viewportY, elementCenterX, elementCenterY };
       }
     }
   }

+ 7 - 0
src/constants.ts

@@ -1,3 +1,5 @@
+import { FontFamily } from "./element/types";
+
 export const DRAGGING_THRESHOLD = 10; // 10px
 export const LINE_CONFIRM_THRESHOLD = 10; // 10px
 export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
@@ -67,6 +69,11 @@ export const FONT_FAMILY = {
   3: "Cascadia",
 } as const;
 
+export const DEFAULT_FONT_SIZE = 20;
+export const DEFAULT_FONT_FAMILY: FontFamily = 1;
+export const DEFAULT_TEXT_ALIGN = "left";
+export const DEFAULT_VERTICAL_ALIGN = "top";
+
 export const CANVAS_ONLY_ACTIONS = ["selectAll"];
 
 export const GRID_SIZE = 20; // TODO make it configurable?

+ 8 - 3
src/data/restore.ts

@@ -8,8 +8,12 @@ import { DataState } from "./types";
 import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
 import { calculateScrollCenter } from "../scene";
 import { randomId } from "../random";
-import { DEFAULT_TEXT_ALIGN, DEFAULT_FONT_FAMILY } from "../appState";
-import { FONT_FAMILY } from "../constants";
+import {
+  FONT_FAMILY,
+  DEFAULT_FONT_FAMILY,
+  DEFAULT_TEXT_ALIGN,
+  DEFAULT_VERTICAL_ALIGN,
+} from "../constants";
 
 const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
   for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
@@ -75,7 +79,8 @@ const migrateElement = (
         fontFamily,
         text: element.text ?? "",
         baseline: element.baseline,
-        textAlign: element.textAlign ?? DEFAULT_TEXT_ALIGN,
+        textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
+        verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
       });
     case "draw":
     case "line":

+ 1 - 0
src/element/index.ts

@@ -8,6 +8,7 @@ import { isInvisiblySmallElement } from "./sizeHelpers";
 export {
   newElement,
   newTextElement,
+  updateTextElement,
   newLinearElement,
   duplicateElement,
 } from "./newElement";

+ 1 - 0
src/element/newElement.test.ts

@@ -81,6 +81,7 @@ it("clones text element", () => {
     fontSize: 20,
     fontFamily: 1,
     textAlign: "left",
+    verticalAlign: "top",
   });
 
   const copy = duplicateElement(null, new Map(), element);

+ 109 - 3
src/element/newElement.ts

@@ -7,12 +7,16 @@ import {
   TextAlign,
   FontFamily,
   GroupId,
+  VerticalAlign,
 } from "../element/types";
 import { measureText, getFontString } from "../utils";
 import { randomInteger, randomId } from "../random";
 import { newElementWith } from "./mutateElement";
 import { getNewGroupIdsForDuplication } from "../groups";
 import { AppState } from "../types";
+import { getElementAbsoluteCoords } from ".";
+import { adjustXYWithRotation } from "../math";
+import { getResizedElementAbsoluteCoords } from "./bounds";
 
 type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
@@ -72,15 +76,39 @@ export const newElement = (
 ): NonDeleted<ExcalidrawGenericElement> =>
   _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
 
+/** computes element x/y offset based on textAlign/verticalAlign */
+function getTextElementPositionOffsets(
+  opts: {
+    textAlign: ExcalidrawTextElement["textAlign"];
+    verticalAlign: ExcalidrawTextElement["verticalAlign"];
+  },
+  metrics: {
+    width: number;
+    height: number;
+  },
+) {
+  return {
+    x:
+      opts.textAlign === "center"
+        ? metrics.width / 2
+        : opts.textAlign === "right"
+        ? metrics.width
+        : 0,
+    y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
+  };
+}
+
 export const newTextElement = (
   opts: {
     text: string;
     fontSize: number;
     fontFamily: FontFamily;
     textAlign: TextAlign;
+    verticalAlign: VerticalAlign;
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawTextElement> => {
   const metrics = measureText(opts.text, getFontString(opts));
+  const offsets = getTextElementPositionOffsets(opts, metrics);
   const textElement = newElementWith(
     {
       ..._newElementBase<ExcalidrawTextElement>("text", opts),
@@ -88,9 +116,9 @@ export const newTextElement = (
       fontSize: opts.fontSize,
       fontFamily: opts.fontFamily,
       textAlign: opts.textAlign,
-      // Center the text
-      x: opts.x - metrics.width / 2,
-      y: opts.y - metrics.height / 2,
+      verticalAlign: opts.verticalAlign,
+      x: opts.x - offsets.x,
+      y: opts.y - offsets.y,
       width: metrics.width,
       height: metrics.height,
       baseline: metrics.baseline,
@@ -101,6 +129,84 @@ export const newTextElement = (
   return textElement;
 };
 
+const getAdjustedDimensions = (
+  element: ExcalidrawTextElement,
+  nextText: string,
+): {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  baseline: number;
+} => {
+  const {
+    width: nextWidth,
+    height: nextHeight,
+    baseline: nextBaseline,
+  } = measureText(nextText, getFontString(element));
+
+  const { textAlign, verticalAlign } = element;
+
+  let x, y;
+
+  if (textAlign === "center" && verticalAlign === "middle") {
+    const prevMetrics = measureText(element.text, getFontString(element));
+    const offsets = getTextElementPositionOffsets(element, {
+      width: nextWidth - prevMetrics.width,
+      height: nextHeight - prevMetrics.height,
+    });
+
+    x = element.x - offsets.x;
+    y = element.y - offsets.y;
+  } else {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+
+    const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
+      element,
+      nextWidth,
+      nextHeight,
+    );
+    const deltaX1 = (x1 - nextX1) / 2;
+    const deltaY1 = (y1 - nextY1) / 2;
+    const deltaX2 = (x2 - nextX2) / 2;
+    const deltaY2 = (y2 - nextY2) / 2;
+
+    [x, y] = adjustXYWithRotation(
+      {
+        s: true,
+        e: textAlign === "center" || textAlign === "left",
+        w: textAlign === "center" || textAlign === "right",
+      },
+      element.x,
+      element.y,
+      element.angle,
+      deltaX1,
+      deltaY1,
+      deltaX2,
+      deltaY2,
+    );
+  }
+
+  return {
+    width: nextWidth,
+    height: nextHeight,
+    x: Number.isFinite(x) ? x : element.x,
+    y: Number.isFinite(y) ? y : element.y,
+    baseline: nextBaseline,
+  };
+};
+
+export const updateTextElement = (
+  element: ExcalidrawTextElement,
+  { text, isDeleted }: { text: string; isDeleted?: boolean },
+): ExcalidrawTextElement => {
+  return newElementWith(element, {
+    text,
+    isDeleted: isDeleted ?? element.isDeleted,
+    ...getAdjustedDimensions(element, text),
+  });
+};
+
 export const newLinearElement = (
   opts: {
     type: ExcalidrawLinearElement["type"];

+ 22 - 4
src/element/resizeElements.ts

@@ -248,6 +248,26 @@ const measureFontSizeFromWH = (
   return null;
 };
 
+const getSidesForResizeHandle = (
+  resizeHandle: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
+  isResizeFromCenter: boolean,
+) => {
+  return {
+    n:
+      /^(n|ne|nw)$/.test(resizeHandle) ||
+      (isResizeFromCenter && /^(s|se|sw)$/.test(resizeHandle)),
+    s:
+      /^(s|se|sw)$/.test(resizeHandle) ||
+      (isResizeFromCenter && /^(n|ne|nw)$/.test(resizeHandle)),
+    w:
+      /^(w|nw|sw)$/.test(resizeHandle) ||
+      (isResizeFromCenter && /^(e|ne|se)$/.test(resizeHandle)),
+    e:
+      /^(e|ne|se)$/.test(resizeHandle) ||
+      (isResizeFromCenter && /^(w|nw|sw)$/.test(resizeHandle)),
+  };
+};
+
 const resizeSingleTextElement = (
   element: NonDeleted<ExcalidrawTextElement>,
   resizeHandle: "nw" | "ne" | "sw" | "se",
@@ -310,7 +330,7 @@ const resizeSingleTextElement = (
     const deltaX2 = (x2 - nextX2) / 2;
     const deltaY2 = (y2 - nextY2) / 2;
     const [nextElementX, nextElementY] = adjustXYWithRotation(
-      resizeHandle,
+      getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
       element.x,
       element.y,
       element.angle,
@@ -318,7 +338,6 @@ const resizeSingleTextElement = (
       deltaY1,
       deltaX2,
       deltaY2,
-      isResizeFromCenter,
     );
     mutateElement(element, {
       fontSize: nextFont.size,
@@ -403,7 +422,7 @@ const resizeSingleElement = (
     element.angle,
   );
   const [nextElementX, nextElementY] = adjustXYWithRotation(
-    resizeHandle,
+    getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
     element.x - flipDiffX,
     element.y - flipDiffY,
     element.angle,
@@ -411,7 +430,6 @@ const resizeSingleElement = (
     deltaY1,
     deltaX2,
     deltaY2,
-    isResizeFromCenter,
   );
   if (
     nextWidth !== 0 &&

+ 87 - 99
src/element/textWysiwyg.tsx

@@ -1,116 +1,111 @@
 import { KEYS } from "../keys";
-import { selectNode, isWritableElement, getFontString } from "../utils";
+import { isWritableElement, getFontString } from "../utils";
 import { globalSceneState } from "../scene";
 import { isTextElement } from "./typeChecks";
 import { CLASSES } from "../constants";
-import { FontFamily } from "./types";
-
-const trimText = (text: string) => {
-  // whitespace only → trim all because we'd end up inserting invisible element
-  if (!text.trim()) {
-    return "";
-  }
-  // replace leading/trailing newlines (only) otherwise it messes up bounding
-  //  box calculation (there's also a bug in FF which inserts trailing newline
-  //  for multiline texts)
-  return text.replace(/^\n+|\n+$/g, "");
+import { ExcalidrawElement } from "./types";
+
+const normalizeText = (text: string) => {
+  return (
+    text
+      // replace tabs with spaces so they render and measure correctly
+      .replace(/\t/g, "        ")
+      // normalize newlines
+      .replace(/\r?\n|\r/g, "\n")
+  );
 };
 
-type TextWysiwygParams = {
-  id: string;
-  initText: string;
-  x: number;
-  y: number;
-  strokeColor: string;
-  fontSize: number;
-  fontFamily: FontFamily;
-  opacity: number;
-  zoom: number;
-  angle: number;
-  textAlign: string;
-  onChange?: (text: string) => void;
-  onSubmit: (text: string) => void;
-  onCancel: () => void;
+const getTransform = (
+  width: number,
+  height: number,
+  angle: number,
+  zoom: number,
+) => {
+  const degree = (180 * angle) / Math.PI;
+  return `translate(${(width * (zoom - 1)) / 2}px, ${
+    (height * (zoom - 1)) / 2
+  }px) scale(${zoom}) rotate(${degree}deg)`;
 };
 
 export const textWysiwyg = ({
   id,
-  initText,
-  x,
-  y,
-  strokeColor,
-  fontSize,
-  fontFamily,
-  opacity,
   zoom,
-  angle,
   onChange,
-  textAlign,
   onSubmit,
   onCancel,
-}: TextWysiwygParams) => {
-  const editable = document.createElement("div");
-  try {
-    editable.contentEditable = "plaintext-only";
-  } catch {
-    editable.contentEditable = "true";
+  getViewportCoords,
+}: {
+  id: ExcalidrawElement["id"];
+  zoom: number;
+  onChange?: (text: string) => void;
+  onSubmit: (text: string) => void;
+  onCancel: () => void;
+  getViewportCoords: (x: number, y: number) => [number, number];
+}) => {
+  function updateWysiwygStyle() {
+    const updatedElement = globalSceneState.getElement(id);
+    if (isTextElement(updatedElement)) {
+      const [viewportX, viewportY] = getViewportCoords(
+        updatedElement.x,
+        updatedElement.y,
+      );
+      const { textAlign, angle } = updatedElement;
+
+      editable.value = updatedElement.text;
+
+      const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
+      const lineHeight = updatedElement.height / lines.length;
+
+      Object.assign(editable.style, {
+        font: getFontString(updatedElement),
+        // must be defined *after* font ¯\_(ツ)_/¯
+        lineHeight: `${lineHeight}px`,
+        width: `${updatedElement.width}px`,
+        height: `${updatedElement.height}px`,
+        left: `${viewportX}px`,
+        top: `${viewportY}px`,
+        transform: getTransform(
+          updatedElement.width,
+          updatedElement.height,
+          angle,
+          zoom,
+        ),
+        textAlign: textAlign,
+        color: updatedElement.strokeColor,
+        opacity: updatedElement.opacity / 100,
+      });
+    }
   }
+
+  const editable = document.createElement("textarea");
+
   editable.dir = "auto";
   editable.tabIndex = 0;
-  editable.innerText = initText;
   editable.dataset.type = "wysiwyg";
-
-  const degree = (180 * angle) / Math.PI;
+  // prevent line wrapping on Safari
+  editable.wrap = "off";
 
   Object.assign(editable.style, {
-    color: strokeColor,
     position: "fixed",
-    opacity: opacity / 100,
-    top: `${y}px`,
-    left: `${x}px`,
-    transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`,
-    textAlign: textAlign,
     display: "inline-block",
-    font: getFontString({ fontSize, fontFamily }),
-    padding: "4px",
-    // This needs to have "1px solid" otherwise the carret doesn't show up
-    // the first time on Safari and Chrome!
-    outline: "1px solid transparent",
-    whiteSpace: "nowrap",
     minHeight: "1em",
     backfaceVisibility: "hidden",
+    margin: 0,
+    padding: 0,
+    border: 0,
+    outline: 0,
+    resize: "none",
+    background: "transparent",
+    overflow: "hidden",
+    // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
+    whiteSpace: "pre",
   });
 
-  editable.onpaste = (event) => {
-    try {
-      const selection = window.getSelection();
-      if (!selection?.rangeCount) {
-        return;
-      }
-      selection.deleteFromDocument();
-
-      const text = event.clipboardData!.getData("text").replace(/\r\n?/g, "\n");
-
-      const span = document.createElement("span");
-      span.innerText = text;
-      const range = selection.getRangeAt(0);
-      range.insertNode(span);
-
-      // deselect
-      window.getSelection()!.removeAllRanges();
-      range.setStart(span, span.childNodes.length);
-      range.setEnd(span, span.childNodes.length);
-      selection.addRange(range);
-
-      event.preventDefault();
-    } catch (error) {
-      console.error(error);
-    }
-  };
+  updateWysiwygStyle();
 
   if (onChange) {
     editable.oninput = () => {
-      onChange(trimText(editable.innerText));
+      onChange(normalizeText(editable.value));
     };
   }
 
@@ -134,8 +129,8 @@ export const textWysiwyg = ({
   };
 
   const handleSubmit = () => {
-    if (editable.innerText) {
-      onSubmit(trimText(editable.innerText));
+    if (editable.value) {
+      onSubmit(normalizeText(editable.value));
     } else {
       onCancel();
     }
@@ -149,10 +144,10 @@ export const textWysiwyg = ({
     isDestroyed = true;
     // remove events to ensure they don't late-fire
     editable.onblur = null;
-    editable.onpaste = null;
     editable.oninput = null;
     editable.onkeydown = null;
 
+    window.removeEventListener("resize", updateWysiwygStyle);
     window.removeEventListener("wheel", stopEvent, true);
     window.removeEventListener("pointerdown", onPointerDown);
     window.removeEventListener("pointerup", rebindBlur);
@@ -191,26 +186,19 @@ export const textWysiwyg = ({
 
   // handle updates of textElement properties of editing element
   const unbindUpdate = globalSceneState.addCallback(() => {
-    const editingElement = globalSceneState
-      .getElementsIncludingDeleted()
-      .find((element) => element.id === id);
-    if (editingElement && isTextElement(editingElement)) {
-      Object.assign(editable.style, {
-        font: getFontString(editingElement),
-        textAlign: editingElement.textAlign,
-        color: editingElement.strokeColor,
-        opacity: editingElement.opacity / 100,
-      });
-    }
+    updateWysiwygStyle();
     editable.focus();
   });
 
   let isDestroyed = false;
 
   editable.onblur = handleSubmit;
+  // reposition wysiwyg in case of window resize. Happens on mobile when
+  //  device keyboard is opened.
+  window.addEventListener("resize", updateWysiwygStyle);
   window.addEventListener("pointerdown", onPointerDown);
   window.addEventListener("wheel", stopEvent, true);
   document.body.appendChild(editable);
   editable.focus();
-  selectNode(editable);
+  editable.select();
 };

+ 2 - 0
src/element/types.ts

@@ -60,6 +60,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
     text: string;
     baseline: number;
     textAlign: TextAlign;
+    verticalAlign: VerticalAlign;
   }>;
 
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
@@ -72,6 +73,7 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
 export type PointerType = "mouse" | "pen" | "touch";
 
 export type TextAlign = "left" | "center" | "right";
+export type VerticalAlign = "top" | "middle";
 
 export type FontFamily = keyof typeof FONT_FAMILY;
 export type FontString = string & { _brand: "fontString" };

+ 31 - 40
src/math.ts

@@ -57,7 +57,12 @@ export const rotate = (
   ];
 
 export const adjustXYWithRotation = (
-  side: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
+  sides: {
+    n?: boolean;
+    e?: boolean;
+    s?: boolean;
+    w?: boolean;
+  },
   x: number,
   y: number,
   angle: number,
@@ -65,49 +70,35 @@ export const adjustXYWithRotation = (
   deltaY1: number,
   deltaX2: number,
   deltaY2: number,
-  isResizeFromCenter: boolean,
 ): [number, number] => {
   const cos = Math.cos(angle);
   const sin = Math.sin(angle);
-  if (side === "e" || side === "ne" || side === "se") {
-    if (isResizeFromCenter) {
-      x += deltaX1 + deltaX2;
-    } else {
-      x += deltaX1 * (1 + cos);
-      y += deltaX1 * sin;
-      x += deltaX2 * (1 - cos);
-      y += deltaX2 * -sin;
-    }
-  }
-  if (side === "s" || side === "sw" || side === "se") {
-    if (isResizeFromCenter) {
-      y += deltaY1 + deltaY2;
-    } else {
-      x += deltaY1 * -sin;
-      y += deltaY1 * (1 + cos);
-      x += deltaY2 * sin;
-      y += deltaY2 * (1 - cos);
-    }
+  if (sides.e && sides.w) {
+    x += deltaX1 + deltaX2;
+  } else if (sides.e) {
+    x += deltaX1 * (1 + cos);
+    y += deltaX1 * sin;
+    x += deltaX2 * (1 - cos);
+    y += deltaX2 * -sin;
+  } else if (sides.w) {
+    x += deltaX1 * (1 - cos);
+    y += deltaX1 * -sin;
+    x += deltaX2 * (1 + cos);
+    y += deltaX2 * sin;
   }
-  if (side === "w" || side === "nw" || side === "sw") {
-    if (isResizeFromCenter) {
-      x += deltaX1 + deltaX2;
-    } else {
-      x += deltaX1 * (1 - cos);
-      y += deltaX1 * -sin;
-      x += deltaX2 * (1 + cos);
-      y += deltaX2 * sin;
-    }
-  }
-  if (side === "n" || side === "nw" || side === "ne") {
-    if (isResizeFromCenter) {
-      y += deltaY1 + deltaY2;
-    } else {
-      x += deltaY1 * sin;
-      y += deltaY1 * (1 - cos);
-      x += deltaY2 * -sin;
-      y += deltaY2 * (1 + cos);
-    }
+
+  if (sides.n && sides.s) {
+    y += deltaY1 + deltaY2;
+  } else if (sides.n) {
+    x += deltaY1 * sin;
+    y += deltaY1 * (1 - cos);
+    x += deltaY2 * -sin;
+    y += deltaY2 * (1 + cos);
+  } else if (sides.s) {
+    x += deltaY1 * -sin;
+    y += deltaY1 * (1 + cos);
+    x += deltaY2 * sin;
+    y += deltaY2 * (1 - cos);
   }
   return [x, y];
 };

+ 8 - 15
src/scene/export.ts

@@ -4,11 +4,11 @@ import { newTextElement } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { getCommonBounds } from "../element/bounds";
 import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
-import { distance, SVG_NS, measureText, getFontString } from "../utils";
+import { distance, SVG_NS } from "../utils";
 import { normalizeScroll } from "./scroll";
 import { AppState } from "../types";
 import { t } from "../i18n";
-import { DEFAULT_FONT_FAMILY } from "../appState";
+import { DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_ALIGN } from "../constants";
 
 export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
 
@@ -150,20 +150,13 @@ export const exportToSvg = (
 };
 
 const getWatermarkElement = (maxX: number, maxY: number) => {
-  const text = t("labels.madeWithExcalidraw");
-  const fontSize = 16;
-  const fontFamily = DEFAULT_FONT_FAMILY;
-  const { width: textWidth } = measureText(
-    text,
-    getFontString({ fontSize, fontFamily }),
-  );
-
   return newTextElement({
-    text,
-    fontSize,
-    fontFamily,
-    textAlign: "center",
-    x: maxX - textWidth / 2,
+    text: t("labels.madeWithExcalidraw"),
+    fontSize: 16,
+    fontFamily: DEFAULT_FONT_FAMILY,
+    textAlign: "right",
+    verticalAlign: DEFAULT_VERTICAL_ALIGN,
+    x: maxX,
     y: maxY + 16,
     strokeColor: oc.gray[5],
     backgroundColor: "transparent",

+ 8 - 9
src/utils.ts

@@ -88,8 +88,12 @@ export const measureText = (text: string, font: FontString) => {
   line.style.whiteSpace = "pre";
   line.style.font = font;
   body.appendChild(line);
-  // Now we can measure width and height of the letter
-  line.innerText = text;
+  line.innerText = text
+    .split("\n")
+    // replace empty lines with single space because leading/trailing empty
+    //  lines would be stripped from computation
+    .map((x) => x || " ")
+    .join("\n");
   const width = line.offsetWidth;
   const height = line.offsetHeight;
   // Now creating 1px sized item that will be aligned to baseline
@@ -214,13 +218,8 @@ export const sceneCoordsToViewportCoords = (
   scale: number,
 ) => {
   const zoomOrigin = getZoomOrigin(canvas, scale);
-  const sceneXWithZoomAndScroll =
-    zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
-  const sceneYWithZoomAndScroll =
-    zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
-
-  const x = sceneXWithZoomAndScroll;
-  const y = sceneYWithZoomAndScroll;
+  const x = zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
+  const y = zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
 
   return { x, y };
 };