diff options
Diffstat (limited to 'packages/excalidraw/element/textMeasurements.ts')
| -rw-r--r-- | packages/excalidraw/element/textMeasurements.ts | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/packages/excalidraw/element/textMeasurements.ts b/packages/excalidraw/element/textMeasurements.ts new file mode 100644 index 0000000..f2a132a --- /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<number> } = {}; + + 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); +}; |
