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();
- });
-};