diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 94384134d..129b7871e 100644 --- a/src/actions/actionBoundText.tsx +++ b/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, diff --git a/src/components/App.tsx b/src/components/App.tsx index 67403e0d9..3a31723be 100644 --- a/src/components/App.tsx +++ b/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, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 86822d072..852cf58ce 100644 --- a/src/element/newElement.ts +++ b/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, diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 7b4ba93e5..81f0e418d 100644 --- a/src/element/resizeElements.ts +++ b/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; diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index 45ba1499a..399383663 100644 --- a/src/element/textElement.test.ts +++ b/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", () => { diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 215a28206..78ed7ae1f 100644 --- a/src/element/textElement.ts +++ b/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 = []; - 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 } = {}; - - 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; -}; diff --git a/src/element/textMeasurements.test.ts b/src/element/textMeasurements.test.ts new file mode 100644 index 000000000..0bf63c2cb --- /dev/null +++ b/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`); + }); +}); diff --git a/src/element/textMeasurements.ts b/src/element/textMeasurements.ts new file mode 100644 index 000000000..6328d8619 --- /dev/null +++ b/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 } = {}; + + 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 = []; + 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; +}; diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index d79d21de8..911233029 100644 --- a/src/element/textWysiwyg.tsx +++ b/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, diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 7c9ae6a40..2efa8f5d9 100644 --- a/src/renderer/renderElement.ts +++ b/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 diff --git a/src/tests/clipboard.test.tsx b/src/tests/clipboard.test.tsx index 784226d67..54718a9a3 100644 --- a/src/tests/clipboard.test.tsx +++ b/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"; diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 7ef3a42d3..101400e36 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/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";