feat: add line height attribute to text element (#6360)

* feat: add line height attribute to text element

* lint

* update line height when redrawing text bounding box

* fix tests

* retain line height when pasting styles

* fix test

* create a util for calculating ling height using old algo

* update line height when resizing multiple text elements

* make line height backward compatible

* udpate line height for older element when font size updated

* remove logs

* Add specs

* lint

* review fixes

* simplify by changing `lineHeight` from px to unitless

* make param non-optional

* update comment

* fix: jumping text due to font size being calculated incorrectly

* update line height when font family is updated

* lint

* Add spec

* more specs

* rename to getDefaultLineHeight

* fix getting lineHeight for potentially undefined fontFamily

* reduce duplication

* fix fallback

* refactor and comment tweaks

* fix

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2023-03-22 11:32:38 +05:30 committed by GitHub
parent ac4c8b3ca7
commit 83383977f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 326 additions and 143 deletions

View File

@ -45,6 +45,7 @@ export const actionUnbindText = register({
const { width, height } = measureText( const { width, height } = measureText(
boundTextElement.originalText, boundTextElement.originalText,
getFontString(boundTextElement), getFontString(boundTextElement),
boundTextElement.lineHeight,
); );
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,

View File

@ -54,6 +54,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getDefaultLineHeight,
} from "../element/textElement"; } from "../element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
oldElement, oldElement,
{ {
fontFamily: value, fontFamily: value,
lineHeight: getDefaultLineHeight(value),
}, },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(newElement, getContainerElement(oldElement));

View File

@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@ -92,12 +95,18 @@ export const actionPasteStyles = register({
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, { newElement = newElementWith(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, fontSize,
fontFamily: fontFamily,
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
getDefaultLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {

View File

@ -260,13 +260,14 @@ import throttle from "lodash.throttle";
import { fileOpen, FileSystemHandle } from "../data/filesystem"; import { fileOpen, FileSystemHandle } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getApproxLineHeight,
getApproxMinLineHeight, getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getTextBindableContainerAtPosition, getTextBindableContainerAtPosition,
isMeasureTextSupported, isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
@ -1731,12 +1732,14 @@ class App extends React.Component<AppProps, AppState> {
(acc: ExcalidrawTextElement[], line, idx) => { (acc: ExcalidrawTextElement[], line, idx) => {
const text = line.trim(); const text = line.trim();
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) { if (text.length) {
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x, x,
y: currentY, y: currentY,
text, text,
lineHeight,
}); });
acc.push(element); acc.push(element);
currentY += element.height + LINE_GAP; currentY += element.height + LINE_GAP;
@ -1745,14 +1748,9 @@ class App extends React.Component<AppProps, AppState> {
// add paragraph only if previous line was not empty, IOW don't add // add paragraph only if previous line was not empty, IOW don't add
// more than one empty line // more than one empty line
if (prevLine) { if (prevLine) {
const defaultLineHeight = getApproxLineHeight( currentY +=
getFontString({ getLineHeightInPx(textElementProps.fontSize, lineHeight) +
fontSize: textElementProps.fontSize, LINE_GAP;
fontFamily: textElementProps.fontFamily,
}),
);
currentY += defaultLineHeight + LINE_GAP;
} }
} }
@ -2607,6 +2605,13 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if ( if (
!existingTextElement && !existingTextElement &&
shouldBindToContainer && shouldBindToContainer &&
@ -2614,11 +2619,14 @@ class App extends React.Component<AppProps, AppState> {
!isArrowElement(container) !isArrowElement(container)
) { ) {
const fontString = { const fontString = {
fontSize: this.state.currentItemFontSize, fontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily,
}; };
const minWidth = getApproxMinLineWidth(getFontString(fontString)); const minWidth = getApproxMinLineWidth(
const minHeight = getApproxMinLineHeight(getFontString(fontString)); getFontString(fontString),
lineHeight,
);
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
const newHeight = Math.max(containerDims.height, minHeight); const newHeight = Math.max(containerDims.height, minHeight);
const newWidth = Math.max(containerDims.width, minWidth); const newWidth = Math.max(containerDims.width, minWidth);
@ -2652,8 +2660,8 @@ class App extends React.Component<AppProps, AppState> {
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: null, roundness: null,
text: "", text: "",
fontSize: this.state.currentItemFontSize, fontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily,
textAlign: parentCenterPosition textAlign: parentCenterPosition
? "center" ? "center"
: this.state.currentItemTextAlign, : this.state.currentItemTextAlign,
@ -2663,6 +2671,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
locked: false, locked: false,
lineHeight,
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {

View File

@ -35,6 +35,7 @@ import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import oc from "open-color"; import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getDefaultLineHeight } from "../element/textElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -165,17 +166,32 @@ const restoreElement = (
const [fontPx, _fontFamily]: [string, string] = ( const [fontPx, _fontFamily]: [string, string] = (
element as any element as any
).font.split(" "); ).font.split(" ");
fontSize = parseInt(fontPx, 10); fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily); fontFamily = getFontFamilyByName(_fontFamily);
} }
const text = element.text ?? "";
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
text: element.text ?? "", text,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || element.text, originalText: element.originalText || text,
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
lineHeight:
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily)),
}); });
if (refreshDimensions) { if (refreshDimensions) {

View File

@ -29,6 +29,7 @@ import {
normalizeText, normalizeText,
wrapText, wrapText,
getMaxContainerWidth, getMaxContainerWidth,
getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { VERTICAL_ALIGN } from "../constants"; import { VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "./typeChecks";
@ -137,10 +138,12 @@ export const newTextElement = (
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts)); const metrics = measureText(text, getFontString(opts), lineHeight);
const offsets = getTextElementPositionOffsets(opts, metrics); const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith( const textElement = newElementWith(
{ {
@ -156,6 +159,7 @@ export const newTextElement = (
height: metrics.height, height: metrics.height,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight,
}, },
{}, {},
); );
@ -176,6 +180,7 @@ const getAdjustedDimensions = (
const { width: nextWidth, height: nextHeight } = measureText( const { width: nextWidth, height: nextHeight } = measureText(
nextText, nextText,
getFontString(element), getFontString(element),
element.lineHeight,
); );
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
@ -185,7 +190,11 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId !element.containerId
) { ) {
const prevMetrics = measureText(element.text, getFontString(element)); const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,

View File

@ -39,13 +39,13 @@ import {
import { Point, PointerDownState } from "../types"; import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getMaxContainerWidth, getMaxContainerWidth,
getApproxMinLineHeight,
} from "./textElement"; } from "./textElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
@ -360,7 +360,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight; let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number } = {}; let boundTextFontSize: number | null = null;
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) { if (transformHandleDirection.includes("e")) {
@ -410,9 +410,7 @@ export const resizeSingleElement = (
boundTextElement.id, boundTextElement.id,
) as typeof boundTextElement | undefined; ) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) { if (stateOfBoundTextElementAtResize) {
boundTextFont = { boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
fontSize: stateOfBoundTextElementAtResize.fontSize,
};
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const updatedElement = { const updatedElement = {
@ -428,12 +426,16 @@ export const resizeSingleElement = (
if (nextFontSize === null) { if (nextFontSize === null) {
return; return;
} }
boundTextFont = { boundTextFontSize = nextFontSize;
fontSize: nextFontSize,
};
} else { } else {
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); const minWidth = getApproxMinLineWidth(
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement)); getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
} }
@ -566,8 +568,10 @@ export const resizeSingleElement = (
}); });
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFontSize != null) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
});
} }
handleBindTextResize(element, transformHandleDirection); handleBindTextResize(element, transformHandleDirection);
} }

View File

@ -1,4 +1,4 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
@ -6,6 +6,9 @@ import {
getMaxContainerWidth, getMaxContainerWidth,
getMaxContainerHeight, getMaxContainerHeight,
wrapText, wrapText,
detectLineHeight,
getLineHeightInPx,
getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { FontString } from "./types"; import { FontString } from "./types";
@ -40,9 +43,7 @@ describe("Test wrapText", () => {
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 80, width: 80,
res: `Hello res: `Hello \nwhats \nup`,
whats
up`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@ -64,8 +65,7 @@ p`,
desc: "break words as per the width", desc: "break words as per the width",
width: 140, width: 140,
res: `Hello whats res: `Hello whats \nup`,
up`,
}, },
{ {
desc: "fit the container", desc: "fit the container",
@ -95,9 +95,7 @@ whats up`;
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 80, width: 80,
res: `Hello res: `Hello\nwhats \nup`,
whats
up`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@ -143,11 +141,7 @@ whats up`,
{ {
desc: "fit characters of long string as per container width", desc: "fit characters of long string as per container width",
width: 170, width: 170,
res: `hellolongtextth res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
isiswhatsupwith
youIamtypingggg
gandtypinggg
break it now`,
}, },
{ {
@ -166,8 +160,7 @@ now`,
desc: "fit the long text when container width is greater than text length and move the rest to next line", desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600, width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
break it now`,
}, },
].forEach((data) => { ].forEach((data) => {
it(`should ${data.desc}`, () => { it(`should ${data.desc}`, () => {
@ -181,8 +174,7 @@ break it now`,
const text = "Hello Excalidraw"; const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width // Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100); const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello expect(res).toEqual(`Hello \nExcalidraw`);
Excalidraw`);
}); });
it("should return the text as is if max width is invalid", () => { it("should return the text as is if max width is invalid", () => {
@ -312,3 +304,35 @@ describe("Test measureText", () => {
}); });
}); });
}); });
const textElement = API.createElement({
type: "text",
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
fontSize: 20,
fontFamily: 1,
height: 175,
});
describe("Test detectLineHeight", () => {
it("should return correct line height", () => {
expect(detectLineHeight(textElement)).toBe(1.25);
});
});
describe("Test getLineHeightInPx", () => {
it("should return correct line height", () => {
expect(
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
).toBe(25);
});
});
describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => {
//@ts-ignore
expect(getDefaultLineHeight()).toBe(1.25);
});
it("should return correct line height", () => {
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});

View File

@ -4,6 +4,7 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
FontString, FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@ -12,6 +13,7 @@ import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
@ -41,12 +43,15 @@ export const normalizeText = (text: string) => {
); );
}; };
export const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
const boundTextUpdates = { const boundTextUpdates = {
x: textElement.x, x: textElement.x,
y: textElement.y, y: textElement.y,
@ -68,6 +73,7 @@ export const redrawTextBoundingBox = (
const metrics = measureText( const metrics = measureText(
boundTextUpdates.text, boundTextUpdates.text,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight,
); );
boundTextUpdates.width = metrics.width; boundTextUpdates.width = metrics.width;
@ -185,7 +191,11 @@ export const handleBindTextResize = (
maxWidth, maxWidth,
); );
} }
const dimensions = measureText(text, getFontString(textElement)); const dimensions = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = dimensions.height; nextHeight = dimensions.height;
nextWidth = dimensions.width; nextWidth = dimensions.width;
} }
@ -261,32 +271,52 @@ const computeBoundTextPosition = (
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (text: string, font: FontString) => { export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
text = text text = text
.split("\n") .split("\n")
// replace empty lines with single space because leading/trailing empty // replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, font); const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font); const width = getTextWidth(text, font);
return { width, height }; return { width, height };
}; };
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); /**
const cacheApproxLineHeight: { [key: FontString]: number } = {}; * 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"];
};
export const getApproxLineHeight = (font: FontString) => { /**
if (cacheApproxLineHeight[font]) { * We calculate the line height from the font size and the unitless line height,
return cacheApproxLineHeight[font]; * aligning with the W3C spec.
} */
const fontSize = parseInt(font); export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// Calculate line height relative to font size // FIXME rename to getApproxMinContainerHeight
cacheApproxLineHeight[font] = fontSize * 1.2; export const getApproxMinLineHeight = (
return cacheApproxLineHeight[font]; fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
}; };
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
@ -309,7 +339,7 @@ const getLineWidth = (text: string, font: FontString) => {
}; };
export const getTextWidth = (text: string, font: FontString) => { export const getTextWidth = (text: string, font: FontString) => {
const lines = text.replace(/\r\n?/g, "\n").split("\n"); const lines = splitIntoLines(text);
let width = 0; let width = 0;
lines.forEach((line) => { lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font)); width = Math.max(width, getLineWidth(line, font));
@ -317,10 +347,13 @@ export const getTextWidth = (text: string, font: FontString) => {
return width; return width;
}; };
export const getTextHeight = (text: string, font: FontString) => { export const getTextHeight = (
const lines = text.replace(/\r\n?/g, "\n").split("\n"); text: string,
const lineHeight = getApproxLineHeight(font); fontSize: number,
return lineHeight * lines.length; lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
}; };
export const wrapText = (text: string, font: FontString, maxWidth: number) => { export const wrapText = (text: string, font: FontString, maxWidth: number) => {
@ -468,21 +501,23 @@ export const charWidth = (() => {
}; };
})(); })();
export const getApproxMinLineWidth = (font: FontString) => { const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font); const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) { if (maxCharWidth === 0) {
return ( return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2 BOUND_TEXT_PADDING * 2
); );
} }
return maxCharWidth + BOUND_TEXT_PADDING * 2; return maxCharWidth + BOUND_TEXT_PADDING * 2;
}; };
export const getApproxMinLineHeight = (font: FontString) => {
return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => { export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font); const cache = charWidth.getCache(font);
if (!cache) { if (!cache) {
@ -828,3 +863,32 @@ export const isMeasureTextSupported = () => {
); );
return width > 0; return width > 0;
}; };
/**
* Unitless line height
*
* In previous versions we used `normal` line height, which browsers interpret
* differently, and based on font-family and font-size.
*
* To make line heights consistent across browsers we hardcode the values for
* each of our fonts based on most common average line-heights.
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
* where the values come from.
*/
const DEFAULT_LINE_HEIGHT = {
// ~1.25 is the average for Virgil in WebKit and Blink.
// Gecko (FF) uses ~1.28.
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
// ~1.15 is the average for Virgil in WebKit and Blink.
// Gecko if all over the place.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily) {
return DEFAULT_LINE_HEIGHT[fontFamily];
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};

View File

@ -783,7 +783,7 @@ describe("textWysiwyg", () => {
rectangle.y + h.elements[0].height / 2 - text.height / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
); );
expect(text.x).toBe(25); expect(text.x).toBe(25);
expect(text.height).toBe(48); expect(text.height).toBe(50);
expect(text.width).toBe(60); expect(text.width).toBe(60);
// Edit and text by removing second line and it should // Edit and text by removing second line and it should
@ -810,7 +810,7 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello"); expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello"); expect(text.originalText).toBe("Hello");
expect(text.height).toBe(24); expect(text.height).toBe(25);
expect(text.width).toBe(50); expect(text.width).toBe(50);
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
@ -903,7 +903,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
85, 85,
5, 4.5,
] ]
`); `);
@ -929,7 +929,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
66, 65,
] ]
`); `);
@ -1067,9 +1067,9 @@ describe("textWysiwyg", () => {
mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80); expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(-35); expect(rectangle.y).toBe(-40);
expect(text.x).toBe(85); expect(text.x).toBe(85);
expect(text.y).toBe(-30); expect(text.y).toBe(-35);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z);
@ -1112,7 +1112,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" }, target: { value: "Online whiteboard collaboration made easy" },
}); });
editor.blur(); editor.blur();
expect(rectangle.height).toBe(178); expect(rectangle.height).toBe(185);
mouse.select(rectangle); mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
@ -1186,6 +1186,41 @@ describe("textWysiwyg", () => {
); );
}); });
it("should update line height when font family updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.25);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.2);
fireEvent.click(screen.getByTitle(/normal/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Helvetica);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.15);
});
describe("should align correctly", () => { describe("should align correctly", () => {
let editor: HTMLTextAreaElement; let editor: HTMLTextAreaElement;
@ -1245,7 +1280,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
45.5, 45,
] ]
`); `);
}); });
@ -1257,7 +1292,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
30, 30,
45.5, 45,
] ]
`); `);
}); });
@ -1269,7 +1304,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
45, 45,
45.5, 45,
] ]
`); `);
}); });
@ -1281,7 +1316,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
66, 65,
] ]
`); `);
}); });
@ -1292,7 +1327,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
30, 30,
66, 65,
] ]
`); `);
}); });
@ -1303,7 +1338,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
45, 45,
66, 65,
] ]
`); `);
}); });
@ -1333,7 +1368,7 @@ describe("textWysiwyg", () => {
const textElement = h.elements[1] as ExcalidrawTextElement; const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600); expect(textElement.width).toBe(600);
expect(textElement.height).toBe(24); expect(textElement.height).toBe(25);
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT); expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
expect((textElement as ExcalidrawTextElement).text).toBe( expect((textElement as ExcalidrawTextElement).text).toBe(
"Excalidraw is an opensource virtual collaborative whiteboard", "Excalidraw is an opensource virtual collaborative whiteboard",
@ -1365,7 +1400,7 @@ describe("textWysiwyg", () => {
], ],
fillStyle: "hachure", fillStyle: "hachure",
groupIds: [], groupIds: [],
height: 34, height: 35,
isDeleted: false, isDeleted: false,
link: null, link: null,
locked: false, locked: false,

View File

@ -22,7 +22,6 @@ import {
import { AppState } from "../types"; import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getContainerCoords, getContainerCoords,
getContainerDims, getContainerDims,
@ -150,9 +149,7 @@ export const textWysiwyg = ({
return; return;
} }
const { textAlign, verticalAlign } = updatedTextElement; const { textAlign, verticalAlign } = updatedTextElement;
const approxLineHeight = getApproxLineHeight(
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) { if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x; let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y; let coordY = updatedTextElement.y;
@ -213,7 +210,7 @@ export const textWysiwyg = ({
if (!isArrowElement(container) && textElementHeight > maxHeight) { if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min( const diff = Math.min(
textElementHeight - maxHeight, textElementHeight - maxHeight,
approxLineHeight, element.lineHeight,
); );
mutateElement(container, { height: containerDims.height + diff }); mutateElement(container, { height: containerDims.height + diff });
return; return;
@ -226,7 +223,7 @@ export const textWysiwyg = ({
) { ) {
const diff = Math.min( const diff = Math.min(
maxHeight - textElementHeight, maxHeight - textElementHeight,
approxLineHeight, element.lineHeight,
); );
mutateElement(container, { height: containerDims.height - diff }); mutateElement(container, { height: containerDims.height - diff });
} }
@ -266,10 +263,6 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff; editable.selectionEnd = editable.value.length - diff;
} }
const lines = updatedTextElement.originalText.split("\n");
const lineHeight = updatedTextElement.containerId
? approxLineHeight
: updatedTextElement.height / lines.length;
if (!container) { if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth); textElementWidth = Math.min(textElementWidth, maxWidth);
@ -282,7 +275,7 @@ export const textWysiwyg = ({
Object.assign(editable.style, { Object.assign(editable.style, {
font: getFontString(updatedTextElement), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`, lineHeight: element.lineHeight,
width: `${textElementWidth}px`, width: `${textElementWidth}px`,
height: `${textElementHeight}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,
@ -388,7 +381,11 @@ export const textWysiwyg = ({
font, font,
getMaxContainerWidth(container!), getMaxContainerWidth(container!),
); );
const { width, height } = measureText(wrappedText, font); const { width, height } = measureText(
wrappedText,
font,
updatedTextElement.lineHeight,
);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
editable.style.height = `${height}px`; editable.style.height = `${height}px`;
} }

View File

@ -135,6 +135,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;
originalText: string; originalText: string;
/**
* Unitless line height (aligned to W3C). To get line height in px, multiply
* with font size (using `getLineHeightInPx` helper).
*/
lineHeight: number & { _brand: "unitlessLineHeight" };
}>; }>;
export type ExcalidrawBindableElement = export type ExcalidrawBindableElement =

View File

@ -40,10 +40,10 @@ import {
} from "../constants"; } from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand"; import { getStroke, StrokeOptions } from "perfect-freehand";
import { import {
getApproxLineHeight,
getBoundTextElement, getBoundTextElement,
getContainerCoords, getContainerCoords,
getContainerElement, getContainerElement,
getLineHeightInPx,
getMaxContainerHeight, getMaxContainerHeight,
getMaxContainerWidth, getMaxContainerWidth,
} from "../element/textElement"; } from "../element/textElement";
@ -279,9 +279,7 @@ const drawElementOnCanvas = (
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.containerId
? getApproxLineHeight(getFontString(element))
: element.height / lines.length;
const horizontalOffset = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2
@ -290,11 +288,16 @@ const drawElementOnCanvas = (
: 0; : 0;
context.textBaseline = "bottom"; context.textBaseline = "bottom";
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
for (let index = 0; index < lines.length; index++) { for (let index = 0; index < lines.length; index++) {
context.fillText( context.fillText(
lines[index], lines[index],
horizontalOffset, horizontalOffset,
(index + 1) * lineHeight, (index + 1) * lineHeightPx,
); );
} }
context.restore(); context.restore();
@ -1313,7 +1316,10 @@ export const renderElementToSvg = (
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.height / lines.length; const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2
@ -1331,7 +1337,7 @@ export const renderElementToSvg = (
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i]; text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`); text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeight}`); text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor); text.setAttribute("fill", element.strokeColor);

View File

@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />

View File

@ -3,8 +3,10 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui"; import { Pointer, Keyboard } from "./helpers/ui";
import ExcalidrawApp from "../excalidraw-app"; import ExcalidrawApp from "../excalidraw-app";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getApproxLineHeight } from "../element/textElement"; import {
import { getFontString } from "../utils"; getDefaultLineHeight,
getLineHeightInPx,
} from "../element/textElement";
import { getElementBounds } from "../element"; import { getElementBounds } from "../element";
import { NormalizedZoomValue } from "../types"; import { NormalizedZoomValue } from "../types";
@ -118,12 +120,10 @@ describe("paste text as single lines", () => {
it("should space items correctly", async () => { it("should space items correctly", async () => {
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
const lineHeight = const lineHeightPx =
getApproxLineHeight( getLineHeightInPx(
getFontString({ h.app.state.currentItemFontSize,
fontSize: h.app.state.currentItemFontSize, getDefaultLineHeight(h.state.currentItemFontFamily),
fontFamily: h.app.state.currentItemFontFamily,
}),
) + ) +
10 / h.app.state.zoom.value; 10 / h.app.state.zoom.value;
mouse.moveTo(100, 100); mouse.moveTo(100, 100);
@ -135,19 +135,17 @@ describe("paste text as single lines", () => {
for (let i = 1; i < h.elements.length; i++) { for (let i = 1; i < h.elements.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [fx, elY] = getElementBounds(h.elements[i]); const [fx, elY] = getElementBounds(h.elements[i]);
expect(elY).toEqual(firstElY + lineHeight * i); expect(elY).toEqual(firstElY + lineHeightPx * i);
} }
}); });
}); });
it("should leave a space for blank new lines", async () => { it("should leave a space for blank new lines", async () => {
const text = "hkhkjhki\n\njgkjhffjh"; const text = "hkhkjhki\n\njgkjhffjh";
const lineHeight = const lineHeightPx =
getApproxLineHeight( getLineHeightInPx(
getFontString({ h.app.state.currentItemFontSize,
fontSize: h.app.state.currentItemFontSize, getDefaultLineHeight(h.state.currentItemFontFamily),
fontFamily: h.app.state.currentItemFontFamily,
}),
) + ) +
10 / h.app.state.zoom.value; 10 / h.app.state.zoom.value;
mouse.moveTo(100, 100); mouse.moveTo(100, 100);
@ -158,7 +156,7 @@ describe("paste text as single lines", () => {
const [fx, firstElY] = getElementBounds(h.elements[0]); const [fx, firstElY] = getElementBounds(h.elements[0]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [lx, lastElY] = getElementBounds(h.elements[1]); const [lx, lastElY] = getElementBounds(h.elements[1]);
expect(lastElY).toEqual(firstElY + lineHeight * 2); expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
}); });
}); });
}); });
@ -224,7 +222,7 @@ describe("Paste bound text container", () => {
await sleep(1); await sleep(1);
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);
const container = h.elements[0]; const container = h.elements[0];
expect(container.height).toBe(354); expect(container.height).toBe(368);
expect(container.width).toBe(166); expect(container.width).toBe(166);
}); });
}); });
@ -247,7 +245,7 @@ describe("Paste bound text container", () => {
await sleep(1); await sleep(1);
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);
const container = h.elements[0]; const container = h.elements[0];
expect(container.height).toBe(740); expect(container.height).toBe(770);
expect(container.width).toBe(166); expect(container.width).toBe(166);
}); });
}); });

View File

@ -291,6 +291,7 @@ Object {
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": false,
"lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -312,7 +313,7 @@ Object {
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 100, "width": 100,
"x": -20, "x": -20,
"y": -8.4, "y": -8.75,
} }
`; `;
@ -329,6 +330,7 @@ Object {
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": false,
"lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,

View File

@ -181,11 +181,13 @@ export class API {
}); });
break; break;
case "text": case "text":
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
element = newTextElement({ element = newTextElement({
...base, ...base,
text: rest.text || "test", text: rest.text || "test",
fontSize: rest.fontSize ?? appState.currentItemFontSize, fontSize,
fontFamily: rest.fontFamily ?? appState.currentItemFontFamily, fontFamily,
textAlign: rest.textAlign ?? appState.currentItemTextAlign, textAlign: rest.textAlign ?? appState.currentItemTextAlign,
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN, verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
containerId: rest.containerId ?? undefined, containerId: rest.containerId ?? undefined,

View File

@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"height": 128, "height": 130,
"width": 367, "width": 367,
} }
`); `);
@ -1040,7 +1040,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"x": 272, "x": 272,
"y": 46, "y": 45,
} }
`); `);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Array [ Array [
20, 20,
36, 35,
502, 502,
94, 95,
205.9061448421403, 205.9061448421403,
53, 52.5,
] ]
`); `);
}); });
@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"height": 128, "height": 130,
"width": 340, "width": 340,
} }
`); `);
@ -1099,7 +1099,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"x": 75, "x": 75,
"y": -4, "y": -5,
} }
`); `);
expect(textElement.text).toMatchInlineSnapshot(` expect(textElement.text).toMatchInlineSnapshot(`