From 89a3bbddb72fba6a3ceae91541afd4d9d6a702b0 Mon Sep 17 00:00:00 2001 From: Alex Kim <45559664+alex-kim-dev@users.noreply.github.com> Date: Thu, 12 Oct 2023 23:59:02 +0500 Subject: [PATCH] test: add more resizing tests (#7028) Co-authored-by: dwelle --- src/element/textWysiwyg.test.tsx | 29 +- src/tests/helpers/ui.ts | 279 +++++- src/tests/linearElementEditor.test.tsx | 72 +- src/tests/resize.test.tsx | 1096 +++++++++++++++++++++--- src/tests/rotate.test.tsx | 81 ++ src/tests/selection.test.tsx | 2 +- src/tests/utils.ts | 48 -- 7 files changed, 1305 insertions(+), 302 deletions(-) create mode 100644 src/tests/rotate.test.tsx delete mode 100644 src/tests/utils.ts diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 9115d0e5..b4f5db19 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -17,7 +17,6 @@ import { } from "./types"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; -import { resize } from "../tests/utils"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; // Unmount ReactDOM from root @@ -953,7 +952,7 @@ describe("textWysiwyg", () => { editor.blur(); // should center align horizontally and vertically by default - resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 85, @@ -977,7 +976,7 @@ describe("textWysiwyg", () => { editor.blur(); // should left align horizontally and bottom vertically after resize - resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 15, @@ -999,7 +998,7 @@ describe("textWysiwyg", () => { editor.blur(); // should right align horizontally and top vertically after resize - resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` [ 374.99999999999994, @@ -1049,7 +1048,7 @@ describe("textWysiwyg", () => { expect(rectangle.height).toBe(75); expect(textElement.fontSize).toBe(20); - resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], { + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], { shift: true, }); expect(rectangle.width).toBe(200); @@ -1189,7 +1188,7 @@ describe("textWysiwyg", () => { updateTextEditor(editor, "Hello"); editor.blur(); - resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect(rectangle.height).toBeCloseTo(155, 8); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); @@ -1513,28 +1512,16 @@ describe("textWysiwyg", () => { }); }); - it("should bump the version of labelled arrow when label updated", async () => { + it("should bump the version of a labeled arrow when the label is updated", async () => { await render(); const arrow = UI.createElement("arrow", { width: 300, height: 0, }); - - mouse.select(arrow); - Keyboard.keyPress(KEYS.ENTER); - let editor = getTextEditor(); - await new Promise((r) => setTimeout(r, 0)); - updateTextEditor(editor, "Hello"); - editor.blur(); - + await UI.editText(arrow, "Hello"); const { version } = arrow; - mouse.select(arrow); - Keyboard.keyPress(KEYS.ENTER); - editor = getTextEditor(); - await new Promise((r) => setTimeout(r, 0)); - updateTextEditor(editor, "Hello\nworld!"); - editor.blur(); + await UI.editText(arrow, "Hello\nworld!"); expect(arrow.version).toEqual(version + 1); }); diff --git a/src/tests/helpers/ui.ts b/src/tests/helpers/ui.ts index f815b07d..62290fe2 100644 --- a/src/tests/helpers/ui.ts +++ b/src/tests/helpers/ui.ts @@ -1,13 +1,37 @@ -import { +import type { Point } from "../../types"; +import type { ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElement, + ExcalidrawArrowElement, + ExcalidrawRectangleElement, + ExcalidrawEllipseElement, + ExcalidrawDiamondElement, + ExcalidrawTextContainer, + ExcalidrawTextElementWithContainer, } from "../../element/types"; +import { + getTransformHandles, + getTransformHandlesFromCoords, + OMIT_SIDES_FOR_FRAME, + OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + TransformHandleType, + type TransformHandle, + type TransformHandleDirection, +} from "../../element/transformHandles"; import { KEYS } from "../../keys"; -import { ToolName } from "../queries/toolQueries"; -import { fireEvent, GlobalTestState } from "../test-utils"; +import { type ToolName } from "../queries/toolQueries"; +import { fireEvent, GlobalTestState, screen } from "../test-utils"; import { mutateElement } from "../../element/mutateElement"; import { API } from "./api"; +import { + isFrameElement, + isLinearElement, + isFreeDrawElement, + isTextElement, +} from "../../element/typeChecks"; +import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; +import { rotatePoint } from "../../math"; const { h } = window; @@ -86,6 +110,29 @@ export class Keyboard { }; } +const getElementPointForSelection = (element: ExcalidrawElement): Point => { + const { x, y, width, height, angle } = element; + const target: Point = [ + x + + (isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2), + y, + ]; + let center: Point; + + if (isLinearElement(element)) { + const bounds = getElementPointsCoords(element, element.points); + center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2]; + } else { + center = [x + width / 2, y + height / 2]; + } + + if (isTextElement(element)) { + return center; + } + + return rotatePoint(target, center, angle); +}; + export class Pointer { public clientX = 0; public clientY = 0; @@ -199,31 +246,120 @@ export class Pointer { elements: ExcalidrawElement | ExcalidrawElement[], ) { API.clearSelection(); + Keyboard.withModifierKeys({ shift: true }, () => { elements = Array.isArray(elements) ? elements : [elements]; elements.forEach((element) => { this.reset(); - this.click(element.x, element.y); + this.click(...getElementPointForSelection(element)); }); }); + this.reset(); } clickOn(element: ExcalidrawElement) { this.reset(); - this.click(element.x, element.y); + this.click(...getElementPointForSelection(element)); this.reset(); } doubleClickOn(element: ExcalidrawElement) { this.reset(); - this.doubleClick(element.x, element.y); + this.doubleClick(...getElementPointForSelection(element)); this.reset(); } } const mouse = new Pointer("mouse"); +const transform = ( + element: ExcalidrawElement | ExcalidrawElement[], + handle: TransformHandleType, + mouseMove: [deltaX: number, deltaY: number], + keyboardModifiers: KeyboardModifiers = {}, +) => { + const elements = Array.isArray(element) ? element : [element]; + mouse.select(elements); + let handleCoords: TransformHandle | undefined; + + if (elements.length === 1) { + handleCoords = getTransformHandles(elements[0], h.state.zoom, "mouse")[ + handle + ]; + } else { + const [x1, y1, x2, y2] = getCommonBounds(elements); + const isFrameSelected = elements.some(isFrameElement); + const transformHandles = getTransformHandlesFromCoords( + [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], + 0, + h.state.zoom, + "mouse", + isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + ); + handleCoords = transformHandles[handle]; + } + + if (!handleCoords) { + throw new Error(`There is no "${handle}" handle for this selection`); + } + + const clientX = handleCoords[0] + handleCoords[2] / 2; + const clientY = handleCoords[1] + handleCoords[3] / 2; + + Keyboard.withModifierKeys(keyboardModifiers, () => { + mouse.reset(); + mouse.down(clientX, clientY); + mouse.move(mouseMove[0], mouseMove[1]); + mouse.up(); + }); +}; + +const proxy = ( + element: T, +): typeof element & { + /** Returns the actual, current element from the elements array, instead of + the proxy */ + get(): typeof element; +} => { + return new Proxy( + {}, + { + get(target, prop) { + const currentElement = h.elements.find( + ({ id }) => id === element.id, + ) as any; + if (prop === "get") { + if (currentElement.hasOwnProperty("get")) { + throw new Error( + "trying to get `get` test property, but ExcalidrawElement seems to define its own", + ); + } + return () => currentElement; + } + return currentElement[prop]; + }, + }, + ) as any; +}; + +/** Tools that can be used to draw shapes */ +type DrawingToolName = Exclude; + +type Element = T extends "line" | "freedraw" + ? ExcalidrawLinearElement + : T extends "arrow" + ? ExcalidrawArrowElement + : T extends "text" + ? ExcalidrawTextElement + : T extends "rectangle" + ? ExcalidrawRectangleElement + : T extends "ellipse" + ? ExcalidrawEllipseElement + : T extends "diamond" + ? ExcalidrawDiamondElement + : ExcalidrawElement; + export class UI { static clickTool = (toolName: ToolName) => { fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName)); @@ -246,6 +382,10 @@ export class UI { fireEvent.click(element); }; + static clickByTitle = (title: string) => { + fireEvent.click(screen.getByTitle(title)); + }; + /** * Creates an Excalidraw element, and returns a proxy that wraps it so that * accessing props will return the latest ones from the object existing in @@ -255,16 +395,17 @@ export class UI { * If you need to get the actual element, not the proxy, call `get()` method * on the proxy object. */ - static createElement( + static createElement( type: T, { position = 0, x = position, y = position, size = 10, - width = size, - height = width, + width: initialWidth = size, + height: initialHeight = initialWidth, angle = 0, + points: initialPoints, }: { position?: number; x?: number; @@ -273,25 +414,46 @@ export class UI { width?: number; height?: number; angle?: number; + points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never; } = {}, - ): (T extends "arrow" | "line" | "freedraw" - ? ExcalidrawLinearElement - : T extends "text" - ? ExcalidrawTextElement - : ExcalidrawElement) & { + ): Element & { /** Returns the actual, current element from the elements array, instead of the proxy */ - get(): T extends "arrow" | "line" | "freedraw" - ? ExcalidrawLinearElement - : T extends "text" - ? ExcalidrawTextElement - : ExcalidrawElement; + get(): Element; } { + const width = initialWidth ?? initialHeight ?? size; + const height = initialHeight ?? size; + const points: Point[] = initialPoints ?? [ + [0, 0], + [width, height], + ]; + UI.clickTool(type); - mouse.reset(); - mouse.down(x, y); - mouse.reset(); - mouse.up(x + (width ?? height ?? size), y + (height ?? size)); + + if (type === "text") { + mouse.reset(); + mouse.click(x, y); + } else if ((type === "line" || type === "arrow") && points.length > 2) { + points.forEach((point) => { + mouse.reset(); + mouse.click(x + point[0], y + point[1]); + }); + Keyboard.keyPress(KEYS.ESCAPE); + } else if (type === "freedraw" && points.length > 2) { + const firstPoint = points[0]; + mouse.reset(); + mouse.down(x + firstPoint[0], y + firstPoint[1]); + points + .slice(1) + .forEach((point) => mouse.moveTo(x + point[0], y + point[1])); + mouse.upAt(); + Keyboard.keyPress(KEYS.ESCAPE); + } else { + mouse.reset(); + mouse.down(x, y); + mouse.reset(); + mouse.up(x + width, y + height); + } const origElement = h.elements[h.elements.length - 1] as any; @@ -299,25 +461,58 @@ export class UI { mutateElement(origElement, { angle }); } - return new Proxy( - {}, - { - get(target, prop) { - const currentElement = h.elements.find( - (element) => element.id === origElement.id, - ) as any; - if (prop === "get") { - if (currentElement.hasOwnProperty("get")) { - throw new Error( - "trying to get `get` test property, but ExcalidrawElement seems to define its own", - ); - } - return () => currentElement; - } - return currentElement[prop]; - }, - }, - ) as any; + return proxy(origElement); + } + + static async editText< + T extends ExcalidrawTextElement | ExcalidrawTextContainer, + >(element: T, text: string) { + const openedEditor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ); + + if (!openedEditor) { + mouse.select(element); + Keyboard.keyPress(KEYS.ENTER); + } + + const editor = + openedEditor ?? + document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ); + if (!editor) { + throw new Error("Can't find wysiwyg text editor in the dom"); + } + + fireEvent.input(editor, { target: { value: text } }); + await new Promise((resolve) => setTimeout(resolve, 0)); + editor.blur(); + + return isTextElement(element) + ? element + : proxy( + h.elements[ + h.elements.length - 1 + ] as ExcalidrawTextElementWithContainer, + ); + } + + static resize( + element: ExcalidrawElement | ExcalidrawElement[], + handle: TransformHandleDirection, + mouseMove: [deltaX: number, deltaY: number], + keyboardModifiers: KeyboardModifiers = {}, + ) { + return transform(element, handle, mouseMove, keyboardModifiers); + } + + static rotate( + element: ExcalidrawElement | ExcalidrawElement[], + mouseMove: [deltaX: number, deltaY: number], + keyboardModifiers: KeyboardModifiers = {}, + ) { + return transform(element, "rotation", mouseMove, keyboardModifiers); } static group(elements: ExcalidrawElement[]) { diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index a31f6f04..920ed884 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -16,7 +16,6 @@ import { Point } from "../types"; import { KEYS } from "../keys"; import { LinearElementEditor } from "../element/linearElementEditor"; import { queryByTestId, queryByText } from "@testing-library/react"; -import { resize, rotate } from "./utils"; import { getBoundTextElementPosition, wrapText, @@ -939,71 +938,10 @@ describe("Test Linear Elements", () => { expect(line.boundElements).toBeNull(); }); - it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => { - createThreePointerLinearElement("arrow", { - type: ROUNDNESS.PROPORTIONAL_RADIUS, - }); - - const arrow = h.elements[0] as ExcalidrawLinearElement; - - const { textElement, container } = createBoundTextElement( - DEFAULT_TEXT, - arrow, - ); - - expect(container.angle).toBe(0); - expect(textElement.angle).toBe(0); - expect(getBoundTextElementPosition(arrow, 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, - ] - `); - - rotate(container, -35, 55); - expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`); - expect(textElement.angle).toBe(0); - expect(getBoundTextElementPosition(container, textElement)) - .toMatchInlineSnapshot(` - { - "x": 21.73926141863671, - "y": 73.31003398390868, - } - `); - expect(textElement.text).toMatchInlineSnapshot(` - "Online whiteboard - collaboration made - easy" - `); - expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) - .toMatchInlineSnapshot(` - [ - 20, - 20, - 102.41961302274555, - 86.49012635273976, - 55.45893770831013, - 45, - ] - `); - }); + // 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", { @@ -1042,7 +980,7 @@ describe("Test Linear Elements", () => { ] `); - resize(container, "ne", [300, 200]); + UI.resize(container, "ne", [300, 200]); expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` diff --git a/src/tests/resize.test.tsx b/src/tests/resize.test.tsx index 60ce3765..be5c2114 100644 --- a/src/tests/resize.test.tsx +++ b/src/tests/resize.test.tsx @@ -1,153 +1,1003 @@ import ReactDOM from "react-dom"; import { render } from "./test-utils"; -import App from "../components/App"; -import * as Renderer from "../renderer/renderScene"; import { reseed } from "../random"; -import { UI, Keyboard } from "./helpers/ui"; -import { resize } from "./utils"; -import { ExcalidrawTextElement } from "../element/types"; +import { UI, Keyboard, Pointer } from "./helpers/ui"; +import type { + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, +} from "../element/types"; +import type { Point } from "../types"; +import { Bounds, getElementPointsCoords } from "../element/bounds"; import { Excalidraw } from "../packages/excalidraw/index"; import { API } from "./helpers/api"; import { KEYS } from "../keys"; -import { vi } from "vitest"; +import { isLinearElement } from "../element/typeChecks"; +import { LinearElementEditor } from "../element/linearElementEditor"; -// Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); - -beforeEach(() => { - localStorage.clear(); - renderStaticScene.mockClear(); - reseed(7); -}); - const { h } = window; +const mouse = new Pointer("mouse"); -describe("resize rectangle ellipses and diamond elements", () => { - const elemData = { - x: 0, - y: 0, - width: 100, - height: 100, - }; - // Value for irrelevant cursor movements - const _ = 234; +const getBoundsFromPoints = ( + element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, +): Bounds => { + if (isLinearElement(element)) { + return getElementPointsCoords(element, element.points); + } - it.each` - handle | move | dimensions | topLeft - ${"n"} | ${[_, -100]} | ${[100, 200]} | ${[elemData.x, -100]} - ${"s"} | ${[_, 39]} | ${[100, 139]} | ${[elemData.x, elemData.x]} - ${"e"} | ${[-20, _]} | ${[80, 100]} | ${[elemData.x, elemData.y]} - ${"w"} | ${[-20, _]} | ${[120, 100]} | ${[-20, elemData.y]} - ${"ne"} | ${[5, 55]} | ${[105, 45]} | ${[elemData.x, 55]} - ${"se"} | ${[-30, -10]} | ${[70, 90]} | ${[elemData.x, elemData.y]} - ${"nw"} | ${[-300, -200]} | ${[400, 300]} | ${[-300, -200]} - ${"sw"} | ${[40, -20]} | ${[60, 80]} | ${[40, 0]} - `( - "resizes with handle $handle", - async ({ handle, move, dimensions, topLeft }) => { - await render(); - const rectangle = UI.createElement("rectangle", elemData); - resize(rectangle, handle, move); - const element = h.elements[0]; - expect([element.width, element.height]).toEqual(dimensions); - expect([element.x, element.y]).toEqual(topLeft); - }, - ); + const { x, y, points } = element; + const pointsX = points.map(([x]) => x); + const pointsY = points.map(([, y]) => y); - it.each` - handle | move | dimensions | topLeft - ${"n"} | ${[_, -100]} | ${[200, 200]} | ${[-50, -100]} - ${"nw"} | ${[-300, -200]} | ${[400, 400]} | ${[-300, -300]} - ${"sw"} | ${[40, -20]} | ${[80, 80]} | ${[20, 0]} - `( - "resizes with fixed side ratios on handle $handle", - async ({ handle, move, dimensions, topLeft }) => { - await render(); - const rectangle = UI.createElement("rectangle", elemData); - resize(rectangle, handle, move, { shift: true }); - const element = h.elements[0]; - expect([element.width, element.height]).toEqual(dimensions); - expect([element.x, element.y]).toEqual(topLeft); - }, - ); + return [ + Math.min(...pointsX) + x, + Math.min(...pointsY) + y, + Math.max(...pointsX) + x, + Math.max(...pointsY) + y, + ]; +}; - it.each` - handle | move | dimensions | topLeft - ${"nw"} | ${[0, 120]} | ${[100, 100]} | ${[0, 100]} - ${"ne"} | ${[-120, 0]} | ${[100, 100]} | ${[-100, 0]} - ${"sw"} | ${[200, -200]} | ${[100, 100]} | ${[100, -100]} - ${"n"} | ${[_, 150]} | ${[50, 50]} | ${[25, 100]} - `( - "Flips while resizing and keeping side ratios on handle $handle", - async ({ handle, move, dimensions, topLeft }) => { - await render(); - const rectangle = UI.createElement("rectangle", elemData); - resize(rectangle, handle, move, { shift: true }); - const element = h.elements[0]; - expect([element.width, element.height]).toEqual(dimensions); - expect([element.x, element.y]).toEqual(topLeft); - }, - ); +beforeEach(async () => { + localStorage.clear(); + reseed(7); + mouse.reset(); - it.each` - handle | move | dimensions | topLeft - ${"ne"} | ${[50, -100]} | ${[200, 300]} | ${[-50, -100]} - ${"s"} | ${[_, -20]} | ${[100, 60]} | ${[0, 20]} - `( - "Resizes from center on handle $handle", - async ({ handle, move, dimensions, topLeft }) => { - await render(); - const rectangle = UI.createElement("rectangle", elemData); - resize(rectangle, handle, move, { alt: true }); - const element = h.elements[0]; - expect([element.width, element.height]).toEqual(dimensions); - expect([element.x, element.y]).toEqual(topLeft); - }, - ); + await render(); + h.state.width = 1000; + h.state.height = 1000; - it.each` - handle | move | dimensions | topLeft - ${"nw"} | ${[100, 120]} | ${[140, 140]} | ${[-20, -20]} - ${"e"} | ${[-130, _]} | ${[160, 160]} | ${[-30, -30]} - `( - "Resizes from center, flips and keeps side rations on handle $handle", - async ({ handle, move, dimensions, topLeft }) => { - await render(); - const rectangle = UI.createElement("rectangle", elemData); - resize(rectangle, handle, move, { alt: true, shift: true }); - const element = h.elements[0]; - expect([element.width, element.height]).toEqual(dimensions); - expect([element.x, element.y]).toEqual(topLeft); - }, - ); + // The bounds of hand-drawn linear elements may change after flipping, so + // removing this style for testing + UI.clickTool("arrow"); + UI.clickByTitle("Architect"); + UI.clickTool("selection"); }); -describe("Test text element", () => { - it("should update font size via keyboard", async () => { - await render(); +describe("generic element", () => { + // = rectangle/diamond/ellipse - const textElement = API.createElement({ - type: "text", - text: "abc", + describe("resizes", () => { + it.each` + handle | move | size | xy + ${"n"} | ${[10, -27]} | ${[200, 127]} | ${[0, -27]} + ${"e"} | ${[67, -45]} | ${[267, 100]} | ${[0, 0]} + ${"s"} | ${[-50, -39]} | ${[200, 61]} | ${[0, 0]} + ${"w"} | ${[20, 90]} | ${[180, 100]} | ${[20, 0]} + ${"ne"} | ${[5, -33]} | ${[205, 133]} | ${[0, -33]} + ${"se"} | ${[-30, -81]} | ${[170, 19]} | ${[0, 0]} + ${"sw"} | ${[37, 25]} | ${[163, 125]} | ${[37, 0]} + ${"nw"} | ${[-34, 42]} | ${[234, 58]} | ${[-34, 42]} + `( + "with handle $handle", + async ({ handle, move, size: [width, height], xy: [x, y] }) => { + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + UI.resize(rectangle, handle, move); + + expect(rectangle.x).toBeCloseTo(x); + expect(rectangle.y).toBeCloseTo(y); + expect(rectangle.width).toBeCloseTo(width); + expect(rectangle.height).toBeCloseTo(height); + expect(rectangle.angle).toBeCloseTo(0); + }, + ); + }); + + describe("flips while resizing", () => { + it.each` + handle | move | size | xy + ${"n"} | ${[15, 139]} | ${[200, 39]} | ${[0, 100]} + ${"e"} | ${[-245, 67]} | ${[45, 100]} | ${[-45, 0]} + ${"s"} | ${[-26, -210]} | ${[200, 110]} | ${[0, -110]} + ${"w"} | ${[241, 0]} | ${[41, 100]} | ${[200, 0]} + ${"ne"} | ${[-250, 125]} | ${[50, 25]} | ${[-50, 100]} + ${"se"} | ${[-283, -58]} | ${[83, 42]} | ${[-83, 0]} + ${"sw"} | ${[40, -123]} | ${[160, 23]} | ${[40, -23]} + ${"nw"} | ${[270, 133]} | ${[70, 33]} | ${[200, 100]} + `( + "with handle $handle", + async ({ handle, move, size: [width, height], xy: [x, y] }) => { + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + UI.resize(rectangle, handle, move); + + expect(rectangle.x).toBeCloseTo(x); + expect(rectangle.y).toBeCloseTo(y); + expect(rectangle.width).toBeCloseTo(width); + expect(rectangle.height).toBeCloseTo(height); + expect(rectangle.angle).toBeCloseTo(0); + }, + ); + }); + + it("resizes with locked aspect ratio", async () => { + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + UI.resize(rectangle, "se", [100, 10], { shift: true }); + + expect(rectangle.x).toBeCloseTo(0); + expect(rectangle.y).toBeCloseTo(0); + expect(rectangle.width).toBeCloseTo(300); + expect(rectangle.height).toBeCloseTo(150); + expect(rectangle.angle).toBeCloseTo(0); + + UI.resize(rectangle, "n", [30, 50], { shift: true }); + + expect(rectangle.x).toBeCloseTo(50); + expect(rectangle.y).toBeCloseTo(50); + expect(rectangle.width).toBeCloseTo(200); + expect(rectangle.height).toBeCloseTo(100); + expect(rectangle.angle).toBeCloseTo(0); + }); + + it("resizes from center", async () => { + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + UI.resize(rectangle, "nw", [20, 10], { alt: true }); + + expect(rectangle.x).toBeCloseTo(20); + expect(rectangle.y).toBeCloseTo(10); + expect(rectangle.width).toBeCloseTo(160); + expect(rectangle.height).toBeCloseTo(80); + expect(rectangle.angle).toBeCloseTo(0); + + UI.resize(rectangle, "e", [15, 43], { alt: true }); + + expect(rectangle.x).toBeCloseTo(5); + expect(rectangle.y).toBeCloseTo(10); + expect(rectangle.width).toBeCloseTo(190); + expect(rectangle.height).toBeCloseTo(80); + expect(rectangle.angle).toBeCloseTo(0); + }); + + it("resizes with bound arrow", async () => { + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + const arrow = UI.createElement("arrow", { + x: -30, + y: 50, + width: 28, + height: 5, }); - window.h.elements = [textElement]; + expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - API.setSelectedElements([textElement]); + UI.resize(rectangle, "e", [40, 0]); - const origFontSize = textElement.fontSize; + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30); + + UI.resize(rectangle, "w", [50, 0]); + + expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80); + }); + + it("resizes with a label", async () => { + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + const label = await UI.editText(rectangle, "Hello world"); + UI.resize(rectangle, "se", [50, 50]); + + expect(label.x + label.width / 2).toBeCloseTo( + rectangle.x + rectangle.width / 2, + ); + expect(label.y + label.height / 2).toBeCloseTo( + rectangle.y + rectangle.height / 2, + ); + expect(label.angle).toBeCloseTo(rectangle.angle); + expect(label.fontSize).toEqual(20); + + UI.resize(rectangle, "w", [190, 0]); + + expect(label.x + label.width / 2).toBeCloseTo( + rectangle.x + rectangle.width / 2, + ); + expect(label.y + label.height / 2).toBeCloseTo( + rectangle.y + rectangle.height / 2, + ); + expect(label.angle).toBeCloseTo(rectangle.angle); + expect(label.fontSize).toEqual(20); + }); +}); + +describe.each(["line", "freedraw"] as const)("%s element", (type) => { + const points: Record = { + line: [ + [0, 0], + [60, -20], + [20, 40], + [-40, 0], + ], + freedraw: [ + [0, 0], + [-2.474600807561444, 41.021700699972], + [3.6627956000014024, 47.84174560617245], + [40.495224145598115, 47.15909710753482], + ], + }; + + it("resizes", async () => { + const element = UI.createElement(type, { points: points[type] }); + const bounds = getBoundsFromPoints(element); + + UI.resize(element, "ne", [30, -60]); + const newBounds = getBoundsFromPoints(element); + + expect(newBounds[0]).toBeCloseTo(bounds[0]); + expect(newBounds[1]).toBeCloseTo(bounds[1] - 60); + expect(newBounds[2]).toBeCloseTo(bounds[2] + 30); + expect(newBounds[3]).toBeCloseTo(bounds[3]); + expect(element.angle).toBeCloseTo(0); + }); + + it("flips while resizing", async () => { + const element = UI.createElement(type, { points: points[type] }); + const bounds = getBoundsFromPoints(element); + + UI.resize(element, "sw", [140, -80]); + const newBounds = getBoundsFromPoints(element); + + expect(newBounds[0]).toBeCloseTo(bounds[2]); + expect(newBounds[1]).toBeCloseTo(bounds[3] - 80); + expect(newBounds[2]).toBeCloseTo(bounds[0] + 140); + expect(newBounds[3]).toBeCloseTo(bounds[1]); + expect(element.angle).toBeCloseTo(0); + }); + + it("resizes with locked aspect ratio", async () => { + const element = UI.createElement(type, { points: points[type] }); + const bounds = getBoundsFromPoints(element); + + UI.resize(element, "ne", [30, -60], { shift: true }); + const newBounds = getBoundsFromPoints(element); + const scale = 1 + 60 / (bounds[3] - bounds[1]); + + expect(newBounds[0]).toBeCloseTo(bounds[0]); + expect(newBounds[1]).toBeCloseTo(bounds[1] - 60); + expect(newBounds[2]).toBeCloseTo( + bounds[0] + (bounds[2] - bounds[0]) * scale, + ); + expect(newBounds[3]).toBeCloseTo(bounds[3]); + expect(element.angle).toBeCloseTo(0); + }); + + it("resizes from center", async () => { + const element = UI.createElement(type, { points: points[type] }); + const bounds = getBoundsFromPoints(element); + + UI.resize(element, "nw", [-20, -30], { alt: true }); + const newBounds = getBoundsFromPoints(element); + + expect(newBounds[0]).toBeCloseTo(bounds[0] - 20); + expect(newBounds[1]).toBeCloseTo(bounds[1] - 30); + expect(newBounds[2]).toBeCloseTo(bounds[2] + 20); + expect(newBounds[3]).toBeCloseTo(bounds[3] + 30); + expect(element.angle).toBeCloseTo(0); + }); +}); + +describe("arrow element", () => { + it("resizes with a label", async () => { + const arrow = UI.createElement("arrow", { + points: [ + [0, 0], + [40, 140], + [80, 60], // label's anchor + [180, 20], + [200, 120], + ], + }); + const label = await UI.editText(arrow, "Hello"); + UI.resize(arrow, "se", [50, 30]); + let labelPos = LinearElementEditor.getBoundTextElementPosition( + arrow, + label, + ); + + expect(labelPos.x + label.width / 2).toBeCloseTo( + arrow.x + arrow.points[2][0], + ); + expect(labelPos.y + label.height / 2).toBeCloseTo( + arrow.y + arrow.points[2][1], + ); + expect(label.angle).toBeCloseTo(0); + expect(label.fontSize).toEqual(20); + + UI.resize(arrow, "w", [20, 0]); + labelPos = LinearElementEditor.getBoundTextElementPosition(arrow, label); + + expect(labelPos.x + label.width / 2).toBeCloseTo( + arrow.x + arrow.points[2][0], + ); + expect(labelPos.y + label.height / 2).toBeCloseTo( + arrow.y + arrow.points[2][1], + ); + expect(label.angle).toBeCloseTo(0); + expect(label.fontSize).toEqual(20); + }); +}); + +describe("text element", () => { + it("resizes", async () => { + const text = UI.createElement("text"); + await UI.editText(text, "hello\nworld"); + const { width, height, fontSize } = text; + const scale = 40 / height + 1; + UI.resize(text, "se", [30, 40]); + + expect(text.x).toBeCloseTo(0); + expect(text.y).toBeCloseTo(0); + expect(text.width).toBeCloseTo(width * scale); + expect(text.height).toBeCloseTo(height * scale); + expect(text.angle).toBeCloseTo(0); + expect(text.fontSize).toBeCloseTo(fontSize * scale); + }); + + // TODO enable this test after adding single text element flipping + it.skip("flips while resizing", async () => { + const text = UI.createElement("text"); + await UI.editText(text, "hello\nworld"); + const { width, height, fontSize } = text; + const scale = 100 / width - 1; + UI.resize(text, "nw", [100, 80]); + + expect(text.x).toBeCloseTo(width); + expect(text.y).toBeCloseTo(height); + expect(text.width).toBeCloseTo(width * scale); + expect(text.height).toBeCloseTo(height * scale); + expect(text.angle).toBeCloseTo(0); + expect(text.fontSize).toBeCloseTo(fontSize * scale); + }); + + // TODO enable this test after fixing text resizing from center + it.skip("resizes from center", async () => { + const text = UI.createElement("text"); + await UI.editText(text, "hello\nworld"); + const { x, y, width, height, fontSize } = text; + const scale = 80 / height + 1; + UI.resize(text, "nw", [-25, -40], { alt: true }); + + expect(text.x).toBeCloseTo(x - ((scale - 1) * width) / 2); + expect(text.y).toBeCloseTo(y - 40); + expect(text.width).toBeCloseTo(width * scale); + expect(text.height).toBeCloseTo(height * scale); + expect(text.angle).toBeCloseTo(0); + expect(text.fontSize).toBeCloseTo(fontSize * scale); + }); + + it("resizes with bound arrow", async () => { + const text = UI.createElement("text"); + await UI.editText(text, "hello\nworld"); + const boundArrow = UI.createElement("arrow", { + x: -30, + y: 25, + width: 28, + height: 5, + }); + + expect(boundArrow.endBinding?.elementId).toEqual(text.id); + + UI.resize(text, "ne", [40, 0]); + + expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); + + const textWidth = text.width; + const scale = 20 / text.height; + UI.resize(text, "nw", [50, 20]); + + expect(boundArrow.endBinding?.elementId).toEqual(text.id); + expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( + 30 + textWidth * scale, + ); + }); + + it("updates font size via keyboard", async () => { + const text = UI.createElement("text"); + await UI.editText(text, "abc"); + const { fontSize } = text; + mouse.select(text); Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => { Keyboard.keyDown(KEYS.CHEVRON_RIGHT); - expect((window.h.elements[0] as ExcalidrawTextElement).fontSize).toBe( - origFontSize * 1.1, - ); + expect(text.fontSize).toBe(fontSize * 1.1); + Keyboard.keyDown(KEYS.CHEVRON_LEFT); - expect((window.h.elements[0] as ExcalidrawTextElement).fontSize).toBe( - origFontSize, - ); + expect(text.fontSize).toBe(fontSize); }); }); }); + +describe("image element", () => { + it("resizes", async () => { + const image = API.createElement({ type: "image", width: 100, height: 100 }); + h.elements = [image]; + UI.resize(image, "ne", [-20, -30]); + + expect(image.x).toBeCloseTo(0); + expect(image.y).toBeCloseTo(-30); + expect(image.width).toBeCloseTo(130); + expect(image.height).toBeCloseTo(130); + expect(image.angle).toBeCloseTo(0); + expect(image.scale).toEqual([1, 1]); + }); + + it("flips while resizing", async () => { + const image = API.createElement({ type: "image", width: 100, height: 100 }); + h.elements = [image]; + UI.resize(image, "sw", [150, -150]); + + expect(image.x).toBeCloseTo(100); + expect(image.y).toBeCloseTo(-50); + expect(image.width).toBeCloseTo(50); + expect(image.height).toBeCloseTo(50); + expect(image.angle).toBeCloseTo(0); + expect(image.scale).toEqual([-1, -1]); + }); + + it("resizes with locked/unlocked aspect ratio", async () => { + const image = API.createElement({ type: "image", width: 100, height: 100 }); + h.elements = [image]; + UI.resize(image, "ne", [30, -20]); + + expect(image.x).toBeCloseTo(0); + expect(image.y).toBeCloseTo(-30); + expect(image.width).toBeCloseTo(130); + expect(image.height).toBeCloseTo(130); + + UI.resize(image, "ne", [-30, 50], { shift: true }); + + expect(image.x).toBeCloseTo(0); + expect(image.y).toBeCloseTo(20); + expect(image.width).toBeCloseTo(100); + expect(image.height).toBeCloseTo(80); + }); + + it("resizes from center", async () => { + const image = API.createElement({ type: "image", width: 100, height: 100 }); + h.elements = [image]; + UI.resize(image, "nw", [25, 15], { alt: true }); + + expect(image.x).toBeCloseTo(15); + expect(image.y).toBeCloseTo(15); + expect(image.width).toBeCloseTo(70); + expect(image.height).toBeCloseTo(70); + expect(image.angle).toBeCloseTo(0); + expect(image.scale).toEqual([1, 1]); + }); + + it("resizes with bound arrow", async () => { + const image = API.createElement({ + type: "image", + width: 100, + height: 100, + }); + h.elements = [image]; + const arrow = UI.createElement("arrow", { + x: -30, + y: 50, + width: 28, + height: 5, + }); + + expect(arrow.endBinding?.elementId).toEqual(image.id); + + UI.resize(image, "ne", [40, 0]); + + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30); + + const imageWidth = image.width; + const scale = 20 / image.height; + UI.resize(image, "nw", [50, 20]); + + expect(arrow.endBinding?.elementId).toEqual(image.id); + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo( + 30 + imageWidth * scale, + ); + }); +}); + +describe("multiple selection", () => { + it("resizes with generic elements", async () => { + const rectangle = UI.createElement("rectangle", { + position: 0, + width: 100, + height: 80, + }); + const rectLabel = await UI.editText(rectangle, "hello\nworld"); + const diamond = UI.createElement("diamond", { + x: 140, + y: 40, + size: 80, + }); + const ellipse = UI.createElement("ellipse", { + x: 40, + y: 100, + width: 80, + height: 60, + }); + + const selectionWidth = 220; + const selectionHeight = 160; + const move = [50, 30] as [number, number]; + const scale = Math.max( + 1 + move[0] / selectionWidth, + 1 + move[1] / selectionHeight, + ); + + UI.resize([rectangle, diamond, ellipse], "se", move); + + expect(rectangle.x).toBeCloseTo(0); + expect(rectangle.y).toBeCloseTo(0); + expect(rectangle.width).toBeCloseTo(100 * scale); + expect(rectangle.height).toBeCloseTo(80 * scale); + expect(rectangle.angle).toEqual(0); + + expect(rectLabel.type).toEqual("text"); + expect(rectLabel.containerId).toEqual(rectangle.id); + expect(rectLabel.x + rectLabel.width / 2).toBeCloseTo( + rectangle.x + rectangle.width / 2, + ); + expect(rectLabel.y + rectLabel.height / 2).toBeCloseTo( + rectangle.y + rectangle.height / 2, + ); + expect(rectLabel.angle).toEqual(0); + expect(rectLabel.fontSize).toBeCloseTo(20 * scale, -1); + + expect(diamond.x).toBeCloseTo(140 * scale); + expect(diamond.y).toBeCloseTo(40 * scale); + expect(diamond.width).toBeCloseTo(80 * scale); + expect(diamond.height).toBeCloseTo(80 * scale); + expect(diamond.angle).toEqual(0); + + expect(ellipse.x).toBeCloseTo(40 * scale); + expect(ellipse.y).toBeCloseTo(100 * scale); + expect(ellipse.width).toBeCloseTo(80 * scale); + expect(ellipse.height).toBeCloseTo(60 * scale); + expect(ellipse.angle).toEqual(0); + }); + + it("resizes with linear elements > 2 points", async () => { + UI.clickTool("line"); + UI.clickByTitle("Sharp"); + + const line = UI.createElement("line", { + x: 60, + y: 40, + points: [ + [0, 0], + [-40, 40], + [-60, 0], + [0, -40], + [40, 20], + [0, 40], + ], + }); + const freedraw = UI.createElement("freedraw", { + x: 63.56072661326618, + y: 100, + points: [ + [0, 0], + [-43.56072661326618, 18.15048126846341], + [-43.56072661326618, 29.041198460587566], + [-38.115368017204105, 42.652452795512204], + [-19.964886748740696, 66.24829266003775], + [19.056612930986716, 77.1390098521619], + ], + }); + + const selectionWidth = 100; + const selectionHeight = 177.1390098521619; + const move = [-25, -25] as [number, number]; + const scale = Math.max( + 1 + move[0] / selectionWidth, + 1 + move[1] / selectionHeight, + ); + + UI.resize([line, freedraw], "se", move); + + expect(line.x).toBeCloseTo(60 * scale); + expect(line.y).toBeCloseTo(40 * scale); + expect(line.width).toBeCloseTo(100 * scale); + expect(line.height).toBeCloseTo(80 * scale); + expect(line.angle).toEqual(0); + + expect(freedraw.x).toBeCloseTo(63.56072661326618 * scale); + expect(freedraw.y).toBeCloseTo(100 * scale); + expect(freedraw.width).toBeCloseTo(62.6173395442529 * scale); + expect(freedraw.height).toBeCloseTo(77.1390098521619 * scale); + expect(freedraw.angle).toEqual(0); + }); + + it("resizes with 2-point lines", async () => { + const horizLine = UI.createElement("line", { + position: 0, + width: 120, + height: 0, + }); + const vertLine = UI.createElement("line", { + x: 0, + y: 20, + width: 0, + height: 80, + }); + const diagLine = UI.createElement("line", { + position: 40, + size: 60, + }); + + const selectionWidth = 120; + const selectionHeight = 100; + const move = [40, 40] as [number, number]; + const scale = Math.max( + 1 - move[0] / selectionWidth, + 1 - move[1] / selectionHeight, + ); + + UI.resize([horizLine, vertLine, diagLine], "nw", move); + + expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale)); + expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale)); + expect(horizLine.width).toBeCloseTo(120 * scale); + expect(horizLine.height).toBeCloseTo(0); + expect(horizLine.angle).toEqual(0); + + expect(vertLine.x).toBeCloseTo(selectionWidth * (1 - scale)); + expect(vertLine.y).toBeCloseTo((selectionHeight - 20) * (1 - scale) + 20); + expect(vertLine.width).toBeCloseTo(0); + expect(vertLine.height).toBeCloseTo(80 * scale); + expect(vertLine.angle).toEqual(0); + + expect(diagLine.x).toBeCloseTo((selectionWidth - 40) * (1 - scale) + 40); + expect(diagLine.y).toBeCloseTo((selectionHeight - 40) * (1 - scale) + 40); + expect(diagLine.width).toBeCloseTo(60 * scale); + expect(diagLine.height).toBeCloseTo(60 * scale); + expect(diagLine.angle).toEqual(0); + }); + + it("resizes with bound arrows", async () => { + const rectangle = UI.createElement("rectangle", { + position: 0, + size: 100, + }); + const leftBoundArrow = UI.createElement("arrow", { + x: -110, + y: 50, + width: 100, + height: 0, + }); + const rightBoundArrow = UI.createElement("arrow", { + x: 210, + y: 50, + width: -100, + height: 0, + }); + + const selectionWidth = 210; + const selectionHeight = 100; + const move = [40, 40] as [number, number]; + const scale = Math.max( + 1 - move[0] / selectionWidth, + 1 - move[1] / selectionHeight, + ); + const leftArrowBinding = { ...leftBoundArrow.endBinding }; + const rightArrowBinding = { ...rightBoundArrow.endBinding }; + delete rightArrowBinding.gap; + + UI.resize([rectangle, rightBoundArrow], "nw", move); + + expect(leftBoundArrow.x).toBeCloseTo(-110); + expect(leftBoundArrow.y).toBeCloseTo(50); + expect(leftBoundArrow.width).toBeCloseTo(140, 0); + expect(leftBoundArrow.height).toBeCloseTo(7, 0); + expect(leftBoundArrow.angle).toEqual(0); + expect(leftBoundArrow.startBinding).toBeNull(); + expect(leftBoundArrow.endBinding).toMatchObject(leftArrowBinding); + + expect(rightBoundArrow.x).toBeCloseTo(210); + expect(rightBoundArrow.y).toBeCloseTo( + (selectionHeight - 50) * (1 - scale) + 50, + ); + expect(rightBoundArrow.width).toBeCloseTo(100 * scale); + expect(rightBoundArrow.height).toBeCloseTo(0); + expect(rightBoundArrow.angle).toEqual(0); + expect(rightBoundArrow.startBinding).toBeNull(); + expect(rightBoundArrow.endBinding).toMatchObject(rightArrowBinding); + }); + + it("resizes with labeled arrows", async () => { + const topArrow = UI.createElement("arrow", { + x: 0, + y: 20, + width: 220, + height: 0, + }); + const topArrowLabel = await UI.editText(topArrow.get(), "lorem ipsum"); + + UI.clickTool("text"); + UI.clickByTitle("Large"); + const bottomArrow = UI.createElement("arrow", { + x: 0, + y: 80, + width: 220, + height: 0, + }); + const bottomArrowLabel = await UI.editText( + bottomArrow.get(), + "dolor\nsit amet", + ); + + const selectionWidth = 220; + const selectionTop = 20 - topArrowLabel.height / 2; + const move = [80, 0] as [number, number]; + const scale = move[0] / selectionWidth + 1; + + UI.resize([topArrow.get(), bottomArrow.get()], "se", move); + const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition( + topArrow, + topArrowLabel, + ); + const bottomArrowLabelPos = LinearElementEditor.getBoundTextElementPosition( + bottomArrow, + bottomArrowLabel, + ); + + expect(topArrow.x).toBeCloseTo(0); + expect(topArrow.y).toBeCloseTo(selectionTop + (20 - selectionTop) * scale); + expect(topArrow.width).toBeCloseTo(300); + expect(topArrow.points).toEqual([ + [0, 0], + [300, 0], + ]); + + expect(topArrowLabelPos.x + topArrowLabel.width / 2).toBeCloseTo( + topArrow.width / 2, + ); + expect(topArrowLabelPos.y + topArrowLabel.height / 2).toBeCloseTo( + topArrow.y, + ); + expect(topArrowLabel.fontSize).toBeCloseTo(20 * scale); + + expect(bottomArrow.x).toBeCloseTo(0); + expect(bottomArrow.y).toBeCloseTo( + selectionTop + (80 - selectionTop) * scale, + ); + expect(bottomArrow.width).toBeCloseTo(300); + expect(topArrow.points).toEqual([ + [0, 0], + [300, 0], + ]); + + expect(bottomArrowLabelPos.x + bottomArrowLabel.width / 2).toBeCloseTo( + bottomArrow.width / 2, + ); + expect(bottomArrowLabelPos.y + bottomArrowLabel.height / 2).toBeCloseTo( + bottomArrow.y, + ); + expect(bottomArrowLabel.fontSize).toBeCloseTo(28 * scale); + }); + + it("resizes with text elements", async () => { + const topText = UI.createElement("text", { position: 0 }); + await UI.editText(topText, "lorem ipsum"); + + UI.clickTool("text"); + UI.clickByTitle("Large"); + const bottomText = UI.createElement("text", { position: 40 }); + await UI.editText(bottomText, "dolor\nsit amet"); + + const selectionWidth = 40 + bottomText.width; + const selectionHeight = 40 + bottomText.height; + const move = [30, -40] as [number, number]; + const scale = Math.max( + 1 + move[0] / selectionWidth, + 1 - move[1] / selectionHeight, + ); + + UI.resize([topText, bottomText], "ne", move); + + expect(topText.x).toBeCloseTo(0); + expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1)); + expect(topText.fontSize).toBeCloseTo(20 * scale); + expect(topText.angle).toEqual(0); + + expect(bottomText.x).toBeCloseTo(40 * scale); + expect(bottomText.y).toBeCloseTo(40 - (selectionHeight - 40) * (scale - 1)); + expect(bottomText.fontSize).toBeCloseTo(28 * scale); + expect(bottomText.angle).toEqual(0); + }); + + it("resizes with images", () => { + const topImage = API.createElement({ + type: "image", + x: 0, + y: 0, + width: 200, + height: 100, + }); + const bottomImage = API.createElement({ + type: "image", + x: 30, + y: 150, + width: 120, + height: 80, + }); + h.elements = [topImage, bottomImage]; + + const selectionWidth = 200; + const selectionHeight = 230; + const move = [-50, -50] as [number, number]; + const scale = Math.max( + 1 + move[0] / selectionWidth, + 1 + move[1] / selectionHeight, + ); + + UI.resize([topImage, bottomImage], "se", move); + + expect(topImage.x).toBeCloseTo(0); + expect(topImage.y).toBeCloseTo(0); + expect(topImage.width).toBeCloseTo(200 * scale); + expect(topImage.height).toBeCloseTo(100 * scale); + expect(topImage.angle).toEqual(0); + expect(topImage.scale).toEqual([1, 1]); + + expect(bottomImage.x).toBeCloseTo(30 * scale); + expect(bottomImage.y).toBeCloseTo(150 * scale); + expect(bottomImage.width).toBeCloseTo(120 * scale); + expect(bottomImage.height).toBeCloseTo(80 * scale); + expect(bottomImage.angle).toEqual(0); + expect(bottomImage.scale).toEqual([1, 1]); + }); + + it("resizes from center", () => { + const rectangle = UI.createElement("rectangle", { + x: -200, + y: -140, + width: 120, + height: 100, + }); + const ellipse = UI.createElement("ellipse", { + position: 60, + width: 140, + height: 80, + }); + + const selectionWidth = 400; + const selectionHeight = 280; + const move = [-80, -80] as [number, number]; + const scale = Math.max( + 1 + (2 * move[0]) / selectionWidth, + 1 + (2 * move[1]) / selectionHeight, + ); + + UI.resize([rectangle, ellipse], "se", move, { alt: true }); + + expect(rectangle.x).toBeCloseTo(-200 * scale); + expect(rectangle.y).toBeCloseTo(-140 * scale); + expect(rectangle.width).toBeCloseTo(120 * scale); + expect(rectangle.height).toBeCloseTo(100 * scale); + expect(rectangle.angle).toEqual(0); + + expect(ellipse.x).toBeCloseTo(60 * scale); + expect(ellipse.y).toBeCloseTo(60 * scale); + expect(ellipse.width).toBeCloseTo(140 * scale); + expect(ellipse.height).toBeCloseTo(80 * scale); + expect(ellipse.angle).toEqual(0); + }); + + it("flips while resizing", async () => { + const image = API.createElement({ + type: "image", + x: 60, + y: 100, + width: 100, + height: 100, + angle: (Math.PI * 7) / 6, + }); + h.elements = [image]; + + const line = UI.createElement("line", { + x: 60, + y: 0, + points: [ + [0, 0], + [-40, 40], + [-20, 60], + [20, 20], + [40, 40], + [-20, 100], + [-60, 60], + ], + }); + + const rectangle = UI.createElement("rectangle", { + x: 180, + y: 60, + width: 160, + height: 80, + angle: Math.PI / 6, + }); + const rectLabel = await UI.editText(rectangle, "hello\nworld"); + + const boundArrow = UI.createElement("arrow", { + x: 380, + y: 240, + width: -60, + height: -80, + }); + const arrowLabel = await UI.editText(boundArrow, "test"); + + const selectionWidth = 380; + const move = [-800, 0] as [number, number]; + const scaleX = move[0] / selectionWidth + 1; + const scaleY = -scaleX; + const lineOrigBounds = getBoundsFromPoints(line); + + UI.resize([line, image, rectangle, boundArrow], "se", move); + const lineNewBounds = getBoundsFromPoints(line); + const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition( + boundArrow, + arrowLabel, + ); + + expect(line.x).toBeCloseTo(60 * scaleX); + expect(line.y).toBeCloseTo(0); + expect(lineNewBounds[0]).toBeCloseTo( + (lineOrigBounds[2] - lineOrigBounds[0]) * scaleX, + ); + expect(lineNewBounds[1]).toBeCloseTo(0); + expect(lineNewBounds[3]).toBeCloseTo( + (lineOrigBounds[3] - lineOrigBounds[1]) * scaleY, + ); + expect(lineNewBounds[2]).toBeCloseTo(0); + expect(line.angle).toEqual(0); + + expect(image.x).toBeCloseTo((60 + 100) * scaleX); + expect(image.y).toBeCloseTo(100 * scaleY); + expect(image.width).toBeCloseTo(100 * -scaleX); + expect(image.height).toBeCloseTo(100 * scaleY); + expect(image.angle).toBeCloseTo((Math.PI * 5) / 6); + expect(image.scale).toEqual([1, 1]); + + expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX); + expect(rectangle.y).toBeCloseTo(60 * scaleY); + expect(rectangle.width).toBeCloseTo(160 * -scaleX); + expect(rectangle.height).toBeCloseTo(80 * scaleY); + expect(rectangle.angle).toEqual((Math.PI * 11) / 6); + + expect(rectLabel.x + rectLabel.width / 2).toBeCloseTo( + rectangle.x + rectangle.width / 2, + ); + expect(rectLabel.y + rectLabel.height / 2).toBeCloseTo( + rectangle.y + rectangle.height / 2, + ); + expect(rectLabel.angle).toBeCloseTo(rectangle.angle); + expect(rectLabel.fontSize).toBeCloseTo(20 * scaleY); + + expect(boundArrow.x).toBeCloseTo(380 * scaleX); + expect(boundArrow.y).toBeCloseTo(240 * scaleY); + expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX); + expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY); + + expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( + boundArrow.x + boundArrow.points[1][0] / 2, + ); + expect(arrowLabelPos.y + arrowLabel.height / 2).toBeCloseTo( + boundArrow.y + boundArrow.points[1][1] / 2, + ); + expect(arrowLabel.angle).toEqual(0); + expect(arrowLabel.fontSize).toBeCloseTo(20 * scaleY); + }); +}); diff --git a/src/tests/rotate.test.tsx b/src/tests/rotate.test.tsx new file mode 100644 index 00000000..0c598e7c --- /dev/null +++ b/src/tests/rotate.test.tsx @@ -0,0 +1,81 @@ +import ReactDOM from "react-dom"; +import { render } from "./test-utils"; +import { reseed } from "../random"; +import { UI } from "./helpers/ui"; +import { Excalidraw } from "../packages/excalidraw/index"; +import { expect } from "vitest"; + +ReactDOM.unmountComponentAtNode(document.getElementById("root")!); + +beforeEach(() => { + localStorage.clear(); + reseed(7); +}); + +test("unselected bound arrow updates when rotating its target element", async () => { + await render(); + const rectangle = UI.createElement("rectangle", { + width: 200, + height: 100, + }); + const arrow = UI.createElement("arrow", { + x: -80, + y: 50, + width: 70, + height: 0, + }); + + expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + + UI.rotate(rectangle, [60, 36], { shift: true }); + + expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + expect(arrow.x).toBeCloseTo(-80); + expect(arrow.y).toBeCloseTo(50); + expect(arrow.width).toBeCloseTo(110.7, 1); + expect(arrow.height).toBeCloseTo(0); +}); + +test("unselected bound arrows update when rotating their target elements", async () => { + await render(); + const ellipse = UI.createElement("ellipse", { + x: 0, + y: 80, + width: 300, + height: 120, + }); + const ellipseArrow = UI.createElement("arrow", { + position: 0, + width: 40, + height: 80, + }); + const text = UI.createElement("text", { + position: 220, + }); + await UI.editText(text, "test"); + const textArrow = UI.createElement("arrow", { + x: 360, + y: 300, + width: -100, + height: -40, + }); + + expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); + expect(textArrow.endBinding?.elementId).toEqual(text.id); + + UI.rotate([ellipse, text], [-82, 23], { shift: true }); + + expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); + expect(ellipseArrow.x).toEqual(0); + expect(ellipseArrow.y).toEqual(0); + expect(ellipseArrow.points[0]).toEqual([0, 0]); + expect(ellipseArrow.points[1][0]).toBeCloseTo(48.5, 1); + expect(ellipseArrow.points[1][1]).toBeCloseTo(126.5, 1); + + expect(textArrow.endBinding?.elementId).toEqual(text.id); + expect(textArrow.x).toEqual(360); + expect(textArrow.y).toEqual(300); + expect(textArrow.points[0]).toEqual([0, 0]); + expect(textArrow.points[1][0]).toBeCloseTo(-94, 1); + expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 1); +}); diff --git a/src/tests/selection.test.tsx b/src/tests/selection.test.tsx index 4f1c2701..f251126f 100644 --- a/src/tests/selection.test.tsx +++ b/src/tests/selection.test.tsx @@ -480,7 +480,7 @@ describe("tool locking & selection", () => { expect(h.state.activeTool.locked).toBe(true); for (const { value } of Object.values(SHAPES)) { - if (value !== "image" && value !== "selection") { + if (value !== "image" && value !== "selection" && value !== "eraser") { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); } diff --git a/src/tests/utils.ts b/src/tests/utils.ts deleted file mode 100644 index 2c91c3fc..00000000 --- a/src/tests/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - getTransformHandles, - TransformHandleDirection, -} from "../element/transformHandles"; -import { ExcalidrawElement } from "../element/types"; -import { Keyboard, KeyboardModifiers, Pointer } from "./helpers/ui"; - -const mouse = new Pointer("mouse"); -const { h } = window; - -export const resize = ( - element: ExcalidrawElement, - handleDir: TransformHandleDirection, - mouseMove: [number, number], - keyboardModifiers: KeyboardModifiers = {}, -) => { - mouse.select(element); - const handle = getTransformHandles(element, h.state.zoom, "mouse")[ - handleDir - ]!; - const clientX = handle[0] + handle[2] / 2; - const clientY = handle[1] + handle[3] / 2; - Keyboard.withModifierKeys(keyboardModifiers, () => { - mouse.reset(); - mouse.down(clientX, clientY); - mouse.move(mouseMove[0], mouseMove[1]); - mouse.up(); - }); -}; - -export const rotate = ( - element: ExcalidrawElement, - deltaX: number, - deltaY: number, - keyboardModifiers: KeyboardModifiers = {}, -) => { - mouse.select(element); - const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!; - const clientX = handle[0] + handle[2] / 2; - const clientY = handle[1] + handle[3] / 2; - - Keyboard.withModifierKeys(keyboardModifiers, () => { - mouse.reset(); - mouse.down(clientX, clientY); - mouse.move(clientX + deltaX, clientY + deltaY); - mouse.up(); - }); -};