diff options
Diffstat (limited to 'packages/excalidraw/element/textWysiwyg.test.tsx')
| -rw-r--r-- | packages/excalidraw/element/textWysiwyg.test.tsx | 1565 |
1 files changed, 1565 insertions, 0 deletions
diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx new file mode 100644 index 0000000..0842e30 --- /dev/null +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -0,0 +1,1565 @@ +import React from "react"; +import { Excalidraw } from "../index"; +import { + GlobalTestState, + render, + screen, + unmountComponent, +} 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"; +import { pointFrom } from "@excalidraw/math"; + +unmountComponent(); + +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(<Excalidraw handleKeyboardGlobally={true} />); + 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: [pointFrom(0, 0), pointFrom(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.editingTextElement?.id).toBe(text.id); + expect( + (h.state.editingTextElement 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.editingTextElement?.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.editingTextElement?.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.editingTextElement?.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.editingTextElement?.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.editingTextElement?.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(<Excalidraw handleKeyboardGlobally={true} />); + // @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.editingTextElement?.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(<Excalidraw handleKeyboardGlobally={true} />); + // @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(<Excalidraw handleKeyboardGlobally={true} />); + 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.editingTextElement).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\nwhiteboa\nrd\ncollabor\nation\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"); + }); + }); +}); |
