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
This commit is contained in:
Aakansha Doshi 2023-12-12 11:32:51 +05:30 committed by GitHub
parent b7d7ccc929
commit d6cd8b78f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
567 changed files with 5066 additions and 8648 deletions

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

View 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");
});
});

View 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>
`;

View file

@ -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] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|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>"
`;

File diff suppressed because one or more lines are too long

View 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",
}
`;

File diff suppressed because it is too large Load diff

View 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,
}
`;

View 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>
`;

File diff suppressed because one or more lines are too long

View 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"
/>
`;

View 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,
}
`;

View file

@ -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,
}
`;

File diff suppressed because it is too large Load diff

View 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,
}
`;

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

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

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

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

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

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

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

View 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 }),
]);
});
});

View file

@ -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,
}
`;

View 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,
}),
]);
});
});

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

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

View 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"));
});
});
});

View 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`);
});
});

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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;

View 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",
};

View 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": []
}
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View 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

View 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),
);
});
});

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

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

View 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,
};

View 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;
};
}

View 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 }),
);
});
});

View 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),
}),
]),
);
});
});

File diff suppressed because it is too large Load diff

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

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

View 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>
`;

View file

@ -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,
},
}
`;

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

View 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"));
};

View 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,
);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

File diff suppressed because one or more lines are too long

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

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

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

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

View 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,
};
},
});

View 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");
});
});

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

View 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,
);
});
});
});

File diff suppressed because it is too large Load diff