mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-22 00:41:09 +02:00

Tests added Fix binding Remove unneeded params Unfinished simple arrow avoidance Fix newly created jumping arrow when gets outside Do not apply the jumping logic to elbow arrows for new elements Existing arrows now jump out Type updates to support fixed binding for simple arrows Fix crash for elbow arrws in mutateElement() Refactored simple arrow creation Updating tests No confirm threshold when inside biding range Fix multi-point arrow grid off Make elbow arrows respect grids Unbind arrow if bound and moved at shaft of arrow key Fix binding test Fix drag unbind when the bound element is in the selection Do not move mid point for simple arrows bound on both ends Add test for mobing mid points for simple arrows when bound on the same element on both ends Fix linear editor bug when both midpoint and endpoint is moved Fix all point multipoint arrow highlight and binding Arrow dragging gets a little drag to avoid accidental unbinding Fixed point binding for simple arrows when the arrow doesn't point to the element Fix binding disabled use-case triggering arrow editor Timed binding mode change for simple arrows Apply fixes Remove code to unbind on drag Update simple arrow fixed point when arrow is dragged or moved by arrow keys Binding highlight fixes Change bind mode timeout logic Fix tests Add Alt bindMode switch No dragging of arrows when bound, similar to elbow Fix timeout not taking effect immediately Bumop z-index for arrows when dragged Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Only transparent bindables allow binding fallthrough Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix lint Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix point click array creation interaction with fixed point binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Restrict new behavior to arrows only Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Allow binding inside images Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix already existing fixed binding retention Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Refactor and implement fixed point binding for unfilled elements Restore drag Removed point binding Binding code refactor Added centered focus point Binding & focus point debug Add invariants to check binding integrity in elements Binding fixes Small refactors Completely rewritten binding Include point updates after binding update Fix point updates when endpoint dragged and opposite endpoint orbits centered focus point only for new arrows Make z-index arrow reorder on bind Turn off inside binding mode after leaving a shape Remove invariants from debug feat: expose `applyTo` options, don't commit empty text element (#9744) * Expose applyTo options, skip re-draw for empty text * Don't commit empty text elements test: added test file for distribute (#9754) z-index update Bind mode on precise binding Fix binding to inside element Fix initial arrow not following cursor (white dot) Fix elbow arrow Fix z-index so it works on hover Fix fixed angle orbiting Move point click arrow creation over to common strategy Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Add binding strategy for drag arrow creation Fix elbow arrow Fix point handles Snap to center Fix transparent shape binding Internal arrow creation fix Fix point binding Fix selection bug Fix new arrow focus point Images now always bind inside Flashing arrow creation on binding band Add watchState debug method to window.h Fix debug canvas crash Remove non-needed bind mode Fix restore No keyboard movement when bound Add actionFinalize when arrow in edit mode Add drag to the Stats panel when bound arrow is moved Further simplify curve tracking Add typing to action register() Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix point at finalize Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix type errors Signed-off-by: Mark Tolmacs <mark@lazycat.hu> New arrow binding rules Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix cyclical dep Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix jiggly arrows Fix jiggly arrow x2 Long inside-other binding Click-click binding Fix arrows Performance [PERF] Replace in-place Jacobian derivation with analytical version Different approach to inside binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fixes Fix inconsistent arrow start jump out Change how images are bound to on new arrow creation Lower timeout Small insurance fix Fix curve test Signed-off-by: Mark Tolmacs <mark@lazycat.hu> No center focus point 90% inside center binding Fixing tests fix: Elbow arrow fixes fix: More arrow fixes Do not trigger arrow binding for linear elements fix: Linear elements fix: Refactor actionFinalize for linear Binding tests updated fix: Jump when cursor not moved fix: history tests Fix history snapshot Fix undo issue fix(eraser): Remove binding from the other element fix(tests): Update tests chore: Attempt filtering new set state Fix excessive history recording Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
1192 lines
32 KiB
TypeScript
1192 lines
32 KiB
TypeScript
import React from "react";
|
|
import { vi } from "vitest";
|
|
|
|
import { FONT_FAMILY, CODES, KEYS, reseed } from "@excalidraw/common";
|
|
|
|
import { setDateTimeForTests } from "@excalidraw/common";
|
|
|
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
|
|
|
import { Excalidraw } from "../index";
|
|
import * as StaticScene from "../renderer/staticScene";
|
|
|
|
import { API } from "./helpers/api";
|
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
|
import {
|
|
assertSelectedElements,
|
|
checkpointHistory,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
togglePopover,
|
|
unmountComponent,
|
|
} from "./test-utils";
|
|
|
|
const { h } = window;
|
|
|
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
|
|
|
const mouse = new Pointer("mouse");
|
|
const finger1 = new Pointer("touch", 1);
|
|
const finger2 = new Pointer("touch", 2);
|
|
|
|
/**
|
|
* This is always called at the end of your test, so usually you don't need to call it.
|
|
* However, if you have a long test, you might want to call it during the test so it's easier
|
|
* to debug where a test failure came from.
|
|
*/
|
|
const checkpoint = (name: string) => {
|
|
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
|
|
`[${name}] number of renders`,
|
|
);
|
|
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
|
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
|
h.elements.forEach((element, i) =>
|
|
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
|
);
|
|
|
|
checkpointHistory(h.history, name);
|
|
};
|
|
beforeEach(async () => {
|
|
unmountComponent();
|
|
|
|
localStorage.clear();
|
|
renderStaticScene.mockClear();
|
|
reseed(7);
|
|
setDateTimeForTests("201933152653");
|
|
|
|
mouse.reset();
|
|
finger1.reset();
|
|
finger2.reset();
|
|
|
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
API.setAppState({ height: 768, width: 1024 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
checkpoint("end of test");
|
|
});
|
|
|
|
describe("regression tests", () => {
|
|
it("draw every type of shape", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(20, 10);
|
|
|
|
UI.clickTool("diamond");
|
|
mouse.down(10, -10);
|
|
mouse.up(20, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(10, -10);
|
|
mouse.up(20, 10);
|
|
|
|
UI.clickTool("arrow");
|
|
mouse.down(40, -10);
|
|
mouse.up(50, 10);
|
|
|
|
UI.clickTool("line");
|
|
mouse.down(40, -10);
|
|
mouse.up(50, 10);
|
|
|
|
UI.clickTool("arrow");
|
|
mouse.click(40, -10);
|
|
mouse.click(50, 10);
|
|
mouse.click(30, 10);
|
|
Keyboard.keyPress(KEYS.ENTER);
|
|
|
|
UI.clickTool("line");
|
|
mouse.click(40, -20);
|
|
mouse.click(50, 10);
|
|
mouse.click(30, 10);
|
|
Keyboard.keyPress(KEYS.ENTER);
|
|
|
|
UI.clickTool("freedraw");
|
|
mouse.down(40, -20);
|
|
mouse.up(50, 10);
|
|
|
|
expect(h.elements.map((element) => element.type)).toEqual([
|
|
"rectangle",
|
|
"diamond",
|
|
"ellipse",
|
|
"arrow",
|
|
"line",
|
|
"arrow",
|
|
"line",
|
|
"freedraw",
|
|
]);
|
|
});
|
|
|
|
it("click to select a shape", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
const firstRectPos = mouse.getPosition();
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
const prevSelectedId = API.getSelectedElement().id;
|
|
mouse.restorePosition(...firstRectPos);
|
|
mouse.click();
|
|
|
|
expect(API.getSelectedElement().id).not.toEqual(prevSelectedId);
|
|
});
|
|
|
|
for (const [keys, shape, shouldSelect] of [
|
|
[`2${KEYS.R}`, "rectangle", true],
|
|
[`3${KEYS.D}`, "diamond", true],
|
|
[`4${KEYS.O}`, "ellipse", true],
|
|
[`5${KEYS.A}`, "arrow", true],
|
|
[`6${KEYS.L}`, "line", true],
|
|
[`7${KEYS.P}`, "freedraw", false],
|
|
] as [string, ExcalidrawElement["type"], boolean][]) {
|
|
for (const key of keys) {
|
|
it(`key ${key} selects ${shape} tool`, () => {
|
|
Keyboard.keyPress(key);
|
|
|
|
expect(h.state.activeTool.type).toBe(shape);
|
|
|
|
mouse.down(10, 10);
|
|
mouse.up(30, 30);
|
|
|
|
if (shouldSelect) {
|
|
expect(API.getSelectedElement().type).toBe(shape);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
it("change the properties of a shape", () => {
|
|
UI.clickTool("rectangle");
|
|
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
togglePopover("Background");
|
|
UI.clickOnTestId("color-yellow");
|
|
UI.clickOnTestId("color-red");
|
|
|
|
togglePopover("Stroke");
|
|
UI.clickOnTestId("color-blue");
|
|
expect(API.getSelectedElement().backgroundColor).toBe("#ffc9c9");
|
|
expect(API.getSelectedElement().strokeColor).toBe("#1971c2");
|
|
});
|
|
|
|
it("click on an element and drag it", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
const { x: prevX, y: prevY } = API.getSelectedElement();
|
|
mouse.down(-8, -8);
|
|
mouse.up(10, 10);
|
|
|
|
const { x: nextX, y: nextY } = API.getSelectedElement();
|
|
expect(nextX).toBeGreaterThan(prevX);
|
|
expect(nextY).toBeGreaterThan(prevY);
|
|
|
|
checkpoint("dragged");
|
|
|
|
mouse.down();
|
|
mouse.up(-10, -10);
|
|
|
|
const { x, y } = API.getSelectedElement();
|
|
expect(x).toBe(prevX);
|
|
expect(y).toBe(prevY);
|
|
});
|
|
|
|
it("alt-drag duplicates an element", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
expect(
|
|
h.elements.filter((element) => element.type === "rectangle").length,
|
|
).toBe(1);
|
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
mouse.down(-8, -8);
|
|
mouse.up(10, 10);
|
|
});
|
|
|
|
expect(
|
|
h.elements.filter((element) => element.type === "rectangle").length,
|
|
).toBe(2);
|
|
});
|
|
|
|
it("click-drag to select a group", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
const finalPosition = mouse.getPosition();
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
mouse.restorePosition(0, 0);
|
|
mouse.down();
|
|
mouse.restorePosition(...finalPosition);
|
|
mouse.up(5, 5);
|
|
|
|
expect(
|
|
h.elements.filter((element) => h.state.selectedElementIds[element.id])
|
|
.length,
|
|
).toBe(2);
|
|
});
|
|
|
|
it("shift-click to multiselect, then drag", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
const prevRectsXY = h.elements
|
|
.filter((element) => element.type === "rectangle")
|
|
.map((element) => ({ x: element.x, y: element.y }));
|
|
|
|
mouse.reset();
|
|
mouse.click(10, 10);
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(20, 0);
|
|
});
|
|
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
h.elements
|
|
.filter((element) => element.type === "rectangle")
|
|
.forEach((element, i) => {
|
|
expect(element.x).toBeGreaterThan(prevRectsXY[i].x);
|
|
expect(element.y).toBeGreaterThan(prevRectsXY[i].y);
|
|
});
|
|
});
|
|
|
|
it("pinch-to-zoom works", () => {
|
|
expect(h.state.zoom.value).toBe(1);
|
|
finger1.down(50, 50);
|
|
finger2.down(60, 50);
|
|
finger1.move(-10, 0);
|
|
expect(h.state.zoom.value).toBeGreaterThan(1);
|
|
const zoomed = h.state.zoom.value;
|
|
finger1.move(5, 0);
|
|
finger2.move(-5, 0);
|
|
expect(h.state.zoom.value).toBeLessThan(zoomed);
|
|
});
|
|
|
|
it("two-finger scroll works", () => {
|
|
// scroll horizontally vertically
|
|
|
|
const startScrollY = h.state.scrollY;
|
|
|
|
finger1.downAt(0, 0);
|
|
finger2.downAt(10, 0);
|
|
|
|
finger1.clientY -= 10;
|
|
finger2.clientY -= 10;
|
|
|
|
finger1.moveTo();
|
|
finger2.moveTo();
|
|
|
|
finger1.upAt();
|
|
finger2.upAt();
|
|
expect(h.state.scrollY).toBeLessThan(startScrollY);
|
|
|
|
// scroll horizontally
|
|
|
|
const startScrollX = h.state.scrollX;
|
|
|
|
finger1.downAt();
|
|
finger2.downAt();
|
|
|
|
finger1.clientX += 10;
|
|
finger2.clientX += 10;
|
|
|
|
finger1.moveTo();
|
|
finger2.moveTo();
|
|
|
|
finger1.upAt();
|
|
finger2.upAt();
|
|
|
|
expect(h.state.scrollX).toBeGreaterThan(startScrollX);
|
|
});
|
|
|
|
it("spacebar + drag scrolls the canvas", () => {
|
|
const { scrollX: startScrollX, scrollY: startScrollY } = h.state;
|
|
Keyboard.keyDown(KEYS.SPACE);
|
|
mouse.down(50, 50);
|
|
mouse.up(60, 60);
|
|
Keyboard.keyUp(KEYS.SPACE);
|
|
const { scrollX, scrollY } = h.state;
|
|
expect(scrollX).not.toEqual(startScrollX);
|
|
expect(scrollY).not.toEqual(startScrollY);
|
|
});
|
|
|
|
it("arrow keys", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
|
expect(h.elements[0].x).toBe(9);
|
|
expect(h.elements[0].y).toBe(9);
|
|
});
|
|
|
|
it("undo/redo drawing an element", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(20, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 0);
|
|
mouse.up(30, 20);
|
|
|
|
UI.clickTool("arrow");
|
|
mouse.click(60, -10);
|
|
mouse.click(60, 10);
|
|
mouse.click(40, 10);
|
|
Keyboard.keyPress(KEYS.ENTER);
|
|
|
|
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.Z);
|
|
Keyboard.keyPress(KEYS.Z);
|
|
});
|
|
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.Z);
|
|
});
|
|
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(1);
|
|
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
|
Keyboard.keyPress(KEYS.Z);
|
|
});
|
|
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
|
|
});
|
|
|
|
it("noop interaction after undo shouldn't create history entry", () => {
|
|
expect(API.getUndoStack().length).toBe(0);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
const firstElementEndPoint = mouse.getPosition();
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
const secondElementEndPoint = mouse.getPosition();
|
|
|
|
expect(API.getUndoStack().length).toBe(2);
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.Z);
|
|
});
|
|
|
|
expect(API.getUndoStack().length).toBe(1);
|
|
|
|
// clicking an element shouldn't add to history
|
|
mouse.restorePosition(...firstElementEndPoint);
|
|
mouse.click();
|
|
expect(API.getUndoStack().length).toBe(1);
|
|
|
|
Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.Z);
|
|
});
|
|
|
|
expect(API.getUndoStack().length).toBe(2);
|
|
|
|
// clicking an element should add to history
|
|
mouse.click();
|
|
expect(API.getUndoStack().length).toBe(3);
|
|
|
|
const firstSelectedElementId = API.getSelectedElement().id;
|
|
|
|
// same for clicking the element just redo-ed
|
|
mouse.restorePosition(...secondElementEndPoint);
|
|
mouse.click();
|
|
expect(API.getUndoStack().length).toBe(4);
|
|
|
|
expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
|
|
});
|
|
|
|
it("zoom hotkeys", () => {
|
|
expect(h.state.zoom.value).toBe(1);
|
|
fireEvent.keyDown(document, {
|
|
code: CODES.EQUAL,
|
|
ctrlKey: true,
|
|
});
|
|
fireEvent.keyUp(document, {
|
|
code: CODES.EQUAL,
|
|
ctrlKey: true,
|
|
});
|
|
expect(h.state.zoom.value).toBeGreaterThan(1);
|
|
fireEvent.keyDown(document, {
|
|
code: CODES.MINUS,
|
|
ctrlKey: true,
|
|
});
|
|
fireEvent.keyUp(document, {
|
|
code: CODES.MINUS,
|
|
ctrlKey: true,
|
|
});
|
|
expect(h.state.zoom.value).toBe(1);
|
|
});
|
|
|
|
it("make a group and duplicate it", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
const end = mouse.getPosition();
|
|
|
|
mouse.reset();
|
|
mouse.down();
|
|
mouse.restorePosition(...end);
|
|
mouse.up();
|
|
|
|
expect(h.elements.length).toBe(3);
|
|
for (const element of h.elements) {
|
|
expect(element.groupIds.length).toBe(0);
|
|
expect(h.state.selectedElementIds[element.id]).toBe(true);
|
|
}
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
for (const element of h.elements) {
|
|
expect(element.groupIds.length).toBe(1);
|
|
}
|
|
|
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
mouse.restorePosition(...end);
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
});
|
|
|
|
expect(h.elements.length).toBe(6);
|
|
const groups = new Set();
|
|
for (const element of h.elements) {
|
|
for (const groupId of element.groupIds) {
|
|
groups.add(groupId);
|
|
}
|
|
}
|
|
|
|
expect(groups.size).toBe(2);
|
|
});
|
|
|
|
it("should group elements and ungroup them", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
const end = mouse.getPosition();
|
|
|
|
mouse.reset();
|
|
mouse.down();
|
|
mouse.restorePosition(...end);
|
|
mouse.up();
|
|
|
|
for (const element of h.elements) {
|
|
expect(element.groupIds.length).toBe(0);
|
|
}
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
for (const element of h.elements) {
|
|
expect(element.groupIds.length).toBe(1);
|
|
}
|
|
|
|
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
|
|
mouse.down();
|
|
mouse.restorePosition(...end);
|
|
mouse.up();
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
for (const element of h.elements) {
|
|
expect(element.groupIds.length).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("double click to edit a group", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.A);
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
expect(h.state.editingGroupId).toBe(null);
|
|
mouse.doubleClick();
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
expect(h.state.editingGroupId).not.toBe(null);
|
|
});
|
|
|
|
it("adjusts z order when grouping", () => {
|
|
const positions: number[][] = [];
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
positions.push(mouse.getPosition());
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
positions.push(mouse.getPosition());
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, -10);
|
|
mouse.up(10, 10);
|
|
positions.push(mouse.getPosition());
|
|
|
|
const ids = h.elements.map((element) => element.id);
|
|
|
|
mouse.restorePosition(...positions[0]);
|
|
mouse.click();
|
|
mouse.restorePosition(...positions[2]);
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click();
|
|
});
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
ids[1],
|
|
ids[0],
|
|
ids[2],
|
|
]);
|
|
});
|
|
|
|
it("supports nested groups", () => {
|
|
const rectA = UI.createElement("rectangle", { position: 0, size: 50 });
|
|
const rectB = UI.createElement("rectangle", { position: 100, size: 50 });
|
|
const rectC = UI.createElement("rectangle", { position: 200, size: 50 });
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.A);
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
mouse.doubleClickOn(rectC);
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.clickOn(rectA);
|
|
});
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
expect(rectC.groupIds.length).toBe(2);
|
|
expect(rectA.groupIds).toEqual(rectC.groupIds);
|
|
expect(rectB.groupIds).toEqual(rectA.groupIds.slice(1));
|
|
|
|
mouse.click(0, 100);
|
|
expect(API.getSelectedElements().length).toBe(0);
|
|
|
|
mouse.clickOn(rectA);
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
expect(h.state.editingGroupId).toBe(null);
|
|
|
|
mouse.doubleClickOn(rectA);
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
expect(h.state.editingGroupId).toBe(rectA.groupIds[1]);
|
|
|
|
mouse.doubleClickOn(rectA);
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
expect(h.state.editingGroupId).toBe(rectA.groupIds[0]);
|
|
|
|
// click outside current (sub)group
|
|
mouse.clickOn(rectB);
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
mouse.doubleClickOn(rectB);
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
});
|
|
|
|
it("updates fontSize & fontFamily appState", () => {
|
|
UI.clickTool("text");
|
|
expect(h.state.currentItemFontFamily).toEqual(FONT_FAMILY.Excalifont);
|
|
fireEvent.click(screen.getByTitle(/code/i));
|
|
expect(h.state.currentItemFontFamily).toEqual(FONT_FAMILY["Comic Shanns"]);
|
|
});
|
|
|
|
it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {
|
|
UI.clickTool("ellipse");
|
|
mouse.down();
|
|
mouse.up(100, 100);
|
|
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
|
|
// hits bounding box without hitting element
|
|
mouse.down(98, 98);
|
|
mouse.up();
|
|
expect(API.getSelectedElements().length).toBe(0);
|
|
});
|
|
|
|
it("switches selected element on pointer down", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(10, 10);
|
|
mouse.up(10, 10);
|
|
|
|
expect(API.getSelectedElement().type).toBe("ellipse");
|
|
|
|
// pointer down on rectangle
|
|
mouse.reset();
|
|
mouse.down();
|
|
|
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
|
});
|
|
|
|
it("can drag element that covers another element, while another elem is selected", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down(100, 100);
|
|
mouse.up(200, 200);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.reset();
|
|
mouse.down(100, 100);
|
|
mouse.up(200, 200);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.reset();
|
|
mouse.down(300, 300);
|
|
mouse.up(350, 350);
|
|
|
|
expect(API.getSelectedElement().type).toBe("ellipse");
|
|
|
|
// pointer down on rectangle
|
|
mouse.reset();
|
|
mouse.down(100, 100);
|
|
mouse.up(200, 200);
|
|
|
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
|
});
|
|
|
|
it("deselects selected element on pointer down when pointer doesn't hit any element", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
|
|
// pointer down on space without elements
|
|
mouse.down(100, 100);
|
|
|
|
expect(API.getSelectedElements().length).toBe(0);
|
|
});
|
|
|
|
it("Drags selected element when hitting only bounding box and keeps element selected", () => {
|
|
UI.clickTool("ellipse");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
const { x: prevX, y: prevY } = API.getSelectedElement();
|
|
API.clearSelection();
|
|
// drag element from point on bounding box that doesn't hit element
|
|
mouse.reset();
|
|
mouse.down(8, 8);
|
|
mouse.up(25, 25);
|
|
|
|
expect(API.getSelectedElement().x).toEqual(prevX + 25);
|
|
expect(API.getSelectedElement().y).toEqual(prevY + 25);
|
|
});
|
|
|
|
it(
|
|
"given selected element A with lower z-index than unselected element B and given B is partially over A " +
|
|
"when clicking intersection between A and B " +
|
|
"B should be selected on pointer up",
|
|
() => {
|
|
// set background color since default is transparent
|
|
// and transparent elements can't be selected by clicking inside of them
|
|
const rect1 = API.createElement({
|
|
type: "rectangle",
|
|
backgroundColor: "red",
|
|
x: 0,
|
|
y: 0,
|
|
width: 1000,
|
|
height: 1000,
|
|
});
|
|
const rect2 = API.createElement({
|
|
type: "rectangle",
|
|
backgroundColor: "red",
|
|
x: 500,
|
|
y: 500,
|
|
width: 500,
|
|
height: 500,
|
|
});
|
|
API.setElements([rect1, rect2]);
|
|
|
|
mouse.select(rect1);
|
|
|
|
// pointerdown on rect2 covering rect1 while rect1 is selected should
|
|
// retain rect1 selection
|
|
mouse.down(900, 900);
|
|
expect(API.getSelectedElement().id).toBe(rect1.id);
|
|
|
|
// pointerup should select rect2
|
|
mouse.up();
|
|
expect(API.getSelectedElement().id).toBe(rect2.id);
|
|
},
|
|
);
|
|
|
|
it(
|
|
"given selected element A with lower z-index than unselected element B and given B is partially over A " +
|
|
"when dragging on intersection between A and B " +
|
|
"A should be dragged and keep being selected",
|
|
() => {
|
|
const rect1 = API.createElement({
|
|
type: "rectangle",
|
|
backgroundColor: "red",
|
|
x: 0,
|
|
y: 0,
|
|
width: 1000,
|
|
height: 1000,
|
|
});
|
|
const rect2 = API.createElement({
|
|
type: "rectangle",
|
|
backgroundColor: "red",
|
|
x: 500,
|
|
y: 500,
|
|
width: 500,
|
|
height: 500,
|
|
});
|
|
API.setElements([rect1, rect2]);
|
|
|
|
mouse.select(rect1);
|
|
|
|
expect(API.getSelectedElement().id).toBe(rect1.id);
|
|
|
|
const { x: prevX, y: prevY } = API.getSelectedElement();
|
|
|
|
// pointer down on intersection between ellipse and rectangle
|
|
mouse.down(900, 900);
|
|
mouse.up(100, 100);
|
|
|
|
expect(API.getSelectedElement().id).toBe(rect1.id);
|
|
expect(API.getSelectedElement().x).toEqual(prevX + 100);
|
|
expect(API.getSelectedElement().y).toEqual(prevY + 100);
|
|
},
|
|
);
|
|
|
|
it("deselects group of selected elements on pointer down when pointer doesn't hit any element", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(100, 100);
|
|
mouse.up(10, 10);
|
|
|
|
// Selects first element without deselecting the second element
|
|
// Second element is already selected because creating it was our last action
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(5, 5);
|
|
});
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
|
|
// pointer down on space without elements
|
|
mouse.reset();
|
|
mouse.down(500, 500);
|
|
|
|
expect(API.getSelectedElements().length).toBe(0);
|
|
});
|
|
|
|
it("switches from group of selected elements to another element on pointer down", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(100, 100);
|
|
mouse.up(100, 100);
|
|
|
|
UI.clickTool("diamond");
|
|
mouse.down(100, 100);
|
|
mouse.up(100, 100);
|
|
|
|
// Selects ellipse without deselecting the diamond
|
|
// Diamond is already selected because creating it was our last action
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(110, 160);
|
|
});
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
|
|
// select rectangle
|
|
mouse.reset();
|
|
mouse.down();
|
|
|
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
|
});
|
|
|
|
it("deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(100, 100);
|
|
mouse.up(10, 10);
|
|
|
|
// Selects first element without deselecting the second element
|
|
// Second element is already selected because creating it was our last action
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(5, 5);
|
|
});
|
|
|
|
// pointer down on common bounding box without hitting any of the elements
|
|
mouse.reset();
|
|
mouse.down(50, 50);
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
|
|
mouse.up();
|
|
expect(API.getSelectedElements().length).toBe(0);
|
|
});
|
|
|
|
it("drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(100, 100);
|
|
mouse.up(10, 10);
|
|
|
|
// Selects first element without deselecting the second element
|
|
// Second element is already selected because creating it was our last action
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(5, 5);
|
|
});
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
|
|
const { x: firstElementPrevX, y: firstElementPrevY } =
|
|
API.getSelectedElements()[0];
|
|
const { x: secondElementPrevX, y: secondElementPrevY } =
|
|
API.getSelectedElements()[1];
|
|
|
|
// drag elements from point on common bounding box that doesn't hit any of the elements
|
|
mouse.reset();
|
|
mouse.down(50, 50);
|
|
mouse.up(25, 25);
|
|
|
|
expect(API.getSelectedElements()[0].x).toEqual(firstElementPrevX + 25);
|
|
expect(API.getSelectedElements()[0].y).toEqual(firstElementPrevY + 25);
|
|
|
|
expect(API.getSelectedElements()[1].x).toEqual(secondElementPrevX + 25);
|
|
expect(API.getSelectedElements()[1].y).toEqual(secondElementPrevY + 25);
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
});
|
|
|
|
it(
|
|
"given a group of selected elements with an element that is not selected inside the group common bounding box " +
|
|
"when element that is not selected is clicked " +
|
|
"should switch selection to not selected element on pointer up",
|
|
() => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
UI.clickTool("ellipse");
|
|
mouse.down(100, 100);
|
|
mouse.up(100, 100);
|
|
|
|
UI.clickTool("diamond");
|
|
mouse.down(100, 100);
|
|
mouse.up(100, 100);
|
|
|
|
// Selects rectangle without deselecting the diamond
|
|
// Diamond is already selected because creating it was our last action
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click();
|
|
});
|
|
|
|
// pointer down on ellipse
|
|
mouse.down(110, 160);
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
|
|
mouse.up();
|
|
expect(API.getSelectedElement().type).toBe("ellipse");
|
|
},
|
|
);
|
|
|
|
it(
|
|
"given a selected element A and a not selected element B with higher z-index than A " +
|
|
"and given B partially overlaps A " +
|
|
"when there's a shift-click on the overlapped section B is added to the selection",
|
|
() => {
|
|
UI.clickTool("rectangle");
|
|
// change background color since default is transparent
|
|
// and transparent elements can't be selected by clicking inside of them
|
|
togglePopover("Background");
|
|
UI.clickOnTestId("color-red");
|
|
mouse.down();
|
|
mouse.up(1000, 1000);
|
|
|
|
// draw ellipse partially over rectangle.
|
|
// since ellipse was created after rectangle it has an higher z-index.
|
|
// we don't need to change background color again since change above
|
|
// affects next drawn elements.
|
|
UI.clickTool("ellipse");
|
|
mouse.reset();
|
|
mouse.down(500, 500);
|
|
mouse.up(1000, 1000);
|
|
|
|
// select rectangle
|
|
mouse.reset();
|
|
mouse.click();
|
|
|
|
// click on intersection between ellipse and rectangle
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(900, 900);
|
|
});
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
},
|
|
);
|
|
|
|
it("shift click on selected element should deselect it on pointer up", () => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(10, 10);
|
|
|
|
// Rectangle is already selected since creating
|
|
// it was our last action
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.down(-8, -8);
|
|
});
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.up();
|
|
});
|
|
expect(API.getSelectedElements().length).toBe(0);
|
|
});
|
|
|
|
it("single-clicking on a subgroup of a selected group should not alter selection", () => {
|
|
const rect1 = UI.createElement("rectangle", {
|
|
x: 10,
|
|
});
|
|
const rect2 = UI.createElement("rectangle", {
|
|
x: 50,
|
|
});
|
|
UI.group([rect1, rect2]);
|
|
|
|
const rect3 = UI.createElement("rectangle", {
|
|
x: 10,
|
|
y: 50,
|
|
});
|
|
const rect4 = UI.createElement("rectangle", {
|
|
x: 50,
|
|
y: 50,
|
|
});
|
|
UI.group([rect3, rect4]);
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.A);
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
const selectedGroupIds_prev = h.state.selectedGroupIds;
|
|
const selectedElements_prev = API.getSelectedElements();
|
|
mouse.clickOn(rect3);
|
|
expect(h.state.selectedGroupIds).toEqual(selectedGroupIds_prev);
|
|
expect(API.getSelectedElements()).toEqual(selectedElements_prev);
|
|
});
|
|
|
|
it("deleting last but one element in editing group should unselect the group", () => {
|
|
const rect1 = UI.createElement("rectangle", { x: 10 });
|
|
const rect2 = UI.createElement("rectangle", { x: 50 });
|
|
|
|
UI.group([rect1, rect2]);
|
|
|
|
mouse.doubleClickOn(rect1);
|
|
Keyboard.keyDown(KEYS.DELETE);
|
|
|
|
// Clicking on the deleted element, hence in the empty space
|
|
mouse.clickOn(rect1);
|
|
|
|
expect(h.state.selectedGroupIds).toEqual({});
|
|
expect(API.getSelectedElements()).toEqual([]);
|
|
|
|
// Clicking back in and expecting no group selection
|
|
mouse.clickOn(rect2);
|
|
|
|
expect(h.state.selectedGroupIds).toEqual({ [rect2.groupIds[0]]: false });
|
|
expect(API.getSelectedElements()).toEqual([rect2.get()]);
|
|
});
|
|
|
|
it("Cmd/Ctrl-click exclusively select element under pointer", () => {
|
|
const rect1 = UI.createElement("rectangle", { x: 0 });
|
|
const rect2 = UI.createElement("rectangle", { x: 30 });
|
|
|
|
UI.group([rect1, rect2]);
|
|
assertSelectedElements(rect1, rect2);
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
mouse.clickOn(rect1);
|
|
});
|
|
assertSelectedElements(rect1);
|
|
|
|
API.clearSelection();
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
mouse.clickOn(rect1);
|
|
});
|
|
assertSelectedElements(rect1);
|
|
|
|
const rect3 = UI.createElement("rectangle", { x: 60 });
|
|
UI.group([rect1, rect3]);
|
|
assertSelectedElements(rect1, rect2, rect3);
|
|
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
mouse.click(10, 5);
|
|
});
|
|
assertSelectedElements(rect1);
|
|
|
|
API.clearSelection();
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
mouse.clickOn(rect3);
|
|
});
|
|
assertSelectedElements(rect3);
|
|
});
|
|
});
|
|
|
|
it(
|
|
"given element A and group of elements B and given both are selected " +
|
|
"when user clicks on B, on pointer up " +
|
|
"only elements from B should be selected",
|
|
() => {
|
|
const rect1 = UI.createElement("rectangle", { y: 0 });
|
|
const rect2 = UI.createElement("rectangle", { y: 30 });
|
|
const rect3 = UI.createElement("rectangle", { y: 60 });
|
|
|
|
UI.group([rect1, rect3]);
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
expect(Object.keys(h.state.selectedGroupIds).length).toBe(1);
|
|
|
|
// Select second rectangle without deselecting group
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.clickOn(rect2);
|
|
});
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
|
|
// clicking on first rectangle that is part of the group should select
|
|
// that group (exclusively)
|
|
mouse.clickOn(rect1);
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
expect(Object.keys(h.state.selectedGroupIds).length).toBe(1);
|
|
},
|
|
);
|
|
|
|
it(
|
|
"given element A and group of elements B and given both are selected " +
|
|
"when user shift-clicks on B, on pointer up " +
|
|
"only element A should be selected",
|
|
() => {
|
|
UI.clickTool("rectangle");
|
|
mouse.down();
|
|
mouse.up(100, 100);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(100, 100);
|
|
|
|
UI.clickTool("rectangle");
|
|
mouse.down(10, 10);
|
|
mouse.up(100, 100);
|
|
|
|
// Select first rectangle while keeping third one selected.
|
|
// Third rectangle is selected because it was the last element to be created.
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click();
|
|
});
|
|
|
|
// Create group with first and third rectangle
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
expect(API.getSelectedElements().length).toBe(2);
|
|
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
|
|
expect(selectedGroupIds.length).toBe(1);
|
|
|
|
// Select second rectangle without deselecting group
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(110, 110);
|
|
});
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
|
|
// Pointer down o first rectangle that is part of the group
|
|
mouse.reset();
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.down();
|
|
});
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.up();
|
|
});
|
|
expect(API.getSelectedElements().length).toBe(1);
|
|
},
|
|
);
|
|
|
|
//
|
|
// DEPRECATED: DO NOT ADD TESTS HERE
|
|
//
|