diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index ed6efe97..7849730d 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -38,7 +38,7 @@ export const actionUnbindText = register({ selectedElements.forEach((element) => { const boundTextElement = getBoundTextElement(element); if (boundTextElement) { - const { width, height, baseline } = measureText( + const { width, height } = measureText( boundTextElement.originalText, getFontString(boundTextElement), ); @@ -51,7 +51,6 @@ export const actionUnbindText = register({ containerId: null, width, height, - baseline, text: boundTextElement.originalText, }); mutateElement(element, { diff --git a/src/components/App.tsx b/src/components/App.tsx index 3c0ac9dc..5b9311f8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2674,14 +2674,6 @@ class App extends React.Component { element, ]); } - - // case: creating new text not centered to parent element → offset Y - // so that the text is centered to cursor position - if (!parentCenterPosition) { - mutateElement(element, { - y: element.y - element.baseline / 2, - }); - } } this.setState({ diff --git a/src/constants.ts b/src/constants.ts index c3952b92..aa25667a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,9 @@ export const isFirefox = "netscape" in window && navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("Gecko") > 1; +export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; +export const isSafari = + !isChrome && navigator.userAgent.indexOf("Safari") !== -1; export const APP_NAME = "Excalidraw"; diff --git a/src/data/restore.ts b/src/data/restore.ts index 0af2f9dc..4434b7bc 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -171,7 +171,6 @@ const restoreElement = ( fontSize, fontFamily, text: element.text ?? "", - baseline: element.baseline, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 6024765e..0c7c90d2 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -153,7 +153,6 @@ export const newTextElement = ( y: opts.y - offsets.y, width: metrics.width, height: metrics.height, - baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, }, @@ -170,18 +169,13 @@ const getAdjustedDimensions = ( y: number; width: number; height: number; - baseline: number; } => { - let maxWidth = null; const container = getContainerElement(element); - if (container) { - maxWidth = getMaxContainerWidth(container); - } - const { - width: nextWidth, - height: nextHeight, - baseline: nextBaseline, - } = measureText(nextText, getFontString(element), maxWidth); + + const { width: nextWidth, height: nextHeight } = measureText( + nextText, + getFontString(element), + ); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -190,11 +184,7 @@ const getAdjustedDimensions = ( verticalAlign === VERTICAL_ALIGN.MIDDLE && !element.containerId ) { - const prevMetrics = measureText( - element.text, - getFontString(element), - maxWidth, - ); + const prevMetrics = measureText(element.text, getFontString(element)); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, height: nextHeight - prevMetrics.height, @@ -258,7 +248,6 @@ const getAdjustedDimensions = ( height: nextHeight, x: Number.isFinite(x) ? x : element.x, y: Number.isFinite(y) ? y : element.y, - baseline: nextBaseline, }; }; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index fe4f8e48..0c0337ed 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -45,8 +45,6 @@ import { getBoundTextElementId, getContainerElement, handleBindTextResize, - measureText, - getMaxContainerHeight, getMaxContainerWidth, } from "./textElement"; @@ -192,11 +190,10 @@ const rescalePointsInElement = ( const MIN_FONT_SIZE = 1; -const measureFontSizeFromWH = ( +const measureFontSizeFromWidth = ( element: NonDeleted, nextWidth: number, - nextHeight: number, -): { size: number; baseline: number } | null => { +): number | null => { // We only use width to scale font on resize let width = element.width; @@ -211,15 +208,8 @@ const measureFontSizeFromWH = ( if (nextFontSize < MIN_FONT_SIZE) { return null; } - const metrics = measureText( - element.text, - getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - element.containerId ? width : null, - ); - return { - size: nextFontSize, - baseline: metrics.baseline + (nextHeight - metrics.height), - }; + + return nextFontSize; }; const getSidesForTransformHandle = ( @@ -290,8 +280,8 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight); - if (nextFont === null) { + const nextFontSize = measureFontSizeFromWidth(element, nextWidth); + if (nextFontSize === null) { return; } const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( @@ -315,10 +305,9 @@ const resizeSingleTextElement = ( deltaY2, ); mutateElement(element, { - fontSize: nextFont.size, + fontSize: nextFontSize, width: nextWidth, height: nextHeight, - baseline: nextFont.baseline, x: nextElementX, y: nextElementY, }); @@ -371,7 +360,7 @@ export const resizeSingleElement = ( let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleY = atStartBoundsHeight / boundsCurrentHeight; - let boundTextFont: { fontSize?: number; baseline?: number } = {}; + let boundTextFont: { fontSize?: number } = {}; const boundTextElement = getBoundTextElement(element); if (transformHandleDirection.includes("e")) { @@ -423,7 +412,6 @@ export const resizeSingleElement = ( if (stateOfBoundTextElementAtResize) { boundTextFont = { fontSize: stateOfBoundTextElementAtResize.fontSize, - baseline: stateOfBoundTextElementAtResize.baseline, }; } if (shouldMaintainAspectRatio) { @@ -433,17 +421,15 @@ export const resizeSingleElement = ( height: eleNewHeight, }; - const nextFont = measureFontSizeFromWH( + const nextFontSize = measureFontSizeFromWidth( boundTextElement, getMaxContainerWidth(updatedElement), - getMaxContainerHeight(updatedElement), ); - if (nextFont === null) { + if (nextFontSize === null) { return; } boundTextFont = { - fontSize: nextFont.size, - baseline: nextFont.baseline, + fontSize: nextFontSize, }; } else { const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); @@ -687,7 +673,6 @@ const resizeMultipleElements = ( y: number; points?: Point[]; fontSize?: number; - baseline?: number; } = { width, height, @@ -696,7 +681,7 @@ const resizeMultipleElements = ( ...rescaledPoints, }; - let boundTextUpdates: { fontSize: number; baseline: number } | null = null; + let boundTextUpdates: { fontSize: number } | null = null; const boundTextElement = getBoundTextElement(element.latest); @@ -706,25 +691,22 @@ const resizeMultipleElements = ( width, height, }; - const textMeasurements = measureFontSizeFromWH( + const fontSize = measureFontSizeFromWidth( boundTextElement ?? (element.orig as ExcalidrawTextElement), getMaxContainerWidth(updatedElement), - getMaxContainerHeight(updatedElement), ); - if (!textMeasurements) { + if (!fontSize) { return; } if (isTextElement(element.orig)) { - update.fontSize = textMeasurements.size; - update.baseline = textMeasurements.baseline; + update.fontSize = fontSize; } if (boundTextElement) { boundTextUpdates = { - fontSize: textMeasurements.size, - baseline: textMeasurements.baseline, + fontSize, }; } } diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index 22086095..6ea7f2ba 100644 --- a/src/element/textElement.test.ts +++ b/src/element/textElement.test.ts @@ -5,7 +5,6 @@ import { getContainerCoords, getMaxContainerWidth, getMaxContainerHeight, - measureText, wrapText, } from "./textElement"; import { FontString } from "./types"; @@ -73,6 +72,13 @@ up`, width: 250, res: "Hello whats up", }, + { + desc: "should push the word if its equal to max width", + width: 60, + res: `Hello +whats +up`, + }, ].forEach((data) => { it(`should ${data.desc}`, () => { const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); @@ -80,6 +86,7 @@ up`, }); }); }); + describe("When text contain new lines", () => { const text = `Hello whats up`; @@ -170,38 +177,6 @@ break it now`, }); describe("Test measureText", () => { - const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; - const text = "Hello World"; - - it("should add correct attributes when maxWidth is passed", () => { - const maxWidth = 200 - BOUND_TEXT_PADDING * 2; - const res = measureText(text, font, maxWidth); - - expect(res.container).toMatchInlineSnapshot(` -
- -
- `); - }); - - it("should add correct attributes when maxWidth is not passed", () => { - const res = measureText(text, font); - - expect(res.container).toMatchInlineSnapshot(` -
- -
- `); - }); - describe("Test getContainerCoords", () => { const params = { width: 200, height: 100, x: 10, y: 20 }; diff --git a/src/element/textElement.ts b/src/element/textElement.ts index b91e9f2f..e5451bc4 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -50,7 +50,6 @@ export const redrawTextBoundingBox = ( text: textElement.text, width: textElement.width, height: textElement.height, - baseline: textElement.baseline, }; boundTextUpdates.text = textElement.text; @@ -66,12 +65,10 @@ export const redrawTextBoundingBox = ( const metrics = measureText( boundTextUpdates.text, getFontString(textElement), - maxWidth, ); boundTextUpdates.width = metrics.width; boundTextUpdates.height = metrics.height; - boundTextUpdates.baseline = metrics.baseline; if (container) { if (isArrowElement(container)) { @@ -177,7 +174,6 @@ export const handleBindTextResize = ( const maxWidth = getMaxContainerWidth(container); const maxHeight = getMaxContainerHeight(container); let containerHeight = containerDims.height; - let nextBaseLine = textElement.baseline; if (transformHandleType !== "n" && transformHandleType !== "s") { if (text) { text = wrapText( @@ -186,14 +182,9 @@ export const handleBindTextResize = ( maxWidth, ); } - const dimensions = measureText( - text, - getFontString(textElement), - maxWidth, - ); + const dimensions = measureText(text, getFontString(textElement)); nextHeight = dimensions.height; nextWidth = dimensions.width; - nextBaseLine = dimensions.baseline; } // increase height in case text element height exceeds if (nextHeight > maxHeight) { @@ -221,7 +212,6 @@ export const handleBindTextResize = ( text, width: nextWidth, height: nextHeight, - baseline: nextBaseLine, }); if (!isArrowElement(container)) { @@ -267,51 +257,19 @@ const computeBoundTextPosition = ( }; // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js -export const measureText = ( - text: string, - font: FontString, - maxWidth?: number | null, -) => { + +export const measureText = (text: string, font: FontString) => { text = text .split("\n") // replace empty lines with single space because leading/trailing empty // lines would be stripped from computation .map((x) => x || " ") .join("\n"); - const container = document.createElement("div"); - container.style.position = "absolute"; - container.style.whiteSpace = "pre"; - container.style.font = font; - container.style.minHeight = "1em"; - if (maxWidth) { - const lineHeight = getApproxLineHeight(font); - // since we are adding a span of width 1px later - container.style.maxWidth = `${maxWidth + 1}px`; - container.style.overflow = "hidden"; - container.style.wordBreak = "break-word"; - container.style.lineHeight = `${String(lineHeight)}px`; - container.style.whiteSpace = "pre-wrap"; - } - document.body.appendChild(container); - container.innerText = text; + const height = getTextHeight(text, font); + const width = getTextWidth(text, font); - const span = document.createElement("span"); - span.style.display = "inline-block"; - span.style.overflow = "hidden"; - span.style.width = "1px"; - span.style.height = "1px"; - container.appendChild(span); - // Baseline is important for positioning text on canvas - const baseline = span.offsetTop + span.offsetHeight; - // since we are adding a span of width 1px - const width = container.offsetWidth + 1; - const height = container.offsetHeight; - document.body.removeChild(container); - if (isTestEnv()) { - return { width, height, baseline, container }; - } - return { width, height, baseline }; + return { width, height }; }; const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); @@ -321,40 +279,45 @@ export const getApproxLineHeight = (font: FontString) => { if (cacheApproxLineHeight[font]) { return cacheApproxLineHeight[font]; } - cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height; + const fontSize = parseInt(font); + cacheApproxLineHeight[font] = fontSize * 1.2; return cacheApproxLineHeight[font]; }; let canvas: HTMLCanvasElement | undefined; + const getLineWidth = (text: string, font: FontString) => { if (!canvas) { canvas = document.createElement("canvas"); } const canvas2dContext = canvas.getContext("2d")!; canvas2dContext.font = font; + const width = canvas2dContext.measureText(text).width; - const metrics = canvas2dContext.measureText(text); // since in test env the canvas measureText algo // doesn't measure text and instead just returns number of // characters hence we assume that each letteris 10px if (isTestEnv()) { - return metrics.width * 10; + return width * 10; } - // Since measureText behaves differently in different browsers - // OS so considering a adjustment factor of 0.2 - const adjustmentFactor = 0.2; - - return metrics.width + adjustmentFactor; + return width; }; export const getTextWidth = (text: string, font: FontString) => { - const lines = text.split("\n"); + const lines = text.replace(/\r\n?/g, "\n").split("\n"); let width = 0; lines.forEach((line) => { width = Math.max(width, getLineWidth(line, font)); }); return width; }; + +export const getTextHeight = (text: string, font: FontString) => { + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeight = getApproxLineHeight(font); + return lineHeight * lines.length; +}; + export const wrapText = (text: string, font: FontString, maxWidth: number) => { const lines: Array = []; const originalLines = text.split("\n"); @@ -376,16 +339,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { let currentLineWidthTillNow = 0; let index = 0; + while (index < words.length) { const currentWordWidth = getLineWidth(words[index], font); + // This will only happen when single word takes entire width + if (currentWordWidth === maxWidth) { + push(words[index]); + index++; + } // Start breaking longer words exceeding max width - if (currentWordWidth >= maxWidth) { + else if (currentWordWidth > maxWidth) { // push current line since the current word exceeds the max width // so will be appended in next line push(currentLine); currentLine = ""; currentLineWidthTillNow = 0; + while (words[index].length > 0) { const currentChar = String.fromCodePoint( words[index].codePointAt(0)!, @@ -486,9 +456,9 @@ export const charWidth = (() => { getCache, }; })(); + export const getApproxMinLineWidth = (font: FontString) => { const maxCharWidth = getMaxCharWidth(font); - if (maxCharWidth === 0) { return ( measureText(DUMMY_TEXT.split("").join("\n"), font).width + diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index fb41a381..f234991d 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -6,12 +6,11 @@ import { CODES, KEYS } from "../keys"; import { fireEvent } from "../tests/test-utils"; import { queryByText } from "@testing-library/react"; -import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { FONT_FAMILY } from "../constants"; import { ExcalidrawTextElement, ExcalidrawTextElementWithContainer, } from "./types"; -import * as textElementUtils from "./textElement"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; import { resize } from "../tests/utils"; @@ -440,17 +439,6 @@ describe("textWysiwyg", () => { let rectangle: any; const { h } = window; - const DUMMY_HEIGHT = 240; - const DUMMY_WIDTH = 160; - const APPROX_LINE_HEIGHT = 25; - const INITIAL_WIDTH = 10; - - beforeAll(() => { - jest - .spyOn(textElementUtils, "getApproxLineHeight") - .mockReturnValue(APPROX_LINE_HEIGHT); - }); - beforeEach(async () => { await render(); h.elements = []; @@ -732,39 +720,6 @@ describe("textWysiwyg", () => { }); it("should wrap text and vertcially center align once text submitted", async () => { - jest - .spyOn(textElementUtils, "measureText") - .mockImplementation((text, font, maxWidth) => { - let width = INITIAL_WIDTH; - let height = APPROX_LINE_HEIGHT; - let baseline = 10; - if (!text) { - return { - width, - height, - baseline, - }; - } - baseline = 30; - width = DUMMY_WIDTH; - if (text === "Hello \nWorld!") { - height = APPROX_LINE_HEIGHT * 2; - } - if (maxWidth) { - width = maxWidth; - // To capture cases where maxWidth passed is initial width - // due to which the text is not wrapped correctly - if (maxWidth === INITIAL_WIDTH) { - height = DUMMY_HEIGHT; - } - } - return { - width, - height, - baseline, - }; - }); - expect(h.elements.length).toBe(1); Keyboard.keyDown(KEYS.ENTER); @@ -773,11 +728,6 @@ describe("textWysiwyg", () => { ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; - // mock scroll height - jest - .spyOn(editor, "scrollHeight", "get") - .mockImplementation(() => APPROX_LINE_HEIGHT * 2); - fireEvent.change(editor, { target: { value: "Hello World!", @@ -791,10 +741,12 @@ describe("textWysiwyg", () => { text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.text).toBe("Hello \nWorld!"); expect(text.originalText).toBe("Hello World!"); - expect(text.y).toBe(57.5); - expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); - expect(text.height).toBe(APPROX_LINE_HEIGHT * 2); - expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); + expect(text.y).toBe( + rectangle.y + h.elements[0].height / 2 - text.height / 2, + ); + expect(text.x).toBe(25); + expect(text.height).toBe(48); + expect(text.width).toBe(60); // Edit and text by removing second line and it should // still vertically align correctly @@ -811,11 +763,6 @@ describe("textWysiwyg", () => { }, }); - // mock scroll height - jest - .spyOn(editor, "scrollHeight", "get") - .mockImplementation(() => APPROX_LINE_HEIGHT); - editor.style.height = "25px"; editor.dispatchEvent(new Event("input")); await new Promise((r) => setTimeout(r, 0)); @@ -825,10 +772,12 @@ describe("textWysiwyg", () => { expect(text.text).toBe("Hello"); expect(text.originalText).toBe("Hello"); - expect(text.y).toBe(57.5); - expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); - expect(text.height).toBe(APPROX_LINE_HEIGHT); - expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); + expect(text.height).toBe(24); + expect(text.width).toBe(50); + expect(text.y).toBe( + rectangle.y + h.elements[0].height / 2 - text.height / 2, + ); + expect(text.x).toBe(30); }); it("should unbind bound text when unbind action from context menu is triggered", async () => { @@ -915,8 +864,8 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 109.5, - 17, + 85, + 5, ] `); @@ -942,7 +891,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 90, + 66, ] `); @@ -965,7 +914,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 424, + 375, -539, ] `); @@ -1080,9 +1029,9 @@ describe("textWysiwyg", () => { mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50); expect(rectangle.x).toBe(80); - expect(rectangle.y).toBe(85); - expect(text.x).toBe(89.5); - expect(text.y).toBe(90); + expect(rectangle.y).toBe(-35); + expect(text.x).toBe(85); + expect(text.y).toBe(-30); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); @@ -1112,29 +1061,6 @@ describe("textWysiwyg", () => { }); it("should restore original container height and clear cache once text is unbind", async () => { - jest - .spyOn(textElementUtils, "measureText") - .mockImplementation((text, font, maxWidth) => { - let width = INITIAL_WIDTH; - let height = APPROX_LINE_HEIGHT; - let baseline = 10; - if (!text) { - return { - width, - height, - baseline, - }; - } - baseline = 30; - width = DUMMY_WIDTH; - height = APPROX_LINE_HEIGHT * 5; - - return { - width, - height, - baseline, - }; - }); const originalRectHeight = rectangle.height; expect(rectangle.height).toBe(originalRectHeight); @@ -1148,7 +1074,7 @@ describe("textWysiwyg", () => { target: { value: "Online whiteboard collaboration made easy" }, }); editor.blur(); - expect(rectangle.height).toBe(135); + expect(rectangle.height).toBe(178); mouse.select(rectangle); fireEvent.contextMenu(GlobalTestState.canvas, { button: 2, @@ -1174,7 +1100,7 @@ describe("textWysiwyg", () => { editor.blur(); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); - expect(rectangle.height).toBe(215); + expect(rectangle.height).toBe(156); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); mouse.select(rectangle); @@ -1186,13 +1112,12 @@ describe("textWysiwyg", () => { await new Promise((r) => setTimeout(r, 0)); editor.blur(); - expect(rectangle.height).toBe(215); + expect(rectangle.height).toBe(156); // cache updated again - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156); }); - //@todo fix this test later once measureText is mocked correctly - it.skip("should reset the container height cache when font properties updated", async () => { + it("should reset the container height cache when font properties updated", async () => { Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); @@ -1218,7 +1143,9 @@ describe("textWysiwyg", () => { expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, ).toEqual(36); - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe( + 96.39999999999999, + ); }); describe("should align correctly", () => { @@ -1256,7 +1183,7 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 94.5, + 30, 25, ] `); @@ -1268,7 +1195,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 174, + 45, 25, ] `); @@ -1280,7 +1207,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 25, + 45.5, ] `); }); @@ -1291,8 +1218,8 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - -25, - 25, + 30, + 45.5, ] `); }); @@ -1303,8 +1230,8 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 174, - 25, + 45, + 45.5, ] `); }); @@ -1314,33 +1241,33 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 15, - 25, - ] - `); + Array [ + 15, + 66, + ] + `); }); it("when bottom center", async () => { fireEvent.click(screen.getByTitle("Center")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 94.5, - 25, - ] - `); + Array [ + 30, + 66, + ] + `); }); it("when bottom right", async () => { fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 25, - ] - `); + Array [ + 45, + 66, + ] + `); }); }); }); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 1ce43b4d..f44bab89 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, VERTICAL_ALIGN } from "../constants"; +import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, @@ -29,6 +29,7 @@ import { getContainerElement, getTextElementAngle, getTextWidth, + measureText, normalizeText, redrawTextBoundingBox, wrapText, @@ -159,7 +160,7 @@ export const textWysiwyg = ({ let maxWidth = updatedTextElement.width; let maxHeight = updatedTextElement.height; - const width = updatedTextElement.width; + let textElementWidth = updatedTextElement.width; // Set to element height by default since that's // what is going to be used for unbounded text let textElementHeight = updatedTextElement.height; @@ -272,7 +273,10 @@ export const textWysiwyg = ({ if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; } - + // As firefox, Safari needs little higher dimensions on DOM + if (isFirefox || isSafari) { + textElementWidth += 0.5; + } // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = (appState.height - viewportY) / appState.zoom.value; @@ -280,12 +284,12 @@ export const textWysiwyg = ({ font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ lineHeight: `${lineHeight}px`, - width: `${Math.min(width, maxWidth)}px`, + width: `${textElementWidth}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, top: `${viewportY}px`, transform: getTransform( - width, + textElementWidth, textElementHeight, getTextElementAngle(updatedTextElement), appState, @@ -378,55 +382,16 @@ export const textWysiwyg = ({ id, ) as ExcalidrawTextElement; const font = getFontString(updatedTextElement); - // using scrollHeight here since we need to calculate - // number of lines so cannot use editable.style.height - // as that gets updated below - // Rounding here so that the lines calculated is more accurate in all browsers. - // The scrollHeight and approxLineHeight differs in diff browsers - // eg it gives 1.05 in firefox for handewritten small font due to which - // height gets updated as lines > 1 and leads to jumping text for first line in bound container - // hence rounding here to avoid that - const lines = Math.round( - editable.scrollHeight / getApproxLineHeight(font), - ); - // auto increase height only when lines > 1 so its - // measured correctly and vertically aligns for - // first line as well as setting height to "auto" - // doubles the height as soon as user starts typing - if (isBoundToContainer(element) && lines > 1) { + if (isBoundToContainer(element)) { const container = getContainerElement(element); - - let height = "auto"; - editable.style.height = "0px"; - let heightSet = false; - if (lines === 2) { - const actualLineCount = wrapText( - editable.value, - font, - getMaxContainerWidth(container!), - ).split("\n").length; - // This is browser behaviour when setting height to "auto" - // It sets the height needed for 2 lines even if actual - // line count is 1 as mentioned above as well - // hence reducing the height by half if actual line count is 1 - // so single line aligns vertically when deleting - if (actualLineCount === 1) { - height = `${editable.scrollHeight / 2}px`; - editable.style.height = height; - heightSet = true; - } - } const wrappedText = wrapText( normalizeText(editable.value), font, getMaxContainerWidth(container!), ); - const width = getTextWidth(wrappedText, font); + const { width, height } = measureText(wrappedText, font); editable.style.width = `${width}px`; - - if (!heightSet) { - editable.style.height = `${editable.scrollHeight}px`; - } + editable.style.height = `${height}px`; } onChange(normalizeText(editable.value)); }; diff --git a/src/element/types.ts b/src/element/types.ts index 01dee1fe..af78f771 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -130,7 +130,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & fontSize: number; fontFamily: FontFamilyValues; text: string; - baseline: number; textAlign: TextAlign; verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 39173d7b..919fb99a 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -36,13 +36,11 @@ import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS, - VERTICAL_ALIGN, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { getApproxLineHeight, getBoundTextElement, - getBoundTextElementOffset, getContainerElement, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -280,22 +278,19 @@ const drawElementOnCanvas = ( const lineHeight = element.containerId ? getApproxLineHeight(getFontString(element)) : element.height / lines.length; - let verticalOffset = element.height - element.baseline; - if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - verticalOffset = getBoundTextElementOffset(element); - } - const horizontalOffset = element.textAlign === "center" ? element.width / 2 : element.textAlign === "right" ? element.width : 0; + context.textBaseline = "bottom"; + for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, - (index + 1) * lineHeight - verticalOffset, + (index + 1) * lineHeight, ); } context.restore(); @@ -1300,7 +1295,7 @@ export const renderElementToSvg = ( ); const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lineHeight = element.height / lines.length; - const verticalOffset = element.height - element.baseline; + const verticalOffset = element.height; const horizontalOffset = element.textAlign === "center" ? element.width / 2 diff --git a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap index bebfda9d..5ea0ab4b 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: 1px; height: 0px; left: 39.5px; top: 20px; 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: -20px; font: Emoji 20px 20px; line-height: 0px; 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: 10px; 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;" tabindex="0" wrap="off" /> diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap index 8af4f83c..b88803cd 100644 --- a/src/tests/data/__snapshots__/restore.test.ts.snap +++ b/src/tests/data/__snapshots__/restore.test.ts.snap @@ -282,7 +282,6 @@ exports[`restoreElements should restore text element correctly passing value for Object { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": Array [], "containerId": null, "fillStyle": "hachure", @@ -312,8 +311,8 @@ Object { "versionNonce": 0, "verticalAlign": "middle", "width": 100, - "x": -0.5, - "y": 0, + "x": -20, + "y": -8.4, } `; @@ -321,7 +320,6 @@ exports[`restoreElements should restore text element correctly with unknown font Object { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": Array [], "containerId": null, "fillStyle": "hachure", diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 3e5ebb8c..a606fb38 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": 10, + "height": 128, "width": 367, } `); @@ -1039,8 +1039,8 @@ describe("Test Linear Elements", () => { expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` Object { - "x": 386.5, - "y": 70, + "x": 272, + "y": 46, } `); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) @@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => { .toMatchInlineSnapshot(` Array [ 20, - 60, - 391.8122896842806, - 70, + 36, + 502, + 94, 205.9061448421403, - 65, + 53, ] `); }); @@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => { expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` Object { - "height": 0, + "height": 128, "width": 340, } `); @@ -1098,8 +1098,8 @@ describe("Test Linear Elements", () => { expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` Object { - "x": 189.5, - "y": 20, + "x": 75, + "y": -4, } `); expect(textElement.text).toMatchInlineSnapshot(`