From e3060dfb8fbb34e3c6773f566b188a6f3c4c98f0 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 11 Feb 2025 14:23:08 +0100 Subject: [PATCH] feat: custom text metrics provider (#9121) --- .../excalidraw/actions/actionBoundText.tsx | 2 +- .../actions/actionTextAutoResize.ts | 2 +- packages/excalidraw/components/App.tsx | 16 +- packages/excalidraw/components/SearchMenu.tsx | 4 +- packages/excalidraw/data/restore.ts | 3 +- packages/excalidraw/data/transform.ts | 2 +- packages/excalidraw/element/dragElements.ts | 3 +- packages/excalidraw/element/newElement.ts | 7 +- packages/excalidraw/element/resizeElements.ts | 10 +- .../excalidraw/element/textElement.test.ts | 3 +- packages/excalidraw/element/textElement.ts | 230 +----------------- .../excalidraw/element/textMeasurements.ts | 224 +++++++++++++++++ packages/excalidraw/element/textWrapping.ts | 12 +- packages/excalidraw/element/textWysiwyg.tsx | 6 +- packages/excalidraw/fonts/Fonts.ts | 3 +- packages/excalidraw/index.tsx | 2 + packages/excalidraw/renderer/renderElement.ts | 2 +- .../excalidraw/renderer/staticSvgScene.ts | 2 +- packages/excalidraw/tests/clipboard.test.tsx | 2 +- 19 files changed, 268 insertions(+), 267 deletions(-) create mode 100644 packages/excalidraw/element/textMeasurements.ts diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index f47346036..d6386ab27 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -10,7 +10,6 @@ import { computeBoundTextPosition, computeContainerDimensionForBoundText, getBoundTextElement, - measureText, redrawTextBoundingBox, } from "../element/textElement"; import { @@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils"; import { register } from "./register"; import { syncMovedIndices } from "../fractionalIndex"; import { StoreAction } from "../store"; +import { measureText } from "../element/textMeasurements"; export const actionUnbindText = register({ name: "unbindText", diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index 3093f3090..cbf9684e4 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -1,6 +1,6 @@ import { isTextElement } from "../element"; import { newElementWith } from "../element/mutateElement"; -import { measureText } from "../element/textElement"; +import { measureText } from "../element/textMeasurements"; import { getSelectedElements } from "../scene"; import { StoreAction } from "../store"; import type { AppClassProperties } from "../types"; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 663b29193..326203359 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -331,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem"; import { fileOpen } from "../data/filesystem"; import { bindTextToShapeAfterDuplication, - getApproxMinLineHeight, - getApproxMinLineWidth, getBoundTextElement, getContainerCenter, getContainerElement, - getLineHeightInPx, - getMinTextElementWidth, - isMeasureTextSupported, isValidTextContainer, - measureText, - normalizeText, } from "../element/textElement"; import { showHyperlinkTooltip, @@ -465,6 +458,15 @@ import { cropElement } from "../element/cropElement"; import { wrapText } from "../element/textWrapping"; import { actionCopyElementLink } from "../actions/actionElementLink"; import { isElementLink, parseElementLinkFromURL } from "../element/elementLink"; +import { + isMeasureTextSupported, + normalizeText, + measureText, + getLineHeightInPx, + getApproxMinLineWidth, + getApproxMinLineHeight, + getMinTextElementWidth, +} from "../element/textMeasurements"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx index c8bf08ac3..f9b3143f3 100644 --- a/packages/excalidraw/components/SearchMenu.tsx +++ b/packages/excalidraw/components/SearchMenu.tsx @@ -7,7 +7,6 @@ import { debounce } from "lodash"; import type { AppClassProperties } from "../types"; import { isTextElement, newTextElement } from "../element"; import type { ExcalidrawTextElement } from "../element/types"; -import { measureText } from "../element/textElement"; import { addEventListener, getFontString } from "../utils"; import { KEYS } from "../keys"; import clsx from "clsx"; @@ -20,6 +19,7 @@ import { useStable } from "../hooks/useStable"; import "./SearchMenu.scss"; import { round } from "../../math"; +import { measureText } from "../element/textMeasurements"; const searchQueryAtom = atom(""); export const searchItemInFocusAtom = atom(null); @@ -607,7 +607,6 @@ const getMatchedLines = ( textToStart, getFontString(textElement), textElement.lineHeight, - true, ); // measureText returns a non-zero width for the empty string @@ -621,7 +620,6 @@ const getMatchedLines = ( lineIndexRange.line, getFontString(textElement), textElement.lineHeight, - true, ); const spaceToStart = diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 42695b413..2ee843376 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -46,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement"; import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import type { MarkOptional, Mutable } from "../utility-types"; -import { detectLineHeight, getContainerElement } from "../element/textElement"; +import { getContainerElement } from "../element/textElement"; import { normalizeLink } from "./url"; import { syncInvalidIndices } from "../fractionalIndex"; import { getSizeFromPoints } from "../points"; @@ -59,6 +59,7 @@ import { } from "../scene"; import type { LocalPoint, Radians } from "../../math"; import { isFiniteNumber, pointFrom } from "../../math"; +import { detectLineHeight } from "../element/textMeasurements"; type RestoredAppState = Omit< AppState, diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index d1fab0db9..f15de763c 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -19,7 +19,6 @@ import { newMagicFrameElement, newTextElement, } from "../element/newElement"; -import { measureText, normalizeText } from "../element/textElement"; import type { ElementsMap, ExcalidrawArrowElement, @@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex"; import { getLineHeight } from "../fonts"; import { isArrowElement } from "../element/typeChecks"; import { pointFrom, type LocalPoint } from "../../math"; +import { measureText, normalizeText } from "../element/textMeasurements"; export type ValidLinearElement = { type: "arrow" | "line"; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 1fd771ba4..bb8fd237e 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -10,7 +10,7 @@ import type { NullableGridSize, PointerDownState, } from "../types"; -import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; +import { getBoundTextElement } from "./textElement"; import type Scene from "../scene/Scene"; import { isArrowElement, @@ -22,6 +22,7 @@ import { import { getFontString } from "../utils"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import { getGridPoint } from "../snapping"; +import { getMinTextElementWidth } from "./textMeasurements"; export const dragSelectedElements = ( pointerDownState: PointerDownState, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 58b5ec43f..e1039537c 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -33,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups"; import type { AppState } from "../types"; import { getElementAbsoluteCoords } from "."; import { getResizedElementAbsoluteCoords } from "./bounds"; -import { - measureText, - normalizeText, - getBoundTextMaxWidth, -} from "./textElement"; +import { getBoundTextMaxWidth } from "./textElement"; import { wrapText } from "./textWrapping"; import { DEFAULT_ELEMENT_PROPS, @@ -51,6 +47,7 @@ import { import type { MarkOptional, Merge, Mutable } from "../utility-types"; import { getLineHeight } from "../fonts"; import type { Radians } from "../../math"; +import { normalizeText, measureText } from "./textMeasurements"; export type ElementConstructorOpts = MarkOptional< Omit, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 1d1ceb65b..4b46757c6 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -41,15 +41,11 @@ import type { import type { PointerDownState } from "../types"; import type Scene from "../scene/Scene"; import { - getApproxMinLineWidth, getBoundTextElement, getBoundTextElementId, getContainerElement, handleBindTextResize, getBoundTextMaxWidth, - getApproxMinLineHeight, - measureText, - getMinTextElementWidth, } from "./textElement"; import { wrapText } from "./textWrapping"; import { LinearElementEditor } from "./linearElementEditor"; @@ -64,6 +60,12 @@ import { type Radians, type LocalPoint, } from "../../math"; +import { + getMinTextElementWidth, + measureText, + getApproxMinLineWidth, + getApproxMinLineHeight, +} from "./textMeasurements"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index cfc078c81..2c23c2b06 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -6,9 +6,8 @@ import { getContainerCoords, getBoundTextMaxWidth, getBoundTextMaxHeight, - detectLineHeight, - getLineHeightInPx, } from "./textElement"; +import { detectLineHeight, getLineHeightInPx } from "./textMeasurements"; import type { ExcalidrawTextElementWithContainer } from "./types"; describe("Test measureText", () => { diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 8c4bc5988..de948d9ce 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -1,4 +1,4 @@ -import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; +import { getFontString, arrayToMap } from "../utils"; import type { ElementsMap, ExcalidrawElement, @@ -6,7 +6,6 @@ import type { ExcalidrawTextContainer, ExcalidrawTextElement, ExcalidrawTextElementWithContainer, - FontString, NonDeletedExcalidrawElement, } from "./types"; import { mutateElement } from "./mutateElement"; @@ -14,7 +13,6 @@ import { ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, ARROW_LABEL_WIDTH_FRACTION, BOUND_TEXT_PADDING, - DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, TEXT_ALIGN, VERTICAL_ALIGN, @@ -30,18 +28,7 @@ import { updateOriginalContainerCache, } from "./containerCache"; import type { ExtractSetType } from "../utility-types"; - -export const normalizeText = (text: string) => { - return ( - normalizeEOL(text) - // replace tabs with spaces so they render and measure correctly - .replace(/\t/g, " ") - ); -}; - -const splitIntoLines = (text: string) => { - return normalizeText(text).split("\n"); -}; +import { measureText } from "./textMeasurements"; export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, @@ -281,201 +268,6 @@ export const computeBoundTextPosition = ( return { x, y }; }; -export const measureText = ( - text: string, - font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], - forceAdvanceWidth?: true, -) => { - const _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 fontSize = parseFloat(font); - const height = getTextHeight(_text, fontSize, lineHeight); - const width = getTextWidth(_text, font, forceAdvanceWidth); - return { width, height }; -}; - -/** - * To get unitless line-height (if unknown) we can calculate it by dividing - * height-per-line by fontSize. - */ -export const detectLineHeight = (textElement: ExcalidrawTextElement) => { - const lineCount = splitIntoLines(textElement.text).length; - return (textElement.height / - lineCount / - textElement.fontSize) as ExcalidrawTextElement["lineHeight"]; -}; - -/** - * We calculate the line height from the font size and the unitless line height, - * aligning with the W3C spec. - */ -export const getLineHeightInPx = ( - fontSize: ExcalidrawTextElement["fontSize"], - lineHeight: ExcalidrawTextElement["lineHeight"], -) => { - return fontSize * lineHeight; -}; - -// FIXME rename to getApproxMinContainerHeight -export const getApproxMinLineHeight = ( - fontSize: ExcalidrawTextElement["fontSize"], - lineHeight: ExcalidrawTextElement["lineHeight"], -) => { - return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; -}; - -let canvas: HTMLCanvasElement | undefined; - -/** - * @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width. - * - * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position. - * - * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for: - * - text wrapping - * - wysiwyg editor (+padding) - * - * Everything else should be based on the actual bounding box width. - * - * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies. - */ -export const getLineWidth = ( - text: string, - font: FontString, - forceAdvanceWidth?: true, -) => { - if (!canvas) { - canvas = document.createElement("canvas"); - } - const canvas2dContext = canvas.getContext("2d")!; - canvas2dContext.font = font; - const metrics = canvas2dContext.measureText(text); - - const advanceWidth = metrics.width; - - // retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage) - if ( - !forceAdvanceWidth && - window.TextMetrics && - "actualBoundingBoxLeft" in window.TextMetrics.prototype && - "actualBoundingBoxRight" in window.TextMetrics.prototype - ) { - // could be negative, therefore getting the absolute value - const actualWidth = - Math.abs(metrics.actualBoundingBoxLeft) + - Math.abs(metrics.actualBoundingBoxRight); - - // fallback to advance width if the actual width is zero, i.e. on text editing start - // or when actual width does not respect whitespace chars, i.e. spaces - // otherwise actual width should always be bigger - return Math.max(actualWidth, advanceWidth); - } - - // 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 advanceWidth * 10; - } - - return advanceWidth; -}; - -export const getTextWidth = ( - text: string, - font: FontString, - forceAdvanceWidth?: true, -) => { - const lines = splitIntoLines(text); - let width = 0; - lines.forEach((line) => { - width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth)); - }); - - return width; -}; - -export const getTextHeight = ( - text: string, - fontSize: number, - lineHeight: ExcalidrawTextElement["lineHeight"], -) => { - const lineCount = splitIntoLines(text).length; - return getLineHeightInPx(fontSize, lineHeight) * lineCount; -}; - -export const charWidth = (() => { - const cachedCharWidth: { [key: FontString]: Array } = {}; - - const calculate = (char: string, font: FontString) => { - const unicode = char.charCodeAt(0); - if (!cachedCharWidth[font]) { - cachedCharWidth[font] = []; - } - if (!cachedCharWidth[font][unicode]) { - const width = getLineWidth(char, font, true); - cachedCharWidth[font][unicode] = width; - } - - return cachedCharWidth[font][unicode]; - }; - - const getCache = (font: FontString) => { - return cachedCharWidth[font]; - }; - - const clearCache = (font: FontString) => { - cachedCharWidth[font] = []; - }; - - return { - calculate, - getCache, - clearCache, - }; -})(); - -const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); - -// FIXME rename to getApproxMinContainerWidth -export const getApproxMinLineWidth = ( - font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], -) => { - const maxCharWidth = getMaxCharWidth(font); - if (maxCharWidth === 0) { - return ( - measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + - BOUND_TEXT_PADDING * 2 - ); - } - return maxCharWidth + 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 getBoundTextElementId = (container: ExcalidrawElement | null) => { return container?.boundElements?.length ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null @@ -712,24 +504,6 @@ 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; -}; - -export const getMinTextElementWidth = ( - font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], -) => { - return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; -}; - /** retrieves text from text elements and concatenates to a single string */ export const getTextFromElements = ( elements: readonly ExcalidrawElement[], diff --git a/packages/excalidraw/element/textMeasurements.ts b/packages/excalidraw/element/textMeasurements.ts new file mode 100644 index 000000000..f2a132a3a --- /dev/null +++ b/packages/excalidraw/element/textMeasurements.ts @@ -0,0 +1,224 @@ +import { + BOUND_TEXT_PADDING, + DEFAULT_FONT_SIZE, + DEFAULT_FONT_FAMILY, +} from "../constants"; +import { getFontString, isTestEnv, normalizeEOL } from "../utils"; +import type { FontString, ExcalidrawTextElement } from "./types"; + +export const measureText = ( + text: string, + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const _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 fontSize = parseFloat(font); + const height = getTextHeight(_text, fontSize, lineHeight); + const width = getTextWidth(_text, font); + return { width, height }; +}; + +const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); + +// FIXME rename to getApproxMinContainerWidth +export const getApproxMinLineWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const maxCharWidth = getMaxCharWidth(font); + if (maxCharWidth === 0) { + return ( + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + + BOUND_TEXT_PADDING * 2 + ); + } + return maxCharWidth + BOUND_TEXT_PADDING * 2; +}; + +export const getMinTextElementWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; +}; + +export const isMeasureTextSupported = () => { + const width = getTextWidth( + DUMMY_TEXT, + getFontString({ + fontSize: DEFAULT_FONT_SIZE, + fontFamily: DEFAULT_FONT_FAMILY, + }), + ); + return width > 0; +}; + +export const normalizeText = (text: string) => { + return ( + normalizeEOL(text) + // replace tabs with spaces so they render and measure correctly + .replace(/\t/g, " ") + ); +}; + +const splitIntoLines = (text: string) => { + return normalizeText(text).split("\n"); +}; + +/** + * To get unitless line-height (if unknown) we can calculate it by dividing + * height-per-line by fontSize. + */ +export const detectLineHeight = (textElement: ExcalidrawTextElement) => { + const lineCount = splitIntoLines(textElement.text).length; + return (textElement.height / + lineCount / + textElement.fontSize) as ExcalidrawTextElement["lineHeight"]; +}; + +/** + * We calculate the line height from the font size and the unitless line height, + * aligning with the W3C spec. + */ +export const getLineHeightInPx = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return fontSize * lineHeight; +}; + +// FIXME rename to getApproxMinContainerHeight +export const getApproxMinLineHeight = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; +}; + +let textMetricsProvider: TextMetricsProvider | undefined; + +/** + * Set a custom text metrics provider. + * + * Useful for overriding the width calculation algorithm where canvas API is not available / desired. + */ +export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => { + textMetricsProvider = provider; +}; + +export interface TextMetricsProvider { + getLineWidth(text: string, fontString: FontString): number; +} + +class CanvasTextMetricsProvider implements TextMetricsProvider { + private canvas: HTMLCanvasElement; + + constructor() { + this.canvas = document.createElement("canvas"); + } + + /** + * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for: + * - text wrapping + * - wysiwyg editor (+padding) + * + * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position. + */ + public getLineWidth(text: string, fontString: FontString): number { + const context = this.canvas.getContext("2d")!; + context.font = fontString; + const metrics = context.measureText(text); + const advanceWidth = metrics.width; + + // 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 advanceWidth * 10; + } + + return advanceWidth; + } +} + +export const getLineWidth = (text: string, font: FontString) => { + if (!textMetricsProvider) { + textMetricsProvider = new CanvasTextMetricsProvider(); + } + + return textMetricsProvider.getLineWidth(text, font); +}; + +export const getTextWidth = (text: string, font: FontString) => { + const lines = splitIntoLines(text); + let width = 0; + lines.forEach((line) => { + width = Math.max(width, getLineWidth(line, font)); + }); + + return width; +}; + +export const getTextHeight = ( + text: string, + fontSize: number, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const lineCount = splitIntoLines(text).length; + return getLineHeightInPx(fontSize, lineHeight) * lineCount; +}; + +export const charWidth = (() => { + const cachedCharWidth: { [key: FontString]: Array } = {}; + + const calculate = (char: string, font: FontString) => { + const unicode = char.charCodeAt(0); + if (!cachedCharWidth[font]) { + cachedCharWidth[font] = []; + } + if (!cachedCharWidth[font][unicode]) { + const width = getLineWidth(char, font); + cachedCharWidth[font][unicode] = width; + } + + return cachedCharWidth[font][unicode]; + }; + + const getCache = (font: FontString) => { + return cachedCharWidth[font]; + }; + + const clearCache = (font: FontString) => { + cachedCharWidth[font] = []; + }; + + return { + calculate, + getCache, + clearCache, + }; +})(); + +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); +}; diff --git a/packages/excalidraw/element/textWrapping.ts b/packages/excalidraw/element/textWrapping.ts index 597f62e15..1913f6ed3 100644 --- a/packages/excalidraw/element/textWrapping.ts +++ b/packages/excalidraw/element/textWrapping.ts @@ -1,5 +1,5 @@ import { ENV } from "../constants"; -import { charWidth, getLineWidth } from "./textElement"; +import { charWidth, getLineWidth } from "./textMeasurements"; import type { FontString } from "./types"; let cachedCjkRegex: RegExp | undefined; @@ -385,7 +385,7 @@ export const wrapText = ( const originalLines = text.split("\n"); for (const originalLine of originalLines) { - const currentLineWidth = getLineWidth(originalLine, font, true); + const currentLineWidth = getLineWidth(originalLine, font); if (currentLineWidth <= maxWidth) { lines.push(originalLine); @@ -423,7 +423,7 @@ const wrapLine = ( // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here const testLineWidth = isSingleCharacter(token) ? currentLineWidth + charWidth.calculate(token, font) - : getLineWidth(testLine, font, true); + : getLineWidth(testLine, font); // build up the current line, skipping length check for possibly trailing whitespaces if (/\s/.test(token) || testLineWidth <= maxWidth) { @@ -443,7 +443,7 @@ const wrapLine = ( // trailing line of the wrapped word might still be joined with next token/s currentLine = trailingLine; - currentLineWidth = getLineWidth(trailingLine, font, true); + currentLineWidth = getLineWidth(trailingLine, font); iterator = tokenIterator.next(); } else { // push & reset, but don't iterate on the next token, as we didn't use it yet! @@ -514,7 +514,7 @@ const wrapWord = ( * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. */ const trimLine = (line: string, font: FontString, maxWidth: number) => { - const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth; + const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; if (!shouldTrimWhitespaces) { return line; @@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => { "", ]; - let trimmedLineWidth = getLineWidth(trimmedLine, font, true); + let trimmedLineWidth = getLineWidth(trimmedLine, font); for (const whitespace of Array.from(whitespaces)) { const _charWidth = charWidth.calculate(whitespace, font); diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index efe27bc6e..a7570862d 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -24,8 +24,6 @@ import { getBoundTextElementId, getContainerElement, getTextElementAngle, - getTextWidth, - normalizeText, redrawTextBoundingBox, getBoundTextMaxHeight, getBoundTextMaxWidth, @@ -50,6 +48,8 @@ import { originalContainerCache, updateOriginalContainerCache, } from "./containerCache"; +import { getTextWidth } from "./textMeasurements"; +import { normalizeText } from "./textMeasurements"; const getTransform = ( width: number, @@ -350,7 +350,7 @@ export const textWysiwyg = ({ font, getBoundTextMaxWidth(container, boundTextElement), ); - const width = getTextWidth(wrappedText, font, true); + const width = getTextWidth(wrappedText, font); editable.style.width = `${width}px`; } }; diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index d8f257c03..4b8ba7828 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/packages/excalidraw/fonts/Fonts.ts @@ -6,7 +6,7 @@ import { getFontFamilyFallbacks, } from "../constants"; import { isTextElement } from "../element"; -import { charWidth, getContainerElement } from "../element/textElement"; +import { getContainerElement } from "../element/textElement"; import { containsCJK } from "../element/textWrapping"; import { ShapeCache } from "../scene/ShapeCache"; import { getFontString, PromisePool, promiseTry } from "../utils"; @@ -31,6 +31,7 @@ import type { } from "../element/types"; import type Scene from "../scene/Scene"; import type { ValueOf } from "../utility-types"; +import { charWidth } from "../element/textMeasurements"; export class Fonts { // it's ok to track fonts across multiple instances only once, so let's use diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 6c9544a38..bd3d0f148 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -295,3 +295,5 @@ export { export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin"; export { getDataURL } from "./data/blob"; export { isElementLink } from "./element/elementLink"; + +export { setCustomTextMetricsProvider } from "./element/textMeasurements"; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 50da857a2..3e87ebaf5 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -52,7 +52,6 @@ import { getBoundTextElement, getContainerCoords, getContainerElement, - getLineHeightInPx, getBoundTextMaxHeight, getBoundTextMaxWidth, } from "../element/textElement"; @@ -64,6 +63,7 @@ import { getVerticalOffset } from "../fonts"; import { isRightAngleRads } from "../../math"; import { getCornerRadius } from "../shapes"; import { getUncroppedImageElement } from "../element/cropElement"; +import { getLineHeightInPx } 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/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index e6cbb8d7c..b14faf7f4 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -16,7 +16,6 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { getBoundTextElement, getContainerElement, - getLineHeightInPx, } from "../element/textElement"; import { isArrowElement, @@ -38,6 +37,7 @@ import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; import { getVerticalOffset } from "../fonts"; import { getCornerRadius, isPathALoop } from "../shapes"; import { getUncroppedWidthAndHeight } from "../element/cropElement"; +import { getLineHeightInPx } from "../element/textMeasurements"; const roughSVGDrawWithPrecision = ( rsvg: RoughSVG, diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 63e26dbc3..94c785d4a 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils"; import { Pointer, Keyboard } from "./helpers/ui"; import { Excalidraw } from "../index"; import { KEYS } from "../keys"; -import { getLineHeightInPx } from "../element/textElement"; +import { getLineHeightInPx } from "../element/textMeasurements"; import { getElementBounds } from "../element"; import type { NormalizedZoomValue } from "../types"; import { API } from "./helpers/api";