aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element/textMeasurements.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/element/textMeasurements.ts')
-rw-r--r--packages/excalidraw/element/textMeasurements.ts224
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);
+};