build: decouple package deps and introduce yarn workspaces (#7415)
* feat: decouple package deps and introduce yarn workspaces * update root directory * fix * fix scripts * fix lint * update path in scripts * remove yarn.lock files from packages * ignore workspace * dummy * dummy * remove comment check * revert workflow changes * ignore ws when installing gh actions * remove log * update path * fix * fix typo
46
packages/excalidraw/tests/App.test.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { reseed } from "../random";
|
||||
import { render, queryByTestId } from "../tests/test-utils";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
it("should show error modal when using brave and measureText API is not working", async () => {
|
||||
(global.navigator as any).brave = {
|
||||
isBrave: {
|
||||
name: "isBrave",
|
||||
},
|
||||
};
|
||||
|
||||
const originalContext = global.HTMLCanvasElement.prototype.getContext("2d");
|
||||
//@ts-ignore
|
||||
global.HTMLCanvasElement.prototype.getContext = (contextId) => {
|
||||
return {
|
||||
...originalContext,
|
||||
measureText: () => ({
|
||||
width: 0,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
await render(<Excalidraw />);
|
||||
expect(
|
||||
queryByTestId(
|
||||
document.querySelector(".excalidraw-modal-container")!,
|
||||
"brave-measure-text-error",
|
||||
),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
161
packages/excalidraw/tests/MermaidToExcalidraw.test.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
import { act, fireEvent, render, waitFor } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import React from "react";
|
||||
import { expect, vi } from "vitest";
|
||||
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
|
||||
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
|
||||
const module = (await importActual()) as any;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...module,
|
||||
};
|
||||
});
|
||||
const parseMermaidToExcalidrawSpy = vi.spyOn(
|
||||
MermaidToExcalidraw,
|
||||
"parseMermaidToExcalidraw",
|
||||
);
|
||||
|
||||
parseMermaidToExcalidrawSpy.mockImplementation(
|
||||
async (
|
||||
definition: string,
|
||||
options?: MermaidToExcalidraw.MermaidOptions | undefined,
|
||||
) => {
|
||||
const firstLine = definition.split("\n")[0];
|
||||
return new Promise((resolve, reject) => {
|
||||
if (firstLine === "flowchart TD") {
|
||||
resolve({
|
||||
elements: [
|
||||
{
|
||||
id: "Start",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 69.703125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "Start",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Stop",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 2.7109375,
|
||||
y: 94,
|
||||
width: 64.28125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "Stop",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Start_Stop",
|
||||
type: "arrow",
|
||||
groupIds: [],
|
||||
x: 34.852,
|
||||
y: 44,
|
||||
strokeWidth: 2,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, 50],
|
||||
],
|
||||
roundness: {
|
||||
type: 2,
|
||||
},
|
||||
start: {
|
||||
id: "Start",
|
||||
},
|
||||
end: {
|
||||
id: "Stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
reject(new Error("ERROR"));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(React, "useRef").mockReturnValue({
|
||||
current: {
|
||||
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
|
||||
},
|
||||
});
|
||||
|
||||
describe("Test <MermaidToExcalidraw/>", () => {
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
openDialog: { name: "ttd", tab: "mermaid" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
await waitFor(() => dialog.querySelector("canvas"));
|
||||
expect(dialog.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should close the popup and set the tool to selection when close button clicked", () => {
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
const closeBtn = dialog.querySelector(".Dialog__close")!;
|
||||
fireEvent.click(closeBtn);
|
||||
expect(document.querySelector(".ttd-dialog")).toBe(null);
|
||||
expect(window.h.state.activeTool).toStrictEqual({
|
||||
customType: null,
|
||||
lastActiveTool: null,
|
||||
locked: false,
|
||||
type: "selection",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error in preview when mermaid library throws error", async () => {
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
|
||||
expect(dialog).not.toBeNull();
|
||||
|
||||
const selector = ".ttd-dialog-input";
|
||||
let editor = await getTextEditor(selector, true);
|
||||
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
|
||||
|
||||
expect(editor.textContent).toMatchInlineSnapshot(`
|
||||
"flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]"
|
||||
`);
|
||||
|
||||
await act(async () => {
|
||||
updateTextEditor(editor, "flowchart TD1");
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
});
|
||||
editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(editor.textContent).toBe("flowchart TD1");
|
||||
expect(
|
||||
dialog.querySelector('[data-testid="mermaid-error"]'),
|
||||
).toMatchInlineSnapshot("null");
|
||||
});
|
||||
});
|
50
packages/excalidraw/tests/__snapshots__/App.test.tsx.snap
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
|
||||
<div
|
||||
data-testid="brave-measure-text-error"
|
||||
>
|
||||
<p>
|
||||
Looks like you are using Brave browser with the
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Aggressively Block Fingerprinting
|
||||
</span>
|
||||
setting enabled.
|
||||
</p>
|
||||
<p>
|
||||
This could result in breaking the
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Text Elements
|
||||
</span>
|
||||
in your drawings.
|
||||
</p>
|
||||
<p>
|
||||
We strongly recommend disabling this setting. You can follow
|
||||
<a
|
||||
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
||||
>
|
||||
these steps
|
||||
</a>
|
||||
on how to do so.
|
||||
</p>
|
||||
<p>
|
||||
If disabling this setting doesn't fix the display of text elements, please open an
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw/issues/new"
|
||||
>
|
||||
issue
|
||||
</a>
|
||||
on our GitHub, or write us on
|
||||
<a
|
||||
href="https://discord.gg/UexuTaE"
|
||||
>
|
||||
Discord
|
||||
.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,10 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
|
||||
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><button class="Dialog__close" title="Close" aria-label="Close"><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g clip-path="url(#a)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 5 15M5 5l10 10"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h20v20H0z"></path></clipPath></defs></svg></button><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style="animation-duration: 0s;"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
|
||||
`;
|
257
packages/excalidraw/tests/__snapshots__/MobileMenu.test.tsx.snap
Normal file
20
packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`tryParseSpreadsheet > works for numbers with comma in them 1`] = `
|
||||
{
|
||||
"spreadsheet": {
|
||||
"labels": [
|
||||
"Week 1",
|
||||
"Week 2",
|
||||
"Week 3",
|
||||
],
|
||||
"title": "Users",
|
||||
"values": [
|
||||
814,
|
||||
10301,
|
||||
4264,
|
||||
],
|
||||
},
|
||||
"type": "VALID_SPREADSHEET",
|
||||
}
|
||||
`;
|
7021
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
Normal file
199
packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap
Normal file
|
@ -0,0 +1,199 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 1`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
30,
|
||||
50,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 1`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 1`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > line 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
30,
|
||||
50,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 1`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
616
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
Normal file
|
@ -0,0 +1,616 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Click me
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="blog.excalidaw.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Excalidraw blog
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="dropdown-menu-item-base dropdown-menu-item-custom"
|
||||
>
|
||||
<button
|
||||
style="height: 2rem;"
|
||||
>
|
||||
custom menu item
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
aria-label="Open"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="load-button"
|
||||
title="Open"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Save to..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="json-export-button"
|
||||
title="Save to..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Save to...
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Export image..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="image-export-button"
|
||||
title="Export image..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M15 8h.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
|
||||
/>
|
||||
<path
|
||||
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
|
||||
/>
|
||||
<path
|
||||
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
|
||||
/>
|
||||
<path
|
||||
d="M19 16v6"
|
||||
/>
|
||||
<path
|
||||
d="M22 19l-3 3l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Reset the canvas"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="clear-canvas-button"
|
||||
title="Reset the canvas"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Reset the canvas
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-group "
|
||||
>
|
||||
<p
|
||||
class="dropdown-menu-group-title"
|
||||
>
|
||||
Excalidraw links
|
||||
</p>
|
||||
<a
|
||||
aria-label="GitHub"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="GitHub"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M7.5 15.833c-3.583 1.167-3.583-2.083-5-2.5m10 4.167v-2.917c0-.833.083-1.166-.417-1.666 2.334-.25 4.584-1.167 4.584-5a3.833 3.833 0 0 0-1.084-2.667 3.5 3.5 0 0 0-.083-2.667s-.917-.25-2.917 1.084a10.25 10.25 0 0 0-5.166 0C5.417 2.333 4.5 2.583 4.5 2.583a3.5 3.5 0 0 0-.083 2.667 3.833 3.833 0 0 0-1.084 2.667c0 3.833 2.25 4.75 4.584 5-.5.5-.5 1-.417 1.666V17.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
GitHub
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Discord"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://discord.gg/UexuTaE"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Discord"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M7.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM12.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM6.25 6.25c2.917-.833 4.583-.833 7.5 0M5.833 13.75c2.917.833 5.417.833 8.334 0"
|
||||
/>
|
||||
<path
|
||||
d="M12.917 14.167c0 .833 1.25 2.5 1.666 2.5 1.25 0 2.361-1.39 2.917-2.5.556-1.39.417-4.861-1.25-9.584-1.214-.846-2.5-1.116-3.75-1.25l-.833 2.084M7.083 14.167c0 .833-1.13 2.5-1.526 2.5-1.191 0-2.249-1.39-2.778-2.5-.529-1.39-.397-4.861 1.19-9.584 1.157-.846 2.318-1.116 3.531-1.25l.833 2.084"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Discord
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Twitter"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://twitter.com/excalidraw"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Twitter"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Twitter
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="toggle-dark-mode"
|
||||
title="Dark mode"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Shift+Alt+D
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="margin-top: 0.5rem;"
|
||||
>
|
||||
<div
|
||||
data-testid="canvas-background-label"
|
||||
style="font-size: .75rem; margin-bottom: .5rem;"
|
||||
>
|
||||
Canvas background
|
||||
</div>
|
||||
<div
|
||||
style="padding: 0px 0.625rem;"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-modal="true"
|
||||
class="color-picker-container"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="color-picker__top-picks"
|
||||
>
|
||||
<button
|
||||
class="color-picker__button active"
|
||||
data-testid="color-top-pick-#ffffff"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="#ffffff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#f8f9fa"
|
||||
style="--swatch-color: #f8f9fa;"
|
||||
title="#f8f9fa"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#f5faff"
|
||||
style="--swatch-color: #f5faff;"
|
||||
title="#f5faff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#fffce8"
|
||||
style="--swatch-color: #fffce8;"
|
||||
title="#fffce8"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#fdf8f6"
|
||||
style="--swatch-color: #fdf8f6;"
|
||||
title="#fdf8f6"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="width: 1px; height: 100%; margin: 0px auto;"
|
||||
/>
|
||||
<button
|
||||
aria-controls="radix-:r0:"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
class="color-picker__button active-color"
|
||||
data-state="closed"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="Show background color picker"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
25
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test Linear Elements > Test bound text element > should match styles for text editor 1`] = `
|
||||
<textarea
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
`;
|
226
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
Normal file
|
@ -0,0 +1,226 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0_copy",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1014066025,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 238820263,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`duplicate element on move when ALT is clicked > rectangle 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1604849351,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangle 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 30,
|
||||
"x": 0,
|
||||
"y": 40,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangles with binding arrow 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id2",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 81784553,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangles with binding arrow 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id2",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 300,
|
||||
"id": "id1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 2019559783,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"versionNonce": 927333447,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangles with binding arrow 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": null,
|
||||
"endBinding": {
|
||||
"elementId": "id1",
|
||||
"focus": -0.46666666666666673,
|
||||
"gap": 10,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 81.48231043525051,
|
||||
"id": "id2",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
81,
|
||||
81.48231043525051,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 238820263,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
"focus": -0.6000000000000001,
|
||||
"gap": 10,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1051383431,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 49.981789081137734,
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,109 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`multi point mode in linear elements > arrow 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 110,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": [
|
||||
70,
|
||||
110,
|
||||
],
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
20,
|
||||
30,
|
||||
],
|
||||
[
|
||||
70,
|
||||
110,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`multi point mode in linear elements > line 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 110,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": [
|
||||
70,
|
||||
110,
|
||||
],
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
20,
|
||||
30,
|
||||
],
|
||||
[
|
||||
70,
|
||||
110,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
}
|
||||
`;
|
17578
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
Normal file
191
packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap
Normal file
|
@ -0,0 +1,191 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`select single element on the scene > arrow 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
30,
|
||||
50,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > arrow escape 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
30,
|
||||
50,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > diamond 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > ellipse 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > rectangle 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
83
packages/excalidraw/tests/actionStyles.test.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { Excalidraw } from "../index";
|
||||
import { CODES } from "../keys";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
togglePopover,
|
||||
} from "../tests/test-utils";
|
||||
import { copiedStyles } from "../actions/actionStyles";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("actionStyles", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
|
||||
// affects node v16+
|
||||
await act(async () => {});
|
||||
});
|
||||
|
||||
it("should copy & paste styles via keyboard", async () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
// Change some styles of second rectangle
|
||||
togglePopover("Stroke");
|
||||
UI.clickOnTestId("color-red");
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-blue");
|
||||
// Fill style
|
||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||
// Stroke width
|
||||
fireEvent.click(screen.getByTitle("Bold"));
|
||||
// Stroke style
|
||||
fireEvent.click(screen.getByTitle("Dotted"));
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
mouse.reset();
|
||||
|
||||
API.setSelectedElements([h.elements[1]]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.C);
|
||||
});
|
||||
const secondRect = JSON.parse(copiedStyles)[0];
|
||||
expect(secondRect.id).toBe(h.elements[1].id);
|
||||
|
||||
mouse.reset();
|
||||
// Paste styles to first rectangle
|
||||
API.setSelectedElements([h.elements[0]]);
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.V);
|
||||
});
|
||||
|
||||
const firstRect = API.getSelectedElement();
|
||||
expect(firstRect.id).toBe(h.elements[0].id);
|
||||
expect(firstRect.strokeColor).toBe("#e03131");
|
||||
expect(firstRect.backgroundColor).toBe("#a5d8ff");
|
||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||
expect(firstRect.strokeStyle).toBe("dotted");
|
||||
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
||||
expect(firstRect.opacity).toBe(60);
|
||||
});
|
||||
});
|
580
packages/excalidraw/tests/align.test.tsx
Normal file
|
@ -0,0 +1,580 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import { render } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { defaultLang, setLanguage } from "../i18n";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import {
|
||||
actionAlignVerticallyCentered,
|
||||
actionAlignHorizontallyCentered,
|
||||
actionGroup,
|
||||
actionAlignTop,
|
||||
actionAlignBottom,
|
||||
actionAlignLeft,
|
||||
actionAlignRight,
|
||||
} from "../actions";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const createAndSelectTwoRectangles = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(110, 110);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
describe("aligning", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
mouse.reset();
|
||||
|
||||
await setLanguage(defaultLang);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("aligns two objects correctly to the top", () => {
|
||||
createAndSelectTwoRectangles();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
|
||||
// Check if x position did not change
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
|
||||
it("aligns two objects correctly to the bottom", () => {
|
||||
createAndSelectTwoRectangles();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
|
||||
// Check if x position did not change
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(110);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
});
|
||||
|
||||
it("aligns two objects correctly to the left", () => {
|
||||
createAndSelectTwoRectangles();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
|
||||
// Check if y position did not change
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
});
|
||||
|
||||
it("aligns two objects correctly to the right", () => {
|
||||
createAndSelectTwoRectangles();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(110);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
// Check if y position did not change
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
});
|
||||
|
||||
it("centers two objects with different sizes correctly vertically", () => {
|
||||
createAndSelectTwoRectanglesWithDifferentSizes();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
// Check if x position did not change
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(60);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(55);
|
||||
});
|
||||
|
||||
it("centers two objects with different sizes correctly horizontally", () => {
|
||||
createAndSelectTwoRectanglesWithDifferentSizes();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(110);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(60);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(55);
|
||||
|
||||
// Check if y position did not change
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(110);
|
||||
});
|
||||
|
||||
const createAndSelectGroupAndRectangle = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add the created group to the current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns a group with another element correctly to the top", () => {
|
||||
createAndSelectGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
|
||||
it("aligns a group with another element correctly to the bottom", () => {
|
||||
createAndSelectGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
|
||||
it("aligns a group with another element correctly to the left", () => {
|
||||
createAndSelectGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
|
||||
it("aligns a group with another element correctly to the right", () => {
|
||||
createAndSelectGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
|
||||
it("centers a group with another element correctly vertically", () => {
|
||||
createAndSelectGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
|
||||
it("centers a group with another element correctly horizontally", () => {
|
||||
createAndSelectGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
|
||||
const createAndSelectTwoGroups = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already selected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
mouse.restorePosition(200, 200);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
|
||||
// Select the first group.
|
||||
// The second group is already selected because it was the last group created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns two groups correctly to the top", () => {
|
||||
createAndSelectTwoGroups();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(100);
|
||||
});
|
||||
|
||||
it("aligns two groups correctly to the bottom", () => {
|
||||
createAndSelectTwoGroups();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(300);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
});
|
||||
|
||||
it("aligns two groups correctly to the left", () => {
|
||||
createAndSelectTwoGroups();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(100);
|
||||
});
|
||||
|
||||
it("aligns two groups correctly to the right", () => {
|
||||
createAndSelectTwoGroups();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(300);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
});
|
||||
|
||||
it("centers two groups correctly vertically", () => {
|
||||
createAndSelectTwoGroups();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(200);
|
||||
});
|
||||
|
||||
it("centers two groups correctly horizontally", () => {
|
||||
createAndSelectTwoGroups();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(200);
|
||||
});
|
||||
|
||||
const createAndSelectNestedGroupAndRectangle = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create first group of rectangles
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(0, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create the nested group
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(300, 300);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the nested group, the rectangle is already selected
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns nested group and other element correctly to the top", () => {
|
||||
createAndSelectNestedGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(0);
|
||||
});
|
||||
|
||||
it("aligns nested group and other element correctly to the bottom", () => {
|
||||
createAndSelectNestedGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
});
|
||||
|
||||
it("aligns nested group and other element correctly to the left", () => {
|
||||
createAndSelectNestedGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(0);
|
||||
});
|
||||
|
||||
it("aligns nested group and other element correctly to the right", () => {
|
||||
createAndSelectNestedGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
});
|
||||
|
||||
it("centers nested group and other element correctly vertically", () => {
|
||||
createAndSelectNestedGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(250);
|
||||
expect(API.getSelectedElements()[3].y).toEqual(150);
|
||||
});
|
||||
|
||||
it("centers nested group and other element correctly horizontally", () => {
|
||||
createAndSelectNestedGroupAndRectangle();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(300);
|
||||
|
||||
h.app.actionManager.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||
});
|
||||
});
|
81
packages/excalidraw/tests/appState.test.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { queryByTestId, render, waitFor } from "./test-utils";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { Pointer, UI } from "./helpers/ui";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("appState", () => {
|
||||
it("drag&drop file doesn't reset non-persisted appState", async () => {
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const exportBackground = !defaultAppState.exportBackground;
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
exportBackground,
|
||||
viewBackgroundColor: "#F00",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.exportBackground).toBe(exportBackground);
|
||||
expect(h.state.viewBackgroundColor).toBe("#F00");
|
||||
});
|
||||
|
||||
API.drop(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
appState: {
|
||||
viewBackgroundColor: "#000",
|
||||
},
|
||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||
}),
|
||||
],
|
||||
{ type: MIME_TYPES.json },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
// non-imported prop → retain
|
||||
expect(h.state.exportBackground).toBe(exportBackground);
|
||||
// imported prop → overwrite
|
||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||
});
|
||||
});
|
||||
|
||||
it("changing fontSize with text tool selected (no element created yet)", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
currentItemFontSize: 30,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
expect(h.state.currentItemFontSize).toBe(30);
|
||||
queryByTestId(container, "fontSize-small")!.click();
|
||||
expect(h.state.currentItemFontSize).toBe(16);
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
mouse.clickAt(100, 100);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawTextElement).fontSize).toBe(16);
|
||||
});
|
||||
});
|
367
packages/excalidraw/tests/binding.test.tsx
Normal file
|
@ -0,0 +1,367 @@
|
|||
import { fireEvent, render } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("element binding", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("should create valid binding if duplicate start/end points", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 1,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[100, 0],
|
||||
],
|
||||
});
|
||||
h.elements = [rect, arrow];
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getSelectedElements()).toEqual([arrow]);
|
||||
mouse.downAt(100, 0);
|
||||
mouse.moveTo(55, 0);
|
||||
mouse.up(0, 0);
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
|
||||
mouse.downAt(100, 0);
|
||||
mouse.move(-45, 0);
|
||||
mouse.up();
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
|
||||
mouse.down();
|
||||
mouse.move(-50, 0);
|
||||
mouse.up();
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
expect(arrow.endBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
});
|
||||
|
||||
//@TODO fix the test with rotation
|
||||
it.skip("rotation of arrow should rebind both ends", () => {
|
||||
const rectLeft = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const rectRight = UI.createElement("rectangle", {
|
||||
x: 400,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 180,
|
||||
height: 1,
|
||||
});
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
|
||||
const rotation = getTransformHandles(arrow, h.state.zoom, "mouse")
|
||||
.rotation!;
|
||||
const rotationHandleX = rotation[0] + rotation[2] / 2;
|
||||
const rotationHandleY = rotation[1] + rotation[3] / 2;
|
||||
mouse.down(rotationHandleX, rotationHandleY);
|
||||
mouse.move(300, 400);
|
||||
mouse.up();
|
||||
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
||||
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
||||
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
|
||||
});
|
||||
|
||||
// TODO fix & reenable once we rewrite tests to work with concurrency
|
||||
it.skip(
|
||||
"editing arrow and moving its head to bind it to element A, finalizing the" +
|
||||
"editing by clicking on element A should end up selecting A",
|
||||
async () => {
|
||||
UI.createElement("rectangle", {
|
||||
y: 0,
|
||||
size: 100,
|
||||
});
|
||||
// Create arrow bound to rectangle
|
||||
UI.clickTool("arrow");
|
||||
mouse.down(50, -100);
|
||||
mouse.up(0, 80);
|
||||
|
||||
// Edit arrow with multi-point
|
||||
mouse.doubleClick();
|
||||
// move arrow head
|
||||
mouse.down();
|
||||
mouse.up(0, 10);
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
|
||||
// NOTE this mouse down/up + await needs to be done in order to repro
|
||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||
mouse.reset();
|
||||
expect(h.state.editingLinearElement).not.toBe(null);
|
||||
mouse.down(0, 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(h.state.editingLinearElement).toBe(null);
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
mouse.up();
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
},
|
||||
);
|
||||
|
||||
it("should bind/unbind arrow when moving it with keyboard", () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: 75,
|
||||
y: 0,
|
||||
size: 100,
|
||||
});
|
||||
|
||||
// Creates arrow 1px away from bidding with rectangle
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
it("should unbind on bound element deletion", () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: 60,
|
||||
y: 0,
|
||||
size: 100,
|
||||
});
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
|
||||
mouse.select(rectangle);
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
Keyboard.keyDown(KEYS.DELETE);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
it("should unbind on text element deletion by submitting empty text", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
|
||||
// edit text element and submit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
|
||||
fireEvent.change(editor, { target: { value: "" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
it("should keep binding on text update", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 50,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
|
||||
// delete text element by submitting empty text
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
|
||||
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
expect(
|
||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
||||
).toBe(null);
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
});
|
||||
|
||||
it("should update binding when text containerized", async () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
width: 100,
|
||||
height: 100,
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
],
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -87.45777932247563],
|
||||
],
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -87.45777932247563],
|
||||
],
|
||||
startBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
text: "ola",
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
],
|
||||
});
|
||||
|
||||
h.elements = [rectangle1, arrow1, arrow2, text1];
|
||||
|
||||
API.setSelectedElements([text1]);
|
||||
|
||||
expect(h.state.selectedElementIds[text1.id]).toBe(true);
|
||||
|
||||
h.app.actionManager.executeAction(actionWrapTextInContainer);
|
||||
|
||||
// new text container will be placed before the text element
|
||||
const container = h.elements.at(-2)!;
|
||||
|
||||
expect(container.type).toBe("rectangle");
|
||||
expect(container.id).not.toBe(rectangle1.id);
|
||||
|
||||
expect(container).toEqual(
|
||||
expect.objectContaining({
|
||||
boundElements: expect.arrayContaining([
|
||||
{
|
||||
type: "text",
|
||||
id: text1.id,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: arrow1.id,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: arrow2.id,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
|
||||
expect(arrow1.endBinding?.elementId).toBe(container.id);
|
||||
expect(arrow2.startBinding?.elementId).toBe(container.id);
|
||||
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
|
||||
});
|
||||
});
|
13
packages/excalidraw/tests/charts.test.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { tryParseSpreadsheet } from "../charts";
|
||||
|
||||
describe("tryParseSpreadsheet", () => {
|
||||
it("works for numbers with comma in them", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`Week Index${"\t"}Users
|
||||
Week 1${"\t"}814
|
||||
Week 2${"\t"}10,301
|
||||
Week 3${"\t"}4,264`,
|
||||
);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
});
|
39
packages/excalidraw/tests/clients.test.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { getNameInitial } from "../clients";
|
||||
|
||||
describe("getClientInitials", () => {
|
||||
it("returns substring if one name provided", () => {
|
||||
expect(getNameInitial("Alan")).toBe("A");
|
||||
});
|
||||
|
||||
it("returns initials", () => {
|
||||
expect(getNameInitial("John Doe")).toBe("J");
|
||||
});
|
||||
|
||||
it("returns correct initials if many names provided", () => {
|
||||
expect(getNameInitial("John Alan Doe")).toBe("J");
|
||||
});
|
||||
|
||||
it("returns single initial if 1 letter provided", () => {
|
||||
expect(getNameInitial("z")).toBe("Z");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace", () => {
|
||||
expect(getNameInitial(" q ")).toBe("Q");
|
||||
});
|
||||
|
||||
it('returns "?" if falsey value provided', () => {
|
||||
expect(getNameInitial("")).toBe("?");
|
||||
expect(getNameInitial(undefined)).toBe("?");
|
||||
expect(getNameInitial(null)).toBe("?");
|
||||
});
|
||||
|
||||
it('returns "?" when value is blank', () => {
|
||||
expect(getNameInitial(" ")).toBe("?");
|
||||
});
|
||||
|
||||
it("works with multibyte strings", () => {
|
||||
expect(getNameInitial("😀")).toBe("😀");
|
||||
// but doesn't work with emoji ZWJ sequences
|
||||
expect(getNameInitial("👨👩👦")).toBe("👨");
|
||||
});
|
||||
});
|
265
packages/excalidraw/tests/clipboard.test.tsx
Normal file
|
@ -0,0 +1,265 @@
|
|||
import { vi } from "vitest";
|
||||
import ReactDOM from "react-dom";
|
||||
import { render, waitFor, GlobalTestState } from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import { Excalidraw } from "../index";
|
||||
import { KEYS } from "../keys";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "../element/textElement";
|
||||
import { getElementBounds } from "../element";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
import { API } from "./helpers/api";
|
||||
import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
vi.mock("../keys.ts", async (importOriginal) => {
|
||||
const module: any = await importOriginal();
|
||||
return {
|
||||
__esmodule: true,
|
||||
...module,
|
||||
isDarwin: false,
|
||||
KEYS: {
|
||||
...module.KEYS,
|
||||
CTRL_OR_CMD: "ctrlKey",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const sendPasteEvent = (text: string) => {
|
||||
const clipboardEvent = createPasteEvent({
|
||||
types: {
|
||||
"text/plain": text,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(clipboardEvent);
|
||||
};
|
||||
|
||||
const pasteWithCtrlCmdShiftV = (text: string) => {
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
//triggering keydown with an empty clipboard
|
||||
Keyboard.keyPress(KEYS.V);
|
||||
//triggering paste event with faked clipboard
|
||||
sendPasteEvent(text);
|
||||
});
|
||||
};
|
||||
|
||||
const pasteWithCtrlCmdV = (text: string) => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
//triggering keydown with an empty clipboard
|
||||
Keyboard.keyPress(KEYS.V);
|
||||
//triggering paste event with faked clipboard
|
||||
sendPasteEvent(text);
|
||||
});
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(null), ms));
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
mouse.reset();
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
autoFocus={true}
|
||||
handleKeyboardGlobally={true}
|
||||
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
|
||||
/>,
|
||||
);
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
});
|
||||
|
||||
describe("general paste behavior", () => {
|
||||
it("should randomize seed on paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rectangle],
|
||||
files: null,
|
||||
});
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].seed).not.toBe(rectangle.seed);
|
||||
});
|
||||
});
|
||||
|
||||
it("should retain seed on shift-paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rectangle],
|
||||
files: null,
|
||||
});
|
||||
|
||||
// assert we don't randomize seed on shift-paste
|
||||
pasteWithCtrlCmdShiftV(clipboardJSON);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].seed).toBe(rectangle.seed);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("paste text as single lines", () => {
|
||||
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
||||
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toEqual(text.split("\n").length);
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore empty lines when creating an element for each line", async () => {
|
||||
const text = "\n\nsajgfakfn\n\n\naaksfnknas\n\nakefnkasf\n\n\n";
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create any element if clipboard has only new lines", async () => {
|
||||
const text = "\n\n\n\n\n";
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(async () => {
|
||||
await sleep(50); // elements lenght will always be zero if we don't wait, since paste is async
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should space items correctly", async () => {
|
||||
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getDefaultLineHeight(h.state.currentItemFontFamily),
|
||||
) +
|
||||
10 / h.app.state.zoom.value;
|
||||
mouse.moveTo(100, 100);
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
||||
for (let i = 1; i < h.elements.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [fx, elY] = getElementBounds(h.elements[i]);
|
||||
expect(elY).toEqual(firstElY + lineHeightPx * i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should leave a space for blank new lines", async () => {
|
||||
const text = "hkhkjhki\n\njgkjhffjh";
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getDefaultLineHeight(h.state.currentItemFontFamily),
|
||||
) +
|
||||
10 / h.app.state.zoom.value;
|
||||
mouse.moveTo(100, 100);
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [lx, lastElY] = getElementBounds(h.elements[1]);
|
||||
expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("paste text as a single element", () => {
|
||||
it("should create single text element when copying text with Ctrl/Cmd+Shift+V", async () => {
|
||||
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
||||
pasteWithCtrlCmdShiftV(text);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
it("should not create any element when only new lines in clipboard", async () => {
|
||||
const text = "\n\n\n\n";
|
||||
pasteWithCtrlCmdShiftV(text);
|
||||
await waitFor(async () => {
|
||||
await sleep(50);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paste bound text container", () => {
|
||||
const container = {
|
||||
type: "ellipse",
|
||||
id: "container-id",
|
||||
x: 554.984375,
|
||||
y: 196.0234375,
|
||||
width: 166,
|
||||
height: 187.01953125,
|
||||
roundness: { type: 2 },
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
};
|
||||
const textElement = {
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
baseline: 168,
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: container.id,
|
||||
originalText:
|
||||
"Excalidraw is a virtual opensource whiteboard for sketching hand-drawn like diagrams",
|
||||
};
|
||||
|
||||
it("should fix ellipse bounding box", async () => {
|
||||
const data = JSON.stringify({
|
||||
type: "excalidraw/clipboard",
|
||||
elements: [container, textElement],
|
||||
});
|
||||
pasteWithCtrlCmdShiftV(data);
|
||||
|
||||
await waitFor(async () => {
|
||||
await sleep(1);
|
||||
expect(h.elements.length).toEqual(2);
|
||||
const container = h.elements[0];
|
||||
expect(container.height).toBe(368);
|
||||
expect(container.width).toBe(166);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fix diamond bounding box", async () => {
|
||||
const data = JSON.stringify({
|
||||
type: "excalidraw/clipboard",
|
||||
elements: [
|
||||
{
|
||||
...container,
|
||||
type: "diamond",
|
||||
},
|
||||
textElement,
|
||||
],
|
||||
});
|
||||
pasteWithCtrlCmdShiftV(data);
|
||||
|
||||
await waitFor(async () => {
|
||||
await sleep(1);
|
||||
expect(h.elements.length).toEqual(2);
|
||||
const container = h.elements[0];
|
||||
expect(container.height).toBe(770);
|
||||
expect(container.width).toBe(166);
|
||||
});
|
||||
});
|
||||
});
|
601
packages/excalidraw/tests/contextmenu.test.tsx
Normal file
|
@ -0,0 +1,601 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
GlobalTestState,
|
||||
screen,
|
||||
queryByText,
|
||||
queryAllByText,
|
||||
waitFor,
|
||||
togglePopover,
|
||||
} from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { reseed } from "../random";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
import { ShortcutName } from "../actions/shortcuts";
|
||||
import { copiedStyles } from "../actions/actionStyles";
|
||||
import { API } from "./helpers/api";
|
||||
import { setDateTimeForTests } from "../utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const checkpoint = (name: string) => {
|
||||
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
|
||||
`[${name}] number of renders`,
|
||||
);
|
||||
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
||||
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
|
||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||
h.elements.forEach((element, i) =>
|
||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||
);
|
||||
};
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
// 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;
|
||||
|
||||
describe("contextMenu element", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
checkpoint("end of test");
|
||||
|
||||
mouse.reset();
|
||||
mouse.down(0, 0);
|
||||
});
|
||||
|
||||
it("shows context menu for canvas", () => {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"paste",
|
||||
"selectAll",
|
||||
"gridMode",
|
||||
"zenMode",
|
||||
"viewMode",
|
||||
"objectsSnapMode",
|
||||
"stats",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
|
||||
expectedShortcutNames.forEach((shortcutName) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows context menu for element", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
"addToLibrary",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"duplicateSelection",
|
||||
"hyperlink",
|
||||
"toggleElementLock",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
|
||||
expectedShortcutNames.forEach((shortcutName) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows context menu for element", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 200,
|
||||
width: 200,
|
||||
backgroundColor: "red",
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 200,
|
||||
width: 200,
|
||||
backgroundColor: "red",
|
||||
});
|
||||
h.elements = [rect1, rect2];
|
||||
API.setSelectedElements([rect1]);
|
||||
|
||||
// lower z-index
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
expect(UI.queryContextMenu()).not.toBeNull();
|
||||
expect(API.getSelectedElement().id).toBe(rect1.id);
|
||||
|
||||
// higher z-index
|
||||
API.setSelectedElements([rect2]);
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
expect(UI.queryContextMenu()).not.toBeNull();
|
||||
expect(API.getSelectedElement().id).toBe(rect2.id);
|
||||
});
|
||||
|
||||
it("shows 'Group selection' in context menu for multiple selected elements", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(10, 10);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
|
||||
mouse.reset();
|
||||
mouse.click(10, 10);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(20, 0);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
"group",
|
||||
"addToLibrary",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"duplicateSelection",
|
||||
"toggleElementLock",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
|
||||
expectedShortcutNames.forEach((shortcutName) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(10, 10);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, -10);
|
||||
mouse.up(10, 10);
|
||||
|
||||
mouse.reset();
|
||||
mouse.click(10, 10);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(20, 0);
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
"ungroup",
|
||||
"addToLibrary",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"duplicateSelection",
|
||||
"toggleElementLock",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
|
||||
expectedShortcutNames.forEach((shortcutName) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting 'Copy styles' in context menu copies styles", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
expect(copiedStyles).toBe("{}");
|
||||
fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
|
||||
expect(copiedStyles).not.toBe("{}");
|
||||
const element = JSON.parse(copiedStyles)[0];
|
||||
expect(element).toEqual(API.getSelectedElement());
|
||||
});
|
||||
|
||||
it("selecting 'Paste styles' in context menu pastes styles", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
// Change some styles of second rectangle
|
||||
togglePopover("Stroke");
|
||||
UI.clickOnTestId("color-red");
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-blue");
|
||||
// Fill style
|
||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||
// Stroke width
|
||||
fireEvent.click(screen.getByTitle("Bold"));
|
||||
// Stroke style
|
||||
fireEvent.click(screen.getByTitle("Dotted"));
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
// closing the background popover as this blocks
|
||||
// context menu from rendering after we started focussing
|
||||
// the popover once rendered :/
|
||||
togglePopover("Background");
|
||||
|
||||
mouse.reset();
|
||||
|
||||
// Copy styles of second rectangle
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
|
||||
let contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
|
||||
const secondRect = JSON.parse(copiedStyles)[0];
|
||||
expect(secondRect.id).toBe(h.elements[1].id);
|
||||
|
||||
mouse.reset();
|
||||
// Paste styles to first rectangle
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Paste styles")!);
|
||||
|
||||
const firstRect = API.getSelectedElement();
|
||||
expect(firstRect.id).toBe(h.elements[0].id);
|
||||
expect(firstRect.strokeColor).toBe("#e03131");
|
||||
expect(firstRect.backgroundColor).toBe("#a5d8ff");
|
||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||
expect(firstRect.strokeStyle).toBe("dotted");
|
||||
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
||||
expect(firstRect.opacity).toBe(60);
|
||||
});
|
||||
|
||||
it("selecting 'Delete' in context menu deletes element", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
|
||||
expect(API.getSelectedElements()).toHaveLength(0);
|
||||
expect(h.elements[0].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("selecting 'Add to library' in context menu adds element to library", async () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Add to library")!);
|
||||
|
||||
await waitFor(async () => {
|
||||
const libraryItems = await h.app.library.getLatestLibrary();
|
||||
expect(libraryItems[0].elements[0]).toEqual(h.elements[0]);
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting 'Duplicate' in context menu duplicates element", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
||||
expect(h.elements).toHaveLength(2);
|
||||
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
|
||||
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
|
||||
expect(rect1).toEqual(rect2);
|
||||
});
|
||||
|
||||
it("selecting 'Send backward' in context menu sends element backward", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const elementsBefore = h.elements;
|
||||
fireEvent.click(queryByText(contextMenu!, "Send backward")!);
|
||||
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
|
||||
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("selecting 'Bring forward' in context menu brings element forward", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const elementsBefore = h.elements;
|
||||
fireEvent.click(queryByText(contextMenu!, "Bring forward")!);
|
||||
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
|
||||
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("selecting 'Send to back' in context menu sends element to back", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const elementsBefore = h.elements;
|
||||
fireEvent.click(queryByText(contextMenu!, "Send to back")!);
|
||||
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("selecting 'Bring to front' in context menu brings element to front", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const elementsBefore = h.elements;
|
||||
fireEvent.click(queryByText(contextMenu!, "Bring to front")!);
|
||||
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
|
||||
});
|
||||
|
||||
it("selecting 'Group selection' in context menu groups selected elements", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(10, 10);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Group selection")!);
|
||||
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
|
||||
expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
|
||||
expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
|
||||
});
|
||||
|
||||
it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(10, 10);
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
expect(contextMenu).not.toBeNull();
|
||||
fireEvent.click(queryByText(contextMenu!, "Ungroup selection")!);
|
||||
|
||||
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
|
||||
expect(selectedGroupIds).toHaveLength(0);
|
||||
expect(h.elements[0].groupIds).toHaveLength(0);
|
||||
expect(h.elements[1].groupIds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("right-clicking on a group should select whole group", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
h.elements = [rectangle1, rectangle2];
|
||||
|
||||
mouse.rightClickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(2);
|
||||
expect(API.getSelectedElements()).toEqual([
|
||||
expect.objectContaining({ id: rectangle1.id }),
|
||||
expect.objectContaining({ id: rectangle2.id }),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,368 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`restoreElements > should restore arrow element correctly 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-arrow01",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore correctly with rectangle, ellipse and diamond elements 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
],
|
||||
"height": 200,
|
||||
"id": "1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 10,
|
||||
"roughness": 2,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "red",
|
||||
"strokeStyle": "dashed",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore correctly with rectangle, ellipse and diamond elements 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
],
|
||||
"height": 200,
|
||||
"id": "2",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 10,
|
||||
"roughness": 2,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "red",
|
||||
"strokeStyle": "dashed",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore correctly with rectangle, ellipse and diamond elements 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
],
|
||||
"height": 200,
|
||||
"id": "3",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 10,
|
||||
"roughness": 2,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "red",
|
||||
"strokeStyle": "dashed",
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
"id": "id-freedraw01",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [],
|
||||
"pressures": [],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"simulatePressure": true,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "freedraw",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 0,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore line and draw elements correctly 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-line01",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore line and draw elements correctly 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-draw01",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": [],
|
||||
"containerId": null,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 14,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "text",
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"text": "text",
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -20,
|
||||
"y": -8.75,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": [],
|
||||
"containerId": null,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 10,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": true,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "",
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"text": "",
|
||||
"textAlign": "left",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "top",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
793
packages/excalidraw/tests/data/restore.test.ts
Normal file
|
@ -0,0 +1,793 @@
|
|||
import * as restore from "../../data/restore";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../../element/types";
|
||||
import * as sizeHelpers from "../../element/sizeHelpers";
|
||||
import { API } from "../helpers/api";
|
||||
import { getDefaultAppState } from "../../appState";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { NormalizedZoomValue } from "../../types";
|
||||
import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "../../constants";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import { vi } from "vitest";
|
||||
|
||||
describe("restoreElements", () => {
|
||||
const mockSizeHelper = vi.spyOn(sizeHelpers, "isInvisiblySmallElement");
|
||||
|
||||
beforeEach(() => {
|
||||
mockSizeHelper.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockSizeHelper.mockRestore();
|
||||
});
|
||||
|
||||
it("should return empty array when element is null", () => {
|
||||
expect(restore.restoreElements(null, null)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("should not call isInvisiblySmallElement when element is a selection element", () => {
|
||||
const selectionEl = { type: "selection" } as ExcalidrawElement;
|
||||
const restoreElements = restore.restoreElements([selectionEl], null);
|
||||
expect(restoreElements.length).toBe(0);
|
||||
expect(sizeHelpers.isInvisiblySmallElement).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should return empty array when input type is not supported", () => {
|
||||
const dummyNotSupportedElement: any = API.createElement({
|
||||
type: "text",
|
||||
});
|
||||
|
||||
dummyNotSupportedElement.type = "not supported";
|
||||
expect(
|
||||
restore.restoreElements([dummyNotSupportedElement], null).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("should return empty array when isInvisiblySmallElement is true", () => {
|
||||
const rectElement = API.createElement({ type: "rectangle" });
|
||||
mockSizeHelper.mockImplementation(() => true);
|
||||
|
||||
expect(restore.restoreElements([rectElement], null).length).toBe(0);
|
||||
});
|
||||
|
||||
it("should restore text element correctly passing value for each attribute", () => {
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
fontSize: 14,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
text: "text",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
id: "id-text01",
|
||||
});
|
||||
|
||||
const restoredText = restore.restoreElements(
|
||||
[textElement],
|
||||
null,
|
||||
)[0] as ExcalidrawTextElement;
|
||||
|
||||
expect(restoredText).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
|
||||
const textElement: any = API.createElement({
|
||||
type: "text",
|
||||
textAlign: undefined,
|
||||
verticalAlign: undefined,
|
||||
id: "id-text01",
|
||||
});
|
||||
|
||||
textElement.text = null;
|
||||
textElement.font = "10 unknown";
|
||||
|
||||
expect(textElement.isDeleted).toBe(false);
|
||||
const restoredText = restore.restoreElements(
|
||||
[textElement],
|
||||
null,
|
||||
)[0] as ExcalidrawTextElement;
|
||||
expect(restoredText.isDeleted).toBe(true);
|
||||
expect(restoredText).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore freedraw element correctly", () => {
|
||||
const freedrawElement = API.createElement({
|
||||
type: "freedraw",
|
||||
id: "id-freedraw01",
|
||||
});
|
||||
|
||||
const restoredFreedraw = restore.restoreElements(
|
||||
[freedrawElement],
|
||||
null,
|
||||
)[0] as ExcalidrawFreeDrawElement;
|
||||
|
||||
expect(restoredFreedraw).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("should restore line and draw elements correctly", () => {
|
||||
const lineElement = API.createElement({ type: "line", id: "id-line01" });
|
||||
|
||||
const drawElement: any = API.createElement({
|
||||
type: "line",
|
||||
id: "id-draw01",
|
||||
});
|
||||
drawElement.type = "draw";
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[lineElement, drawElement],
|
||||
null,
|
||||
);
|
||||
|
||||
const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
|
||||
const restoredDraw = restoredElements[1] as ExcalidrawLinearElement;
|
||||
|
||||
expect(restoredLine).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
expect(restoredDraw).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("should restore arrow element correctly", () => {
|
||||
const arrowElement = API.createElement({ type: "arrow", id: "id-arrow01" });
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead);
|
||||
});
|
||||
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is undefined', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
Object.defineProperty(arrowElement, "endArrowhead", {
|
||||
get: vi.fn(() => undefined),
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(restoredArrow.endArrowhead).toBe("arrow");
|
||||
});
|
||||
|
||||
it("when element.points of a line element is not an array", () => {
|
||||
const lineElement: any = API.createElement({
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 200,
|
||||
});
|
||||
|
||||
lineElement.points = "not an array";
|
||||
|
||||
const expectedLinePoints = [
|
||||
[0, 0],
|
||||
[lineElement.width, lineElement.height],
|
||||
];
|
||||
|
||||
const restoredLine = restore.restoreElements(
|
||||
[lineElement],
|
||||
null,
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(restoredLine.points).toMatchObject(expectedLinePoints);
|
||||
});
|
||||
|
||||
it("when the number of points of a line is greater or equal 2", () => {
|
||||
const lineElement_0 = API.createElement({
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 200,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
const lineElement_1 = API.createElement({
|
||||
type: "line",
|
||||
width: 200,
|
||||
height: 400,
|
||||
x: 30,
|
||||
y: 40,
|
||||
});
|
||||
|
||||
const pointsEl_0 = [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
];
|
||||
Object.defineProperty(lineElement_0, "points", {
|
||||
get: vi.fn(() => pointsEl_0),
|
||||
});
|
||||
|
||||
const pointsEl_1 = [
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
];
|
||||
Object.defineProperty(lineElement_1, "points", {
|
||||
get: vi.fn(() => pointsEl_1),
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[lineElement_0, lineElement_1],
|
||||
null,
|
||||
);
|
||||
|
||||
const restoredLine_0 = restoredElements[0] as ExcalidrawLinearElement;
|
||||
const restoredLine_1 = restoredElements[1] as ExcalidrawLinearElement;
|
||||
|
||||
expect(restoredLine_0.points).toMatchObject(pointsEl_0);
|
||||
|
||||
const offsetX = pointsEl_1[0][0];
|
||||
const offsetY = pointsEl_1[0][1];
|
||||
const restoredPointsEl1 = [
|
||||
[pointsEl_1[0][0] - offsetX, pointsEl_1[0][1] - offsetY],
|
||||
[pointsEl_1[1][0] - offsetX, pointsEl_1[1][1] - offsetY],
|
||||
];
|
||||
expect(restoredLine_1.points).toMatchObject(restoredPointsEl1);
|
||||
expect(restoredLine_1.x).toBe(lineElement_1.x + offsetX);
|
||||
expect(restoredLine_1.y).toBe(lineElement_1.y + offsetY);
|
||||
});
|
||||
|
||||
it("should restore correctly with rectangle, ellipse and diamond elements", () => {
|
||||
const types = ["rectangle", "ellipse", "diamond"];
|
||||
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
let idCount = 0;
|
||||
types.forEach((elType) => {
|
||||
idCount += 1;
|
||||
const element = API.createElement({
|
||||
type: elType as "rectangle" | "ellipse" | "diamond" | "embeddable",
|
||||
id: idCount.toString(),
|
||||
fillStyle: "cross-hatch",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dashed",
|
||||
roughness: 2,
|
||||
opacity: 10,
|
||||
x: 10,
|
||||
y: 20,
|
||||
strokeColor: "red",
|
||||
backgroundColor: "blue",
|
||||
width: 100,
|
||||
height: 200,
|
||||
groupIds: ["1", "2", "3"],
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
});
|
||||
|
||||
elements.push(element);
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements(elements, null);
|
||||
|
||||
expect(restoredElements[0]).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
expect(restoredElements[1]).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
expect(restoredElements[2]).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("bump versions of local duplicate elements when supplied", () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const ellipse = API.createElement({ type: "ellipse" });
|
||||
const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[rectangle, ellipse],
|
||||
[rectangle_modified],
|
||||
);
|
||||
|
||||
expect(restoredElements[0].id).toBe(rectangle.id);
|
||||
expect(restoredElements[0].versionNonce).not.toBe(rectangle.versionNonce);
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: rectangle.id,
|
||||
version: rectangle_modified.version + 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: ellipse.id,
|
||||
version: ellipse.version,
|
||||
versionNonce: ellipse.versionNonce,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreAppState", () => {
|
||||
it("should restore with imported data", () => {
|
||||
const stubImportedAppState = getDefaultAppState();
|
||||
stubImportedAppState.activeTool.type = "selection";
|
||||
stubImportedAppState.cursorButton = "down";
|
||||
stubImportedAppState.name = "imported app state";
|
||||
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
stubLocalAppState.activeTool.type = "rectangle";
|
||||
stubLocalAppState.cursorButton = "up";
|
||||
stubLocalAppState.name = "local app state";
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
expect(restoredAppState.activeTool).toEqual(
|
||||
stubImportedAppState.activeTool,
|
||||
);
|
||||
expect(restoredAppState.cursorButton).toBe("up");
|
||||
expect(restoredAppState.name).toBe(stubImportedAppState.name);
|
||||
});
|
||||
|
||||
it("should restore with current app state when imported data state is undefined", () => {
|
||||
const stubImportedAppState = {
|
||||
...getDefaultAppState(),
|
||||
cursorButton: undefined,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
stubLocalAppState.cursorButton = "down";
|
||||
stubLocalAppState.name = "local app state";
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
expect(restoredAppState.cursorButton).toBe(stubLocalAppState.cursorButton);
|
||||
expect(restoredAppState.name).toBe(stubLocalAppState.name);
|
||||
});
|
||||
|
||||
it("should return imported data when local app state is null", () => {
|
||||
const stubImportedAppState = getDefaultAppState();
|
||||
stubImportedAppState.cursorButton = "down";
|
||||
stubImportedAppState.name = "imported app state";
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
null,
|
||||
);
|
||||
expect(restoredAppState.cursorButton).toBe("up");
|
||||
expect(restoredAppState.name).toBe(stubImportedAppState.name);
|
||||
});
|
||||
|
||||
it("should return local app state when imported data state is null", () => {
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
stubLocalAppState.cursorButton = "down";
|
||||
stubLocalAppState.name = "local app state";
|
||||
|
||||
const restoredAppState = restore.restoreAppState(null, stubLocalAppState);
|
||||
expect(restoredAppState.cursorButton).toBe(stubLocalAppState.cursorButton);
|
||||
expect(restoredAppState.name).toBe(stubLocalAppState.name);
|
||||
});
|
||||
|
||||
it("should return default app state when imported data state and local app state are undefined", () => {
|
||||
const stubImportedAppState = {
|
||||
...getDefaultAppState(),
|
||||
cursorButton: undefined,
|
||||
};
|
||||
|
||||
const stubLocalAppState = {
|
||||
...getDefaultAppState(),
|
||||
cursorButton: undefined,
|
||||
};
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
expect(restoredAppState.cursorButton).toBe(
|
||||
getDefaultAppState().cursorButton,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return default app state when imported data state and local app state are null", () => {
|
||||
const restoredAppState = restore.restoreAppState(null, null);
|
||||
expect(restoredAppState.cursorButton).toBe(
|
||||
getDefaultAppState().cursorButton,
|
||||
);
|
||||
});
|
||||
|
||||
it("when imported data state has a not allowed Excalidraw Element Types", () => {
|
||||
const stubImportedAppState: any = getDefaultAppState();
|
||||
|
||||
stubImportedAppState.activeTool = "not allowed Excalidraw Element Types";
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
expect(restoredAppState.activeTool.type).toBe("selection");
|
||||
});
|
||||
|
||||
describe("with zoom in imported data state", () => {
|
||||
it("when imported data state has zoom as a number", () => {
|
||||
const stubImportedAppState: any = getDefaultAppState();
|
||||
|
||||
stubImportedAppState.zoom = 10;
|
||||
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
|
||||
expect(restoredAppState.zoom.value).toBe(10);
|
||||
});
|
||||
|
||||
it("when the zoom of imported data state is not a number", () => {
|
||||
const stubImportedAppState = getDefaultAppState();
|
||||
stubImportedAppState.zoom = {
|
||||
value: 10 as NormalizedZoomValue,
|
||||
};
|
||||
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
|
||||
expect(restoredAppState.zoom.value).toBe(10);
|
||||
expect(restoredAppState.zoom).toMatchObject(stubImportedAppState.zoom);
|
||||
});
|
||||
|
||||
it("when the zoom of imported data state zoom is null", () => {
|
||||
const stubImportedAppState = getDefaultAppState();
|
||||
|
||||
Object.defineProperty(stubImportedAppState, "zoom", {
|
||||
get: vi.fn(() => null),
|
||||
});
|
||||
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
|
||||
const restoredAppState = restore.restoreAppState(
|
||||
stubImportedAppState,
|
||||
stubLocalAppState,
|
||||
);
|
||||
|
||||
expect(restoredAppState.zoom).toMatchObject(getDefaultAppState().zoom);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle appState.openSidebar legacy values", () => {
|
||||
expect(restore.restoreAppState({}, null).openSidebar).toBe(null);
|
||||
expect(
|
||||
restore.restoreAppState({ openSidebar: "library" } as any, null)
|
||||
.openSidebar,
|
||||
).toEqual({ name: DEFAULT_SIDEBAR.name });
|
||||
expect(
|
||||
restore.restoreAppState({ openSidebar: "xxx" } as any, null).openSidebar,
|
||||
).toEqual({ name: DEFAULT_SIDEBAR.name });
|
||||
// while "library" was our legacy sidebar name, we can't assume it's legacy
|
||||
// value as it may be some host app's custom sidebar name ¯\_(ツ)_/¯
|
||||
expect(
|
||||
restore.restoreAppState({ openSidebar: { name: "library" } } as any, null)
|
||||
.openSidebar,
|
||||
).toEqual({ name: "library" });
|
||||
expect(
|
||||
restore.restoreAppState(
|
||||
{ openSidebar: { name: DEFAULT_SIDEBAR.name, tab: "ola" } } as any,
|
||||
null,
|
||||
).openSidebar,
|
||||
).toEqual({ name: DEFAULT_SIDEBAR.name, tab: "ola" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore", () => {
|
||||
it("when imported data state is null it should return an empty array of elements", () => {
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
|
||||
const restoredData = restore.restore(null, stubLocalAppState, null);
|
||||
expect(restoredData.elements.length).toBe(0);
|
||||
});
|
||||
|
||||
it("when imported data state is null it should return the local app state property", () => {
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
stubLocalAppState.cursorButton = "down";
|
||||
stubLocalAppState.name = "local app state";
|
||||
|
||||
const restoredData = restore.restore(null, stubLocalAppState, null);
|
||||
expect(restoredData.appState.cursorButton).toBe(
|
||||
stubLocalAppState.cursorButton,
|
||||
);
|
||||
expect(restoredData.appState.name).toBe(stubLocalAppState.name);
|
||||
});
|
||||
|
||||
it("when imported data state has elements", () => {
|
||||
const stubLocalAppState = getDefaultAppState();
|
||||
|
||||
const textElement = API.createElement({ type: "text" });
|
||||
const rectElement = API.createElement({ type: "rectangle" });
|
||||
const elements = [textElement, rectElement];
|
||||
|
||||
const importedDataState = {} as ImportedDataState;
|
||||
importedDataState.elements = elements;
|
||||
|
||||
const restoredData = restore.restore(
|
||||
importedDataState,
|
||||
stubLocalAppState,
|
||||
null,
|
||||
);
|
||||
expect(restoredData.elements.length).toBe(elements.length);
|
||||
});
|
||||
|
||||
it("when local app state is null but imported app state is supplied", () => {
|
||||
const stubImportedAppState = getDefaultAppState();
|
||||
stubImportedAppState.cursorButton = "down";
|
||||
stubImportedAppState.name = "imported app state";
|
||||
|
||||
const importedDataState = {} as ImportedDataState;
|
||||
importedDataState.appState = stubImportedAppState;
|
||||
|
||||
const restoredData = restore.restore(importedDataState, null, null);
|
||||
expect(restoredData.appState.cursorButton).toBe("up");
|
||||
expect(restoredData.appState.name).toBe(stubImportedAppState.name);
|
||||
});
|
||||
|
||||
it("bump versions of local duplicate elements when supplied", () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const ellipse = API.createElement({ type: "ellipse" });
|
||||
|
||||
const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
|
||||
|
||||
const restoredData = restore.restore(
|
||||
{ elements: [rectangle, ellipse] },
|
||||
null,
|
||||
[rectangle_modified],
|
||||
);
|
||||
|
||||
expect(restoredData.elements[0].id).toBe(rectangle.id);
|
||||
expect(restoredData.elements[0].versionNonce).not.toBe(
|
||||
rectangle.versionNonce,
|
||||
);
|
||||
expect(restoredData.elements).toEqual([
|
||||
expect.objectContaining({ version: rectangle_modified.version + 1 }),
|
||||
expect.objectContaining({
|
||||
id: ellipse.id,
|
||||
version: ellipse.version,
|
||||
versionNonce: ellipse.versionNonce,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairing bindings", () => {
|
||||
it("should repair container boundElements when repair is true", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const boundElement = API.createElement({
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
expect(container.boundElements).toEqual([]);
|
||||
|
||||
let restoredElements = restore.restoreElements(
|
||||
[container, boundElement],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
restoredElements = restore.restoreElements(
|
||||
[container, boundElement],
|
||||
null,
|
||||
{ repairBindings: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [{ type: boundElement.type, id: boundElement.id }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should repair containerId of boundElements when repair is true", () => {
|
||||
const boundElement = API.createElement({
|
||||
type: "text",
|
||||
containerId: null,
|
||||
});
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: boundElement.type, id: boundElement.id }],
|
||||
});
|
||||
|
||||
let restoredElements = restore.restoreElements(
|
||||
[container, boundElement],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [{ type: boundElement.type, id: boundElement.id }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
restoredElements = restore.restoreElements(
|
||||
[container, boundElement],
|
||||
null,
|
||||
{ repairBindings: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [{ type: boundElement.type, id: boundElement.id }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ignore bound element if deleted", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const boundElement = API.createElement({
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
expect(container.boundElements).toEqual([]);
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[container, boundElement],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove bindings of deleted elements from boundElements when repair is true", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const boundElement = API.createElement({
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
});
|
||||
const invisibleBoundElement = API.createElement({
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const obsoleteBinding = { type: boundElement.type, id: boundElement.id };
|
||||
const invisibleBinding = {
|
||||
type: invisibleBoundElement.type,
|
||||
id: invisibleBoundElement.id,
|
||||
};
|
||||
expect(container.boundElements).toEqual([]);
|
||||
|
||||
const nonExistentBinding = { type: "text", id: "non-existent" };
|
||||
// @ts-ignore
|
||||
container.boundElements = [
|
||||
obsoleteBinding,
|
||||
invisibleBinding,
|
||||
nonExistentBinding,
|
||||
];
|
||||
|
||||
let restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ repairBindings: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove containerId if container not exists when repair is true", () => {
|
||||
const boundElement = API.createElement({
|
||||
type: "text",
|
||||
containerId: "non-existent",
|
||||
});
|
||||
const boundElementDeleted = API.createElement({
|
||||
type: "text",
|
||||
containerId: "non-existent",
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
let restoredElements = restore.restoreElements(
|
||||
[boundElement, boundElementDeleted],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: "non-existent",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElementDeleted.id,
|
||||
containerId: "non-existent",
|
||||
}),
|
||||
]);
|
||||
|
||||
restoredElements = restore.restoreElements(
|
||||
[boundElement, boundElementDeleted],
|
||||
null,
|
||||
{ repairBindings: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElementDeleted.id,
|
||||
containerId: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
323
packages/excalidraw/tests/dragCreate.test.tsx
Normal file
|
@ -0,0 +1,323 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import { Excalidraw } from "../index";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { KEYS } from "../keys";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "./test-utils";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { reseed } from "../random";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("Test dragCreate", () => {
|
||||
describe("add element to the scene when pointer dragging long enough", () => {
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// move to (60,70)
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.elements[0].type).toEqual("rectangle");
|
||||
expect(h.elements[0].x).toEqual(30);
|
||||
expect(h.elements[0].y).toEqual(20);
|
||||
expect(h.elements[0].width).toEqual(30); // 60 - 30
|
||||
expect(h.elements[0].height).toEqual(50); // 70 - 20
|
||||
|
||||
expect(h.elements.length).toMatchSnapshot();
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("ellipse", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// move to (60,70)
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.elements[0].type).toEqual("ellipse");
|
||||
expect(h.elements[0].x).toEqual(30);
|
||||
expect(h.elements[0].y).toEqual(20);
|
||||
expect(h.elements[0].width).toEqual(30); // 60 - 30
|
||||
expect(h.elements[0].height).toEqual(50); // 70 - 20
|
||||
|
||||
expect(h.elements.length).toMatchSnapshot();
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("diamond", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// move to (60,70)
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.elements[0].type).toEqual("diamond");
|
||||
expect(h.elements[0].x).toEqual(30);
|
||||
expect(h.elements[0].y).toEqual(20);
|
||||
expect(h.elements[0].width).toEqual(30); // 60 - 30
|
||||
expect(h.elements[0].height).toEqual(50); // 70 - 20
|
||||
|
||||
expect(h.elements.length).toMatchSnapshot();
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("arrow", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// move to (60,70)
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(element.type).toEqual("arrow");
|
||||
expect(element.x).toEqual(30);
|
||||
expect(element.y).toEqual(20);
|
||||
expect(element.points.length).toEqual(2);
|
||||
expect(element.points[0]).toEqual([0, 0]);
|
||||
expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
|
||||
|
||||
expect(h.elements.length).toMatchSnapshot();
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("line", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// move to (60,70)
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(element.type).toEqual("line");
|
||||
expect(element.x).toEqual(30);
|
||||
expect(element.y).toEqual(20);
|
||||
expect(element.points.length).toEqual(2);
|
||||
expect(element.points[0]).toEqual([0, 0]);
|
||||
expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
});
|
||||
|
||||
describe("do not add element to the scene if size is too small", () => {
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("ellipse", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("diamond", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("arrow", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
// select tool
|
||||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
// we need to finalize it because arrows and lines enter multi-mode
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("line", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
// select tool
|
||||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
||||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
// we need to finalize it because arrows and lines enter multi-mode
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
388
packages/excalidraw/tests/elementLocking.test.tsx
Normal file
|
@ -0,0 +1,388 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import { Excalidraw } from "../index";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { actionSelectAll } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
const h = window.h;
|
||||
|
||||
describe("element locking", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.elements = [];
|
||||
});
|
||||
|
||||
it("click-selecting a locked element is disabled", () => {
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
h.elements = [lockedRectangle];
|
||||
|
||||
mouse.clickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
});
|
||||
|
||||
it("box-selecting a locked element is disabled", () => {
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
h.elements = [lockedRectangle];
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(250, 250);
|
||||
mouse.upAt(250, 250);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
});
|
||||
|
||||
it("dragging a locked element is disabled", () => {
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
h.elements = [lockedRectangle];
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.upAt(100, 100);
|
||||
expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
|
||||
});
|
||||
|
||||
it("you can drag element that's below a locked element", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
h.elements = [rectangle, lockedRectangle];
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.upAt(100, 100);
|
||||
expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
|
||||
expect(rectangle).toEqual(expect.objectContaining({ x: 50, y: 50 }));
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
expect(API.getSelectedElement().id).toBe(rectangle.id);
|
||||
});
|
||||
|
||||
it("selectAll shouldn't select locked elements", () => {
|
||||
h.elements = [
|
||||
API.createElement({ type: "rectangle" }),
|
||||
API.createElement({ type: "rectangle", locked: true }),
|
||||
];
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
});
|
||||
|
||||
it("clicking on a locked element should select the unlocked element beneath it", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
h.elements = [rectangle, lockedRectangle];
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
mouse.clickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
expect(API.getSelectedElement().id).toBe(rectangle.id);
|
||||
});
|
||||
|
||||
it("right-clicking on a locked element should select it & open its contextMenu", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
h.elements = [rectangle, lockedRectangle];
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
mouse.rightClickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
expect(API.getSelectedElement().id).toBe(lockedRectangle.id);
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(
|
||||
contextMenu?.querySelector(
|
||||
`li[data-testid="toggleElementLock"] .context-menu-item__label`,
|
||||
),
|
||||
).toHaveTextContent(t("labels.elementLock.unlock"));
|
||||
});
|
||||
|
||||
it("right-clicking on element covered by locked element should ignore the locked element", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
h.elements = [rectangle, lockedRectangle];
|
||||
API.setSelectedElements([rectangle]);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
expect(API.getSelectedElement().id).toBe(rectangle.id);
|
||||
mouse.rightClickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
expect(API.getSelectedElement().id).toBe(rectangle.id);
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
expect(contextMenu).not.toBeNull();
|
||||
});
|
||||
|
||||
it("selecting a group selects all elements including locked ones", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
groupIds: ["g1"],
|
||||
x: 200,
|
||||
y: 200,
|
||||
});
|
||||
|
||||
h.elements = [rectangle, lockedRectangle];
|
||||
|
||||
mouse.clickAt(250, 250);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
|
||||
mouse.clickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(2);
|
||||
});
|
||||
|
||||
it("should ignore locked text element in center of container on ENTER", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
locked: true,
|
||||
});
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.state.editingElement?.id).not.toBe(text.id);
|
||||
expect(h.state.editingElement?.id).toBe(h.elements[1].id);
|
||||
});
|
||||
|
||||
it("should ignore locked text under cursor when clicked with text tool", () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: true,
|
||||
});
|
||||
h.elements = [text];
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.state.editingElement?.id).toBe(h.elements[1].id);
|
||||
});
|
||||
|
||||
it("should ignore text under cursor when double-clicked with selection tool", () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: true,
|
||||
});
|
||||
h.elements = [text];
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.state.editingElement?.id).toBe(h.elements[1].id);
|
||||
});
|
||||
|
||||
it("locking should include bound text", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
|
||||
UI.clickTool("selection");
|
||||
mouse.clickAt(container.x + 10, container.y + 10);
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.L);
|
||||
});
|
||||
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
locked: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: text.id,
|
||||
locked: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("bound text shouldn't be editable via double-click", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
locked: true,
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
locked: true,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
});
|
||||
h.elements = [container, text];
|
||||
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(container.width / 2, container.height / 2);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.state.editingElement?.id).toBe(h.elements[2].id);
|
||||
});
|
||||
|
||||
it("bound text shouldn't be editable via text tool", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
locked: true,
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
locked: true,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
});
|
||||
h.elements = [container, text];
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(container.width / 2, container.height / 2);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.state.editingElement?.id).toBe(h.elements[2].id);
|
||||
});
|
||||
});
|
409
packages/excalidraw/tests/excalidraw.test.tsx
Normal file
|
@ -0,0 +1,409 @@
|
|||
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
|
||||
import { Excalidraw, Footer, MainMenu } from "../index";
|
||||
import { queryByText, queryByTestId } from "@testing-library/react";
|
||||
import { GRID_SIZE, THEME } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("<Excalidraw/>", () => {
|
||||
afterEach(() => {
|
||||
const menu = document.querySelector(".dropdown-menu");
|
||||
if (menu) {
|
||||
toggleMenu(document.querySelector(".excalidraw")!);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Test zenModeEnabled prop", () => {
|
||||
it('should show exit zen mode button when zen mode is set and zen mode option in context menu when zenModeEnabled is "undefined"', async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
expect(h.state.zenModeEnabled).toBe(false);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Zen mode")!);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("should not show exit zen mode button and zen mode option in context menu when zenModeEnabled is set", async () => {
|
||||
const { container } = await render(<Excalidraw zenModeEnabled={true} />);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
expect(queryByText(contextMenu as HTMLElement, "Zen mode")).toBe(null);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the footer only when Footer is passed as children", async () => {
|
||||
//Footer not passed hence it will not render the footer
|
||||
let { container } = await render(
|
||||
<Excalidraw>
|
||||
<div>This is a custom footer</div>
|
||||
</Excalidraw>,
|
||||
);
|
||||
expect(container.querySelector(".footer-center")).toBe(null);
|
||||
|
||||
// Footer passed hence it will render the footer
|
||||
({ container } = await render(
|
||||
<Excalidraw>
|
||||
<Footer>
|
||||
<div>This is a custom footer</div>
|
||||
</Footer>
|
||||
</Excalidraw>,
|
||||
));
|
||||
expect(container.querySelector(".footer-center")).toMatchInlineSnapshot(
|
||||
`
|
||||
<div
|
||||
class="footer-center zen-mode-transition"
|
||||
>
|
||||
<div>
|
||||
This is a custom footer
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Test gridModeEnabled prop", () => {
|
||||
it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
expect(h.state.gridSize).toBe(null);
|
||||
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Show grid")!);
|
||||
expect(h.state.gridSize).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it('should not show grid mode in context menu when gridModeEnabled is not "undefined"', async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw gridModeEnabled={false} />,
|
||||
);
|
||||
expect(h.state.gridSize).toBe(null);
|
||||
|
||||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
expect(queryByText(contextMenu as HTMLElement, "Show grid")).toBe(null);
|
||||
expect(h.state.gridSize).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test UIOptions prop", () => {
|
||||
describe("Test canvasActions", () => {
|
||||
it('should render menu with default items when "UIOPtions" is "undefined"', async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={undefined} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should hide clear canvas button when clearCanvas is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide export button when export is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "json-export-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide 'Save as image' button when 'saveAsImage' is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "image-export-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide load button when loadScene is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ canvasActions: { loadScene: false } }} />,
|
||||
);
|
||||
|
||||
expect(queryByTestId(container, "load-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide save as button when saveFileToDisk is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
UIOptions={{ canvasActions: { export: { saveFileToDisk: false } } }}
|
||||
/>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "save-as-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide save button when saveToActiveFile is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
UIOptions={{ canvasActions: { saveToActiveFile: false } }}
|
||||
/>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "save-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide the canvas background picker when changeViewBackgroundColor is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
UIOptions={{ canvasActions: { changeViewBackgroundColor: false } }}
|
||||
/>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
||||
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide the canvas background picker even if passed if the `canvasActions.changeViewBackgroundColor` is set to false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
UIOptions={{ canvasActions: { changeViewBackgroundColor: false } }}
|
||||
>
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
</Excalidraw>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
||||
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
||||
});
|
||||
|
||||
it("should hide the theme toggle when theme is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
|
||||
});
|
||||
|
||||
it("should not render default items in custom menu even if passed if the prop in `canvasActions` is set to false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ canvasActions: { loadScene: false } }}>
|
||||
<MainMenu>
|
||||
<MainMenu.ItemCustom>
|
||||
<button
|
||||
style={{ height: "2rem" }}
|
||||
onClick={() => window.alert("custom menu item")}
|
||||
>
|
||||
custom item
|
||||
</button>
|
||||
</MainMenu.ItemCustom>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
</MainMenu>
|
||||
</Excalidraw>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
|
||||
expect(queryByTestId(container, "load-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test theme prop", () => {
|
||||
it("should show the theme toggle by default", async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not show theme toggle when the theme prop is defined", async () => {
|
||||
const { container } = await render(<Excalidraw theme={THEME.DARK} />);
|
||||
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
|
||||
});
|
||||
|
||||
it("should show theme mode toggle when `UIOptions.canvasActions.toggleTheme` is true", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
theme={THEME.DARK}
|
||||
UIOptions={{ canvasActions: { toggleTheme: true } }}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not show theme toggle when `UIOptions.canvasActions.toggleTheme` is false", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
UIOptions={{ canvasActions: { toggleTheme: false } }}
|
||||
theme={THEME.DARK}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test name prop", () => {
|
||||
it('should allow editing name when the name prop is "undefined"', async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
fireEvent.click(queryByTestId(container, "image-export-button")!);
|
||||
const textInput: HTMLInputElement | null = document.querySelector(
|
||||
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
||||
);
|
||||
expect(textInput?.value).toContain(`${t("labels.untitled")}`);
|
||||
expect(textInput?.nodeName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it('should set the name and not allow editing when the name prop is present"', async () => {
|
||||
const name = "test";
|
||||
const { container } = await render(<Excalidraw name={name} />);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
await fireEvent.click(queryByTestId(container, "image-export-button")!);
|
||||
const textInput = document.querySelector(
|
||||
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
||||
) as HTMLInputElement;
|
||||
expect(textInput?.value).toEqual(name);
|
||||
expect(textInput?.nodeName).toBe("INPUT");
|
||||
expect(textInput?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test autoFocus prop", () => {
|
||||
it("should not focus when autoFocus is false", async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
|
||||
expect(
|
||||
container.querySelector(".excalidraw") === document.activeElement,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should focus when autoFocus is true", async () => {
|
||||
const { container } = await render(<Excalidraw autoFocus={true} />);
|
||||
|
||||
expect(
|
||||
container.querySelector(".excalidraw") === document.activeElement,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MainMenu/>", () => {
|
||||
it("should render main menu with host menu items if passed from host", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw>
|
||||
<MainMenu>
|
||||
<MainMenu.Item onSelect={() => window.alert("Clicked")}>
|
||||
Click me
|
||||
</MainMenu.Item>
|
||||
<MainMenu.ItemLink href="blog.excalidaw.com">
|
||||
Excalidraw blog
|
||||
</MainMenu.ItemLink>
|
||||
<MainMenu.ItemCustom>
|
||||
<button
|
||||
style={{ height: "2rem" }}
|
||||
onClick={() => window.alert("custom menu item")}
|
||||
>
|
||||
custom menu item
|
||||
</button>
|
||||
</MainMenu.ItemCustom>
|
||||
<MainMenu.DefaultItems.Help />
|
||||
</MainMenu>
|
||||
</Excalidraw>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should update themeToggle text even if MainMenu memoized", async () => {
|
||||
const CustomExcalidraw = () => {
|
||||
const customMenu = useMemo(() => {
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
</MainMenu>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return <Excalidraw>{customMenu}</Excalidraw>;
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
|
||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||
|
||||
expect(
|
||||
queryByTestId(container, "toggle-dark-mode")?.textContent,
|
||||
).toContain(t("buttons.darkMode"));
|
||||
|
||||
fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
|
||||
|
||||
expect(
|
||||
queryByTestId(container, "toggle-dark-mode")?.textContent,
|
||||
).toContain(t("buttons.lightMode"));
|
||||
});
|
||||
});
|
||||
});
|
178
packages/excalidraw/tests/export.test.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { render, waitFor } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import {
|
||||
encodePngMetadata,
|
||||
encodeSvgMetadata,
|
||||
decodeSvgMetadata,
|
||||
} from "../data/image";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { FileId } from "../element/types";
|
||||
import { getDataURL } from "../data/blob";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const testElements = [
|
||||
{
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "A",
|
||||
text: "😀",
|
||||
}),
|
||||
// can't get jsdom text measurement to work so this is a temp hack
|
||||
// to ensure the element isn't stripped as invisible
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
];
|
||||
|
||||
// tiny polyfill for TextDecoder.decode on which we depend
|
||||
Object.defineProperty(window, "TextDecoder", {
|
||||
value: class TextDecoder {
|
||||
decode(ab: ArrayBuffer) {
|
||||
return new Uint8Array(ab).reduce(
|
||||
(acc, c) => acc + String.fromCharCode(c),
|
||||
"",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
describe("export", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("export embedded png and reimport", async () => {
|
||||
const pngBlob = await API.loadFile("./fixtures/smiley.png");
|
||||
const pngBlobEmbedded = await encodePngMetadata({
|
||||
blob: pngBlob,
|
||||
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||
});
|
||||
API.drop(pngBlobEmbedded);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("test encoding/decoding scene for SVG export", async () => {
|
||||
const encoded = await encodeSvgMetadata({
|
||||
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||
});
|
||||
const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded }));
|
||||
expect(decoded.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("import embedded png (legacy v1)", async () => {
|
||||
API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "test" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("import embedded png (v2)", async () => {
|
||||
API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("import embedded svg (legacy v1)", async () => {
|
||||
API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "test" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("import embedded svg (v2)", async () => {
|
||||
API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("exporting svg containing transformed images", async () => {
|
||||
const normalizeAngle = (angle: number) => (angle / 180) * Math.PI;
|
||||
|
||||
const elements = [
|
||||
API.createElement({
|
||||
type: "image",
|
||||
fileId: "file_A",
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: [1, 1],
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: normalizeAngle(315),
|
||||
}),
|
||||
API.createElement({
|
||||
type: "image",
|
||||
fileId: "file_A",
|
||||
x: 100,
|
||||
y: 0,
|
||||
scale: [-1, 1],
|
||||
width: 50,
|
||||
height: 50,
|
||||
angle: normalizeAngle(45),
|
||||
}),
|
||||
API.createElement({
|
||||
type: "image",
|
||||
fileId: "file_A",
|
||||
x: 0,
|
||||
y: 100,
|
||||
scale: [1, -1],
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: normalizeAngle(45),
|
||||
}),
|
||||
API.createElement({
|
||||
type: "image",
|
||||
fileId: "file_A",
|
||||
x: 100,
|
||||
y: 100,
|
||||
scale: [-1, -1],
|
||||
width: 50,
|
||||
height: 50,
|
||||
angle: normalizeAngle(315),
|
||||
}),
|
||||
];
|
||||
const appState = { ...getDefaultAppState(), exportBackground: false };
|
||||
const files = {
|
||||
file_A: {
|
||||
id: "file_A" as FileId,
|
||||
dataURL: await getDataURL(await API.loadFile("./fixtures/deer.png")),
|
||||
mimeType: "image/png",
|
||||
created: Date.now(),
|
||||
lastRetrieved: Date.now(),
|
||||
},
|
||||
} as const;
|
||||
|
||||
const svg = await exportToSvg(elements, appState, files);
|
||||
|
||||
const svgText = svg.outerHTML;
|
||||
|
||||
// expect 1 <image> element (deduped)
|
||||
expect(svgText.match(/<image/g)?.length).toBe(1);
|
||||
// expect 4 <use> elements (one for each excalidraw image element)
|
||||
expect(svgText.match(/<use/g)?.length).toBe(4);
|
||||
|
||||
// in case of regressions, save the SVG to a file and visually compare to:
|
||||
// src/tests/fixtures/svg-image-exporting-reference.svg
|
||||
expect(svgText).toMatchSnapshot(`svg export output`);
|
||||
});
|
||||
});
|
177
packages/excalidraw/tests/fitToContent.test.tsx
Normal file
|
@ -0,0 +1,177 @@
|
|||
import { render } from "./test-utils";
|
||||
import { API } from "./helpers/api";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("fitToContent", () => {
|
||||
it("should zoom to fit the selected element", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 50,
|
||||
height: 100,
|
||||
x: 50,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
h.app.scrollToContent(rectElement, { fitToContent: true });
|
||||
|
||||
// element is 10x taller than the viewport size,
|
||||
// zoom should be at least 1/10
|
||||
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
|
||||
});
|
||||
|
||||
it("should zoom to fit multiple elements", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
const topLeft = API.createElement({
|
||||
width: 20,
|
||||
height: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const bottomRight = API.createElement({
|
||||
width: 20,
|
||||
height: 20,
|
||||
x: 80,
|
||||
y: 80,
|
||||
});
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
h.app.scrollToContent([topLeft, bottomRight], {
|
||||
fitToContent: true,
|
||||
});
|
||||
|
||||
// elements take 100x100, which is 10x bigger than the viewport size,
|
||||
// zoom should be at least 1/10
|
||||
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
|
||||
});
|
||||
|
||||
it("should scroll the viewport to the selected element", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
h.app.scrollToContent(rectElement);
|
||||
|
||||
// zoom level should stay the same
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
// state should reflect some scrolling
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const waitForNextAnimationFrame = () => {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("fitToContent animated", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, "requestAnimationFrame");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should ease scroll the viewport to the selected element", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: -100,
|
||||
y: -100,
|
||||
});
|
||||
|
||||
h.app.scrollToContent(rectElement, { animate: true });
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
||||
|
||||
// Since this is an animation, we expect values to change through time.
|
||||
// We'll verify that the scroll values change at 50ms and 100ms
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
const prevScrollX = h.state.scrollX;
|
||||
const prevScrollY = h.state.scrollY;
|
||||
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||
});
|
||||
|
||||
it("should animate the scroll but not the zoom", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 50;
|
||||
h.state.height = 50;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
const prevScrollX = h.state.scrollX;
|
||||
const prevScrollY = h.state.scrollY;
|
||||
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||
});
|
||||
});
|
BIN
packages/excalidraw/tests/fixtures/deer.png
vendored
Normal file
After Width: | Height: | Size: 12 KiB |
33
packages/excalidraw/tests/fixtures/diagramFixture.ts
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { VERSIONS } from "../../constants";
|
||||
import {
|
||||
diamondFixture,
|
||||
ellipseFixture,
|
||||
rectangleFixture,
|
||||
} from "./elementFixture";
|
||||
|
||||
export const diagramFixture = {
|
||||
type: "excalidraw",
|
||||
version: VERSIONS.excalidraw,
|
||||
source: "https://excalidraw.com",
|
||||
elements: [diamondFixture, ellipseFixture, rectangleFixture],
|
||||
appState: {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
gridSize: null,
|
||||
},
|
||||
files: {},
|
||||
};
|
||||
|
||||
export const diagramFactory = ({
|
||||
overrides = {},
|
||||
elementOverrides = {},
|
||||
} = {}) => ({
|
||||
...diagramFixture,
|
||||
elements: [
|
||||
{ ...diamondFixture, ...elementOverrides },
|
||||
{ ...ellipseFixture, ...elementOverrides },
|
||||
{ ...rectangleFixture, ...elementOverrides },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export default diagramFixture;
|
51
packages/excalidraw/tests/fixtures/elementFixture.ts
vendored
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { ExcalidrawElement } from "../../element/types";
|
||||
|
||||
const elementBase: Omit<ExcalidrawElement, "type"> = {
|
||||
id: "vWrqOAfkind2qcm7LDAGZ",
|
||||
x: 414,
|
||||
y: 237,
|
||||
width: 214,
|
||||
height: 214,
|
||||
angle: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#15aabf",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
seed: 1041657908,
|
||||
version: 120,
|
||||
versionNonce: 1188004276,
|
||||
isDeleted: false,
|
||||
boundElements: null,
|
||||
updated: 1,
|
||||
link: null,
|
||||
locked: false,
|
||||
};
|
||||
|
||||
export const rectangleFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "rectangle",
|
||||
};
|
||||
export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "ellipse",
|
||||
};
|
||||
export const diamondFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "diamond",
|
||||
};
|
||||
export const rectangleWithLinkFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "rectangle",
|
||||
link: "excalidraw.com",
|
||||
};
|
31
packages/excalidraw/tests/fixtures/fixture_library.excalidrawlib
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"type": "excalidrawlib",
|
||||
"version": 1,
|
||||
"library": [
|
||||
[
|
||||
{
|
||||
"type": "rectangle",
|
||||
"version": 38,
|
||||
"versionNonce": 1046419680,
|
||||
"isDeleted": false,
|
||||
"id": "A",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"angle": 0,
|
||||
"x": 21801,
|
||||
"y": 719.5,
|
||||
"strokeColor": "#c92a2a",
|
||||
"backgroundColor": "#e64980",
|
||||
"width": 50,
|
||||
"height": 30,
|
||||
"seed": 117297479,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"boundElementIds": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
BIN
packages/excalidraw/tests/fixtures/smiley.png
vendored
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/excalidraw/tests/fixtures/smiley_embedded_v2.png
vendored
Normal file
After Width: | Height: | Size: 2 KiB |
16
packages/excalidraw/tests/fixtures/smiley_embedded_v2.svg
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 77">
|
||||
<!-- svg-source:excalidraw -->
|
||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nGVSy07jMFx1MDAxNN3zXHUwMDE1kdmOIE1IXztcdTAwMWVcdTAwMDNiw6ZcdTAwMTKjXHUwMDExXHUwMDFhjUxym1hcdTAwMTjbsm9pU4TEZ8xufpFPwNcpcVqyiHTPfVx1MDAxZJ9zX4+ShGFrgM1cdTAwMTNcdTAwMDabkktRWb5mP1xif1x1MDAwMeuEVj6VhdjplS1DZYNo5qenUvuGRjuc52madk0g4Vx1MDAxOVx1MDAxNDpf9uDjJHlccn+fXHUwMDExXHUwMDE1tS4up2Urr2/M9lwivV1v26vf8Pc+tIaiLy5cYlx1MDAxYozoxkPT6biPW1x1MDAxZufF5KTokbWosCE0XHUwMDE2NSDqXHUwMDA2PVZMeoyrWtL8tEdcdTAwMWNa/Vx1MDAwNJdaakt7j8tZxjNcdTAwMWVXP/LyqbZ6paq+XHUwMDA2LVfOcOufXHUwMDE565ZCylx1MDAwNbay04eXzcpcdTAwMDI72PJrR3J0gPd9Tnv9Y5dfWzdcbpzb69GGl1x1MDAwMkmCUVx1MDAxYd9BXHUwMDFjzW1cdTAwMTV0/3M4v+HW7OYwR8GAXHUwMDE5XHUwMDAw+ZJl6WSSj2dxzcD9/Fx1MDAxMLzTKlx1MDAxY0IxK/JiXHUwMDFj08Jdef8xTFxccukgykhcbv7sbqNjqVZSRtvJbk/u4/+/94GmWuFCbGHfV0Kv+bOQ7Z4sNOJcXIqaXHUwMDE4M1x0y4E3njVcbn+qfVx1MDAxYbVcdTAwMTk67EBcbkVbzkZcdTAwMDF88/+gIePGLJAj5bo7Zi9cdTAwMDLWXHUwMDE332/ieFx1MDAxOb7dVO+GqHbM6Z1HNPPtXHUwMDEz+I3nwSJ9<!-- payload-end -->
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("https://excalidraw.com/Virgil.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cascadia";
|
||||
src: url("https://excalidraw.com/Cascadia.woff2");
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="56" height="77" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 18 28.5)"><text x="0" y="41" font-family="Virgil, Segoe UI Emoji" font-size="36px" fill="#c92a2a" text-anchor="start" style="white-space: pre;" direction="ltr">😀</text></g></svg>
|
After Width: | Height: | Size: 1.7 KiB |
16
packages/excalidraw/tests/fixtures/svg-image-exporting-reference.svg
vendored
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
packages/excalidraw/tests/fixtures/test_embedded_v1.png
vendored
Normal file
After Width: | Height: | Size: 1.7 KiB |
16
packages/excalidraw/tests/fixtures/test_embedded_v1.svg
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 97 77">
|
||||
<!-- svg-source:excalidraw -->
|
||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-start -->ewogICJ0eXBlIjogImV4Y2FsaWRyYXciLAogICJ2ZXJzaW9uIjogMiwKICAic291cmNlIjogImh0dHBzOi8vZXhjYWxpZHJhdy5jb20iLAogICJlbGVtZW50cyI6IFsKICAgIHsKICAgICAgImlkIjogInRabVFwa0cyQlZ2SzNxT01icHVXeiIsCiAgICAgICJ0eXBlIjogInRleHQiLAogICAgICAieCI6IDg2MS4xMTExMTExMTExMTExLAogICAgICAieSI6IDM1Ni4zMzMzMzMzMzMzMzMzLAogICAgICAid2lkdGgiOiA3NywKICAgICAgImhlaWdodCI6IDU3LAogICAgICAiYW5nbGUiOiAwLAogICAgICAic3Ryb2tlQ29sb3IiOiAiIzAwMDAwMCIsCiAgICAgICJiYWNrZ3JvdW5kQ29sb3IiOiAiIzg2OGU5NiIsCiAgICAgICJmaWxsU3R5bGUiOiAiY3Jvc3MtaGF0Y2giLAogICAgICAic3Ryb2tlV2lkdGgiOiAyLAogICAgICAic3Ryb2tlU3R5bGUiOiAic29saWQiLAogICAgICAicm91Z2huZXNzIjogMSwKICAgICAgIm9wYWNpdHkiOiAxMDAsCiAgICAgICJncm91cElkcyI6IFtdLAogICAgICAic3Ryb2tlU2hhcnBuZXNzIjogInJvdW5kIiwKICAgICAgInNlZWQiOiA0NzYzNjM3OTMsCiAgICAgICJ2ZXJzaW9uIjogMjMsCiAgICAgICJ2ZXJzaW9uTm9uY2UiOiA1OTc0MzUxMzUsCiAgICAgICJpc0RlbGV0ZWQiOiBmYWxzZSwKICAgICAgImJvdW5kRWxlbWVudElkcyI6IG51bGwsCiAgICAgICJ0ZXh0IjogInRlc3QiLAogICAgICAiZm9udFNpemUiOiAzNiwKICAgICAgImZvbnRGYW1pbHkiOiAxLAogICAgICAidGV4dEFsaWduIjogImxlZnQiLAogICAgICAidmVydGljYWxBbGlnbiI6ICJ0b3AiLAogICAgICAiYmFzZWxpbmUiOiA0MQogICAgfQogIF0sCiAgImFwcFN0YXRlIjogewogICAgInZpZXdCYWNrZ3JvdW5kQ29sb3IiOiAiI2ZmZmZmZiIsCiAgICAiZ3JpZFNpemUiOiBudWxsCiAgfQp9<!-- payload-end -->
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("https://excalidraw.com/Virgil.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cascadia";
|
||||
src: url("https://excalidraw.com/Cascadia.woff2");
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="97" height="77" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 38.5 28.5)"><text x="0" y="41" font-family="Virgil, Segoe UI Emoji" font-size="36px" fill="#000000" text-anchor="start" style="white-space: pre;" direction="ltr">test</text></g></svg>
|
After Width: | Height: | Size: 1.9 KiB |
882
packages/excalidraw/tests/flip.test.tsx
Normal file
|
@ -0,0 +1,882 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FileId,
|
||||
} from "../element/types";
|
||||
import { newLinearElement } from "../element";
|
||||
import { Excalidraw } from "../index";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { vi } from "vitest";
|
||||
import * as blob from "../data/blob";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElementPosition } from "../element/textElement";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
import { cloneJSON } from "../utils";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
// This needs to fixed in vitest mock, as when importActual used with mock
|
||||
// the tests hangs - https://github.com/vitest-dev/vitest/issues/546.
|
||||
// But fortunately spying and mocking the return value of spy works :p
|
||||
|
||||
const resizeImageFileSpy = vi.spyOn(blob, "resizeImageFile");
|
||||
const generateIdFromFileSpy = vi.spyOn(blob, "generateIdFromFile");
|
||||
|
||||
resizeImageFileSpy.mockImplementation(async (imageFile: File) => imageFile);
|
||||
generateIdFromFileSpy.mockImplementation(async () => "fileId" as FileId);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
mouse.reset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
h.setState({
|
||||
zoom: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const createAndSelectOneRectangle = (angle: number = 0) => {
|
||||
UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
angle,
|
||||
});
|
||||
};
|
||||
|
||||
const createAndSelectOneDiamond = (angle: number = 0) => {
|
||||
UI.createElement("diamond", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
angle,
|
||||
});
|
||||
};
|
||||
|
||||
const createAndSelectOneEllipse = (angle: number = 0) => {
|
||||
UI.createElement("ellipse", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
angle,
|
||||
});
|
||||
};
|
||||
|
||||
const createAndSelectOneArrow = (angle: number = 0) => {
|
||||
UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
angle,
|
||||
});
|
||||
};
|
||||
|
||||
const createAndSelectOneLine = (angle: number = 0) => {
|
||||
UI.createElement("line", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
angle,
|
||||
});
|
||||
};
|
||||
|
||||
const createAndReturnOneDraw = (angle: number = 0) => {
|
||||
return UI.createElement("freedraw", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 100,
|
||||
angle,
|
||||
});
|
||||
};
|
||||
|
||||
const createLinearElementWithCurveInsideMinMaxPoints = (
|
||||
type: "line" | "arrow",
|
||||
extraProps: any = {},
|
||||
) => {
|
||||
return newLinearElement({
|
||||
type,
|
||||
x: 2256.910668124894,
|
||||
y: -2412.5069664197654,
|
||||
width: 1750.4888916015625,
|
||||
height: 410.51605224609375,
|
||||
angle: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#fa5252",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
boundElements: null,
|
||||
link: null,
|
||||
locked: false,
|
||||
points: [
|
||||
[0, 0],
|
||||
[-922.4761962890625, 300.3277587890625],
|
||||
[828.0126953125, 410.51605224609375],
|
||||
],
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
});
|
||||
};
|
||||
|
||||
const createLinearElementsWithCurveOutsideMinMaxPoints = (
|
||||
type: "line" | "arrow",
|
||||
extraProps: any = {},
|
||||
) => {
|
||||
return newLinearElement({
|
||||
type,
|
||||
x: -1388.6555370382996,
|
||||
y: 1037.698247710191,
|
||||
width: 591.2804897585779,
|
||||
height: 69.32871961377737,
|
||||
angle: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
boundElements: null,
|
||||
link: null,
|
||||
locked: false,
|
||||
points: [
|
||||
[0, 0],
|
||||
[-584.1485186423079, -15.365636022723947],
|
||||
[-591.2804897585779, 36.09360810181511],
|
||||
[-148.56510566829502, 53.96308359105342],
|
||||
],
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
...extraProps,
|
||||
});
|
||||
};
|
||||
|
||||
const checkElementsBoundingBox = async (
|
||||
element1: ExcalidrawElement,
|
||||
element2: ExcalidrawElement,
|
||||
toleranceInPx: number = 0,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1);
|
||||
|
||||
const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2);
|
||||
|
||||
debugger;
|
||||
await waitFor(() => {
|
||||
// Check if width and height did not change
|
||||
expect(x2 - x1).toBeCloseTo(x22 - x12, -1);
|
||||
expect(y2 - y1).toBeCloseTo(y22 - y12, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0];
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const checkTwoPointsLineHorizontalFlip = async () => {
|
||||
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
||||
await waitFor(() => {
|
||||
expect(originalElement.points[0][0]).toBeCloseTo(
|
||||
-newElement.points[0][0],
|
||||
5,
|
||||
);
|
||||
expect(originalElement.points[0][1]).toBeCloseTo(
|
||||
newElement.points[0][1],
|
||||
5,
|
||||
);
|
||||
expect(originalElement.points[1][0]).toBeCloseTo(
|
||||
-newElement.points[1][0],
|
||||
5,
|
||||
);
|
||||
expect(originalElement.points[1][1]).toBeCloseTo(
|
||||
newElement.points[1][1],
|
||||
5,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const checkTwoPointsLineVerticalFlip = async () => {
|
||||
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
||||
await waitFor(() => {
|
||||
expect(originalElement.points[0][0]).toBeCloseTo(
|
||||
newElement.points[0][0],
|
||||
5,
|
||||
);
|
||||
expect(originalElement.points[0][1]).toBeCloseTo(
|
||||
-newElement.points[0][1],
|
||||
5,
|
||||
);
|
||||
expect(originalElement.points[1][0]).toBeCloseTo(
|
||||
newElement.points[1][0],
|
||||
5,
|
||||
);
|
||||
expect(originalElement.points[1][1]).toBeCloseTo(
|
||||
-newElement.points[1][1],
|
||||
5,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const checkRotatedHorizontalFlip = async (
|
||||
expectedAngle: number,
|
||||
toleranceInPx: number = 0.00001,
|
||||
) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0];
|
||||
await waitFor(() => {
|
||||
expect(newElement.angle).toBeCloseTo(expectedAngle);
|
||||
});
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const checkRotatedVerticalFlip = async (
|
||||
expectedAngle: number,
|
||||
toleranceInPx: number = 0.00001,
|
||||
) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
const newElement = h.elements[0];
|
||||
await waitFor(() => {
|
||||
expect(newElement.angle).toBeCloseTo(expectedAngle);
|
||||
});
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
|
||||
const newElement = h.elements[0];
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
|
||||
const newElement = h.elements[0];
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS = 5;
|
||||
const MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS = 20;
|
||||
|
||||
// Rectangle element
|
||||
describe("rectangle", () => {
|
||||
it("flips an unrotated rectangle horizontally correctly", async () => {
|
||||
createAndSelectOneRectangle();
|
||||
|
||||
await checkHorizontalFlip();
|
||||
});
|
||||
|
||||
it("flips an unrotated rectangle vertically correctly", async () => {
|
||||
createAndSelectOneRectangle();
|
||||
|
||||
await checkVerticalFlip();
|
||||
});
|
||||
|
||||
it("flips a rotated rectangle horizontally correctly", async () => {
|
||||
const originalAngle = (3 * Math.PI) / 4;
|
||||
const expectedAngle = (5 * Math.PI) / 4;
|
||||
|
||||
createAndSelectOneRectangle(originalAngle);
|
||||
|
||||
await checkRotatedHorizontalFlip(expectedAngle);
|
||||
});
|
||||
|
||||
it("flips a rotated rectangle vertically correctly", async () => {
|
||||
const originalAngle = (3 * Math.PI) / 4;
|
||||
const expectedAgnle = (5 * Math.PI) / 4;
|
||||
|
||||
createAndSelectOneRectangle(originalAngle);
|
||||
|
||||
await checkRotatedVerticalFlip(expectedAgnle);
|
||||
});
|
||||
});
|
||||
|
||||
// Diamond element
|
||||
describe("diamond", () => {
|
||||
it("flips an unrotated diamond horizontally correctly", async () => {
|
||||
createAndSelectOneDiamond();
|
||||
|
||||
await checkHorizontalFlip();
|
||||
});
|
||||
|
||||
it("flips an unrotated diamond vertically correctly", async () => {
|
||||
createAndSelectOneDiamond();
|
||||
|
||||
await checkVerticalFlip();
|
||||
});
|
||||
|
||||
it("flips a rotated diamond horizontally correctly", async () => {
|
||||
const originalAngle = (5 * Math.PI) / 4;
|
||||
const expectedAngle = (3 * Math.PI) / 4;
|
||||
|
||||
createAndSelectOneDiamond(originalAngle);
|
||||
|
||||
await checkRotatedHorizontalFlip(expectedAngle);
|
||||
});
|
||||
|
||||
it("flips a rotated diamond vertically correctly", async () => {
|
||||
const originalAngle = (5 * Math.PI) / 4;
|
||||
const expectedAngle = (3 * Math.PI) / 4;
|
||||
|
||||
createAndSelectOneDiamond(originalAngle);
|
||||
|
||||
await checkRotatedVerticalFlip(expectedAngle);
|
||||
});
|
||||
});
|
||||
|
||||
// Ellipse element
|
||||
describe("ellipse", () => {
|
||||
it("flips an unrotated ellipse horizontally correctly", async () => {
|
||||
createAndSelectOneEllipse();
|
||||
|
||||
await checkHorizontalFlip();
|
||||
});
|
||||
|
||||
it("flips an unrotated ellipse vertically correctly", async () => {
|
||||
createAndSelectOneEllipse();
|
||||
|
||||
await checkVerticalFlip();
|
||||
});
|
||||
|
||||
it("flips a rotated ellipse horizontally correctly", async () => {
|
||||
const originalAngle = (7 * Math.PI) / 4;
|
||||
const expectedAngle = Math.PI / 4;
|
||||
|
||||
createAndSelectOneEllipse(originalAngle);
|
||||
|
||||
await checkRotatedHorizontalFlip(expectedAngle);
|
||||
});
|
||||
|
||||
it("flips a rotated ellipse vertically correctly", async () => {
|
||||
const originalAngle = (7 * Math.PI) / 4;
|
||||
const expectedAngle = Math.PI / 4;
|
||||
|
||||
createAndSelectOneEllipse(originalAngle);
|
||||
|
||||
await checkRotatedVerticalFlip(expectedAngle);
|
||||
});
|
||||
});
|
||||
|
||||
// Arrow element
|
||||
describe("arrow", () => {
|
||||
it("flips an unrotated arrow horizontally with line inside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
await checkHorizontalFlip(
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips an unrotated arrow vertically with line inside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
|
||||
await checkVerticalFlip(50);
|
||||
});
|
||||
|
||||
it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
};
|
||||
mutateElement(line, {
|
||||
angle: originalAngle,
|
||||
});
|
||||
|
||||
await checkRotatedHorizontalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips a rotated arrow vertically with line inside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
};
|
||||
mutateElement(line, {
|
||||
angle: originalAngle,
|
||||
});
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||
it.skip("flips an unrotated arrow horizontally with line outside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
|
||||
await checkHorizontalFlip(
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||
it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||
it.skip("flips an unrotated arrow vertically with line outside min/max points bounds", async () => {
|
||||
const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
h.app.scene.replaceAllElements([arrow]);
|
||||
h.app.setState({ selectedElementIds: { [arrow.id]: true } });
|
||||
|
||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
|
||||
it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips an unrotated arrow horizontally correctly", async () => {
|
||||
createAndSelectOneArrow();
|
||||
await checkHorizontalFlip(
|
||||
TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips an unrotated arrow vertically correctly", async () => {
|
||||
createAndSelectOneArrow();
|
||||
await checkVerticalFlip(TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
});
|
||||
|
||||
it("flips a two points arrow horizontally correctly", async () => {
|
||||
createAndSelectOneArrow();
|
||||
await checkTwoPointsLineHorizontalFlip();
|
||||
});
|
||||
|
||||
it("flips a two points arrow vertically correctly", async () => {
|
||||
createAndSelectOneArrow();
|
||||
await checkTwoPointsLineVerticalFlip();
|
||||
});
|
||||
});
|
||||
|
||||
// Line element
|
||||
describe("line", () => {
|
||||
it("flips an unrotated line horizontally with line inside min/max points bounds", async () => {
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkHorizontalFlip(
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips an unrotated line vertically with line inside min/max points bounds", async () => {
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
});
|
||||
|
||||
it("flips an unrotated line horizontally correctly", async () => {
|
||||
createAndSelectOneLine();
|
||||
await checkHorizontalFlip(
|
||||
TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||
it.skip("flips an unrotated line horizontally with line outside min/max points bounds", async () => {
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkHorizontalFlip(
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||
it.skip("flips an unrotated line vertically with line outside min/max points bounds", async () => {
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||
it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedHorizontalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
//TODO: elements with curve outside minMax points have a wrong bounding box
|
||||
it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
mutateElement(line, { angle: originalAngle });
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.app.setState({ selectedElementIds: { [line.id]: true } });
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips an unrotated line vertically correctly", async () => {
|
||||
createAndSelectOneLine();
|
||||
await checkVerticalFlip(TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
|
||||
});
|
||||
|
||||
it("flips a rotated line horizontally with line inside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
};
|
||||
mutateElement(line, {
|
||||
angle: originalAngle,
|
||||
});
|
||||
|
||||
await checkRotatedHorizontalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips a rotated line vertically with line inside min/max points bounds", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
h.app.scene.replaceAllElements([line]);
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
};
|
||||
mutateElement(line, {
|
||||
angle: originalAngle,
|
||||
});
|
||||
|
||||
await checkRotatedVerticalFlip(
|
||||
expectedAngle,
|
||||
MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("flips a two points line horizontally correctly", async () => {
|
||||
createAndSelectOneLine();
|
||||
await checkTwoPointsLineHorizontalFlip();
|
||||
});
|
||||
|
||||
it("flips a two points line vertically correctly", async () => {
|
||||
createAndSelectOneLine();
|
||||
await checkTwoPointsLineVerticalFlip();
|
||||
});
|
||||
});
|
||||
|
||||
// Draw element
|
||||
describe("freedraw", () => {
|
||||
it("flips an unrotated drawing horizontally correctly", async () => {
|
||||
const draw = createAndReturnOneDraw();
|
||||
// select draw, since not done automatically
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[draw.id]: true,
|
||||
};
|
||||
await checkHorizontalFlip();
|
||||
});
|
||||
|
||||
it("flips an unrotated drawing vertically correctly", async () => {
|
||||
const draw = createAndReturnOneDraw();
|
||||
// select draw, since not done automatically
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[draw.id]: true,
|
||||
};
|
||||
await checkVerticalFlip();
|
||||
});
|
||||
|
||||
it("flips a rotated drawing horizontally correctly", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
|
||||
const draw = createAndReturnOneDraw(originalAngle);
|
||||
// select draw, since not done automatically
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[draw.id]: true,
|
||||
};
|
||||
|
||||
await checkRotatedHorizontalFlip(expectedAngle);
|
||||
});
|
||||
|
||||
it("flips a rotated drawing vertically correctly", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
|
||||
const draw = createAndReturnOneDraw(originalAngle);
|
||||
// select draw, since not done automatically
|
||||
h.state.selectedElementIds = {
|
||||
...h.state.selectedElementIds,
|
||||
[draw.id]: true,
|
||||
};
|
||||
|
||||
await checkRotatedVerticalFlip(expectedAngle);
|
||||
});
|
||||
});
|
||||
|
||||
//image
|
||||
//TODO: currently there is no test for pixel colors at flipped positions.
|
||||
describe("image", () => {
|
||||
const createImage = async () => {
|
||||
const sendPasteEvent = (file?: File) => {
|
||||
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
|
||||
document.dispatchEvent(clipboardEvent);
|
||||
};
|
||||
|
||||
sendPasteEvent(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
|
||||
};
|
||||
|
||||
it("flips an unrotated image horizontally correctly", async () => {
|
||||
//paste image
|
||||
await createImage();
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
|
||||
expect(API.getSelectedElements().length).toBeGreaterThan(0);
|
||||
expect(API.getSelectedElements()[0].type).toEqual("image");
|
||||
expect(h.app.files.fileId).toBeDefined();
|
||||
});
|
||||
await checkHorizontalFlip();
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
|
||||
expect(h.elements[0].angle).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("flips an unrotated image vertically correctly", async () => {
|
||||
//paste image
|
||||
await createImage();
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
|
||||
expect(API.getSelectedElements().length).toBeGreaterThan(0);
|
||||
expect(API.getSelectedElements()[0].type).toEqual("image");
|
||||
expect(h.app.files.fileId).toBeDefined();
|
||||
});
|
||||
|
||||
await checkVerticalFlip();
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, -1]);
|
||||
expect(h.elements[0].angle).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("flips an rotated image horizontally correctly", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
//paste image
|
||||
await createImage();
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
|
||||
expect(API.getSelectedElements().length).toBeGreaterThan(0);
|
||||
expect(API.getSelectedElements()[0].type).toEqual("image");
|
||||
expect(h.app.files.fileId).toBeDefined();
|
||||
});
|
||||
mutateElement(h.elements[0], {
|
||||
angle: originalAngle,
|
||||
});
|
||||
await checkRotatedHorizontalFlip(expectedAngle);
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
|
||||
});
|
||||
|
||||
it("flips an rotated image vertically correctly", async () => {
|
||||
const originalAngle = Math.PI / 4;
|
||||
const expectedAngle = (7 * Math.PI) / 4;
|
||||
//paste image
|
||||
await createImage();
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
|
||||
expect(h.elements[0].angle).toEqual(0);
|
||||
expect(API.getSelectedElements().length).toBeGreaterThan(0);
|
||||
expect(API.getSelectedElements()[0].type).toEqual("image");
|
||||
expect(h.app.files.fileId).toBeDefined();
|
||||
});
|
||||
mutateElement(h.elements[0], {
|
||||
angle: originalAngle,
|
||||
});
|
||||
|
||||
await checkRotatedVerticalFlip(expectedAngle);
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, -1]);
|
||||
expect(h.elements[0].angle).toBeCloseTo(expectedAngle);
|
||||
});
|
||||
|
||||
it("flips an image both vertically & horizontally", async () => {
|
||||
//paste image
|
||||
await createImage();
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
|
||||
expect(API.getSelectedElements().length).toBeGreaterThan(0);
|
||||
expect(API.getSelectedElements()[0].type).toEqual("image");
|
||||
expect(h.app.files.fileId).toBeDefined();
|
||||
});
|
||||
|
||||
await checkVerticalHorizontalFlip();
|
||||
expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, -1]);
|
||||
expect(h.elements[0].angle).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutliple elements", () => {
|
||||
it("with bound text flip correctly", async () => {
|
||||
UI.clickTool("arrow");
|
||||
fireEvent.click(screen.getByTitle("Architect"));
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 180,
|
||||
height: 80,
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
let editor = document.querySelector<HTMLTextAreaElement>(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.input(editor, { target: { value: "arrow" } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector<HTMLTextAreaElement>(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.input(editor, { target: { value: "rect\ntext" } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
|
||||
mouse.select([arrow, rectangle]);
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
|
||||
const arrowText = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
const arrowTextPos = getBoundTextElementPosition(arrow.get(), arrowText)!;
|
||||
const rectText = h.elements[3] as ExcalidrawTextElementWithContainer;
|
||||
|
||||
expect(arrow.x).toBeCloseTo(180);
|
||||
expect(arrow.y).toBeCloseTo(200);
|
||||
expect(arrow.points[1][0]).toBeCloseTo(-180);
|
||||
expect(arrow.points[1][1]).toBeCloseTo(-80);
|
||||
|
||||
expect(arrowTextPos.x - (arrow.x - arrow.width)).toBeCloseTo(
|
||||
arrow.x - (arrowTextPos.x + arrowText.width),
|
||||
);
|
||||
expect(arrowTextPos.y - (arrow.y - arrow.height)).toBeCloseTo(
|
||||
arrow.y - (arrowTextPos.y + arrowText.height),
|
||||
);
|
||||
|
||||
expect(rectangle.x).toBeCloseTo(80);
|
||||
expect(rectangle.y).toBeCloseTo(0);
|
||||
|
||||
expect(rectText.x - rectangle.x).toBeCloseTo(
|
||||
rectangle.x + rectangle.width - (rectText.x + rectText.width),
|
||||
);
|
||||
expect(rectText.y - rectangle.y).toBeCloseTo(
|
||||
rectangle.y + rectangle.height - (rectText.y + rectText.height),
|
||||
);
|
||||
});
|
||||
});
|
70
packages/excalidraw/tests/geometricAlgebra.test.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import * as GA from "../ga";
|
||||
import { point, toString, direction, offset } from "../ga";
|
||||
import * as GAPoint from "../gapoints";
|
||||
import * as GALine from "../galines";
|
||||
import * as GATransform from "../gatransforms";
|
||||
|
||||
describe("geometric algebra", () => {
|
||||
describe("points", () => {
|
||||
it("distanceToLine", () => {
|
||||
const point = GA.point(3, 3);
|
||||
const line = GALine.equation(0, 1, -1);
|
||||
expect(GAPoint.distanceToLine(point, line)).toEqual(2);
|
||||
});
|
||||
|
||||
it("distanceToLine neg", () => {
|
||||
const point = GA.point(-3, -3);
|
||||
const line = GALine.equation(0, 1, -1);
|
||||
expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
|
||||
});
|
||||
});
|
||||
describe("lines", () => {
|
||||
it("through", () => {
|
||||
const a = GA.point(0, 0);
|
||||
const b = GA.point(2, 0);
|
||||
expect(toString(GALine.through(a, b))).toEqual(
|
||||
toString(GALine.equation(0, 2, 0)),
|
||||
);
|
||||
});
|
||||
it("parallel", () => {
|
||||
const point = GA.point(3, 3);
|
||||
const line = GALine.equation(0, 1, -1);
|
||||
const parallel = GALine.parallel(line, 2);
|
||||
expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translation", () => {
|
||||
it("points", () => {
|
||||
const start = point(2, 2);
|
||||
const move = GATransform.translation(direction(0, 1));
|
||||
const end = GATransform.apply(move, start);
|
||||
expect(toString(end)).toEqual(toString(point(2, 3)));
|
||||
});
|
||||
|
||||
it("points 2", () => {
|
||||
const start = point(2, 2);
|
||||
const move = GATransform.translation(offset(3, 4));
|
||||
const end = GATransform.apply(move, start);
|
||||
expect(toString(end)).toEqual(toString(point(5, 6)));
|
||||
});
|
||||
|
||||
it("lines", () => {
|
||||
const original = GALine.through(point(2, 2), point(3, 4));
|
||||
const move = GATransform.translation(offset(3, 4));
|
||||
const parallel = GATransform.apply(move, original);
|
||||
expect(toString(parallel)).toEqual(
|
||||
toString(GALine.through(point(5, 6), point(6, 8))),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("rotation", () => {
|
||||
it("points", () => {
|
||||
const start = point(2, 2);
|
||||
const pivot = point(1, 1);
|
||||
const rotate = GATransform.rotation(pivot, Math.PI / 2);
|
||||
const end = GATransform.apply(rotate, start);
|
||||
expect(toString(end)).toEqual(toString(point(2, 0)));
|
||||
});
|
||||
});
|
||||
});
|
341
packages/excalidraw/tests/helpers/api.ts
Normal file
|
@ -0,0 +1,341 @@
|
|||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawMagicFrameElement,
|
||||
} from "../../element/types";
|
||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||
import { getDefaultAppState } from "../../appState";
|
||||
import { GlobalTestState, createEvent, fireEvent } from "../test-utils";
|
||||
import fs from "fs";
|
||||
import util from "util";
|
||||
import path from "path";
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import {
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
newImageElement,
|
||||
newMagicFrameElement,
|
||||
} from "../../element/newElement";
|
||||
import { Point } from "../../types";
|
||||
import { getSelectedElements } from "../../scene/selection";
|
||||
import { isLinearElementType } from "../../element/typeChecks";
|
||||
import { Mutable } from "../../utility-types";
|
||||
import { assertNever } from "../../utils";
|
||||
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
|
||||
const { h } = window;
|
||||
|
||||
export class API {
|
||||
static setSelectedElements = (elements: ExcalidrawElement[]) => {
|
||||
h.setState({
|
||||
selectedElementIds: elements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
});
|
||||
};
|
||||
|
||||
static getSelectedElements = (
|
||||
includeBoundTextElement: boolean = false,
|
||||
includeElementsInFrames: boolean = false,
|
||||
): ExcalidrawElement[] => {
|
||||
return getSelectedElements(h.elements, h.state, {
|
||||
includeBoundTextElement,
|
||||
includeElementsInFrames,
|
||||
});
|
||||
};
|
||||
|
||||
static getSelectedElement = (): ExcalidrawElement => {
|
||||
const selectedElements = API.getSelectedElements();
|
||||
if (selectedElements.length !== 1) {
|
||||
throw new Error(
|
||||
`expected 1 selected element; got ${selectedElements.length}`,
|
||||
);
|
||||
}
|
||||
return selectedElements[0];
|
||||
};
|
||||
|
||||
static getStateHistory = () => {
|
||||
// @ts-ignore
|
||||
return h.history.stateHistory;
|
||||
};
|
||||
|
||||
static clearSelection = () => {
|
||||
// @ts-ignore
|
||||
h.app.clearSelection(null);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
};
|
||||
|
||||
static createElement = <
|
||||
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
|
||||
>({
|
||||
// @ts-ignore
|
||||
type = "rectangle",
|
||||
id,
|
||||
x = 0,
|
||||
y = x,
|
||||
width = 100,
|
||||
height = width,
|
||||
isDeleted = false,
|
||||
groupIds = [],
|
||||
...rest
|
||||
}: {
|
||||
type?: T;
|
||||
x?: number;
|
||||
y?: number;
|
||||
height?: number;
|
||||
width?: number;
|
||||
angle?: number;
|
||||
id?: string;
|
||||
isDeleted?: boolean;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
groupIds?: string[];
|
||||
// generic element props
|
||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
||||
fillStyle?: ExcalidrawGenericElement["fillStyle"];
|
||||
strokeWidth?: ExcalidrawGenericElement["strokeWidth"];
|
||||
strokeStyle?: ExcalidrawGenericElement["strokeStyle"];
|
||||
roundness?: ExcalidrawGenericElement["roundness"];
|
||||
roughness?: ExcalidrawGenericElement["roughness"];
|
||||
opacity?: ExcalidrawGenericElement["opacity"];
|
||||
// text props
|
||||
text?: T extends "text" ? ExcalidrawTextElement["text"] : never;
|
||||
fontSize?: T extends "text" ? ExcalidrawTextElement["fontSize"] : never;
|
||||
fontFamily?: T extends "text" ? ExcalidrawTextElement["fontFamily"] : never;
|
||||
textAlign?: T extends "text" ? ExcalidrawTextElement["textAlign"] : never;
|
||||
verticalAlign?: T extends "text"
|
||||
? ExcalidrawTextElement["verticalAlign"]
|
||||
: never;
|
||||
boundElements?: ExcalidrawGenericElement["boundElements"];
|
||||
containerId?: T extends "text"
|
||||
? ExcalidrawTextElement["containerId"]
|
||||
: never;
|
||||
points?: T extends "arrow" | "line" ? readonly Point[] : never;
|
||||
locked?: boolean;
|
||||
fileId?: T extends "image" ? string : never;
|
||||
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
||||
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
||||
startBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["startBinding"]
|
||||
: never;
|
||||
endBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["endBinding"]
|
||||
: never;
|
||||
}): T extends "arrow" | "line"
|
||||
? ExcalidrawLinearElement
|
||||
: T extends "freedraw"
|
||||
? ExcalidrawFreeDrawElement
|
||||
: T extends "text"
|
||||
? ExcalidrawTextElement
|
||||
: T extends "image"
|
||||
? ExcalidrawImageElement
|
||||
: T extends "frame"
|
||||
? ExcalidrawFrameElement
|
||||
: T extends "magicframe"
|
||||
? ExcalidrawMagicFrameElement
|
||||
: ExcalidrawGenericElement => {
|
||||
let element: Mutable<ExcalidrawElement> = null!;
|
||||
|
||||
const appState = h?.state || getDefaultAppState();
|
||||
|
||||
const base: Omit<
|
||||
ExcalidrawGenericElement,
|
||||
| "id"
|
||||
| "width"
|
||||
| "height"
|
||||
| "type"
|
||||
| "seed"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "isDeleted"
|
||||
| "groupIds"
|
||||
| "link"
|
||||
| "updated"
|
||||
> = {
|
||||
x,
|
||||
y,
|
||||
frameId: rest.frameId ?? null,
|
||||
angle: rest.angle ?? 0,
|
||||
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
|
||||
backgroundColor:
|
||||
rest.backgroundColor ?? appState.currentItemBackgroundColor,
|
||||
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
|
||||
strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
|
||||
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
|
||||
roundness: (
|
||||
rest.roundness === undefined
|
||||
? appState.currentItemRoundness === "round"
|
||||
: rest.roundness
|
||||
)
|
||||
? {
|
||||
type: isLinearElementType(type)
|
||||
? ROUNDNESS.PROPORTIONAL_RADIUS
|
||||
: ROUNDNESS.ADAPTIVE_RADIUS,
|
||||
}
|
||||
: null,
|
||||
roughness: rest.roughness ?? appState.currentItemRoughness,
|
||||
opacity: rest.opacity ?? appState.currentItemOpacity,
|
||||
boundElements: rest.boundElements ?? null,
|
||||
locked: rest.locked ?? false,
|
||||
};
|
||||
switch (type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
element = newElement({
|
||||
type: type as "rectangle" | "diamond" | "ellipse",
|
||||
width,
|
||||
height,
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "embeddable":
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
...base,
|
||||
validated: null,
|
||||
});
|
||||
break;
|
||||
case "iframe":
|
||||
element = newIframeElement({
|
||||
type: "iframe",
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
|
||||
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
|
||||
element = newTextElement({
|
||||
...base,
|
||||
text: rest.text || "test",
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: rest.textAlign ?? appState.currentItemTextAlign,
|
||||
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: rest.containerId ?? undefined,
|
||||
});
|
||||
element.width = width;
|
||||
element.height = height;
|
||||
break;
|
||||
case "freedraw":
|
||||
element = newFreeDrawElement({
|
||||
type: type as "freedraw",
|
||||
simulatePressure: true,
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "arrow":
|
||||
case "line":
|
||||
element = newLinearElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
points: rest.points ?? [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
],
|
||||
});
|
||||
break;
|
||||
case "image":
|
||||
element = newImageElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
fileId: (rest.fileId as string as FileId) ?? null,
|
||||
status: rest.status || "saved",
|
||||
scale: rest.scale || [1, 1],
|
||||
});
|
||||
break;
|
||||
case "frame":
|
||||
element = newFrameElement({ ...base, width, height });
|
||||
break;
|
||||
case "magicframe":
|
||||
element = newMagicFrameElement({ ...base, width, height });
|
||||
break;
|
||||
default:
|
||||
assertNever(
|
||||
type,
|
||||
`API.createElement: unimplemented element type ${type}}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (element.type === "arrow") {
|
||||
element.startBinding = rest.startBinding ?? null;
|
||||
element.endBinding = rest.endBinding ?? null;
|
||||
}
|
||||
if (id) {
|
||||
element.id = id;
|
||||
}
|
||||
if (isDeleted) {
|
||||
element.isDeleted = isDeleted;
|
||||
}
|
||||
if (groupIds) {
|
||||
element.groupIds = groupIds;
|
||||
}
|
||||
return element as any;
|
||||
};
|
||||
|
||||
static readFile = async <T extends "utf8" | null>(
|
||||
filepath: string,
|
||||
encoding?: T,
|
||||
): Promise<T extends "utf8" ? string : Buffer> => {
|
||||
filepath = path.isAbsolute(filepath)
|
||||
? filepath
|
||||
: path.resolve(path.join(__dirname, "../", filepath));
|
||||
return readFile(filepath, { encoding }) as any;
|
||||
};
|
||||
|
||||
static loadFile = async (filepath: string) => {
|
||||
const { base, ext } = path.parse(filepath);
|
||||
return new File([await API.readFile(filepath, null)], base, {
|
||||
type: getMimeType(ext),
|
||||
});
|
||||
};
|
||||
|
||||
static drop = async (blob: Blob) => {
|
||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||
const text = await new Promise<string>((resolve, reject) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const files = [blob] as File[] & { item: (index: number) => File };
|
||||
files.item = (index: number) => files[index];
|
||||
|
||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||
value: {
|
||||
files,
|
||||
getData: (type: string) => {
|
||||
if (type === blob.type) {
|
||||
return text;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
});
|
||||
fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
|
||||
};
|
||||
}
|
91
packages/excalidraw/tests/helpers/polyfills.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
class ClipboardEvent {
|
||||
constructor(
|
||||
type: "paste" | "copy",
|
||||
eventInitDict: {
|
||||
clipboardData: DataTransfer;
|
||||
},
|
||||
) {
|
||||
return Object.assign(
|
||||
new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: eventInitDict.clipboardData,
|
||||
},
|
||||
) as any as ClipboardEvent;
|
||||
}
|
||||
}
|
||||
|
||||
type DataKind = "string" | "file";
|
||||
|
||||
class DataTransferItem {
|
||||
kind: DataKind;
|
||||
type: string;
|
||||
data: string | Blob;
|
||||
|
||||
constructor(kind: DataKind, type: string, data: string | Blob) {
|
||||
this.kind = kind;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
getAsString(callback: (data: string) => void): void {
|
||||
if (this.kind === "string") {
|
||||
callback(this.data as string);
|
||||
}
|
||||
}
|
||||
|
||||
getAsFile(): File | null {
|
||||
if (this.kind === "file" && this.data instanceof File) {
|
||||
return this.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransferList {
|
||||
items: DataTransferItem[] = [];
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
if (typeof data === "string") {
|
||||
this.items.push(new DataTransferItem("string", type, data));
|
||||
} else if (data instanceof File) {
|
||||
this.items.push(new DataTransferItem("file", type, data));
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransfer {
|
||||
public items: DataTransferList = new DataTransferList();
|
||||
private _types: Record<string, string> = {};
|
||||
|
||||
get files() {
|
||||
return this.items.items
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile()!);
|
||||
}
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
this.items.add(data, type);
|
||||
}
|
||||
|
||||
setData(type: string, value: string) {
|
||||
this._types[type] = value;
|
||||
}
|
||||
|
||||
getData(type: string) {
|
||||
return this._types[type] || "";
|
||||
}
|
||||
}
|
||||
|
||||
export const testPolyfills = {
|
||||
ClipboardEvent,
|
||||
DataTransfer,
|
||||
DataTransferItem,
|
||||
};
|
533
packages/excalidraw/tests/helpers/ui.ts
Normal file
|
@ -0,0 +1,533 @@
|
|||
import type { Point, ToolType } 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 { fireEvent, GlobalTestState, screen } from "../test-utils";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { API } from "./api";
|
||||
import {
|
||||
isLinearElement,
|
||||
isFreeDrawElement,
|
||||
isTextElement,
|
||||
isFrameLikeElement,
|
||||
} from "../../element/typeChecks";
|
||||
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
|
||||
import { rotatePoint } from "../../math";
|
||||
import { getTextEditor } from "../queries/dom";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
let altKey = false;
|
||||
let shiftKey = false;
|
||||
let ctrlKey = false;
|
||||
|
||||
export type KeyboardModifiers = {
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
};
|
||||
export class Keyboard {
|
||||
static withModifierKeys = (modifiers: KeyboardModifiers, cb: () => void) => {
|
||||
const prevAltKey = altKey;
|
||||
const prevShiftKey = shiftKey;
|
||||
const prevCtrlKey = ctrlKey;
|
||||
|
||||
altKey = !!modifiers.alt;
|
||||
shiftKey = !!modifiers.shift;
|
||||
ctrlKey = !!modifiers.ctrl;
|
||||
|
||||
try {
|
||||
cb();
|
||||
} finally {
|
||||
altKey = prevAltKey;
|
||||
shiftKey = prevShiftKey;
|
||||
ctrlKey = prevCtrlKey;
|
||||
}
|
||||
};
|
||||
|
||||
static keyDown = (key: string) => {
|
||||
fireEvent.keyDown(document, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
});
|
||||
};
|
||||
|
||||
static keyUp = (key: string) => {
|
||||
fireEvent.keyUp(document, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
});
|
||||
};
|
||||
|
||||
static keyPress = (key: string) => {
|
||||
Keyboard.keyDown(key);
|
||||
Keyboard.keyUp(key);
|
||||
};
|
||||
|
||||
static codeDown = (code: string) => {
|
||||
fireEvent.keyDown(document, {
|
||||
code,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
});
|
||||
};
|
||||
|
||||
static codeUp = (code: string) => {
|
||||
fireEvent.keyUp(document, {
|
||||
code,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
});
|
||||
};
|
||||
|
||||
static codePress = (code: string) => {
|
||||
Keyboard.codeDown(code);
|
||||
Keyboard.codeUp(code);
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private readonly pointerType: "mouse" | "touch" | "pen",
|
||||
private readonly pointerId = 1,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.clientX = 0;
|
||||
this.clientY = 0;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return [this.clientX, this.clientY];
|
||||
}
|
||||
|
||||
restorePosition(x = 0, y = 0) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
private getEvent() {
|
||||
return {
|
||||
clientX: this.clientX,
|
||||
clientY: this.clientY,
|
||||
pointerType: this.pointerType,
|
||||
pointerId: this.pointerId,
|
||||
altKey,
|
||||
shiftKey,
|
||||
ctrlKey,
|
||||
};
|
||||
}
|
||||
|
||||
// incremental (moving by deltas)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
move(dx: number, dy: number) {
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
this.clientX += dx;
|
||||
this.clientY += dy;
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
}
|
||||
|
||||
down(dx = 0, dy = 0) {
|
||||
this.move(dx, dy);
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
up(dx = 0, dy = 0) {
|
||||
this.move(dx, dy);
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
click(dx = 0, dy = 0) {
|
||||
this.down(dx, dy);
|
||||
this.up();
|
||||
}
|
||||
|
||||
doubleClick(dx = 0, dy = 0) {
|
||||
this.move(dx, dy);
|
||||
fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
// absolute coords
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
moveTo(x: number = this.clientX, y: number = this.clientY) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
downAt(x = this.clientX, y = this.clientY) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
upAt(x = this.clientX, y = this.clientY) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
clickAt(x: number, y: number) {
|
||||
this.downAt(x, y);
|
||||
this.upAt();
|
||||
}
|
||||
|
||||
rightClickAt(x: number, y: number) {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
});
|
||||
}
|
||||
|
||||
doubleClickAt(x: number, y: number) {
|
||||
this.moveTo(x, y);
|
||||
fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
select(
|
||||
/** if multiple elements supplied, they're shift-selected */
|
||||
elements: ExcalidrawElement | ExcalidrawElement[],
|
||||
) {
|
||||
API.clearSelection();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
elements = Array.isArray(elements) ? elements : [elements];
|
||||
elements.forEach((element) => {
|
||||
this.reset();
|
||||
this.click(...getElementPointForSelection(element));
|
||||
});
|
||||
});
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
clickOn(element: ExcalidrawElement) {
|
||||
this.reset();
|
||||
this.click(...getElementPointForSelection(element));
|
||||
this.reset();
|
||||
}
|
||||
|
||||
doubleClickOn(element: ExcalidrawElement) {
|
||||
this.reset();
|
||||
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(isFrameLikeElement);
|
||||
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 = <T extends ExcalidrawElement>(
|
||||
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<ToolType, "lock" | "selection" | "eraser">;
|
||||
|
||||
type Element<T extends DrawingToolName> = 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: ToolType | "lock") => {
|
||||
fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
|
||||
};
|
||||
|
||||
static clickLabeledElement = (label: string) => {
|
||||
const element = document.querySelector(`[aria-label='${label}']`);
|
||||
if (!element) {
|
||||
throw new Error(`No labeled element found: ${label}`);
|
||||
}
|
||||
fireEvent.click(element);
|
||||
};
|
||||
|
||||
static clickOnTestId = (testId: string) => {
|
||||
const element = document.querySelector(`[data-testid='${testId}']`);
|
||||
// const element = GlobalTestState.renderResult.queryByTestId(testId);
|
||||
if (!element) {
|
||||
throw new Error(`No element with testid "${testId}" found`);
|
||||
}
|
||||
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
|
||||
* the app's elements array. This is because across the app lifecycle we tend
|
||||
* to recreate element objects and the returned reference will become stale.
|
||||
*
|
||||
* If you need to get the actual element, not the proxy, call `get()` method
|
||||
* on the proxy object.
|
||||
*/
|
||||
static createElement<T extends DrawingToolName>(
|
||||
type: T,
|
||||
{
|
||||
position = 0,
|
||||
x = position,
|
||||
y = position,
|
||||
size = 10,
|
||||
width: initialWidth = size,
|
||||
height: initialHeight = initialWidth,
|
||||
angle = 0,
|
||||
points: initialPoints,
|
||||
}: {
|
||||
position?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
angle?: number;
|
||||
points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
|
||||
} = {},
|
||||
): Element<T> & {
|
||||
/** Returns the actual, current element from the elements array, instead
|
||||
of the proxy */
|
||||
get(): Element<T>;
|
||||
} {
|
||||
const width = initialWidth ?? initialHeight ?? size;
|
||||
const height = initialHeight ?? size;
|
||||
const points: Point[] = initialPoints ?? [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
];
|
||||
|
||||
UI.clickTool(type);
|
||||
|
||||
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;
|
||||
|
||||
if (angle !== 0) {
|
||||
mutateElement(origElement, { angle });
|
||||
}
|
||||
|
||||
return proxy(origElement);
|
||||
}
|
||||
|
||||
static async editText<
|
||||
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
|
||||
>(element: T, text: string) {
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const openedEditor =
|
||||
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
|
||||
|
||||
if (!openedEditor) {
|
||||
mouse.select(element);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
}
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector);
|
||||
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[]) {
|
||||
mouse.select(elements);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
}
|
||||
|
||||
static ungroup(elements: ExcalidrawElement[]) {
|
||||
mouse.select(elements);
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
}
|
||||
|
||||
static queryContextMenu = () => {
|
||||
return GlobalTestState.renderResult.container.querySelector(
|
||||
".context-menu",
|
||||
) as HTMLElement | null;
|
||||
};
|
||||
}
|
192
packages/excalidraw/tests/history.test.tsx
Normal file
|
@ -0,0 +1,192 @@
|
|||
import { assertSelectedElements, render } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("history", () => {
|
||||
it("initializing scene should end up with single history entry", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||
appState: {
|
||||
zenModeEnabled: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
|
||||
await waitFor(() =>
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||
);
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
]);
|
||||
const rectangle = UI.createElement("rectangle");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: rectangle.id }),
|
||||
]);
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
// noop
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(1);
|
||||
|
||||
h.app.actionManager.executeAction(redoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: rectangle.id, isDeleted: false }),
|
||||
]);
|
||||
expect(API.getStateHistory().length).toBe(2);
|
||||
});
|
||||
|
||||
it("scene import via drag&drop should create new history entry", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||
appState: {
|
||||
viewBackgroundColor: "#FFF",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
|
||||
await waitFor(() =>
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||
);
|
||||
|
||||
API.drop(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
viewBackgroundColor: "#000",
|
||||
},
|
||||
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
||||
}),
|
||||
],
|
||||
{ type: MIME_TYPES.json },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(API.getStateHistory().length).toBe(2));
|
||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||
]);
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
h.app.actionManager.executeAction(undoAction);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
||||
]);
|
||||
expect(h.state.viewBackgroundColor).toBe("#FFF");
|
||||
h.app.actionManager.executeAction(redoAction);
|
||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "B", isDeleted: false }),
|
||||
expect.objectContaining({ id: "A", isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("undo/redo works properly with groups", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
const rect1 = API.createElement({ type: "rectangle", groupIds: ["A"] });
|
||||
const rect2 = API.createElement({ type: "rectangle", groupIds: ["A"] });
|
||||
|
||||
h.elements = [rect1, rect2];
|
||||
mouse.select(rect1);
|
||||
assertSelectedElements([rect1, rect2]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress("d");
|
||||
});
|
||||
expect(h.elements.length).toBe(4);
|
||||
assertSelectedElements([h.elements[2], h.elements[3]]);
|
||||
expect(h.state.selectedGroupIds).not.toEqual(
|
||||
expect.objectContaining({ A: true }),
|
||||
);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress("z");
|
||||
});
|
||||
expect(h.elements.length).toBe(4);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
||||
]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress("z");
|
||||
});
|
||||
expect(h.elements.length).toBe(4);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }),
|
||||
]);
|
||||
expect(h.state.selectedGroupIds).not.toEqual(
|
||||
expect.objectContaining({ A: true }),
|
||||
);
|
||||
|
||||
// undo again, and duplicate once more
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress("z");
|
||||
Keyboard.keyPress("d");
|
||||
});
|
||||
expect(h.elements.length).toBe(6);
|
||||
expect(h.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
||||
expect.objectContaining({
|
||||
id: `${rect1.id}_copy_copy`,
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${rect2.id}_copy_copy`,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(h.state.selectedGroupIds).not.toEqual(
|
||||
expect.objectContaining({ A: true }),
|
||||
);
|
||||
});
|
||||
});
|
327
packages/excalidraw/tests/library.test.tsx
Normal file
|
@ -0,0 +1,327 @@
|
|||
import { vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "./test-utils";
|
||||
import { queryByTestId } from "@testing-library/react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { LibraryItem, LibraryItems } from "../types";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { ExcalidrawGenericElement } from "../element/types";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import { parseLibraryJSON } from "../data/blob";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const libraryJSONPromise = API.readFile(
|
||||
"./fixtures/fixture_library.excalidrawlib",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const mockLibraryFilePromise = new Promise<Blob>(async (resolve, reject) => {
|
||||
try {
|
||||
resolve(
|
||||
new Blob([await libraryJSONPromise], { type: MIME_TYPES.excalidrawlib }),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock("../data/filesystem.ts", async (importOriginal) => {
|
||||
const module = await importOriginal();
|
||||
return {
|
||||
__esmodule: true,
|
||||
//@ts-ignore
|
||||
...module,
|
||||
fileOpen: vi.fn(() => mockLibraryFilePromise),
|
||||
};
|
||||
});
|
||||
|
||||
describe("library", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
h.app.library.resetLibrary();
|
||||
});
|
||||
|
||||
it("import library via drag&drop", async () => {
|
||||
expect(await h.app.library.getLatestLibrary()).toEqual([]);
|
||||
await API.drop(
|
||||
await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
|
||||
);
|
||||
await waitFor(async () => {
|
||||
expect(await h.app.library.getLatestLibrary()).toEqual([
|
||||
{
|
||||
status: "unpublished",
|
||||
elements: [expect.objectContaining({ id: "A" })],
|
||||
id: "id0",
|
||||
created: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: mocked to test logic, not actual drag&drop via UI
|
||||
it("drop library item onto canvas", async () => {
|
||||
expect(h.elements).toEqual([]);
|
||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||
await API.drop(
|
||||
new Blob([serializeLibraryAsJSON(libraryItems)], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should regenerate ids but retain bindings on library insert", async () => {
|
||||
const rectangle = API.createElement({
|
||||
id: "rectangle1",
|
||||
type: "rectangle",
|
||||
boundElements: [
|
||||
{ type: "text", id: "text1" },
|
||||
{ type: "arrow", id: "arrow1" },
|
||||
],
|
||||
});
|
||||
const text = API.createElement({
|
||||
id: "text1",
|
||||
type: "text",
|
||||
text: "ola",
|
||||
containerId: "rectangle1",
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
id: "arrow1",
|
||||
type: "arrow",
|
||||
endBinding: { elementId: "rectangle1", focus: -1, gap: 0 },
|
||||
});
|
||||
|
||||
await API.drop(
|
||||
new Blob(
|
||||
[
|
||||
serializeLibraryAsJSON([
|
||||
{
|
||||
id: "item1",
|
||||
status: "published",
|
||||
elements: [rectangle, text, arrow],
|
||||
created: 1,
|
||||
},
|
||||
]),
|
||||
],
|
||||
{
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "rectangle1_copy",
|
||||
boundElements: expect.arrayContaining([
|
||||
{ type: "text", id: "text1_copy" },
|
||||
{ type: "arrow", id: "arrow1_copy" },
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "text1_copy",
|
||||
containerId: "rectangle1_copy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "arrow1_copy",
|
||||
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fix duplicate ids between items on insert", async () => {
|
||||
// note, we're not testing for duplicate group ids and such because
|
||||
// deduplication of that happens upstream in the library component
|
||||
// which would be very hard to orchestrate in this test
|
||||
|
||||
const elem1 = API.createElement({
|
||||
id: "elem1",
|
||||
type: "rectangle",
|
||||
});
|
||||
const item1: LibraryItem = {
|
||||
id: "item1",
|
||||
status: "published",
|
||||
elements: [elem1],
|
||||
created: 1,
|
||||
};
|
||||
|
||||
await API.drop(
|
||||
new Blob([serializeLibraryAsJSON([item1, item1])], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "elem1_copy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("inserting library item should revert to selection tool", async () => {
|
||||
UI.clickTool("rectangle");
|
||||
expect(h.elements).toEqual([]);
|
||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||
await API.drop(
|
||||
new Blob([serializeLibraryAsJSON(libraryItems)], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
});
|
||||
|
||||
describe("library menu", () => {
|
||||
it("should load library from file picker", async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
|
||||
const latestLibrary = await h.app.library.getLatestLibrary();
|
||||
expect(latestLibrary.length).toBe(0);
|
||||
|
||||
const libraryButton = container.querySelector(".sidebar-trigger");
|
||||
|
||||
fireEvent.click(libraryButton!);
|
||||
fireEvent.click(
|
||||
queryByTestId(
|
||||
container.querySelector(".layer-ui__library")!,
|
||||
"dropdown-menu-button",
|
||||
)!,
|
||||
);
|
||||
queryByTestId(container, "lib-dropdown--load")!.click();
|
||||
|
||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||
|
||||
await waitFor(async () => {
|
||||
const latestLibrary = await h.app.library.getLatestLibrary();
|
||||
expect(latestLibrary.length).toBeGreaterThan(0);
|
||||
expect(latestLibrary.length).toBe(libraryItems.length);
|
||||
expect(latestLibrary[0].elements).toEqual(libraryItems[0].elements);
|
||||
});
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeLibraryItemsOnSquareGrid()", () => {
|
||||
it("should distribute items on a grid", async () => {
|
||||
const createLibraryItem = (
|
||||
elements: ExcalidrawGenericElement[],
|
||||
): LibraryItem => {
|
||||
return {
|
||||
id: `id-${Date.now()}`,
|
||||
elements,
|
||||
status: "unpublished",
|
||||
created: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const PADDING = 50;
|
||||
|
||||
const el1 = API.createElement({
|
||||
id: "id1",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const el2 = API.createElement({
|
||||
id: "id2",
|
||||
width: 100,
|
||||
height: 80,
|
||||
x: -100,
|
||||
y: -50,
|
||||
});
|
||||
|
||||
const el3 = API.createElement({
|
||||
id: "id3",
|
||||
width: 40,
|
||||
height: 50,
|
||||
x: -100,
|
||||
y: -50,
|
||||
});
|
||||
|
||||
const el4 = API.createElement({
|
||||
id: "id4",
|
||||
width: 50,
|
||||
height: 50,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const el5 = API.createElement({
|
||||
id: "id5",
|
||||
width: 70,
|
||||
height: 100,
|
||||
x: 40,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const libraryItems: LibraryItems = [
|
||||
createLibraryItem([el1]),
|
||||
createLibraryItem([el2]),
|
||||
createLibraryItem([el3]),
|
||||
createLibraryItem([el4, el5]),
|
||||
];
|
||||
|
||||
const distributed = distributeLibraryItemsOnSquareGrid(libraryItems);
|
||||
// assert the returned library items are flattened to elements
|
||||
expect(distributed.length).toEqual(
|
||||
libraryItems.map((x) => x.elements).flat().length,
|
||||
);
|
||||
expect(distributed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: el1.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el2.id,
|
||||
x:
|
||||
el1.width +
|
||||
PADDING +
|
||||
(getCommonBoundingBox([el4, el5]).width - el2.width) / 2,
|
||||
y: Math.abs(el1.height - el2.height) / 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el3.id,
|
||||
x: Math.abs(el1.width - el3.width) / 2,
|
||||
y:
|
||||
Math.max(el1.height, el2.height) +
|
||||
PADDING +
|
||||
Math.abs(el3.height - Math.max(el4.height, el5.height)) / 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el4.id,
|
||||
x: Math.max(el1.width, el2.width) + PADDING,
|
||||
y: Math.max(el1.height, el2.height) + PADDING,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el5.id,
|
||||
x: Math.max(el1.width, el2.width) + PADDING + Math.abs(el5.x - el4.x),
|
||||
y:
|
||||
Math.max(el1.height, el2.height) +
|
||||
PADDING +
|
||||
Math.abs(el5.y - el4.y),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
1230
packages/excalidraw/tests/linearElementEditor.test.tsx
Normal file
166
packages/excalidraw/tests/move.test.tsx
Normal file
|
@ -0,0 +1,166 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { render, fireEvent } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { reseed } from "../random";
|
||||
import { bindOrUnbindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
ExcalidrawRectangleElement,
|
||||
} from "../element/types";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("move element", () => {
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
|
||||
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
}
|
||||
|
||||
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(2);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([0, 40]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("rectangles with binding arrow", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
// create elements
|
||||
const rectA = UI.createElement("rectangle", { size: 100 });
|
||||
const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 });
|
||||
const line = UI.createElement("line", { x: 110, y: 50, size: 80 });
|
||||
|
||||
// bind line to two rectangles
|
||||
bindOrUnbindLinearElement(
|
||||
line.get() as NonDeleted<ExcalidrawLinearElement>,
|
||||
rectA.get() as ExcalidrawRectangleElement,
|
||||
rectB.get() as ExcalidrawRectangleElement,
|
||||
);
|
||||
|
||||
// select the second rectangles
|
||||
new Pointer("mouse").clickOn(rectB);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(24);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(19);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(3);
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([200, 0]);
|
||||
expect([line.x, line.y]).toEqual([110, 50]);
|
||||
expect([line.width, line.height]).toEqual([80, 80]);
|
||||
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
|
||||
// Move selected rectangle
|
||||
Keyboard.keyDown(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyDown(KEYS.ARROW_DOWN);
|
||||
Keyboard.keyDown(KEYS.ARROW_DOWN);
|
||||
|
||||
// Check that the arrow size has been changed according to moving the rectangle
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(3);
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([201, 2]);
|
||||
expect([Math.round(line.x), Math.round(line.y)]).toEqual([110, 50]);
|
||||
expect([Math.round(line.width), Math.round(line.height)]).toEqual([81, 81]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate element on move when ALT is clicked", () => {
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
|
||||
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
}
|
||||
|
||||
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40, altKey: true });
|
||||
|
||||
// firing another pointerMove event with alt key pressed should NOT trigger
|
||||
// another duplication
|
||||
fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40, altKey: true });
|
||||
fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
// TODO: This used to be 4, but binding made it go up to 5. Do we need
|
||||
// that additional render?
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(2);
|
||||
|
||||
// previous element should stay intact
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toEqual([-10, 60]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
});
|
173
packages/excalidraw/tests/multiPointCreate.test.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { reseed } from "../random";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("remove shape in non linear elements", () => {
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect({ width: 1000, height: 1000 });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("ellipse", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("diamond", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi point mode in linear elements", () => {
|
||||
it("arrow", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
// first point is added on pointer down
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
// second point, enable multi point
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 50, clientY: 60 });
|
||||
|
||||
// third point
|
||||
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 60 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.pointerMove(canvas, { clientX: 100, clientY: 140 });
|
||||
|
||||
// done
|
||||
fireEvent.pointerDown(canvas);
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(element.type).toEqual("arrow");
|
||||
expect(element.x).toEqual(30);
|
||||
expect(element.y).toEqual(30);
|
||||
expect(element.points).toEqual([
|
||||
[0, 0],
|
||||
[20, 30],
|
||||
[70, 110],
|
||||
]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("line", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
// first point is added on pointer down
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
// second point, enable multi point
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 50, clientY: 60 });
|
||||
|
||||
// third point
|
||||
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 60 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.pointerMove(canvas, { clientX: 100, clientY: 140 });
|
||||
|
||||
// done
|
||||
fireEvent.pointerDown(canvas);
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ENTER,
|
||||
});
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(element.type).toEqual("line");
|
||||
expect(element.x).toEqual(30);
|
||||
expect(element.y).toEqual(30);
|
||||
expect(element.points).toEqual([
|
||||
[0, 0],
|
||||
[20, 30],
|
||||
[70, 110],
|
||||
]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
});
|
|
@ -0,0 +1,616 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Click me
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="blog.excalidaw.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Excalidraw blog
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="dropdown-menu-item-base dropdown-menu-item-custom"
|
||||
>
|
||||
<button
|
||||
style="height: 2rem;"
|
||||
>
|
||||
custom menu item
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
aria-label="Open"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="load-button"
|
||||
title="Open"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Save to..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="json-export-button"
|
||||
title="Save to..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Save to...
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Export image..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="image-export-button"
|
||||
title="Export image..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M15 8h.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
|
||||
/>
|
||||
<path
|
||||
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
|
||||
/>
|
||||
<path
|
||||
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
|
||||
/>
|
||||
<path
|
||||
d="M19 16v6"
|
||||
/>
|
||||
<path
|
||||
d="M22 19l-3 3l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Reset the canvas"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="clear-canvas-button"
|
||||
title="Reset the canvas"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Reset the canvas
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-group "
|
||||
>
|
||||
<p
|
||||
class="dropdown-menu-group-title"
|
||||
>
|
||||
Excalidraw links
|
||||
</p>
|
||||
<a
|
||||
aria-label="GitHub"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="GitHub"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M7.5 15.833c-3.583 1.167-3.583-2.083-5-2.5m10 4.167v-2.917c0-.833.083-1.166-.417-1.666 2.334-.25 4.584-1.167 4.584-5a3.833 3.833 0 0 0-1.084-2.667 3.5 3.5 0 0 0-.083-2.667s-.917-.25-2.917 1.084a10.25 10.25 0 0 0-5.166 0C5.417 2.333 4.5 2.583 4.5 2.583a3.5 3.5 0 0 0-.083 2.667 3.833 3.833 0 0 0-1.084 2.667c0 3.833 2.25 4.75 4.584 5-.5.5-.5 1-.417 1.666V17.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
GitHub
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Discord"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://discord.gg/UexuTaE"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Discord"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M7.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM12.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM6.25 6.25c2.917-.833 4.583-.833 7.5 0M5.833 13.75c2.917.833 5.417.833 8.334 0"
|
||||
/>
|
||||
<path
|
||||
d="M12.917 14.167c0 .833 1.25 2.5 1.666 2.5 1.25 0 2.361-1.39 2.917-2.5.556-1.39.417-4.861-1.25-9.584-1.214-.846-2.5-1.116-3.75-1.25l-.833 2.084M7.083 14.167c0 .833-1.13 2.5-1.526 2.5-1.191 0-2.249-1.39-2.778-2.5-.529-1.39-.397-4.861 1.19-9.584 1.157-.846 2.318-1.116 3.531-1.25l.833 2.084"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Discord
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Twitter"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://twitter.com/excalidraw"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Twitter"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Twitter
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="toggle-dark-mode"
|
||||
title="Dark mode"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Shift+Alt+D
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="margin-top: 0.5rem;"
|
||||
>
|
||||
<div
|
||||
data-testid="canvas-background-label"
|
||||
style="font-size: .75rem; margin-bottom: .5rem;"
|
||||
>
|
||||
Canvas background
|
||||
</div>
|
||||
<div
|
||||
style="padding: 0px 0.625rem;"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-modal="true"
|
||||
class="color-picker-container"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="color-picker__top-picks"
|
||||
>
|
||||
<button
|
||||
class="color-picker__button active"
|
||||
data-testid="color-top-pick-#ffffff"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="#ffffff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#f8f9fa"
|
||||
style="--swatch-color: #f8f9fa;"
|
||||
title="#f8f9fa"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#f5faff"
|
||||
style="--swatch-color: #f5faff;"
|
||||
title="#f5faff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#fffce8"
|
||||
style="--swatch-color: #fffce8;"
|
||||
title="#fffce8"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#fdf8f6"
|
||||
style="--swatch-color: #fdf8f6;"
|
||||
title="#fdf8f6"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="width: 1px; height: 100%; margin: 0px auto;"
|
||||
/>
|
||||
<button
|
||||
aria-controls="radix-:r0:"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
class="color-picker__button active-color"
|
||||
data-state="closed"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="Show background color picker"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,100 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`exportToSvg > with default arguments 1`] = `
|
||||
{
|
||||
"activeEmbeddable": null,
|
||||
"activeTool": {
|
||||
"customType": null,
|
||||
"lastActiveTool": null,
|
||||
"locked": false,
|
||||
"type": "selection",
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
"currentItemFontFamily": 1,
|
||||
"currentItemFontSize": 20,
|
||||
"currentItemOpacity": 100,
|
||||
"currentItemRoughness": 1,
|
||||
"currentItemRoundness": "round",
|
||||
"currentItemStartArrowhead": null,
|
||||
"currentItemStrokeColor": "#1e1e1e",
|
||||
"currentItemStrokeStyle": "solid",
|
||||
"currentItemStrokeWidth": 2,
|
||||
"currentItemTextAlign": "left",
|
||||
"cursorButton": "up",
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"draggingElement": null,
|
||||
"editingElement": null,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"exportEmbedScene": false,
|
||||
"exportPadding": undefined,
|
||||
"exportScale": 1,
|
||||
"exportWithDarkMode": false,
|
||||
"fileHandle": null,
|
||||
"frameRendering": {
|
||||
"clip": true,
|
||||
"enabled": true,
|
||||
"name": true,
|
||||
"outline": true,
|
||||
},
|
||||
"frameToHighlight": null,
|
||||
"gridSize": null,
|
||||
"isBindingEnabled": true,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "name",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
},
|
||||
"penDetected": false,
|
||||
"penMode": false,
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
"selectedLinearElement": null,
|
||||
"selectionElement": null,
|
||||
"shouldCacheIgnoreZoom": false,
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"viewModeEnabled": false,
|
||||
"zenModeEnabled": false,
|
||||
"zoom": {
|
||||
"value": 1,
|
||||
},
|
||||
}
|
||||
`;
|
60
packages/excalidraw/tests/packages/events.test.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { vi } from "vitest";
|
||||
import { Excalidraw } from "../../index";
|
||||
import { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { resolvablePromise } from "../../utils";
|
||||
import { render } from "../test-utils";
|
||||
import { Pointer } from "../helpers/ui";
|
||||
|
||||
describe("event callbacks", () => {
|
||||
const h = window.h;
|
||||
|
||||
let excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
/>,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
});
|
||||
|
||||
it("should trigger onChange on render", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const origBackgroundColor = h.state.viewBackgroundColor;
|
||||
excalidrawAPI.onChange(onChange);
|
||||
excalidrawAPI.updateScene({ appState: { viewBackgroundColor: "red" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
// elements
|
||||
[],
|
||||
// appState
|
||||
expect.objectContaining({
|
||||
viewBackgroundColor: "red",
|
||||
}),
|
||||
// files
|
||||
{},
|
||||
);
|
||||
expect(onChange.mock.lastCall[1].viewBackgroundColor).not.toBe(
|
||||
origBackgroundColor,
|
||||
);
|
||||
});
|
||||
|
||||
it("should trigger onPointerDown/onPointerUp on canvas pointerDown/pointerUp", async () => {
|
||||
const onPointerDown = vi.fn();
|
||||
const onPointerUp = vi.fn();
|
||||
|
||||
excalidrawAPI.onPointerDown(onPointerDown);
|
||||
excalidrawAPI.onPointerUp(onPointerUp);
|
||||
|
||||
mouse.downAt(100);
|
||||
expect(onPointerDown).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerUp).not.toHaveBeenCalled();
|
||||
mouse.up();
|
||||
expect(onPointerDown).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerUp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
19
packages/excalidraw/tests/queries/dom.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { waitFor } from "@testing-library/dom";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
|
||||
export const getTextEditor = async (selector: string, waitForEditor = true) => {
|
||||
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
|
||||
if (waitForEditor) {
|
||||
await waitFor(() => expect(query()).not.toBe(null));
|
||||
return query();
|
||||
}
|
||||
return query();
|
||||
};
|
||||
|
||||
export const updateTextEditor = (
|
||||
editor: HTMLTextAreaElement,
|
||||
value: string,
|
||||
) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
25
packages/excalidraw/tests/queries/toolQueries.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { queries, buildQueries } from "@testing-library/react";
|
||||
import { ToolType } from "../../types";
|
||||
import { TOOL_TYPE } from "../../constants";
|
||||
|
||||
const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => {
|
||||
const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool];
|
||||
return queries.getAllByTestId(container, `toolbar-${toolTitle}`);
|
||||
};
|
||||
|
||||
const getMultipleError = (_container: any, tool: any) =>
|
||||
`Found multiple elements with tool name: ${tool}`;
|
||||
const getMissingError = (_container: any, tool: any) =>
|
||||
`Unable to find an element with tool name: ${tool}`;
|
||||
|
||||
export const [
|
||||
queryByToolName,
|
||||
getAllByToolName,
|
||||
getByToolName,
|
||||
findAllByToolName,
|
||||
findByToolName,
|
||||
] = buildQueries<(ToolType | "lock")[]>(
|
||||
_getAllByToolName,
|
||||
getMultipleError,
|
||||
getMissingError,
|
||||
);
|
1172
packages/excalidraw/tests/regressionTests.test.tsx
Normal file
1003
packages/excalidraw/tests/resize.test.tsx
Normal file
81
packages/excalidraw/tests/rotate.test.tsx
Normal file
|
@ -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 "../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(<Excalidraw />);
|
||||
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(<Excalidraw />);
|
||||
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);
|
||||
});
|
410
packages/excalidraw/tests/scene/export.test.ts
Normal file
|
@ -0,0 +1,410 @@
|
|||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import * as exportUtils from "../../scene/export";
|
||||
import {
|
||||
diamondFixture,
|
||||
ellipseFixture,
|
||||
rectangleWithLinkFixture,
|
||||
} from "../fixtures/elementFixture";
|
||||
import { API } from "../helpers/api";
|
||||
import { exportToCanvas, exportToSvg } from "../../../utils";
|
||||
import { FRAME_STYLE } from "../../constants";
|
||||
import { prepareElementsForExport } from "../../data";
|
||||
|
||||
describe("exportToSvg", () => {
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
const ELEMENT_HEIGHT = 100;
|
||||
const ELEMENT_WIDTH = 100;
|
||||
const ELEMENTS = [
|
||||
{ ...diamondFixture, height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH },
|
||||
{ ...ellipseFixture, height: ELEMENT_HEIGHT, width: ELEMENT_WIDTH },
|
||||
] as NonDeletedExcalidrawElement[];
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: "#ffffff",
|
||||
files: {},
|
||||
};
|
||||
|
||||
it("with default arguments", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(svgElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("with background color", async () => {
|
||||
const BACKGROUND_COLOR = "#abcdef";
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: BACKGROUND_COLOR,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(svgElement.querySelector("rect")).toHaveAttribute(
|
||||
"fill",
|
||||
BACKGROUND_COLOR,
|
||||
);
|
||||
});
|
||||
|
||||
it("with dark mode", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportWithDarkMode: true,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
|
||||
'"_themeFilter_1883f3"',
|
||||
);
|
||||
});
|
||||
|
||||
it("with exportPadding", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportPadding: 0,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
|
||||
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
|
||||
expect(svgElement).toHaveAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${ELEMENT_WIDTH} ${ELEMENT_HEIGHT}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("with scale", async () => {
|
||||
const SCALE = 2;
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportPadding: 0,
|
||||
exportScale: SCALE,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(svgElement).toHaveAttribute(
|
||||
"height",
|
||||
(ELEMENT_HEIGHT * SCALE).toString(),
|
||||
);
|
||||
expect(svgElement).toHaveAttribute(
|
||||
"width",
|
||||
(ELEMENT_WIDTH * SCALE).toString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("with exportEmbedScene", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
ELEMENTS,
|
||||
{
|
||||
...DEFAULT_OPTIONS,
|
||||
exportEmbedScene: true,
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("with elements that have a link", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[rectangleWithLinkFixture],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exporting frames", () => {
|
||||
const getFrameNameHeight = (exportType: "canvas" | "svg") => {
|
||||
const height =
|
||||
FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight +
|
||||
FRAME_STYLE.nameOffsetY;
|
||||
// canvas truncates dimensions to integers
|
||||
if (exportType === "canvas") {
|
||||
return Math.trunc(height);
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
// a few tests with exportToCanvas (where we can't inspect elements)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("exportToCanvas", () => {
|
||||
it("exporting canvas with a single frame shouldn't crop if not exporting frame directly", async () => {
|
||||
const elements = [
|
||||
API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 100,
|
||||
y: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements,
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
});
|
||||
|
||||
expect(canvas.width).toEqual(200);
|
||||
expect(canvas.height).toEqual(100 + getFrameNameHeight("canvas"));
|
||||
});
|
||||
|
||||
it("exporting canvas with a single frame should crop when exporting frame directly", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const elements = [
|
||||
frame,
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 100,
|
||||
y: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements,
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
exportingFrame: frame,
|
||||
});
|
||||
|
||||
expect(canvas.width).toEqual(frame.width);
|
||||
expect(canvas.height).toEqual(frame.height);
|
||||
});
|
||||
});
|
||||
|
||||
// exportToSvg (so we can test for element existence)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("exportToSvg", () => {
|
||||
it("exporting frame should include overlapping elements, but crop to frame", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 50,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const rectOverlapping = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 50,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: [rectOverlapping, frame, frameChild],
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
exportingFrame: frame,
|
||||
});
|
||||
|
||||
// frame itself isn't exported
|
||||
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
|
||||
// frame child is exported
|
||||
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
|
||||
// overlapping element is exported
|
||||
expect(
|
||||
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
|
||||
).not.toBeNull();
|
||||
|
||||
expect(svg.getAttribute("width")).toBe(frame.width.toString());
|
||||
expect(svg.getAttribute("height")).toBe(frame.height.toString());
|
||||
});
|
||||
|
||||
it("should filter non-overlapping elements when exporting a frame", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 50,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const elementOutside = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 200,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: [frameChild, frame, elementOutside],
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
exportingFrame: frame,
|
||||
});
|
||||
|
||||
// frame itself isn't exported
|
||||
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
|
||||
// frame child is exported
|
||||
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
|
||||
// non-overlapping element is not exported
|
||||
expect(svg.querySelector(`[data-id="${elementOutside.id}"]`)).toBeNull();
|
||||
|
||||
expect(svg.getAttribute("width")).toBe(frame.width.toString());
|
||||
expect(svg.getAttribute("height")).toBe(frame.height.toString());
|
||||
});
|
||||
|
||||
it("should export multiple frames when selected, excluding overlapping elements", async () => {
|
||||
const frame1 = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frame2 = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 200,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const frame1Child = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 50,
|
||||
frameId: frame1.id,
|
||||
});
|
||||
const frame2Child = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 200,
|
||||
y: 0,
|
||||
frameId: frame2.id,
|
||||
});
|
||||
const frame2Overlapping = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 350,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
// low-level exportToSvg api expects elements to be pre-filtered, so let's
|
||||
// use the filter we use in the editor
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
[frame1Child, frame1, frame2Child, frame2, frame2Overlapping],
|
||||
{
|
||||
selectedElementIds: { [frame1.id]: true, [frame2.id]: true },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: exportedElements,
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
// frames themselves should be exported when multiple frames selected
|
||||
expect(svg.querySelector(`[data-id="${frame1.id}"]`)).not.toBeNull();
|
||||
expect(svg.querySelector(`[data-id="${frame2.id}"]`)).not.toBeNull();
|
||||
// children should be epxorted
|
||||
expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
|
||||
expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).not.toBeNull();
|
||||
// overlapping elements or non-overlapping elements should not be exported
|
||||
expect(
|
||||
svg.querySelector(`[data-id="${frame2Overlapping.id}"]`),
|
||||
).toBeNull();
|
||||
|
||||
expect(svg.getAttribute("width")).toBe(
|
||||
(frame2.x + frame2.width).toString(),
|
||||
);
|
||||
expect(svg.getAttribute("height")).toBe(
|
||||
(frame2.y + frame2.height + getFrameNameHeight("svg")).toString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render frame alone when not selected", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
// low-level exportToSvg api expects elements to be pre-filtered, so let's
|
||||
// use the filter we use in the editor
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
[frame],
|
||||
{
|
||||
selectedElementIds: {},
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: exportedElements,
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
// frame itself isn't exported
|
||||
expect(svg.querySelector(`[data-id="${frame.id}"]`)).not.toBeNull();
|
||||
|
||||
expect(svg.getAttribute("width")).toBe(frame.width.toString());
|
||||
expect(svg.getAttribute("height")).toBe(
|
||||
(frame.height + getFrameNameHeight("svg")).toString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
111
packages/excalidraw/tests/scroll.test.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import {
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("appState", () => {
|
||||
it("scroll-to-content on init works with non-zero offsets", async () => {
|
||||
const WIDTH = 200;
|
||||
const HEIGHT = 100;
|
||||
const OFFSET_LEFT = 20;
|
||||
const OFFSET_TOP = 10;
|
||||
|
||||
const ELEM_WIDTH = 100;
|
||||
const ELEM_HEIGHT = 60;
|
||||
|
||||
mockBoundingClientRect();
|
||||
|
||||
await render(
|
||||
<div>
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
width: ELEM_WIDTH,
|
||||
height: ELEM_HEIGHT,
|
||||
}),
|
||||
],
|
||||
scrollToContent: true,
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(h.state.width).toBe(200);
|
||||
expect(h.state.height).toBe(100);
|
||||
expect(h.state.offsetLeft).toBe(OFFSET_LEFT);
|
||||
expect(h.state.offsetTop).toBe(OFFSET_TOP);
|
||||
|
||||
// assert scroll is in center
|
||||
expect(h.state.scrollX).toBe(WIDTH / 2 - ELEM_WIDTH / 2);
|
||||
expect(h.state.scrollY).toBe(HEIGHT / 2 - ELEM_HEIGHT / 2);
|
||||
});
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("moving by page up/down/left/right", async () => {
|
||||
mockBoundingClientRect();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />, {});
|
||||
|
||||
const scrollTest = () => {
|
||||
const initialScrollY = h.state.scrollY;
|
||||
const initialScrollX = h.state.scrollX;
|
||||
const pageStepY = h.state.height / h.state.zoom.value;
|
||||
const pageStepX = h.state.width / h.state.zoom.value;
|
||||
// Assert the following assertions have meaning
|
||||
expect(pageStepY).toBeGreaterThan(0);
|
||||
expect(pageStepX).toBeGreaterThan(0);
|
||||
// Assert we scroll up
|
||||
Keyboard.keyPress(KEYS.PAGE_UP);
|
||||
expect(h.state.scrollY).toBe(initialScrollY + pageStepY);
|
||||
// x-axis unchanged
|
||||
expect(h.state.scrollX).toBe(initialScrollX);
|
||||
|
||||
// Assert we scroll down
|
||||
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||
expect(h.state.scrollY).toBe(initialScrollY - pageStepY);
|
||||
// x-axis unchanged
|
||||
expect(h.state.scrollX).toBe(initialScrollX);
|
||||
|
||||
// Assert we scroll left
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.PAGE_UP);
|
||||
});
|
||||
expect(h.state.scrollX).toBe(initialScrollX + pageStepX);
|
||||
// y-axis unchanged
|
||||
expect(h.state.scrollY).toBe(initialScrollY - pageStepY);
|
||||
|
||||
// Assert we scroll right
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||
});
|
||||
expect(h.state.scrollX).toBe(initialScrollX - pageStepX);
|
||||
// y-axis unchanged
|
||||
expect(h.state.scrollY).toBe(initialScrollY - pageStepY);
|
||||
};
|
||||
|
||||
const zoom = h.state.zoom.value;
|
||||
// Assert we scroll properly when zoomed in
|
||||
h.setState({ zoom: { value: (zoom * 1.1) as typeof zoom } });
|
||||
scrollTest();
|
||||
// Assert we scroll properly when zoomed out
|
||||
h.setState({ zoom: { value: (zoom * 0.9) as typeof zoom } });
|
||||
scrollTest();
|
||||
// Assert we scroll properly with normal zoom
|
||||
h.setState({ zoom: { value: zoom } });
|
||||
scrollTest();
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
});
|
532
packages/excalidraw/tests/selection.test.tsx
Normal file
|
@ -0,0 +1,532 @@
|
|||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
assertSelectedElements,
|
||||
} from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { KEYS } from "../keys";
|
||||
import { reseed } from "../random";
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("box-selection", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("should allow adding to selection via box-select when holding shift", async () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
h.elements = [rect1, rect2];
|
||||
|
||||
mouse.downAt(175, -20);
|
||||
mouse.moveTo(85, 70);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([rect2.id]);
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.downAt(75, -20);
|
||||
mouse.moveTo(-15, 70);
|
||||
mouse.up();
|
||||
});
|
||||
|
||||
assertSelectedElements([rect2.id, rect1.id]);
|
||||
});
|
||||
|
||||
it("should (de)select element when box-selecting over and out while not holding shift", async () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
h.elements = [rect1];
|
||||
|
||||
mouse.downAt(75, -20);
|
||||
mouse.moveTo(-15, 70);
|
||||
|
||||
assertSelectedElements([rect1.id]);
|
||||
|
||||
mouse.moveTo(100, -100);
|
||||
|
||||
assertSelectedElements([]);
|
||||
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inner box-selection", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
it("selecting elements visually nested inside another", async () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
const rect3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
h.elements = [rect1, rect2, rect3];
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(40, 40);
|
||||
mouse.moveTo(290, 290);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([rect2.id, rect3.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting grouped elements visually nested inside another", async () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const rect3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
h.elements = [rect1, rect2, rect3];
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(40, 40);
|
||||
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([rect2.id, rect3.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting & deselecting grouped elements visually nested inside another", async () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const rect3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
h.elements = [rect1, rect2, rect3];
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(rect2.x - 20, rect2.y - 20);
|
||||
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||
assertSelectedElements([rect2.id, rect3.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
mouse.moveTo(rect2.x - 10, rect2.y - 10);
|
||||
assertSelectedElements([rect1.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
mouse.up();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection element", () => {
|
||||
it("create selection element on pointer down", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
const selectionElement = h.state.selectionElement!;
|
||||
expect(selectionElement).not.toBeNull();
|
||||
expect(selectionElement.type).toEqual("selection");
|
||||
expect([selectionElement.x, selectionElement.y]).toEqual([60, 100]);
|
||||
expect([selectionElement.width, selectionElement.height]).toEqual([0, 0]);
|
||||
|
||||
// TODO: There is a memory leak if pointer up is not triggered
|
||||
fireEvent.pointerUp(canvas);
|
||||
});
|
||||
|
||||
it("resize selection element on pointer move", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
const selectionElement = h.state.selectionElement!;
|
||||
expect(selectionElement).not.toBeNull();
|
||||
expect(selectionElement.type).toEqual("selection");
|
||||
expect([selectionElement.x, selectionElement.y]).toEqual([60, 30]);
|
||||
expect([selectionElement.width, selectionElement.height]).toEqual([90, 70]);
|
||||
|
||||
// TODO: There is a memory leak if pointer up is not triggered
|
||||
fireEvent.pointerUp(canvas);
|
||||
});
|
||||
|
||||
it("remove selection element on pointer up", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// select tool
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("select single element on the scene", () => {
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ESCAPE,
|
||||
});
|
||||
}
|
||||
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
// click on a line on the rectangle
|
||||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("diamond", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ESCAPE,
|
||||
});
|
||||
}
|
||||
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
// click on a line on the rectangle
|
||||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("ellipse", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ESCAPE,
|
||||
});
|
||||
}
|
||||
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
// click on a line on the rectangle
|
||||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("arrow", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ESCAPE,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
1 2 3 4 5 6 7 8 9
|
||||
1
|
||||
2 x
|
||||
3
|
||||
4 .
|
||||
5
|
||||
6
|
||||
7 x
|
||||
8
|
||||
9
|
||||
*/
|
||||
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
// click on a line on the arrow
|
||||
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
it("arrow escape", async () => {
|
||||
const { getByToolName, container } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
fireEvent.keyDown(document, {
|
||||
key: KEYS.ESCAPE,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
1 2 3 4 5 6 7 8 9
|
||||
1
|
||||
2 x
|
||||
3
|
||||
4 .
|
||||
5
|
||||
6
|
||||
7 x
|
||||
8
|
||||
9
|
||||
*/
|
||||
|
||||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
// click on a line on the arrow
|
||||
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool locking & selection", () => {
|
||||
it("should not select newly created element while tool is locked", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
UI.clickTool("lock");
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
|
||||
for (const { value } of Object.values(SHAPES)) {
|
||||
if (value !== "image" && value !== "selection" && value !== "eraser") {
|
||||
const element = UI.createElement(value);
|
||||
expect(h.state.selectedElementIds[element.id]).not.toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedElementIds stability", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("box-selection should be stable when not changing selection", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
h.elements = [rectangle];
|
||||
|
||||
const selectedElementIds_1 = h.state.selectedElementIds;
|
||||
|
||||
mouse.downAt(-100, -100);
|
||||
mouse.moveTo(-50, -50);
|
||||
mouse.up();
|
||||
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_1);
|
||||
|
||||
mouse.downAt(-50, -50);
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
const selectedElementIds_2 = h.state.selectedElementIds;
|
||||
|
||||
expect(selectedElementIds_2).toEqual({ [rectangle.id]: true });
|
||||
|
||||
mouse.moveTo(60, 60);
|
||||
|
||||
// box-selecting further without changing selection should keep
|
||||
// selectedElementIds stable (the same object)
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
|
||||
|
||||
mouse.up();
|
||||
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
|
||||
});
|
||||
});
|
30
packages/excalidraw/tests/shortcuts.test.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { Excalidraw } from "../entry";
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { fireEvent, render, waitFor } from "./test-utils";
|
||||
|
||||
describe("shortcuts", () => {
|
||||
it("Clear canvas shortcut should display confirm dialog", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{ elements: [API.createElement({ type: "rectangle" })] }}
|
||||
handleKeyboardGlobally
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(window.h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyDown(KEYS.DELETE);
|
||||
});
|
||||
const confirmDialog = document.querySelector(".confirm-dialog")!;
|
||||
expect(confirmDialog).not.toBe(null);
|
||||
|
||||
fireEvent.click(confirmDialog.querySelector('[aria-label="Confirm"]')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.h.elements[0].isDeleted).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
250
packages/excalidraw/tests/test-utils.ts
Normal file
|
@ -0,0 +1,250 @@
|
|||
import "pepjs";
|
||||
|
||||
import {
|
||||
render,
|
||||
queries,
|
||||
RenderResult,
|
||||
RenderOptions,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import * as toolQueries from "./queries/toolQueries";
|
||||
import { ImportedDataState } from "../data/types";
|
||||
import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
||||
|
||||
import { SceneData } from "../types";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { UI } from "./helpers/ui";
|
||||
|
||||
const customQueries = {
|
||||
...queries,
|
||||
...toolQueries,
|
||||
};
|
||||
|
||||
type TestRenderFn = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<
|
||||
RenderOptions & { localStorageData?: ImportedDataState },
|
||||
"queries"
|
||||
>,
|
||||
) => Promise<RenderResult<typeof customQueries>>;
|
||||
|
||||
const renderApp: TestRenderFn = async (ui, options) => {
|
||||
if (options?.localStorageData) {
|
||||
initLocalStorage(options.localStorageData);
|
||||
delete options.localStorageData;
|
||||
}
|
||||
|
||||
const renderResult = render(ui, {
|
||||
queries: customQueries,
|
||||
...options,
|
||||
});
|
||||
|
||||
GlobalTestState.renderResult = renderResult;
|
||||
|
||||
Object.defineProperty(GlobalTestState, "canvas", {
|
||||
// must be a getter because at the time of ExcalidrawApp render the
|
||||
// child App component isn't likely mounted yet (and thus canvas not
|
||||
// present in DOM)
|
||||
get() {
|
||||
return renderResult.container.querySelector("canvas.static")!;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(GlobalTestState, "interactiveCanvas", {
|
||||
// must be a getter because at the time of ExcalidrawApp render the
|
||||
// child App component isn't likely mounted yet (and thus canvas not
|
||||
// present in DOM)
|
||||
get() {
|
||||
return renderResult.container.querySelector("canvas.interactive")!;
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = renderResult.container.querySelector("canvas.static");
|
||||
if (!canvas) {
|
||||
throw new Error("not initialized yet");
|
||||
}
|
||||
|
||||
const interactiveCanvas =
|
||||
renderResult.container.querySelector("canvas.interactive");
|
||||
if (!interactiveCanvas) {
|
||||
throw new Error("not initialized yet");
|
||||
}
|
||||
});
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
|
||||
// re-export everything
|
||||
export * from "@testing-library/react";
|
||||
|
||||
// override render method
|
||||
export { renderApp as render };
|
||||
|
||||
/**
|
||||
* For state-sharing across test helpers.
|
||||
* NOTE: there shouldn't be concurrency issues as each test is running in its
|
||||
* own process and thus gets its own instance of this module when running
|
||||
* tests in parallel.
|
||||
*/
|
||||
export class GlobalTestState {
|
||||
/**
|
||||
* automatically updated on each call to render()
|
||||
*/
|
||||
static renderResult: RenderResult<typeof customQueries> = null!;
|
||||
/**
|
||||
* retrieves static canvas for currently rendered app instance
|
||||
*/
|
||||
static get canvas(): HTMLCanvasElement {
|
||||
return null!;
|
||||
}
|
||||
/**
|
||||
* retrieves interactive canvas for currently rendered app instance
|
||||
*/
|
||||
static get interactiveCanvas(): HTMLCanvasElement {
|
||||
return null!;
|
||||
}
|
||||
}
|
||||
|
||||
const initLocalStorage = (data: ImportedDataState) => {
|
||||
if (data.elements) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(data.elements),
|
||||
);
|
||||
}
|
||||
if (data.appState) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(data.appState),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSceneData = (data: SceneData) => {
|
||||
(window.collab as any).excalidrawAPI.updateScene(data);
|
||||
};
|
||||
|
||||
const originalGetBoundingClientRect =
|
||||
global.window.HTMLDivElement.prototype.getBoundingClientRect;
|
||||
|
||||
export const mockBoundingClientRect = (
|
||||
{
|
||||
top = 0,
|
||||
left = 0,
|
||||
bottom = 0,
|
||||
right = 0,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
x = 0,
|
||||
y = 0,
|
||||
toJSON = () => {},
|
||||
} = {
|
||||
top: 10,
|
||||
left: 20,
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
width: 200,
|
||||
x: 10,
|
||||
y: 20,
|
||||
height: 100,
|
||||
},
|
||||
) => {
|
||||
// override getBoundingClientRect as by default it will always return all values as 0 even if customized in html
|
||||
global.window.HTMLDivElement.prototype.getBoundingClientRect = () => ({
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
toJSON,
|
||||
});
|
||||
};
|
||||
|
||||
export const withExcalidrawDimensions = async (
|
||||
dimensions: { width: number; height: number },
|
||||
cb: () => void,
|
||||
) => {
|
||||
mockBoundingClientRect(dimensions);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
window.h.app.refresh();
|
||||
|
||||
await cb();
|
||||
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
window.h.app.refresh();
|
||||
};
|
||||
|
||||
export const restoreOriginalGetBoundingClientRect = () => {
|
||||
global.window.HTMLDivElement.prototype.getBoundingClientRect =
|
||||
originalGetBoundingClientRect;
|
||||
};
|
||||
|
||||
export const assertSelectedElements = (
|
||||
...elements: (
|
||||
| (ExcalidrawElement["id"] | ExcalidrawElement)[]
|
||||
| ExcalidrawElement["id"]
|
||||
| ExcalidrawElement
|
||||
)[]
|
||||
) => {
|
||||
const { h } = window;
|
||||
const selectedElementIds = getSelectedElements(
|
||||
h.app.getSceneElements(),
|
||||
h.state,
|
||||
).map((el) => el.id);
|
||||
const ids = elements
|
||||
.flat()
|
||||
.map((item) => (typeof item === "string" ? item : item.id));
|
||||
expect(selectedElementIds.length).toBe(ids.length);
|
||||
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
|
||||
};
|
||||
|
||||
export const toggleMenu = (container: HTMLElement) => {
|
||||
// open menu
|
||||
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
|
||||
};
|
||||
|
||||
export const togglePopover = (label: string) => {
|
||||
// Needed for radix-ui/react-popover as tests fail due to resize observer not being present
|
||||
(global as any).ResizeObserver = class ResizeObserver {
|
||||
constructor(cb: any) {
|
||||
(this as any).cb = cb;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
UI.clickLabeledElement(label);
|
||||
};
|
||||
|
||||
expect.extend({
|
||||
toBeNonNaNNumber(received) {
|
||||
const pass = typeof received === "number" && !isNaN(received);
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => `expected ${received} not to be a non-NaN number`,
|
||||
pass: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: () => `expected ${received} to be a non-NaN number`,
|
||||
pass: false,
|
||||
};
|
||||
},
|
||||
});
|
57
packages/excalidraw/tests/tool.test.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Excalidraw } from "../index";
|
||||
import { ExcalidrawImperativeAPI } from "../types";
|
||||
import { resolvablePromise } from "../utils";
|
||||
import { render } from "./test-utils";
|
||||
import { Pointer } from "./helpers/ui";
|
||||
|
||||
describe("setActiveTool()", () => {
|
||||
const h = window.h;
|
||||
|
||||
let excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
/>,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
});
|
||||
|
||||
it("should expose setActiveTool on package API", () => {
|
||||
expect(excalidrawAPI.setActiveTool).toBeDefined();
|
||||
expect(excalidrawAPI.setActiveTool).toBe(h.app.setActiveTool);
|
||||
});
|
||||
|
||||
it("should set the active tool type", async () => {
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
excalidrawAPI.setActiveTool({ type: "rectangle" });
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
|
||||
it("should support tool locking", async () => {
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
excalidrawAPI.setActiveTool({ type: "rectangle", locked: true });
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("should set custom tool", async () => {
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
excalidrawAPI.setActiveTool({ type: "custom", customType: "comment" });
|
||||
expect(h.state.activeTool.type).toBe("custom");
|
||||
expect(h.state.activeTool.customType).toBe("comment");
|
||||
});
|
||||
});
|
13
packages/excalidraw/tests/utils.test.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import * as utils from "../utils";
|
||||
|
||||
describe("Test isTransparent", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(utils.isTransparent("#ff00")).toEqual(true);
|
||||
expect(utils.isTransparent("#fff00000")).toEqual(true);
|
||||
expect(utils.isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(utils.isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
67
packages/excalidraw/tests/viewMode.test.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { render, GlobalTestState } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { KEYS } from "../keys";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { CURSOR_TYPE } from "../constants";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
const touch = new Pointer("touch");
|
||||
const pen = new Pointer("pen");
|
||||
const pointerTypes = [mouse, touch, pen];
|
||||
|
||||
describe("view mode", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("after switching to view mode – cursor type should be pointer", async () => {
|
||||
h.setState({ viewModeEnabled: true });
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
|
||||
it("after switching to view mode, moving, clicking, and pressing space key – cursor type should be pointer", async () => {
|
||||
h.setState({ viewModeEnabled: true });
|
||||
|
||||
pointerTypes.forEach((pointerType) => {
|
||||
const pointer = pointerType;
|
||||
pointer.reset();
|
||||
pointer.move(100, 100);
|
||||
pointer.click();
|
||||
Keyboard.keyPress(KEYS.SPACE);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("cursor should stay as grabbing type when hovering over canvas elements", async () => {
|
||||
// create a rectangle, then hover over it – cursor should be
|
||||
// move type for mouse and grab for touch & pen
|
||||
// then switch to view-mode and cursor should be grabbing type
|
||||
UI.createElement("rectangle", { size: 100 });
|
||||
|
||||
pointerTypes.forEach((pointerType) => {
|
||||
const pointer = pointerType;
|
||||
|
||||
pointer.moveTo(50, 50);
|
||||
// eslint-disable-next-line dot-notation
|
||||
if (pointerType["pointerType"] === "mouse") {
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.MOVE,
|
||||
);
|
||||
} else {
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
}
|
||||
|
||||
h.setState({ viewModeEnabled: true });
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|