Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-02-27 14:18:41 -06:00
commit 4c939cefad
85 changed files with 902 additions and 820 deletions

View file

@ -22,15 +22,15 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getBoundTextElement,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureTextElement,
normalizeText,
wrapTextElement,
getMaxContainerWidth,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
import { getSubtypeMethods, isValidSubtype } from "../subtypes";
@ -189,7 +189,6 @@ export const newTextElement = (
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
},
@ -206,18 +205,12 @@ const getAdjustedDimensions = (
y: number;
width: number;
height: number;
baseline: number;
} => {
let maxWidth = null;
const container = getContainerElement(element);
if (container) {
maxWidth = getMaxContainerWidth(container);
}
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureTextElement(element, { text: nextText }, maxWidth);
const { width: nextWidth, height: nextHeight } = measureTextElement(element, {
text: nextText,
});
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@ -226,11 +219,9 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureTextElement(
element,
{ fontSize: element.fontSize },
maxWidth,
);
const prevMetrics = measureTextElement(element, {
fontSize: element.fontSize,
});
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@ -294,7 +285,6 @@ const getAdjustedDimensions = (
height: nextHeight,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
baseline: nextBaseline,
};
};
@ -312,38 +302,6 @@ export const refreshTextDimensions = (
return { text, ...dimensions };
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = (
textElement: ExcalidrawTextElement,
{

View file

@ -43,12 +43,10 @@ import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
measureTextElement,
getMaxContainerWidth,
} from "./textElement";
import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
@ -192,11 +190,10 @@ const rescalePointsInElement = (
const MIN_FONT_SIZE = 1;
const measureFontSizeFromWH = (
const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number,
nextHeight: number,
): { size: number; baseline: number } | null => {
): number | null => {
// We only use width to scale font on resize
let width = element.width;
@ -211,15 +208,8 @@ const measureFontSizeFromWH = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureTextElement(
element,
{ fontSize: nextFontSize },
element.containerId ? width : null,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
return nextFontSize;
};
const getSidesForTransformHandle = (
@ -290,8 +280,8 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
if (nextFont === null) {
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
if (nextFontSize === null) {
return;
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@ -315,10 +305,9 @@ const resizeSingleTextElement = (
deltaY2,
);
mutateElement(element, {
fontSize: nextFont.size,
fontSize: nextFontSize,
width: nextWidth,
height: nextHeight,
baseline: nextFont.baseline,
x: nextElementX,
y: nextElementY,
});
@ -371,7 +360,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number; baseline?: number } = {};
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) {
@ -423,23 +412,24 @@ export const resizeSingleElement = (
if (stateOfBoundTextElementAtResize) {
boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
}
if (shouldMaintainAspectRatio) {
const boundTextElementPadding =
getBoundTextElementOffset(boundTextElement);
const nextFont = measureFontSizeFromWH(
const updatedElement = {
...element,
width: eleNewWidth,
height: eleNewHeight,
};
const nextFontSize = measureFontSizeFromWidth(
boundTextElement,
eleNewWidth - boundTextElementPadding * 2,
eleNewHeight - boundTextElementPadding * 2,
getMaxContainerWidth(updatedElement),
);
if (nextFont === null) {
if (nextFontSize === null) {
return;
}
boundTextFont = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
fontSize: nextFontSize,
};
} else {
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
@ -683,7 +673,6 @@ const resizeMultipleElements = (
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
@ -692,31 +681,32 @@ const resizeMultipleElements = (
...rescaledPoints,
};
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
let boundTextUpdates: { fontSize: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
const textMeasurements = measureFontSizeFromWH(
const updatedElement = {
...element.latest,
width,
height,
};
const fontSize = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
height - optionalPadding,
getMaxContainerWidth(updatedElement),
);
if (!textMeasurements) {
if (!fontSize) {
return;
}
if (isTextElement(element.orig)) {
update.fontSize = textMeasurements.size;
update.baseline = textMeasurements.baseline;
update.fontSize = fontSize;
}
if (boundTextElement) {
boundTextUpdates = {
fontSize: textMeasurements.size,
baseline: textMeasurements.baseline,
fontSize,
};
}
}

View file

@ -1,5 +1,12 @@
import { BOUND_TEXT_PADDING } from "../constants";
import { measureText, wrapText } from "./textElement";
import { API } from "../tests/helpers/api";
import {
computeContainerHeightForBoundText,
getContainerCoords,
getMaxContainerWidth,
getMaxContainerHeight,
wrapText,
} from "./textElement";
import { FontString } from "./types";
describe("Test wrapText", () => {
@ -65,6 +72,13 @@ up`,
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);
@ -72,6 +86,7 @@ up`,
});
});
});
describe("When text contain new lines", () => {
const text = `Hello
whats up`;
@ -162,35 +177,115 @@ break it now`,
});
describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
const text = "Hello World";
describe("Test getContainerCoords", () => {
const params = { width: 200, height: 100, x: 10, y: 20 };
it("should add correct attributes when maxWidth is passed", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = measureText(text, font, maxWidth);
it("should compute coords correctly when ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 44.2893218813452455,
y: 39.64466094067262,
});
});
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
it("should compute coords correctly when rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 15,
y: 25,
});
});
it("should compute coords correctly when diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 65,
y: 50,
});
});
});
it("should add correct attributes when maxWidth is not passed", () => {
const res = measureText(text, font);
describe("Test computeContainerHeightForBoundText", () => {
const params = {
width: 178,
height: 194,
};
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
it("should compute container height correctly for rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(computeContainerHeightForBoundText(element, 150)).toEqual(160);
});
it("should compute container height correctly for ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(computeContainerHeightForBoundText(element, 150)).toEqual(226);
});
it("should compute container height correctly for diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(computeContainerHeightForBoundText(element, 150)).toEqual(320);
});
});
describe("Test getMaxContainerWidth", () => {
const params = {
width: 178,
height: 194,
};
it("should return max width when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerWidth(container)).toBe(168);
});
it("should return max width when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerWidth(container)).toBe(116);
});
it("should return max width when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerWidth(container)).toBe(79);
});
});
describe("Test getMaxContainerHeight", () => {
const params = {
width: 178,
height: 194,
};
it("should return max height when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerHeight(container)).toBe(184);
});
it("should return max height when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerHeight(container)).toBe(127);
});
it("should return max height when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerHeight(container)).toBe(87);
});
});
});

View file

@ -13,7 +13,6 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import {
isBoundToContainer,
isImageElement,
@ -30,16 +29,16 @@ import {
updateOriginalContainerCache,
} from "./textWysiwyg";
export const measureTextElement = function (element, next, maxWidth) {
export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next, maxWidth);
return map.measureText(element, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, maxWidth);
return measureText(text, font);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
@ -69,78 +68,69 @@ export const redrawTextBoundingBox = (
container: ExcalidrawElement | null,
) => {
let maxWidth = undefined;
let text = textElement.text;
const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
};
boundTextUpdates.text = textElement.text;
if (container) {
maxWidth = getMaxContainerWidth(container);
text = wrapTextElement(textElement, maxWidth);
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
}
const width = measureTextElement(
textElement,
{ text: textElement.originalText },
maxWidth,
).width;
const { height, baseline } = measureTextElement(textElement, { text });
const metrics = { width, height, baseline };
let coordY = textElement.y;
let coordX = textElement.x;
const metrics = measureTextElement(textElement, {
text: boundTextUpdates.text,
});
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX += textElement.width - metrics.width;
boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
coordX += textElement.width / 2 - metrics.width / 2;
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
}
}
// Resize container and vertically center align the text
if (container) {
if (!isArrowElement(container)) {
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
BOUND_TEXT_PADDING;
} else {
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + BOUND_TEXT_PADDING;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x +
containerDims.width -
metrics.width -
BOUND_TEXT_PADDING;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
mutateElement(container, { height: nextHeight });
} else {
if (isArrowElement(container)) {
const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2;
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
} else {
const containerDims = getContainerDims(container);
let maxContainerHeight = getMaxContainerHeight(container);
let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerHeightForBoundText(
container,
metrics.height,
);
mutateElement(container, { height: nextHeight });
maxContainerHeight = getMaxContainerHeight(container);
updateOriginalContainerCache(container.id, nextHeight);
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
}
}
mutateElement(textElement, {
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
y: coordY,
x: coordX,
text,
});
mutateElement(textElement, boundTextUpdates);
};
export const bindTextToShapeAfterDuplication = (
@ -212,23 +202,21 @@ export const handleBindTextResize = (
const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapTextElement(textElement, maxWidth);
}
const dimensions = measureTextElement(
textElement,
{ text },
container.width,
);
const dimensions = measureTextElement(textElement, { text });
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
containerHeight = computeContainerHeightForBoundText(
container,
nextHeight,
);
const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
@ -248,94 +236,64 @@ export const handleBindTextResize = (
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
updateBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
mutateElement(
textElement,
computeBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
),
);
}
}
};
const updateBoundTextPosition = (
const computeBoundTextPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const containerDims = getContainerDims(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
const containerCoords = getContainerCoords(container);
const maxContainerHeight = getMaxContainerHeight(container);
const maxContainerWidth = getMaxContainerWidth(container);
let x;
let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding;
y = containerCoords.y;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y =
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
} else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
y =
containerCoords.y +
(maxContainerHeight / 2 - boundTextElement.height / 2);
}
const x =
boundTextElement.textAlign === TEXT_ALIGN.LEFT
? container.x + boundTextElementPadding
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
? container.x +
containerDims.width -
boundTextElement.width -
boundTextElementPadding
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
mutateElement(boundTextElement, { x, y });
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
x = containerCoords.x;
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
} else {
x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
}
return { x, y };
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
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 container = document.createElement("div");
container.style.position = "absolute";
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
// since we are adding a span of width 1px later
container.style.maxWidth = `${maxWidth + 1}px`;
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
container.style.whiteSpace = "pre-wrap";
}
document.body.appendChild(container);
container.innerText = text;
const height = getTextHeight(text, font);
const width = getTextWidth(text, font);
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.overflow = "hidden";
span.style.width = "1px";
span.style.height = "1px";
container.appendChild(span);
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
// since we are adding a span of width 1px
const width = container.offsetWidth + 1;
const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) {
return { width, height, baseline, container };
}
return { width, height, baseline };
return { width, height };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
@ -345,40 +303,47 @@ export const getApproxLineHeight = (font: FontString) => {
if (cacheApproxLineHeight[font]) {
return cacheApproxLineHeight[font];
}
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
const fontSize = parseInt(font);
// Calculate line height relative to font size
cacheApproxLineHeight[font] = fontSize * 1.2;
return cacheApproxLineHeight[font];
};
let canvas: HTMLCanvasElement | undefined;
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;
const metrics = canvas2dContext.measureText(text);
// 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 metrics.width * 10;
return width * 10;
}
// Since measureText behaves differently in different browsers
// OS so considering a adjustment factor of 0.2
const adjustmentFactor = 0.2;
return metrics.width + adjustmentFactor;
return width;
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n");
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 = getApproxLineHeight(font);
return lineHeight * lines.length;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = [];
const originalLines = text.split("\n");
@ -400,16 +365,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let currentLineWidthTillNow = 0;
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
if (currentWordWidth >= maxWidth) {
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
@ -510,9 +482,9 @@ export const charWidth = (() => {
getCache,
};
})();
export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@ -652,6 +624,26 @@ export const getContainerCenter = (
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
};
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
}
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
if (container.type === "diamond") {
offsetX += container.width / 4;
offsetY += container.height / 4;
}
return {
x: container.x + offsetX,
y: container.y + offsetY,
};
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) {
@ -664,12 +656,13 @@ export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null,
) => {
const container = getContainerElement(boundTextElement);
if (!container) {
if (!container || !boundTextElement) {
return 0;
}
if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8;
}
return BOUND_TEXT_PADDING;
};
@ -754,3 +747,76 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
isArrowElement(element)
);
};
export const computeContainerHeightForBoundText = (
container: NonDeletedExcalidrawElement,
boundTextElementHeight: number,
) => {
if (container.type === "ellipse") {
return Math.round(
((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2,
);
}
if (isArrowElement(container)) {
return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2;
}
if (container.type === "diamond") {
return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2);
}
return boundTextElementHeight + BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
if (container.type === "ellipse") {
// The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The width of the largest rectangle inscribed inside a rhombus is
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
if (container.type === "ellipse") {
// The height of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The height of the largest rectangle inscribed inside a rhombus is
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
}
return height - BOUND_TEXT_PADDING * 2;
};

View file

@ -6,14 +6,11 @@ import { CODES, KEYS } from "../keys";
import { fireEvent } from "../tests/test-utils";
import { queryByText } from "@testing-library/react";
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { FONT_FAMILY } from "../constants";
import {
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "./types";
import * as textElementUtils from "./textElement";
import { getFontString } from "../utils";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
@ -442,17 +439,6 @@ describe("textWysiwyg", () => {
let rectangle: any;
const { h } = window;
const DUMMY_HEIGHT = 240;
const DUMMY_WIDTH = 160;
const APPROX_LINE_HEIGHT = 25;
const INITIAL_WIDTH = 10;
beforeAll(() => {
jest
.spyOn(textElementUtils, "getApproxLineHeight")
.mockReturnValue(APPROX_LINE_HEIGHT);
});
beforeEach(async () => {
await render(<ExcalidrawApp />);
h.elements = [];
@ -734,53 +720,6 @@ describe("textWysiwyg", () => {
});
it("should wrap text and vertcially center align once text submitted", async () => {
const mockMeasureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
if (text === "Hello \nWorld!") {
height = APPROX_LINE_HEIGHT * 2;
}
if (maxWidth) {
width = maxWidth;
// To capture cases where maxWidth passed is initial width
// due to which the text is not wrapped correctly
if (maxWidth === INITIAL_WIDTH) {
height = DUMMY_HEIGHT;
}
}
return {
width,
height,
baseline,
};
};
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
jest
.spyOn(textElementUtils, "measureTextElement")
.mockImplementation((element, next, maxWidth) => {
return mockMeasureText(
next?.text ?? element.text,
getFontString(element),
maxWidth,
);
});
expect(h.elements.length).toBe(1);
Keyboard.keyDown(KEYS.ENTER);
@ -789,11 +728,6 @@ describe("textWysiwyg", () => {
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
fireEvent.change(editor, {
target: {
value: "Hello World!",
@ -808,11 +742,11 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2,
rectangle.y + h.elements[0].height / 2 - text.height / 2,
);
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
expect(text.x).toBe(25);
expect(text.height).toBe(48);
expect(text.width).toBe(60);
// Edit and text by removing second line and it should
// still vertically align correctly
@ -829,11 +763,6 @@ describe("textWysiwyg", () => {
},
});
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT);
editor.style.height = "25px";
editor.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 0));
@ -843,12 +772,12 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello");
expect(text.height).toBe(24);
expect(text.width).toBe(50);
expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2,
rectangle.y + h.elements[0].height / 2 - text.height / 2,
);
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
expect(text.height).toBe(APPROX_LINE_HEIGHT);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
expect(text.x).toBe(30);
});
it("should unbind bound text when unbind action from context menu is triggered", async () => {
@ -935,8 +864,8 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
109.5,
17,
85,
5,
]
`);
@ -950,6 +879,8 @@ describe("textWysiwyg", () => {
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0));
@ -960,7 +891,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
90,
66,
]
`);
@ -983,7 +914,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
424,
375,
-539,
]
`);
@ -1098,9 +1029,9 @@ describe("textWysiwyg", () => {
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85);
expect(text.x).toBe(89.5);
expect(text.y).toBe(90);
expect(rectangle.y).toBe(-35);
expect(text.x).toBe(85);
expect(text.y).toBe(-30);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
@ -1130,43 +1061,6 @@ describe("textWysiwyg", () => {
});
it("should restore original container height and clear cache once text is unbind", async () => {
const mockMeasureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
};
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
jest
.spyOn(textElementUtils, "measureTextElement")
.mockImplementation((element, next, maxWidth) => {
return mockMeasureText(
next?.text ?? element.text,
getFontString(element),
maxWidth,
);
});
const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight);
@ -1180,7 +1074,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" },
});
editor.blur();
expect(rectangle.height).toBe(135);
expect(rectangle.height).toBe(178);
mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
@ -1206,7 +1100,7 @@ describe("textWysiwyg", () => {
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215);
expect(rectangle.height).toBe(156);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
@ -1218,13 +1112,12 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBe(215);
expect(rectangle.height).toBe(156);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
});
//@todo fix this test later once measureText is mocked correctly
it.skip("should reset the container height cache when font properties updated", async () => {
it("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
@ -1250,7 +1143,9 @@ describe("textWysiwyg", () => {
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
96.39999999999999,
);
});
describe("should align correctly", () => {
@ -1278,7 +1173,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
20,
25,
]
`);
});
@ -1288,8 +1183,8 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
94.5,
20,
30,
25,
]
`);
});
@ -1299,22 +1194,22 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
174,
20,
]
`);
Array [
45,
25,
]
`);
});
it("when center left", async () => {
fireEvent.click(screen.getByTitle("Center vertically"));
fireEvent.click(screen.getByTitle("Left"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
25,
]
`);
Array [
15,
45.5,
]
`);
});
it("when center center", async () => {
@ -1322,11 +1217,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
-25,
25,
]
`);
Array [
30,
45.5,
]
`);
});
it("when center right", async () => {
@ -1334,11 +1229,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
174,
25,
]
`);
Array [
45,
45.5,
]
`);
});
it("when bottom left", async () => {
@ -1346,33 +1241,33 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
15,
25,
]
`);
Array [
15,
66,
]
`);
});
it("when bottom center", async () => {
fireEvent.click(screen.getByTitle("Center"));
fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
94.5,
25,
]
`);
Array [
30,
66,
]
`);
});
it("when bottom right", async () => {
fireEvent.click(screen.getByTitle("Right"));
fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
174,
25,
]
`);
Array [
45,
66,
]
`);
});
});
});

View file

@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants";
import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -24,14 +24,17 @@ import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerCoords,
getContainerDims,
getContainerElement,
getTextElementAngle,
measureText,
getTextWidth,
measureText,
normalizeText,
redrawTextBoundingBox,
wrapText,
getMaxContainerHeight,
getMaxContainerWidth,
} from "./textElement";
import {
actionDecreaseFontSize,
@ -39,7 +42,6 @@ import {
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
@ -155,19 +157,23 @@ export const textWysiwyg = ({
if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y;
let eCoordY = coordY;
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
// Editing metrics
const eMetrics = measureText(
updatedTextElement.originalText,
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
getMaxContainerWidth(container),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
container ? getContainerDims(container).width : null,
);
let maxWidth = eMetrics.width;
let maxHeight = eMetrics.height;
const width = eMetrics.width;
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
// Set to element height by default since that's
// what is going to be used for unbounded text
let textElementHeight = Math.max(updatedTextElement.height, maxHeight);
@ -181,7 +187,6 @@ export const textWysiwyg = ({
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
eCoordY = coordY;
}
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
@ -198,7 +203,11 @@ export const textWysiwyg = ({
const font = getFontString(updatedTextElement);
textElementHeight =
getApproxLineHeight(font) *
updatedTextElement.text.split("\n").length;
wrapText(
updatedTextElement.originalText,
font,
getMaxContainerWidth(container),
).split("\n").length;
textElementHeight = Math.max(
textElementHeight,
updatedTextElement.height,
@ -248,25 +257,21 @@ export const textWysiwyg = ({
// Start pushing text upward until a diff of 30px (padding)
// is reached
else {
const containerCoords = getContainerCoords(container);
// vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) {
coordY =
container.y + containerDims.height / 2 - textElementHeight / 2;
eCoordY = coordY + textElementHeight / 2 - eMetrics.height / 2;
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
}
}
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
eCoordY = coordY + textElementHeight - eMetrics.height;
coordY = containerCoords.y + (maxHeight - textElementHeight);
}
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, eCoordY);
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
const initialSelectionStart = editable.selectionStart;
const initialSelectionEnd = editable.selectionEnd;
const initialLength = editable.value.length;
@ -308,6 +313,12 @@ export const textWysiwyg = ({
: 0;
const { width: w, height: h } = updatedTextElement;
let transformWidth = updatedTextElement.width;
// As firefox, Safari needs little higher dimensions on DOM
if (isFirefox || isSafari) {
textElementWidth += 0.5;
transformWidth += 0.5;
}
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
@ -315,14 +326,14 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
width: `${Math.min(width, maxWidth)}px`,
width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transformOrigin: `${w / 2}px ${h / 2}px`,
transform: getTransform(
offsetX,
updatedTextElement.width,
transformWidth,
updatedTextElement.height,
getTextElementAngle(updatedTextElement),
appState,
@ -415,55 +426,16 @@ export const textWysiwyg = ({
id,
) as ExcalidrawTextElement;
const font = getFontString(updatedTextElement);
// using scrollHeight here since we need to calculate
// number of lines so cannot use editable.style.height
// as that gets updated below
// Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
// auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for
// first line as well as setting height to "auto"
// doubles the height as soon as user starts typing
if (isBoundToContainer(element) && lines > 1) {
if (isBoundToContainer(element)) {
const container = getContainerElement(element);
let height = "auto";
editable.style.height = "0px";
let heightSet = false;
if (lines === 2) {
const actualLineCount = wrapText(
editable.value,
font,
getMaxContainerWidth(container!),
).split("\n").length;
// This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well
// hence reducing the height by half if actual line count is 1
// so single line aligns vertically when deleting
if (actualLineCount === 1) {
height = `${editable.scrollHeight / 2}px`;
editable.style.height = height;
heightSet = true;
}
}
const wrappedText = wrapText(
normalizeText(editable.value),
font,
getMaxContainerWidth(container!),
);
const width = getTextWidth(wrappedText, font);
const { width, height } = measureText(wrappedText, font);
editable.style.width = `${width}px`;
if (!heightSet) {
editable.style.height = `${editable.scrollHeight}px`;
}
editable.style.height = `${height}px`;
}
onChange(normalizeText(editable.value));
};
@ -500,7 +472,9 @@ export const textWysiwyg = ({
event.code === CODES.BRACKET_RIGHT))
) {
event.preventDefault();
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
if (event.isComposing) {
return;
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
outdent();
} else {
indent();
@ -649,6 +623,7 @@ export const textWysiwyg = ({
),
});
}
redrawTextBoundingBox(updateElement, container);
}
onSubmit({

View file

@ -132,7 +132,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number;
fontFamily: FontFamilyValues;
text: string;
baseline: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;