diff --git a/dev-docs/yarn.lock b/dev-docs/yarn.lock index f6d61d9b6..041f39b55 100644 --- a/dev-docs/yarn.lock +++ b/dev-docs/yarn.lock @@ -7542,9 +7542,9 @@ webpack-sources@^3.2.2, webpack-sources@^3.2.3: integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== webpack@^5.73.0: - version "5.74.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980" - integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA== + version "5.76.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c" + integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51" diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 129b7871e..34acc23b0 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -45,6 +45,7 @@ export const actionUnbindText = register({ const { width, height } = measureText( boundTextElement.originalText, getFontString(boundTextElement), + boundTextElement.lineHeight, ); const originalContainerHeight = getOriginalContainerHeightFromCache( element.id, @@ -239,15 +240,23 @@ export const actionCreateContainerFromText = register({ linearElementIds.includes(ele.id), ) as ExcalidrawLinearElement[]; linearElements.forEach((ele) => { - let startBinding = null; - let endBinding = null; - if (ele.startBinding) { - startBinding = { ...ele.startBinding, elementId: container.id }; + let startBinding = ele.startBinding; + let endBinding = ele.endBinding; + + if (startBinding?.elementId === textElement.id) { + startBinding = { + ...startBinding, + elementId: container.id, + }; } - if (ele.endBinding) { - endBinding = { ...ele.endBinding, elementId: container.id }; + + if (endBinding?.elementId === textElement.id) { + endBinding = { ...endBinding, elementId: container.id }; + } + + if (startBinding || endBinding) { + mutateElement(ele, { startBinding, endBinding }); } - mutateElement(ele, { startBinding, endBinding }); }); } diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 309e46bdc..b9090bcde 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -55,6 +55,7 @@ import { getBoundTextElement, getContainerElement, } from "../element/textElement"; +import { getDefaultLineHeight } from "../element/textMeasurements"; import { isBoundToContainer, isLinearElement, @@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({ oldElement, { fontFamily: value, + lineHeight: getDefaultLineHeight(value), }, ); redrawTextBoundingBox(newElement, getContainerElement(oldElement)); diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index b2be3853d..1eb18bda8 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -19,6 +19,7 @@ import { getDefaultRoundnessTypeForElement, } from "../element/typeChecks"; import { getSelectedElements } from "../scene"; +import { getDefaultLineHeight } from "../element/textMeasurements"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -92,12 +93,18 @@ export const actionPasteStyles = register({ }); if (isTextElement(newElement)) { + const fontSize = + elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE; + const fontFamily = + elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY; newElement = newElementWith(newElement, { - fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, - fontFamily: - elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY, + fontSize, + fontFamily, textAlign: elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, + lineHeight: + elementStylesToCopyFrom.lineHeight || + getDefaultLineHeight(fontFamily), }); let container = null; if (newElement.containerId) { diff --git a/src/components/App.tsx b/src/components/App.tsx index 3a31723be..39791a8af 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -268,10 +268,11 @@ import { isValidTextContainer, } from "../element/textElement"; import { - getLineHeight, getApproxMinLineHeight, getApproxMinLineWidth, isMeasureTextSupported, + getLineHeightInPx, + getDefaultLineHeight, } from "../element/textMeasurements"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { @@ -1733,12 +1734,14 @@ class App extends React.Component { (acc: ExcalidrawTextElement[], line, idx) => { const text = line.trim(); + const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); if (text.length) { const element = newTextElement({ ...textElementProps, x, y: currentY, text, + lineHeight, }); acc.push(element); currentY += element.height + LINE_GAP; @@ -1747,14 +1750,9 @@ class App extends React.Component { // add paragraph only if previous line was not empty, IOW don't add // more than one empty line if (prevLine) { - const defaultLineHeight = getLineHeight( - getFontString({ - fontSize: textElementProps.fontSize, - fontFamily: textElementProps.fontFamily, - }), - ); - - currentY += defaultLineHeight + LINE_GAP; + currentY += + getLineHeightInPx(textElementProps.fontSize, lineHeight) + + LINE_GAP; } } @@ -2609,6 +2607,13 @@ class App extends React.Component { existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } + const fontFamily = + existingTextElement?.fontFamily || this.state.currentItemFontFamily; + + const lineHeight = + existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); + const fontSize = this.state.currentItemFontSize; + if ( !existingTextElement && shouldBindToContainer && @@ -2616,11 +2621,14 @@ class App extends React.Component { !isArrowElement(container) ) { const fontString = { - fontSize: this.state.currentItemFontSize, - fontFamily: this.state.currentItemFontFamily, + fontSize, + fontFamily, }; - const minWidth = getApproxMinLineWidth(getFontString(fontString)); - const minHeight = getApproxMinLineHeight(getFontString(fontString)); + const minWidth = getApproxMinLineWidth( + getFontString(fontString), + lineHeight, + ); + const minHeight = getApproxMinLineHeight(fontSize, lineHeight); const containerDims = getContainerDims(container); const newHeight = Math.max(containerDims.height, minHeight); const newWidth = Math.max(containerDims.width, minWidth); @@ -2654,8 +2662,8 @@ class App extends React.Component { opacity: this.state.currentItemOpacity, roundness: null, text: "", - fontSize: this.state.currentItemFontSize, - fontFamily: this.state.currentItemFontFamily, + fontSize, + fontFamily, textAlign: parentCenterPosition ? "center" : this.state.currentItemTextAlign, @@ -2665,6 +2673,7 @@ class App extends React.Component { containerId: shouldBindToContainer ? container?.id : undefined, groupIds: container?.groupIds ?? [], locked: false, + lineHeight, }); if (!existingTextElement && shouldBindToContainer && container) { diff --git a/src/data/index.ts b/src/data/index.ts index 89877aab9..20ba75ebe 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -89,7 +89,9 @@ export const exportCanvas = async ( return await fileSave(blob, { description: "Export to PNG", name, - extension: appState.exportEmbedScene ? "excalidraw.png" : "png", + // FIXME reintroduce `excalidraw.png` when most people upgrade away + // from 111.0.5563.64 (arm64), see #6349 + extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png", fileHandle, }); } else if (type === "clipboard") { diff --git a/src/data/restore.ts b/src/data/restore.ts index 98df4547b..9b57182cd 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -35,6 +35,10 @@ import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import oc from "open-color"; import { MarkOptional, Mutable } from "../utility-types"; +import { + detectLineHeight, + getDefaultLineHeight, +} from "../element/textMeasurements"; type RestoredAppState = Omit< AppState, @@ -165,17 +169,32 @@ const restoreElement = ( const [fontPx, _fontFamily]: [string, string] = ( element as any ).font.split(" "); - fontSize = parseInt(fontPx, 10); + fontSize = parseFloat(fontPx); fontFamily = getFontFamilyByName(_fontFamily); } + const text = element.text ?? ""; + element = restoreElementWithProperties(element, { fontSize, fontFamily, - text: element.text ?? "", + text, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, 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) { diff --git a/src/element/collision.ts b/src/element/collision.ts index 097b76d34..0e7257d79 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -786,7 +786,12 @@ export const findFocusPointForEllipse = ( orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / squares; - const n = (-m * px - 1) / py; + let n = (-m * px - 1) / py; + + if (n === 0) { + // if zero {-0, 0}, fall back to a same-sign value in the similar range + n = (Object.is(n, -0) ? -1 : 1) * 0.01; + } const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); return GA.point(x, (-m * x - 1) / n); diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 852cf58ce..e726e82ef 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -31,7 +31,11 @@ import { import { VERTICAL_ALIGN } from "../constants"; import { isArrowElement } from "./typeChecks"; import { MarkOptional, Merge, Mutable } from "../utility-types"; -import { measureText, wrapText } from "./textMeasurements"; +import { + measureText, + wrapText, + getDefaultLineHeight, +} from "./textMeasurements"; type ElementConstructorOpts = MarkOptional< Omit, @@ -136,10 +140,12 @@ export const newTextElement = ( textAlign: TextAlign; verticalAlign: VerticalAlign; containerId?: ExcalidrawTextContainer["id"]; + lineHeight?: ExcalidrawTextElement["lineHeight"]; } & ElementConstructorOpts, ): NonDeleted => { + const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily); const text = normalizeText(opts.text); - const metrics = measureText(text, getFontString(opts)); + const metrics = measureText(text, getFontString(opts), lineHeight); const offsets = getTextElementPositionOffsets(opts, metrics); const textElement = newElementWith( { @@ -155,6 +161,7 @@ export const newTextElement = ( height: metrics.height, containerId: opts.containerId || null, originalText: text, + lineHeight, }, {}, ); @@ -175,6 +182,7 @@ const getAdjustedDimensions = ( const { width: nextWidth, height: nextHeight } = measureText( nextText, getFontString(element), + element.lineHeight, ); const { textAlign, verticalAlign } = element; let x: number; @@ -184,7 +192,11 @@ const getAdjustedDimensions = ( verticalAlign === VERTICAL_ALIGN.MIDDLE && !element.containerId ) { - const prevMetrics = measureText(element.text, getFontString(element)); + const prevMetrics = measureText( + element.text, + getFontString(element), + element.lineHeight, + ); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, height: nextHeight - prevMetrics.height, diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 81f0e418d..3d05a4746 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -361,7 +361,7 @@ export const resizeSingleElement = ( let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleY = atStartBoundsHeight / boundsCurrentHeight; - let boundTextFont: { fontSize?: number } = {}; + let boundTextFontSize: number | null = null; const boundTextElement = getBoundTextElement(element); if (transformHandleDirection.includes("e")) { @@ -411,9 +411,7 @@ export const resizeSingleElement = ( boundTextElement.id, ) as typeof boundTextElement | undefined; if (stateOfBoundTextElementAtResize) { - boundTextFont = { - fontSize: stateOfBoundTextElementAtResize.fontSize, - }; + boundTextFontSize = stateOfBoundTextElementAtResize.fontSize; } if (shouldMaintainAspectRatio) { const updatedElement = { @@ -429,12 +427,16 @@ export const resizeSingleElement = ( if (nextFontSize === null) { return; } - boundTextFont = { - fontSize: nextFontSize, - }; + boundTextFontSize = nextFontSize; } else { - const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); - const minHeight = getApproxMinLineHeight(getFontString(boundTextElement)); + const minWidth = getApproxMinLineWidth( + getFontString(boundTextElement), + boundTextElement.lineHeight, + ); + const minHeight = getApproxMinLineHeight( + boundTextElement.fontSize, + boundTextElement.lineHeight, + ); eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); } @@ -567,8 +569,10 @@ export const resizeSingleElement = ( }); mutateElement(element, resizedElement); - if (boundTextElement && boundTextFont) { - mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); + if (boundTextElement && boundTextFontSize != null) { + mutateElement(boundTextElement, { + fontSize: boundTextFontSize, + }); } handleBindTextResize(element, transformHandleDirection); } diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 78ed7ae1f..cde5b8080 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -40,7 +40,6 @@ export const redrawTextBoundingBox = ( container: ExcalidrawElement | null, ) => { let maxWidth = undefined; - const boundTextUpdates = { x: textElement.x, y: textElement.y, @@ -61,6 +60,7 @@ export const redrawTextBoundingBox = ( const metrics = measureText( boundTextUpdates.text, getFontString(textElement), + textElement.lineHeight, ); boundTextUpdates.width = metrics.width; @@ -175,7 +175,11 @@ export const handleBindTextResize = ( maxWidth, ); } - const dimensions = measureText(text, getFontString(textElement)); + const dimensions = measureText( + text, + getFontString(textElement), + textElement.lineHeight, + ); nextHeight = dimensions.height; nextWidth = dimensions.width; } diff --git a/src/element/textMeasurements.test.ts b/src/element/textMeasurements.test.ts index 0bf63c2cb..56bc05d85 100644 --- a/src/element/textMeasurements.test.ts +++ b/src/element/textMeasurements.test.ts @@ -1,5 +1,11 @@ -import { BOUND_TEXT_PADDING } from "../constants"; -import { wrapText } from "./textMeasurements"; +import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { API } from "../tests/helpers/api"; +import { + detectLineHeight, + getDefaultLineHeight, + getLineHeightInPx, + wrapText, +} from "./textMeasurements"; import { FontString } from "./types"; describe("Test wrapText", () => { @@ -33,9 +39,7 @@ describe("Test wrapText", () => { { desc: "break all words when width of each word is less than container width", width: 80, - res: `Hello -whats -up`, + res: `Hello \nwhats \nup`, }, { desc: "break all characters when width of each character is less than container width", @@ -57,8 +61,7 @@ p`, desc: "break words as per the width", width: 140, - res: `Hello whats -up`, + res: `Hello whats \nup`, }, { desc: "fit the container", @@ -88,9 +91,7 @@ whats up`; { desc: "break all words when width of each word is less than container width", width: 80, - res: `Hello -whats -up`, + res: `Hello\nwhats \nup`, }, { desc: "break all characters when width of each character is less than container width", @@ -136,11 +137,7 @@ whats up`, { desc: "fit characters of long string as per container width", width: 170, - res: `hellolongtextth -isiswhatsupwith -youIamtypingggg -gandtypinggg -break it now`, + res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`, }, { @@ -159,8 +156,7 @@ now`, desc: "fit the long text when container width is greater than text length and move the rest to next line", width: 600, - res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg -break it now`, + res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`, }, ].forEach((data) => { it(`should ${data.desc}`, () => { @@ -174,7 +170,44 @@ break it now`, const text = "Hello Excalidraw"; // Length of "Excalidraw" is 100 and exacty equal to max width const res = wrapText(text, font, 100); - expect(res).toEqual(`Hello -Excalidraw`); + expect(res).toEqual(`Hello \nExcalidraw`); + }); + + it("should return the text as is if max width is invalid", () => { + const text = "Hello Excalidraw"; + expect(wrapText(text, font, NaN)).toEqual(text); + expect(wrapText(text, font, -1)).toEqual(text); + expect(wrapText(text, font, Infinity)).toEqual(text); + }); +}); +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); }); }); diff --git a/src/element/textMeasurements.ts b/src/element/textMeasurements.ts index 53663b5cc..30161a645 100644 --- a/src/element/textMeasurements.ts +++ b/src/element/textMeasurements.ts @@ -2,9 +2,11 @@ import { BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, + FONT_FAMILY, } from "../constants"; import { getFontString, isTestEnv } from "../utils"; -import { FontString } from "./types"; +import { normalizeText } from "./textElement"; +import { ExcalidrawTextElement, FontFamilyValues, FontString } from "./types"; const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); const cacheLineHeight: { [key: FontString]: number } = {}; @@ -35,16 +37,14 @@ const getLineWidth = (text: string, font: FontString) => { canvas2dContext.font = font; const width = canvas2dContext.measureText(text).width; - /* istanbul ignore else */ if (isTestEnv()) { return width * DUMMY_CHAR_WIDTH; } - /* istanbul ignore next */ return width; }; export const getTextWidth = (text: string, font: FontString) => { - const lines = text.replace(/\r\n?/g, "\n").split("\n"); + const lines = splitIntoLines(text); let width = 0; lines.forEach((line) => { width = Math.max(width, getLineWidth(line, font)); @@ -52,39 +52,57 @@ export const getTextWidth = (text: string, font: FontString) => { return width; }; -export const getTextHeight = (text: string, font: FontString) => { - const lines = text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeight = getLineHeight(font); - return lineHeight * lines.length; +export const getTextHeight = ( + text: string, + fontSize: number, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const lineCount = splitIntoLines(text).length; + return getLineHeightInPx(fontSize, lineHeight) * lineCount; }; -export const measureText = (text: string, font: FontString) => { +export const splitIntoLines = (text: string) => { + return normalizeText(text).split("\n"); +}; + +export const measureText = ( + text: string, + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { 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 height = getTextHeight(text, font); + const fontSize = parseFloat(font); + const height = getTextHeight(text, fontSize, lineHeight); const width = getTextWidth(text, font); return { width, height }; }; -export const getApproxMinLineWidth = (font: FontString) => { +export const getApproxMinLineWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { const maxCharWidth = getMaxCharWidth(font); if (maxCharWidth === 0) { return ( - measureText(DUMMY_TEXT.split("").join("\n"), font).width + + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + BOUND_TEXT_PADDING * 2 ); } return maxCharWidth + BOUND_TEXT_PADDING * 2; }; -export const getApproxMinLineHeight = (font: FontString) => { - return getLineHeight(font) + BOUND_TEXT_PADDING * 2; +// FIXME rename to getApproxMinContainerHeight +export const getApproxMinLineHeight = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; }; export const charWidth = (() => { @@ -150,6 +168,13 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { }; export const wrapText = (text: string, font: FontString, maxWidth: number) => { + // if maxWidth is not finite or NaN which can happen in case of bugs in + // computation, we need to make sure we don't continue as we'll end up + // in an infinite loop + if (!Number.isFinite(maxWidth) || maxWidth < 0) { + return text; + } + const lines: Array = []; const originalLines = text.split("\n"); const spaceWidth = getLineWidth(" ", font); @@ -272,3 +297,54 @@ export const isMeasureTextSupported = () => { ); return width > 0; }; + +/** + * We calculate the line height from the font size and the unitless line height, + * aligning with the W3C spec. + */ +export const getLineHeightInPx = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return fontSize * lineHeight; +}; + +/** + * 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"]; +}; + +/** + * 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]; +}; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 25d266306..0873a2d23 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -783,7 +783,7 @@ describe("textWysiwyg", () => { rectangle.y + h.elements[0].height / 2 - text.height / 2, ); expect(text.x).toBe(25); - expect(text.height).toBe(48); + expect(text.height).toBe(50); expect(text.width).toBe(60); // Edit and text by removing second line and it should @@ -810,7 +810,7 @@ describe("textWysiwyg", () => { expect(text.text).toBe("Hello"); expect(text.originalText).toBe("Hello"); - expect(text.height).toBe(24); + expect(text.height).toBe(25); expect(text.width).toBe(50); expect(text.y).toBe( 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(` Array [ 85, - 5, + 4.5, ] `); @@ -929,7 +929,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 66, + 65, ] `); @@ -1067,9 +1067,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(-35); + expect(rectangle.y).toBe(-40); expect(text.x).toBe(85); - expect(text.y).toBe(-30); + expect(text.y).toBe(-35); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); @@ -1112,7 +1112,7 @@ describe("textWysiwyg", () => { target: { value: "Online whiteboard collaboration made easy" }, }); editor.blur(); - expect(rectangle.height).toBe(178); + expect(rectangle.height).toBe(185); mouse.select(rectangle); fireEvent.contextMenu(GlobalTestState.canvas, { 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", () => { let editor: HTMLTextAreaElement; @@ -1245,7 +1280,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 45.5, + 45, ] `); }); @@ -1257,7 +1292,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 30, - 45.5, + 45, ] `); }); @@ -1269,7 +1304,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 45, - 45.5, + 45, ] `); }); @@ -1281,7 +1316,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 66, + 65, ] `); }); @@ -1292,7 +1327,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 30, - 66, + 65, ] `); }); @@ -1303,7 +1338,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 45, - 66, + 65, ] `); }); @@ -1333,7 +1368,7 @@ describe("textWysiwyg", () => { const textElement = h.elements[1] as ExcalidrawTextElement; expect(textElement.width).toBe(600); - expect(textElement.height).toBe(24); + expect(textElement.height).toBe(25); expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT); expect((textElement as ExcalidrawTextElement).text).toBe( "Excalidraw is an opensource virtual collaborative whiteboard", @@ -1365,7 +1400,7 @@ describe("textWysiwyg", () => { ], fillStyle: "hachure", groupIds: [], - height: 34, + height: 35, isDeleted: false, link: null, locked: false, diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 911233029..cdf79c95e 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -41,7 +41,6 @@ import App from "../components/App"; import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; import { - getLineHeight, getTextWidth, measureText, wrapText, @@ -153,7 +152,6 @@ export const textWysiwyg = ({ return; } const { textAlign, verticalAlign } = updatedTextElement; - const lineHeight = getLineHeight(getFontString(updatedTextElement)); if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; @@ -185,7 +183,8 @@ export const textWysiwyg = ({ textElementHeight = getTextHeight( updatedTextElement.text, - getFontString(updatedTextElement), + updatedTextElement.fontSize, + updatedTextElement.lineHeight, ); let originalContainerData; @@ -212,7 +211,10 @@ export const textWysiwyg = ({ // autogrow container height if text exceeds if (!isArrowElement(container) && textElementHeight > maxHeight) { - const diff = Math.min(textElementHeight - maxHeight, lineHeight); + const diff = Math.min( + textElementHeight - maxHeight, + element.lineHeight, + ); mutateElement(container, { height: containerDims.height + diff }); return; } else if ( @@ -222,7 +224,10 @@ export const textWysiwyg = ({ containerDims.height > originalContainerData.height && textElementHeight < maxHeight ) { - const diff = Math.min(maxHeight - textElementHeight, lineHeight); + const diff = Math.min( + maxHeight - textElementHeight, + element.lineHeight, + ); mutateElement(container, { height: containerDims.height - diff }); } else { const { y } = computeBoundTextPosition( @@ -263,7 +268,7 @@ export const textWysiwyg = ({ Object.assign(editable.style, { font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ - lineHeight: `${lineHeight}px`, + lineHeight: element.lineHeight, width: `${textElementWidth}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, @@ -369,7 +374,11 @@ export const textWysiwyg = ({ font, getBoundTextMaxWidth(container!), ); - const { width, height } = measureText(wrappedText, font); + const { width, height } = measureText( + wrappedText, + font, + updatedTextElement.lineHeight, + ); editable.style.width = `${width}px`; editable.style.height = `${height}px`; } diff --git a/src/element/types.ts b/src/element/types.ts index e99b7e897..4b4bad74e 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -135,6 +135,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; 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 = diff --git a/src/packages/excalidraw/package.json b/src/packages/excalidraw/package.json index a23a3b1e8..be4e61d27 100644 --- a/src/packages/excalidraw/package.json +++ b/src/packages/excalidraw/package.json @@ -64,7 +64,7 @@ "terser-webpack-plugin": "5.3.3", "ts-loader": "9.3.1", "typescript": "4.7.4", - "webpack": "5.73.0", + "webpack": "5.76.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.9.3", diff --git a/src/packages/excalidraw/yarn.lock b/src/packages/excalidraw/yarn.lock index 320b26a54..339cda939 100644 --- a/src/packages/excalidraw/yarn.lock +++ b/src/packages/excalidraw/yarn.lock @@ -1393,10 +1393,10 @@ acorn-walk@^8.0.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== -acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== ajv-formats@^2.1.1: version "2.1.1" @@ -2068,10 +2068,10 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3: - version "5.10.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" - integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" + integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -3751,10 +3751,10 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -watchpack@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" - integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -3858,21 +3858,21 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.73.0: - version "5.73.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" - integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== +webpack@5.76.0: + version "5.76.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c" + integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51" "@webassemblyjs/ast" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" + acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.9.3" + enhanced-resolve "^5.10.0" es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" @@ -3885,7 +3885,7 @@ webpack@5.73.0: schema-utils "^3.1.0" tapable "^2.1.1" terser-webpack-plugin "^5.1.3" - watchpack "^2.3.1" + watchpack "^2.4.0" webpack-sources "^3.2.3" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: diff --git a/src/packages/utils/package.json b/src/packages/utils/package.json index b8aea2b6d..7375e8b58 100644 --- a/src/packages/utils/package.json +++ b/src/packages/utils/package.json @@ -48,7 +48,7 @@ "file-loader": "6.2.0", "sass-loader": "13.0.2", "ts-loader": "9.3.1", - "webpack": "5.73.0", + "webpack": "5.76.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0" }, diff --git a/src/packages/utils/yarn.lock b/src/packages/utils/yarn.lock index 483399b2f..c5d00fd23 100644 --- a/src/packages/utils/yarn.lock +++ b/src/packages/utils/yarn.lock @@ -1187,10 +1187,10 @@ acorn-walk@^8.0.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== -acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== ajv-keywords@^3.5.2: version "3.5.2" @@ -1383,18 +1383,7 @@ braces@^3.0.1: dependencies: fill-range "^7.0.1" -browserslist@^4.14.5: - version "4.19.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.3.tgz#29b7caad327ecf2859485f696f9604214bedd383" - integrity sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg== - dependencies: - caniuse-lite "^1.0.30001312" - electron-to-chromium "^1.4.71" - escalade "^3.1.1" - node-releases "^2.0.2" - picocolors "^1.0.0" - -browserslist@^4.20.2, browserslist@^4.21.2: +browserslist@^4.14.5, browserslist@^4.20.2, browserslist@^4.21.2: version "4.21.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf" integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA== @@ -1417,11 +1406,6 @@ call-bind@^1.0.0: function-bind "^1.1.1" get-intrinsic "^1.0.2" -caniuse-lite@^1.0.30001312: - version "1.0.30001312" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz#e11eba4b87e24d22697dae05455d5aea28550d5f" - integrity sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ== - caniuse-lite@^1.0.30001366: version "1.0.30001367" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001367.tgz#2b97fe472e8fa29c78c5970615d7cd2ee414108a" @@ -1601,20 +1585,15 @@ electron-to-chromium@^1.4.188: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.195.tgz#139b2d95a42a3f17df217589723a1deac71d1473" integrity sha512-vefjEh0sk871xNmR5whJf9TEngX+KTKS3hOHpjoMpauKkwlGwtMz1H8IaIjAT/GNnX0TbGwAdmVoXCAzXf+PPg== -electron-to-chromium@^1.4.71: - version "1.4.75" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.75.tgz#d1ad9bb46f2f1bf432118c2be21d27ffeae82fdd" - integrity sha512-LxgUNeu3BVU7sXaKjUDD9xivocQLxFtq6wgERrutdY/yIOps3ODOZExK1jg8DTEg4U8TUCb5MLGeWFOYuxjF3Q== - emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3: - version "5.9.3" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88" - integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" + integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -2011,11 +1990,6 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -node-releases@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01" - integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg== - node-releases@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" @@ -2494,10 +2468,10 @@ util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -watchpack@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" - integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -2548,21 +2522,21 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.73.0: - version "5.73.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" - integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== +webpack@5.76.0: + version "5.76.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c" + integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51" "@webassemblyjs/ast" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" + acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.9.3" + enhanced-resolve "^5.10.0" es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" @@ -2575,7 +2549,7 @@ webpack@5.73.0: schema-utils "^3.1.0" tapable "^2.1.1" terser-webpack-plugin "^5.1.3" - watchpack "^2.3.1" + watchpack "^2.4.0" webpack-sources "^3.2.3" which@^2.0.1: diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 2efa8f5d9..403dbd9a1 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -47,7 +47,7 @@ import { getBoundTextMaxWidth, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { getLineHeight } from "../element/textMeasurements"; +import { getLineHeightInPx } from "../element/textMeasurements"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original @@ -279,9 +279,6 @@ const drawElementOnCanvas = ( // Canvas does not support multiline text by default const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeight = element.containerId - ? getLineHeight(getFontString(element)) - : element.height / lines.length; const horizontalOffset = element.textAlign === "center" ? element.width / 2 @@ -290,11 +287,16 @@ const drawElementOnCanvas = ( : 0; context.textBaseline = "bottom"; + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, - (index + 1) * lineHeight, + (index + 1) * lineHeightPx, ); } context.restore(); @@ -1316,7 +1318,10 @@ export const renderElementToSvg = ( }) rotate(${degree} ${cx} ${cy})`, ); 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 = element.textAlign === "center" ? element.width / 2 @@ -1334,7 +1339,7 @@ export const renderElementToSvg = ( const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); text.textContent = lines[i]; text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeight}`); + text.setAttribute("y", `${i * lineHeightPx}`); text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("fill", element.strokeColor); diff --git a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap index f84eab3a2..a2f142b66 100644 --- a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te class="excalidraw-wysiwyg" data-type="wysiwyg" 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" wrap="off" /> diff --git a/src/tests/binding.test.tsx b/src/tests/binding.test.tsx index 52bfad100..c615eb925 100644 --- a/src/tests/binding.test.tsx +++ b/src/tests/binding.test.tsx @@ -4,6 +4,7 @@ import { UI, Pointer, Keyboard } from "./helpers/ui"; import { getTransformHandles } from "../element/transformHandles"; import { API } from "./helpers/api"; import { KEYS } from "../keys"; +import { actionCreateContainerFromText } from "../actions/actionBoundText"; const { h } = window; @@ -209,4 +210,103 @@ describe("element binding", () => { ).toBe(null); expect(arrow.endBinding?.elementId).toBe(text.id); }); + + it("should update binding when text containerized", async () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + width: 100, + height: 100, + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + points: [ + [0, 0], + [0, -87.45777932247563], + ], + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + endBinding: { + elementId: "text1", + focus: 0.2, + gap: 7, + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [ + [0, 0], + [0, -87.45777932247563], + ], + startBinding: { + elementId: "text1", + focus: 0.2, + gap: 7, + }, + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + text: "ola", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + h.elements = [rectangle1, arrow1, arrow2, text1]; + + API.setSelectedElements([text1]); + + expect(h.state.selectedElementIds[text1.id]).toBe(true); + + h.app.actionManager.executeAction(actionCreateContainerFromText); + + // new text container will be placed before the text element + const container = h.elements.at(-2)!; + + expect(container.type).toBe("rectangle"); + expect(container.id).not.toBe(rectangle1.id); + + expect(container).toEqual( + expect.objectContaining({ + boundElements: expect.arrayContaining([ + { + type: "text", + id: text1.id, + }, + { + type: "arrow", + id: arrow1.id, + }, + { + type: "arrow", + id: arrow2.id, + }, + ]), + }), + ); + + expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); + expect(arrow1.endBinding?.elementId).toBe(container.id); + expect(arrow2.startBinding?.elementId).toBe(container.id); + expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); + }); }); diff --git a/src/tests/clipboard.test.tsx b/src/tests/clipboard.test.tsx index 54718a9a3..f20cdcd9c 100644 --- a/src/tests/clipboard.test.tsx +++ b/src/tests/clipboard.test.tsx @@ -3,10 +3,13 @@ import { render, waitFor, GlobalTestState } from "./test-utils"; import { Pointer, Keyboard } from "./helpers/ui"; import ExcalidrawApp from "../excalidraw-app"; import { KEYS } from "../keys"; -import { getLineHeight } from "../element/textMeasurements"; -import { getFontString } from "../utils"; + import { getElementBounds } from "../element"; import { NormalizedZoomValue } from "../types"; +import { + getDefaultLineHeight, + getLineHeightInPx, +} from "../element/textMeasurements"; const { h } = window; @@ -118,12 +121,10 @@ describe("paste text as single lines", () => { it("should space items correctly", async () => { const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; - const lineHeight = - getLineHeight( - getFontString({ - fontSize: h.app.state.currentItemFontSize, - fontFamily: h.app.state.currentItemFontFamily, - }), + const lineHeightPx = + getLineHeightInPx( + h.app.state.currentItemFontSize, + getDefaultLineHeight(h.state.currentItemFontFamily), ) + 10 / h.app.state.zoom.value; mouse.moveTo(100, 100); @@ -135,19 +136,17 @@ describe("paste text as single lines", () => { for (let i = 1; i < h.elements.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unused-vars 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 () => { const text = "hkhkjhki\n\njgkjhffjh"; - const lineHeight = - getLineHeight( - getFontString({ - fontSize: h.app.state.currentItemFontSize, - fontFamily: h.app.state.currentItemFontFamily, - }), + const lineHeightPx = + getLineHeightInPx( + h.app.state.currentItemFontSize, + getDefaultLineHeight(h.state.currentItemFontFamily), ) + 10 / h.app.state.zoom.value; mouse.moveTo(100, 100); @@ -158,7 +157,7 @@ describe("paste text as single lines", () => { const [fx, firstElY] = getElementBounds(h.elements[0]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [lx, lastElY] = getElementBounds(h.elements[1]); - expect(lastElY).toEqual(firstElY + lineHeight * 2); + expect(lastElY).toEqual(firstElY + lineHeightPx * 2); }); }); }); @@ -224,7 +223,7 @@ describe("Paste bound text container", () => { await sleep(1); expect(h.elements.length).toEqual(2); const container = h.elements[0]; - expect(container.height).toBe(354); + expect(container.height).toBe(368); expect(container.width).toBe(166); }); }); @@ -247,7 +246,7 @@ describe("Paste bound text container", () => { await sleep(1); expect(h.elements.length).toEqual(2); const container = h.elements[0]; - expect(container.height).toBe(740); + expect(container.height).toBe(770); expect(container.width).toBe(166); }); }); diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap index b88803cd4..e9a0da005 100644 --- a/src/tests/data/__snapshots__/restore.test.ts.snap +++ b/src/tests/data/__snapshots__/restore.test.ts.snap @@ -291,6 +291,7 @@ Object { "height": 100, "id": "id-text01", "isDeleted": false, + "lineHeight": 1.25, "link": null, "locked": false, "opacity": 100, @@ -312,7 +313,7 @@ Object { "verticalAlign": "middle", "width": 100, "x": -20, - "y": -8.4, + "y": -8.75, } `; @@ -329,6 +330,7 @@ Object { "height": 100, "id": "id-text01", "isDeleted": false, + "lineHeight": 1.25, "link": null, "locked": false, "opacity": 100, diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index a0feab2f2..bc8bfc8a9 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -111,6 +111,9 @@ export class API { fileId?: T extends "image" ? string : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never; + startBinding?: T extends "arrow" + ? ExcalidrawLinearElement["startBinding"] + : never; endBinding?: T extends "arrow" ? ExcalidrawLinearElement["endBinding"] : never; @@ -178,11 +181,13 @@ export class API { }); break; case "text": + const fontSize = rest.fontSize ?? appState.currentItemFontSize; + const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily; element = newTextElement({ ...base, text: rest.text || "test", - fontSize: rest.fontSize ?? appState.currentItemFontSize, - fontFamily: rest.fontFamily ?? appState.currentItemFontFamily, + fontSize, + fontFamily, textAlign: rest.textAlign ?? appState.currentItemTextAlign, verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN, containerId: rest.containerId ?? undefined, @@ -221,6 +226,10 @@ export class API { }); break; } + if (element.type === "arrow") { + element.startBinding = rest.startBinding ?? null; + element.endBinding = rest.endBinding ?? null; + } if (id) { element.id = id; } diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 101400e36..a0bcb1c7c 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => { expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` Object { - "height": 128, + "height": 130, "width": 367, } `); @@ -1041,7 +1041,7 @@ describe("Test Linear Elements", () => { ).toMatchInlineSnapshot(` Object { "x": 272, - "y": 46, + "y": 45, } `); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) @@ -1053,11 +1053,11 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` Array [ 20, - 36, + 35, 502, - 94, + 95, 205.9061448421403, - 53, + 52.5, ] `); }); @@ -1092,7 +1092,7 @@ describe("Test Linear Elements", () => { expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` Object { - "height": 128, + "height": 130, "width": 340, } `); @@ -1102,7 +1102,7 @@ describe("Test Linear Elements", () => { ).toMatchInlineSnapshot(` Object { "x": 75, - "y": -4, + "y": -5, } `); expect(textElement.text).toMatchInlineSnapshot(`