import ReactDOM from "react-dom"; import { ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, FontString, } from "../element/types"; import { Excalidraw } from "../index"; import { centerPoint } from "../math"; import { reseed } from "../random"; import * as Renderer from "../renderer/renderScene"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { screen, render, fireEvent, GlobalTestState } from "./test-utils"; import { API } from "../tests/helpers/api"; import { Point } from "../types"; import { KEYS } from "../keys"; import { LinearElementEditor } from "../element/linearElementEditor"; import { queryByTestId, queryByText } from "@testing-library/react"; import { getBoundTextElementPosition, wrapText, getBoundTextMaxWidth, } from "../element/textElement"; import * as textElementUtils from "../element/textElement"; import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); const { h } = window; const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; describe("Test Linear Elements", () => { let container: HTMLElement; let interactiveCanvas: HTMLCanvasElement; beforeEach(async () => { // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); localStorage.clear(); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); reseed(7); const comp = await render(); h.state.width = 1000; h.state.height = 1000; container = comp.container; interactiveCanvas = container.querySelector("canvas.interactive")!; }); const p1: Point = [20, 20]; const p2: Point = [60, 20]; const midpoint = centerPoint(p1, p2); const delta = 50; const mouse = new Pointer("mouse"); const createTwoPointerLinearElement = ( type: ExcalidrawLinearElement["type"], roundness: ExcalidrawElement["roundness"] = null, roughness: ExcalidrawLinearElement["roughness"] = 0, ) => { const line = API.createElement({ x: p1[0], y: p1[1], width: p2[0] - p1[0], height: 0, type, roughness, points: [ [0, 0], [p2[0] - p1[0], p2[1] - p1[1]], ], roundness, }); h.elements = [line]; mouse.clickAt(p1[0], p1[1]); return line; }; const createThreePointerLinearElement = ( type: ExcalidrawLinearElement["type"], roundness: ExcalidrawElement["roundness"] = null, roughness: ExcalidrawLinearElement["roughness"] = 0, ) => { //dragging line from midpoint const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]]; const line = API.createElement({ x: p1[0], y: p1[1], width: p3[0] - p1[0], height: 0, type, roughness, points: [ [0, 0], [p3[0], p3[1]], [p2[0] - p1[0], p2[1] - p1[1]], ], roundness, }); h.elements = [line]; mouse.clickAt(p1[0], p1[1]); return line; }; const enterLineEditingMode = ( line: ExcalidrawLinearElement, selectProgrammatically = false, ) => { if (selectProgrammatically) { API.setSelectedElements([line]); } else { mouse.clickAt(p1[0], p1[1]); } Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.ENTER); }); expect(h.state.editingLinearElement?.elementId).toEqual(line.id); }; const drag = (startPoint: Point, endPoint: Point) => { fireEvent.pointerDown(interactiveCanvas, { clientX: startPoint[0], clientY: startPoint[1], }); fireEvent.pointerMove(interactiveCanvas, { clientX: endPoint[0], clientY: endPoint[1], }); fireEvent.pointerUp(interactiveCanvas, { clientX: endPoint[0], clientY: endPoint[1], }); }; const deletePoint = (point: Point) => { fireEvent.pointerDown(interactiveCanvas, { clientX: point[0], clientY: point[1], }); fireEvent.pointerUp(interactiveCanvas, { clientX: point[0], clientY: point[1], }); Keyboard.keyPress(KEYS.DELETE); }; it("should not drag line and add midpoint until dragged beyond a threshold", () => { createTwoPointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; const originalX = line.x; const originalY = line.y; expect(line.points.length).toEqual(2); mouse.clickAt(midpoint[0], midpoint[1]); drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]); expect(line.points.length).toEqual(2); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(2); drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(3); }); it("should allow dragging line from midpoint in 2 pointer lines outside editor", async () => { createTwoPointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2); // drag line from midpoint drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` [ [ 0, 0, ], [ 70, 50, ], [ 40, 0, ], ] `); }); it("should allow entering and exiting line editor via context menu", () => { createTwoPointerLinearElement("line"); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); // Enter line editor let contextMenu = document.querySelector(".context-menu"); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); // Exiting line editor fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); contextMenu = document.querySelector(".context-menu"); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); fireEvent.click( queryByText(contextMenu as HTMLElement, "Exit line editor")!, ); expect(h.state.editingLinearElement?.elementId).toBeUndefined(); }); it("should enter line editor when using double clicked with ctrl key", () => { createTwoPointerLinearElement("line"); expect(h.state.editingLinearElement?.elementId).toBeUndefined(); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.doubleClick(); }); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); }); describe("Inside editor", () => { it("should not drag line and add midpoint when dragged irrespective of threshold", () => { createTwoPointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; const originalX = line.x; const originalY = line.y; enterLineEditingMode(line); expect(line.points.length).toEqual(2); mouse.clickAt(midpoint[0], midpoint[1]); expect(line.points.length).toEqual(2); drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(3); }); it("should allow dragging line from midpoint in 2 pointer lines", async () => { createTwoPointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; enterLineEditingMode(line); // drag line from midpoint drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` [ [ 0, 0, ], [ 70, 50, ], [ 40, 0, ], ] `); }); it("should update the midpoints when element roundness changed", async () => { createThreePointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; expect(line.points.length).toEqual(3); enterLineEditingMode(line); const midPointsWithSharpEdge = LinearElementEditor.getEditorMidPoints( line, h.state, ); // update roundness fireEvent.click(screen.getByTitle("Round")); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `10`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( h.elements[0] as ExcalidrawLinearElement, h.state, ); expect(midPointsWithRoundEdge[0]).not.toEqual(midPointsWithSharpEdge[0]); expect(midPointsWithRoundEdge[1]).not.toEqual(midPointsWithSharpEdge[1]); expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` [ [ 55.9697848965255, 47.442326230998205, ], [ 76.08587175006699, 43.294165939653226, ], ] `); }); it("should update all the midpoints when element position changed", async () => { createThreePointerLinearElement("line", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); const line = h.elements[0] as ExcalidrawLinearElement; expect(line.points.length).toEqual(3); enterLineEditingMode(line); const points = LinearElementEditor.getPointsGlobalCoordinates(line); expect([line.x, line.y]).toEqual(points[0]); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); const startPoint = centerPoint(points[0], midPoints[0] as Point); const deltaX = 50; const deltaY = 20; const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY]; // Move the element drag(startPoint, endPoint); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `13`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect([line.x, line.y]).toEqual([ points[0][0] + deltaX, points[0][1] + deltaY, ]); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); expect(midPoints[0]).not.toEqual(newMidPoints[0]); expect(midPoints[1]).not.toEqual(newMidPoints[1]); expect(newMidPoints).toMatchInlineSnapshot(` [ [ 105.96978489652551, 67.4423262309982, ], [ 126.08587175006699, 63.294165939653226, ], ] `); }); describe("When edges are round", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it const firstSegmentMidpoint: Point = [55, 45]; const lastSegmentMidpoint: Point = [75, 40]; let line: ExcalidrawLinearElement; beforeEach(() => { line = createThreePointerLinearElement("line"); expect(line.points.length).toEqual(3); enterLineEditingMode(line); }); it("should allow dragging lines from midpoints in between segments", async () => { // drag line via first segment midpoint drag(firstSegmentMidpoint, [ firstSegmentMidpoint[0] + delta, firstSegmentMidpoint[1] + delta, ]); expect(line.points.length).toEqual(4); // drag line from last segment midpoint drag(lastSegmentMidpoint, [ lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta, ]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `17`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) .toMatchInlineSnapshot(` [ [ 0, 0, ], [ 85, 75, ], [ 70, 50, ], [ 105, 70, ], [ 40, 0, ], ] `); }); it("should update only the first segment midpoint when its point is dragged", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); const hitCoords: Point = [points[0][0], points[0][1]]; // Drag from first point drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `13`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] - delta, points[0][1] - delta, ]); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); expect(midPoints[0]).not.toEqual(newMidPoints[0]); expect(midPoints[1]).toEqual(newMidPoints[1]); }); it("should hide midpoints in the segment when points moved close", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); const hitCoords: Point = [points[0][0], points[0][1]]; // Drag from first point drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `13`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] + delta, points[0][1] + delta, ]); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); // This midpoint is hidden since the points are too close expect(newMidPoints[0]).toBeNull(); expect(midPoints[1]).toEqual(newMidPoints[1]); }); it("should remove the midpoint when one of the points in the segment is deleted", async () => { const line = h.elements[0] as ExcalidrawLinearElement; enterLineEditingMode(line); const points = LinearElementEditor.getPointsGlobalCoordinates(line); // dragging line from last segment midpoint drag(lastSegmentMidpoint, [ lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50, ]); expect(line.points.length).toEqual(4); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); // delete 3rd point deletePoint(points[2]); expect(line.points.length).toEqual(3); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `19`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); expect(newMidPoints.length).toEqual(2); expect(midPoints[0]).toEqual(newMidPoints[0]); expect(midPoints[1]).toEqual(newMidPoints[1]); }); }); describe("When edges are round", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it const firstSegmentMidpoint: Point = [ 55.9697848965255, 47.442326230998205, ]; const lastSegmentMidpoint: Point = [ 76.08587175006699, 43.294165939653226, ]; let line: ExcalidrawLinearElement; beforeEach(() => { line = createThreePointerLinearElement("line", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); expect(line.points.length).toEqual(3); enterLineEditingMode(line); }); it("should allow dragging lines from midpoints in between segments", async () => { // drag line from first segment midpoint drag(firstSegmentMidpoint, [ firstSegmentMidpoint[0] + delta, firstSegmentMidpoint[1] + delta, ]); expect(line.points.length).toEqual(4); // drag line from last segment midpoint drag(lastSegmentMidpoint, [ lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta, ]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `17`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) .toMatchInlineSnapshot(` [ [ 0, 0, ], [ 85.96978489652551, 77.4423262309982, ], [ 70, 50, ], [ 106.08587175006699, 73.29416593965323, ], [ 40, 0, ], ] `); }); it("should update all the midpoints when its point is dragged", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); const hitCoords: Point = [points[0][0], points[0][1]]; // Drag from first point drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] - delta, points[0][1] - delta, ]); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); expect(midPoints[0]).not.toEqual(newMidPoints[0]); expect(midPoints[1]).not.toEqual(newMidPoints[1]); expect(newMidPoints).toMatchInlineSnapshot(` [ [ 31.884084517616053, 23.13275505472383, ], [ 77.74792546875662, 44.57840982272327, ], ] `); }); it("should hide midpoints in the segment when points moved close", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); const hitCoords: Point = [points[0][0], points[0][1]]; // Drag from first point drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `13`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] + delta, points[0][1] + delta, ]); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); // This mid point is hidden due to point being too close expect(newMidPoints[0]).toBeNull(); expect(newMidPoints[1]).not.toEqual(midPoints[1]); }); it("should update all the midpoints when a point is deleted", async () => { drag(lastSegmentMidpoint, [ lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta, ]); expect(line.points.length).toEqual(4); const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); const points = LinearElementEditor.getPointsGlobalCoordinates(line); // delete 3rd point deletePoint(points[2]); expect(line.points.length).toEqual(3); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, h.state, ); expect(newMidPoints.length).toEqual(2); expect(midPoints[0]).not.toEqual(newMidPoints[0]); expect(midPoints[1]).not.toEqual(newMidPoints[1]); expect(newMidPoints).toMatchInlineSnapshot(` [ [ 55.9697848965255, 47.442326230998205, ], [ 76.08587175006699, 43.294165939653226, ], ] `); }); }); it("in-editor dragging a line point covered by another element", () => { createTwoPointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; h.elements = [ line, API.createElement({ type: "rectangle", x: line.x - 50, y: line.y - 50, width: 100, height: 100, backgroundColor: "red", fillStyle: "solid", }), ]; const dragEndPositionOffset = [100, 100] as const; API.setSelectedElements([line]); enterLineEditingMode(line, true); drag( [line.points[0][0] + line.x, line.points[0][1] + line.y], [dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y], ); expect(line.points).toMatchInlineSnapshot(` [ [ 0, 0, ], [ -60, -100, ], ] `); }); }); describe("Test bound text element", () => { const DEFAULT_TEXT = "Online whiteboard collaboration made easy"; const createBoundTextElement = ( text: string, container: ExcalidrawLinearElement, ) => { const textElement = API.createElement({ type: "text", x: 0, y: 0, text: wrapText(text, font, getBoundTextMaxWidth(container)), containerId: container.id, width: 30, height: 20, }) as ExcalidrawTextElementWithContainer; container = { ...container, boundElements: (container.boundElements || []).concat({ type: "text", id: textElement.id, }), }; const elements: ExcalidrawElement[] = []; h.elements.forEach((element) => { if (element.id === container.id) { elements.push(container); } else { elements.push(element); } }); const updatedTextElement = { ...textElement, originalText: text }; h.elements = [...elements, updatedTextElement]; return { textElement: updatedTextElement, container }; }; describe("Test getBoundTextElementPosition", () => { it("should return correct position for 2 pointer arrow", () => { createTwoPointerLinearElement("arrow"); const arrow = h.elements[0] as ExcalidrawLinearElement; const { textElement, container } = createBoundTextElement( DEFAULT_TEXT, arrow, ); const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, ); expect(position).toMatchInlineSnapshot(` { "x": 25, "y": 10, } `); }); it("should return correct position for arrow with odd points", () => { createThreePointerLinearElement("arrow", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); const arrow = h.elements[0] as ExcalidrawLinearElement; const { textElement, container } = createBoundTextElement( DEFAULT_TEXT, arrow, ); const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, ); expect(position).toMatchInlineSnapshot(` { "x": 75, "y": 60, } `); }); it("should return correct position for arrow with even points", () => { createThreePointerLinearElement("arrow", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); const arrow = h.elements[0] as ExcalidrawLinearElement; const { textElement, container } = createBoundTextElement( DEFAULT_TEXT, arrow, ); enterLineEditingMode(container); // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it const firstSegmentMidpoint: Point = [ 55.9697848965255, 47.442326230998205, ]; // drag line from first segment midpoint drag(firstSegmentMidpoint, [ firstSegmentMidpoint[0] + delta, firstSegmentMidpoint[1] + delta, ]); const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, ); expect(position).toMatchInlineSnapshot(` { "x": 85.82201843191861, "y": 75.63461309860818, } `); }); }); it("should match styles for text editor", () => { createTwoPointerLinearElement("arrow"); Keyboard.keyPress(KEYS.ENTER); const editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; expect(editor).toMatchSnapshot(); }); it("should bind text to arrow when double clicked", async () => { createTwoPointerLinearElement("arrow"); const arrow = h.elements[0] as ExcalidrawLinearElement; expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(arrow.id); mouse.doubleClickAt(arrow.x, arrow.y); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(arrow.id); mouse.down(); const editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; fireEvent.change(editor, { target: { value: DEFAULT_TEXT }, }); await new Promise((r) => setTimeout(r, 0)); editor.blur(); expect(arrow.boundElements).toStrictEqual([ { id: text.id, type: "text" }, ]); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) .toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); }); it("should bind text to arrow when clicked on arrow and enter pressed", async () => { const arrow = createTwoPointerLinearElement("arrow"); expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(arrow.id); Keyboard.keyPress(KEYS.ENTER); expect(h.elements.length).toBe(2); const textElement = h.elements[1] as ExcalidrawTextElementWithContainer; expect(textElement.type).toBe("text"); expect(textElement.containerId).toBe(arrow.id); const editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; await new Promise((r) => setTimeout(r, 0)); fireEvent.change(editor, { target: { value: DEFAULT_TEXT }, }); editor.blur(); expect(arrow.boundElements).toStrictEqual([ { id: textElement.id, type: "text" }, ]); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) .toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); }); it("should not bind text to line when double clicked", async () => { const line = createTwoPointerLinearElement("line"); expect(h.elements.length).toBe(1); mouse.doubleClickAt(line.x, line.y); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBeNull(); expect(line.boundElements).toBeNull(); }); // TODO fix #7029 and rewrite this test it.todo( "should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", ); it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => { createThreePointerLinearElement("arrow", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); const arrow = h.elements[0] as ExcalidrawLinearElement; const { textElement, container } = createBoundTextElement( DEFAULT_TEXT, arrow, ); expect(container.width).toBe(70); expect(container.height).toBe(50); expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` { "x": 75, "y": 60, } `); expect(textElement.text).toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) .toMatchInlineSnapshot(` [ 20, 20, 105, 80, 55.45893770831013, 45, ] `); UI.resize(container, "ne", [300, 200]); expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` { "height": 130, "width": 366.11716195150507, } `); expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` { "x": 271.11716195150507, "y": 45, } `); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) .toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) .toMatchInlineSnapshot(` [ 20, 35, 501.11716195150507, 95, 205.4589377083102, 52.5, ] `); }); it("should resize and position the bound text correctly when 2 pointer linear element resized", () => { createTwoPointerLinearElement("arrow"); const arrow = h.elements[0] as ExcalidrawLinearElement; const { textElement, container } = createBoundTextElement( DEFAULT_TEXT, arrow, ); expect(container.width).toBe(40); expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` { "x": 25, "y": 10, } `); expect(textElement.text).toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); const points = LinearElementEditor.getPointsGlobalCoordinates(container); // Drag from last point drag(points[1], [points[1][0] + 300, points[1][1]]); expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` { "height": 130, "width": 340, } `); expect(getBoundTextElementPosition(container, textElement)) .toMatchInlineSnapshot(` { "x": 75, "y": -5, } `); expect(textElement.text).toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); }); it("should not render vertical align tool when element selected", () => { createTwoPointerLinearElement("arrow"); const arrow = h.elements[0] as ExcalidrawLinearElement; createBoundTextElement(DEFAULT_TEXT, arrow); API.setSelectedElements([arrow]); expect(queryByTestId(container, "align-top")).toBeNull(); expect(queryByTestId(container, "align-middle")).toBeNull(); expect(queryByTestId(container, "align-bottom")).toBeNull(); }); it("should wrap the bound text when arrow bound container moves", async () => { const rect = UI.createElement("rectangle", { x: 400, width: 200, height: 500, }); const arrow = UI.createElement("arrow", { x: -10, y: 250, width: 400, height: 1, }); mouse.select(arrow); Keyboard.keyPress(KEYS.ENTER); const editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; await new Promise((r) => setTimeout(r, 0)); fireEvent.change(editor, { target: { value: DEFAULT_TEXT } }); editor.blur(); const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); expect(arrow.width).toBe(400); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), ).toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); const handleBindTextResizeSpy = vi.spyOn( textElementUtils, "handleBindTextResize", ); mouse.select(rect); mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); expect(arrow.width).toBe(200); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( h.elements[1], false, ); expect( wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), ).toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" `); }); it("should not render horizontal align tool when element selected", () => { createTwoPointerLinearElement("arrow"); const arrow = h.elements[0] as ExcalidrawLinearElement; createBoundTextElement(DEFAULT_TEXT, arrow); API.setSelectedElements([arrow]); expect(queryByTestId(container, "align-left")).toBeNull(); expect(queryByTestId(container, "align-horizontal-center")).toBeNull(); expect(queryByTestId(container, "align-right")).toBeNull(); }); it("should update label coords when a label binded via context menu is unbinded", async () => { createTwoPointerLinearElement("arrow"); const text = API.createElement({ type: "text", text: "Hello Excalidraw", }); expect(text.x).toBe(0); expect(text.y).toBe(0); h.elements = [h.elements[0], text]; const container = h.elements[0]; 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(container.boundElements).toStrictEqual([ { id: h.elements[1].id, type: "text" }, ]); expect(text.containerId).toBe(container.id); expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE); mouse.reset(); mouse.clickAt( container.x + container.width / 2, container.y + container.height / 2, ); mouse.down(); mouse.up(); API.setSelectedElements([h.elements[0], h.elements[1]]); fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { button: 2, clientX: 20, clientY: 30, }); contextMenu = document.querySelector(".context-menu"); fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); expect(container.boundElements).toEqual([]); expect(text).toEqual( expect.objectContaining({ containerId: null, width: 160, height: 25, x: -40, y: 7.5, }), ); }); it("should not update label position when arrow dragged", () => { createTwoPointerLinearElement("arrow"); let arrow = h.elements[0] as ExcalidrawLinearElement; createBoundTextElement(DEFAULT_TEXT, arrow); let label = h.elements[1] as ExcalidrawTextElementWithContainer; expect(arrow.x).toBe(20); expect(arrow.y).toBe(20); expect(label.x).toBe(0); expect(label.y).toBe(0); mouse.reset(); mouse.select(arrow); mouse.select(label); mouse.downAt(arrow.x, arrow.y); mouse.moveTo(arrow.x + 20, arrow.y + 30); mouse.up(arrow.x + 20, arrow.y + 30); arrow = h.elements[0] as ExcalidrawLinearElement; label = h.elements[1] as ExcalidrawTextElementWithContainer; expect(arrow.x).toBe(80); expect(arrow.y).toBe(100); expect(label.x).toBe(0); expect(label.y).toBe(0); }); }); });