Browse Source

feat: improve text measurements in bound containers (#6187)

* feat: move to canvas measureText

* calcualte height with better heuristic

* improve heuristic more

* remove vertical offset as its not needed

* lint

* calculate width of individual char and ceil to calculate width and remove adjustment factor

* push the word if equal to max width

* update height when text overflows for vertical alignment top/bottom

* remove the hack of updating height when line mismatch as its not needed

* remove scroll height and calculate the height instead

* remove unused code

* fix

* remove

* use math.ceil for whole width instead of individual chars

* fix tests

* fix

* fix

* redraw text bounding box instead when font loaded to fix alignment as well

* fix

* fix

* fix

* Add a 0.05px extra only for firefox

* Add spec

* stop taking ceil and increase firefox editor width by 0.05px

* Ad 0.05px in safari too

* lint

* lint

* remove baseline from measureFontSizeFromWH

* don't redraw on font load

* lint

* refactor name and signature
Aakansha Doshi 2 years ago
parent
commit
9659254fd6

+ 1 - 2
src/actions/actionBoundText.tsx

@@ -38,7 +38,7 @@ export const actionUnbindText = register({
     selectedElements.forEach((element) => {
       const boundTextElement = getBoundTextElement(element);
       if (boundTextElement) {
-        const { width, height, baseline } = measureText(
+        const { width, height } = measureText(
           boundTextElement.originalText,
           getFontString(boundTextElement),
         );
@@ -51,7 +51,6 @@ export const actionUnbindText = register({
           containerId: null,
           width,
           height,
-          baseline,
           text: boundTextElement.originalText,
         });
         mutateElement(element, {

+ 0 - 8
src/components/App.tsx

@@ -2674,14 +2674,6 @@ class App extends React.Component<AppProps, AppState> {
           element,
         ]);
       }
-
-      // case: creating new text not centered to parent element → offset Y
-      // so that the text is centered to cursor position
-      if (!parentCenterPosition) {
-        mutateElement(element, {
-          y: element.y - element.baseline / 2,
-        });
-      }
     }
 
     this.setState({

+ 3 - 0
src/constants.ts

@@ -9,6 +9,9 @@ export const isFirefox =
   "netscape" in window &&
   navigator.userAgent.indexOf("rv:") > 1 &&
   navigator.userAgent.indexOf("Gecko") > 1;
+export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
+export const isSafari =
+  !isChrome && navigator.userAgent.indexOf("Safari") !== -1;
 
 export const APP_NAME = "Excalidraw";
 

+ 0 - 1
src/data/restore.ts

@@ -171,7 +171,6 @@ const restoreElement = (
         fontSize,
         fontFamily,
         text: element.text ?? "",
-        baseline: element.baseline,
         textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
         verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
         containerId: element.containerId ?? null,

+ 6 - 17
src/element/newElement.ts

@@ -153,7 +153,6 @@ export const newTextElement = (
       y: opts.y - offsets.y,
       width: metrics.width,
       height: metrics.height,
-      baseline: metrics.baseline,
       containerId: opts.containerId || null,
       originalText: text,
     },
@@ -170,18 +169,13 @@ const getAdjustedDimensions = (
   y: number;
   width: number;
   height: number;
-  baseline: number;
 } => {
-  let maxWidth = null;
   const container = getContainerElement(element);
-  if (container) {
-    maxWidth = getMaxContainerWidth(container);
-  }
-  const {
-    width: nextWidth,
-    height: nextHeight,
-    baseline: nextBaseline,
-  } = measureText(nextText, getFontString(element), maxWidth);
+
+  const { width: nextWidth, height: nextHeight } = measureText(
+    nextText,
+    getFontString(element),
+  );
   const { textAlign, verticalAlign } = element;
   let x: number;
   let y: number;
@@ -190,11 +184,7 @@ const getAdjustedDimensions = (
     verticalAlign === VERTICAL_ALIGN.MIDDLE &&
     !element.containerId
   ) {
-    const prevMetrics = measureText(
-      element.text,
-      getFontString(element),
-      maxWidth,
-    );
+    const prevMetrics = measureText(element.text, getFontString(element));
     const offsets = getTextElementPositionOffsets(element, {
       width: nextWidth - prevMetrics.width,
       height: nextHeight - prevMetrics.height,
@@ -258,7 +248,6 @@ const getAdjustedDimensions = (
     height: nextHeight,
     x: Number.isFinite(x) ? x : element.x,
     y: Number.isFinite(y) ? y : element.y,
-    baseline: nextBaseline,
   };
 };
 

+ 16 - 34
src/element/resizeElements.ts

@@ -45,8 +45,6 @@ import {
   getBoundTextElementId,
   getContainerElement,
   handleBindTextResize,
-  measureText,
-  getMaxContainerHeight,
   getMaxContainerWidth,
 } from "./textElement";
 
@@ -192,11 +190,10 @@ const rescalePointsInElement = (
 
 const MIN_FONT_SIZE = 1;
 
-const measureFontSizeFromWH = (
+const measureFontSizeFromWidth = (
   element: NonDeleted<ExcalidrawTextElement>,
   nextWidth: number,
-  nextHeight: number,
-): { size: number; baseline: number } | null => {
+): number | null => {
   // We only use width to scale font on resize
   let width = element.width;
 
@@ -211,15 +208,8 @@ const measureFontSizeFromWH = (
   if (nextFontSize < MIN_FONT_SIZE) {
     return null;
   }
-  const metrics = measureText(
-    element.text,
-    getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
-    element.containerId ? width : null,
-  );
-  return {
-    size: nextFontSize,
-    baseline: metrics.baseline + (nextHeight - metrics.height),
-  };
+
+  return nextFontSize;
 };
 
 const getSidesForTransformHandle = (
@@ -290,8 +280,8 @@ const resizeSingleTextElement = (
   if (scale > 0) {
     const nextWidth = element.width * scale;
     const nextHeight = element.height * scale;
-    const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
-    if (nextFont === null) {
+    const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
+    if (nextFontSize === null) {
       return;
     }
     const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@@ -315,10 +305,9 @@ const resizeSingleTextElement = (
       deltaY2,
     );
     mutateElement(element, {
-      fontSize: nextFont.size,
+      fontSize: nextFontSize,
       width: nextWidth,
       height: nextHeight,
-      baseline: nextFont.baseline,
       x: nextElementX,
       y: nextElementY,
     });
@@ -371,7 +360,7 @@ export const resizeSingleElement = (
   let scaleX = atStartBoundsWidth / boundsCurrentWidth;
   let scaleY = atStartBoundsHeight / boundsCurrentHeight;
 
-  let boundTextFont: { fontSize?: number; baseline?: number } = {};
+  let boundTextFont: { fontSize?: number } = {};
   const boundTextElement = getBoundTextElement(element);
 
   if (transformHandleDirection.includes("e")) {
@@ -423,7 +412,6 @@ export const resizeSingleElement = (
     if (stateOfBoundTextElementAtResize) {
       boundTextFont = {
         fontSize: stateOfBoundTextElementAtResize.fontSize,
-        baseline: stateOfBoundTextElementAtResize.baseline,
       };
     }
     if (shouldMaintainAspectRatio) {
@@ -433,17 +421,15 @@ export const resizeSingleElement = (
         height: eleNewHeight,
       };
 
-      const nextFont = measureFontSizeFromWH(
+      const nextFontSize = measureFontSizeFromWidth(
         boundTextElement,
         getMaxContainerWidth(updatedElement),
-        getMaxContainerHeight(updatedElement),
       );
-      if (nextFont === null) {
+      if (nextFontSize === null) {
         return;
       }
       boundTextFont = {
-        fontSize: nextFont.size,
-        baseline: nextFont.baseline,
+        fontSize: nextFontSize,
       };
     } else {
       const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
@@ -687,7 +673,6 @@ const resizeMultipleElements = (
       y: number;
       points?: Point[];
       fontSize?: number;
-      baseline?: number;
     } = {
       width,
       height,
@@ -696,7 +681,7 @@ const resizeMultipleElements = (
       ...rescaledPoints,
     };
 
-    let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
+    let boundTextUpdates: { fontSize: number } | null = null;
 
     const boundTextElement = getBoundTextElement(element.latest);
 
@@ -706,25 +691,22 @@ const resizeMultipleElements = (
         width,
         height,
       };
-      const textMeasurements = measureFontSizeFromWH(
+      const fontSize = measureFontSizeFromWidth(
         boundTextElement ?? (element.orig as ExcalidrawTextElement),
         getMaxContainerWidth(updatedElement),
-        getMaxContainerHeight(updatedElement),
       );
 
-      if (!textMeasurements) {
+      if (!fontSize) {
         return;
       }
 
       if (isTextElement(element.orig)) {
-        update.fontSize = textMeasurements.size;
-        update.baseline = textMeasurements.baseline;
+        update.fontSize = fontSize;
       }
 
       if (boundTextElement) {
         boundTextUpdates = {
-          fontSize: textMeasurements.size,
-          baseline: textMeasurements.baseline,
+          fontSize,
         };
       }
     }

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

@@ -5,7 +5,6 @@ import {
   getContainerCoords,
   getMaxContainerWidth,
   getMaxContainerHeight,
-  measureText,
   wrapText,
 } from "./textElement";
 import { FontString } from "./types";
@@ -73,6 +72,13 @@ up`,
         width: 250,
         res: "Hello whats up",
       },
+      {
+        desc: "should push the word if its equal to max width",
+        width: 60,
+        res: `Hello
+whats
+up`,
+      },
     ].forEach((data) => {
       it(`should ${data.desc}`, () => {
         const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
@@ -80,6 +86,7 @@ up`,
       });
     });
   });
+
   describe("When text contain new lines", () => {
     const text = `Hello
 whats up`;
@@ -170,38 +177,6 @@ break it now`,
 });
 
 describe("Test measureText", () => {
-  const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
-  const text = "Hello World";
-
-  it("should add correct attributes when maxWidth is passed", () => {
-    const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
-    const res = measureText(text, font, maxWidth);
-
-    expect(res.container).toMatchInlineSnapshot(`
-      <div
-        style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;"
-      >
-        <span
-          style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
-        />
-      </div>
-    `);
-  });
-
-  it("should add correct attributes when maxWidth is not passed", () => {
-    const res = measureText(text, font);
-
-    expect(res.container).toMatchInlineSnapshot(`
-      <div
-        style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
-      >
-        <span
-          style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
-        />
-      </div>
-    `);
-  });
-
   describe("Test getContainerCoords", () => {
     const params = { width: 200, height: 100, x: 10, y: 20 };
 

+ 31 - 61
src/element/textElement.ts

@@ -50,7 +50,6 @@ export const redrawTextBoundingBox = (
     text: textElement.text,
     width: textElement.width,
     height: textElement.height,
-    baseline: textElement.baseline,
   };
 
   boundTextUpdates.text = textElement.text;
@@ -66,12 +65,10 @@ export const redrawTextBoundingBox = (
   const metrics = measureText(
     boundTextUpdates.text,
     getFontString(textElement),
-    maxWidth,
   );
 
   boundTextUpdates.width = metrics.width;
   boundTextUpdates.height = metrics.height;
-  boundTextUpdates.baseline = metrics.baseline;
 
   if (container) {
     if (isArrowElement(container)) {
@@ -177,7 +174,6 @@ export const handleBindTextResize = (
     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(
@@ -186,14 +182,9 @@ export const handleBindTextResize = (
           maxWidth,
         );
       }
-      const dimensions = measureText(
-        text,
-        getFontString(textElement),
-        maxWidth,
-      );
+      const dimensions = measureText(text, getFontString(textElement));
       nextHeight = dimensions.height;
       nextWidth = dimensions.width;
-      nextBaseLine = dimensions.baseline;
     }
     // increase height in case text element height exceeds
     if (nextHeight > maxHeight) {
@@ -221,7 +212,6 @@ export const handleBindTextResize = (
       text,
       width: nextWidth,
       height: nextHeight,
-      baseline: nextBaseLine,
     });
 
     if (!isArrowElement(container)) {
@@ -267,51 +257,19 @@ const computeBoundTextPosition = (
 };
 
 // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
-export const measureText = (
-  text: string,
-  font: FontString,
-  maxWidth?: number | null,
-) => {
+
+export const measureText = (text: string, font: FontString) => {
   text = 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 container = document.createElement("div");
-  container.style.position = "absolute";
-  container.style.whiteSpace = "pre";
-  container.style.font = font;
-  container.style.minHeight = "1em";
-
-  if (maxWidth) {
-    const lineHeight = getApproxLineHeight(font);
-    // since we are adding a span of width 1px later
-    container.style.maxWidth = `${maxWidth + 1}px`;
-    container.style.overflow = "hidden";
-    container.style.wordBreak = "break-word";
-    container.style.lineHeight = `${String(lineHeight)}px`;
-    container.style.whiteSpace = "pre-wrap";
-  }
-  document.body.appendChild(container);
-  container.innerText = text;
-
-  const span = document.createElement("span");
-  span.style.display = "inline-block";
-  span.style.overflow = "hidden";
-  span.style.width = "1px";
-  span.style.height = "1px";
-  container.appendChild(span);
-  // Baseline is important for positioning text on canvas
-  const baseline = span.offsetTop + span.offsetHeight;
-  // since we are adding a span of width 1px
-  const width = container.offsetWidth + 1;
-  const height = container.offsetHeight;
-  document.body.removeChild(container);
-  if (isTestEnv()) {
-    return { width, height, baseline, container };
-  }
-  return { width, height, baseline };
+
+  const height = getTextHeight(text, font);
+  const width = getTextWidth(text, font);
+
+  return { width, height };
 };
 
 const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
@@ -321,40 +279,45 @@ export const getApproxLineHeight = (font: FontString) => {
   if (cacheApproxLineHeight[font]) {
     return cacheApproxLineHeight[font];
   }
-  cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
+  const fontSize = parseInt(font);
+  cacheApproxLineHeight[font] = fontSize * 1.2;
   return cacheApproxLineHeight[font];
 };
 
 let canvas: HTMLCanvasElement | undefined;
+
 const getLineWidth = (text: string, font: FontString) => {
   if (!canvas) {
     canvas = document.createElement("canvas");
   }
   const canvas2dContext = canvas.getContext("2d")!;
   canvas2dContext.font = font;
+  const width = canvas2dContext.measureText(text).width;
 
-  const metrics = canvas2dContext.measureText(text);
   // since in test env the canvas measureText algo
   // doesn't measure text and instead just returns number of
   // characters hence we assume that each letteris 10px
   if (isTestEnv()) {
-    return metrics.width * 10;
+    return width * 10;
   }
-  // Since measureText behaves differently in different browsers
-  // OS so considering a adjustment factor of 0.2
-  const adjustmentFactor = 0.2;
-
-  return metrics.width + adjustmentFactor;
+  return width;
 };
 
 export const getTextWidth = (text: string, font: FontString) => {
-  const lines = text.split("\n");
+  const lines = text.replace(/\r\n?/g, "\n").split("\n");
   let width = 0;
   lines.forEach((line) => {
     width = Math.max(width, getLineWidth(line, font));
   });
   return width;
 };
+
+export const getTextHeight = (text: string, font: FontString) => {
+  const lines = text.replace(/\r\n?/g, "\n").split("\n");
+  const lineHeight = getApproxLineHeight(font);
+  return lineHeight * lines.length;
+};
+
 export const wrapText = (text: string, font: FontString, maxWidth: number) => {
   const lines: Array<string> = [];
   const originalLines = text.split("\n");
@@ -376,16 +339,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
     let currentLineWidthTillNow = 0;
 
     let index = 0;
+
     while (index < words.length) {
       const currentWordWidth = getLineWidth(words[index], font);
+      // This will only happen when single word takes entire width
+      if (currentWordWidth === maxWidth) {
+        push(words[index]);
+        index++;
+      }
 
       // Start breaking longer words exceeding max width
-      if (currentWordWidth >= maxWidth) {
+      else if (currentWordWidth > maxWidth) {
         // push current line since the current word exceeds the max width
         // so will be appended in next line
         push(currentLine);
         currentLine = "";
         currentLineWidthTillNow = 0;
+
         while (words[index].length > 0) {
           const currentChar = String.fromCodePoint(
             words[index].codePointAt(0)!,
@@ -486,9 +456,9 @@ export const charWidth = (() => {
     getCache,
   };
 })();
+
 export const getApproxMinLineWidth = (font: FontString) => {
   const maxCharWidth = getMaxCharWidth(font);
-
   if (maxCharWidth === 0) {
     return (
       measureText(DUMMY_TEXT.split("").join("\n"), font).width +

+ 50 - 123
src/element/textWysiwyg.test.tsx

@@ -6,12 +6,11 @@ import { CODES, KEYS } from "../keys";
 import { fireEvent } from "../tests/test-utils";
 import { queryByText } from "@testing-library/react";
 
-import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
+import { FONT_FAMILY } from "../constants";
 import {
   ExcalidrawTextElement,
   ExcalidrawTextElementWithContainer,
 } from "./types";
-import * as textElementUtils from "./textElement";
 import { API } from "../tests/helpers/api";
 import { mutateElement } from "./mutateElement";
 import { resize } from "../tests/utils";
@@ -440,17 +439,6 @@ describe("textWysiwyg", () => {
     let rectangle: any;
     const { h } = window;
 
-    const DUMMY_HEIGHT = 240;
-    const DUMMY_WIDTH = 160;
-    const APPROX_LINE_HEIGHT = 25;
-    const INITIAL_WIDTH = 10;
-
-    beforeAll(() => {
-      jest
-        .spyOn(textElementUtils, "getApproxLineHeight")
-        .mockReturnValue(APPROX_LINE_HEIGHT);
-    });
-
     beforeEach(async () => {
       await render(<ExcalidrawApp />);
       h.elements = [];
@@ -732,39 +720,6 @@ describe("textWysiwyg", () => {
     });
 
     it("should wrap text and vertcially center align once text submitted", async () => {
-      jest
-        .spyOn(textElementUtils, "measureText")
-        .mockImplementation((text, font, maxWidth) => {
-          let width = INITIAL_WIDTH;
-          let height = APPROX_LINE_HEIGHT;
-          let baseline = 10;
-          if (!text) {
-            return {
-              width,
-              height,
-              baseline,
-            };
-          }
-          baseline = 30;
-          width = DUMMY_WIDTH;
-          if (text === "Hello \nWorld!") {
-            height = APPROX_LINE_HEIGHT * 2;
-          }
-          if (maxWidth) {
-            width = maxWidth;
-            // To capture cases where maxWidth passed is initial width
-            // due to which the text is not wrapped correctly
-            if (maxWidth === INITIAL_WIDTH) {
-              height = DUMMY_HEIGHT;
-            }
-          }
-          return {
-            width,
-            height,
-            baseline,
-          };
-        });
-
       expect(h.elements.length).toBe(1);
 
       Keyboard.keyDown(KEYS.ENTER);
@@ -773,11 +728,6 @@ describe("textWysiwyg", () => {
         ".excalidraw-textEditorContainer > textarea",
       ) as HTMLTextAreaElement;
 
-      // mock scroll height
-      jest
-        .spyOn(editor, "scrollHeight", "get")
-        .mockImplementation(() => APPROX_LINE_HEIGHT * 2);
-
       fireEvent.change(editor, {
         target: {
           value: "Hello World!",
@@ -791,10 +741,12 @@ describe("textWysiwyg", () => {
       text = h.elements[1] as ExcalidrawTextElementWithContainer;
       expect(text.text).toBe("Hello \nWorld!");
       expect(text.originalText).toBe("Hello World!");
-      expect(text.y).toBe(57.5);
-      expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
-      expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
-      expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
+      expect(text.y).toBe(
+        rectangle.y + h.elements[0].height / 2 - text.height / 2,
+      );
+      expect(text.x).toBe(25);
+      expect(text.height).toBe(48);
+      expect(text.width).toBe(60);
 
       // Edit and text by removing second line and it should
       // still vertically align correctly
@@ -811,11 +763,6 @@ describe("textWysiwyg", () => {
         },
       });
 
-      // mock scroll height
-      jest
-        .spyOn(editor, "scrollHeight", "get")
-        .mockImplementation(() => APPROX_LINE_HEIGHT);
-      editor.style.height = "25px";
       editor.dispatchEvent(new Event("input"));
 
       await new Promise((r) => setTimeout(r, 0));
@@ -825,10 +772,12 @@ describe("textWysiwyg", () => {
 
       expect(text.text).toBe("Hello");
       expect(text.originalText).toBe("Hello");
-      expect(text.y).toBe(57.5);
-      expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
-      expect(text.height).toBe(APPROX_LINE_HEIGHT);
-      expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
+      expect(text.height).toBe(24);
+      expect(text.width).toBe(50);
+      expect(text.y).toBe(
+        rectangle.y + h.elements[0].height / 2 - text.height / 2,
+      );
+      expect(text.x).toBe(30);
     });
 
     it("should unbind bound text when unbind action from context menu is triggered", async () => {
@@ -915,8 +864,8 @@ describe("textWysiwyg", () => {
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         Array [
-          109.5,
-          17,
+          85,
+          5,
         ]
       `);
 
@@ -942,7 +891,7 @@ describe("textWysiwyg", () => {
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         Array [
           15,
-          90,
+          66,
         ]
       `);
 
@@ -965,7 +914,7 @@ describe("textWysiwyg", () => {
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
       expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
         Array [
-          424,
+          375,
           -539,
         ]
       `);
@@ -1080,9 +1029,9 @@ describe("textWysiwyg", () => {
       mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
       mouse.up(rectangle.x + 100, rectangle.y + 50);
       expect(rectangle.x).toBe(80);
-      expect(rectangle.y).toBe(85);
-      expect(text.x).toBe(89.5);
-      expect(text.y).toBe(90);
+      expect(rectangle.y).toBe(-35);
+      expect(text.x).toBe(85);
+      expect(text.y).toBe(-30);
 
       Keyboard.withModifierKeys({ ctrl: true }, () => {
         Keyboard.keyPress(KEYS.Z);
@@ -1112,29 +1061,6 @@ describe("textWysiwyg", () => {
     });
 
     it("should restore original container height and clear cache once text is unbind", async () => {
-      jest
-        .spyOn(textElementUtils, "measureText")
-        .mockImplementation((text, font, maxWidth) => {
-          let width = INITIAL_WIDTH;
-          let height = APPROX_LINE_HEIGHT;
-          let baseline = 10;
-          if (!text) {
-            return {
-              width,
-              height,
-              baseline,
-            };
-          }
-          baseline = 30;
-          width = DUMMY_WIDTH;
-          height = APPROX_LINE_HEIGHT * 5;
-
-          return {
-            width,
-            height,
-            baseline,
-          };
-        });
       const originalRectHeight = rectangle.height;
       expect(rectangle.height).toBe(originalRectHeight);
 
@@ -1148,7 +1074,7 @@ describe("textWysiwyg", () => {
         target: { value: "Online whiteboard collaboration made easy" },
       });
       editor.blur();
-      expect(rectangle.height).toBe(135);
+      expect(rectangle.height).toBe(178);
       mouse.select(rectangle);
       fireEvent.contextMenu(GlobalTestState.canvas, {
         button: 2,
@@ -1174,7 +1100,7 @@ describe("textWysiwyg", () => {
       editor.blur();
 
       resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
-      expect(rectangle.height).toBe(215);
+      expect(rectangle.height).toBe(156);
       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
 
       mouse.select(rectangle);
@@ -1186,13 +1112,12 @@ describe("textWysiwyg", () => {
 
       await new Promise((r) => setTimeout(r, 0));
       editor.blur();
-      expect(rectangle.height).toBe(215);
+      expect(rectangle.height).toBe(156);
       // cache updated again
-      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
+      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
     });
 
-    //@todo fix this test later once measureText is mocked correctly
-    it.skip("should reset the container height cache when font properties updated", async () => {
+    it("should reset the container height cache when font properties updated", async () => {
       Keyboard.keyPress(KEYS.ENTER);
       expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
 
@@ -1218,7 +1143,9 @@ describe("textWysiwyg", () => {
       expect(
         (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
       ).toEqual(36);
-      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
+      expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
+        96.39999999999999,
+      );
     });
 
     describe("should align correctly", () => {
@@ -1256,7 +1183,7 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Align top"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
           Array [
-            94.5,
+            30,
             25,
           ]
         `);
@@ -1268,7 +1195,7 @@ describe("textWysiwyg", () => {
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
           Array [
-            174,
+            45,
             25,
           ]
         `);
@@ -1280,7 +1207,7 @@ describe("textWysiwyg", () => {
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
           Array [
             15,
-            25,
+            45.5,
           ]
         `);
       });
@@ -1291,8 +1218,8 @@ describe("textWysiwyg", () => {
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
           Array [
-            -25,
-            25,
+            30,
+            45.5,
           ]
         `);
       });
@@ -1303,8 +1230,8 @@ describe("textWysiwyg", () => {
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
           Array [
-            174,
-            25,
+            45,
+            45.5,
           ]
         `);
       });
@@ -1314,33 +1241,33 @@ describe("textWysiwyg", () => {
         fireEvent.click(screen.getByTitle("Align bottom"));
 
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-            Array [
-              15,
-              25,
-            ]
-          `);
+          Array [
+            15,
+            66,
+          ]
+        `);
       });
 
       it("when bottom center", async () => {
         fireEvent.click(screen.getByTitle("Center"));
         fireEvent.click(screen.getByTitle("Align bottom"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-            Array [
-              94.5,
-              25,
-            ]
-          `);
+          Array [
+            30,
+            66,
+          ]
+        `);
       });
 
       it("when bottom right", async () => {
         fireEvent.click(screen.getByTitle("Right"));
         fireEvent.click(screen.getByTitle("Align bottom"));
         expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
-            Array [
-              174,
-              25,
-            ]
-          `);
+          Array [
+            45,
+            66,
+          ]
+        `);
       });
     });
   });

+ 12 - 47
src/element/textWysiwyg.tsx

@@ -11,7 +11,7 @@ import {
   isBoundToContainer,
   isTextElement,
 } from "./typeChecks";
-import { CLASSES, VERTICAL_ALIGN } from "../constants";
+import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
 import {
   ExcalidrawElement,
   ExcalidrawLinearElement,
@@ -29,6 +29,7 @@ import {
   getContainerElement,
   getTextElementAngle,
   getTextWidth,
+  measureText,
   normalizeText,
   redrawTextBoundingBox,
   wrapText,
@@ -159,7 +160,7 @@ export const textWysiwyg = ({
       let maxWidth = updatedTextElement.width;
 
       let maxHeight = updatedTextElement.height;
-      const width = updatedTextElement.width;
+      let textElementWidth = updatedTextElement.width;
       // Set to element height by default since that's
       // what is going to be used for unbounded text
       let textElementHeight = updatedTextElement.height;
@@ -272,7 +273,10 @@ export const textWysiwyg = ({
       if (!container) {
         maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
       }
-
+      // As firefox, Safari needs little higher dimensions on DOM
+      if (isFirefox || isSafari) {
+        textElementWidth += 0.5;
+      }
       // Make sure text editor height doesn't go beyond viewport
       const editorMaxHeight =
         (appState.height - viewportY) / appState.zoom.value;
@@ -280,12 +284,12 @@ export const textWysiwyg = ({
         font: getFontString(updatedTextElement),
         // must be defined *after* font ¯\_(ツ)_/¯
         lineHeight: `${lineHeight}px`,
-        width: `${Math.min(width, maxWidth)}px`,
+        width: `${textElementWidth}px`,
         height: `${textElementHeight}px`,
         left: `${viewportX}px`,
         top: `${viewportY}px`,
         transform: getTransform(
-          width,
+          textElementWidth,
           textElementHeight,
           getTextElementAngle(updatedTextElement),
           appState,
@@ -378,55 +382,16 @@ export const textWysiwyg = ({
         id,
       ) as ExcalidrawTextElement;
       const font = getFontString(updatedTextElement);
-      // using scrollHeight here since we need to calculate
-      // number of lines so cannot use editable.style.height
-      // as that gets updated below
-      // Rounding here so that the lines calculated is more accurate in all browsers.
-      // The scrollHeight and approxLineHeight differs in diff browsers
-      // eg it gives 1.05 in firefox for handewritten small font due to which
-      // height gets updated as lines > 1 and leads to jumping text for first line in bound container
-      // hence rounding here to avoid that
-      const lines = Math.round(
-        editable.scrollHeight / getApproxLineHeight(font),
-      );
-      // auto increase height only when lines  > 1 so its
-      // measured correctly and vertically aligns for
-      // first line as well as setting height to "auto"
-      // doubles the height as soon as user starts typing
-      if (isBoundToContainer(element) && lines > 1) {
+      if (isBoundToContainer(element)) {
         const container = getContainerElement(element);
-
-        let height = "auto";
-        editable.style.height = "0px";
-        let heightSet = false;
-        if (lines === 2) {
-          const actualLineCount = wrapText(
-            editable.value,
-            font,
-            getMaxContainerWidth(container!),
-          ).split("\n").length;
-          // This is browser behaviour when setting height to "auto"
-          // It sets the height needed for 2 lines even if actual
-          // line count is 1 as mentioned above as well
-          // hence reducing the height by half if actual line count is 1
-          // so single line aligns vertically when deleting
-          if (actualLineCount === 1) {
-            height = `${editable.scrollHeight / 2}px`;
-            editable.style.height = height;
-            heightSet = true;
-          }
-        }
         const wrappedText = wrapText(
           normalizeText(editable.value),
           font,
           getMaxContainerWidth(container!),
         );
-        const width = getTextWidth(wrappedText, font);
+        const { width, height } = measureText(wrappedText, font);
         editable.style.width = `${width}px`;
-
-        if (!heightSet) {
-          editable.style.height = `${editable.scrollHeight}px`;
-        }
+        editable.style.height = `${height}px`;
       }
       onChange(normalizeText(editable.value));
     };

+ 0 - 1
src/element/types.ts

@@ -130,7 +130,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
     fontSize: number;
     fontFamily: FontFamilyValues;
     text: string;
-    baseline: number;
     textAlign: TextAlign;
     verticalAlign: VerticalAlign;
     containerId: ExcalidrawGenericElement["id"] | null;

+ 4 - 9
src/renderer/renderElement.ts

@@ -36,13 +36,11 @@ import {
   MAX_DECIMALS_FOR_SVG_EXPORT,
   MIME_TYPES,
   SVG_NS,
-  VERTICAL_ALIGN,
 } from "../constants";
 import { getStroke, StrokeOptions } from "perfect-freehand";
 import {
   getApproxLineHeight,
   getBoundTextElement,
-  getBoundTextElementOffset,
   getContainerElement,
 } from "../element/textElement";
 import { LinearElementEditor } from "../element/linearElementEditor";
@@ -280,22 +278,19 @@ const drawElementOnCanvas = (
         const lineHeight = element.containerId
           ? getApproxLineHeight(getFontString(element))
           : element.height / lines.length;
-        let verticalOffset = element.height - element.baseline;
-        if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
-          verticalOffset = getBoundTextElementOffset(element);
-        }
-
         const horizontalOffset =
           element.textAlign === "center"
             ? element.width / 2
             : element.textAlign === "right"
             ? element.width
             : 0;
+        context.textBaseline = "bottom";
+
         for (let index = 0; index < lines.length; index++) {
           context.fillText(
             lines[index],
             horizontalOffset,
-            (index + 1) * lineHeight - verticalOffset,
+            (index + 1) * lineHeight,
           );
         }
         context.restore();
@@ -1300,7 +1295,7 @@ export const renderElementToSvg = (
         );
         const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
         const lineHeight = element.height / lines.length;
-        const verticalOffset = element.height - element.baseline;
+        const verticalOffset = element.height;
         const horizontalOffset =
           element.textAlign === "center"
             ? element.width / 2

+ 1 - 1
src/tests/__snapshots__/linearElementEditor.test.tsx.snap

@@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
   class="excalidraw-wysiwyg"
   data-type="wysiwyg"
   dir="auto"
-  style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 1px; height: 0px; left: 39.5px; top: 20px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -20px; font: Emoji 20px 20px; line-height: 0px; font-family: Virgil, Segoe UI Emoji;"
+  style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;"
   tabindex="0"
   wrap="off"
 />

+ 2 - 4
src/tests/data/__snapshots__/restore.test.ts.snap

@@ -282,7 +282,6 @@ exports[`restoreElements should restore text element correctly passing value for
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
-  "baseline": 0,
   "boundElements": Array [],
   "containerId": null,
   "fillStyle": "hachure",
@@ -312,8 +311,8 @@ Object {
   "versionNonce": 0,
   "verticalAlign": "middle",
   "width": 100,
-  "x": -0.5,
-  "y": 0,
+  "x": -20,
+  "y": -8.4,
 }
 `;
 
@@ -321,7 +320,6 @@ exports[`restoreElements should restore text element correctly with unknown font
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
-  "baseline": 0,
   "boundElements": Array [],
   "containerId": null,
   "fillStyle": "hachure",

+ 10 - 10
src/tests/linearElementEditor.test.tsx

@@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
       expect({ width: container.width, height: container.height })
         .toMatchInlineSnapshot(`
         Object {
-          "height": 10,
+          "height": 128,
           "width": 367,
         }
       `);
@@ -1039,8 +1039,8 @@ describe("Test Linear Elements", () => {
       expect(getBoundTextElementPosition(container, textElement))
         .toMatchInlineSnapshot(`
         Object {
-          "x": 386.5,
-          "y": 70,
+          "x": 272,
+          "y": 46,
         }
       `);
       expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
@@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
         .toMatchInlineSnapshot(`
         Array [
           20,
-          60,
-          391.8122896842806,
-          70,
+          36,
+          502,
+          94,
           205.9061448421403,
-          65,
+          53,
         ]
       `);
     });
@@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
       expect({ width: container.width, height: container.height })
         .toMatchInlineSnapshot(`
         Object {
-          "height": 0,
+          "height": 128,
           "width": 340,
         }
       `);
@@ -1098,8 +1098,8 @@ describe("Test Linear Elements", () => {
       expect(getBoundTextElementPosition(container, textElement))
         .toMatchInlineSnapshot(`
         Object {
-          "x": 189.5,
-          "y": 20,
+          "x": 75,
+          "y": -4,
         }
       `);
       expect(textElement.text).toMatchInlineSnapshot(`