import ReactDOM from "react-dom"; import { ExcalidrawLinearElement } from "../element/types"; import ExcalidrawApp from "../excalidraw-app"; import { centerPoint } from "../math"; import { reseed } from "../random"; import * as Renderer from "../renderer/renderScene"; import { Keyboard, Pointer } 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 { queryByText } from "@testing-library/react"; const renderScene = jest.spyOn(Renderer, "renderScene"); const { h } = window; describe("Test Linear Elements", () => { let container: HTMLElement; let canvas: HTMLCanvasElement; beforeEach(async () => { // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); localStorage.clear(); renderScene.mockClear(); reseed(7); const comp = await render(); container = comp.container; canvas = container.querySelector("canvas")!; canvas.width = 1000; canvas.height = 1000; }); 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"], strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp", roughness: ExcalidrawLinearElement["roughness"] = 0, ) => { h.elements = [ 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]], ], strokeSharpness, }), ]; mouse.clickAt(p1[0], p1[1]); }; const createThreePointerLinearElement = ( type: ExcalidrawLinearElement["type"], strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp", roughness: ExcalidrawLinearElement["roughness"] = 0, ) => { //dragging line from midpoint const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]]; h.elements = [ 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]], ], strokeSharpness, }), ]; mouse.clickAt(p1[0], p1[1]); }; const enterLineEditingMode = ( line: ExcalidrawLinearElement, selectProgrammatically = false, ) => { if (selectProgrammatically) { API.setSelectedElements([line]); } else { mouse.clickAt(p1[0], p1[1]); } Keyboard.keyPress(KEYS.ENTER); expect(h.state.editingLinearElement?.elementId).toEqual(line.id); }; const drag = (startPoint: Point, endPoint: Point) => { fireEvent.pointerDown(canvas, { clientX: startPoint[0], clientY: startPoint[1], }); fireEvent.pointerMove(canvas, { clientX: endPoint[0], clientY: endPoint[1], }); fireEvent.pointerUp(canvas, { clientX: endPoint[0], clientY: endPoint[1], }); }; const deletePoint = (point: Point) => { fireEvent.pointerDown(canvas, { clientX: point[0], clientY: point[1], }); fireEvent.pointerUp(canvas, { 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(renderScene).toHaveBeenCalledTimes(7); expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2); // drag line from midpoint drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); expect(renderScene).toHaveBeenCalledTimes(11); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` Array [ Array [ 0, 0, ], Array [ 70, 50, ], Array [ 40, 0, ], ] `); }); it("should allow entering and exiting line editor via context menu", () => { createTwoPointerLinearElement("line"); fireEvent.contextMenu(GlobalTestState.canvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); // Enter line editor let contextMenu = document.querySelector(".context-menu"); fireEvent.contextMenu(GlobalTestState.canvas, { 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.canvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); contextMenu = document.querySelector(".context-menu"); fireEvent.contextMenu(GlobalTestState.canvas, { button: 2, clientX: midpoint[0], clientY: midpoint[1], }); fireEvent.click( queryByText(contextMenu as HTMLElement, "Exit line editor")!, ); expect(h.state.editingLinearElement?.elementId).toBeUndefined(); }); 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(renderScene).toHaveBeenCalledTimes(15); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` Array [ Array [ 0, 0, ], Array [ 70, 50, ], Array [ 40, 0, ], ] `); }); it("should update the midpoints when element sharpness 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 sharpness fireEvent.click(screen.getByTitle("Round")); expect(renderScene).toHaveBeenCalledTimes(12); 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(` Array [ Array [ 55.9697848965255, 47.442326230998205, ], Array [ 76.08587175006699, 43.294165939653226, ], ] `); }); it("should update all the midpoints when element position changed", async () => { createThreePointerLinearElement("line", "round"); 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(renderScene).toHaveBeenCalledTimes(16); 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(` Array [ Array [ 105.96978489652551, 67.4423262309982, ], Array [ 126.08587175006699, 63.294165939653226, ], ] `); }); describe("When edges are sharp", () => { // This is the expected midpoint for line with sharp 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(() => { createThreePointerLinearElement("line"); line = h.elements[0] as ExcalidrawLinearElement; 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(renderScene).toHaveBeenCalledTimes(21); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) .toMatchInlineSnapshot(` Array [ Array [ 0, 0, ], Array [ 85, 75, ], Array [ 70, 50, ], Array [ 105, 70, ], Array [ 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(renderScene).toHaveBeenCalledTimes(16); 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(renderScene).toHaveBeenCalledTimes(16); 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(renderScene).toHaveBeenCalledTimes(21); 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(() => { createThreePointerLinearElement("line", "round"); line = h.elements[0] as ExcalidrawLinearElement; 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(renderScene).toHaveBeenCalledTimes(21); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) .toMatchInlineSnapshot(` Array [ Array [ 0, 0, ], Array [ 85.96978489652551, 77.4423262309982, ], Array [ 70, 50, ], Array [ 106.08587175006699, 73.29416593965323, ], Array [ 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(` Array [ Array [ 31.884084517616053, 23.13275505472383, ], Array [ 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(renderScene).toHaveBeenCalledTimes(16); 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(` Array [ Array [ 55.9697848965255, 47.442326230998205, ], Array [ 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 origPoints = line.points.map((point) => [...point]); 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(` Array [ Array [ 0, 0, ], Array [ ${origPoints[1][0] - dragEndPositionOffset[0]}, ${origPoints[1][1] - dragEndPositionOffset[1]}, ], ] `); }); }); });