Jelajahi Sumber

move measurements related utils to textMeasurements.ts

Aakansha Doshi 2 tahun lalu
induk
melakukan
e900cb0b64

+ 1 - 1
src/actions/actionBoundText.tsx

@@ -4,9 +4,9 @@ import { mutateElement } from "../element/mutateElement";
 import {
   computeContainerDimensionForBoundText,
   getBoundTextElement,
-  measureText,
   redrawTextBoundingBox,
 } from "../element/textElement";
+import { measureText } from "../element/textMeasurements";
 import {
   getOriginalContainerHeightFromCache,
   resetOriginalContainerCache,

+ 6 - 4
src/components/App.tsx

@@ -260,17 +260,19 @@ import throttle from "lodash.throttle";
 import { fileOpen, FileSystemHandle } from "../data/filesystem";
 import {
   bindTextToShapeAfterDuplication,
-  getLineHeight,
-  getApproxMinLineHeight,
-  getApproxMinLineWidth,
   getBoundTextElement,
   getContainerCenter,
   getContainerDims,
   getContainerElement,
   getTextBindableContainerAtPosition,
-  isMeasureTextSupported,
   isValidTextContainer,
 } from "../element/textElement";
+import {
+  getLineHeight,
+  getApproxMinLineHeight,
+  getApproxMinLineWidth,
+  isMeasureTextSupported,
+} from "../element/textMeasurements";
 import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
 import {
   normalizeLink,

+ 1 - 2
src/element/newElement.ts

@@ -25,14 +25,13 @@ import {
   getBoundTextElementOffset,
   getContainerDims,
   getContainerElement,
-  measureText,
   normalizeText,
-  wrapText,
   getBoundTextMaxWidth,
 } from "./textElement";
 import { VERTICAL_ALIGN } from "../constants";
 import { isArrowElement } from "./typeChecks";
 import { MarkOptional, Merge, Mutable } from "../utility-types";
+import { measureText, wrapText } from "./textMeasurements";
 
 type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,

+ 4 - 3
src/element/resizeElements.ts

@@ -39,15 +39,16 @@ import {
 import { Point, PointerDownState } from "../types";
 import Scene from "../scene/Scene";
 import {
-  getApproxMinLineHeight,
-  getApproxMinLineWidth,
   getBoundTextElement,
   getBoundTextElementId,
   getContainerElement,
   handleBindTextResize,
   getBoundTextMaxWidth,
 } from "./textElement";
-
+import {
+  getApproxMinLineHeight,
+  getApproxMinLineWidth,
+} from "./textMeasurements";
 export const normalizeAngle = (angle: number): number => {
   if (angle >= 2 * Math.PI) {
     return angle - 2 * Math.PI;

+ 1 - 180
src/element/textElement.test.ts

@@ -1,190 +1,11 @@
-import { BOUND_TEXT_PADDING } from "../constants";
 import { API } from "../tests/helpers/api";
 import {
   computeContainerDimensionForBoundText,
   getContainerCoords,
   getBoundTextMaxWidth,
   getBoundTextMaxHeight,
-  wrapText,
 } from "./textElement";
-import { ExcalidrawTextElementWithContainer, FontString } from "./types";
-
-describe("Test wrapText", () => {
-  const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
-
-  it("shouldn't add new lines for trailing spaces", () => {
-    const text = "Hello whats up     ";
-    const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
-    const res = wrapText(text, font, maxWidth);
-    expect(res).toBe(text);
-  });
-
-  it("should work with emojis", () => {
-    const text = "😀";
-    const maxWidth = 1;
-    const res = wrapText(text, font, maxWidth);
-    expect(res).toBe("😀");
-  });
-
-  it("should show the text correctly when max width reached", () => {
-    const text = "Hello😀";
-    const maxWidth = 10;
-    const res = wrapText(text, font, maxWidth);
-    expect(res).toBe("H\ne\nl\nl\no\n😀");
-  });
-
-  describe("When text doesn't contain new lines", () => {
-    const text = "Hello whats up";
-
-    [
-      {
-        desc: "break all words when width of each word is less than container width",
-        width: 80,
-        res: `Hello 
-whats 
-up`,
-      },
-      {
-        desc: "break all characters when width of each character is less than container width",
-        width: 25,
-        res: `H
-e
-l
-l
-o
-w
-h
-a
-t
-s
-u
-p`,
-      },
-      {
-        desc: "break words as per the width",
-
-        width: 140,
-        res: `Hello whats 
-up`,
-      },
-      {
-        desc: "fit the container",
-
-        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);
-        expect(res).toEqual(data.res);
-      });
-    });
-  });
-
-  describe("When text contain new lines", () => {
-    const text = `Hello
-whats up`;
-    [
-      {
-        desc: "break all words when width of each word is less than container width",
-        width: 80,
-        res: `Hello
-whats 
-up`,
-      },
-      {
-        desc: "break all characters when width of each character is less than container width",
-        width: 25,
-        res: `H
-e
-l
-l
-o
-w
-h
-a
-t
-s
-u
-p`,
-      },
-      {
-        desc: "break words as per the width",
-
-        width: 150,
-        res: `Hello
-whats up`,
-      },
-      {
-        desc: "fit the container",
-
-        width: 250,
-        res: `Hello
-whats up`,
-      },
-    ].forEach((data) => {
-      it(`should respect new lines and ${data.desc}`, () => {
-        const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
-        expect(res).toEqual(data.res);
-      });
-    });
-  });
-
-  describe("When text is long", () => {
-    const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
-    [
-      {
-        desc: "fit characters of long string as per container width",
-        width: 170,
-        res: `hellolongtextth
-isiswhatsupwith
-youIamtypingggg
-gandtypinggg 
-break it now`,
-      },
-
-      {
-        desc: "fit characters of long string as per container width and break words as per the width",
-
-        width: 130,
-        res: `hellolongte
-xtthisiswha
-tsupwithyou
-Iamtypinggg
-ggandtyping
-gg break it
-now`,
-      },
-      {
-        desc: "fit the long text when container width is greater than text length and move the rest to next line",
-
-        width: 600,
-        res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg 
-break it now`,
-      },
-    ].forEach((data) => {
-      it(`should ${data.desc}`, () => {
-        const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
-        expect(res).toEqual(data.res);
-      });
-    });
-  });
-
-  it("should wrap the text correctly when word length is exactly equal to max width", () => {
-    const text = "Hello Excalidraw";
-    // Length of "Excalidraw" is 100 and exacty equal to max width
-    const res = wrapText(text, font, 100);
-    expect(res).toEqual(`Hello 
-Excalidraw`);
-  });
-});
+import { ExcalidrawTextElementWithContainer } from "./types";
 
 describe("Test measureText", () => {
   describe("Test getContainerCoords", () => {

+ 3 - 284
src/element/textElement.ts

@@ -1,20 +1,13 @@
-import { getFontString, arrayToMap, isTestEnv } from "../utils";
+import { getFontString, arrayToMap } from "../utils";
 import {
   ExcalidrawElement,
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
   ExcalidrawTextElementWithContainer,
-  FontString,
   NonDeletedExcalidrawElement,
 } from "./types";
 import { mutateElement } from "./mutateElement";
-import {
-  BOUND_TEXT_PADDING,
-  DEFAULT_FONT_FAMILY,
-  DEFAULT_FONT_SIZE,
-  TEXT_ALIGN,
-  VERTICAL_ALIGN,
-} from "../constants";
+import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
 import { MaybeTransformHandleType } from "./transformHandles";
 import Scene from "../scene/Scene";
 import { isTextElement } from ".";
@@ -30,6 +23,7 @@ import {
   updateOriginalContainerCache,
 } from "./textWysiwyg";
 import { ExtractSetType } from "../utility-types";
+import { measureText, wrapText } from "./textMeasurements";
 
 export const normalizeText = (text: string) => {
   return (
@@ -261,270 +255,6 @@ export const computeBoundTextPosition = (
   return { x, y };
 };
 
-// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
-
-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 height = getTextHeight(text, font);
-  const width = getTextWidth(text, font);
-
-  return { width, height };
-};
-
-const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
-const cacheLineHeight: { [key: FontString]: number } = {};
-
-export const getLineHeight = (font: FontString) => {
-  if (cacheLineHeight[font]) {
-    return cacheLineHeight[font];
-  }
-  const fontSize = parseInt(font);
-
-  // Calculate line height relative to font size
-  cacheLineHeight[font] = fontSize * 1.2;
-  return cacheLineHeight[font];
-};
-
-let canvas: HTMLCanvasElement | undefined;
-
-// since in test env the canvas measureText algo
-// doesn't measure text and instead just returns number of
-// characters hence we assume that each letter is 10px
-const DUMMY_CHAR_WIDTH = 10;
-
-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;
-
-  /* istanbul ignore else */
-  if (isTestEnv()) {
-    return width * DUMMY_CHAR_WIDTH;
-  }
-  /* istanbul ignore next */
-  return width;
-};
-
-export const getTextWidth = (text: string, font: FontString) => {
-  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 = getLineHeight(font);
-  return lineHeight * lines.length;
-};
-
-export const wrapText = (text: string, font: FontString, maxWidth: number) => {
-  const lines: Array<string> = [];
-  const originalLines = text.split("\n");
-  const spaceWidth = getLineWidth(" ", font);
-
-  let currentLine = "";
-  let currentLineWidthTillNow = 0;
-
-  const push = (str: string) => {
-    if (str.trim()) {
-      lines.push(str);
-    }
-  };
-
-  const resetParams = () => {
-    currentLine = "";
-    currentLineWidthTillNow = 0;
-  };
-
-  originalLines.forEach((originalLine) => {
-    const currentLineWidth = getTextWidth(originalLine, font);
-
-    //Push the line if its <= maxWidth
-    if (currentLineWidth <= maxWidth) {
-      lines.push(originalLine);
-      return; // continue
-    }
-    const words = originalLine.split(" ");
-
-    resetParams();
-
-    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
-      else if (currentWordWidth > maxWidth) {
-        // push current line since the current word exceeds the max width
-        // so will be appended in next line
-        push(currentLine);
-
-        resetParams();
-
-        while (words[index].length > 0) {
-          const currentChar = String.fromCodePoint(
-            words[index].codePointAt(0)!,
-          );
-          const width = charWidth.calculate(currentChar, font);
-          currentLineWidthTillNow += width;
-          words[index] = words[index].slice(currentChar.length);
-
-          if (currentLineWidthTillNow >= maxWidth) {
-            push(currentLine);
-            currentLine = currentChar;
-            currentLineWidthTillNow = width;
-          } else {
-            currentLine += currentChar;
-          }
-        }
-
-        // push current line if appending space exceeds max width
-        if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
-          push(currentLine);
-          resetParams();
-        } else {
-          // space needs to be appended before next word
-          // as currentLine contains chars which couldn't be appended
-          // to previous line
-          currentLine += " ";
-          currentLineWidthTillNow += spaceWidth;
-        }
-        index++;
-      } else {
-        // Start appending words in a line till max width reached
-        while (currentLineWidthTillNow < maxWidth && index < words.length) {
-          const word = words[index];
-          currentLineWidthTillNow = getLineWidth(currentLine + word, font);
-
-          if (currentLineWidthTillNow > maxWidth) {
-            push(currentLine);
-            resetParams();
-
-            break;
-          }
-          index++;
-          currentLine += `${word} `;
-
-          // Push the word if appending space exceeds max width
-          if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
-            const word = currentLine.slice(0, -1);
-            push(word);
-            resetParams();
-            break;
-          }
-        }
-      }
-    }
-    if (currentLine.slice(-1) === " ") {
-      // only remove last trailing space which we have added when joining words
-      currentLine = currentLine.slice(0, -1);
-      push(currentLine);
-    }
-  });
-  return lines.join("\n");
-};
-
-export const charWidth = (() => {
-  const cachedCharWidth: { [key: FontString]: Array<number> } = {};
-
-  const calculate = (char: string, font: FontString) => {
-    const ascii = char.charCodeAt(0);
-    if (!cachedCharWidth[font]) {
-      cachedCharWidth[font] = [];
-    }
-    if (!cachedCharWidth[font][ascii]) {
-      const width = getLineWidth(char, font);
-      cachedCharWidth[font][ascii] = width;
-    }
-
-    return cachedCharWidth[font][ascii];
-  };
-
-  const getCache = (font: FontString) => {
-    return cachedCharWidth[font];
-  };
-  return {
-    calculate,
-    getCache,
-  };
-})();
-
-export const getApproxMinLineWidth = (font: FontString) => {
-  const maxCharWidth = getMaxCharWidth(font);
-  if (maxCharWidth === 0) {
-    return (
-      measureText(DUMMY_TEXT.split("").join("\n"), font).width +
-      BOUND_TEXT_PADDING * 2
-    );
-  }
-  return maxCharWidth + BOUND_TEXT_PADDING * 2;
-};
-
-export const getApproxMinLineHeight = (font: FontString) => {
-  return getLineHeight(font) + BOUND_TEXT_PADDING * 2;
-};
-
-export const getMinCharWidth = (font: FontString) => {
-  const cache = charWidth.getCache(font);
-  if (!cache) {
-    return 0;
-  }
-  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
-
-  return Math.min(...cacheWithOutEmpty);
-};
-
-export const getMaxCharWidth = (font: FontString) => {
-  const cache = charWidth.getCache(font);
-  if (!cache) {
-    return 0;
-  }
-  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
-  return Math.max(...cacheWithOutEmpty);
-};
-
-export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
-  // Generally lower case is used so converting to lower case
-  const dummyText = DUMMY_TEXT.toLocaleLowerCase();
-  const batchLength = 6;
-  let index = 0;
-  let widthTillNow = 0;
-  let str = "";
-  while (widthTillNow <= width) {
-    const batch = dummyText.substr(index, index + batchLength);
-    str += batch;
-    widthTillNow += getLineWidth(str, font);
-    if (index === dummyText.length - 1) {
-      index = 0;
-    }
-    index = index + batchLength;
-  }
-
-  while (widthTillNow > width) {
-    str = str.substr(0, str.length - 1);
-    widthTillNow = getLineWidth(str, font);
-  }
-  return str.length;
-};
-
 export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
   return container?.boundElements?.length
     ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
@@ -795,14 +525,3 @@ export const getBoundTextMaxHeight = (
   }
   return height - BOUND_TEXT_PADDING * 2;
 };
-
-export const isMeasureTextSupported = () => {
-  const width = getTextWidth(
-    DUMMY_TEXT,
-    getFontString({
-      fontSize: DEFAULT_FONT_SIZE,
-      fontFamily: DEFAULT_FONT_FAMILY,
-    }),
-  );
-  return width > 0;
-};

+ 180 - 0
src/element/textMeasurements.test.ts

@@ -0,0 +1,180 @@
+import { BOUND_TEXT_PADDING } from "../constants";
+import { wrapText } from "./textMeasurements";
+import { FontString } from "./types";
+
+describe("Test wrapText", () => {
+  const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
+
+  it("shouldn't add new lines for trailing spaces", () => {
+    const text = "Hello whats up     ";
+    const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
+    const res = wrapText(text, font, maxWidth);
+    expect(res).toBe(text);
+  });
+
+  it("should work with emojis", () => {
+    const text = "😀";
+    const maxWidth = 1;
+    const res = wrapText(text, font, maxWidth);
+    expect(res).toBe("😀");
+  });
+
+  it("should show the text correctly when max width reached", () => {
+    const text = "Hello😀";
+    const maxWidth = 10;
+    const res = wrapText(text, font, maxWidth);
+    expect(res).toBe("H\ne\nl\nl\no\n😀");
+  });
+
+  describe("When text doesn't contain new lines", () => {
+    const text = "Hello whats up";
+
+    [
+      {
+        desc: "break all words when width of each word is less than container width",
+        width: 80,
+        res: `Hello 
+whats 
+up`,
+      },
+      {
+        desc: "break all characters when width of each character is less than container width",
+        width: 25,
+        res: `H
+e
+l
+l
+o
+w
+h
+a
+t
+s
+u
+p`,
+      },
+      {
+        desc: "break words as per the width",
+
+        width: 140,
+        res: `Hello whats 
+up`,
+      },
+      {
+        desc: "fit the container",
+
+        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);
+        expect(res).toEqual(data.res);
+      });
+    });
+  });
+
+  describe("When text contain new lines", () => {
+    const text = `Hello
+whats up`;
+    [
+      {
+        desc: "break all words when width of each word is less than container width",
+        width: 80,
+        res: `Hello
+whats 
+up`,
+      },
+      {
+        desc: "break all characters when width of each character is less than container width",
+        width: 25,
+        res: `H
+e
+l
+l
+o
+w
+h
+a
+t
+s
+u
+p`,
+      },
+      {
+        desc: "break words as per the width",
+
+        width: 150,
+        res: `Hello
+whats up`,
+      },
+      {
+        desc: "fit the container",
+
+        width: 250,
+        res: `Hello
+whats up`,
+      },
+    ].forEach((data) => {
+      it(`should respect new lines and ${data.desc}`, () => {
+        const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
+        expect(res).toEqual(data.res);
+      });
+    });
+  });
+
+  describe("When text is long", () => {
+    const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
+    [
+      {
+        desc: "fit characters of long string as per container width",
+        width: 170,
+        res: `hellolongtextth
+isiswhatsupwith
+youIamtypingggg
+gandtypinggg 
+break it now`,
+      },
+
+      {
+        desc: "fit characters of long string as per container width and break words as per the width",
+
+        width: 130,
+        res: `hellolongte
+xtthisiswha
+tsupwithyou
+Iamtypinggg
+ggandtyping
+gg break it
+now`,
+      },
+      {
+        desc: "fit the long text when container width is greater than text length and move the rest to next line",
+
+        width: 600,
+        res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg 
+break it now`,
+      },
+    ].forEach((data) => {
+      it(`should ${data.desc}`, () => {
+        const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
+        expect(res).toEqual(data.res);
+      });
+    });
+  });
+
+  it("should wrap the text correctly when word length is exactly equal to max width", () => {
+    const text = "Hello Excalidraw";
+    // Length of "Excalidraw" is 100 and exacty equal to max width
+    const res = wrapText(text, font, 100);
+    expect(res).toEqual(`Hello 
+Excalidraw`);
+  });
+});

+ 284 - 0
src/element/textMeasurements.ts

@@ -0,0 +1,284 @@
+import {
+  BOUND_TEXT_PADDING,
+  DEFAULT_FONT_FAMILY,
+  DEFAULT_FONT_SIZE,
+} from "../constants";
+import { getFontString, isTestEnv } from "../utils";
+import { FontString } from "./types";
+
+const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
+const cacheLineHeight: { [key: FontString]: number } = {};
+
+export const getLineHeight = (font: FontString) => {
+  if (cacheLineHeight[font]) {
+    return cacheLineHeight[font];
+  }
+  const fontSize = parseInt(font);
+
+  // Calculate line height relative to font size
+  cacheLineHeight[font] = fontSize * 1.2;
+  return cacheLineHeight[font];
+};
+
+let canvas: HTMLCanvasElement | undefined;
+
+// since in test env the canvas measureText algo
+// doesn't measure text and instead just returns number of
+// characters hence we assume that each letter is 10px
+const DUMMY_CHAR_WIDTH = 10;
+
+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;
+
+  /* istanbul ignore else */
+  if (isTestEnv()) {
+    return width * DUMMY_CHAR_WIDTH;
+  }
+  /* istanbul ignore next */
+  return width;
+};
+
+export const getTextWidth = (text: string, font: FontString) => {
+  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 = getLineHeight(font);
+  return lineHeight * lines.length;
+};
+
+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 height = getTextHeight(text, font);
+  const width = getTextWidth(text, font);
+
+  return { width, height };
+};
+
+export const getApproxMinLineWidth = (font: FontString) => {
+  const maxCharWidth = getMaxCharWidth(font);
+  if (maxCharWidth === 0) {
+    return (
+      measureText(DUMMY_TEXT.split("").join("\n"), font).width +
+      BOUND_TEXT_PADDING * 2
+    );
+  }
+  return maxCharWidth + BOUND_TEXT_PADDING * 2;
+};
+
+export const getApproxMinLineHeight = (font: FontString) => {
+  return getLineHeight(font) + BOUND_TEXT_PADDING * 2;
+};
+
+export const charWidth = (() => {
+  const cachedCharWidth: { [key: FontString]: Array<number> } = {};
+
+  const calculate = (char: string, font: FontString) => {
+    const ascii = char.charCodeAt(0);
+    if (!cachedCharWidth[font]) {
+      cachedCharWidth[font] = [];
+    }
+    if (!cachedCharWidth[font][ascii]) {
+      const width = getLineWidth(char, font);
+      cachedCharWidth[font][ascii] = width;
+    }
+
+    return cachedCharWidth[font][ascii];
+  };
+
+  const getCache = (font: FontString) => {
+    return cachedCharWidth[font];
+  };
+  return {
+    calculate,
+    getCache,
+  };
+})();
+
+export const getMinCharWidth = (font: FontString) => {
+  const cache = charWidth.getCache(font);
+  if (!cache) {
+    return 0;
+  }
+  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
+
+  return Math.min(...cacheWithOutEmpty);
+};
+
+export const getMaxCharWidth = (font: FontString) => {
+  const cache = charWidth.getCache(font);
+  if (!cache) {
+    return 0;
+  }
+  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
+  return Math.max(...cacheWithOutEmpty);
+};
+
+/** this is not used currently but might be useful
+ * in future hence keeping it
+ */
+/* istanbul ignore next */
+export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
+  // Generally lower case is used so converting to lower case
+  const dummyText = DUMMY_TEXT.toLocaleLowerCase();
+  const batchLength = 6;
+  let index = 0;
+  let widthTillNow = 0;
+  let str = "";
+  while (widthTillNow <= width) {
+    const batch = dummyText.substr(index, index + batchLength);
+    str += batch;
+    widthTillNow += getLineWidth(str, font);
+    if (index === dummyText.length - 1) {
+      index = 0;
+    }
+    index = index + batchLength;
+  }
+
+  while (widthTillNow > width) {
+    str = str.substr(0, str.length - 1);
+    widthTillNow = getLineWidth(str, font);
+  }
+  return str.length;
+};
+
+export const wrapText = (text: string, font: FontString, maxWidth: number) => {
+  const lines: Array<string> = [];
+  const originalLines = text.split("\n");
+  const spaceWidth = getLineWidth(" ", font);
+
+  let currentLine = "";
+  let currentLineWidthTillNow = 0;
+
+  const push = (str: string) => {
+    if (str.trim()) {
+      lines.push(str);
+    }
+  };
+
+  const resetParams = () => {
+    currentLine = "";
+    currentLineWidthTillNow = 0;
+  };
+
+  originalLines.forEach((originalLine) => {
+    const currentLineWidth = getTextWidth(originalLine, font);
+
+    //Push the line if its <= maxWidth
+    if (currentLineWidth <= maxWidth) {
+      lines.push(originalLine);
+      return; // continue
+    }
+    const words = originalLine.split(" ");
+
+    resetParams();
+
+    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
+      else if (currentWordWidth > maxWidth) {
+        // push current line since the current word exceeds the max width
+        // so will be appended in next line
+        push(currentLine);
+
+        resetParams();
+
+        while (words[index].length > 0) {
+          const currentChar = String.fromCodePoint(
+            words[index].codePointAt(0)!,
+          );
+          const width = charWidth.calculate(currentChar, font);
+          currentLineWidthTillNow += width;
+          words[index] = words[index].slice(currentChar.length);
+
+          if (currentLineWidthTillNow >= maxWidth) {
+            push(currentLine);
+            currentLine = currentChar;
+            currentLineWidthTillNow = width;
+          } else {
+            currentLine += currentChar;
+          }
+        }
+
+        // push current line if appending space exceeds max width
+        if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
+          push(currentLine);
+          resetParams();
+        } else {
+          // space needs to be appended before next word
+          // as currentLine contains chars which couldn't be appended
+          // to previous line
+          currentLine += " ";
+          currentLineWidthTillNow += spaceWidth;
+        }
+        index++;
+      } else {
+        // Start appending words in a line till max width reached
+        while (currentLineWidthTillNow < maxWidth && index < words.length) {
+          const word = words[index];
+          currentLineWidthTillNow = getLineWidth(currentLine + word, font);
+
+          if (currentLineWidthTillNow > maxWidth) {
+            push(currentLine);
+            resetParams();
+
+            break;
+          }
+          index++;
+          currentLine += `${word} `;
+
+          // Push the word if appending space exceeds max width
+          if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
+            const word = currentLine.slice(0, -1);
+            push(word);
+            resetParams();
+            break;
+          }
+        }
+      }
+    }
+    if (currentLine.slice(-1) === " ") {
+      // only remove last trailing space which we have added when joining words
+      currentLine = currentLine.slice(0, -1);
+      push(currentLine);
+    }
+  });
+  return lines.join("\n");
+};
+
+export const isMeasureTextSupported = () => {
+  const width = getTextWidth(
+    DUMMY_TEXT,
+    getFontString({
+      fontSize: DEFAULT_FONT_SIZE,
+      fontFamily: DEFAULT_FONT_FAMILY,
+    }),
+  );
+  return width > 0;
+};

+ 7 - 5
src/element/textWysiwyg.tsx

@@ -22,20 +22,15 @@ import {
 import { AppState } from "../types";
 import { mutateElement } from "./mutateElement";
 import {
-  getLineHeight,
   getBoundTextElementId,
   getContainerDims,
   getContainerElement,
   getTextElementAngle,
-  getTextWidth,
-  measureText,
   normalizeText,
   redrawTextBoundingBox,
-  wrapText,
   getBoundTextMaxHeight,
   getBoundTextMaxWidth,
   computeBoundTextPosition,
-  getTextHeight,
 } from "./textElement";
 import {
   actionDecreaseFontSize,
@@ -45,6 +40,13 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
 import App from "../components/App";
 import { LinearElementEditor } from "./linearElementEditor";
 import { parseClipboard } from "../clipboard";
+import {
+  getLineHeight,
+  getTextWidth,
+  measureText,
+  wrapText,
+  getTextHeight,
+} from "./textMeasurements";
 
 const getTransform = (
   width: number,

+ 1 - 1
src/renderer/renderElement.ts

@@ -40,7 +40,6 @@ import {
 } from "../constants";
 import { getStroke, StrokeOptions } from "perfect-freehand";
 import {
-  getLineHeight,
   getBoundTextElement,
   getContainerCoords,
   getContainerElement,
@@ -48,6 +47,7 @@ import {
   getBoundTextMaxWidth,
 } from "../element/textElement";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { getLineHeight } from "../element/textMeasurements";
 
 // 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

+ 1 - 1
src/tests/clipboard.test.tsx

@@ -3,7 +3,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
 import { Pointer, Keyboard } from "./helpers/ui";
 import ExcalidrawApp from "../excalidraw-app";
 import { KEYS } from "../keys";
-import { getLineHeight } from "../element/textElement";
+import { getLineHeight } from "../element/textMeasurements";
 import { getFontString } from "../utils";
 import { getElementBounds } from "../element";
 import { NormalizedZoomValue } from "../types";

+ 2 - 1
src/tests/linearElementEditor.test.tsx

@@ -17,7 +17,8 @@ import { KEYS } from "../keys";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { queryByTestId, queryByText } from "@testing-library/react";
 import { resize, rotate } from "./utils";
-import { wrapText, getBoundTextMaxWidth } from "../element/textElement";
+import { getBoundTextMaxWidth } from "../element/textElement";
+import { wrapText } from "../element/textMeasurements";
 import * as textElementUtils from "../element/textElement";
 import { ROUNDNESS } from "../constants";