import React from "react"; import ReactDOM from "react-dom"; import { Excalidraw } from "../index"; import { GlobalTestState, render, screen } from "../tests/test-utils"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { CODES, KEYS } from "../keys"; import { fireEvent, mockBoundingClientRect, restoreOriginalGetBoundingClientRect, } from "../tests/test-utils"; import { queryByText } from "@testing-library/react"; import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; import type { ExcalidrawTextElement, ExcalidrawTextElementWithContainer, } from "./types"; import { API } from "../tests/helpers/api"; import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); const tab = " "; const mouse = new Pointer("mouse"); const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; describe("textWysiwyg", () => { describe("start text editing", () => { const { h } = window; beforeEach(async () => { await render(); API.setElements([]); }); it("should prefer editing selected text element (non-bindable container present)", async () => { const line = API.createElement({ type: "line", width: 100, height: 0, points: [ [0, 0], [100, 0], ], }); const textSize = 20; const text = API.createElement({ type: "text", text: "ola", x: line.width / 2 - textSize / 2, y: -textSize / 2, width: textSize, height: textSize, }); API.setElements([text, line]); API.setSelectedElements([text]); Keyboard.keyPress(KEYS.ENTER); expect(h.state.editingElement?.id).toBe(text.id); expect( (h.state.editingElement as ExcalidrawTextElement).containerId, ).toBe(null); }); it("should prefer editing selected text element (bindable container present)", async () => { const container = API.createElement({ type: "rectangle", width: 100, boundElements: [], }); const textSize = 20; const boundText = API.createElement({ type: "text", text: "ola", x: container.width / 2 - textSize / 2, y: container.height / 2 - textSize / 2, width: textSize, height: textSize, containerId: container.id, }); const boundText2 = API.createElement({ type: "text", text: "ola", x: container.width / 2 - textSize / 2, y: container.height / 2 - textSize / 2, width: textSize, height: textSize, containerId: container.id, }); API.setElements([container, boundText, boundText2]); API.updateElement(container, { boundElements: [{ type: "text", id: boundText.id }], }); API.setSelectedElements([boundText2]); Keyboard.keyPress(KEYS.ENTER); expect(h.state.editingElement?.id).toBe(boundText2.id); }); it("should not create bound text on ENTER if text exists at container center", () => { const container = API.createElement({ type: "rectangle", width: 100, }); const textSize = 20; const text = API.createElement({ type: "text", text: "ola", x: container.width / 2 - textSize / 2, y: container.height / 2 - textSize / 2, width: textSize, height: textSize, containerId: container.id, }); API.updateElement(container, { boundElements: [{ type: "text", id: text.id }], }); API.setElements([container, text]); API.setSelectedElements([container]); Keyboard.keyPress(KEYS.ENTER); expect(h.state.editingElement?.id).toBe(text.id); }); it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => { const container = API.createElement({ type: "rectangle", width: 100, boundElements: [], }); const textSize = 20; const boundText = API.createElement({ type: "text", text: "ola", x: container.width / 2 - textSize / 2, y: container.height / 2 - textSize / 2, width: textSize, height: textSize, containerId: container.id, }); const boundText2 = API.createElement({ type: "text", text: "ola", x: container.width / 2 - textSize / 2, y: container.height / 2 - textSize / 2, width: textSize, height: textSize, containerId: container.id, }); API.setElements([container, boundText, boundText2]); API.updateElement(container, { boundElements: [{ type: "text", id: boundText.id }], }); API.setSelectedElements([container]); Keyboard.keyPress(KEYS.ENTER); expect(h.state.editingElement?.id).toBe(boundText.id); }); it("should edit text under cursor when clicked with text tool", async () => { const text = API.createElement({ type: "text", text: "ola", x: 60, y: 0, width: 100, height: 100, }); API.setElements([text]); UI.clickTool("text"); mouse.clickAt(text.x + 50, text.y + 50); const editor = await getTextEditor(textEditorSelector, false); expect(editor).not.toBe(null); expect(h.state.editingElement?.id).toBe(text.id); expect(h.elements.length).toBe(1); }); it("should edit text under cursor when double-clicked with selection tool", async () => { const text = API.createElement({ type: "text", text: "ola", x: 60, y: 0, width: 100, height: 100, }); API.setElements([text]); UI.clickTool("selection"); mouse.doubleClickAt(text.x + 50, text.y + 50); const editor = await getTextEditor(textEditorSelector, false); expect(editor).not.toBe(null); expect(h.state.editingElement?.id).toBe(text.id); expect(h.elements.length).toBe(1); }); // FIXME too flaky. No one knows why. it.skip("should bump the version of a labeled arrow when the label is updated", async () => { const arrow = UI.createElement("arrow", { width: 300, height: 0, }); await UI.editText(arrow, "Hello"); const { version } = arrow; await UI.editText(arrow, "Hello\nworld!"); expect(arrow.version).toEqual(version + 1); }); }); describe("Test text wrapping", () => { const { h } = window; const dimensions = { height: 400, width: 800 }; beforeAll(() => { mockBoundingClientRect(dimensions); }); beforeEach(async () => { await render(); // @ts-ignore h.app.refreshViewportBreakpoints(); // @ts-ignore h.app.refreshEditorBreakpoints(); API.setElements([]); }); afterAll(() => { restoreOriginalGetBoundingClientRect(); }); it("should keep width when editing a wrapped text", async () => { const text = API.createElement({ type: "text", text: "Excalidraw\nEditor", }); API.setElements([text]); const prevWidth = text.width; const prevHeight = text.height; const prevText = text.text; // text is wrapped UI.resize(text, "e", [-20, 0]); expect(text.width).not.toEqual(prevWidth); expect(text.height).not.toEqual(prevHeight); expect(text.text).not.toEqual(prevText); expect(text.autoResize).toBe(false); const wrappedWidth = text.width; const wrappedHeight = text.height; const wrappedText = text.text; // edit text UI.clickTool("selection"); mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); const editor = await getTextEditor(textEditorSelector); expect(editor).not.toBe(null); expect(h.state.editingElement?.id).toBe(text.id); expect(h.elements.length).toBe(1); const nextText = `${wrappedText} is great!`; updateTextEditor(editor, nextText); Keyboard.exitTextEditor(editor); expect(h.elements[0].width).toEqual(wrappedWidth); expect(h.elements[0].height).toBeGreaterThan(wrappedHeight); // remove all texts and then add it back editing updateTextEditor(editor, ""); updateTextEditor(editor, nextText); Keyboard.exitTextEditor(editor); expect(h.elements[0].width).toEqual(wrappedWidth); }); it("should restore original text after unwrapping a wrapped text", async () => { const originalText = "Excalidraw\neditor\nis great!"; const text = API.createElement({ type: "text", text: originalText, }); API.setElements([text]); // wrap UI.resize(text, "e", [-40, 0]); // enter text editing mode UI.clickTool("selection"); mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); const editor = await getTextEditor(textEditorSelector); Keyboard.exitTextEditor(editor); // restore after unwrapping UI.resize(text, "e", [40, 0]); expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); // wrap again and add a new line UI.resize(text, "e", [-30, 0]); const wrappedText = text.text; UI.clickTool("selection"); mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); updateTextEditor(editor, `${wrappedText}\nA new line!`); Keyboard.exitTextEditor(editor); // remove the newly added line UI.clickTool("selection"); mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); updateTextEditor(editor, wrappedText); Keyboard.exitTextEditor(editor); // unwrap UI.resize(text, "e", [30, 0]); // expect the text to be restored the same expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); }); }); describe("Test container-unbound text", () => { const { h } = window; const dimensions = { height: 400, width: 800 }; let textarea: HTMLTextAreaElement; let textElement: ExcalidrawTextElement; beforeAll(() => { mockBoundingClientRect(dimensions); }); beforeEach(async () => { await render(); // @ts-ignore h.app.refreshViewportBreakpoints(); // @ts-ignore h.app.refreshEditorBreakpoints(); textElement = UI.createElement("text"); mouse.clickOn(textElement); textarea = await getTextEditor(textEditorSelector, true); }); afterAll(() => { restoreOriginalGetBoundingClientRect(); }); it("should add a tab at the start of the first line", () => { textarea.value = "Line#1\nLine#2"; // cursor: "|Line#1\nLine#2" textarea.selectionStart = 0; textarea.selectionEnd = 0; fireEvent.keyDown(textarea, { key: KEYS.TAB }); expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`); // cursor: " |Line#1\nLine#2" expect(textarea.selectionStart).toEqual(4); expect(textarea.selectionEnd).toEqual(4); }); it("should add a tab at the start of the second line", () => { textarea.value = "Line#1\nLine#2"; // cursor: "Line#1\nLin|e#2" textarea.selectionStart = 10; textarea.selectionEnd = 10; fireEvent.keyDown(textarea, { key: KEYS.TAB }); expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`); // cursor: "Line#1\n Lin|e#2" expect(textarea.selectionStart).toEqual(14); expect(textarea.selectionEnd).toEqual(14); }); it("should add a tab at the start of the first and second line", () => { textarea.value = "Line#1\nLine#2\nLine#3"; // cursor: "Li|ne#1\nLi|ne#2\nLine#3" textarea.selectionStart = 2; textarea.selectionEnd = 9; fireEvent.keyDown(textarea, { key: KEYS.TAB }); expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`); // cursor: " Li|ne#1\n Li|ne#2\nLine#3" expect(textarea.selectionStart).toEqual(6); expect(textarea.selectionEnd).toEqual(17); }); it("should remove a tab at the start of the first line", () => { textarea.value = `${tab}Line#1\nLine#2`; // cursor: "| Line#1\nLine#2" textarea.selectionStart = 0; textarea.selectionEnd = 0; fireEvent.keyDown(textarea, { key: KEYS.TAB, shiftKey: true, }); expect(textarea.value).toEqual(`Line#1\nLine#2`); // cursor: "|Line#1\nLine#2" expect(textarea.selectionStart).toEqual(0); expect(textarea.selectionEnd).toEqual(0); }); it("should remove a tab at the start of the second line", () => { // cursor: "Line#1\n Lin|e#2" textarea.value = `Line#1\n${tab}Line#2`; textarea.selectionStart = 15; textarea.selectionEnd = 15; fireEvent.keyDown(textarea, { key: KEYS.TAB, shiftKey: true, }); expect(textarea.value).toEqual(`Line#1\nLine#2`); // cursor: "Line#1\nLin|e#2" expect(textarea.selectionStart).toEqual(11); expect(textarea.selectionEnd).toEqual(11); }); it("should remove a tab at the start of the first and second line", () => { // cursor: " Li|ne#1\n Li|ne#2\nLine#3" textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`; textarea.selectionStart = 6; textarea.selectionEnd = 17; fireEvent.keyDown(textarea, { key: KEYS.TAB, shiftKey: true, }); expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`); // cursor: "Li|ne#1\nLi|ne#2\nLine#3" expect(textarea.selectionStart).toEqual(2); expect(textarea.selectionEnd).toEqual(9); }); it("should remove a tab at the start of the second line and cursor stay on this line", () => { // cursor: "Line#1\n | Line#2" textarea.value = `Line#1\n${tab}Line#2`; textarea.selectionStart = 9; textarea.selectionEnd = 9; fireEvent.keyDown(textarea, { key: KEYS.TAB, shiftKey: true, }); // cursor: "Line#1\n|Line#2" expect(textarea.selectionStart).toEqual(7); }); it("should remove partial tabs", () => { // cursor: "Line#1\n Line#|2" textarea.value = `Line#1\n Line#2`; textarea.selectionStart = 15; textarea.selectionEnd = 15; fireEvent.keyDown(textarea, { key: KEYS.TAB, shiftKey: true, }); expect(textarea.value).toEqual(`Line#1\nLine#2`); }); it("should remove nothing", () => { // cursor: "Line#1\n Li|ne#2" textarea.value = `Line#1\nLine#2`; textarea.selectionStart = 9; textarea.selectionEnd = 9; fireEvent.keyDown(textarea, { key: KEYS.TAB, shiftKey: true, }); expect(textarea.value).toEqual(`Line#1\nLine#2`); }); it("should resize text via shortcuts while in wysiwyg", () => { textarea.value = "abc def"; const origFontSize = textElement.fontSize; fireEvent.keyDown(textarea, { key: KEYS.CHEVRON_RIGHT, ctrlKey: true, shiftKey: true, }); expect(textElement.fontSize).toBe(origFontSize * 1.1); fireEvent.keyDown(textarea, { key: KEYS.CHEVRON_LEFT, ctrlKey: true, shiftKey: true, }); expect(textElement.fontSize).toBe(origFontSize); }); it("zooming via keyboard should zoom canvas", () => { expect(h.state.zoom.value).toBe(1); fireEvent.keyDown(textarea, { code: CODES.MINUS, ctrlKey: true, }); expect(h.state.zoom.value).toBe(0.9); fireEvent.keyDown(textarea, { code: CODES.NUM_SUBTRACT, ctrlKey: true, }); expect(h.state.zoom.value).toBe(0.8); fireEvent.keyDown(textarea, { code: CODES.NUM_ADD, ctrlKey: true, }); expect(h.state.zoom.value).toBe(0.9); fireEvent.keyDown(textarea, { code: CODES.EQUAL, ctrlKey: true, }); expect(h.state.zoom.value).toBe(1); }); it("text should never go beyond max width", async () => { UI.clickTool("text"); mouse.click(0, 0); textarea = await getTextEditor(textEditorSelector, true); updateTextEditor( textarea, "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", ); Keyboard.exitTextEditor(textarea); expect(textarea.style.width).toBe("792px"); expect(h.elements[0].width).toBe(1000); }); }); describe("Test container-bound text", () => { let rectangle: any; const { h } = window; beforeEach(async () => { await render(); API.setElements([]); rectangle = UI.createElement("rectangle", { x: 10, y: 20, width: 90, height: 75, }); }); it("should bind text to container when double clicked inside filled container", async () => { const rectangle = API.createElement({ type: "rectangle", x: 10, y: 20, width: 90, height: 75, backgroundColor: "red", }); API.setElements([rectangle]); expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); mouse.down(); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); }); it("should set the text element angle to same as container angle when binding to rotated container", async () => { const rectangle = API.createElement({ type: "rectangle", width: 90, height: 75, angle: 45, }); API.setElements([rectangle]); mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); expect(text.angle).toBe(rectangle.angle); mouse.down(); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); }); it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => { const diamond = API.createElement({ type: "diamond", x: 10, y: 20, width: 90, height: 75, }); API.setElements([diamond]); expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(diamond.id); API.setSelectedElements([diamond]); Keyboard.keyPress(KEYS.ENTER); const editor = await getTextEditor(textEditorSelector, true); const value = new Array(1000).fill("1").join("\n"); // Pasting large text to simulate height increase expect(() => fireEvent.input(editor, { target: { value } }), ).not.toThrow(); expect(diamond.height).toBe(50020); // Clearing text to simulate height decrease expect(() => updateTextEditor(editor, "")).not.toThrow(); expect(diamond.height).toBe(70); }); it("should bind text to container when double clicked on center of transparent container", async () => { const rectangle = API.createElement({ type: "rectangle", x: 10, y: 20, width: 90, height: 75, backgroundColor: "transparent", }); API.setElements([rectangle]); mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); expect(h.elements.length).toBe(2); let text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(null); mouse.down(); let editor = await getTextEditor(textEditorSelector, true); Keyboard.exitTextEditor(editor); mouse.doubleClickAt( rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ); expect(h.elements.length).toBe(3); text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); mouse.down(); editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); }); it("should bind text to container when clicked on container and enter pressed", async () => { expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); }); it("should bind text to container when double clicked on container stroke", async () => { const rectangle = API.createElement({ type: "rectangle", x: 10, y: 20, width: 90, height: 75, strokeWidth: 4, }); API.setElements([rectangle]); expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); mouse.doubleClickAt(rectangle.x + 2, rectangle.y + 2); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); mouse.down(); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); }); it("shouldn't bind to non-text-bindable containers", async () => { const freedraw = API.createElement({ type: "freedraw", width: 100, height: 0, }); API.setElements([freedraw]); UI.clickTool("text"); mouse.clickAt( freedraw.x + freedraw.width / 2, freedraw.y + freedraw.height / 2, ); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(freedraw.boundElements).toBe(null); expect(h.elements[1].type).toBe("text"); expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null); }); ["freedraw", "line"].forEach((type: any) => { it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => { API.setElements([]); const element = UI.createElement(type, { width: 100, height: 50, }); API.setSelectedElements([element]); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(1); }); }); it("should'nt bind text to container when not double clicked on center", async () => { expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); // clicking somewhere on top left mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(null); mouse.down(); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toBe(null); }); it("should bind text to container when triggered via context menu", async () => { expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); UI.clickTool("text"); mouse.clickAt(20, 30); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor( editor, "Excalidraw is an opensource virtual collaborative whiteboard", ); expect(h.elements.length).toBe(2); expect(h.elements[1].type).toBe("text"); API.setSelectedElements([h.elements[0], h.elements[1]]); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: 20, clientY: 30, }); const contextMenu = document.querySelector(".context-menu"); fireEvent.click( queryByText(contextMenu as HTMLElement, "Bind text to the container")!, ); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(rectangle.boundElements).toStrictEqual([ { id: h.elements[1].id, type: "text" }, ]); expect(text.containerId).toBe(rectangle.id); expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE); expect(text.textAlign).toBe(TEXT_ALIGN.CENTER); expect(text.x).toBe( h.elements[0].x + h.elements[0].width / 2 - text.width / 2, ); expect(text.y).toBe( h.elements[0].y + h.elements[0].height / 2 - text.height / 2, ); }); it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => { expect(h.elements.length).toBe(1); mouse.doubleClickAt( rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ); const text = h.elements[1] as ExcalidrawTextElementWithContainer; const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(await getTextEditor(textEditorSelector, false)).toBe(null); expect(h.state.editingElement).toBe(null); expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); fireEvent.click(screen.getByTitle(/code/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY["Comic Shanns"]); //undo Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); }); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY.Excalifont); //redo Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.keyPress(KEYS.Z); }); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY["Comic Shanns"]); }); it("should wrap text and vertcially center align once text submitted", async () => { expect(h.elements.length).toBe(1); Keyboard.keyDown(KEYS.ENTER); let text = h.elements[1] as ExcalidrawTextElementWithContainer; let editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.text).toBe("Hello \nWorld!"); expect(text.originalText).toBe("Hello World!"); expect(text.y).toBe( rectangle.y + h.elements[0].height / 2 - text.height / 2, ); expect(text.x).toBe(25); expect(text.height).toBe(50); expect(text.width).toBe(60); // Edit and text by removing second line and it should // still vertically align correctly mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.text).toBe("Hello"); expect(text.originalText).toBe("Hello"); expect(text.height).toBe(25); 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 () => { expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(rectangle.id); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.containerId).toBe(rectangle.id); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); mouse.reset(); UI.clickTool("selection"); mouse.clickAt(10, 20); mouse.down(); mouse.up(); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: 20, clientY: 30, }); const contextMenu = document.querySelector(".context-menu"); fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); expect(h.elements[0].boundElements).toEqual([]); expect((h.elements[1] as ExcalidrawTextElement).containerId).toEqual( null, ); }); it("shouldn't bind to container if container has bound text", async () => { expect(h.elements.length).toBe(1); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(2); // Bind first text const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.containerId).toBe(rectangle.id); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(2); expect(rectangle.boundElements).toStrictEqual([ { id: h.elements[1].id, type: "text" }, ]); expect(text.containerId).toBe(rectangle.id); }); it("should respect text alignment when resizing", async () => { Keyboard.keyPress(KEYS.ENTER); let editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); // should center align horizontally and vertically by default UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 85, "5.00000", ] `); mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); editor = await getTextEditor(textEditorSelector, true); editor.select(); fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Align bottom")); Keyboard.exitTextEditor(editor); // should left align horizontally and bottom vertically after resize UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 15, 65, ] `); mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); editor = await getTextEditor(textEditorSelector, true); editor.select(); fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Align top")); Keyboard.exitTextEditor(editor); // should right align horizontally and top vertically after resize UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ "375.00000", "-535.00000", ] `); }); it("should always bind to selected container and insert it in correct position", async () => { const rectangle2 = UI.createElement("rectangle", { x: 5, y: 10, width: 120, height: 100, }); API.setSelectedElements([rectangle]); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(3); expect(h.elements[1].type).toBe("text"); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); mouse.down(); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); expect(rectangle2.boundElements).toBeNull(); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); }); it("should scale font size correctly when resizing using shift", async () => { Keyboard.keyPress(KEYS.ENTER); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); const textElement = h.elements[1] as ExcalidrawTextElement; expect(rectangle.width).toBe(90); expect(rectangle.height).toBe(75); expect(textElement.fontSize).toBe(20); UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], { shift: true, }); expect(rectangle.width).toBe(200); expect(rectangle.height).toBe(166.66666666666669); expect(textElement.fontSize).toBe(47.5); }); it("should bind text correctly when container duplicated with alt-drag", async () => { Keyboard.keyPress(KEYS.ENTER); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); expect(h.elements.length).toBe(2); mouse.select(rectangle); Keyboard.withModifierKeys({ alt: true }, () => { mouse.down(rectangle.x + 10, rectangle.y + 10); mouse.up(rectangle.x + 10, rectangle.y + 10); }); expect(h.elements.length).toBe(4); const duplicatedRectangle = h.elements[0]; const duplicatedText = h .elements[1] as ExcalidrawTextElementWithContainer; const originalRect = h.elements[2]; const originalText = h.elements[3] as ExcalidrawTextElementWithContainer; expect(originalRect.boundElements).toStrictEqual([ { id: originalText.id, type: "text" }, ]); expect(originalText.containerId).toBe(originalRect.id); expect(duplicatedRectangle.boundElements).toStrictEqual([ { id: duplicatedText.id, type: "text" }, ]); expect(duplicatedText.containerId).toBe(duplicatedRectangle.id); }); it("undo should work", async () => { Keyboard.keyPress(KEYS.ENTER); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: h.elements[1].id, type: "text" }, ]); let text = h.elements[1] as ExcalidrawTextElementWithContainer; const originalRectX = rectangle.x; const originalRectY = rectangle.y; const originalTextX = text.x; const originalTextY = text.y; mouse.select(rectangle); mouse.downAt(rectangle.x, rectangle.y); 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(-40); expect(text.x).toBe(85); expect(text.y).toBe(-35); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); }); expect(rectangle.x).toBe(originalRectX); expect(rectangle.y).toBe(originalRectY); text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.x).toBe(originalTextX); expect(text.y).toBe(originalTextY); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); expect(text.containerId).toBe(rectangle.id); }); it("should not allow bound text with only whitespaces", async () => { Keyboard.keyPress(KEYS.ENTER); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, " "); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([]); expect(h.elements[1].isDeleted).toBe(true); }); it("should restore original container height and clear cache once text is unbind", async () => { const container = API.createElement({ type: "rectangle", height: 75, width: 90, }); const originalRectHeight = container.height; expect(container.height).toBe(originalRectHeight); const text = API.createElement({ type: "text", text: "Online whiteboard collaboration made easy", }); API.setElements([container, text]); API.setSelectedElements([container, text]); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: 20, clientY: 30, }); let contextMenu = document.querySelector(".context-menu"); fireEvent.click( queryByText(contextMenu as HTMLElement, "Bind text to the container")!, ); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe( "Online \nwhitebo\nard \ncollabo\nration \nmade \neasy", ); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: 20, clientY: 30, }); contextMenu = document.querySelector(".context-menu"); fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); expect(h.elements[0].boundElements).toEqual([]); expect(getOriginalContainerHeightFromCache(container.id)).toBe(null); expect(container.height).toBe(originalRectHeight); }); it("should reset the container height cache when resizing", async () => { Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); let editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect(rectangle.height).toBeCloseTo(155, 8); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); editor = await getTextEditor(textEditorSelector, true); Keyboard.exitTextEditor(editor); expect(rectangle.height).toBeCloseTo(155, 8); // cache updated again expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo( 155, 8, ); }); it("should reset the container height cache when font properties updated", async () => { Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); fireEvent.click(screen.getByTitle(/code/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY["Comic Shanns"]); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); fireEvent.click(screen.getByTitle(/Very large/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, ).toEqual(36); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100); }); it("should update line height when font family updated", async () => { Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello World!"); Keyboard.exitTextEditor(editor); 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["Comic Shanns"]); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, ).toEqual(1.25); fireEvent.click(screen.getByTitle(/normal/i)); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY.Nunito); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, ).toEqual(1.35); }); describe("should align correctly", () => { let editor: HTMLTextAreaElement; beforeEach(async () => { Keyboard.keyPress(KEYS.ENTER); editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello"); Keyboard.exitTextEditor(editor); mouse.select(rectangle); Keyboard.keyPress(KEYS.ENTER); editor = await getTextEditor(textEditorSelector, true); editor.select(); }); it("when top left", async () => { fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 15, 25, ] `); }); it("when top center", async () => { fireEvent.click(screen.getByTitle("Center")); fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 30, 25, ] `); }); it("when top right", async () => { fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 45, 25, ] `); }); it("when center left", async () => { fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Left")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 15, 45, ] `); }); it("when center center", async () => { fireEvent.click(screen.getByTitle("Center")); fireEvent.click(screen.getByTitle("Center vertically")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 30, 45, ] `); }); it("when center right", async () => { fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Center vertically")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 45, 45, ] `); }); it("when bottom left", async () => { fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 15, 65, ] `); }); 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(` [ 30, 65, ] `); }); 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(` [ 45, 65, ] `); }); }); it("should wrap text in a container when wrap text in container triggered from context menu", async () => { UI.clickTool("text"); mouse.clickAt(20, 30); const editor = await getTextEditor(textEditorSelector, true); updateTextEditor( editor, "Excalidraw is an opensource virtual collaborative whiteboard", ); editor.select(); fireEvent.click(screen.getByTitle("Left")); Keyboard.exitTextEditor(editor); const textElement = h.elements[1] as ExcalidrawTextElement; expect(textElement.width).toBe(600); 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", ); API.setSelectedElements([textElement]); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: 20, clientY: 30, }); const contextMenu = document.querySelector(".context-menu"); fireEvent.click( queryByText(contextMenu as HTMLElement, "Wrap text in a container")!, ); expect(h.elements.length).toBe(3); expect(h.elements[1]).toEqual( expect.objectContaining({ angle: 0, backgroundColor: "transparent", boundElements: [ { id: h.elements[2].id, type: "text", }, ], fillStyle: "solid", groupIds: [], height: 35, isDeleted: false, link: null, locked: false, opacity: 100, roughness: 1, roundness: { type: 3, }, strokeColor: "#1e1e1e", strokeStyle: "solid", strokeWidth: 2, type: "rectangle", updated: 1, version: 2, width: 610, x: 15, y: 25, }), ); expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect.objectContaining({ text: "Excalidraw is an opensource virtual collaborative whiteboard", verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, boundElements: null, }), ); }); it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => { expect(h.elements.length).toBe(1); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(2); // Bind first text let text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.containerId).toBe(rectangle.id); let editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Hello!"); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign, ).toBe(VERTICAL_ALIGN.MIDDLE); fireEvent.click(screen.getByTitle("Align bottom")); Keyboard.exitTextEditor(editor); expect(rectangle.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign, ).toBe(VERTICAL_ALIGN.BOTTOM); // Attempt to Bind 2nd text using text tool UI.clickTool("text"); mouse.clickAt( rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ); editor = await getTextEditor(textEditorSelector, true); updateTextEditor(editor, "Excalidraw"); Keyboard.exitTextEditor(editor); expect(h.elements.length).toBe(3); expect(rectangle.boundElements).toStrictEqual([ { id: h.elements[1].id, type: "text" }, ]); text = h.elements[2] as ExcalidrawTextElementWithContainer; expect(text.containerId).toBe(null); expect(text.text).toBe("Excalidraw"); }); }); });