mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
feat: custom text metrics provider (#9121)
This commit is contained in:
parent
c329470b73
commit
e3060dfb8f
19 changed files with 268 additions and 267 deletions
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
|
|
@ -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<string>("");
|
||||
export const searchItemInFocusAtom = atom<number | null>(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 =
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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<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, 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[],
|
||||
|
|
224
packages/excalidraw/element/textMeasurements.ts
Normal file
224
packages/excalidraw/element/textMeasurements.ts
Normal file
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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`;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Add table
Reference in a new issue