snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
46
packages/excalidraw/tests/App.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { reseed } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
import { render, queryByTestId, unmountComponent } from "../tests/test-utils";
|
||||
|
||||
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
unmountComponent();
|
||||
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();
|
||||
});
|
||||
});
|
||||
145
packages/excalidraw/tests/MermaidToExcalidraw.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { mockMermaidToExcalidraw } from "./helpers/mocks";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
import { render, waitFor } from "./test-utils";
|
||||
|
||||
// Mock CodeMirror deps so the dynamic import of CodeMirrorEditor fails,
|
||||
// causing TTDDialogInput to fall back to <textarea> in tests.
|
||||
vi.mock("@codemirror/view", () => ({}));
|
||||
vi.mock("@codemirror/state", () => ({}));
|
||||
vi.mock("@codemirror/language", () => ({}));
|
||||
vi.mock("@lezer/highlight", () => ({}));
|
||||
|
||||
mockMermaidToExcalidraw({
|
||||
mockRef: true,
|
||||
parseMermaidToExcalidraw: async (definition) => {
|
||||
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"));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeDialogSnapshot = (dialog: Element) => {
|
||||
const dialogClone = dialog.cloneNode(true) as HTMLElement;
|
||||
|
||||
dialogClone
|
||||
.querySelectorAll<HTMLElement>(".ttd-dialog-content")
|
||||
.forEach((element) => {
|
||||
// Radix Tabs injects this during initial mount animation prevention.
|
||||
// Its presence depends on render timing and is unrelated to this test.
|
||||
if (element.style.animationDuration === "0s") {
|
||||
element.style.removeProperty("animation-duration");
|
||||
}
|
||||
|
||||
if (!element.getAttribute("style")) {
|
||||
element.removeAttribute("style");
|
||||
}
|
||||
});
|
||||
|
||||
return dialogClone.outerHTML;
|
||||
};
|
||||
|
||||
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(() => expect(dialog.querySelector("canvas")).not.toBeNull());
|
||||
expect(normalizeDialogSnapshot(dialog)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
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, waitForEditor: true });
|
||||
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
|
||||
|
||||
expect(editor.textContent).toMatchSnapshot();
|
||||
|
||||
updateTextEditor(editor, "flowchart TD1");
|
||||
editor = await getTextEditor({ selector, waitForEditor: false });
|
||||
|
||||
expect(editor.textContent).toBe("flowchart TD1");
|
||||
expect(
|
||||
dialog.querySelector('[data-testid="mermaid-error"]'),
|
||||
).toMatchInlineSnapshot("null");
|
||||
});
|
||||
});
|
||||
50
packages/excalidraw/tests/__snapshots__/App.test.tsx.snap
Normal file
@@ -0,0 +1,50 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
|
||||
<div
|
||||
data-testid="brave-measure-text-error"
|
||||
>
|
||||
<p>
|
||||
Looks like you are using Brave browser with the
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Aggressively Block Fingerprinting
|
||||
</span>
|
||||
setting enabled.
|
||||
</p>
|
||||
<p>
|
||||
This could result in breaking the
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Text Elements
|
||||
</span>
|
||||
in your drawings.
|
||||
</p>
|
||||
<p>
|
||||
We strongly recommend disabling this setting. You can follow
|
||||
<a
|
||||
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
||||
>
|
||||
these steps
|
||||
</a>
|
||||
on how to do so.
|
||||
</p>
|
||||
<p>
|
||||
If disabling this setting doesn't fix the display of text elements, please open an
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw/issues/new"
|
||||
>
|
||||
issue
|
||||
</a>
|
||||
on our GitHub, or write us on
|
||||
<a
|
||||
href="https://discord.gg/UexuTaE"
|
||||
>
|
||||
Discord
|
||||
.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><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-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</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-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><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>"`;
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
|
||||
"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]"
|
||||
`;
|
||||
257
packages/excalidraw/tests/__snapshots__/MobileMenu.test.tsx.snap
Normal file
25
packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap
Normal file
@@ -0,0 +1,25 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`tryParseSpreadsheet > works for numbers with comma in them 1`] = `
|
||||
{
|
||||
"data": {
|
||||
"labels": [
|
||||
"Week 1",
|
||||
"Week 2",
|
||||
"Week 3",
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"title": "Users",
|
||||
"values": [
|
||||
814,
|
||||
10301,
|
||||
4264,
|
||||
],
|
||||
},
|
||||
],
|
||||
"title": "Users",
|
||||
},
|
||||
"ok": true,
|
||||
}
|
||||
`;
|
||||
10217
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
Normal file
206
packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap
Normal file
@@ -0,0 +1,206 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 3`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"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": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 3`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"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 > ellipse 3`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"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 > line 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
30,
|
||||
50,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 3`] = `1`;
|
||||
|
||||
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
786
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
Normal file
@@ -0,0 +1,786 @@
|
||||
// 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
|
||||
aria-labelledby="radix-:r85:"
|
||||
aria-orientation="vertical"
|
||||
class="dropdown-menu main-menu"
|
||||
data-align="start"
|
||||
data-orientation="vertical"
|
||||
data-radix-menu-content=""
|
||||
data-side="bottom"
|
||||
data-state="open"
|
||||
data-testid="dropdown-menu"
|
||||
dir="ltr"
|
||||
id="radix-:r86:"
|
||||
role="menu"
|
||||
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2;"
|
||||
>
|
||||
<button
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Click me
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
href="blog.excalidaw.com"
|
||||
rel="noopener noopener"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Excalidraw blog
|
||||
</span>
|
||||
</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="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="help-menu-item"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Help"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Help
|
||||
</span>
|
||||
</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
|
||||
aria-labelledby="radix-:rq:"
|
||||
aria-orientation="vertical"
|
||||
class="dropdown-menu main-menu"
|
||||
data-align="start"
|
||||
data-orientation="vertical"
|
||||
data-radix-menu-content=""
|
||||
data-side="bottom"
|
||||
data-state="open"
|
||||
data-testid="dropdown-menu"
|
||||
dir="ltr"
|
||||
id="radix-:rr:"
|
||||
role="menu"
|
||||
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2;"
|
||||
>
|
||||
<button
|
||||
aria-label="Open"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="load-button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Open"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Save to..."
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="json-export-button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Save to..."
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Save to...
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Export image..."
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="image-export-button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Export image..."
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Export image...
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Find on canvas"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="search-menu-button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Find on canvas"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<path
|
||||
d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
|
||||
/>
|
||||
<path
|
||||
d="M21 21l-6 -6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Find on canvas
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+F
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="help-menu-item"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Help"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Help
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Reset the canvas"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="clear-canvas-button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Reset the canvas"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Reset the canvas
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="height: 1px; margin: 6px 0px; flex: 0 0 auto;"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-group "
|
||||
>
|
||||
<p
|
||||
class="dropdown-menu-group-title"
|
||||
>
|
||||
Excalidraw links
|
||||
</p>
|
||||
<a
|
||||
aria-label="GitHub"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
rel="noopener noopener"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
GitHub
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="X"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
href="https://x.com/excalidraw"
|
||||
rel="noopener noopener"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
target="_blank"
|
||||
title="X"
|
||||
>
|
||||
<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="M4 4l11.733 16h4.267l-11.733 -16z"
|
||||
/>
|
||||
<path
|
||||
d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Follow us
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Discord"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
href="https://discord.gg/UexuTaE"
|
||||
rel="noopener noopener"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Discord chat
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
style="height: 1px; margin: 6px 0px; flex: 0 0 auto;"
|
||||
/>
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
class="radix-menu-item dropdown-menu-item dropdown-menu-item-base"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
data-testid="toggle-dark-mode"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
title="Dark mode"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Dark mode
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Shift+Alt+D
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="margin-top: 0.75rem;"
|
||||
>
|
||||
<div
|
||||
data-testid="canvas-background-label"
|
||||
style="font-size: 0.875rem; margin-bottom: 0.25rem; margin-left: 0.5rem;"
|
||||
>
|
||||
Canvas background
|
||||
</div>
|
||||
<div
|
||||
style="padding: 0px 0.625rem;"
|
||||
>
|
||||
<div
|
||||
aria-modal="true"
|
||||
class="color-picker-container"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="color-picker__top-picks"
|
||||
>
|
||||
<button
|
||||
class="color-picker__button active has-outline"
|
||||
data-testid="color-top-pick-#ffffff"
|
||||
data-top-pick-index="0"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="#ffffff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
data-testid="color-top-pick-#f8f9fa"
|
||||
data-top-pick-index="1"
|
||||
style="--swatch-color: #f8f9fa;"
|
||||
title="#f8f9fa"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
data-testid="color-top-pick-#f5faff"
|
||||
data-top-pick-index="2"
|
||||
style="--swatch-color: #f5faff;"
|
||||
title="#f5faff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
data-testid="color-top-pick-#fffce8"
|
||||
data-top-pick-index="3"
|
||||
style="--swatch-color: #fffce8;"
|
||||
title="#fffce8"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button has-outline"
|
||||
data-testid="color-top-pick-#fdf8f6"
|
||||
data-top-pick-index="4"
|
||||
style="--swatch-color: #fdf8f6;"
|
||||
title="#fdf8f6"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="width: 1px; height: 1rem; margin: 0px auto;"
|
||||
/>
|
||||
<button
|
||||
aria-controls="radix-:r16:"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
class="color-picker__button active-color properties-trigger has-outline"
|
||||
data-openpopup="canvasBackground"
|
||||
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>
|
||||
`;
|
||||
11
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
Normal file
22881
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
Normal file
241
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
Normal file
@@ -0,0 +1,241 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 23633383,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id4",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1505387817,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 81784553,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangle 5`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1014066025,
|
||||
"width": 30,
|
||||
"x": 0,
|
||||
"y": 40,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangles with binding arrow 5`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id6",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 760410951,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangles with binding arrow 6`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id6",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 300,
|
||||
"id": "id3",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1116226695,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 735304455,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`move element > rectangles with binding arrow 7`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id3",
|
||||
"fixedPoint": [
|
||||
"-0.02000",
|
||||
"0.48010",
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "90.01760",
|
||||
"id": "id6",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"moveMidPointsWithElement": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
89,
|
||||
"90.01760",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 23633383,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
"fixedPoint": [
|
||||
"1.06000",
|
||||
"0.56011",
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 13,
|
||||
"versionNonce": 271613161,
|
||||
"width": 89,
|
||||
"x": 106,
|
||||
"y": "56.01120",
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,107 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`multi point mode in linear elements > arrow 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 110,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"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": 5,
|
||||
"versionNonce": 1014066025,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`multi point mode in linear elements > line 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 110,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
20,
|
||||
30,
|
||||
],
|
||||
[
|
||||
70,
|
||||
110,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 1014066025,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
}
|
||||
`;
|
||||
15720
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
Normal file
198
packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap
Normal file
@@ -0,0 +1,198 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`select single element on the scene > arrow 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"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": 5,
|
||||
"versionNonce": 1116226695,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > arrow escape 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
30,
|
||||
50,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 1116226695,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > diamond 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > ellipse 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`select single element on the scene > rectangle 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
}
|
||||
`;
|
||||
182
packages/excalidraw/tests/actionStyles.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CODES,
|
||||
COLOR_PALETTE,
|
||||
ROUNDNESS,
|
||||
STROKE_WIDTH,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { copiedStyles } from "../actions/actionStyles";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
togglePopover,
|
||||
} from "../tests/test-utils";
|
||||
|
||||
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.getByTestId("opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
mouse.reset();
|
||||
|
||||
API.setSelectedElements([h.elements[1]] as NonDeletedExcalidrawElement[]);
|
||||
|
||||
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]] as NonDeletedExcalidrawElement[]);
|
||||
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(STROKE_WIDTH.bold);
|
||||
expect(firstRect.strokeStyle).toBe("dotted");
|
||||
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
||||
expect(firstRect.opacity).toBe(60);
|
||||
});
|
||||
|
||||
it("should show and apply roughness for sticky notes", async () => {
|
||||
const stickyNote = API.createElement({
|
||||
type: "stickynote",
|
||||
roughness: 0,
|
||||
});
|
||||
|
||||
API.setElements([stickyNote]);
|
||||
API.setSelectedElements([stickyNote]);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
|
||||
expect(API.getSelectedElement().roughness).toBe(2);
|
||||
});
|
||||
|
||||
it("should show and apply roundness for sticky notes", async () => {
|
||||
const stickyNote = API.createElement({
|
||||
type: "stickynote",
|
||||
roundness: null,
|
||||
});
|
||||
|
||||
API.setElements([stickyNote]);
|
||||
API.setSelectedElements([stickyNote]);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Round"));
|
||||
|
||||
expect(API.getSelectedElement().roundness).toEqual({
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide transparent stroke color for sticky notes", async () => {
|
||||
UI.clickTool("stickynote");
|
||||
|
||||
togglePopover("Text color");
|
||||
|
||||
// hidden via `excludedColors` (same mechanism as bucket fill): the swatch
|
||||
// is gone, but an invisible placeholder keeps the grid so the remaining
|
||||
// colors stay on their usual positions and hotkeys
|
||||
expect(screen.queryByTestId("color-transparent")).toBeNull();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.color-picker-content--default [aria-hidden="true"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByTestId("color-black")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the transparent hotkey dead and the others intact for sticky notes", async () => {
|
||||
UI.clickTool("stickynote");
|
||||
|
||||
togglePopover("Text color");
|
||||
|
||||
const picker = document.querySelector(".color-picker-content")!;
|
||||
const before = h.state.currentItemStickynoteStrokeColor;
|
||||
|
||||
// `q` maps to the hidden transparent swatch (and to the global tool-lock
|
||||
// shortcut) — inside the popup it must be inert, not leaked
|
||||
fireEvent.keyDown(picker, { key: "q" });
|
||||
expect(h.state.currentItemStickynoteStrokeColor).toBe(before);
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
|
||||
// the remaining colors keep their usual keys (w = white)
|
||||
fireEvent.keyDown(picker, { key: "w" });
|
||||
expect(h.state.currentItemStickynoteStrokeColor).toBe(COLOR_PALETTE.white);
|
||||
});
|
||||
|
||||
it("should track sticky note stroke color separately", async () => {
|
||||
API.setAppState({
|
||||
currentItemStrokeColor: COLOR_PALETTE.red[4],
|
||||
currentItemStickynoteStrokeColor: COLOR_PALETTE.black,
|
||||
});
|
||||
const stickyNote = API.createElement({
|
||||
type: "stickynote",
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
});
|
||||
|
||||
API.setElements([stickyNote]);
|
||||
API.setSelectedElements([stickyNote]);
|
||||
|
||||
togglePopover("Text color");
|
||||
UI.clickOnTestId("color-blue");
|
||||
|
||||
expect(API.getSelectedElement().strokeColor).toBe(COLOR_PALETTE.blue[4]);
|
||||
expect(h.state.currentItemStickynoteStrokeColor).toBe(
|
||||
COLOR_PALETTE.blue[4],
|
||||
);
|
||||
expect(h.state.currentItemStrokeColor).toBe(COLOR_PALETTE.red[4]);
|
||||
});
|
||||
});
|
||||
167
packages/excalidraw/tests/animation.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnimationController } from "../renderer/animation";
|
||||
|
||||
const FIRST_KEY = "animation-test-first";
|
||||
const SECOND_KEY = "animation-test-second";
|
||||
|
||||
describe("AnimationController", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
AnimationController.cancel(SECOND_KEY);
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts a new animation after the previous last animation was cancelled", async () => {
|
||||
let firstFrames = 0;
|
||||
AnimationController.start(FIRST_KEY, () => {
|
||||
firstFrames++;
|
||||
return { keep: true };
|
||||
});
|
||||
|
||||
expect(firstFrames).toBe(1);
|
||||
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
let secondFrames = 0;
|
||||
AnimationController.start(SECOND_KEY, () => {
|
||||
secondFrames++;
|
||||
return secondFrames === 1 ? { keep: true } : null;
|
||||
});
|
||||
|
||||
expect(secondFrames).toBe(1);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(secondFrames).toBe(2);
|
||||
expect(AnimationController.running(SECOND_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels a frame scheduled during a tick if no animations remain", async () => {
|
||||
let firstFrames = 0;
|
||||
let secondFrames = 0;
|
||||
|
||||
AnimationController.start(FIRST_KEY, ({ state }) => {
|
||||
if (!state) {
|
||||
return { keep: true };
|
||||
}
|
||||
|
||||
firstFrames++;
|
||||
|
||||
AnimationController.start(SECOND_KEY, () => {
|
||||
secondFrames++;
|
||||
return { keep: true };
|
||||
});
|
||||
AnimationController.cancel(SECOND_KEY);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(firstFrames).toBe(1);
|
||||
expect(secondFrames).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not resurrect an animation cancelled during its initial callback", () => {
|
||||
let frames = 0;
|
||||
|
||||
AnimationController.start(FIRST_KEY, () => {
|
||||
frames++;
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
return { keep: true };
|
||||
});
|
||||
|
||||
expect(frames).toBe(1);
|
||||
expect(AnimationController.running(FIRST_KEY)).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("cleans up the registration when the initial callback throws", () => {
|
||||
expect(() =>
|
||||
AnimationController.start(FIRST_KEY, () => {
|
||||
throw new Error("initial frame failed");
|
||||
}),
|
||||
).toThrow("initial frame failed");
|
||||
|
||||
expect(AnimationController.running(FIRST_KEY)).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves a same-key replacement started during the initial callback", async () => {
|
||||
let originalFrames = 0;
|
||||
let replacementFrames = 0;
|
||||
|
||||
const replacement = ({ state }: { state?: { keep: true } }) => {
|
||||
replacementFrames++;
|
||||
return state ? null : { keep: true as const };
|
||||
};
|
||||
|
||||
AnimationController.start(FIRST_KEY, () => {
|
||||
originalFrames++;
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
AnimationController.start(FIRST_KEY, replacement);
|
||||
return { keep: true };
|
||||
});
|
||||
|
||||
expect(originalFrames).toBe(1);
|
||||
expect(replacementFrames).toBe(1);
|
||||
expect(AnimationController.running(FIRST_KEY)).toBe(true);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(originalFrames).toBe(1);
|
||||
expect(replacementFrames).toBe(2);
|
||||
expect(AnimationController.running(FIRST_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a completed callback delete its same-key replacement", async () => {
|
||||
// tests for this unwanted case:
|
||||
//
|
||||
// 1. The original animation’s scheduled callback begins.
|
||||
// 2. Before it returns, application code cancels it and starts
|
||||
// a replacement using the same key.
|
||||
// 3. The original callback returns null.
|
||||
// 4. Previously, tick() then called delete(key), accidentally deleting
|
||||
// the replacement.
|
||||
|
||||
let originalFrames = 0;
|
||||
let replacementFrames = 0;
|
||||
|
||||
const replacement = ({ state }: { state?: { keep: true } }) => {
|
||||
replacementFrames++;
|
||||
return state ? null : { keep: true as const };
|
||||
};
|
||||
|
||||
AnimationController.start(FIRST_KEY, ({ state }) => {
|
||||
if (!state) {
|
||||
return { keep: true };
|
||||
}
|
||||
|
||||
originalFrames++;
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
AnimationController.start(FIRST_KEY, replacement);
|
||||
return null;
|
||||
});
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(originalFrames).toBe(1);
|
||||
expect(replacementFrames).toBe(1);
|
||||
expect(AnimationController.running(FIRST_KEY)).toBe(true);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(originalFrames).toBe(1);
|
||||
expect(replacementFrames).toBe(2);
|
||||
expect(AnimationController.running(FIRST_KEY)).toBe(false);
|
||||
});
|
||||
});
|
||||
88
packages/excalidraw/tests/appState.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Pointer, UI } from "./helpers/ui";
|
||||
import { fireEvent, queryByTestId, render, waitFor } from "./test-utils";
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: 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);
|
||||
fireEvent.click(queryByTestId(container, "fontSize-small")!);
|
||||
expect(h.state.currentItemFontSize).toBe(16);
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
mouse.clickAt(100, 100);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawTextElement).fontSize).toBe(16);
|
||||
});
|
||||
});
|
||||
111
packages/excalidraw/tests/appStateHooks.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ExcalidrawAPIContext } from "../components/App";
|
||||
import { AppStateObserver } from "../components/AppStateObserver";
|
||||
import {
|
||||
useAppStateValue,
|
||||
useOnAppStateChange,
|
||||
} from "../hooks/useAppStateValue";
|
||||
|
||||
import type { AppState, ExcalidrawImperativeAPI } from "../types";
|
||||
|
||||
const createAppState = (): AppState => ({
|
||||
...getDefaultAppState(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
});
|
||||
|
||||
const createMockAPI = (initialState: AppState) => {
|
||||
let state = initialState;
|
||||
const observer = new AppStateObserver(() => state);
|
||||
|
||||
return {
|
||||
api: {
|
||||
isDestroyed: false,
|
||||
getAppState: () => state,
|
||||
onStateChange: observer.onStateChange,
|
||||
} as Pick<
|
||||
ExcalidrawImperativeAPI,
|
||||
"isDestroyed" | "getAppState" | "onStateChange"
|
||||
> as ExcalidrawImperativeAPI,
|
||||
updateAppState: (partial: Partial<AppState>) => {
|
||||
const prevState = state;
|
||||
state = { ...state, ...partial };
|
||||
observer.flush(prevState);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("app state hooks", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("useAppStateValue rerenders when the selected value changes", () => {
|
||||
const renderSpy = vi.fn();
|
||||
const { api, updateAppState } = createMockAPI(createAppState());
|
||||
|
||||
const ValueConsumer = () => {
|
||||
const value = useAppStateValue("viewModeEnabled");
|
||||
renderSpy(value);
|
||||
return <div data-testid="value">{String(value)}</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<ExcalidrawAPIContext.Provider value={api}>
|
||||
<ValueConsumer />
|
||||
</ExcalidrawAPIContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("value").textContent).toBe("false");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
updateAppState({ viewModeEnabled: true });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("value").textContent).toBe("true");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("useOnAppStateChange notifies without rerendering", () => {
|
||||
const renderSpy = vi.fn();
|
||||
const callback = vi.fn();
|
||||
const { api, updateAppState } = createMockAPI(createAppState());
|
||||
|
||||
const ChangeConsumer = () => {
|
||||
const value = useOnAppStateChange("viewModeEnabled", callback);
|
||||
renderSpy(value);
|
||||
return <div data-testid="value">{String(value)}</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<ExcalidrawAPIContext.Provider value={api}>
|
||||
<ChangeConsumer />
|
||||
</ExcalidrawAPIContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("value").textContent).toBe("undefined");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
false,
|
||||
expect.objectContaining({ viewModeEnabled: false }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
updateAppState({ viewModeEnabled: true });
|
||||
});
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ viewModeEnabled: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
653
packages/excalidraw/tests/arrowBinding.test.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import { reseed } from "@excalidraw/common";
|
||||
import {
|
||||
isElbowArrow,
|
||||
projectFixedPointOntoDiagonal,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionToggleArrowBinding } from "../actions/actionToggleArrowBinding";
|
||||
import { Excalidraw, sceneCoordsToViewportCoords } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
waitFor,
|
||||
unmountComponent,
|
||||
} from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fire Ctrl (or Meta on Mac) keydown on document. */
|
||||
const ctrlKeyDown = (extra: Partial<KeyboardEventInit> = {}) =>
|
||||
fireEvent.keyDown(document, {
|
||||
key: "Control",
|
||||
code: "ControlLeft",
|
||||
ctrlKey: true,
|
||||
repeat: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** Fire Ctrl (or Meta on Mac) keyup on document (ctrlKey is false on keyup). */
|
||||
const ctrlKeyUp = () =>
|
||||
fireEvent.keyUp(document, {
|
||||
key: "Control",
|
||||
code: "ControlLeft",
|
||||
ctrlKey: false,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Arrow binding – non-default case (bindingPreference: disabled)", () => {
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.state.width = 1920;
|
||||
h.state.height = 1080;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mouse.reset();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// actionToggleArrowBinding
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("actionToggleArrowBinding", () => {
|
||||
it("isBindingEnabled defaults to true", () => {
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("checked() reflects the current bindingPreference value", () => {
|
||||
expect(actionToggleArrowBinding.checked!(h.state)).toBe(true);
|
||||
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
expect(actionToggleArrowBinding.checked!(h.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("executing the action toggles binding from enabled → disabled", () => {
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
API.executeAction(actionToggleArrowBinding);
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
expect(h.state.bindingPreference).toBe("disabled");
|
||||
});
|
||||
|
||||
it("executing the action toggles binding from disabled → enabled", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
API.executeAction(actionToggleArrowBinding);
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
expect(h.state.bindingPreference).toBe("enabled");
|
||||
});
|
||||
|
||||
it("checked() returns false after action disables binding", () => {
|
||||
API.executeAction(actionToggleArrowBinding); // true → false
|
||||
expect(actionToggleArrowBinding.checked!(h.state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Arrow does not bind when isBindingEnabled is false
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Arrow drawing with binding disabled", () => {
|
||||
/**
|
||||
* Baseline: verify binding IS created with binding enabled so we know the
|
||||
* spatial setup is correct.
|
||||
*/
|
||||
it("arrow startBinding is set when binding is enabled", async () => {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "baselineRect",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 200,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
// Start inside the rectangle so startBinding can be created
|
||||
mouse.down(200, 200);
|
||||
mouse.up(700, 200);
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.type === "arrow",
|
||||
);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.startBinding).not.toBeNull();
|
||||
expect(arrow!.startBinding!.elementId).toBe("baselineRect");
|
||||
});
|
||||
});
|
||||
|
||||
it("arrow has no startBinding when binding is disabled", async () => {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect1",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 200,
|
||||
}),
|
||||
]);
|
||||
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
// Start inside the rectangle – binding is off, so no startBinding
|
||||
mouse.down(200, 200);
|
||||
mouse.up(700, 200);
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.type === "arrow",
|
||||
);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.startBinding).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("arrow has no endBinding when binding is disabled", async () => {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect2",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200,
|
||||
}),
|
||||
]);
|
||||
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
// End inside the target rectangle – binding off -> no endBinding
|
||||
mouse.down(100, 200);
|
||||
mouse.up(600, 200);
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.type === "arrow",
|
||||
);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.endBinding).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-enabling binding via action causes new arrows to bind again", async () => {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect3",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 200,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Disable then re-enable binding
|
||||
API.executeAction(actionToggleArrowBinding); // true -> false
|
||||
API.executeAction(actionToggleArrowBinding); // false -> true
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(700, 200);
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.type === "arrow",
|
||||
);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.startBinding).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("elbow arrow does not snap to rectangle when binding is disabled", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect-elbow-no-snap",
|
||||
x: 900,
|
||||
y: 500,
|
||||
width: 200,
|
||||
height: 120,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([rect]);
|
||||
|
||||
// Turn off arrow binding
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
isMidpointSnappingEnabled: false,
|
||||
});
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
|
||||
// Create the elbow arrow
|
||||
UI.clickTool("arrow");
|
||||
API.setAppState({ currentItemArrowType: "elbow" });
|
||||
mouse.downAt(700, 400);
|
||||
mouse.upAt(760, 460);
|
||||
|
||||
let arrow: ExcalidrawArrowElement;
|
||||
await waitFor(() => {
|
||||
const maybeArrow = h.elements.find(isElbowArrow);
|
||||
expect(maybeArrow).toBeDefined();
|
||||
arrow = maybeArrow!;
|
||||
});
|
||||
|
||||
// Move the elbow arrow
|
||||
UI.clickTool("selection");
|
||||
|
||||
const insideRectanglePoint = pointFrom<GlobalPoint>(1010, 570);
|
||||
const originalArrowEndGlobal = pointFrom<GlobalPoint>(
|
||||
arrow!.x + arrow!.points[arrow!.points.length - 1][0],
|
||||
arrow!.y + arrow!.points[arrow!.points.length - 1][1],
|
||||
);
|
||||
const originalArrowEndViewport = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: originalArrowEndGlobal[0],
|
||||
sceneY: originalArrowEndGlobal[1],
|
||||
},
|
||||
h.state,
|
||||
);
|
||||
const insideRectangleViewport = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: insideRectanglePoint[0],
|
||||
sceneY: insideRectanglePoint[1],
|
||||
},
|
||||
h.state,
|
||||
);
|
||||
|
||||
// End point dragged inside the rectangle; with snapping enabled this
|
||||
// would snap to the rectangle outline.
|
||||
mouse.moveTo(originalArrowEndViewport.x, originalArrowEndViewport.y);
|
||||
mouse.down();
|
||||
mouse.moveTo(insideRectangleViewport.x, insideRectangleViewport.y);
|
||||
mouse.up();
|
||||
|
||||
const updatedEnd = arrow!.points[arrow!.points.length - 1];
|
||||
const updatedEndGlobal = pointFrom<GlobalPoint>(
|
||||
arrow!.x + updatedEnd[0],
|
||||
arrow!.y + updatedEnd[1],
|
||||
);
|
||||
|
||||
expect(arrow!.startBinding).toBeNull();
|
||||
expect(arrow!.endBinding).toBeNull();
|
||||
expect(updatedEndGlobal).not.toEqual(originalArrowEndGlobal);
|
||||
expect(updatedEndGlobal).toEqual(insideRectanglePoint);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Arrow does snap to midpoint when isMidpointSnappingEnabled is true
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Midpoint snapping", () => {
|
||||
it("does not snap to midpoint when midpoint snapping is turned off", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectNoMidSnap",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 200,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 250,
|
||||
width: 502,
|
||||
height: -48,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(502, -48)],
|
||||
}) as ExcalidrawArrowElement;
|
||||
const elementsMap = new Map<string, ExcalidrawElement>([
|
||||
[rect.id, rect],
|
||||
[arrow.id, arrow],
|
||||
]);
|
||||
const point = pointFrom<GlobalPoint>(502, 202);
|
||||
|
||||
const snappedWithMidpoint = projectFixedPointOntoDiagonal(
|
||||
arrow,
|
||||
point,
|
||||
rect,
|
||||
"end",
|
||||
elementsMap,
|
||||
h.state.zoom,
|
||||
true,
|
||||
);
|
||||
const snappedWithoutMidpoint = projectFixedPointOntoDiagonal(
|
||||
arrow,
|
||||
point,
|
||||
rect,
|
||||
"end",
|
||||
elementsMap,
|
||||
h.state.zoom,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(snappedWithMidpoint).toEqual([500, 200]);
|
||||
expect(snappedWithoutMidpoint).not.toEqual([500, 200]);
|
||||
});
|
||||
|
||||
it("does not snap an angle-locked elbow binding to midpoint on finalize", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect-elbow-angle-lock",
|
||||
x: 600,
|
||||
y: 300,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([rect]);
|
||||
API.setAppState({
|
||||
currentItemArrowType: "elbow",
|
||||
gridModeEnabled: false,
|
||||
isMidpointSnappingEnabled: true,
|
||||
});
|
||||
|
||||
const start = sceneCoordsToViewportCoords(
|
||||
{ sceneX: 480, sceneY: 392 },
|
||||
h.state,
|
||||
);
|
||||
const target = sceneCoordsToViewportCoords(
|
||||
{ sceneX: 596, sceneY: 397 },
|
||||
h.state,
|
||||
);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
mouse.downAt(start.x, start.y);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(target.x, target.y);
|
||||
mouse.upAt();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(isElbowArrow);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.endBinding?.elementId).toBe(rect.id);
|
||||
|
||||
const endY = arrow!.y + arrow!.points.at(-1)![1];
|
||||
expect(endY).toBeCloseTo(397, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Grid binding interactions", () => {
|
||||
it("does not snap angle-locked binding to grid when grid mode is disabled", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect-angle-lock-no-grid",
|
||||
x: 600,
|
||||
y: 300,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([rect]);
|
||||
API.setAppState({ gridModeEnabled: false, gridSize: 20 });
|
||||
|
||||
const start = sceneCoordsToViewportCoords(
|
||||
{ sceneX: 300, sceneY: 333 },
|
||||
h.state,
|
||||
);
|
||||
const target = sceneCoordsToViewportCoords(
|
||||
{ sceneX: 596, sceneY: 347 },
|
||||
h.state,
|
||||
);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
mouse.downAt(start.x, start.y);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(target.x, target.y);
|
||||
mouse.upAt();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(
|
||||
(element): element is ExcalidrawArrowElement =>
|
||||
element.type === "arrow",
|
||||
);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.endBinding?.elementId).toBe(rect.id);
|
||||
|
||||
const startY = arrow!.y + arrow!.points[0][1];
|
||||
const endY = arrow!.y + arrow!.points.at(-1)![1];
|
||||
expect(endY).toBeCloseTo(startY);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the incoming direction to choose the grid axis for rotated bindables", async () => {
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
id: "ellipse-rotated-grid-binding",
|
||||
x: 610,
|
||||
y: 300,
|
||||
width: 240,
|
||||
height: 120,
|
||||
angle: Math.PI / 3,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([ellipse]);
|
||||
API.setAppState({ gridModeEnabled: true, gridSize: 20 });
|
||||
|
||||
const start = sceneCoordsToViewportCoords(
|
||||
{ sceneX: 400, sceneY: 360 },
|
||||
h.state,
|
||||
);
|
||||
const target = sceneCoordsToViewportCoords(
|
||||
{ sceneX: 658, sceneY: 360 },
|
||||
h.state,
|
||||
);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
mouse.downAt(start.x, start.y);
|
||||
mouse.moveTo(target.x, target.y);
|
||||
mouse.upAt();
|
||||
|
||||
await waitFor(() => {
|
||||
const arrow = h.elements.find(
|
||||
(element): element is ExcalidrawArrowElement =>
|
||||
element.type === "arrow",
|
||||
);
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow!.endBinding?.elementId).toBe(ellipse.id);
|
||||
|
||||
const startY = arrow!.y + arrow!.points[0][1];
|
||||
const endY = arrow!.y + arrow!.points.at(-1)![1];
|
||||
expect(endY).toBeCloseTo(startY, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Ctrl / Cmd key toggle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Ctrl key toggle when binding preference is disabled", () => {
|
||||
it("Ctrl keydown temporarily enables binding when preference is disabled", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
ctrlKeyDown();
|
||||
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Ctrl keyup restores isBindingEnabled to false after the temporary toggle", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
ctrlKeyUp();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("full round-trip: off → Ctrl down → on → Ctrl up → off", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
ctrlKeyUp();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("full round-trip can be repeated after Ctrl release resets state", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
// First cycle
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
ctrlKeyUp();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
|
||||
// Second cycle
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
ctrlKeyUp();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ctrl key toggle when binding starts ON", () => {
|
||||
it("Ctrl keydown temporarily disables binding when it was on", () => {
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("Ctrl keyup restores isBindingEnabled to true after the temporary toggle", () => {
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
|
||||
ctrlKeyUp();
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// event.repeat guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("event.repeat guard", () => {
|
||||
it("Ctrl keydown with repeat=true does not toggle binding (preference: disabled)", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
});
|
||||
|
||||
ctrlKeyDown({ repeat: true });
|
||||
|
||||
// Must remain off – repeat events are ignored
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("Ctrl keydown with repeat=true does not toggle binding (default: on)", () => {
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
ctrlKeyDown({ repeat: true });
|
||||
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("pressing another key while Ctrl held does not change binding state", () => {
|
||||
// Start ON, first Ctrl keydown flips to false
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
|
||||
ctrlKeyDown();
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
|
||||
// A second keydown with Ctrl (e.g. pressing another key while Ctrl held)
|
||||
// should leave state unchanged
|
||||
fireEvent.keyDown(document, {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
repeat: false,
|
||||
});
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
|
||||
// Ctrl keyup restores to preference value
|
||||
ctrlKeyUp();
|
||||
expect(h.state.isBindingEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View-mode guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("View-mode guard", () => {
|
||||
it("Ctrl keydown in viewMode does not toggle isBindingEnabled", () => {
|
||||
API.setAppState({
|
||||
bindingPreference: "disabled",
|
||||
isBindingEnabled: false,
|
||||
viewModeEnabled: true,
|
||||
});
|
||||
|
||||
ctrlKeyDown();
|
||||
|
||||
// Handler returns early in viewMode — state must stay false
|
||||
expect(h.state.isBindingEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
860
packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
import { render, unmountComponent } from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
/**
|
||||
* An arrow whose endpoint sits at (endX, endY), approached from (startX, startY).
|
||||
*/
|
||||
const createArrow = (
|
||||
id: string,
|
||||
[startX, startY]: [number, number],
|
||||
[endX, endY]: [number, number],
|
||||
overrides: Partial<ExcalidrawArrowElement> = {},
|
||||
) =>
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id,
|
||||
x: startX,
|
||||
y: startY,
|
||||
width: Math.abs(endX - startX),
|
||||
height: Math.abs(endY - startY),
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(endX - startX, endY - startY),
|
||||
],
|
||||
...overrides,
|
||||
}) as ExcalidrawArrowElement;
|
||||
|
||||
const getText = () =>
|
||||
h.elements.find(
|
||||
(element): element is ExcalidrawTextElement => element.type === "text",
|
||||
)!;
|
||||
|
||||
const getArrow = (id: string) =>
|
||||
h.elements.find(
|
||||
(element): element is ExcalidrawArrowElement => element.id === id,
|
||||
)!;
|
||||
|
||||
/** global coordinates of an arrow's start/end point */
|
||||
const endpointOf = (arrow: ExcalidrawArrowElement, which: "start" | "end") => {
|
||||
const point = arrow.points[which === "start" ? 0 : arrow.points.length - 1];
|
||||
return [arrow.x + point[0], arrow.y + point[1]] as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* `normalizeFixedPoint` nudges exact side midpoints off 0.5 by 1e-4 to keep
|
||||
* headings unambiguous, which shows up as a sub-pixel offset scaled by the
|
||||
* text size.
|
||||
*/
|
||||
const expectPointsClose = (
|
||||
actual: readonly [number, number],
|
||||
expected: readonly [number, number],
|
||||
) => {
|
||||
expect(actual[0]).toBeCloseTo(expected[0], 1);
|
||||
expect(actual[1]).toBeCloseTo(expected[1], 1);
|
||||
};
|
||||
|
||||
/** creates a text bound to the arrow endpoint at (x, y) via the text tool */
|
||||
const bindTextAt = async (x: number, y: number, text: string) => {
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(x, y);
|
||||
mouse.clickAt(x, y);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
describe("binding text to an arrow endpoint", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally />);
|
||||
});
|
||||
|
||||
describe("hover affordance", () => {
|
||||
it("highlights a free arrow endpoint while the text tool is active", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
|
||||
// away from the arrow entirely
|
||||
mouse.moveTo(400, 400);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the label midpoint when hovering the arrow itself", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
// mid-arrow, clear of both endpoints
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the endpoint over the label midpoint", () => {
|
||||
// short enough that the endpoint and the midpoint are close together
|
||||
API.setElements([createArrow("arrow", [100, 160], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor?.anchor).toBe("end");
|
||||
});
|
||||
|
||||
it("does not offer a label midpoint on an arrow that already has one", () => {
|
||||
const arrow = createArrow("arrow", [100, 300], [100, 100]);
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
containerId: arrow.id,
|
||||
text: "existing",
|
||||
});
|
||||
API.setElements([
|
||||
{ ...arrow, boundElements: [{ id: label.id, type: "text" }] },
|
||||
label,
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
// a click only becomes a label when it snaps to the arrow's center, so
|
||||
// merely being inside the arrow's bounding box must not raise the anchor
|
||||
it("does not offer a label midpoint away from the arrow's center", () => {
|
||||
// bounding box spans (100,400)-(400,580); label centers on (250,580)
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
height: 180,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(150, 180),
|
||||
pointFrom<LocalPoint>(300, 0),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
// inside the bounding box but nowhere near the stroke or the midpoint
|
||||
mouse.moveTo(150, 560);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// on the stroke, but far from the midpoint
|
||||
mouse.moveTo(175, 490);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
mouse.moveTo(250, 580);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not highlight endpoints for other tools", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("selection");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the highlight on escape", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
// escape reverts the tool via the action's appState rather than
|
||||
// `setActiveTool`, so the highlight has to be cleared there too
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding toggle (ctrl/cmd)", () => {
|
||||
it("does not offer an endpoint while binding is disabled", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("still offers the label anchor — a label is not an arrow binding", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
// the highlight is maintained at write time — the renderer draws whatever
|
||||
// `hoveredArrowTextAnchor` says — so the toggle itself has to refresh the
|
||||
// anchor: there is no pointermove to do it
|
||||
it("clears the highlight the moment ctrl is pressed, restores on release", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyDown("Control");
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
Keyboard.keyUp("Control");
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops plain text instead of binding the endpoint", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
await bindTextAt(100, 100, "plain");
|
||||
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getArrow("arrow").startBinding).toBeNull();
|
||||
expect(getText().containerId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not highlight an endpoint that is already bound", () => {
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100], {
|
||||
endBinding: {
|
||||
elementId: "other",
|
||||
fixedPoint: [0.5001, 0.5001],
|
||||
mode: "orbit",
|
||||
},
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "other",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// ...but the arrow can still take a label at its midpoint
|
||||
mouse.moveTo(100, 200);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the highlight when switching away from the text tool", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
UI.clickTool("selection");
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placement strategy", () => {
|
||||
// the side of the text the arrow attaches to is the one it already points
|
||||
// at, so the text lands beyond the tip and the arrow needn't move
|
||||
it.each([
|
||||
{
|
||||
name: "arrow pointing up binds the text's bottom side midpoint",
|
||||
from: [100, 300] as [number, number],
|
||||
to: [100, 100] as [number, number],
|
||||
fixedPoint: [0.5001, 1],
|
||||
textAlign: "center",
|
||||
verticalAlign: "bottom",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing right binds the text's left side midpoint",
|
||||
from: [100, 100] as [number, number],
|
||||
to: [300, 100] as [number, number],
|
||||
fixedPoint: [0, 0.5001],
|
||||
textAlign: "left",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing down binds the text's top side midpoint",
|
||||
from: [100, 100] as [number, number],
|
||||
to: [100, 300] as [number, number],
|
||||
fixedPoint: [0.5001, 0],
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing left binds the text's right side midpoint",
|
||||
from: [300, 100] as [number, number],
|
||||
to: [100, 100] as [number, number],
|
||||
fixedPoint: [1, 0.5001],
|
||||
textAlign: "right",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
])("$name", async ({ from, to, fixedPoint, textAlign, verticalAlign }) => {
|
||||
API.setElements([createArrow("arrow", from, to)]);
|
||||
|
||||
await bindTextAt(to[0], to[1], "label");
|
||||
|
||||
const text = getText();
|
||||
expect(getArrow("arrow").endBinding).toEqual({
|
||||
elementId: text.id,
|
||||
fixedPoint,
|
||||
mode: "orbit",
|
||||
});
|
||||
expect(text.textAlign).toBe(textAlign);
|
||||
expect(text.verticalAlign).toBe(verticalAlign);
|
||||
expect(text.containerId).toBeNull();
|
||||
expect(text.boundElements).toEqual([{ id: "arrow", type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("binds the start point when that is the endpoint under the cursor", async () => {
|
||||
// arrow drawn away from (100, 100) to the right
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
await bindTextAt(100, 100, "label");
|
||||
|
||||
const text = getText();
|
||||
const arrow = getArrow("arrow");
|
||||
expect(arrow.startBinding?.elementId).toBe(text.id);
|
||||
expect(arrow.endBinding).toBeNull();
|
||||
// the arrow leaves the tip heading right, so the text sits to its left
|
||||
expect(arrow.startBinding?.fixedPoint).toEqual([1, 0.5001]);
|
||||
expect(text.textAlign).toBe("right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the arrow stays put", () => {
|
||||
it("does not move the arrow when the text is created and typed into", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(100, 100, "a label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
// growing the text (wider + a second line) must not drag the tip
|
||||
updateTextEditor(editor, "a much longer label\nspanning two lines");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
it("keeps the bound side midpoint pinned as the text grows", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
const editor = await bindTextAt(100, 100, "short");
|
||||
const bottomMidOf = (text: ExcalidrawTextElement) =>
|
||||
[text.x + text.width / 2, text.y + text.height] as const;
|
||||
const anchorBefore = bottomMidOf(getText());
|
||||
|
||||
updateTextEditor(editor, "a much longer label\nspanning two lines");
|
||||
|
||||
const text = getText();
|
||||
expect(text.width).toBeGreaterThan(0);
|
||||
expectPointsClose(bottomMidOf(text), anchorBefore);
|
||||
});
|
||||
|
||||
// An orbit binding resolves the endpoint by intersecting the arrow's own
|
||||
// line with the gap-grown text outline. Anchoring along the bound side's
|
||||
// normal instead used to leave the tip `gap * tan(angle)` off to the side
|
||||
// — invisible on orthogonal arrows, several px on diagonal ones, and only
|
||||
// once the first keystroke triggered `updateBoundElements`.
|
||||
it.each([
|
||||
{ name: "45°", from: [100, 100] as [number, number] },
|
||||
{ name: "steep", from: [160, 100] as [number, number] },
|
||||
{ name: "shallow", from: [100, 160] as [number, number] },
|
||||
])("does not shift a diagonal arrow's tip ($name)", async ({ from }) => {
|
||||
API.setElements([createArrow("arrow", from, [300, 300])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(300, 300, "x");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
updateTextEditor(editor, "a considerably longer label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
// the binding gap is derived from the *bind target*, so anchoring with the
|
||||
// arrow's stroke width offsets the tip by half the difference between the
|
||||
// two the moment `updateBoundElements` first runs
|
||||
it.each([
|
||||
{ arrowStroke: 2, textStroke: "bold" as const },
|
||||
{ arrowStroke: 4, textStroke: "thin" as const },
|
||||
{ arrowStroke: 1, textStroke: "medium" as const },
|
||||
])(
|
||||
"does not shift when the text's stroke width differs (arrow $arrowStroke vs $textStroke)",
|
||||
async ({ arrowStroke, textStroke }) => {
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100], {
|
||||
strokeWidth: arrowStroke,
|
||||
}),
|
||||
]);
|
||||
API.setAppState({ currentItemStrokeWidthKey: textStroke });
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(100, 100, "x");
|
||||
expect(getText().strokeWidth).not.toBe(arrowStroke);
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
updateTextEditor(editor, "a considerably longer label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("drag-sizing", () => {
|
||||
/** press at the endpoint, drag to `toX`, release */
|
||||
const dragTextAt = async (
|
||||
[x, y]: [number, number],
|
||||
toX: number,
|
||||
text: string,
|
||||
) => {
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(x, y);
|
||||
mouse.downAt(x, y);
|
||||
// several moves so the drag registers
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
mouse.moveTo(x + ((toX - x) * i) / 4, y);
|
||||
}
|
||||
mouse.upAt(toX, y);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
it("sets a fixed width without moving the anchor (left-bound)", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
// arrow points right, so the text hangs off its left edge at x = tip+gap
|
||||
await dragTextAt([300, 100], 520, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("left");
|
||||
// left edge is the anchor and must be untouched by the drag
|
||||
expect(text.x).toBeCloseTo(306, 0);
|
||||
expect(text.width).toBeCloseTo(214, 0);
|
||||
});
|
||||
|
||||
it("grows leftwards for a right-bound text, pinning its right edge", async () => {
|
||||
API.setElements([createArrow("arrow", [500, 100], [300, 100])]);
|
||||
|
||||
await dragTextAt([300, 100], 80, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("right");
|
||||
// the right edge is the anchor
|
||||
expect(text.x + text.width).toBeCloseTo(294, 0);
|
||||
});
|
||||
|
||||
it("keeps a centred text centred on the anchor", async () => {
|
||||
API.setElements([createArrow("arrow", [300, 100], [300, 300])]);
|
||||
|
||||
await dragTextAt([300, 300], 430, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("center");
|
||||
expect(text.x + text.width / 2).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it("does not let the drag run back over the arrow", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
// drag towards the arrow, i.e. the wrong side of the anchor
|
||||
await dragTextAt([300, 100], 150, "label");
|
||||
|
||||
const text = getText();
|
||||
// treated as no drag at all — the text keeps autogrowing
|
||||
expect(text.autoResize).toBe(true);
|
||||
expect(text.x).toBeCloseTo(306, 0);
|
||||
});
|
||||
|
||||
// the autoResize handle unwraps the text back to one line; anchoring that
|
||||
// resize by alignment is what keeps the arrow still
|
||||
it("does not move the arrow when the text is un-wrapped", async () => {
|
||||
API.setElements([createArrow("arrow", [500, 100], [300, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
// grows leftwards off the text's right edge
|
||||
await dragTextAt([300, 100], 80, "a label long enough to wrap");
|
||||
Keyboard.exitTextEditor(await getTextEditor());
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
const rightEdge = text.x + text.width;
|
||||
|
||||
API.setAppState({ selectedElementIds: { [text.id]: true } });
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const resized = getText();
|
||||
expect(resized.autoResize).toBe(true);
|
||||
expect(resized.x + resized.width).toBeCloseTo(rightEdge, 4);
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
// the anchor can only pin one point, so every other arrow bound to the text
|
||||
// has to be re-routed to the resized box
|
||||
it("re-routes the other arrows bound to the text", () => {
|
||||
const wrapped = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "text",
|
||||
x: 200,
|
||||
y: 280,
|
||||
width: 300,
|
||||
height: 50,
|
||||
text: "this is it my friends\nald aksdl askdlasdk",
|
||||
textAlign: "right",
|
||||
verticalAlign: "middle",
|
||||
boundElements: [{ id: "fromTop", type: "arrow" }],
|
||||
}),
|
||||
// a wrapped, fixed-width text: `originalText` is what the resize
|
||||
// measures against once it unwraps
|
||||
originalText: "this is it my friends ald aksdl askdlasdk",
|
||||
autoResize: false,
|
||||
};
|
||||
// bound to the text's top side, i.e. not the side the alignment pins
|
||||
const fromTop = createArrow("fromTop", [350, 120], [350, 260], {
|
||||
endBinding: {
|
||||
elementId: "text",
|
||||
fixedPoint: [0.5001, 0],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
API.setElements([wrapped, fromTop]);
|
||||
const tipBefore = endpointOf(getArrow("fromTop"), "end");
|
||||
|
||||
API.setAppState({ selectedElementIds: { text: true } });
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const text = getText();
|
||||
// the box really did move out from under the arrow
|
||||
expect(text.y).not.toBeCloseTo(280, 0);
|
||||
|
||||
const tipAfter = endpointOf(getArrow("fromTop"), "end");
|
||||
expect(tipAfter).not.toEqual(tipBefore);
|
||||
// still just clear of the top edge it is bound to...
|
||||
expect(tipAfter[1]).toBeLessThan(text.y);
|
||||
// ...and it chased the box's new centre. Not asserting the centre
|
||||
// exactly: the arrow is diagonal now, so the outline intersection sits
|
||||
// slightly to one side of the fixed point.
|
||||
const centre = text.x + text.width / 2;
|
||||
expect(Math.abs(tipAfter[0] - centre)).toBeLessThan(
|
||||
Math.abs(tipBefore[0] - centre),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the arrow endpoint where it was", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
await dragTextAt([300, 100], 520, "a label long enough to wrap");
|
||||
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occlusion", () => {
|
||||
const coveringRect = (overrides: Record<string, unknown> = {}) =>
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 120,
|
||||
backgroundColor: "#ffc9c9",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("does not reach through an element stacked above the endpoint", async () => {
|
||||
// rect is drawn after (above) the arrow and covers its tip at (100, 100)
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
coveringRect(),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// the click belongs to the rect, as it would without the arrow there
|
||||
await bindTextAt(100, 100, "label");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getText().containerId).toBe("rect");
|
||||
});
|
||||
|
||||
it("still offers the endpoint when the arrow is stacked above", () => {
|
||||
API.setElements([
|
||||
coveringRect(),
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("is not occluded by a transparent element above it", () => {
|
||||
// transparent shapes are only hit on their stroke, so they don't cover
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
coveringRect({ backgroundColor: "transparent" }),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
// generic text-creation history behaviour lives in textWysiwyg.test.tsx;
|
||||
// these cover what the endpoint binding adds on top
|
||||
it("removes the text and its binding in a single undo", async () => {
|
||||
UI.createElement("arrow", { x: 100, y: 300, width: 0, height: -200 });
|
||||
const arrowId = h.elements[0].id;
|
||||
const arrow = () =>
|
||||
h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.id === arrowId,
|
||||
)!;
|
||||
|
||||
const editor = await bindTextAt(100, 100, "bound");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
const textId = arrow().endBinding?.elementId;
|
||||
expect(textId).toBeDefined();
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(h.elements.find((el) => el.id === textId)?.isDeleted).toBe(true);
|
||||
expect(arrow().isDeleted).toBe(false);
|
||||
expect(arrow().endBinding).toBeNull();
|
||||
|
||||
// and the endpoint is immediately reusable
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: arrowId,
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the text and its binding on redo", async () => {
|
||||
UI.createElement("arrow", { x: 100, y: 300, width: 0, height: -200 });
|
||||
const arrowId = h.elements[0].id;
|
||||
const arrow = () =>
|
||||
h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.id === arrowId,
|
||||
)!;
|
||||
|
||||
const editor = await bindTextAt(100, 100, "bound");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
const textId = arrow().endBinding?.elementId;
|
||||
|
||||
Keyboard.undo();
|
||||
Keyboard.redo();
|
||||
|
||||
const text = h.elements.find((el) => el.id === textId)!;
|
||||
expect(text.isDeleted).toBe(false);
|
||||
expect(isTextElement(text) && text.text).toBe("bound");
|
||||
expect(arrow().endBinding?.elementId).toBe(textId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction with existing text-tool behavior", () => {
|
||||
it("adds a label to the arrow when clicking away from the endpoints", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [500, 100])]);
|
||||
|
||||
await bindTextAt(300, 100, "label");
|
||||
|
||||
const text = getText();
|
||||
// mid-arrow click is still the "arrow label" gesture
|
||||
expect(text.containerId).toBe("arrow");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getArrow("arrow").startBinding).toBeNull();
|
||||
});
|
||||
|
||||
// the text tool edits before it creates: a text that is the top-most hit
|
||||
// under the cursor takes the click, so the endpoint must not be offered
|
||||
// over it. Occlusion alone can't provide this — its hit test skips bound
|
||||
// texts.
|
||||
it("yields to a container-bound label overlapping the endpoint", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect",
|
||||
x: 60,
|
||||
y: 60,
|
||||
width: 80,
|
||||
height: 80,
|
||||
// transparent, so the rect itself doesn't occlude the endpoint
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
containerId: rect.id,
|
||||
text: "under",
|
||||
x: 75,
|
||||
y: 87.5,
|
||||
width: 50,
|
||||
height: 25,
|
||||
});
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
{ ...rect, boundElements: [{ id: label.id, type: "text" }] },
|
||||
label,
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// the click edits the label rather than binding the endpoint under it
|
||||
mouse.clickAt(100, 100);
|
||||
await getTextEditor();
|
||||
expect(h.state.editingTextElement?.id).toBe("label");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a fresh text instead of adopting the selected one", async () => {
|
||||
// with a text selected, a text-tool click normally edits that text no
|
||||
// matter where the click lands — but a click on an endpoint promises a
|
||||
// new label there, not a binding to whatever happened to be selected
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "faraway",
|
||||
x: 400,
|
||||
y: 400,
|
||||
text: "faraway",
|
||||
}),
|
||||
]);
|
||||
API.setAppState({ selectedElementIds: { faraway: true } });
|
||||
|
||||
const editor = await bindTextAt(100, 100, "fresh");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const boundTextId = getArrow("arrow").endBinding?.elementId;
|
||||
expect(boundTextId).toBeDefined();
|
||||
expect(boundTextId).not.toBe("faraway");
|
||||
|
||||
const faraway = h.elements.find((el) => el.id === "faraway")!;
|
||||
expect(isTextElement(faraway) && faraway.text).toBe("faraway");
|
||||
expect(faraway.boundElements ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a fresh text instead of adopting one lying beyond the tip", async () => {
|
||||
// the text covers the anchor region past the tip, but the arrow is the
|
||||
// top-most hit at the endpoint itself — the endpoint stays available,
|
||||
// and the new label must not be conflated with the text underneath
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "under",
|
||||
x: 75,
|
||||
y: 55,
|
||||
width: 50,
|
||||
height: 50,
|
||||
text: "under",
|
||||
}),
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
|
||||
const editor = await bindTextAt(100, 100, "fresh");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const boundTextId = getArrow("arrow").endBinding?.elementId;
|
||||
expect(boundTextId).toBeDefined();
|
||||
expect(boundTextId).not.toBe("under");
|
||||
|
||||
const under = h.elements.find((el) => el.id === "under")!;
|
||||
expect(isTextElement(under) && under.text).toBe("under");
|
||||
expect(under.boundElements ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes the binding when the text is submitted empty", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
const editor = await bindTextAt(100, 100, "temporary");
|
||||
expect(getArrow("arrow").endBinding).not.toBeNull();
|
||||
|
||||
updateTextEditor(editor, "");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
839
packages/excalidraw/tests/bucketFill.test.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import {
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
KEYS,
|
||||
} from "@excalidraw/common";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { ExcalidrawLineElement } from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { activeEyeDropperAtom } from "../components/EyeDropper";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer } from "./helpers/ui";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
render,
|
||||
togglePopover,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const selectBucketFill = () => {
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "bucketfill" });
|
||||
});
|
||||
};
|
||||
|
||||
describe("bucket fill tool", () => {
|
||||
beforeAll(() => {
|
||||
// radix popovers (font family, color picker) need a ResizeObserver;
|
||||
// jsdom has none (same stub as test-utils' togglePopover)
|
||||
(global as any).ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
const seedRectangle = () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 120,
|
||||
height: 100,
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
// commit the owner as its own history increment so the bucket fill is a
|
||||
// separate, independently-undoable step
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [rect],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
});
|
||||
return rect;
|
||||
};
|
||||
|
||||
it("uses the paint droplet as the bucket fill cursor hotspot", () => {
|
||||
selectBucketFill();
|
||||
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toMatch(
|
||||
/^url\(data:image\/svg\+xml/,
|
||||
);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toMatch(
|
||||
/\) 5 18, auto$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("colorizes the cursor with the effective fill color and keeps it updated", () => {
|
||||
act(() => {
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
||||
});
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
const fallbackColor = h.app.bucketFill.getBucketFillBackgroundColor(
|
||||
COLOR_PALETTE.transparent,
|
||||
);
|
||||
const fallbackCursor = GlobalTestState.interactiveCanvas.style.cursor;
|
||||
expect(decodeURIComponent(fallbackCursor)).toContain(
|
||||
`fill="${fallbackColor}"`,
|
||||
);
|
||||
|
||||
const nextColor = "#ffec99";
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: nextColor });
|
||||
});
|
||||
|
||||
const updatedCursor = GlobalTestState.interactiveCanvas.style.cursor;
|
||||
expect(updatedCursor).not.toBe(fallbackCursor);
|
||||
expect(decodeURIComponent(updatedCursor)).toContain(`fill="${nextColor}"`);
|
||||
});
|
||||
|
||||
it("does not rebuild or reapply an unchanged bucket fill cursor", () => {
|
||||
selectBucketFill();
|
||||
const encodeURIComponentSpy = vi.spyOn(globalThis, "encodeURIComponent");
|
||||
const cursorSetterSpy = vi.spyOn(
|
||||
GlobalTestState.interactiveCanvas.style,
|
||||
"cursor",
|
||||
"set",
|
||||
);
|
||||
|
||||
h.app.cursor.applyForTool();
|
||||
|
||||
expect(encodeURIComponentSpy).not.toHaveBeenCalled();
|
||||
expect(cursorSetterSpy).not.toHaveBeenCalled();
|
||||
encodeURIComponentSpy.mockRestore();
|
||||
cursorSetterSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("cycles the non-white top picks on repeated B presses", () => {
|
||||
const colorPicks = BUCKET_FILL_BACKGROUND_PICKS.filter(
|
||||
(color) => color !== COLOR_PALETTE.white,
|
||||
);
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: COLOR_PALETTE.white });
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.B);
|
||||
expect(h.state.activeTool.type).toBe("bucketfill");
|
||||
expect(h.state.currentItemBackgroundColor).toBe(COLOR_PALETTE.white);
|
||||
|
||||
for (const color of colorPicks) {
|
||||
Keyboard.keyPress(KEYS.B);
|
||||
expect(h.state.currentItemBackgroundColor).toBe(color);
|
||||
expect(
|
||||
decodeURIComponent(GlobalTestState.interactiveCanvas.style.cursor),
|
||||
).toContain(`fill="${color}"`);
|
||||
}
|
||||
|
||||
Keyboard.keyPress(KEYS.B);
|
||||
expect(h.state.currentItemBackgroundColor).toBe(colorPicks[0]);
|
||||
});
|
||||
|
||||
it("temporarily activates the background eye dropper while Alt is held", async () => {
|
||||
selectBucketFill();
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyDown(KEYS.ALT);
|
||||
});
|
||||
|
||||
const eyeDropper = editorJotaiStore.get(activeEyeDropperAtom);
|
||||
expect(eyeDropper).not.toBeNull();
|
||||
expect(eyeDropper!.colorPickerType).toBe("elementBackground");
|
||||
expect(eyeDropper!.keepOpenOnAlt).toBe(true);
|
||||
expect(eyeDropper!.swapPreviewOnAlt).toBeUndefined();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
GlobalTestState.renderResult.container.querySelector(
|
||||
".excalidraw-eye-dropper-preview",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
const color = "#ff8787";
|
||||
act(() => {
|
||||
eyeDropper!.onSelect(color, { altKey: true } as PointerEvent);
|
||||
});
|
||||
expect(h.state.currentItemBackgroundColor).toBe(color);
|
||||
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(editorJotaiStore.get(activeEyeDropperAtom)).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
GlobalTestState.renderResult.container.querySelector(
|
||||
".excalidraw-eye-dropper-preview",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the bucket fill cursor immediately when Escape exits the tool", () => {
|
||||
selectBucketFill();
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("url(");
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe("");
|
||||
});
|
||||
|
||||
it("creates a unselected line polygon with the current background color", () => {
|
||||
const rect = seedRectangle();
|
||||
act(() => {
|
||||
// the bucket fill color is the shared shape background color
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
const fill = h.elements.find(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
) as ExcalidrawLineElement | undefined;
|
||||
|
||||
expect(fill).toBeDefined();
|
||||
expect(fill!.polygon).toBe(true);
|
||||
expect(fill!.backgroundColor).toBe("#ffec99");
|
||||
expect(fill!.strokeColor).toBe("transparent");
|
||||
expect(fill!.fillStyle).toBe("solid");
|
||||
// no metadata marker: fills are recognized by shape, not provenance
|
||||
expect(fill!.customData).toBeUndefined();
|
||||
|
||||
// linear elements must be normalized: points[0] === [0, 0], and the
|
||||
// polygon is explicitly closed
|
||||
expect(fill!.points[0]).toEqual([0, 0]);
|
||||
expect(fill!.points[fill!.points.length - 1]).toEqual([0, 0]);
|
||||
|
||||
// inserted immediately below (before) the owner in z-order
|
||||
expect(h.elements[0].id).toBe(fill!.id);
|
||||
expect(h.elements[1].id).toBe(rect.id);
|
||||
|
||||
// the fill is NOT selected and the tool stays active so the user can keep
|
||||
// filling regions back-to-back
|
||||
expect(h.state.selectedElementIds[fill!.id]).not.toBe(true);
|
||||
expect(h.state.activeTool.type).toBe("bucketfill");
|
||||
});
|
||||
|
||||
it("fires the public onPointerDown/onPointerUp callbacks", async () => {
|
||||
// regression: the bucket branch used to early-return before the shared
|
||||
// pointer lifecycle, silently skipping the public callback contract
|
||||
const onPointerDown = vi.fn();
|
||||
const onPointerUp = vi.fn();
|
||||
await render(
|
||||
<Excalidraw
|
||||
handleKeyboardGlobally={true}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
/>,
|
||||
);
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
expect(onPointerDown).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerUp).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recovers from a lost pointer-up via the shared cleanup", () => {
|
||||
// regression: the old bespoke once-listener only reset pointer state on
|
||||
// a real pointer-up; the shared missing-pointer-up cleanup now covers
|
||||
// lost ones (e.g. window blurred mid-click)
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.downAt(80, 70);
|
||||
expect(h.state.cursorButton).toBe("down");
|
||||
|
||||
act(() => {
|
||||
(h.app as any).maybeCleanupAfterMissingPointerUp(null);
|
||||
});
|
||||
expect(h.state.cursorButton).toBe("up");
|
||||
// the click never completed, so the armed fill is discarded — an
|
||||
// interrupted interaction must not leave a permanent edit
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(0);
|
||||
mouse.upAt(80, 70);
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dragging with the bucket tool neither selects nor creates extras", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.downAt(80, 70);
|
||||
mouse.moveTo(120, 100); // drag well past any threshold
|
||||
mouse.upAt(120, 100);
|
||||
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(1);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(Object.keys(h.state.selectedElementIds)).toHaveLength(0);
|
||||
expect(h.state.activeTool.type).toBe("bucketfill");
|
||||
});
|
||||
|
||||
it("re-clicking a filled region does not stack a duplicate fill", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-clicking with a changed color restyles the existing fill", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffc9c9" });
|
||||
});
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
const fills = h.elements.filter(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
);
|
||||
expect(fills).toHaveLength(1);
|
||||
expect(fills[0].backgroundColor).toBe("#ffc9c9");
|
||||
|
||||
// the restyle is its own undoable step
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
});
|
||||
const afterUndo = h.elements.filter(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
);
|
||||
expect(afterUndo).toHaveLength(1);
|
||||
expect(afterUndo[0].backgroundColor).toBe("#ffec99");
|
||||
});
|
||||
|
||||
it("restyles an orphaned fill even when no region can be derived", () => {
|
||||
const rect = seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
// delete the rectangle: the fill's boundaries are gone, so no region
|
||||
// can be derived around the click anymore
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: h.elements.filter((el) => el.id !== rect.id),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffc9c9" });
|
||||
});
|
||||
|
||||
mouse.clickAt(80, 70); // on the orphaned paint
|
||||
|
||||
const fills = h.elements.filter(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
);
|
||||
expect(fills).toHaveLength(1);
|
||||
expect(fills[0].backgroundColor).toBe("#ffc9c9");
|
||||
});
|
||||
|
||||
it("clicking a since-subdivided part of a filled region creates a new fill", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70); // fill the whole rectangle
|
||||
|
||||
// split the region with a line drawn afterwards
|
||||
const splitter = API.createElement({
|
||||
type: "line",
|
||||
x: 10,
|
||||
y: 70,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(140, 0)],
|
||||
});
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [...h.elements, splitter],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
});
|
||||
|
||||
mouse.clickAt(80, 45); // top half: a different (smaller) region now
|
||||
|
||||
const fills = h.elements.filter(
|
||||
(el) => el.type === "line" && !el.isDeleted && el.id !== splitter.id,
|
||||
);
|
||||
expect(fills).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("can fill multiple regions in a row without re-selecting the tool", () => {
|
||||
const first = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const second = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 200,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [first, second],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(70, 60); // inside first
|
||||
expect(h.state.activeTool.type).toBe("bucketfill");
|
||||
mouse.clickAt(250, 60); // inside second, tool still active
|
||||
|
||||
const fills = h.elements.filter(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
);
|
||||
expect(fills).toHaveLength(2);
|
||||
expect(h.state.activeTool.type).toBe("bucketfill");
|
||||
});
|
||||
|
||||
it("shows fill color, fill style and opacity in the panel when the tool is active", () => {
|
||||
seedRectangle();
|
||||
selectBucketFill();
|
||||
|
||||
const panel = document.querySelector(".selected-shape-actions");
|
||||
expect(panel).not.toBeNull();
|
||||
// the fill (background) color picker is shown...
|
||||
expect(panel!.querySelector('[aria-label="Background"]')).not.toBeNull();
|
||||
// ...along with the fill style + opacity controls for the created fills...
|
||||
expect(panel!.querySelector('[data-testid="fill-hachure"]')).not.toBeNull();
|
||||
expect(panel!.querySelector('[data-testid="opacity"]')).not.toBeNull();
|
||||
// ...and nothing else from the generic shape panel (e.g. stroke)
|
||||
expect(panel!.querySelector('[aria-label="Stroke"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the current fill style and opacity for created fills", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: "#ffec99",
|
||||
currentItemFillStyle: "hachure",
|
||||
currentItemOpacity: 60,
|
||||
});
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
const fill = h.elements.find(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
) as ExcalidrawLineElement | undefined;
|
||||
expect(fill).toBeDefined();
|
||||
expect(fill!.fillStyle).toBe("hachure");
|
||||
expect(fill!.opacity).toBe(60);
|
||||
});
|
||||
|
||||
it("selects the tool with the b shortcut", () => {
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
Keyboard.keyPress(KEYS.B);
|
||||
expect(h.state.activeTool.type).toBe("bucketfill");
|
||||
});
|
||||
|
||||
it("opens the background color popup with the g shortcut", () => {
|
||||
selectBucketFill();
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
expect(h.state.openPopup).toBe("elementBackground");
|
||||
});
|
||||
|
||||
it("swallows the hidden transparent entry's hotkey (q) inside the picker", () => {
|
||||
// regression: the hidden transparent entry's `q` returned "unhandled",
|
||||
// so the picker didn't stop propagation and App.onKeyDown toggled the
|
||||
// global tool lock while the color dialog was open
|
||||
seedRectangle();
|
||||
selectBucketFill();
|
||||
togglePopover("Background");
|
||||
|
||||
const picker = document.querySelector(".color-picker-content")!;
|
||||
expect(picker).not.toBeNull();
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
const before = h.state.currentItemBackgroundColor;
|
||||
|
||||
// `q` maps to the hidden transparent swatch AND to the global tool-lock
|
||||
// shortcut — inside the modal it must be dead, not leaked
|
||||
fireEvent.keyDown(picker, { key: "q" });
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
expect(h.state.currentItemBackgroundColor).toBe(before);
|
||||
|
||||
// the other hotkeys keep working on their usual keys (w = white)
|
||||
fireEvent.keyDown(picker, { key: "w" });
|
||||
expect(h.state.currentItemBackgroundColor).not.toBe(before);
|
||||
expect(h.state.currentItemBackgroundColor).not.toBe("transparent");
|
||||
});
|
||||
|
||||
it("shift+f opens the font popup instead of switching to bucket fill", () => {
|
||||
// regression: bucket fill was originally bound to shift+f, shadowing the
|
||||
// long-standing "show fonts" shortcut for text elements
|
||||
const text = API.createElement({ type: "text", x: 20, y: 20 });
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [text],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
API.setAppState({ selectedElementIds: { [text.id]: true } });
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
Keyboard.keyPress("F");
|
||||
});
|
||||
|
||||
expect(h.state.activeTool.type).not.toBe("bucketfill");
|
||||
expect(h.state.openPopup).toBe("fontFamily");
|
||||
});
|
||||
|
||||
it("falls back to green when the shared background color is transparent", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "transparent" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
const fill = h.elements.find(
|
||||
(el) => el.type === "line" && !el.isDeleted,
|
||||
) as ExcalidrawLineElement | undefined;
|
||||
expect(fill).toBeDefined();
|
||||
expect(fill!.backgroundColor).toBe("#b2f2bb");
|
||||
// the shared appState value is left untouched
|
||||
expect(h.state.currentItemBackgroundColor).toBe("transparent");
|
||||
});
|
||||
|
||||
it("inserts the overlap fill below the lowest participating shape", () => {
|
||||
const below = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const owner = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [below, owner],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
// click the overlap lens (inside both rectangles)
|
||||
mouse.clickAt(75, 75);
|
||||
|
||||
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
|
||||
expect(fill).toBeDefined();
|
||||
// both rectangles bound the lens, so the fill goes below the lower one
|
||||
expect(h.elements[0].id).toBe(fill.id);
|
||||
expect(h.elements[1].id).toBe(below.id);
|
||||
expect(h.elements[2].id).toBe(owner.id);
|
||||
});
|
||||
|
||||
it("inserts the fill above a participant whose opaque background covers it", () => {
|
||||
// lower rectangle is filled red: its background would hide a fill placed
|
||||
// beneath it, so the bucket fill must go above it (but below the
|
||||
// transparent owner, whose outline should stay visible)
|
||||
const below = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
roundness: null,
|
||||
backgroundColor: "#ff0000",
|
||||
});
|
||||
const owner = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [below, owner],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(75, 75);
|
||||
|
||||
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
|
||||
expect(fill).toBeDefined();
|
||||
// order: below (red, covered) -> fill -> owner (transparent outline on top)
|
||||
expect(h.elements[0].id).toBe(below.id);
|
||||
expect(h.elements[1].id).toBe(fill.id);
|
||||
expect(h.elements[2].id).toBe(owner.id);
|
||||
});
|
||||
|
||||
it("does nothing when clicking empty canvas", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(400, 400);
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fill on right-click or middle-click", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
for (const button of [1, 2]) {
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
button,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
button,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
}
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a normal touch tap fills once, on release", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
});
|
||||
// the fill is only armed while the finger is down — nothing committed yet
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(0);
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
});
|
||||
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a second touch pointer aborts the fill (pinch/pan intent)", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
});
|
||||
// a second finger lands while the first is still down
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 100,
|
||||
clientY: 90,
|
||||
button: 0,
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
});
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
});
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
});
|
||||
|
||||
// the user meant to pinch/pan, not to fill — no permanent edit
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("pointercancel aborts the fill", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
});
|
||||
// the browser takes the pointer over (scroll, palm rejection) — no
|
||||
// pointerup will follow
|
||||
fireEvent.pointerCancel(GlobalTestState.interactiveCanvas, {
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
});
|
||||
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(0);
|
||||
|
||||
// and the next tap fills normally
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
button: 0,
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
});
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 80,
|
||||
clientY: 70,
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
});
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not fill in view mode", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
act(() => {
|
||||
API.setAppState({ viewModeEnabled: true });
|
||||
});
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("undo removes the generated fill in one step", () => {
|
||||
seedRectangle();
|
||||
act(() => {
|
||||
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
|
||||
});
|
||||
selectBucketFill();
|
||||
|
||||
mouse.clickAt(80, 70);
|
||||
|
||||
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
|
||||
expect(fill).toBeDefined();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
});
|
||||
|
||||
expect(
|
||||
h.elements.find((el) => el.id === fill.id && !el.isDeleted),
|
||||
).toBeUndefined();
|
||||
// the owner rectangle survives
|
||||
expect(
|
||||
h.elements.filter((el) => el.type === "rectangle" && !el.isDeleted),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
164
packages/excalidraw/tests/charts.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it("parses multi-series CSV for radar charts", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`Metric,Player A,Player B,Player C
|
||||
Speed,80,60,75
|
||||
Strength,65,85,70
|
||||
Agility,90,70,88
|
||||
Intelligence,70,88,92
|
||||
Stamina,85,75,80`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: "Metric",
|
||||
labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"],
|
||||
series: [
|
||||
{ title: "Player A", values: [80, 65, 90, 70, 85] },
|
||||
{ title: "Player B", values: [60, 85, 70, 88, 75] },
|
||||
{ title: "Player C", values: [75, 70, 88, 92, 80] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses TSV with empty chart-name header cell", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`\tDunk\tEgg
|
||||
Physical Strength\t10\t2
|
||||
Swordsmanship\t8\t1
|
||||
Political Instinct\t3\t9`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: null,
|
||||
labels: ["Physical Strength", "Swordsmanship", "Political Instinct"],
|
||||
series: [
|
||||
{ title: "Dunk", values: [10, 8, 3] },
|
||||
{ title: "Egg", values: [2, 1, 9] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 2-row multi-series TSV without transposing", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`Physical Strength\t10\t2
|
||||
Swordsmanship skill\t8\t1`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: null,
|
||||
labels: ["Physical Strength", "Swordsmanship skill"],
|
||||
series: [
|
||||
{ title: "Series 1", values: [10, 8] },
|
||||
{ title: "Series 2", values: [2, 1] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses semicolon-separated values", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`Metric;Player A;Player B
|
||||
Speed;80;60
|
||||
Strength;65;85
|
||||
Agility;90;70`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: "Metric",
|
||||
labels: ["Speed", "Strength", "Agility"],
|
||||
series: [
|
||||
{ title: "Player A", values: [80, 65, 90] },
|
||||
{ title: "Player B", values: [60, 85, 70] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("transposes wide data (more value cols than rows) into series-per-row", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`trait,Dunk,Egg,Daeron
|
||||
Physical,10,2,7
|
||||
Mental,10,2,7`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: "trait",
|
||||
labels: ["Dunk", "Egg", "Daeron"],
|
||||
series: [
|
||||
{ title: "Physical", values: [10, 2, 7] },
|
||||
{ title: "Mental", values: [10, 2, 7] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("transposes single data row with header into single series", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`trait,Dunk,Egg,Daeron
|
||||
Physical,10,2,7`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: "Physical",
|
||||
labels: ["Dunk", "Egg", "Daeron"],
|
||||
series: [{ title: "Physical", values: [10, 2, 7] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("transposes single data row without header into single series", () => {
|
||||
const result = tryParseSpreadsheet(`Physical,10,2,7`);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: "Physical",
|
||||
labels: null,
|
||||
series: [{ title: "Physical", values: [10, 2, 7] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers tab over comma/semicolon when tabs produce multiple columns", () => {
|
||||
const result = tryParseSpreadsheet(
|
||||
`Label\tValue
|
||||
A\t10
|
||||
B\t20`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
title: "Value",
|
||||
labels: ["A", "B"],
|
||||
series: [{ title: "Value", values: [10, 20] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
39
packages/excalidraw/tests/clients.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getNameInitial } from "../clients";
|
||||
|
||||
describe("getClientInitials", () => {
|
||||
it("returns substring if one name provided", () => {
|
||||
expect(getNameInitial("Alan")).toBe("A");
|
||||
});
|
||||
|
||||
it("returns initials", () => {
|
||||
expect(getNameInitial("John Doe")).toBe("J");
|
||||
});
|
||||
|
||||
it("returns correct initials if many names provided", () => {
|
||||
expect(getNameInitial("John Alan Doe")).toBe("J");
|
||||
});
|
||||
|
||||
it("returns single initial if 1 letter provided", () => {
|
||||
expect(getNameInitial("z")).toBe("Z");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace", () => {
|
||||
expect(getNameInitial(" q ")).toBe("Q");
|
||||
});
|
||||
|
||||
it('returns "?" if falsey value provided', () => {
|
||||
expect(getNameInitial("")).toBe("?");
|
||||
expect(getNameInitial(undefined)).toBe("?");
|
||||
expect(getNameInitial(null)).toBe("?");
|
||||
});
|
||||
|
||||
it('returns "?" when value is blank', () => {
|
||||
expect(getNameInitial(" ")).toBe("?");
|
||||
});
|
||||
|
||||
it("works with multibyte strings", () => {
|
||||
expect(getNameInitial("😀")).toBe("😀");
|
||||
// but doesn't work with emoji ZWJ sequences
|
||||
expect(getNameInitial("👨👩👦")).toBe("👨");
|
||||
});
|
||||
});
|
||||
647
packages/excalidraw/tests/clipboard.test.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { getLineHeightInPx } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
|
||||
|
||||
import { getElementBounds } from "@excalidraw/element";
|
||||
|
||||
import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { mockMermaidToExcalidraw } from "./helpers/mocks";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import {
|
||||
render,
|
||||
waitFor,
|
||||
GlobalTestState,
|
||||
unmountComponent,
|
||||
} from "./test-utils";
|
||||
|
||||
import type { NormalizedZoomValue } from "../types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
vi.mock("@excalidraw/common", async (importOriginal) => {
|
||||
const module = await importOriginal<typeof import("@excalidraw/common")>();
|
||||
const { mockThrottleRAF } = await import("./helpers/mocks");
|
||||
|
||||
return {
|
||||
__esmodule: true,
|
||||
...module,
|
||||
isDarwin: false,
|
||||
KEYS: {
|
||||
...module.KEYS,
|
||||
CTRL_OR_CMD: "ctrlKey",
|
||||
},
|
||||
throttleRAF: mockThrottleRAF,
|
||||
};
|
||||
});
|
||||
|
||||
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 () => {
|
||||
unmountComponent();
|
||||
|
||||
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 elementsMap = arrayToMap(h.elements);
|
||||
|
||||
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getLineHeight(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], elementsMap);
|
||||
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], elementsMap);
|
||||
expect(elY).toEqual(firstElY + lineHeightPx * i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should leave a space for blank new lines", async () => {
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
const text = "hkhkjhki\n\njgkjhffjh";
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getLineHeight(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], elementsMap);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [lx, lastElY] = getElementBounds(h.elements[1], elementsMap);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasting & frames", () => {
|
||||
it("should add pasted elements to frame under cursor", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
|
||||
API.setElements([frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[0].index! < frame.index!).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should layer pasted elements above the highest frame child", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
|
||||
API.setElements([frameChild, frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frameChild.id,
|
||||
h.elements[1].id,
|
||||
frame.id,
|
||||
]);
|
||||
expect(h.elements[1].index! > frameChild.index!).toBe(true);
|
||||
expect(h.elements[1].index! < frame.index!).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve denormalized pasted frame child order", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [frame, frameChild],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(200, 200);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[0].type).toBe(frame.type);
|
||||
expect(h.elements[1].type).toBe(frameChild.type);
|
||||
expect(h.elements[1].frameId).toBe(h.elements[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove element from frame when pasted outside", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: frame.id,
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
API.setElements([frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(150, 150);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter out elements not overlapping frame", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 50,
|
||||
height: 50,
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
API.setElements([frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect, rect2],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(90, 90);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not filter out elements not overlapping frame if part of group", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 50,
|
||||
height: 50,
|
||||
x: 100,
|
||||
y: 100,
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
API.setElements([frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect, rect2],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(90, 90);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].type).toBe(rect2.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[2].id).toBe(frame.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not filter out other frames and their children", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const frame2 = API.createElement({
|
||||
type: "frame",
|
||||
width: 75,
|
||||
height: 75,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 50,
|
||||
height: 50,
|
||||
x: 55,
|
||||
y: 55,
|
||||
frameId: frame2.id,
|
||||
});
|
||||
|
||||
API.setElements([frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect, rect2, frame2],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(90, 90);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(4);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(h.elements[3].id);
|
||||
expect(h.elements[3].type).toBe(frame2.type);
|
||||
expect(h.elements[3].frameId).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboard - pasting mermaid definition", () => {
|
||||
beforeAll(() => {
|
||||
mockMermaidToExcalidraw({
|
||||
parseMermaidToExcalidraw: async (definition) => {
|
||||
const lines = definition.split("\n");
|
||||
return new Promise((resolve, reject) => {
|
||||
if (lines.some((line) => line === "flowchart TD")) {
|
||||
resolve({
|
||||
elements: [
|
||||
{
|
||||
id: "rect1",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 69.703125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "A",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
reject(new Error("ERROR"));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect and paste as mermaid", async () => {
|
||||
const text = "flowchart TD\nA";
|
||||
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toEqual(2);
|
||||
expect(h.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "rectangle" }),
|
||||
expect.objectContaining({ type: "text", text: "A" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should support directives", async () => {
|
||||
const text = "%%{init: { **config** } }%%\nflowchart TD\nA";
|
||||
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toEqual(2);
|
||||
expect(h.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "rectangle" }),
|
||||
expect.objectContaining({ type: "text", text: "A" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should paste as normal text if invalid mermaid", async () => {
|
||||
const text = "flowchart TD xx\nA";
|
||||
pasteWithCtrlCmdV(text);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toEqual(2);
|
||||
expect(h.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text", text: "flowchart TD xx" }),
|
||||
expect.objectContaining({ type: "text", text: "A" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
386
packages/excalidraw/tests/colorInput.test.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import { normalizeInputColor } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { fireEvent, render, waitFor, toggleMenu } from "./test-utils";
|
||||
|
||||
describe("normalizeInputColor", () => {
|
||||
describe("hex colors", () => {
|
||||
it("returns hex color with hash as-is", () => {
|
||||
expect(normalizeInputColor("#ff0000")).toBe("#ff0000");
|
||||
expect(normalizeInputColor("#FF0000")).toBe("#FF0000");
|
||||
expect(normalizeInputColor("#abc")).toBe("#abc");
|
||||
expect(normalizeInputColor("#ABC")).toBe("#ABC");
|
||||
});
|
||||
|
||||
it("adds hash to hex color without hash", () => {
|
||||
expect(normalizeInputColor("ff0000")).toBe("#ff0000");
|
||||
expect(normalizeInputColor("FF0000")).toBe("#FF0000");
|
||||
expect(normalizeInputColor("abc")).toBe("#abc");
|
||||
expect(normalizeInputColor("ABC")).toBe("#ABC");
|
||||
});
|
||||
|
||||
it("handles 8-digit hex (hexa) with alpha", () => {
|
||||
expect(normalizeInputColor("#ff000080")).toBe("#ff000080");
|
||||
expect(normalizeInputColor("#ff0000ff")).toBe("#ff0000ff");
|
||||
});
|
||||
|
||||
it("does NOT add hash to hexa without hash (tinycolor detects as hex8, not hex)", () => {
|
||||
// Note: tinycolor detects 8-digit hex as "hex8" format, not "hex"
|
||||
expect(normalizeInputColor("ff000080")).toBe("#ff000080");
|
||||
});
|
||||
});
|
||||
|
||||
describe("named colors", () => {
|
||||
it("returns named colors as-is", () => {
|
||||
expect(normalizeInputColor("red")).toBe("red");
|
||||
expect(normalizeInputColor("blue")).toBe("blue");
|
||||
expect(normalizeInputColor("green")).toBe("green");
|
||||
expect(normalizeInputColor("white")).toBe("white");
|
||||
expect(normalizeInputColor("black")).toBe("black");
|
||||
expect(normalizeInputColor("transparent")).toBe("transparent");
|
||||
});
|
||||
|
||||
it("handles case variations of named colors", () => {
|
||||
expect(normalizeInputColor("RED")).toBe("RED");
|
||||
expect(normalizeInputColor("Red")).toBe("Red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgb/rgba colors", () => {
|
||||
it("returns rgb colors as-is", () => {
|
||||
expect(normalizeInputColor("rgb(255, 0, 0)")).toBe("rgb(255, 0, 0)");
|
||||
expect(normalizeInputColor("rgb(0,0,0)")).toBe("rgb(0,0,0)");
|
||||
});
|
||||
|
||||
// NOTE: tinycolor clamps values, so rgb(256, 0, 0) is treated as valid
|
||||
it("tinycolor considers out-of-range rgb values as valid (clamped)", () => {
|
||||
expect(normalizeInputColor("rgb(256, 0, 0)")).toBe("rgb(256, 0, 0)");
|
||||
});
|
||||
|
||||
it("returns rgba colors as-is", () => {
|
||||
expect(normalizeInputColor("rgba(255, 0, 0, 0.5)")).toBe(
|
||||
"rgba(255, 0, 0, 0.5)",
|
||||
);
|
||||
expect(normalizeInputColor("rgba(0,0,0,1)")).toBe("rgba(0,0,0,1)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hsl/hsla colors", () => {
|
||||
it("returns hsl colors as-is", () => {
|
||||
expect(normalizeInputColor("hsl(0, 100%, 50%)")).toBe(
|
||||
"hsl(0, 100%, 50%)",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns hsla colors as-is", () => {
|
||||
expect(normalizeInputColor("hsla(0, 100%, 50%, 0.5)")).toBe(
|
||||
"hsla(0, 100%, 50%, 0.5)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
it("trims leading whitespace", () => {
|
||||
expect(normalizeInputColor(" #ff0000")).toBe("#ff0000");
|
||||
expect(normalizeInputColor(" red")).toBe("red");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace", () => {
|
||||
expect(normalizeInputColor("#ff0000 ")).toBe("#ff0000");
|
||||
expect(normalizeInputColor("red ")).toBe("red");
|
||||
});
|
||||
|
||||
it("trims both leading and trailing whitespace", () => {
|
||||
expect(normalizeInputColor(" #ff0000 ")).toBe("#ff0000");
|
||||
expect(normalizeInputColor(" red ")).toBe("red");
|
||||
});
|
||||
|
||||
it("adds hash to trimmed hex without hash", () => {
|
||||
expect(normalizeInputColor(" ff0000 ")).toBe("#ff0000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid colors", () => {
|
||||
it("returns null for invalid color strings", () => {
|
||||
expect(normalizeInputColor("notacolor")).toBe(null);
|
||||
expect(normalizeInputColor("gggggg")).toBe(null);
|
||||
expect(normalizeInputColor("#gggggg")).toBe(null);
|
||||
expect(normalizeInputColor("")).toBe(null);
|
||||
expect(normalizeInputColor(" ")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for partial/malformed colors", () => {
|
||||
expect(normalizeInputColor("#ff")).toBe(null);
|
||||
expect(normalizeInputColor("rgb(")).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColorInput error handling", () => {
|
||||
const openCanvasBackgroundColorPicker = async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
|
||||
// Open main menu to access canvas background color picker
|
||||
toggleMenu(container);
|
||||
|
||||
// Set up ResizeObserver for radix-ui components
|
||||
(global as any).ResizeObserver = class ResizeObserver {
|
||||
constructor(cb: any) {
|
||||
(this as any).cb = cb;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// Open color picker for canvas background
|
||||
const canvasBgButton = container.querySelector(
|
||||
'[data-openpopup="canvasBackground"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(canvasBgButton).toBeTruthy();
|
||||
fireEvent.click(canvasBgButton);
|
||||
|
||||
// Wait for color picker to open
|
||||
await waitFor(() => {
|
||||
const colorInput = container.querySelector(
|
||||
".color-picker-input",
|
||||
) as HTMLInputElement;
|
||||
expect(colorInput).toBeTruthy();
|
||||
});
|
||||
|
||||
return container.querySelector(".color-picker-input") as HTMLInputElement;
|
||||
};
|
||||
|
||||
it("shows error for invalid hex length (too short)", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter invalid hex code with length 1
|
||||
fireEvent.change(colorInput, { target: { value: "1" } });
|
||||
|
||||
// Check error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeTruthy();
|
||||
expect(errorMessage.textContent).toContain(
|
||||
"Hex code must be 3, 4, 6, or 8 characters",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error for invalid hex length (too long)", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter invalid hex code with length 9
|
||||
fireEvent.change(colorInput, { target: { value: "123456789" } });
|
||||
|
||||
// Check error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeTruthy();
|
||||
expect(errorMessage.textContent).toContain(
|
||||
"Hex code must be 3, 4, 6, or 8 characters",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows generic error for non-hex-shaped invalid input", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter invalid input that isn't hex-shaped
|
||||
fireEvent.change(colorInput, { target: { value: "zzzzzz" } });
|
||||
|
||||
// Check error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeTruthy();
|
||||
expect(errorMessage.textContent).toContain("Not a valid color");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows generic error for unrecognized color names", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter non-hex text (not a valid color name)
|
||||
fireEvent.change(colorInput, { target: { value: "notacolor" } });
|
||||
|
||||
// Check error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeTruthy();
|
||||
expect(errorMessage.textContent).toContain("Not a valid color");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show an error for a complete named color", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
fireEvent.change(colorInput, { target: { value: "transparent" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(colorInput.value).toBe("transparent");
|
||||
expect(
|
||||
document.querySelector(".color-picker__error-message"),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show an error for valid rgb() input", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
fireEvent.change(colorInput, { target: { value: "rgb(255, 0, 0)" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector(".color-picker__error-message"),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show error for valid hex codes", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter valid hex code
|
||||
fireEvent.change(colorInput, { target: { value: "ff0000" } });
|
||||
|
||||
// Check no error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears error when input is cleared", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter invalid hex code to trigger error
|
||||
fireEvent.change(colorInput, { target: { value: "notacolor" } });
|
||||
|
||||
// Check error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear input
|
||||
fireEvent.change(colorInput, { target: { value: "" } });
|
||||
|
||||
// Check error message disappears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears error when blurring input", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter invalid hex code to trigger error
|
||||
fireEvent.change(colorInput, { target: { value: "notacolor" } });
|
||||
|
||||
// Check error message appears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
// Blur input (simulate clicking outside)
|
||||
fireEvent.blur(colorInput);
|
||||
|
||||
// Check error message disappears
|
||||
await waitFor(() => {
|
||||
const errorMessage = document.querySelector(
|
||||
".color-picker__error-message",
|
||||
) as HTMLElement;
|
||||
expect(errorMessage).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the typed value when editing a valid color", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
fireEvent.change(colorInput, { target: { value: "ff0000" } });
|
||||
await waitFor(() => {
|
||||
expect(colorInput.value).toBe("ff0000");
|
||||
});
|
||||
|
||||
// deleting a char makes it invalid, but must not be reverted
|
||||
fireEvent.change(colorInput, { target: { value: "ff000" } });
|
||||
await waitFor(() => {
|
||||
expect(colorInput.value).toBe("ff000");
|
||||
expect(
|
||||
document.querySelector(".color-picker__error-message"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// retyping it resolves to the color we're already at, so the `color` prop
|
||||
// doesn't change — the input must still show what was typed
|
||||
fireEvent.change(colorInput, { target: { value: "ff0000" } });
|
||||
await waitFor(() => {
|
||||
expect(colorInput.value).toBe("ff0000");
|
||||
expect(
|
||||
document.querySelector(".color-picker__error-message"),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("can delete a valid color down to an empty input", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
fireEvent.change(colorInput, { target: { value: "1e9df0" } });
|
||||
await waitFor(() => {
|
||||
expect(colorInput.value).toBe("1e9df0");
|
||||
});
|
||||
|
||||
for (const value of ["1e9df", "1e9d", "1e9", "1e", "1", ""]) {
|
||||
fireEvent.change(colorInput, { target: { value } });
|
||||
await waitFor(() => {
|
||||
expect(colorInput.value).toBe(value);
|
||||
});
|
||||
}
|
||||
|
||||
expect(document.querySelector(".color-picker__error-message")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("input has aria-invalid attribute when error is shown", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter invalid hex code to trigger error
|
||||
fireEvent.change(colorInput, { target: { value: "notacolor" } });
|
||||
|
||||
// Check aria-invalid attribute is set to true
|
||||
await waitFor(() => {
|
||||
expect(colorInput.getAttribute("aria-invalid")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("input has aria-invalid attribute set to false when no error", async () => {
|
||||
const colorInput = await openCanvasBackgroundColorPicker();
|
||||
|
||||
// Enter valid hex code
|
||||
fireEvent.change(colorInput, { target: { value: "ff0000" } });
|
||||
|
||||
// Check aria-invalid attribute is set to false
|
||||
await waitFor(() => {
|
||||
expect(colorInput.getAttribute("aria-invalid")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
128
packages/excalidraw/tests/colorPickerHotkeys.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
|
||||
import type { ColorPaletteCustom } from "@excalidraw/common";
|
||||
|
||||
import { colorPickerKeyNavHandler } from "../components/ColorPicker/keyboardNavHandlers";
|
||||
|
||||
const handlerArgs = (
|
||||
key: string,
|
||||
palette: ColorPaletteCustom,
|
||||
onChange: (color: string) => void,
|
||||
excludedColors?: readonly string[],
|
||||
) => ({
|
||||
event: {
|
||||
key,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.KeyboardEvent,
|
||||
activeColorPickerSection: null,
|
||||
palette,
|
||||
color: null,
|
||||
onChange,
|
||||
customColors: [],
|
||||
setActiveColorPickerSection: () => {},
|
||||
updateData: () => {},
|
||||
activeShade: 1,
|
||||
onEyeDropperToggle: () => {},
|
||||
onEscape: () => {},
|
||||
excludedColors,
|
||||
});
|
||||
|
||||
describe("color picker hotkeys", () => {
|
||||
it("reports an excluded (hidden) entry's hotkey as handled without selecting it", () => {
|
||||
// regression: returning "unhandled" let the key escape the modal and hit
|
||||
// global shortcuts (`q` toggles the tool lock; see the integration
|
||||
// regression in bucketFill.test.tsx)
|
||||
const onChange = vi.fn();
|
||||
const palette = {
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
red: "#ff0000",
|
||||
} as ColorPaletteCustom;
|
||||
|
||||
const handled = colorPickerKeyNavHandler(
|
||||
handlerArgs("q", palette, onChange, [COLOR_PALETTE.transparent]),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a hotkey past the end of a short custom palette as handled", () => {
|
||||
// a host-provided palette with fewer entries than hotkey bindings must
|
||||
// not leak the unbound keys either
|
||||
const onChange = vi.fn();
|
||||
const palette = { red: "#ff0000" } as ColorPaletteCustom;
|
||||
|
||||
// `b` is the 15th binding — far past the single palette entry
|
||||
const handled = colorPickerKeyNavHandler(
|
||||
handlerArgs("b", palette, onChange),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still selects a present, non-excluded entry", () => {
|
||||
const onChange = vi.fn();
|
||||
const palette = {
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
red: "#ff0000",
|
||||
} as ColorPaletteCustom;
|
||||
|
||||
const handled = colorPickerKeyNavHandler(
|
||||
handlerArgs("w", palette, onChange, [COLOR_PALETTE.transparent]),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onChange).toHaveBeenCalledWith("#ff0000");
|
||||
});
|
||||
|
||||
it("steps over an excluded entry when navigating vertically", () => {
|
||||
// three rows of five; `transparent` sits in column 0 of row 1 and is
|
||||
// excluded, so vertical moves through that cell must skip it in the
|
||||
// arrow's direction (wrapping) instead of landing on the hidden swatch
|
||||
const palette = {
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
white: "#w0",
|
||||
gray: "#g0",
|
||||
black: "#k0",
|
||||
bronze: "#b0",
|
||||
red: "#r1",
|
||||
green: "#g1",
|
||||
blue: "#b1",
|
||||
yellow: "#y1",
|
||||
orange: "#o1",
|
||||
pink: "#r2",
|
||||
grape: "#g2",
|
||||
violet: "#b2",
|
||||
cyan: "#y2",
|
||||
teal: "#o2",
|
||||
} as unknown as ColorPaletteCustom;
|
||||
const excluded = [COLOR_PALETTE.transparent];
|
||||
|
||||
// up from row 2 / column 0 wraps past the excluded cell to row 3
|
||||
let onChange = vi.fn();
|
||||
expect(
|
||||
colorPickerKeyNavHandler({
|
||||
...handlerArgs("ArrowUp", palette, onChange, excluded),
|
||||
activeColorPickerSection: "baseColors",
|
||||
color: "#r1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(onChange).toHaveBeenCalledWith("#r2");
|
||||
|
||||
// down from row 3 / column 0 wraps past the excluded cell to row 2
|
||||
onChange = vi.fn();
|
||||
expect(
|
||||
colorPickerKeyNavHandler({
|
||||
...handlerArgs("ArrowDown", palette, onChange, excluded),
|
||||
activeColorPickerSection: "baseColors",
|
||||
color: "#r2",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(onChange).toHaveBeenCalledWith("#r1");
|
||||
});
|
||||
});
|
||||
130
packages/excalidraw/tests/colorTargets.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
STICKY_NOTE_BACKGROUND_PICKS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getColorTargetAppStateUpdates,
|
||||
resolveColorTarget,
|
||||
} from "../actions/colorTargets";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const appState = (overrides: Partial<AppState> = {}): AppState =>
|
||||
({
|
||||
...getDefaultAppState(),
|
||||
selectedElementIds: {},
|
||||
editingTextElement: null,
|
||||
...overrides,
|
||||
} as AppState);
|
||||
|
||||
describe("resolveColorTarget", () => {
|
||||
const note = API.createElement({ type: "stickynote", id: "note" });
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
containerId: "note",
|
||||
});
|
||||
const rectangle = API.createElement({ type: "rectangle", id: "rectangle" });
|
||||
const elements = [note, label, rectangle];
|
||||
|
||||
it("follows the active tool when nothing is targeted", () => {
|
||||
const regular = resolveColorTarget(appState(), elements, "strokeColor");
|
||||
expect(regular.kind).toBe("regular");
|
||||
expect(regular.appStateKeys).toEqual(["currentItemStrokeColor"]);
|
||||
expect(regular.topPicks).toBe(DEFAULT_ELEMENT_STROKE_PICKS);
|
||||
expect(regular.excludedColors).toBeUndefined();
|
||||
|
||||
const sticky = resolveColorTarget(
|
||||
appState({
|
||||
activeTool: {
|
||||
...getDefaultAppState().activeTool,
|
||||
type: "stickynote" as const,
|
||||
customType: null,
|
||||
},
|
||||
}),
|
||||
elements,
|
||||
"backgroundColor",
|
||||
);
|
||||
expect(sticky.kind).toBe("sticky");
|
||||
expect(sticky.appStateKeys).toEqual([
|
||||
"currentItemStickynoteBackgroundColor",
|
||||
]);
|
||||
expect(sticky.topPicks).toBe(STICKY_NOTE_BACKGROUND_PICKS);
|
||||
expect(sticky.customizableTopPicks).toBe("stickyNoteBackground");
|
||||
expect(sticky.excludedColors).toContain(COLOR_PALETTE.transparent);
|
||||
});
|
||||
|
||||
it("classifies a note (and its label) as the sticky domain", () => {
|
||||
const target = resolveColorTarget(
|
||||
appState({ selectedElementIds: { note: true } }),
|
||||
elements,
|
||||
"strokeColor",
|
||||
);
|
||||
expect(target.kind).toBe("sticky");
|
||||
expect(target.appStateKeys).toEqual(["currentItemStickynoteStrokeColor"]);
|
||||
});
|
||||
|
||||
it("writes both domains for a mixed selection but shows the regular picker", () => {
|
||||
const target = resolveColorTarget(
|
||||
appState({ selectedElementIds: { note: true, rectangle: true } }),
|
||||
elements,
|
||||
"strokeColor",
|
||||
);
|
||||
expect(target.kind).toBe("mixed");
|
||||
expect(target.appStateKeys).toEqual([
|
||||
"currentItemStrokeColor",
|
||||
"currentItemStickynoteStrokeColor",
|
||||
]);
|
||||
expect(target.topPicks).toBe(DEFAULT_ELEMENT_STROKE_PICKS);
|
||||
expect(target.excludedColors).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats the sticky label being edited as a sticky target even with no selection", () => {
|
||||
// `handleTextWysiwyg` deselects while editing; `changeProperty` still
|
||||
// targets the edited text explicitly
|
||||
const target = resolveColorTarget(
|
||||
appState({ editingTextElement: label }),
|
||||
elements,
|
||||
"strokeColor",
|
||||
);
|
||||
expect(target.kind).toBe("sticky");
|
||||
});
|
||||
|
||||
it("normalizes transparent for the sticky defaults only", () => {
|
||||
const stickyStroke = resolveColorTarget(
|
||||
appState({ selectedElementIds: { note: true } }),
|
||||
elements,
|
||||
"strokeColor",
|
||||
);
|
||||
expect(
|
||||
getColorTargetAppStateUpdates(stickyStroke, COLOR_PALETTE.transparent),
|
||||
).toEqual({ currentItemStickynoteStrokeColor: COLOR_PALETTE.black });
|
||||
|
||||
const stickyBackground = resolveColorTarget(
|
||||
appState({ selectedElementIds: { note: true } }),
|
||||
elements,
|
||||
"backgroundColor",
|
||||
);
|
||||
expect(
|
||||
getColorTargetAppStateUpdates(
|
||||
stickyBackground,
|
||||
COLOR_PALETTE.transparent,
|
||||
),
|
||||
).toEqual({ currentItemStickynoteBackgroundColor: DEFAULT_STICKY_NOTE_BG });
|
||||
|
||||
const regular = resolveColorTarget(
|
||||
appState({ selectedElementIds: { rectangle: true } }),
|
||||
elements,
|
||||
"backgroundColor",
|
||||
);
|
||||
expect(
|
||||
getColorTargetAppStateUpdates(regular, COLOR_PALETTE.transparent),
|
||||
).toEqual({ currentItemBackgroundColor: COLOR_PALETTE.transparent });
|
||||
});
|
||||
});
|
||||
51
packages/excalidraw/tests/colorTopPicks.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { COLOR_TOP_PICKS_SLOTS } from "@excalidraw/common";
|
||||
|
||||
import { restoreAppState } from "../data/restore";
|
||||
|
||||
describe("restore appState.colorTopPicks", () => {
|
||||
it("dedupes notation variants and drops non-strings", () => {
|
||||
const restored = restoreAppState(
|
||||
{
|
||||
colorTopPicks: {
|
||||
elementStroke: null,
|
||||
elementBackground: [
|
||||
"#FFF",
|
||||
"#ffffff",
|
||||
"white",
|
||||
"#a5d8ff",
|
||||
"rgb(165, 216, 255)",
|
||||
"#eebefa",
|
||||
42,
|
||||
"transparent",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
null,
|
||||
);
|
||||
expect(restored.colorTopPicks.elementBackground).toEqual([
|
||||
"#FFF",
|
||||
"#a5d8ff",
|
||||
"#eebefa",
|
||||
"transparent",
|
||||
]);
|
||||
expect(restored.colorTopPicks.elementStroke).toBe(null);
|
||||
});
|
||||
|
||||
it("caps at the strip slot count and nulls malformed input", () => {
|
||||
const many = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `#0000${String(i).padStart(2, "0")}`,
|
||||
);
|
||||
const restored = restoreAppState(
|
||||
{
|
||||
colorTopPicks: { elementStroke: many, elementBackground: "junk" },
|
||||
} as any,
|
||||
null,
|
||||
);
|
||||
expect(restored.colorTopPicks.elementStroke).toEqual(
|
||||
many.slice(0, COLOR_TOP_PICKS_SLOTS),
|
||||
);
|
||||
expect(restored.colorTopPicks.elementBackground).toBe(null);
|
||||
});
|
||||
});
|
||||
628
packages/excalidraw/tests/contextmenu.test.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { KEYS, STROKE_WIDTH, reseed } from "@excalidraw/common";
|
||||
|
||||
import { setDateTimeForTests } from "@excalidraw/common";
|
||||
|
||||
import { copiedStyles } from "../actions/actionStyles";
|
||||
import { Excalidraw } from "../index";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
GlobalTestState,
|
||||
screen,
|
||||
queryByText,
|
||||
queryAllByText,
|
||||
waitFor,
|
||||
togglePopover,
|
||||
unmountComponent,
|
||||
checkpointHistory,
|
||||
} from "./test-utils";
|
||||
|
||||
import type { ShortcutName } from "../actions/shortcuts";
|
||||
import type { ActionName } from "../actions/types";
|
||||
|
||||
const checkpoint = (name: string) => {
|
||||
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
|
||||
`[${name}] number of renders`,
|
||||
);
|
||||
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
||||
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
|
||||
h.elements.forEach((element, i) =>
|
||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||
);
|
||||
|
||||
checkpointHistory(h.history, name);
|
||||
};
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const renderStaticScene = vi.spyOn(StaticScene, "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 expectedShortcutNames: ShortcutName[] = [
|
||||
"paste",
|
||||
"selectAll",
|
||||
"gridMode",
|
||||
"objectsSnapMode",
|
||||
"zenMode",
|
||||
"viewMode",
|
||||
"stats",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expectedShortcutNames.forEach((shortcutName) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows context menu for element", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(10, 10);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedContextMenuItems: ActionName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"wrapSelectionInFrame",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
"addToLibrary",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"duplicateSelection",
|
||||
"hyperlink",
|
||||
"copyElementLink",
|
||||
"toggleElementLock",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
|
||||
expectedContextMenuItems.forEach((item) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${item}"]`),
|
||||
).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",
|
||||
});
|
||||
API.setElements([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(12, -10);
|
||||
mouse.up(10, 10);
|
||||
|
||||
mouse.reset();
|
||||
mouse.click(10, 10);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(22, 0);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"wrapSelectionInFrame",
|
||||
"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(12, -10);
|
||||
mouse.up(10, 10);
|
||||
|
||||
mouse.reset();
|
||||
mouse.click(10, 10);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(22, 0);
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedContextMenuItems: ActionName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"wrapSelectionInFrame",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
"copyElementLink",
|
||||
"ungroup",
|
||||
"addToLibrary",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"duplicateSelection",
|
||||
"toggleElementLock",
|
||||
];
|
||||
|
||||
expect(contextMenu).not.toBeNull();
|
||||
expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
|
||||
expectedContextMenuItems.forEach((item) => {
|
||||
expect(
|
||||
contextMenu?.querySelector(`li[data-testid="${item}"]`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting 'Copy styles' in context menu copies styles", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(10, 10);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
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.getByTestId("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(STROKE_WIDTH.bold);
|
||||
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(0, 0);
|
||||
mouse.up(10, 10);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
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(0, 0);
|
||||
mouse.up(10, 10);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
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(0, 0);
|
||||
mouse.up(10, 10);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
||||
expect(h.elements).toHaveLength(2);
|
||||
const {
|
||||
id: _id0,
|
||||
seed: _seed0,
|
||||
x: _x0,
|
||||
y: _y0,
|
||||
index: _fractionalIndex0,
|
||||
version: _version0,
|
||||
versionNonce: _versionNonce0,
|
||||
...rect1
|
||||
} = h.elements[0];
|
||||
const {
|
||||
id: _id1,
|
||||
seed: _seed1,
|
||||
x: _x1,
|
||||
y: _y1,
|
||||
index: _fractionalIndex1,
|
||||
version: _version1,
|
||||
versionNonce: _versionNonce1,
|
||||
...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: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
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: 3,
|
||||
clientY: 3,
|
||||
});
|
||||
|
||||
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"],
|
||||
});
|
||||
API.setElements([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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
46
packages/excalidraw/tests/convertElementType.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ROUNDNESS } from "@excalidraw/common";
|
||||
|
||||
import { convertElementTypes } from "../components/ConvertElementTypePopup";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { act, render } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("convert element type", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
// #9662
|
||||
it("recalculates roundness type when switching between generic shapes", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, // Dooesn't matter as long as it is set
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
act(() => {
|
||||
convertElementTypes(h.app, {
|
||||
conversionType: "generic",
|
||||
nextType: "diamond",
|
||||
});
|
||||
});
|
||||
|
||||
expect(h.elements[0].type).toBe("diamond");
|
||||
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.PROPORTIONAL_RADIUS);
|
||||
|
||||
act(() => {
|
||||
convertElementTypes(h.app, {
|
||||
conversionType: "generic",
|
||||
nextType: "rectangle",
|
||||
});
|
||||
});
|
||||
|
||||
expect(h.elements[0].type).toBe("rectangle");
|
||||
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.ADAPTIVE_RADIUS);
|
||||
});
|
||||
});
|
||||
167
packages/excalidraw/tests/created.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { randomInteger } from "@excalidraw/common";
|
||||
import { CaptureUpdateAction, newElementWith } from "@excalidraw/element";
|
||||
|
||||
import type { FractionalIndex } from "@excalidraw/element/types";
|
||||
|
||||
import { reconcileElements } from "../data/reconcile";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { act, render } from "./test-utils";
|
||||
|
||||
import type { RemoteExcalidrawElement } from "../data/reconcile";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("element creation timestamps", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "rectangle", created: 123 },
|
||||
{ type: "rectangle", created: null },
|
||||
{ type: "stickynote", created: 123 },
|
||||
{ type: "stickynote", created: null },
|
||||
] as const)(
|
||||
"preserves $type created=$created through capture, edits, and creation undo/redo",
|
||||
({ type, created }) => {
|
||||
const element = API.createElement({
|
||||
type,
|
||||
x: 10,
|
||||
created,
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
|
||||
API.updateScene({
|
||||
elements: [element],
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
});
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
h.app.scheduleCapture();
|
||||
h.setState({});
|
||||
});
|
||||
expect(API.getUndoStack()).toHaveLength(1);
|
||||
expect(h.elements[0]).toMatchObject({
|
||||
created,
|
||||
version: element.version,
|
||||
versionNonce: element.versionNonce,
|
||||
});
|
||||
|
||||
const edited = newElementWith(h.elements[0], { x: 50 });
|
||||
API.updateScene({
|
||||
elements: [edited],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
expect(API.getUndoStack()).toHaveLength(2);
|
||||
expect(h.elements[0]).toMatchObject({
|
||||
created,
|
||||
x: 50,
|
||||
version: edited.version,
|
||||
versionNonce: edited.versionNonce,
|
||||
});
|
||||
expect(h.store.snapshot.elements.get(element.id)?.created).toBe(created);
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements[0]).toMatchObject({
|
||||
created,
|
||||
x: 10,
|
||||
isDeleted: false,
|
||||
});
|
||||
expect(h.elements[0].version).toBeGreaterThan(edited.version);
|
||||
|
||||
Keyboard.undo();
|
||||
expect(h.elements[0]).toMatchObject({ created, isDeleted: true });
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
|
||||
Keyboard.redo();
|
||||
expect(h.elements[0]).toMatchObject({
|
||||
created,
|
||||
x: 10,
|
||||
isDeleted: false,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
expect(h.elements[0]).toMatchObject({
|
||||
created,
|
||||
x: 50,
|
||||
isDeleted: false,
|
||||
});
|
||||
expect(API.getUndoStack()).toHaveLength(2);
|
||||
expect(API.getRedoStack()).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("continues to a visible undo change after replaying creation metadata", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
created: null,
|
||||
});
|
||||
API.updateScene({
|
||||
elements: [element],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
API.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 50 })],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
// An explicit metadata repair must round-trip without consuming a
|
||||
// separate undo keypress before the user's visible edit. `created` is not
|
||||
// an element update, so a repair constructs a new element revision.
|
||||
API.updateScene({
|
||||
elements: [
|
||||
{
|
||||
...h.elements[0],
|
||||
created: 123,
|
||||
version: h.elements[0].version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
},
|
||||
],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
expect(API.getUndoStack()).toHaveLength(2);
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(h.elements[0]).toMatchObject({ x: 10, created: null });
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
expect(API.getRedoStack()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves the remote creation timestamp and revision during reconciliation", () => {
|
||||
const element = API.createElement({ type: "rectangle", created: 123 });
|
||||
API.updateScene({
|
||||
elements: [element],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
const remoteElement = newElementWith(h.elements[0], { x: 50 });
|
||||
const reconciled = reconcileElements(
|
||||
h.app.scene.getElementsIncludingDeleted(),
|
||||
[remoteElement as RemoteExcalidrawElement],
|
||||
h.state,
|
||||
);
|
||||
|
||||
API.updateScene({
|
||||
elements: reconciled,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
expect(h.elements[0]).toMatchObject({
|
||||
x: 50,
|
||||
created: 123,
|
||||
version: remoteElement.version,
|
||||
versionNonce: remoteElement.versionNonce,
|
||||
});
|
||||
expect(h.store.snapshot.elements.get(element.id)).toMatchObject({
|
||||
created: 123,
|
||||
version: remoteElement.version,
|
||||
versionNonce: remoteElement.versionNonce,
|
||||
});
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
147
packages/excalidraw/tests/crossDocument.test.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React from "react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render as renderReact,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import type { ExcalidrawImperativeAPI } from "../types";
|
||||
|
||||
describe("cross-document rendering", () => {
|
||||
it("scopes listeners, fonts, and portals to ownerDocument", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
|
||||
const ownerDocument = iframe.contentDocument!;
|
||||
const ownerWindow = iframe.contentWindow! as Window & typeof globalThis;
|
||||
const mountNode = ownerDocument.createElement("div");
|
||||
ownerDocument.body.append(mountNode);
|
||||
|
||||
const fonts = {
|
||||
load: vi.fn().mockResolvedValue([]),
|
||||
check: vi.fn().mockReturnValue(true),
|
||||
has: vi.fn().mockReturnValue(true),
|
||||
add: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(ownerDocument, "fonts", { value: fonts });
|
||||
const addEventListener = vi.fn();
|
||||
Object.defineProperties(ownerWindow, {
|
||||
addEventListener: { value: addEventListener },
|
||||
removeEventListener: { value: vi.fn() },
|
||||
requestAnimationFrame: {
|
||||
value: window.requestAnimationFrame.bind(window),
|
||||
},
|
||||
ResizeObserver: { value: window.ResizeObserver },
|
||||
});
|
||||
Object.defineProperty(ownerDocument, "defaultView", {
|
||||
value: ownerWindow,
|
||||
});
|
||||
Object.defineProperty(
|
||||
ownerWindow.HTMLCanvasElement.prototype,
|
||||
"getContext",
|
||||
{
|
||||
value: window.HTMLCanvasElement.prototype.getContext,
|
||||
},
|
||||
);
|
||||
const addDocumentEventListener = vi.spyOn(
|
||||
ownerDocument,
|
||||
"addEventListener",
|
||||
);
|
||||
let unmount: (() => void) | null = null;
|
||||
try {
|
||||
let api: ExcalidrawImperativeAPI | null = null;
|
||||
const renderResult = renderReact(
|
||||
<Excalidraw
|
||||
ownerDocument={ownerDocument}
|
||||
handleKeyboardGlobally={true}
|
||||
onExcalidrawAPI={(nextApi) => {
|
||||
api = nextApi;
|
||||
}}
|
||||
/>,
|
||||
{ container: mountNode, baseElement: ownerDocument.body },
|
||||
);
|
||||
unmount = renderResult.unmount;
|
||||
const { container } = renderResult;
|
||||
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector("canvas.interactive")).not.toBeNull(),
|
||||
);
|
||||
expect(api).not.toBeNull();
|
||||
await waitFor(() => expect(api!.getAppState().isLoading).toBe(false));
|
||||
expect(
|
||||
addDocumentEventListener.mock.calls.some(
|
||||
([eventName]) => eventName === "pointermove",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
addDocumentEventListener.mock.calls.some(
|
||||
([eventName]) => eventName === "keydown",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
addDocumentEventListener.mock.calls.some(
|
||||
([eventName]) => eventName === "paste",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
addEventListener.mock.calls.some(
|
||||
([eventName]) => eventName === "message",
|
||||
),
|
||||
).toBe(true);
|
||||
const messageHandler = addEventListener.mock.calls.find(
|
||||
([eventName]) => eventName === "message",
|
||||
)?.[1] as EventListener | undefined;
|
||||
expect(typeof messageHandler).toBe("function");
|
||||
expect(() =>
|
||||
messageHandler!(
|
||||
new ownerWindow.MessageEvent("message", {
|
||||
data: JSON.stringify({ method: "paused", value: true }),
|
||||
origin: "https://player.vimeo.com",
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
const excalidrawContainer = container.querySelector(".excalidraw")!;
|
||||
fireEvent.pointerEnter(excalidrawContainer);
|
||||
expect(ownerDocument.documentElement.style.overscrollBehaviorX).toBe(
|
||||
"none",
|
||||
);
|
||||
fireEvent.pointerLeave(excalidrawContainer);
|
||||
expect(ownerDocument.documentElement.style.overscrollBehaviorX).toBe(
|
||||
"auto",
|
||||
);
|
||||
|
||||
expect(fonts.addEventListener).toHaveBeenCalledWith(
|
||||
"loadingdone",
|
||||
expect.any(Function),
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
ownerDocument.body.classList.add("excalidraw-animations-disabled");
|
||||
act(() => {
|
||||
api!.updateScene({
|
||||
appState: { openDialog: { name: "imageExport" } },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ownerDocument.querySelector(".excalidraw-modal-container"),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
expect(document.querySelector(".excalidraw-modal-container")).toBeNull();
|
||||
expect(ownerDocument.querySelector(".Modal")).toHaveClass(
|
||||
"animations-disabled",
|
||||
);
|
||||
} finally {
|
||||
unmount?.();
|
||||
iframe.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
215
packages/excalidraw/tests/cursorHint.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from "react";
|
||||
|
||||
import { ARROW_TYPE, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CURSOR_HINT_COOLDOWN } from "../components/CursorHint";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { Keyboard, UI } from "./helpers/ui";
|
||||
import {
|
||||
GlobalTestState,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const getCursorHint = () =>
|
||||
document.querySelector<HTMLElement>("[data-testid='cursor-hint']");
|
||||
|
||||
// dispatch on the canvas so the event propagates to the container's
|
||||
// keydown handler (`handleKeyboardGlobally` is disabled by default)
|
||||
const pressKey = (key: string) => {
|
||||
Keyboard.keyPress(key, GlobalTestState.interactiveCanvas);
|
||||
};
|
||||
|
||||
describe("cursor hint", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
// hints are only shown once the pointer position is known
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows hint when cycling arrow types via shortcut, and auto-hides it", async () => {
|
||||
UI.clickTool("arrow");
|
||||
expect(getCursorHint()).toBeNull();
|
||||
|
||||
// default arrow type is `round`, so cycling continues to `elbow`
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
expect(getCursorHint()!.querySelector("svg")).not.toBeNull();
|
||||
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.sharp);
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCursorHint()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows hint when switching to the arrow tool via shortcut", () => {
|
||||
UI.clickTool("rectangle");
|
||||
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows hint when picking the line tool via shortcut", () => {
|
||||
pressKey(KEYS.L);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("line");
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows hint when picking a tool via numeric shortcut", () => {
|
||||
pressKey("6");
|
||||
|
||||
expect(h.state.activeTool.type).toBe("line");
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides hint immediately on pointerdown", () => {
|
||||
UI.clickTool("arrow");
|
||||
|
||||
pressKey(KEYS.A);
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
|
||||
expect(getCursorHint()).toBeNull();
|
||||
|
||||
// complete the gesture so no drag state leaks into other tests
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show hint when picking a tool via toolbar", () => {
|
||||
UI.clickTool("arrow");
|
||||
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
expect(getCursorHint()).toBeNull();
|
||||
});
|
||||
|
||||
describe("cooldown", () => {
|
||||
// dismiss outside the canvas so we don't start drawing an element
|
||||
// (which would block subsequent tool-switch shortcuts)
|
||||
const dismissHint = () => {
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(getCursorHint()).toBeNull();
|
||||
};
|
||||
|
||||
const expireCooldown = () => {
|
||||
(h.app.cursorHints as any).lastShownAt =
|
||||
Date.now() - CURSOR_HINT_COOLDOWN;
|
||||
};
|
||||
|
||||
it("suppresses tool-switch hint during cooldown", () => {
|
||||
pressKey(KEYS.A);
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
dismissHint();
|
||||
|
||||
pressKey("v");
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
expect(getCursorHint()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows tool-switch hint again once cooldown expires", () => {
|
||||
pressKey(KEYS.A);
|
||||
dismissHint();
|
||||
pressKey("v");
|
||||
|
||||
expireCooldown();
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cycling arrow types bypasses cooldown", () => {
|
||||
pressKey(KEYS.A);
|
||||
dismissHint();
|
||||
|
||||
// arrow tool already active -> cycles
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("numeric shortcuts bypass cooldown", () => {
|
||||
pressKey(KEYS.A);
|
||||
dismissHint();
|
||||
pressKey("v");
|
||||
|
||||
pressKey("6");
|
||||
expect(h.state.activeTool.type).toBe("line");
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
dismissHint();
|
||||
|
||||
pressKey("v");
|
||||
pressKey("5");
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
expect(getCursorHint()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("letter shortcut to another tool is also suppressed during cooldown", () => {
|
||||
pressKey(KEYS.A);
|
||||
dismissHint();
|
||||
|
||||
pressKey(KEYS.L);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("line");
|
||||
expect(getCursorHint()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show hint when pointer position is unknown", () => {
|
||||
// simulate no pointermove having happened yet
|
||||
h.app.viewport.lastPosition.x = 0;
|
||||
h.app.viewport.lastPosition.y = 0;
|
||||
|
||||
UI.clickTool("arrow");
|
||||
pressKey(KEYS.A);
|
||||
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||
expect(getCursorHint()).toBeNull();
|
||||
});
|
||||
|
||||
it("tracks pointer position while visible", () => {
|
||||
mockBoundingClientRect({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
|
||||
try {
|
||||
UI.clickTool("arrow");
|
||||
pressKey(KEYS.A);
|
||||
|
||||
fireEvent.pointerMove(window, { clientX: 200, clientY: 100 });
|
||||
|
||||
// container-relative position, 16px offset from the pointer
|
||||
expect(getCursorHint()!.style.transform).toBe("translate(116px, 66px)");
|
||||
} finally {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`repairing bindings > should strip arrow binding if repair throws 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-arrow01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore arrow element correctly 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-arrow01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore correctly with rectangle, ellipse and diamond elements 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
],
|
||||
"height": 200,
|
||||
"id": "1",
|
||||
"index": "a0",
|
||||
"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": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore correctly with rectangle, ellipse and diamond elements 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
],
|
||||
"height": 200,
|
||||
"id": "2",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 10,
|
||||
"roughness": 2,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "red",
|
||||
"strokeStyle": "dashed",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore correctly with rectangle, ellipse and diamond elements 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "blue",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
],
|
||||
"height": 200,
|
||||
"id": "3",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 10,
|
||||
"roughness": 2,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "red",
|
||||
"strokeStyle": "dashed",
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore freedraw element correctly 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-freedraw01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
],
|
||||
],
|
||||
"pressures": [],
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"simulatePressure": true,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeOptions": {
|
||||
"streamline": "0.50000",
|
||||
"variability": "variable",
|
||||
},
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "freedraw",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore line and draw elements correctly 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-line01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore line and draw elements correctly 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-draw01",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
100,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 14,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": "1.25000",
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "text",
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"text": "text",
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -20,
|
||||
"y": "-8.75000",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"font": "10 unknown",
|
||||
"fontFamily": 5,
|
||||
"fontSize": 10,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"index": "a0",
|
||||
"isDeleted": true,
|
||||
"labelPosition": null,
|
||||
"lineHeight": "1.25000",
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "",
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"text": "",
|
||||
"textAlign": "left",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "top",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
386
packages/excalidraw/tests/data/reconcile.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { syncInvalidIndices } from "@excalidraw/element";
|
||||
|
||||
import { randomInteger, cloneJSON } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { reconcileElements } from "../../data/reconcile";
|
||||
|
||||
import type { RemoteExcalidrawElement } from "../../data/reconcile";
|
||||
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
||||
id: string;
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
index: string;
|
||||
};
|
||||
|
||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
||||
|
||||
const createElement = (opts: { uid: string } | ElementLike) => {
|
||||
let uid: string;
|
||||
let id: string;
|
||||
let version: number | null;
|
||||
let versionNonce: number | null = null;
|
||||
if ("uid" in opts) {
|
||||
const match = opts.uid.match(/^(\w+)(?::(\d+))?$/)!;
|
||||
id = match[1];
|
||||
version = match[2] ? parseInt(match[2]) : null;
|
||||
uid = version ? `${id}:${version}` : id;
|
||||
} else {
|
||||
({ id, version, versionNonce } = opts);
|
||||
uid = id;
|
||||
}
|
||||
return {
|
||||
uid,
|
||||
id,
|
||||
version,
|
||||
versionNonce: versionNonce || randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
const idsToElements = (ids: (Id | ElementLike)[], cache: Cache = {}) => {
|
||||
return syncInvalidIndices(
|
||||
ids.reduce((acc, _uid) => {
|
||||
const { uid, id, version, versionNonce } = createElement(
|
||||
typeof _uid === "string" ? { uid: _uid } : _uid,
|
||||
);
|
||||
const cached = cache[uid];
|
||||
const elem = {
|
||||
id,
|
||||
version: version ?? 0,
|
||||
versionNonce,
|
||||
...cached,
|
||||
} as ExcalidrawElement;
|
||||
// @ts-ignore
|
||||
cache[uid] = elem;
|
||||
acc.push(elem);
|
||||
return acc;
|
||||
}, [] as ExcalidrawElement[]),
|
||||
);
|
||||
};
|
||||
|
||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
||||
local: (Id | ElementLike)[],
|
||||
remote: (Id | ElementLike)[],
|
||||
target: U[],
|
||||
) => {
|
||||
const cache: Cache = {};
|
||||
const _local = idsToElements(local, cache);
|
||||
const _remote = idsToElements(remote, cache);
|
||||
|
||||
const reconciled = reconcileElements(
|
||||
cloneJSON(_local),
|
||||
cloneJSON(_remote) as RemoteExcalidrawElement[],
|
||||
{} as AppState,
|
||||
);
|
||||
|
||||
const reconciledIds = reconciled.map((x) => x.id);
|
||||
const reconciledIndices = reconciled.map((x) => x.index);
|
||||
|
||||
expect(target.length).toEqual(reconciled.length);
|
||||
expect(reconciledIndices.length).toEqual(
|
||||
new Set([...reconciledIndices]).size,
|
||||
); // expect no duplicated indices
|
||||
assert.deepEqual(
|
||||
reconciledIds,
|
||||
target.map((uid) => {
|
||||
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
||||
const element = (source === "L" ? _local : _remote).find(
|
||||
(e) => e.id === id,
|
||||
)!;
|
||||
|
||||
return element.id;
|
||||
}),
|
||||
"remote reconciliation",
|
||||
);
|
||||
|
||||
// convergent reconciliation on the remote client
|
||||
try {
|
||||
assert.deepEqual(
|
||||
reconcileElements(
|
||||
cloneJSON(_remote),
|
||||
cloneJSON(_local as RemoteExcalidrawElement[]),
|
||||
{} as AppState,
|
||||
).map((x) => x.id),
|
||||
reconciledIds,
|
||||
"convergent reconciliation",
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("local original", _remote);
|
||||
console.error("remote original", _local);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// bidirectional re-reconciliation on remote client
|
||||
try {
|
||||
assert.deepEqual(
|
||||
reconcileElements(
|
||||
cloneJSON(_remote),
|
||||
cloneJSON(reconciled as unknown as RemoteExcalidrawElement[]),
|
||||
{} as AppState,
|
||||
).map((x) => x.id),
|
||||
reconciledIds,
|
||||
"local re-reconciliation",
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("local original", _remote);
|
||||
console.error("remote reconciled", reconciled);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
describe("elements reconciliation", () => {
|
||||
it("reconcileElements()", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
//
|
||||
// in following tests, we pass:
|
||||
// (1) an array of local elements and their version (:1, :2...)
|
||||
// (2) an array of remote elements and their version (:1, :2...)
|
||||
// (3) expected reconciled elements
|
||||
//
|
||||
// in the reconciled array:
|
||||
// :L means local element was resolved
|
||||
// :R means remote element was resolved
|
||||
//
|
||||
// if versions are missing, it defaults to version 0
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
||||
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "C:1"], ["B:1"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
||||
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
||||
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
||||
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
||||
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
||||
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
||||
test(["A"], ["A:1"], ["A:R"]);
|
||||
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["C:R", "A:R", "B:L", "D:L"]);
|
||||
|
||||
// some of the following tests are kinda arbitrary and they're less
|
||||
// likely to happen in real-world cases
|
||||
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
||||
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:R", "B:R", "C:L", "G:R"]);
|
||||
test(
|
||||
["A:2", "B:2", "C"],
|
||||
["D", "B:1", "A:3"],
|
||||
["D:R", "B:L", "A:R", "C:L"],
|
||||
);
|
||||
test(
|
||||
["A:2", "B:2", "C"],
|
||||
["D", "B:2", "A:3", "C"],
|
||||
["D:R", "B:L", "A:R", "C:L"],
|
||||
);
|
||||
test(
|
||||
["A", "B", "C", "D", "E", "F"],
|
||||
["A", "B:2", "X", "E:2", "F", "Y"],
|
||||
["A:L", "B:R", "X:R", "C:L", "E:R", "D:L", "F:L", "Y:R"],
|
||||
);
|
||||
|
||||
// fractional elements (previously annotated)
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["A", "B", "X", "Y", "Z"],
|
||||
["A:R", "B:R", "C:L", "X:R", "Y:R", "Z:R"],
|
||||
);
|
||||
|
||||
test(["A"], ["X", "Y"], ["A:L", "X:R", "Y:R"]);
|
||||
test(["A"], ["X", "Y", "Z"], ["A:L", "X:R", "Y:R", "Z:R"]);
|
||||
test(["A", "B"], ["C", "D", "F"], ["A:L", "C:R", "B:L", "D:R", "F:R"]);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["C:1", "B", "D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["X", "A", "Y", "B", "Z"],
|
||||
["X:R", "A:R", "Y:R", "B:L", "C:L", "Z:R"],
|
||||
);
|
||||
test(
|
||||
["B", "A", "C"],
|
||||
["X", "A", "Y", "B", "Z"],
|
||||
["X:R", "A:R", "C:L", "Y:R", "B:R", "Z:R"],
|
||||
);
|
||||
test(["A", "B"], ["A", "X", "Y"], ["A:R", "B:L", "X:R", "Y:R"]);
|
||||
test(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["A", "X", "C", "Y", "D", "Z"],
|
||||
["A:R", "B:L", "X:R", "C:R", "Y:R", "D:R", "E:L", "Z:R"],
|
||||
);
|
||||
test(
|
||||
["X", "Y", "Z"],
|
||||
["A", "B", "C"],
|
||||
["A:R", "X:L", "B:R", "Y:L", "C:R", "Z:L"],
|
||||
);
|
||||
test(
|
||||
["X", "Y", "Z"],
|
||||
["A", "B", "C", "X", "D", "Y", "Z"],
|
||||
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
||||
);
|
||||
test(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["C", "X", "A", "Y", "D", "E:1"],
|
||||
["B:L", "C:L", "X:R", "A:R", "Y:R", "D:R", "E:R"],
|
||||
);
|
||||
test(
|
||||
["C:1", "B", "D:1"],
|
||||
["A", "B", "C:1", "D:1"],
|
||||
["A:R", "B:R", "C:R", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["C:1", "B", "D:1"],
|
||||
["A", "B", "C:2", "D:1"],
|
||||
["A:R", "B:L", "C:R", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["A", "C:1", "B", "D:1"],
|
||||
["A:L", "C:R", "B:L", "D:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["C", "X", "B", "Y", "A", "Z"],
|
||||
["C:R", "D:L", "X:R", "B:R", "Y:R", "A:R", "Z:R"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["A", "B:1", "C:1"],
|
||||
["A:R", "B:R", "C:R", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["A", "C:1", "B:1"],
|
||||
["A:R", "C:R", "B:R", "D:L"],
|
||||
);
|
||||
|
||||
test(
|
||||
["A", "B", "C", "D"],
|
||||
["A", "C:1", "B", "D:1"],
|
||||
["A:R", "C:R", "B:R", "D:R"],
|
||||
);
|
||||
|
||||
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
||||
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
||||
test(["A", "B"], ["A", "C", "B", "D"], ["A:R", "C:R", "B:R", "D:R"]);
|
||||
test(["A", "B"], ["B", "C", "D"], ["A:L", "B:R", "C:R", "D:R"]);
|
||||
test(["A", "B"], ["C", "D"], ["A:L", "C:R", "B:L", "D:R"]);
|
||||
test(["A", "B"], ["A", "B:1"], ["A:L", "B:R"]);
|
||||
test(["A:2", "B"], ["A", "B:1"], ["A:L", "B:R"]);
|
||||
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
||||
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
||||
test(["A:2", "B:2"], ["A", "C", "B:1"], ["A:L", "B:L", "C:R"]);
|
||||
|
||||
// concurrent convergency
|
||||
test(["A", "B", "C"], ["A", "B", "D"], ["A:R", "B:R", "C:L", "D:R"]);
|
||||
test(["A", "B", "E"], ["A", "B", "D"], ["A:R", "B:R", "D:R", "E:L"]);
|
||||
test(
|
||||
["A", "B", "C"],
|
||||
["A", "B", "D", "E"],
|
||||
["A:R", "B:R", "C:L", "D:R", "E:R"],
|
||||
);
|
||||
test(
|
||||
["A", "B", "E"],
|
||||
["A", "B", "D", "C"],
|
||||
["A:R", "B:R", "D:R", "E:L", "C:R"],
|
||||
);
|
||||
test(["A", "B"], ["B", "D"], ["A:L", "B:R", "D:R"]);
|
||||
test(["C", "A", "B"], ["C", "B", "D"], ["C:R", "A:L", "B:R", "D:R"]);
|
||||
});
|
||||
|
||||
it("test identical elements reconciliation", () => {
|
||||
const testIdentical = (
|
||||
local: ElementLike[],
|
||||
remote: ElementLike[],
|
||||
expected: Id[],
|
||||
) => {
|
||||
const ret = reconcileElements(
|
||||
local as unknown as OrderedExcalidrawElement[],
|
||||
remote as unknown as RemoteExcalidrawElement[],
|
||||
{} as AppState,
|
||||
);
|
||||
|
||||
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
||||
throw new Error("reconcileElements: duplicate elements found");
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
ret.map((x) => x.id),
|
||||
expected,
|
||||
);
|
||||
};
|
||||
|
||||
// identical id/version/versionNonce/index
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
testIdentical(
|
||||
[{ id: "A", version: 1, versionNonce: 1, index: "a0" }],
|
||||
[{ id: "A", version: 1, versionNonce: 1, index: "a0" }],
|
||||
["A"],
|
||||
);
|
||||
testIdentical(
|
||||
[
|
||||
{ id: "A", version: 1, versionNonce: 1, index: "a0" },
|
||||
{ id: "B", version: 1, versionNonce: 1, index: "a0" },
|
||||
],
|
||||
[
|
||||
{ id: "B", version: 1, versionNonce: 1, index: "a0" },
|
||||
{ id: "A", version: 1, versionNonce: 1, index: "a0" },
|
||||
],
|
||||
["A", "B"],
|
||||
);
|
||||
|
||||
// actually identical (arrays and element objects)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const elements1 = [
|
||||
{
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
index: "a0",
|
||||
},
|
||||
{
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
index: "a0",
|
||||
},
|
||||
];
|
||||
|
||||
testIdentical(elements1, elements1, ["A", "B"]);
|
||||
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
||||
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
||||
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
||||
|
||||
const el1 = {
|
||||
id: "A",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
index: "a0",
|
||||
};
|
||||
const el2 = {
|
||||
id: "B",
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
index: "a0",
|
||||
};
|
||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
||||
});
|
||||
});
|
||||
1597
packages/excalidraw/tests/data/restore.test.ts
Normal file
600
packages/excalidraw/tests/dragCreate.test.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
KEYS,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
reseed,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import * as InteractiveScene from "../renderer/interactiveScene";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
unmountComponent,
|
||||
} from "./test-utils";
|
||||
import {
|
||||
getTextEditor,
|
||||
TEXT_EDITOR_SELECTOR,
|
||||
updateTextEditor,
|
||||
} from "./queries/dom";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(
|
||||
InteractiveScene,
|
||||
"renderInteractiveScene",
|
||||
);
|
||||
const renderStaticScene = vi.spyOn(StaticScene, "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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`6`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`6`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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("sticky note creation", () => {
|
||||
const createDefaultStickyNote = (canvas: Element) => {
|
||||
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 300, clientY: 300 });
|
||||
};
|
||||
|
||||
it("places a clicked note on the grid", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
API.setAppState({ gridModeEnabled: true, gridSize: 20 });
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
|
||||
createDefaultStickyNote(container.querySelector("canvas.interactive")!);
|
||||
await getTextEditor();
|
||||
|
||||
expect(
|
||||
h.elements.find((element) => element.type === "stickynote"),
|
||||
).toMatchObject({ x: 180, y: 180 });
|
||||
});
|
||||
|
||||
it("switches back to selection and starts editing when tool is unlocked", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
createDefaultStickyNote(canvas);
|
||||
|
||||
await getTextEditor();
|
||||
|
||||
const stickyNote = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
);
|
||||
const boundText = h.elements.find((element) => element.type === "text");
|
||||
|
||||
expect(stickyNote).toBeDefined();
|
||||
expect(boundText).toEqual(
|
||||
expect.objectContaining({
|
||||
containerId: stickyNote?.id,
|
||||
}),
|
||||
);
|
||||
expect(h.state.editingTextElement?.id).toBe(boundText?.id);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps sticky note tool active without editing when tool is locked", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
fireEvent.click(getByToolName("lock"));
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
createDefaultStickyNote(canvas);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
h.elements.filter((element) => element.type === "stickynote"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(h.elements.filter((element) => element.type === "text")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(h.state.editingTextElement).toBeNull();
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBeNull();
|
||||
expect(h.state.activeTool.type).toBe("stickynote");
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("treats a wobble under the drag threshold as a click", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 306, clientY: 305 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 306, clientY: 305 });
|
||||
|
||||
await getTextEditor();
|
||||
|
||||
const stickyNote = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
expect(stickyNote.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(stickyNote.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(stickyNote.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
|
||||
it("previews a drag at its true size and snaps to the minimum on release", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 320, clientY: 320 });
|
||||
|
||||
// mid-gesture: no clamp, the note is exactly what was dragged
|
||||
const draft = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
expect(draft.width).toBe(20);
|
||||
expect(draft.height).toBe(20);
|
||||
|
||||
fireEvent.pointerUp(canvas, { clientX: 320, clientY: 320 });
|
||||
await getTextEditor();
|
||||
|
||||
// on release it grows to the minimum, away from the origin corner: one
|
||||
// 25px line at the default 20px ceiling plus padding and the date footer
|
||||
// high — and as wide, since a plain drag is proportional and the square
|
||||
// survives the snap
|
||||
const stickyNote = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
expect(stickyNote.x).toBe(300);
|
||||
expect(stickyNote.y).toBe(300);
|
||||
expect(stickyNote.width).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
expect(stickyNote.height).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
expect(stickyNote.baseHeight).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
});
|
||||
|
||||
it("sizes the minimum note to fit one line at a large font ceiling", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
// a 60px line (48 × 1.25) plus padding and the date footer high — and as
|
||||
// wide, since a plain drag is proportional
|
||||
API.setAppState({ currentItemFontSize: 48 });
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 320, clientY: 320 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 320, clientY: 320 });
|
||||
const editor = await getTextEditor();
|
||||
|
||||
const stickyNote = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
expect(stickyNote.width).toBe(60 + STICKY_NOTE_BODY_INSET_Y);
|
||||
expect(stickyNote.height).toBe(60 + STICKY_NOTE_BODY_INSET_Y);
|
||||
|
||||
// one line fits without growing the note
|
||||
updateTextEditor(editor, "A");
|
||||
expect(
|
||||
(
|
||||
h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement
|
||||
).height,
|
||||
).toBe(60 + STICKY_NOTE_BODY_INSET_Y);
|
||||
});
|
||||
|
||||
it("drags out a square note by default and keeps it square through the snap", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
|
||||
// an uneven drag: proportional by default, so the draft is square
|
||||
fireEvent.pointerMove(canvas, { clientX: 340, clientY: 320 });
|
||||
const draft = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
expect([draft.width, draft.height]).toEqual([40, 40]);
|
||||
|
||||
fireEvent.pointerUp(canvas, { clientX: 340, clientY: 320 });
|
||||
await getTextEditor();
|
||||
|
||||
// the two minimums differ; the square survives the snap
|
||||
const note = h.elements.find((element) => element.type === "stickynote")!;
|
||||
expect(note.width).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
expect(note.height).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
});
|
||||
|
||||
it("drags out a free-form note with Shift", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, {
|
||||
clientX: 300,
|
||||
clientY: 300,
|
||||
shiftKey: true,
|
||||
});
|
||||
fireEvent.pointerMove(canvas, {
|
||||
clientX: 340,
|
||||
clientY: 320,
|
||||
shiftKey: true,
|
||||
});
|
||||
const draft = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
expect([draft.width, draft.height]).toEqual([40, 20]);
|
||||
|
||||
fireEvent.pointerUp(canvas, {
|
||||
clientX: 340,
|
||||
clientY: 320,
|
||||
shiftKey: true,
|
||||
});
|
||||
await getTextEditor();
|
||||
|
||||
// each side snaps to its own minimum
|
||||
const note = h.elements.find((element) => element.type === "stickynote")!;
|
||||
expect(note.width).toBe(STICKY_NOTE_MIN_SIZE);
|
||||
expect(note.height).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
});
|
||||
|
||||
it("keeps the origin corner fixed when a drag toward the top-left snaps to the minimum", async () => {
|
||||
const { getByToolName, container } = await render(<Excalidraw />);
|
||||
fireEvent.click(getByToolName("stickynote"));
|
||||
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 280, clientY: 280 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 280, clientY: 280 });
|
||||
await getTextEditor();
|
||||
|
||||
const stickyNote = h.elements.find(
|
||||
(element) => element.type === "stickynote",
|
||||
) as ExcalidrawStickyNoteElement;
|
||||
// the bottom-right corner is where the pointer went down
|
||||
expect(stickyNote.x + stickyNote.width).toBe(300);
|
||||
expect(stickyNote.y + stickyNote.height).toBe(300);
|
||||
expect(stickyNote.width).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
|
||||
});
|
||||
});
|
||||
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`7`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "arrow",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`7`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "line",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
858
packages/excalidraw/tests/drawShape.test.tsx
Normal file
@@ -0,0 +1,858 @@
|
||||
import * as common from "@excalidraw/common";
|
||||
|
||||
import { getTargetElements, isArrowElement } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { actionFinalize } from "../actions";
|
||||
import { getShapeActionPredicates } from "../components/shapeActionPredicates";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer } from "./helpers/ui";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
import { GlobalTestState, act, fireEvent, render, waitFor } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const sketch = (points: [number, number][]) => {
|
||||
const [startX, startY] = points[0];
|
||||
|
||||
mouse.downAt(startX, startY);
|
||||
act(() => {
|
||||
for (const [x, y] of points.slice(1)) {
|
||||
h.app.drawShape.trail.addPointToPath(x, y);
|
||||
}
|
||||
});
|
||||
const [endX, endY] = points[points.length - 1];
|
||||
mouse.upAt(endX, endY);
|
||||
};
|
||||
|
||||
/** a closed, roughly rectangular path */
|
||||
const rectanglePath = (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): [number, number][] => {
|
||||
const points: [number, number][] = [];
|
||||
const step = 10;
|
||||
for (let i = x; i <= x + width; i += step) {
|
||||
points.push([i, y]);
|
||||
}
|
||||
for (let i = y; i <= y + height; i += step) {
|
||||
points.push([x + width, i]);
|
||||
}
|
||||
for (let i = x + width; i >= x; i -= step) {
|
||||
points.push([i, y + height]);
|
||||
}
|
||||
for (let i = y + height; i >= y; i -= step) {
|
||||
points.push([x, i]);
|
||||
}
|
||||
points.push([x, y]);
|
||||
return points;
|
||||
};
|
||||
|
||||
const seg = (
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
steps: number,
|
||||
): [number, number][] => {
|
||||
const points: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
points.push([
|
||||
from[0] + ((to[0] - from[0]) * i) / steps,
|
||||
from[1] + ((to[1] - from[1]) * i) / steps,
|
||||
]);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
/** a V-headed arrow: the shaft, then both head arms drawn back from the tip */
|
||||
const arrowPath = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): [number, number][] => {
|
||||
const tip: [number, number] = [x2, y2];
|
||||
const length = Math.hypot(x2 - x1, y2 - y1);
|
||||
const ux = (x2 - x1) / length;
|
||||
const uy = (y2 - y1) / length;
|
||||
const headLength = length * 0.3;
|
||||
const headAngle = Math.PI / 6;
|
||||
const arm = (sign: number): [number, number] => [
|
||||
x2 -
|
||||
headLength * (ux * Math.cos(headAngle) - sign * uy * Math.sin(headAngle)),
|
||||
y2 -
|
||||
headLength * (uy * Math.cos(headAngle) + sign * ux * Math.sin(headAngle)),
|
||||
];
|
||||
return [
|
||||
...seg([x1, y1], tip, 20),
|
||||
...seg(tip, arm(1), 8),
|
||||
...seg(arm(1), tip, 8),
|
||||
...seg(tip, arm(-1), 8),
|
||||
];
|
||||
};
|
||||
|
||||
/** a closed, roughly circular path */
|
||||
const circlePath = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
): [number, number][] => {
|
||||
const points: [number, number][] = [];
|
||||
for (let a = 0; a <= Math.PI * 2 + 0.1; a += 0.15) {
|
||||
points.push([cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
describe("autoshape tool", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "autoshape" });
|
||||
});
|
||||
});
|
||||
|
||||
it("converts a sketched rectangle into a rectangle element", () => {
|
||||
sketch(rectanglePath(100, 100, 200, 120));
|
||||
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(h.elements[0].type).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("converts a sketched circle into an ellipse element", () => {
|
||||
sketch(circlePath(300, 300, 80));
|
||||
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(h.elements[0].type).toBe("ellipse");
|
||||
});
|
||||
|
||||
it("keeps the autoshape tool active after finalizing a shape", () => {
|
||||
sketch(rectanglePath(100, 100, 200, 120));
|
||||
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("does not select the recognized element, so styles stay tool defaults", () => {
|
||||
sketch(rectanglePath(100, 100, 200, 120));
|
||||
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("does not select a recognized arrow, yet still binds it", () => {
|
||||
const start = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const end = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 400,
|
||||
y: 300,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([start, end]);
|
||||
|
||||
sketch(arrowPath(160, 160, 390, 290));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
|
||||
expect(arrow.startBinding?.elementId).toBe(start.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(end.id);
|
||||
});
|
||||
|
||||
it("does not select a recognized arrow that binds nothing", () => {
|
||||
sketch(arrowPath(100, 100, 400, 300));
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["arrow"]);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("upgrades a line drawn between two bindable shapes to a bound arrow", () => {
|
||||
const start = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const end = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([start, end]);
|
||||
|
||||
sketch(seg([205, 205], [395, 395], 30));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(arrow.startBinding?.elementId).toBe(start.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(end.id);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
});
|
||||
|
||||
it("orbit-binds arrows sketched from inside one shape into another, endpoints on the outlines", () => {
|
||||
const start = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const end = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([start, end]);
|
||||
|
||||
// starts and ends deep inside the shapes
|
||||
sketch(seg([150, 150], [450, 450], 30));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(arrow.startBinding?.mode).toBe("orbit");
|
||||
expect(arrow.endBinding?.mode).toBe("orbit");
|
||||
|
||||
// the endpoints were pulled out of the shape interiors onto the outlines
|
||||
const [endDx, endDy] = arrow.points[arrow.points.length - 1];
|
||||
expect(arrow.x).toBeGreaterThan(200);
|
||||
expect(arrow.y).toBeGreaterThan(200);
|
||||
expect(arrow.x + endDx).toBeLessThan(400);
|
||||
expect(arrow.y + endDy).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it("keeps inside-bindings for an arrow sketched within a single shape", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 500,
|
||||
height: 500,
|
||||
});
|
||||
API.setElements([container]);
|
||||
|
||||
sketch(arrowPath(150, 300, 400, 300));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(arrow.startBinding?.mode).toBe("inside");
|
||||
expect(arrow.endBinding?.mode).toBe("inside");
|
||||
// the endpoints stay where they were drawn
|
||||
expect(arrow.x).toBeCloseTo(150, 0);
|
||||
expect(arrow.y).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it("keeps a line sketched within a single shape a line", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 500,
|
||||
height: 500,
|
||||
});
|
||||
API.setElements([container]);
|
||||
|
||||
sketch(seg([150, 150], [450, 450], 30));
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual([
|
||||
"rectangle",
|
||||
"line",
|
||||
]);
|
||||
});
|
||||
|
||||
it("upgrades a line from inside a shape to blank canvas into an arrow orbit-bound at the shape", () => {
|
||||
const only = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([only]);
|
||||
|
||||
sketch(seg([150, 150], [395, 395], 30));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(arrow.startBinding?.elementId).toBe(only.id);
|
||||
expect(arrow.startBinding?.mode).toBe("orbit");
|
||||
expect(arrow.endBinding).toBeNull();
|
||||
// the start was pulled out of the shape interior onto the outline
|
||||
expect(arrow.x).toBeGreaterThan(200);
|
||||
expect(arrow.y).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it("upgrades a line from blank canvas into a shape into an arrow orbit-bound at the shape", () => {
|
||||
const only = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([only]);
|
||||
|
||||
sketch(seg([100, 100], [450, 450], 30));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(arrow.startBinding).toBeNull();
|
||||
expect(arrow.endBinding?.elementId).toBe(only.id);
|
||||
expect(arrow.endBinding?.mode).toBe("orbit");
|
||||
// the end was pulled back onto the outline
|
||||
const [endDx, endDy] = arrow.points[arrow.points.length - 1];
|
||||
expect(arrow.x + endDx).toBeLessThan(400);
|
||||
expect(arrow.y + endDy).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it("keeps a line touching no shape a line", () => {
|
||||
sketch(seg([100, 100], [400, 400], 30));
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["line"]);
|
||||
});
|
||||
|
||||
it("allows sketching consecutive shapes without reselecting the tool", () => {
|
||||
sketch(rectanglePath(100, 100, 200, 120));
|
||||
sketch(circlePath(600, 300, 80));
|
||||
|
||||
expect(h.elements).toHaveLength(2);
|
||||
expect(h.elements[0].type).toBe("rectangle");
|
||||
expect(h.elements[1].type).toBe("ellipse");
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("ignores a sketch too small to be a shape", () => {
|
||||
sketch([
|
||||
[100, 100],
|
||||
[101, 101],
|
||||
[102, 100],
|
||||
[101, 99],
|
||||
]);
|
||||
|
||||
expect(h.elements).toHaveLength(0);
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoshape styles panel & selection (preview path)", () => {
|
||||
/**
|
||||
* Unlike `sketch`, also drives the pointermove handler, so
|
||||
* `state.newElement` holds the live recognition preview mid-gesture the way
|
||||
* it does in the real app.
|
||||
*/
|
||||
const sketchWithPreview = (points: [number, number][]) => {
|
||||
const [startX, startY] = points[0];
|
||||
|
||||
mouse.downAt(startX, startY);
|
||||
act(() => {
|
||||
for (const [x, y] of points.slice(1)) {
|
||||
h.app.drawShape.trail.addPointToPath(x, y);
|
||||
h.app.drawShape.handlePointerMove({ x, y });
|
||||
}
|
||||
});
|
||||
const [endX, endY] = points[points.length - 1];
|
||||
mouse.upAt(endX, endY);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "autoshape" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not select a recognized arrow drawn with a live preview", () => {
|
||||
sketchWithPreview(arrowPath(100, 100, 400, 300));
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["arrow"]);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("does not select a recognized rectangle drawn with a live preview", () => {
|
||||
sketchWithPreview(rectanglePath(100, 100, 200, 120));
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["rectangle"]);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("shows no preview for lines (noise), yet still commits them on release", () => {
|
||||
const linePoints = seg([100, 100], [400, 400], 30);
|
||||
mouse.downAt(100, 100);
|
||||
act(() => {
|
||||
for (const [x, y] of linePoints.slice(1)) {
|
||||
h.app.drawShape.trail.addPointToPath(x, y);
|
||||
h.app.drawShape.handlePointerMove({ x, y });
|
||||
}
|
||||
});
|
||||
|
||||
// the stroke reads as a line mid-gesture — no preview element
|
||||
expect(h.state.newElement).toBeNull();
|
||||
|
||||
mouse.upAt(400, 400);
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["line"]);
|
||||
});
|
||||
|
||||
it("upgrades a line between two bindable shapes identically with a live preview", () => {
|
||||
const start = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const end = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([start, end]);
|
||||
|
||||
sketchWithPreview(seg([205, 205], [395, 395], 30));
|
||||
|
||||
const arrow = h.elements.find((element) => element.type === "arrow");
|
||||
assert(isArrowElement(arrow));
|
||||
|
||||
expect(arrow.startBinding?.elementId).toBe(start.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(end.id);
|
||||
expect(h.state.newElement).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the styles panel on tool defaults while sketching", () => {
|
||||
mouse.downAt(100, 100);
|
||||
act(() => {
|
||||
for (const [x, y] of rectanglePath(100, 100, 200, 120).slice(1)) {
|
||||
h.app.drawShape.trail.addPointToPath(x, y);
|
||||
h.app.drawShape.handlePointerMove({ x, y });
|
||||
}
|
||||
});
|
||||
|
||||
// the preview exists, but the styles panel must not target it — it keeps
|
||||
// showing the tool defaults
|
||||
expect(h.state.newElement?.type).toBe("rectangle");
|
||||
const targetElements = getTargetElements(
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.state,
|
||||
);
|
||||
expect(targetElements).toEqual([]);
|
||||
|
||||
// the drawShape panel is limited to stroke color, background, fill style
|
||||
// and stroke style
|
||||
const predicates = getShapeActionPredicates(
|
||||
h.state,
|
||||
targetElements,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app,
|
||||
);
|
||||
expect(predicates.strokeColor).toBe(true);
|
||||
expect(predicates.backgroundColor).toBe(true);
|
||||
expect(predicates.strokeStyle).toBe(true);
|
||||
// fill follows the standard rule: hidden while the background is
|
||||
// transparent (the default), shown otherwise
|
||||
expect(predicates.fill).toBe(false);
|
||||
expect(predicates.strokeWidth).toBe(true);
|
||||
expect(predicates.sloppiness).toBe(true);
|
||||
expect(predicates.roundness).toBe(false);
|
||||
expect(predicates.opacity).toBe(false);
|
||||
expect(predicates.layers).toBe(false);
|
||||
|
||||
act(() => {
|
||||
h.setState({ currentItemBackgroundColor: "#ffc9c9" });
|
||||
});
|
||||
expect(
|
||||
getShapeActionPredicates(
|
||||
h.state,
|
||||
targetElements,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app,
|
||||
).fill,
|
||||
).toBe(true);
|
||||
|
||||
mouse.upAt(300, 220);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoshape finalize funnel", () => {
|
||||
/** starts a gesture and leaves the pointer down */
|
||||
const startSketch = (points: [number, number][]) => {
|
||||
const [startX, startY] = points[0];
|
||||
|
||||
mouse.downAt(startX, startY);
|
||||
act(() => {
|
||||
for (const [x, y] of points.slice(1)) {
|
||||
h.app.drawShape.trail.addPointToPath(x, y);
|
||||
h.app.drawShape.handlePointerMove({ x, y });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "autoshape" });
|
||||
});
|
||||
});
|
||||
|
||||
it("commits the pending sketch when actionFinalize fires mid-gesture", () => {
|
||||
startSketch(rectanglePath(100, 100, 200, 120));
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionFinalize);
|
||||
});
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["rectangle"]);
|
||||
expect(h.state.newElement).toBeNull();
|
||||
expect(h.app.drawShape.hasPendingGesture()).toBe(false);
|
||||
|
||||
// releasing the pointer must not commit a second element
|
||||
mouse.upAt(300, 220);
|
||||
expect(h.elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("commits the sketch when the tool is switched away mid-gesture (paste resets the tool)", () => {
|
||||
startSketch(rectanglePath(100, 100, 200, 120));
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "selection" });
|
||||
});
|
||||
|
||||
expect(h.elements.map((element) => element.type)).toEqual(["rectangle"]);
|
||||
expect(h.app.drawShape.hasPendingGesture()).toBe(false);
|
||||
expect(h.state.newElement).toBeNull();
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
|
||||
mouse.upAt(300, 220);
|
||||
expect(h.elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks undo while a sketch is in progress", () => {
|
||||
sketch(rectanglePath(100, 100, 200, 120));
|
||||
expect(h.elements).toHaveLength(1);
|
||||
|
||||
// start a second gesture; a tiny stroke isn't recognized yet, so
|
||||
// `newElement` alone would not block history
|
||||
startSketch([
|
||||
[600, 100],
|
||||
[610, 105],
|
||||
[620, 100],
|
||||
]);
|
||||
expect(h.state.newElement).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(common.KEYS.Z);
|
||||
});
|
||||
|
||||
// undo was blocked — the previously committed shape is still there
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(h.elements[0].isDeleted).toBe(false);
|
||||
|
||||
mouse.upAt(620, 100);
|
||||
// the tiny sketch is discarded, the committed shape survives
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(h.app.drawShape.hasPendingGesture()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoshape double-click to type", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "autoshape" });
|
||||
});
|
||||
});
|
||||
|
||||
it("creates free text on blank canvas and stays on the tool after submit", async () => {
|
||||
mouse.doubleClickAt(200, 200);
|
||||
|
||||
expect(h.state.editingTextElement).not.toBeNull();
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, "hello");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
expect(h.elements).toHaveLength(1);
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBeNull();
|
||||
expect(h.state.editingTextElement).toBeNull();
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
expect(API.getSelectedElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("binds text to a transparent container even off-center (unlike selection mode)", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
API.setElements([rectangle]);
|
||||
|
||||
// interior hit far from both the stroke and the center
|
||||
mouse.doubleClickAt(140, 120);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
expect(h.state.editingTextElement?.containerId).toBe(rectangle.id);
|
||||
|
||||
updateTextEditor(editor, "label");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const text = h.elements.find(
|
||||
(element) => element.type === "text",
|
||||
) as ExcalidrawTextElement;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(h.elements[0].boundElements).toEqual([
|
||||
{ type: "text", id: text.id },
|
||||
]);
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("edits the existing bound text instead of creating another", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
// positioned at the container center, as bound text always is
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 190,
|
||||
y: 145,
|
||||
width: 20,
|
||||
height: 10,
|
||||
containerId: rectangle.id,
|
||||
});
|
||||
API.updateElement(rectangle, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
API.setElements([rectangle, boundText]);
|
||||
|
||||
mouse.doubleClickAt(150, 130);
|
||||
|
||||
await getTextEditor();
|
||||
expect(h.state.editingTextElement?.id).toBe(boundText.id);
|
||||
expect(h.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("creates free text at the pointer on Alt+double-click inside a container", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.doubleClickAt(140, 120);
|
||||
});
|
||||
|
||||
await getTextEditor();
|
||||
expect(h.state.editingTextElement?.containerId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes the text and unbinds on empty submit", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([rectangle]);
|
||||
|
||||
mouse.doubleClickAt(150, 130);
|
||||
const editor = await getTextEditor();
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(1);
|
||||
expect(h.elements[0].boundElements ?? []).toHaveLength(0);
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("undo after submit removes only the text", async () => {
|
||||
sketch(rectanglePath(100, 100, 200, 120));
|
||||
expect(h.elements).toHaveLength(1);
|
||||
|
||||
mouse.doubleClickAt(400, 400);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, "note");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(2);
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(
|
||||
h.elements.filter((element) => !element.isDeleted).map((el) => el.type),
|
||||
).toEqual(["rectangle"]);
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("allows to start a sketch on pointerdown while the editor is open", async () => {
|
||||
mouse.doubleClickAt(200, 200);
|
||||
await getTextEditor();
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, "note");
|
||||
|
||||
mouse.downAt(400, 400);
|
||||
expect(h.app.drawShape.hasPendingGesture()).toBe(true);
|
||||
mouse.upAt(400, 500);
|
||||
|
||||
// no shape got inserted by the click-away
|
||||
expect(
|
||||
h.elements.filter(
|
||||
(element) => element.type === "text" && !element.isDeleted,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("double-tap (touch) creates text through the same path", async () => {
|
||||
act(() => {
|
||||
// @ts-ignore private method — the touch double-tap detector funnels here
|
||||
h.app.handleCanvasDoubleClick({
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
type: "touch",
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(h.state.editingTextElement).not.toBeNull();
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoshape tool activation", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("activates via its keyboard shortcut", () => {
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
Keyboard.keyPress(common.KEYS.X);
|
||||
});
|
||||
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
|
||||
it("is not activated by an unmodified X (freedraw's shortcut)", () => {
|
||||
Keyboard.keyPress(common.KEYS.X);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("activates from the extra tools dropdown", () => {
|
||||
fireEvent.click(
|
||||
GlobalTestState.renderResult.container.querySelector(
|
||||
".App-toolbar__extra-tools-trigger",
|
||||
)!,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="toolbar-autoshape"]',
|
||||
)!,
|
||||
);
|
||||
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoshape compact toolbar placement", () => {
|
||||
it.each(["tablet", "phone"] as const)(
|
||||
"groups autoshape under freedraw on %s",
|
||||
async (formFactor) => {
|
||||
const { container } = await render(
|
||||
<Excalidraw UIOptions={{ getFormFactor: () => formFactor }} />,
|
||||
);
|
||||
fireEvent.resize(window);
|
||||
await waitFor(() =>
|
||||
expect(h.app.editorInterface.formFactor).toBe(formFactor),
|
||||
);
|
||||
|
||||
const freedrawTrigger = container.querySelector(
|
||||
'[data-testid="toolbar-freedraw"]',
|
||||
)!;
|
||||
fireEvent.click(freedrawTrigger);
|
||||
|
||||
const drawShapeOption = await waitFor(() => {
|
||||
const option = document.querySelector<HTMLButtonElement>(
|
||||
'.tool-popover-content [data-testid="toolbar-autoshape"]',
|
||||
);
|
||||
expect(option).not.toBeNull();
|
||||
return option!;
|
||||
});
|
||||
fireEvent.click(drawShapeOption);
|
||||
expect(h.state.activeTool.type).toBe("autoshape");
|
||||
expect(freedrawTrigger).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const extraToolsTrigger = container.querySelector(
|
||||
".App-toolbar__extra-tools-trigger",
|
||||
)!;
|
||||
expect(extraToolsTrigger).not.toHaveClass(
|
||||
"App-toolbar__extra-tools-trigger--selected",
|
||||
);
|
||||
|
||||
fireEvent.click(extraToolsTrigger);
|
||||
const extraTools = await waitFor(() => {
|
||||
const menu = document.querySelector(
|
||||
".App-toolbar__extra-tools-dropdown",
|
||||
);
|
||||
expect(menu).not.toBeNull();
|
||||
return menu!;
|
||||
});
|
||||
expect(
|
||||
extraTools.querySelector('[data-testid="toolbar-autoshape"]'),
|
||||
).not.toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
390
packages/excalidraw/tests/elementLocking.test.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { actionSelectAll } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { render, unmountComponent } from "../tests/test-utils";
|
||||
|
||||
import { getTextEditor } from "./queries/dom";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
const h = window.h;
|
||||
|
||||
describe("element locking", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
API.setElements([]);
|
||||
});
|
||||
|
||||
it("click-selecting a locked element is disabled", () => {
|
||||
const lockedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
API.setElements([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,
|
||||
});
|
||||
|
||||
API.setElements([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,
|
||||
});
|
||||
|
||||
API.setElements([lockedRectangle]);
|
||||
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.upAt(100, 100);
|
||||
expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
|
||||
});
|
||||
|
||||
it("dragging 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,
|
||||
});
|
||||
|
||||
API.setElements([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: 0, y: 0 }));
|
||||
|
||||
// once selected, the locked element above should be ignored
|
||||
API.setSelectedElements([rectangle]);
|
||||
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", () => {
|
||||
API.setElements([
|
||||
API.createElement({ type: "rectangle" }),
|
||||
API.createElement({ type: "rectangle", locked: true }),
|
||||
]);
|
||||
API.executeAction(actionSelectAll);
|
||||
expect(API.getSelectedElements().length).toBe(1);
|
||||
});
|
||||
|
||||
it("clicking on a locked element should not 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,
|
||||
});
|
||||
|
||||
API.setElements([rectangle, lockedRectangle]);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
mouse.clickAt(50, 50);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
expect(h.state.activeLockedId).toBe(lockedRectangle.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,
|
||||
});
|
||||
|
||||
API.setElements([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,
|
||||
});
|
||||
|
||||
API.setElements([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,
|
||||
});
|
||||
|
||||
API.setElements([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,
|
||||
});
|
||||
API.setElements([container, text]);
|
||||
API.setSelectedElements([container]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.state.editingTextElement?.id).not.toBe(text.id);
|
||||
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
|
||||
});
|
||||
|
||||
it("should ignore locked text under cursor when clicked with text tool", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: true,
|
||||
});
|
||||
API.setElements([text]);
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
const editor = await getTextEditor();
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingTextElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
|
||||
});
|
||||
|
||||
it("should ignore text under cursor when double-clicked with selection tool", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: true,
|
||||
});
|
||||
API.setElements([text]);
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
const editor = await getTextEditor();
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingTextElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.state.editingTextElement?.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,
|
||||
});
|
||||
h.app.scene.mutateElement(container, {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
});
|
||||
|
||||
API.setElements([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", async () => {
|
||||
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,
|
||||
});
|
||||
h.app.scene.mutateElement(container, {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
});
|
||||
API.setElements([container, text]);
|
||||
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(container.width / 2, container.height / 2);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingTextElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.state.editingTextElement?.id).toBe(h.elements[2].id);
|
||||
});
|
||||
|
||||
it("bound text shouldn't be editable via text tool", async () => {
|
||||
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,
|
||||
});
|
||||
h.app.scene.mutateElement(container, {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
});
|
||||
API.setElements([container, text]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(container.width / 2, container.height / 2);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingTextElement?.id).not.toBe(text.id);
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.state.editingTextElement?.id).toBe(h.elements[2].id);
|
||||
});
|
||||
});
|
||||
500
packages/excalidraw/tests/excalidraw.test.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
import { queryByText, queryByTestId } from "@testing-library/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { THEME } from "@excalidraw/common";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { Excalidraw, Footer, MainMenu } from "../index";
|
||||
import { actionExportWithDarkMode } from "../actions/actionExport";
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
toggleMenu,
|
||||
render,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
|
||||
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.querySelector(".excalidraw")).toHaveClass(
|
||||
"excalidraw--zen-mode",
|
||||
);
|
||||
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.gridModeEnabled).toBe(false);
|
||||
|
||||
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, "Toggle grid")!);
|
||||
expect(h.state.gridModeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
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.gridModeEnabled).toBe(false);
|
||||
|
||||
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.gridModeEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("should sync export theme with the UI theme when there is no session override", async () => {
|
||||
await render(<Excalidraw theme={THEME.DARK} />);
|
||||
|
||||
expect(h.state.exportWithDarkMode).toBe(true);
|
||||
|
||||
act(() => {
|
||||
h.setState({ exportWithDarkMode: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.exportWithDarkMode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep the export theme override for the current session", async () => {
|
||||
await render(<Excalidraw theme={THEME.LIGHT} />);
|
||||
|
||||
act(() => {
|
||||
(h.app as any).actionManager.executeAction(
|
||||
actionExportWithDarkMode,
|
||||
"ui",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
expect(h.app.sessionExportThemeOverride).toBe(THEME.DARK);
|
||||
expect(h.state.exportWithDarkMode).toBe(true);
|
||||
|
||||
act(() => {
|
||||
h.setState({ theme: THEME.DARK });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.setState({ theme: THEME.LIGHT });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.exportWithDarkMode).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test name prop", () => {
|
||||
it("should allow editing name", 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 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");
|
||||
});
|
||||
});
|
||||
|
||||
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 allowSystemTheme={false} />
|
||||
</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"));
|
||||
});
|
||||
|
||||
it("should show theme toggle when the theme prop and onThemeChange are defined", async () => {
|
||||
const onThemeChange = vi.fn();
|
||||
const { container } = await render(
|
||||
<Excalidraw theme={THEME.DARK} onThemeChange={onThemeChange} />,
|
||||
);
|
||||
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should call onThemeChange instead of mutating theme when defined", async () => {
|
||||
const onThemeChange = vi.fn();
|
||||
const { container } = await render(
|
||||
<Excalidraw theme={THEME.LIGHT} onThemeChange={onThemeChange} />,
|
||||
);
|
||||
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
|
||||
|
||||
expect(onThemeChange).toHaveBeenCalledWith(THEME.DARK);
|
||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply a custom class name to the editor root", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw className="custom-excalidraw" />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".excalidraw")).toHaveClass(
|
||||
"custom-excalidraw",
|
||||
);
|
||||
});
|
||||
});
|
||||
221
packages/excalidraw/tests/export.test.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React from "react";
|
||||
|
||||
import { SVG_NS } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { getDataURL } from "../data/blob";
|
||||
import { encodePngMetadata } from "../data/image";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
import { Excalidraw } from "../index";
|
||||
import {
|
||||
decodeSvgBase64Payload,
|
||||
encodeSvgBase64Payload,
|
||||
exportToSvg,
|
||||
} from "../scene/export";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { render, waitFor } from "./test-utils";
|
||||
|
||||
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"),
|
||||
});
|
||||
await API.drop([{ kind: "file", file: pngBlobEmbedded }]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("test encoding/decoding scene for SVG export", async () => {
|
||||
const metadataElement = document.createElementNS(SVG_NS, "metadata");
|
||||
|
||||
encodeSvgBase64Payload({
|
||||
metadataElement,
|
||||
payload: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||
});
|
||||
|
||||
const decoded = JSON.parse(
|
||||
decodeSvgBase64Payload({ svg: metadataElement.innerHTML }),
|
||||
);
|
||||
expect(decoded.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("export svg-embedded scene", async () => {
|
||||
const svg = await exportToSvg(
|
||||
testElements,
|
||||
{ ...getDefaultAppState(), exportEmbedScene: true },
|
||||
{},
|
||||
);
|
||||
const svgText = svg.outerHTML;
|
||||
|
||||
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
|
||||
});
|
||||
|
||||
it("import embedded png (legacy v1)", async () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: 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 () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: 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 () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: 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 () => {
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: 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`);
|
||||
});
|
||||
});
|
||||
124
packages/excalidraw/tests/eyeDropper.test.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
|
||||
import { KEYS, THEME } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { fireEvent, GlobalTestState, render, waitFor } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("eye dropper", () => {
|
||||
it("keeps the color preview within the editor container", async () => {
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
|
||||
Keyboard.keyPress("i");
|
||||
|
||||
const preview = await waitFor(() => {
|
||||
const element =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-preview",
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
|
||||
const eyeDropperContainer =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-backdrop",
|
||||
)!;
|
||||
expect(eyeDropperContainer.style.cursor).toMatch(
|
||||
/^url\(data:image\/svg\+xml/,
|
||||
);
|
||||
expect(eyeDropperContainer.style.cursor).toMatch(/\) 2 21, auto$/);
|
||||
|
||||
eyeDropperContainer.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 500,
|
||||
bottom: 350,
|
||||
width: 400,
|
||||
height: 300,
|
||||
x: 100,
|
||||
y: 50,
|
||||
toJSON: () => {},
|
||||
});
|
||||
Object.defineProperties(preview, {
|
||||
offsetWidth: { configurable: true, value: 48 },
|
||||
offsetHeight: { configurable: true, value: 48 },
|
||||
});
|
||||
|
||||
// This position fits within the viewport, but not within the editor.
|
||||
fireEvent.pointerMove(window, { clientX: 480, clientY: 330 });
|
||||
|
||||
expect(preview.style.left).toBe("325px");
|
||||
expect(preview.style.top).toBe("225px");
|
||||
});
|
||||
|
||||
it("applies the unfiltered color in dark mode", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
autoFocus={true}
|
||||
handleKeyboardGlobally={true}
|
||||
theme={THEME.DARK}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ctx = h.app.canvas.getContext("2d")!;
|
||||
vi.spyOn(ctx, "getImageData").mockReturnValue({
|
||||
data: new Uint8ClampedArray([18, 18, 18, 255]),
|
||||
} as ImageData);
|
||||
|
||||
Keyboard.keyPress(KEYS.I);
|
||||
|
||||
const eyeDropperContainer = await waitFor(() => {
|
||||
const element =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-backdrop",
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
|
||||
fireEvent.pointerUp(eyeDropperContainer, {
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
});
|
||||
|
||||
expect(h.state.currentItemBackgroundColor).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("contrasts the preview border with the sampled color", async () => {
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
|
||||
const ctx = h.app.canvas.getContext("2d")!;
|
||||
const getImageDataSpy = vi.spyOn(ctx, "getImageData").mockReturnValue({
|
||||
data: new Uint8ClampedArray([18, 18, 18, 255]),
|
||||
} as ImageData);
|
||||
|
||||
Keyboard.keyPress(KEYS.I);
|
||||
|
||||
const preview = await waitFor(() => {
|
||||
const element =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
|
||||
".excalidraw-eye-dropper-preview",
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
|
||||
expect(
|
||||
preview.style.getPropertyValue("--eye-dropper-preview-border-color"),
|
||||
).toBe("#fff");
|
||||
|
||||
getImageDataSpy.mockReturnValue({
|
||||
data: new Uint8ClampedArray([237, 237, 237, 255]),
|
||||
} as ImageData);
|
||||
fireEvent.pointerMove(window, { clientX: 50, clientY: 50 });
|
||||
|
||||
expect(
|
||||
preview.style.getPropertyValue("--eye-dropper-preview-border-color"),
|
||||
).toBe("#222");
|
||||
});
|
||||
});
|
||||
369
packages/excalidraw/tests/fitToContent.test.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import React from "react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../components/App.viewport";
|
||||
import { AnimationController } from "../renderer/animation";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { act, render } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
/**
|
||||
* The scroll/zoom animation is driven by `AnimationController`. With render
|
||||
* throttling enabled (see the `beforeEach` below) it schedules frames via
|
||||
* `requestAnimationFrame`, advancing the easing based on elapsed wall-clock
|
||||
* time. We use a very long animation `duration` (see `LONG_ANIMATION_DURATION`)
|
||||
* so it can never complete while we sample it, and let a few frames pass
|
||||
* between samples so the easing makes observable (but partial) progress.
|
||||
*/
|
||||
const LONG_ANIMATION_DURATION = 1_000_000;
|
||||
|
||||
const waitForAnimationProgress = (frames = 4) => {
|
||||
return act(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
let remaining = frames;
|
||||
const step = () => {
|
||||
if (--remaining <= 0) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Polls until the scroll/zoom animation has removed itself from the
|
||||
* `AnimationController` (i.e. it ran to completion), or until `maxFrames`
|
||||
* elapses as a safety net so a regression can't hang the suite.
|
||||
*/
|
||||
const waitForAnimationToStop = (maxFrames = 200) => {
|
||||
return act(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
let remaining = maxFrames;
|
||||
const check = () => {
|
||||
if (
|
||||
!AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY) ||
|
||||
--remaining <= 0
|
||||
) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe("scale-down", () => {
|
||||
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,
|
||||
});
|
||||
API.setElements([rectElement]);
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
act(() => {
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement,
|
||||
fit: "scale-down",
|
||||
animation: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
API.setElements([topLeft, bottomRight]);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
act(() => {
|
||||
h.app.viewport.setViewport({
|
||||
target: [topLeft, bottomRight],
|
||||
fit: "scale-down",
|
||||
animation: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 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 zoom to fit when scrolling to an element by id", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 50,
|
||||
height: 100,
|
||||
x: 50,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectElement]);
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
act(() => {
|
||||
// navigating by element id (a string target) with zoomToFit
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement.id,
|
||||
fit: "scale-down",
|
||||
animation: false,
|
||||
});
|
||||
});
|
||||
|
||||
// element is 10x taller than the viewport, so fit-to-content should
|
||||
// drop the zoom to <= 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,
|
||||
});
|
||||
API.setElements([rectElement]);
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
act(() => {
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement,
|
||||
fit: "contain",
|
||||
animation: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBe(0.1);
|
||||
|
||||
// state should reflect some scrolling
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("none", () => {
|
||||
it("should keep the current zoom and only center the target", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 100;
|
||||
h.state.height = 100;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 50,
|
||||
height: 50,
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
});
|
||||
API.setElements([rectElement]);
|
||||
|
||||
act(() => {
|
||||
API.setAppState({ zoom: { value: getNormalizedZoom(0.5) } });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement,
|
||||
fit: "none",
|
||||
animation: false,
|
||||
});
|
||||
});
|
||||
|
||||
// pan-only: the zoom must stay untouched...
|
||||
expect(h.state.zoom.value).toBe(0.5);
|
||||
// ...with the target's center (1025, 1025) centered in the viewport
|
||||
// (scroll = viewportSize / 2 / zoom - sceneCenter)
|
||||
expect(h.state.scrollX).toBeCloseTo(100 / 2 / 0.5 - 1025);
|
||||
expect(h.state.scrollY).toBeCloseTo(100 / 2 / 0.5 - 1025);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scale-down animated", () => {
|
||||
beforeEach(() => {
|
||||
// pace the animation via requestAnimationFrame instead of a tight
|
||||
// setTimeout(0) loop, which would otherwise starve the test's own timers
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
|
||||
// stop any in-flight scroll/zoom animation so it doesn't keep ticking on
|
||||
// the unmounted component and leak into the next test via the singleton
|
||||
AnimationController.reset();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
API.setElements([rectElement]);
|
||||
|
||||
act(() => {
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement,
|
||||
fit: "scale-down",
|
||||
animation: { duration: LONG_ANIMATION_DURATION },
|
||||
});
|
||||
});
|
||||
|
||||
// the animation hasn't progressed yet, so we're still at the origin
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
// Since this is an animation, we expect values to change through time.
|
||||
await waitForAnimationProgress();
|
||||
|
||||
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 waitForAnimationProgress();
|
||||
|
||||
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,
|
||||
});
|
||||
API.setElements([rectElement]);
|
||||
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
act(() => {
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement,
|
||||
fit: "scale-down",
|
||||
animation: { duration: LONG_ANIMATION_DURATION },
|
||||
});
|
||||
});
|
||||
|
||||
await waitForAnimationProgress();
|
||||
|
||||
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 waitForAnimationProgress();
|
||||
|
||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||
});
|
||||
|
||||
it("should stop ticking and settle on the target once complete", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: -100,
|
||||
y: -100,
|
||||
});
|
||||
API.setElements([rectElement]);
|
||||
|
||||
act(() => {
|
||||
// a short duration so the animation completes within a few frames
|
||||
h.app.viewport.setViewport({
|
||||
target: rectElement,
|
||||
fit: "scale-down",
|
||||
animation: { duration: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
await waitForAnimationToStop();
|
||||
|
||||
// the animation must remove itself from the controller rather than keep
|
||||
// ticking forever after reaching the target
|
||||
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// it should have settled on the target viewport (moved off the origin)
|
||||
const settledScrollX = h.state.scrollX;
|
||||
const settledScrollY = h.state.scrollY;
|
||||
expect(settledScrollX).not.toBe(0);
|
||||
expect(settledScrollY).not.toBe(0);
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
|
||||
// further frames must not move the viewport (no perpetual re-rendering)
|
||||
await waitForAnimationProgress();
|
||||
expect(h.state.scrollX).toBe(settledScrollX);
|
||||
expect(h.state.scrollY).toBe(settledScrollY);
|
||||
});
|
||||
});
|
||||
9
packages/excalidraw/tests/fixtures/constants.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export const DEER_IMAGE_DIMENSIONS = {
|
||||
width: 318,
|
||||
height: 335,
|
||||
};
|
||||
|
||||
export const SMILEY_IMAGE_DIMENSIONS = {
|
||||
width: 56,
|
||||
height: 77,
|
||||
};
|
||||
BIN
packages/excalidraw/tests/fixtures/deer.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
51
packages/excalidraw/tests/fixtures/diagramFixture.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import { VERSIONS } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
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",
|
||||
gridModeEnabled: false,
|
||||
},
|
||||
files: {},
|
||||
};
|
||||
|
||||
export const diagramFactory = ({
|
||||
overrides = {},
|
||||
elementOverrides = {} as Partial<ExcalidrawElement>,
|
||||
} = {}) => ({
|
||||
...diagramFixture,
|
||||
elements: [
|
||||
{
|
||||
...diamondFixture,
|
||||
...elementOverrides,
|
||||
isDeleted: elementOverrides.isDeleted ?? false,
|
||||
} as NonDeletedExcalidrawElement,
|
||||
{
|
||||
...ellipseFixture,
|
||||
...elementOverrides,
|
||||
isDeleted: elementOverrides.isDeleted ?? false,
|
||||
} as NonDeletedExcalidrawElement,
|
||||
{
|
||||
...rectangleFixture,
|
||||
...elementOverrides,
|
||||
isDeleted: elementOverrides.isDeleted ?? false,
|
||||
} as NonDeletedExcalidrawElement,
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export default diagramFixture;
|
||||
72
packages/excalidraw/tests/fixtures/elementFixture.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
import { DEFAULT_FONT_FAMILY } from "@excalidraw/common";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
const elementBase: Omit<ExcalidrawElement, "type"> = {
|
||||
id: "vWrqOAfkind2qcm7LDAGZ",
|
||||
x: 414,
|
||||
y: 237,
|
||||
width: 214,
|
||||
height: 214,
|
||||
angle: 0 as Radians,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#15aabf",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
index: null,
|
||||
seed: 1041657908,
|
||||
version: 120,
|
||||
versionNonce: 1188004276,
|
||||
isDeleted: false,
|
||||
boundElements: null,
|
||||
updated: 1,
|
||||
created: null,
|
||||
link: null,
|
||||
locked: false,
|
||||
};
|
||||
|
||||
export const rectangleFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "rectangle",
|
||||
};
|
||||
export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "ellipse",
|
||||
};
|
||||
export const diamondFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "diamond",
|
||||
};
|
||||
export const rectangleWithLinkFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "rectangle",
|
||||
link: "excalidraw.com",
|
||||
};
|
||||
|
||||
export const textFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "text",
|
||||
fontSize: 20,
|
||||
baseFontSize: null,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
strokeColor: "#1e1e1e",
|
||||
text: "original text",
|
||||
originalText: "original text",
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
containerId: null,
|
||||
lineHeight: 1.25 as any,
|
||||
autoResize: false,
|
||||
};
|
||||
31
packages/excalidraw/tests/fixtures/fixture_library.excalidrawlib
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "excalidrawlib",
|
||||
"version": 1,
|
||||
"library": [
|
||||
[
|
||||
{
|
||||
"type": "rectangle",
|
||||
"version": 38,
|
||||
"versionNonce": 1046419680,
|
||||
"isDeleted": false,
|
||||
"id": "A",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"angle": 0,
|
||||
"x": 21801,
|
||||
"y": 719.5,
|
||||
"strokeColor": "#c92a2a",
|
||||
"backgroundColor": "#e64980",
|
||||
"width": 50,
|
||||
"height": 30,
|
||||
"seed": 117297479,
|
||||
"groupIds": [],
|
||||
"strokeSharpness": "sharp",
|
||||
"boundElementIds": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
BIN
packages/excalidraw/tests/fixtures/smiley.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/excalidraw/tests/fixtures/smiley_embedded_v2.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
16
packages/excalidraw/tests/fixtures/smiley_embedded_v2.svg
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 77">
|
||||
<!-- svg-source:excalidraw -->
|
||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nGVSy07jMFx1MDAxNN3zXHUwMDE1kdmOIE1IXztcdTAwMWVcdTAwMDNiw6ZcdTAwMTKjXHUwMDExXHUwMDFhjUxym1hcdTAwMTjbsm9pU4TEZ8xufpFPwNcpcVqyiHTPfVx1MDAxZJ9zX4+ShGFrgM1cdTAwMTNcdTAwMDabkktRWb5mP1xif1x1MDAwMeuEVj6VhdjplS1DZYNo5qenUvuGRjuc52madk0g4Vx1MDAxOVx1MDAxNDpf9uDjJHlccn+fXHUwMDExXHUwMDE1tS4up2Urr2/M9lwivV1v26vf8Pc+tIaiLy5cYlx1MDAxYozoxkPT6biPW1x1MDAxZufF5KTokbWosCE0XHUwMDE2NSDqXHUwMDA2PVZMeoyrWtL8tEdcdTAwMWNa/Vx1MDAwNJdaakt7j8tZxjNcdTAwMWVXP/LyqbZ6paq+XHUwMDA2LVfOcOufXHUwMDE565ZCylx1MDAwNbay04eXzcpcdTAwMDI72PJrR3J0gPd9Tnv9Y5dfWzdcbpzb69GGl1x1MDAwMkmCUVx1MDAxYd9BXHUwMDFjzW1cdTAwMTV0/3M4v+HW7OYwR8GAXHUwMDE5XHUwMDAw+ZJl6WSSj2dxzcD9/Fx1MDAxMLzTKlx1MDAxY0IxK/JiXHUwMDFj08Jdef8xTFxccukgykhcbv7sbqNjqVZSRtvJbk/u4/+/94GmWuFCbGHfV0Kv+bOQ7Z4sNOJcXIqaXHUwMDE4M1x0y4E3njVcbn+qfVx1MDAxYbVcdTAwMTk67EBcbkVbzkZcdTAwMDF88/+gIePGLJAj5bo7Zi9cdTAwMDLWXHUwMDE332/ieFx1MDAxOb7dVO+GqHbM6Z1HNPPtXHUwMDEz+I3nwSJ9<!-- payload-end -->
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("https://excalidraw.com/Virgil.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cascadia";
|
||||
src: url("https://excalidraw.com/Cascadia.woff2");
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="56" height="77" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 18 28.5)"><text x="0" y="41" font-family="Virgil, Segoe UI Emoji" font-size="36px" fill="#c92a2a" text-anchor="start" style="white-space: pre;" direction="ltr">😀</text></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
16
packages/excalidraw/tests/fixtures/svg-image-exporting-reference.svg
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/excalidraw/tests/fixtures/test_embedded_v1.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
16
packages/excalidraw/tests/fixtures/test_embedded_v1.svg
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 97 77">
|
||||
<!-- svg-source:excalidraw -->
|
||||
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-start -->ewogICJ0eXBlIjogImV4Y2FsaWRyYXciLAogICJ2ZXJzaW9uIjogMiwKICAic291cmNlIjogImh0dHBzOi8vZXhjYWxpZHJhdy5jb20iLAogICJlbGVtZW50cyI6IFsKICAgIHsKICAgICAgImlkIjogInRabVFwa0cyQlZ2SzNxT01icHVXeiIsCiAgICAgICJ0eXBlIjogInRleHQiLAogICAgICAieCI6IDg2MS4xMTExMTExMTExMTExLAogICAgICAieSI6IDM1Ni4zMzMzMzMzMzMzMzMzLAogICAgICAid2lkdGgiOiA3NywKICAgICAgImhlaWdodCI6IDU3LAogICAgICAiYW5nbGUiOiAwLAogICAgICAic3Ryb2tlQ29sb3IiOiAiIzAwMDAwMCIsCiAgICAgICJiYWNrZ3JvdW5kQ29sb3IiOiAiIzg2OGU5NiIsCiAgICAgICJmaWxsU3R5bGUiOiAiY3Jvc3MtaGF0Y2giLAogICAgICAic3Ryb2tlV2lkdGgiOiAyLAogICAgICAic3Ryb2tlU3R5bGUiOiAic29saWQiLAogICAgICAicm91Z2huZXNzIjogMSwKICAgICAgIm9wYWNpdHkiOiAxMDAsCiAgICAgICJncm91cElkcyI6IFtdLAogICAgICAic3Ryb2tlU2hhcnBuZXNzIjogInJvdW5kIiwKICAgICAgInNlZWQiOiA0NzYzNjM3OTMsCiAgICAgICJ2ZXJzaW9uIjogMjMsCiAgICAgICJ2ZXJzaW9uTm9uY2UiOiA1OTc0MzUxMzUsCiAgICAgICJpc0RlbGV0ZWQiOiBmYWxzZSwKICAgICAgImJvdW5kRWxlbWVudElkcyI6IG51bGwsCiAgICAgICJ0ZXh0IjogInRlc3QiLAogICAgICAiZm9udFNpemUiOiAzNiwKICAgICAgImZvbnRGYW1pbHkiOiAxLAogICAgICAidGV4dEFsaWduIjogImxlZnQiLAogICAgICAidmVydGljYWxBbGlnbiI6ICJ0b3AiLAogICAgICAiYmFzZWxpbmUiOiA0MQogICAgfQogIF0sCiAgImFwcFN0YXRlIjogewogICAgInZpZXdCYWNrZ3JvdW5kQ29sb3IiOiAiI2ZmZmZmZiIsCiAgICAiZ3JpZFNpemUiOiBudWxsCiAgfQp9<!-- payload-end -->
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("https://excalidraw.com/Virgil.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cascadia";
|
||||
src: url("https://excalidraw.com/Cascadia.woff2");
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="97" height="77" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 38.5 28.5)"><text x="0" y="41" font-family="Virgil, Segoe UI Emoji" font-size="36px" fill="#000000" text-anchor="start" style="white-space: pre;" direction="ltr">test</text></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
920
packages/excalidraw/tests/flip.test.tsx
Normal file
@@ -0,0 +1,920 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, type Radians } from "@excalidraw/math";
|
||||
|
||||
import { getBoundTextElementPosition } from "@excalidraw/element";
|
||||
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
import { newLinearElement } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FileId,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
||||
import * as blobModule from "../data/blob";
|
||||
|
||||
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
|
||||
import { API } from "./helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import {
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
render,
|
||||
screen,
|
||||
unmountComponent,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
|
||||
import { getTextEditor } from "./queries/dom";
|
||||
|
||||
import { mockHTMLImageElement } from "./helpers/mocks";
|
||||
|
||||
import type { NormalizedZoomValue } from "../types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(() => {
|
||||
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
||||
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
||||
|
||||
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
|
||||
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
unmountComponent();
|
||||
|
||||
mouse.reset();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
API.setAppState({
|
||||
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 as Radians,
|
||||
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: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(-922.4761962890625, 300.3277587890625),
|
||||
pointFrom<LocalPoint>(828.0126953125, 410.51605224609375),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
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],
|
||||
],
|
||||
...extraProps,
|
||||
});
|
||||
};
|
||||
|
||||
const checkElementsBoundingBox = async (
|
||||
element1: ExcalidrawElement,
|
||||
element2: ExcalidrawElement,
|
||||
toleranceInPx: number = 0,
|
||||
) => {
|
||||
const elementsMap = arrayToMap([element1, element2]);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1, elementsMap);
|
||||
|
||||
const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2, elementsMap);
|
||||
|
||||
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]);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0];
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const checkTwoPointsLineHorizontalFlip = async () => {
|
||||
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||
API.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;
|
||||
API.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]);
|
||||
API.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]);
|
||||
API.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]);
|
||||
|
||||
API.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]);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.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");
|
||||
API.setElements([arrow]);
|
||||
API.setAppState({ 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");
|
||||
API.setElements([arrow]);
|
||||
API.setAppState({ 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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
API.setElements([line]);
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
},
|
||||
});
|
||||
API.updateElement(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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
|
||||
API.setElements([line]);
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
},
|
||||
});
|
||||
API.updateElement(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");
|
||||
API.setElements([arrow]);
|
||||
API.setAppState({ 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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
API.updateElement(line, { angle: originalAngle });
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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");
|
||||
API.setElements([arrow]);
|
||||
API.setAppState({ 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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
|
||||
API.updateElement(line, { angle: originalAngle });
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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");
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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");
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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");
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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");
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
API.updateElement(line, { angle: originalAngle });
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
|
||||
API.updateElement(line, { angle: originalAngle });
|
||||
API.setElements([line]);
|
||||
API.setAppState({ 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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
API.setElements([line]);
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
},
|
||||
});
|
||||
API.updateElement(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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
|
||||
API.setElements([line]);
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
...h.state.selectedElementIds,
|
||||
[line.id]: true,
|
||||
},
|
||||
});
|
||||
API.updateElement(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
|
||||
API.setAppState({
|
||||
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
|
||||
API.setAppState({
|
||||
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
|
||||
API.setAppState({
|
||||
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
|
||||
API.setAppState({
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||
h.state.height = 1000;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockHTMLImageElement(
|
||||
SMILEY_IMAGE_DIMENSIONS.width,
|
||||
SMILEY_IMAGE_DIMENSIONS.height,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
h.state.height = 0;
|
||||
});
|
||||
|
||||
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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
//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();
|
||||
});
|
||||
API.updateElement(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) as Radians;
|
||||
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
|
||||
//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();
|
||||
});
|
||||
API.updateElement(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 = await getTextEditor();
|
||||
fireEvent.input(editor, { target: { value: "arrow" } });
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor();
|
||||
fireEvent.input(editor, { target: { value: "rect\ntext" } });
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
mouse.select([arrow, rectangle]);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipVertical);
|
||||
|
||||
const arrowText = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
const arrowTextPos = getBoundTextElementPosition(
|
||||
arrow.get(),
|
||||
arrowText,
|
||||
arrayToMap(h.elements),
|
||||
)!;
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
packages/excalidraw/tests/freedrawMode.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { act, fireEvent, render, screen } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("freedraw mode action", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
|
||||
await act(async () => {});
|
||||
});
|
||||
|
||||
it("applies currentItemStrokeVariability to newly drawn freedraw elements", () => {
|
||||
// default app state draws constant-width strokes
|
||||
expect(h.state.currentItemStrokeVariability).toBe("constant");
|
||||
|
||||
UI.createElement("freedraw", { x: 0, y: 0 });
|
||||
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
|
||||
).toBe("constant");
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it("toggling the radio updates both the selected element and the default", () => {
|
||||
const element = UI.createElement("freedraw", { x: 0, y: 0 });
|
||||
API.setSelectedElements([element.get()] as NonDeletedExcalidrawElement[]);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Variable"));
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
|
||||
).toBe("variable");
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
|
||||
).toBe(0.5);
|
||||
expect(h.state.currentItemStrokeVariability).toBe("variable");
|
||||
|
||||
fireEvent.click(screen.getByTitle("Constant"));
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
|
||||
).toBe("constant");
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
|
||||
).toBe(0.5);
|
||||
expect(h.state.currentItemStrokeVariability).toBe("constant");
|
||||
});
|
||||
});
|
||||
579
packages/excalidraw/tests/helpers/api.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import util from "util";
|
||||
|
||||
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
ROUNDNESS,
|
||||
assertNever,
|
||||
getStrokeWidthByKey,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
newImageElement,
|
||||
newLinearElement,
|
||||
newMagicFrameElement,
|
||||
newStickyNoteElement,
|
||||
newTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { isUsingAdaptiveRadius, getSelectedElements } from "@excalidraw/element";
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { FONT_SIZES } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
FixedSegment,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import { createTestHook } from "../../components/App";
|
||||
import { getDefaultAppState } from "../../appState";
|
||||
import { GlobalTestState, createEvent, fireEvent, act } from "../test-utils";
|
||||
|
||||
import type { Action } from "../../actions/types";
|
||||
import type App from "../../components/App";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
// so that window.h is available when App.tsx is not imported as well.
|
||||
createTestHook();
|
||||
|
||||
const { h } = window;
|
||||
|
||||
export class API {
|
||||
static updateScene: InstanceType<typeof App>["updateScene"] = (...args) => {
|
||||
act(() => {
|
||||
h.app.updateScene(...args);
|
||||
});
|
||||
};
|
||||
static setAppState: React.Component<any, AppState>["setState"] = (
|
||||
state,
|
||||
cb,
|
||||
) => {
|
||||
act(() => {
|
||||
h.setState(state, cb);
|
||||
});
|
||||
};
|
||||
|
||||
static setElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
act(() => {
|
||||
h.elements = elements;
|
||||
});
|
||||
};
|
||||
|
||||
static setSelectedElements = (elements: NonDeletedExcalidrawElement[], editingGroupId?: string | null) => {
|
||||
act(() => {
|
||||
h.setState({
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: editingGroupId ?? null,
|
||||
selectedElementIds: elements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
elements,
|
||||
h.state,
|
||||
h.app,
|
||||
)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
static updateElement = <T extends ExcalidrawElement>(
|
||||
...args: Parameters<typeof h.app.scene.mutateElement<T>>
|
||||
) => {
|
||||
act(() => {
|
||||
h.app.scene.mutateElement(...args);
|
||||
});
|
||||
};
|
||||
|
||||
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 getUndoStack = () => {
|
||||
// @ts-ignore
|
||||
return h.history.undoStack;
|
||||
};
|
||||
|
||||
static getRedoStack = () => {
|
||||
// @ts-ignore
|
||||
return h.history.redoStack;
|
||||
};
|
||||
|
||||
static getSnapshot = () => {
|
||||
return Array.from(h.store.snapshot.elements.values());
|
||||
};
|
||||
|
||||
static clearSelection = () => {
|
||||
act(() => {
|
||||
// @ts-ignore
|
||||
h.app.clearSelection(null);
|
||||
});
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
};
|
||||
|
||||
static getElement = <T extends ExcalidrawElement>(element: T): T => {
|
||||
return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element;
|
||||
}
|
||||
|
||||
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;
|
||||
index?: ExcalidrawElement["index"];
|
||||
groupIds?: ExcalidrawElement["groupIds"];
|
||||
created?: ExcalidrawElement["created"];
|
||||
// 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"];
|
||||
baseHeight?: T extends "stickynote"
|
||||
? ExcalidrawStickyNoteElement["baseHeight"]
|
||||
: never;
|
||||
containerId?: T extends "text"
|
||||
? ExcalidrawTextElement["containerId"]
|
||||
: never;
|
||||
points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
|
||||
polygon?: T extends "line" ? boolean : never;
|
||||
strokeOptions?: T extends "freedraw"
|
||||
? ExcalidrawFreeDrawElement["strokeOptions"]
|
||||
: 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"
|
||||
? ExcalidrawArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"]
|
||||
: never;
|
||||
endBinding?: T extends "arrow"
|
||||
? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"]
|
||||
: never;
|
||||
startArrowhead?: T extends "arrow"
|
||||
? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"]
|
||||
: never;
|
||||
endArrowhead?: T extends "arrow"
|
||||
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
|
||||
: never;
|
||||
elbowed?: boolean;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
}): NonDeleted<
|
||||
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
|
||||
: T extends "stickynote"
|
||||
? ExcalidrawStickyNoteElement
|
||||
: ExcalidrawGenericElement
|
||||
> => {
|
||||
let element: Mutable<ExcalidrawElement> = null!;
|
||||
|
||||
const appState = h?.state || getDefaultAppState();
|
||||
|
||||
const base: Omit<
|
||||
ExcalidrawGenericElement,
|
||||
| "id"
|
||||
| "type"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "isDeleted"
|
||||
| "groupIds"
|
||||
| "link"
|
||||
| "updated"
|
||||
> = {
|
||||
seed: 1,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
frameId: rest.frameId ?? null,
|
||||
index: rest.index ?? null,
|
||||
angle: (rest.angle ?? 0) as Radians,
|
||||
strokeColor:
|
||||
rest.strokeColor ??
|
||||
(type === "stickynote"
|
||||
? appState.currentItemStickynoteStrokeColor
|
||||
: appState.currentItemStrokeColor),
|
||||
backgroundColor:
|
||||
rest.backgroundColor ??
|
||||
(type === "stickynote"
|
||||
? appState.currentItemStickynoteBackgroundColor
|
||||
: appState.currentItemBackgroundColor),
|
||||
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
|
||||
strokeWidth:
|
||||
rest.strokeWidth ??
|
||||
getStrokeWidthByKey(type, appState.currentItemStrokeWidthKey),
|
||||
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
|
||||
roundness: (
|
||||
rest.roundness === undefined
|
||||
? appState.currentItemRoundness === "round"
|
||||
: rest.roundness
|
||||
)
|
||||
? {
|
||||
type: isUsingAdaptiveRadius(type)
|
||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
roughness: rest.roughness ?? appState.currentItemRoughness,
|
||||
opacity: rest.opacity ?? appState.currentItemOpacity,
|
||||
boundElements: rest.boundElements ?? null,
|
||||
locked: rest.locked ?? false,
|
||||
created: rest.created === undefined ? getUpdatedTimestamp() : rest.created,
|
||||
};
|
||||
switch (type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
element = newElement({
|
||||
type: type as "rectangle" | "diamond" | "ellipse",
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "embeddable":
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "iframe":
|
||||
element = newIframeElement({
|
||||
type: "iframe",
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "stickynote":
|
||||
element = newStickyNoteElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
baseHeight: rest.baseHeight ?? height,
|
||||
});
|
||||
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,
|
||||
points: rest.points,
|
||||
strokeOptions: rest.strokeOptions,
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "arrow":
|
||||
element = newArrowElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
points: rest.points ?? [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
elbowed: rest.elbowed ?? false,
|
||||
});
|
||||
break;
|
||||
case "line":
|
||||
element = newLinearElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
points: rest.points ?? [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
polygon: rest.polygon,
|
||||
});
|
||||
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;
|
||||
element.startArrowhead = rest.startArrowhead ?? null;
|
||||
element.endArrowhead = rest.endArrowhead ?? null;
|
||||
}
|
||||
if (id) {
|
||||
element.id = id;
|
||||
}
|
||||
if (isDeleted) {
|
||||
element.isDeleted = isDeleted;
|
||||
}
|
||||
if (groupIds) {
|
||||
element.groupIds = groupIds;
|
||||
}
|
||||
return element as any;
|
||||
};
|
||||
|
||||
static createTextContainer = (opts?: {
|
||||
frameId?: ExcalidrawElement["id"];
|
||||
groupIds?: ExcalidrawElement["groupIds"];
|
||||
label?: {
|
||||
text?: string;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
groupIds?: ExcalidrawElement["groupIds"];
|
||||
};
|
||||
}) => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
frameId: opts?.frameId || null,
|
||||
groupIds: opts?.groupIds,
|
||||
});
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: opts?.label?.text || "sample-text",
|
||||
width: 50,
|
||||
height: 20,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
containerId: rectangle.id,
|
||||
frameId:
|
||||
opts?.label?.frameId === undefined
|
||||
? opts?.frameId ?? null
|
||||
: opts?.label?.frameId ?? null,
|
||||
groupIds: opts?.label?.groupIds === undefined
|
||||
? opts?.groupIds
|
||||
: opts?.label?.groupIds ,
|
||||
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(
|
||||
rectangle,
|
||||
{
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
},
|
||||
);
|
||||
|
||||
return [rectangle, text];
|
||||
};
|
||||
|
||||
static createLabeledArrow = (opts?: {
|
||||
frameId?: ExcalidrawElement["id"];
|
||||
label?: {
|
||||
text?: string;
|
||||
frameId?: ExcalidrawElement["id"] | null;
|
||||
};
|
||||
}) => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
frameId: opts?.frameId || null,
|
||||
});
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
width: 50,
|
||||
height: 20,
|
||||
containerId: arrow.id,
|
||||
frameId:
|
||||
opts?.label?.frameId === undefined
|
||||
? opts?.frameId ?? null
|
||||
: opts?.label?.frameId ?? null,
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(
|
||||
arrow,
|
||||
{
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
},
|
||||
);
|
||||
|
||||
return [arrow, text];
|
||||
};
|
||||
|
||||
static readFile = async <T extends "utf8" | null>(
|
||||
filepath: string,
|
||||
encoding?: T,
|
||||
): Promise<T extends "utf8" ? string : ArrayBuffer> => {
|
||||
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 (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
|
||||
|
||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||
|
||||
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
|
||||
|
||||
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
|
||||
files.item = (index: number) => files[index];
|
||||
|
||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||
value: {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
|
||||
files,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
|
||||
items: items.map((item, idx) => {
|
||||
if (item.kind === "string") {
|
||||
return {
|
||||
kind: "string",
|
||||
type: item.type,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
|
||||
getAsString: (cb: (text: string) => any) => cb(item.value),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "file",
|
||||
type: item.type || item.file.type,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
|
||||
getAsFile: () => item.file,
|
||||
};
|
||||
}),
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
|
||||
getData: (type: string) => {
|
||||
return items.find((item) => item.type === "string" && item.type === type) || "";
|
||||
},
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
|
||||
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(fileDropEvent, "clientX", {
|
||||
value: 0,
|
||||
});
|
||||
Object.defineProperty(fileDropEvent, "clientY", {
|
||||
value: 0,
|
||||
});
|
||||
|
||||
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
|
||||
};
|
||||
|
||||
static executeAction = (action: Action) => {
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(action);
|
||||
});
|
||||
};
|
||||
}
|
||||
2
packages/excalidraw/tests/helpers/colorize.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const yellow = (str: string) => `\u001b[33m${str}\u001b[0m`;
|
||||
export const red = (str: string) => `\u001b[31m${str}\u001b[0m`;
|
||||
6
packages/excalidraw/tests/helpers/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const INITIALIZED_IMAGE_PROPS = {
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
};
|
||||
111
packages/excalidraw/tests/helpers/mocks.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { throttleRAF as throttleRAFType } from "@excalidraw/common";
|
||||
|
||||
type ThrottledFn<T extends unknown[]> = ((...args: T) => void) & {
|
||||
flush: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const mockThrottleRAF: typeof throttleRAFType = <T extends unknown[]>(
|
||||
fn: (...args: T) => void,
|
||||
) => {
|
||||
const ret = ((...args: T) => {
|
||||
fn(...args);
|
||||
}) as ThrottledFn<T>;
|
||||
|
||||
ret.flush = () => {};
|
||||
ret.cancel = () => {};
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
export const mockMermaidToExcalidraw = (opts: {
|
||||
parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw;
|
||||
mockRef?: boolean;
|
||||
}) => {
|
||||
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(opts.parseMermaidToExcalidraw);
|
||||
|
||||
if (opts.mockRef) {
|
||||
vi.spyOn(React, "useRef").mockReturnValue({
|
||||
current: {
|
||||
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Mock for HTMLImageElement (use with `vi.unstubAllGlobals()`)
|
||||
// as jsdom.resources: "usable" throws an error on image load
|
||||
export const mockHTMLImageElement = (
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
) => {
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class extends Image {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
Object.defineProperty(this, "naturalWidth", {
|
||||
value: naturalWidth,
|
||||
});
|
||||
Object.defineProperty(this, "naturalHeight", {
|
||||
value: naturalHeight,
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.onload?.({} as Event);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
|
||||
export const mockMultipleHTMLImageElements = (
|
||||
sizes: (readonly [number, number])[],
|
||||
) => {
|
||||
const _sizes = [...sizes];
|
||||
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class extends Image {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const size = _sizes.shift();
|
||||
if (!size) {
|
||||
throw new Error("Insufficient sizes");
|
||||
}
|
||||
|
||||
Object.defineProperty(this, "naturalWidth", {
|
||||
value: size[0],
|
||||
});
|
||||
Object.defineProperty(this, "naturalHeight", {
|
||||
value: size[1],
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.onload?.({} as Event);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
135
packages/excalidraw/tests/helpers/polyfills.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { URL } from "node:url";
|
||||
|
||||
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 DataTransferItemList extends Array<DataTransferItem> {
|
||||
add(data: string | File, type: string = ""): void {
|
||||
if (typeof data === "string") {
|
||||
this.push(new DataTransferItem("string", type, data));
|
||||
} else if (data instanceof File) {
|
||||
this.push(new DataTransferItem("file", type, data));
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransfer {
|
||||
public items: DataTransferItemList = new DataTransferItemList();
|
||||
|
||||
get files() {
|
||||
return this.items
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile()!);
|
||||
}
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
if (typeof data === "string") {
|
||||
this.items.add(data, type);
|
||||
} else {
|
||||
this.items.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
setData(type: string, value: string) {
|
||||
this.items.add(value, type);
|
||||
}
|
||||
|
||||
getData(type: string) {
|
||||
return this.items.find((item) => item.type === type)?.data || "";
|
||||
}
|
||||
}
|
||||
|
||||
export const testPolyfills = {
|
||||
ClipboardEvent,
|
||||
DataTransfer,
|
||||
DataTransferItem,
|
||||
// https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
|
||||
URL,
|
||||
};
|
||||
|
||||
export const PolyfillLocalStorage = () => {
|
||||
// Node.js 25+ provides a native localStorage global that shadows jsdom's,
|
||||
// and jsdom's own localStorage also uses the native one -- both are broken
|
||||
// (empty objects without Storage methods). On older Node versions, jsdom
|
||||
// provides a working localStorage. This polyfill replaces localStorage on
|
||||
// all supported versions with a standard Storage implementation backed by
|
||||
// a Map, ensuring consistent behavior regardless of the Node.js version.
|
||||
const storage = new Map<string, string>();
|
||||
const storagePolyfill: Storage = {
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
},
|
||||
key(index) {
|
||||
return Array.from(storage.keys())[index] ?? null;
|
||||
},
|
||||
getItem(key) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
*[Symbol.iterator]() {
|
||||
yield* storage.entries();
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: storagePolyfill,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
700
packages/excalidraw/tests/helpers/ui.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getCommonBounds,
|
||||
getElementPointsCoords,
|
||||
getLineHeightInPx,
|
||||
} from "@excalidraw/element";
|
||||
import { cropElement } from "@excalidraw/element";
|
||||
import {
|
||||
getTransformHandles,
|
||||
getTransformHandlesFromCoords,
|
||||
OMIT_SIDES_FOR_FRAME,
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
type TransformHandle,
|
||||
type TransformHandleDirection,
|
||||
} from "@excalidraw/element";
|
||||
import {
|
||||
isLinearElement,
|
||||
isFreeDrawElement,
|
||||
isTextElement,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element";
|
||||
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type { TransformHandleType } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { createTestHook } from "../../components/App";
|
||||
import { getTextEditor, TEXT_EDITOR_SELECTOR } from "../queries/dom";
|
||||
import { act, fireEvent, GlobalTestState, screen } from "../test-utils";
|
||||
|
||||
import { API } from "./api";
|
||||
|
||||
import type { ToolType } from "../../types";
|
||||
|
||||
// so that window.h is available when App.tsx is not imported as well.
|
||||
createTestHook();
|
||||
|
||||
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,
|
||||
target: HTMLElement | Document | Window = document,
|
||||
) => {
|
||||
fireEvent.keyDown(target, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
});
|
||||
};
|
||||
|
||||
static keyUp = (
|
||||
key: string,
|
||||
target: HTMLElement | Document | Window = document,
|
||||
) => {
|
||||
fireEvent.keyUp(target, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
});
|
||||
};
|
||||
|
||||
static keyPress = (key: string, target?: HTMLElement | Document | Window) => {
|
||||
Keyboard.keyDown(key, target);
|
||||
Keyboard.keyUp(key, target);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
static undo = () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress("z");
|
||||
});
|
||||
};
|
||||
|
||||
static redo = () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress("z");
|
||||
});
|
||||
};
|
||||
|
||||
static exitTextEditor = (textarea: HTMLTextAreaElement) => {
|
||||
fireEvent.keyDown(textarea, { key: KEYS.ESCAPE });
|
||||
};
|
||||
}
|
||||
|
||||
const getElementPointForSelection = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, angle } = element;
|
||||
const target = pointFrom<GlobalPoint>(
|
||||
x +
|
||||
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
|
||||
y,
|
||||
);
|
||||
let center: GlobalPoint;
|
||||
|
||||
if (isLinearElement(element)) {
|
||||
const bounds = getElementPointsCoords(element, element.points);
|
||||
center = pointFrom(
|
||||
(bounds[0] + bounds[2]) / 2,
|
||||
(bounds[1] + bounds[3]) / 2,
|
||||
);
|
||||
} else {
|
||||
center = elementCenterPoint(element, elementsMap);
|
||||
}
|
||||
|
||||
if (isTextElement(element)) {
|
||||
return center;
|
||||
}
|
||||
|
||||
return pointRotateRads(target, center, angle);
|
||||
};
|
||||
|
||||
export class Pointer {
|
||||
public clientX = 0;
|
||||
public clientY = 0;
|
||||
|
||||
static activePointers: Pointer[] = [];
|
||||
static resetAll() {
|
||||
Pointer.activePointers.forEach((pointer) => pointer.reset());
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly pointerType: "mouse" | "touch" | "pen",
|
||||
private readonly pointerId = 1,
|
||||
) {
|
||||
Pointer.activePointers.push(this);
|
||||
}
|
||||
|
||||
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;
|
||||
// fire "mousemove" to update editor cursor position
|
||||
fireEvent.mouseMove(document, this.getEvent());
|
||||
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,
|
||||
h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
clickOn(element: ExcalidrawElement) {
|
||||
this.reset();
|
||||
this.click(
|
||||
...getElementPointForSelection(
|
||||
element,
|
||||
h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
doubleClickOn(element: ExcalidrawElement) {
|
||||
this.reset();
|
||||
this.doubleClick(
|
||||
...getElementPointForSelection(
|
||||
element,
|
||||
h.app.scene.getElementsMapIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
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];
|
||||
act(() => {
|
||||
h.setState({
|
||||
selectedElementIds: elements.reduce(
|
||||
(acc, e) => ({
|
||||
...acc,
|
||||
[e.id]: true,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
});
|
||||
});
|
||||
let handleCoords: TransformHandle | undefined;
|
||||
if (elements.length === 1) {
|
||||
handleCoords = getTransformHandles(
|
||||
elements[0],
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"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 as Radians,
|
||||
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" | "lasso"
|
||||
>;
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
/** clicks a tool that lives in the extra-tools dropdown, opening it first */
|
||||
static clickExtraTool = (toolName: ToolType) => {
|
||||
fireEvent.click(
|
||||
document.querySelector(".App-toolbar__extra-tools-trigger")!,
|
||||
);
|
||||
UI.clickTool(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" ? LocalPoint[] : 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: LocalPoint[] = initialPoints ?? [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(width, height),
|
||||
];
|
||||
|
||||
UI.clickTool(type);
|
||||
|
||||
if (type === "text") {
|
||||
const clickY = h.state.gridModeEnabled
|
||||
? y
|
||||
: y +
|
||||
getLineHeightInPx(
|
||||
h.state.currentItemFontSize,
|
||||
getLineHeight(h.state.currentItemFontFamily),
|
||||
) /
|
||||
2;
|
||||
|
||||
mouse.reset();
|
||||
mouse.click(x, clickY);
|
||||
} 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) {
|
||||
act(() => {
|
||||
h.app.scene.mutateElement(origElement, { angle });
|
||||
});
|
||||
}
|
||||
|
||||
return proxy(origElement);
|
||||
}
|
||||
|
||||
static async editText<
|
||||
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
|
||||
>(element: T, text: string) {
|
||||
const openedEditor =
|
||||
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
|
||||
|
||||
if (!openedEditor) {
|
||||
mouse.select(element);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
}
|
||||
|
||||
const editor = await getTextEditor();
|
||||
if (!editor) {
|
||||
throw new Error("Can't find wysiwyg text editor in the dom");
|
||||
}
|
||||
|
||||
fireEvent.input(editor, { target: { value: text } });
|
||||
act(() => {
|
||||
editor.blur();
|
||||
});
|
||||
|
||||
return isTextElement(element)
|
||||
? element
|
||||
: proxy(
|
||||
h.elements[
|
||||
h.elements.length - 1
|
||||
] as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
}
|
||||
|
||||
static updateInput = (input: HTMLInputElement, value: string | number) => {
|
||||
act(() => {
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: String(value) } });
|
||||
input.blur();
|
||||
});
|
||||
};
|
||||
|
||||
static resize(
|
||||
element: ExcalidrawElement | ExcalidrawElement[],
|
||||
handle: TransformHandleDirection,
|
||||
mouseMove: [deltaX: number, deltaY: number],
|
||||
keyboardModifiers: KeyboardModifiers = {},
|
||||
) {
|
||||
return transform(element, handle, mouseMove, keyboardModifiers);
|
||||
}
|
||||
|
||||
static crop(
|
||||
element: ExcalidrawImageElement,
|
||||
handle: TransformHandleDirection,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
mouseMove: [deltaX: number, deltaY: number],
|
||||
keepAspectRatio = false,
|
||||
) {
|
||||
const handleCoords = getTransformHandles(
|
||||
element,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
{},
|
||||
)[handle]!;
|
||||
|
||||
const clientX = handleCoords[0] + handleCoords[2] / 2;
|
||||
const clientY = handleCoords[1] + handleCoords[3] / 2;
|
||||
|
||||
const mutations = cropElement(
|
||||
element,
|
||||
h.scene.getNonDeletedElementsMap(),
|
||||
handle,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
clientX + mouseMove[0],
|
||||
clientY + mouseMove[1],
|
||||
keepAspectRatio ? element.width / element.height : undefined,
|
||||
);
|
||||
|
||||
API.updateElement(element, mutations);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
static queryStats = () => {
|
||||
return GlobalTestState.renderResult.container.querySelector(
|
||||
".exc-stats",
|
||||
) as HTMLElement | null;
|
||||
};
|
||||
|
||||
static queryStatsProperty = (label: string) => {
|
||||
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
||||
|
||||
expect(elementStats).not.toBeNull();
|
||||
|
||||
if (elementStats) {
|
||||
return (
|
||||
elementStats?.querySelector(
|
||||
`.exc-stats__row .drag-input-container[data-testid="${label}"]`,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
5308
packages/excalidraw/tests/history.test.tsx
Normal file
176
packages/excalidraw/tests/image.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { MIME_TYPES, randomId, reseed } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
import * as blobModule from "../data/blob";
|
||||
import * as filesystemModule from "../data/filesystem";
|
||||
import { Excalidraw } from "../index";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { mockMultipleHTMLImageElements } from "./helpers/mocks";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { GlobalTestState, render, waitFor } from "./test-utils";
|
||||
import {
|
||||
DEER_IMAGE_DIMENSIONS,
|
||||
SMILEY_IMAGE_DIMENSIONS,
|
||||
} from "./fixtures/constants";
|
||||
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
|
||||
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
export const setupImageTest = async (
|
||||
sizes: { width: number; height: number }[],
|
||||
props?: ExcalidrawProps,
|
||||
) => {
|
||||
await render(
|
||||
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} {...props} />,
|
||||
);
|
||||
|
||||
h.state.height = 1000;
|
||||
|
||||
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
|
||||
};
|
||||
|
||||
describe("resizeImageFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns the original file when it already fits the max dimensions", async () => {
|
||||
mockMultipleHTMLImageElements([[100, 100]]);
|
||||
|
||||
const imageFile = new File([new Uint8Array([1, 2, 3])], "image.png", {
|
||||
type: MIME_TYPES.png,
|
||||
});
|
||||
|
||||
await expect(
|
||||
blobModule.resizeImageFile(imageFile, { maxWidthOrHeight: 200 }),
|
||||
).resolves.toBe(imageFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image insertion", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
reseed(7);
|
||||
|
||||
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
||||
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
||||
|
||||
generateIdSpy.mockImplementation(() =>
|
||||
Promise.resolve(randomId() as FileId),
|
||||
);
|
||||
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
||||
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
});
|
||||
|
||||
const setup = () =>
|
||||
setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
|
||||
|
||||
const assert = async () => {
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
...DEER_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...INITIALIZED_IMAGE_PROPS,
|
||||
...SMILEY_IMAGE_DIMENSIONS,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
// Not placed on top of each other
|
||||
const dimensionsSet = new Set(h.elements.map((el) => `${el.x}-${el.y}`));
|
||||
expect(dimensionsSet.size).toEqual(h.elements.length);
|
||||
};
|
||||
|
||||
it("should eventually initialize all dropped images", async () => {
|
||||
await setup();
|
||||
|
||||
const files = await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]);
|
||||
await API.drop(files.map((file) => ({ kind: "file", file })));
|
||||
|
||||
await assert();
|
||||
});
|
||||
|
||||
it("should eventually initialize all pasted images", async () => {
|
||||
await setup();
|
||||
|
||||
document.dispatchEvent(
|
||||
createPasteEvent({
|
||||
files: await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await assert();
|
||||
});
|
||||
|
||||
it("should eventually initialize all images added through image tool", async () => {
|
||||
await setup();
|
||||
|
||||
const fileOpenSpy = vi.spyOn(filesystemModule, "fileOpen");
|
||||
fileOpenSpy.mockImplementation(
|
||||
async () =>
|
||||
await Promise.all([
|
||||
API.loadFile("./fixtures/deer.png"),
|
||||
API.loadFile("./fixtures/smiley.png"),
|
||||
]),
|
||||
);
|
||||
UI.clickExtraTool("image");
|
||||
|
||||
await assert();
|
||||
});
|
||||
|
||||
it("passes host-configured max image dimensions to the resize helper", async () => {
|
||||
await setupImageTest([DEER_IMAGE_DIMENSIONS], {
|
||||
imageOptions: { maxWidthOrHeight: 2048 },
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{ kind: "file", file: await API.loadFile("./fixtures/deer.png") },
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(blobModule.resizeImageFile).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
{ maxWidthOrHeight: 2048 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces host-configured max image file size", async () => {
|
||||
await setupImageTest([DEER_IMAGE_DIMENSIONS], {
|
||||
imageOptions: { maxFileSizeBytes: 1024 * 1024 },
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: new File([new Uint8Array(2 * 1024 * 1024)], "image.png", {
|
||||
type: MIME_TYPES.png,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.errorMessage).toBe(
|
||||
"File is too big. Maximum allowed size is 1MB.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
1648
packages/excalidraw/tests/interactivity.test.tsx
Normal file
286
packages/excalidraw/tests/laser.test.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { CURSOR_TYPE } from "@excalidraw/common";
|
||||
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { getLinkHandleFromCoords } from "../components/hyperlink/helpers";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Pointer } from "./helpers/ui";
|
||||
import { act, GlobalTestState, render, waitFor } from "./test-utils";
|
||||
|
||||
import type { Collaborator, ExcalidrawProps, SocketId } from "../types";
|
||||
|
||||
describe("laser tool interactions", () => {
|
||||
const h = window.h;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
it("opens links while using the laser tool", async () => {
|
||||
const onLinkOpenSpy = vi.fn();
|
||||
const onLinkOpen: NonNullable<ExcalidrawProps["onLinkOpen"]> = (
|
||||
...args
|
||||
) => {
|
||||
onLinkOpenSpy(...args);
|
||||
args[1].preventDefault();
|
||||
};
|
||||
await render(<Excalidraw onLinkOpen={onLinkOpen} />);
|
||||
|
||||
const linkedRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 120,
|
||||
height: 90,
|
||||
});
|
||||
API.setElements([linkedRect]);
|
||||
API.updateElement(linkedRect, {
|
||||
link: "https://example.com",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
const elementsMap = h.app.scene.getNonDeletedElementsMap();
|
||||
const currentRect = API.getElement(linkedRect);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(currentRect, elementsMap);
|
||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
currentRect.angle,
|
||||
h.state,
|
||||
);
|
||||
const iconCenterX = linkX + linkWidth / 2;
|
||||
const iconCenterY = linkY + linkHeight / 2;
|
||||
|
||||
mouse.moveTo(iconCenterX, iconCenterY);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.POINTER,
|
||||
);
|
||||
|
||||
mouse.clickAt(iconCenterX, iconCenterY);
|
||||
expect(onLinkOpenSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("activates embeddables on center click while using the laser tool", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
const embeddable = API.createElement({
|
||||
type: "embeddable",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 300,
|
||||
height: 180,
|
||||
});
|
||||
API.setElements([embeddable]);
|
||||
API.updateElement(embeddable, {
|
||||
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
const handleIframeLikeCenterClickSpy = vi.spyOn(
|
||||
h.app as unknown as {
|
||||
handleIframeLikeCenterClick: () => void;
|
||||
},
|
||||
"handleIframeLikeCenterClick",
|
||||
);
|
||||
|
||||
const centerX = embeddable.x + embeddable.width / 2;
|
||||
const centerY = embeddable.y + embeddable.height / 2;
|
||||
|
||||
mouse.moveTo(centerX, centerY);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.POINTER,
|
||||
);
|
||||
mouse.clickAt(centerX, centerY);
|
||||
|
||||
expect(handleIframeLikeCenterClickSpy).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
|
||||
expect(h.state.activeEmbeddable?.state).toBe("active");
|
||||
});
|
||||
|
||||
handleIframeLikeCenterClickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("activates embeddables covered by a higher z-index canvas element", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
const embeddable = API.createElement({
|
||||
type: "embeddable",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 300,
|
||||
height: 180,
|
||||
});
|
||||
const coveringRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 300,
|
||||
height: 180,
|
||||
backgroundColor: "#ff0000",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
API.setElements([embeddable, coveringRectangle]);
|
||||
API.updateElement(embeddable, {
|
||||
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
const centerX = embeddable.x + embeddable.width / 2;
|
||||
const centerY = embeddable.y + embeddable.height / 2;
|
||||
|
||||
mouse.moveTo(centerX, centerY);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.POINTER,
|
||||
);
|
||||
mouse.clickAt(centerX, centerY);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
|
||||
expect(h.state.activeEmbeddable?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't pan in view mode when laser tool is active", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
API.setAppState({ viewModeEnabled: true });
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
|
||||
|
||||
const initialScrollX = h.state.scrollX;
|
||||
const initialScrollY = h.state.scrollY;
|
||||
|
||||
mouse.downAt(100, 100);
|
||||
mouse.moveTo(180, 160);
|
||||
mouse.upAt(180, 160);
|
||||
|
||||
expect(h.state.scrollX).toBe(initialScrollX);
|
||||
expect(h.state.scrollY).toBe(initialScrollY);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
|
||||
});
|
||||
|
||||
it("cleans up remote laser trails when the last collaborator leaves", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
const socketId = "socket-id" as SocketId;
|
||||
const collaborators = new Map<SocketId, Collaborator>([
|
||||
[
|
||||
socketId,
|
||||
{
|
||||
pointer: {
|
||||
x: 10,
|
||||
y: 10,
|
||||
tool: "laser",
|
||||
},
|
||||
button: "down",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const svgLayer = document.querySelector(".SVGLayer svg")!;
|
||||
|
||||
act(() => {
|
||||
h.app.updateScene({ collaborators });
|
||||
});
|
||||
|
||||
expect(svgLayer.querySelectorAll("path")).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
h.app.updateScene({ collaborators: new Map() });
|
||||
});
|
||||
|
||||
expect(svgLayer.querySelectorAll("path")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iframe-like element hit testing outside frame bounds", () => {
|
||||
const h = window.h;
|
||||
const mouse = new Pointer("mouse");
|
||||
const iframeLikeTypes = ["embeddable", "iframe"] as const;
|
||||
|
||||
const addFramedIframeLikeElement = (type: typeof iframeLikeTypes[number]) => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 100,
|
||||
height: 180,
|
||||
});
|
||||
const iframeLikeElement = API.createElement({
|
||||
type,
|
||||
x: 80,
|
||||
y: 40,
|
||||
width: 300,
|
||||
height: 180,
|
||||
frameId: frame.id,
|
||||
});
|
||||
API.setElements([frame, iframeLikeElement]);
|
||||
if (type === "embeddable") {
|
||||
API.updateElement(iframeLikeElement, {
|
||||
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
|
||||
});
|
||||
}
|
||||
|
||||
return { frame, iframeLikeElement };
|
||||
};
|
||||
|
||||
it.each(iframeLikeTypes)(
|
||||
"activates the visible part of a %s outside its frame",
|
||||
async (type) => {
|
||||
await render(<Excalidraw />);
|
||||
const { frame, iframeLikeElement } = addFramedIframeLikeElement(type);
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
const centerX = iframeLikeElement.x + iframeLikeElement.width / 2;
|
||||
const centerY = iframeLikeElement.y + iframeLikeElement.height / 2;
|
||||
expect(centerX).toBeGreaterThan(frame.x + frame.width);
|
||||
|
||||
mouse.moveTo(centerX, centerY);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.POINTER,
|
||||
);
|
||||
mouse.clickAt(centerX, centerY);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.activeEmbeddable?.element.id).toBe(iframeLikeElement.id);
|
||||
expect(h.state.activeEmbeddable?.state).toBe("active");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(iframeLikeTypes)(
|
||||
"drags a %s from its visible part outside its frame",
|
||||
async (type) => {
|
||||
await render(<Excalidraw />);
|
||||
const { frame, iframeLikeElement } = addFramedIframeLikeElement(type);
|
||||
const startX = frame.x + frame.width + 20;
|
||||
const startY = iframeLikeElement.y + iframeLikeElement.height / 2;
|
||||
const initialX = iframeLikeElement.x;
|
||||
const initialY = iframeLikeElement.y;
|
||||
|
||||
mouse.moveTo(startX, startY);
|
||||
mouse.downAt(startX, startY);
|
||||
mouse.moveTo(startX + 30, startY + 20);
|
||||
mouse.upAt(startX + 30, startY + 20);
|
||||
|
||||
const draggedElement = API.getElement(iframeLikeElement);
|
||||
expect(draggedElement.x).toBe(initialX + 30);
|
||||
expect(draggedElement.y).toBe(initialY + 20);
|
||||
},
|
||||
);
|
||||
});
|
||||
2222
packages/excalidraw/tests/lasso.test.tsx
Normal file
383
packages/excalidraw/tests/library.test.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { act, queryByTestId } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
|
||||
|
||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { parseLibraryJSON } from "../data/blob";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { fireEvent, render, waitFor } from "./test-utils";
|
||||
|
||||
import type { LibraryItem, LibraryItems } from "../types";
|
||||
|
||||
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 items inserting", () => {
|
||||
beforeEach(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",
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
const libraryItems: LibraryItems = [
|
||||
{
|
||||
id: "libraryItem_id0",
|
||||
status: "unpublished",
|
||||
elements: [rectangle, text, arrow],
|
||||
created: 0,
|
||||
name: "test",
|
||||
},
|
||||
];
|
||||
|
||||
await render(<Excalidraw initialData={{ libraryItems }} />);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => {
|
||||
return h.app.library.resetLibrary();
|
||||
});
|
||||
});
|
||||
|
||||
it("should regenerate ids but retain bindings on library insert", async () => {
|
||||
const libraryItems = await h.app.library.getLatestLibrary();
|
||||
|
||||
expect(libraryItems.length).toBe(1);
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "string",
|
||||
value: JSON.stringify({
|
||||
itemIds: [libraryItems[0].id],
|
||||
}),
|
||||
type: MIME_TYPES.excalidrawlibIds,
|
||||
},
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
const rectangle = h.elements.find((e) => e.type === "rectangle")!;
|
||||
const text = h.elements.find((e) => e.type === "text")!;
|
||||
const arrow = h.elements.find((e) => e.type === "arrow")!;
|
||||
expect(h.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "rectangle",
|
||||
id: expect.not.stringMatching("rectangle1"),
|
||||
boundElements: expect.arrayContaining([
|
||||
{ type: "text", id: text.id },
|
||||
{ type: "arrow", id: arrow.id },
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
id: expect.not.stringMatching("text1"),
|
||||
containerId: rectangle.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "arrow",
|
||||
id: expect.not.stringMatching("arrow1"),
|
||||
endBinding: expect.objectContaining({
|
||||
elementId: rectangle.id,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("library", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
await act(() => {
|
||||
return h.app.library.resetLibrary();
|
||||
});
|
||||
});
|
||||
|
||||
it("import library via drag&drop", async () => {
|
||||
expect(await h.app.library.getLatestLibrary()).toEqual([]);
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
file: 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: expect.any(String),
|
||||
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([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON(libraryItems),
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON([item1, item1]),
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
[ORIG_ID]: "elem1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(/^elem1$/),
|
||||
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
kind: "string",
|
||||
value: serializeLibraryAsJSON(libraryItems),
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
},
|
||||
]);
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||
});
|
||||
// this has a high flake
|
||||
// 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",
|
||||
)!,
|
||||
);
|
||||
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
|
||||
|
||||
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);
|
||||
const { versionNonce, ...strippedElement } = libraryItems[0]?.elements[0]; // stripped due to mutations
|
||||
expect(latestLibrary[0].elements).toEqual([
|
||||
expect.objectContaining(strippedElement),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeLibraryItemsOnSquareGrid()", () => {
|
||||
it("should distribute items on a grid", async () => {
|
||||
const createLibraryItem = (
|
||||
elements: NonDeleted<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),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
192
packages/excalidraw/tests/move.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
import { KEYS, reseed } from "@excalidraw/common";
|
||||
import { bindBindingElement } from "@excalidraw/element";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import * as InteractiveCanvas from "../renderer/interactiveScene";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { render, fireEvent, act, unmountComponent } from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(
|
||||
InteractiveCanvas,
|
||||
"renderInteractiveScene",
|
||||
);
|
||||
const renderStaticScene = vi.spyOn(StaticScene, "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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
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.mock.calls.length).toMatchInlineSnapshot(`3`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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 arrow = UI.createElement("arrow", { x: 105, y: 50, size: 88 });
|
||||
|
||||
act(() => {
|
||||
// bind line to two rectangles
|
||||
bindBindingElement(
|
||||
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
|
||||
rectA.get() as NonDeleted<ExcalidrawBindableElement>,
|
||||
"orbit",
|
||||
"start",
|
||||
h.app.scene,
|
||||
);
|
||||
bindBindingElement(
|
||||
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
|
||||
rectB.get() as NonDeleted<ExcalidrawBindableElement>,
|
||||
"orbit",
|
||||
"end",
|
||||
h.app.scene,
|
||||
);
|
||||
});
|
||||
|
||||
// select the second rectangle
|
||||
new Pointer("mouse").clickOn(rectB);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`15`);
|
||||
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([[arrow.x, arrow.y]]).toCloselyEqualPoints(
|
||||
[[106.00000000000001, 55.6867741935484]],
|
||||
0,
|
||||
);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0);
|
||||
|
||||
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.mock.calls.length).toMatchInlineSnapshot(`3`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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([[arrow.x, arrow.y]]).toCloselyEqualPoints(
|
||||
[[106, 55.6867741935484]],
|
||||
0,
|
||||
);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0);
|
||||
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`5`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
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);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`4`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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());
|
||||
});
|
||||
});
|
||||
227
packages/excalidraw/tests/multiPointCreate.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { KEYS, reseed } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import * as InteractiveCanvas from "../renderer/interactiveScene";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
unmountComponent,
|
||||
} from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(
|
||||
InteractiveCanvas,
|
||||
"renderInteractiveScene",
|
||||
);
|
||||
const renderStaticScene = vi.spyOn(StaticScene, "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.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
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());
|
||||
});
|
||||
|
||||
it("prevents switching to eraser while creating a multi-point 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 });
|
||||
|
||||
expect(h.state.activeTool.type).toEqual("line");
|
||||
|
||||
// try to switch to eraser
|
||||
fireEvent.keyDown(canvas, {
|
||||
key: KEYS.E,
|
||||
});
|
||||
|
||||
expect(h.state.activeTool.type).toEqual("line");
|
||||
|
||||
// third point should still be added to the line instead of erasing
|
||||
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.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -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="https://plus.excalidraw.com/blog"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Excalidraw blog
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="dropdown-menu-item-base dropdown-menu-item-custom"
|
||||
>
|
||||
<button
|
||||
style="height: 2rem;"
|
||||
>
|
||||
custom menu item
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
aria-label="Open"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="load-button"
|
||||
title="Open"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Save to..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="json-export-button"
|
||||
title="Save to..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Save to...
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Export image..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="image-export-button"
|
||||
title="Export image..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M15 8h.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
|
||||
/>
|
||||
<path
|
||||
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
|
||||
/>
|
||||
<path
|
||||
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
|
||||
/>
|
||||
<path
|
||||
d="M19 16v6"
|
||||
/>
|
||||
<path
|
||||
d="M22 19l-3 3l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Reset the canvas"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="clear-canvas-button"
|
||||
title="Reset the canvas"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Reset the canvas
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-group "
|
||||
>
|
||||
<p
|
||||
class="dropdown-menu-group-title"
|
||||
>
|
||||
Excalidraw links
|
||||
</p>
|
||||
<a
|
||||
aria-label="GitHub"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="GitHub"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M7.5 15.833c-3.583 1.167-3.583-2.083-5-2.5m10 4.167v-2.917c0-.833.083-1.166-.417-1.666 2.334-.25 4.584-1.167 4.584-5a3.833 3.833 0 0 0-1.084-2.667 3.5 3.5 0 0 0-.083-2.667s-.917-.25-2.917 1.084a10.25 10.25 0 0 0-5.166 0C5.417 2.333 4.5 2.583 4.5 2.583a3.5 3.5 0 0 0-.083 2.667 3.833 3.833 0 0 0-1.084 2.667c0 3.833 2.25 4.75 4.584 5-.5.5-.5 1-.417 1.666V17.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
GitHub
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Discord"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://discord.gg/UexuTaE"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Discord"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M7.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM12.5 10.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666ZM6.25 6.25c2.917-.833 4.583-.833 7.5 0M5.833 13.75c2.917.833 5.417.833 8.334 0"
|
||||
/>
|
||||
<path
|
||||
d="M12.917 14.167c0 .833 1.25 2.5 1.666 2.5 1.25 0 2.361-1.39 2.917-2.5.556-1.39.417-4.861-1.25-9.584-1.214-.846-2.5-1.116-3.75-1.25l-.833 2.084M7.083 14.167c0 .833-1.13 2.5-1.526 2.5-1.191 0-2.249-1.39-2.778-2.5-.529-1.39-.397-4.861 1.19-9.584 1.157-.846 2.318-1.116 3.531-1.25l.833 2.084"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Discord
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
aria-label="Twitter"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://twitter.com/excalidraw"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="Twitter"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Twitter
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="toggle-dark-mode"
|
||||
title="Dark mode"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Shift+Alt+D
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="margin-top: 0.5rem;"
|
||||
>
|
||||
<div
|
||||
data-testid="canvas-background-label"
|
||||
style="font-size: .75rem; margin-bottom: .5rem;"
|
||||
>
|
||||
Canvas background
|
||||
</div>
|
||||
<div
|
||||
style="padding: 0px 0.625rem;"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-modal="true"
|
||||
class="color-picker-container"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="color-picker__top-picks"
|
||||
>
|
||||
<button
|
||||
class="color-picker__button active"
|
||||
data-testid="color-top-pick-#ffffff"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="#ffffff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#f8f9fa"
|
||||
style="--swatch-color: #f8f9fa;"
|
||||
title="#f8f9fa"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#f5faff"
|
||||
style="--swatch-color: #f5faff;"
|
||||
title="#f5faff"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#fffce8"
|
||||
style="--swatch-color: #fffce8;"
|
||||
title="#fffce8"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="color-picker__button"
|
||||
data-testid="color-top-pick-#fdf8f6"
|
||||
style="--swatch-color: #fdf8f6;"
|
||||
title="#fdf8f6"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="width: 1px; height: 100%; margin: 0px auto;"
|
||||
/>
|
||||
<button
|
||||
aria-controls="radix-:r0:"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
class="color-picker__button active-color"
|
||||
data-state="closed"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="Show background color picker"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="color-picker__button-outline"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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",
|
||||
},
|
||||
"amIBeingFollowed": false,
|
||||
"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",
|
||||
"currentItemStrokeWidthKey": "medium",
|
||||
"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,
|
||||
"shouldDisconnectFollowModeOnCanvasInteraction": true,
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
"userToFollow": null,
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"viewModeEnabled": false,
|
||||
"zenModeEnabled": false,
|
||||
"zoom": {
|
||||
"value": 1,
|
||||
},
|
||||
}
|
||||
`;
|
||||
149
packages/excalidraw/tests/packages/events.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { resolvablePromise } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw, CaptureUpdateAction } from "../../index";
|
||||
import { API } from "../helpers/api";
|
||||
import { Pointer } from "../helpers/ui";
|
||||
import { render, unmountComponent } from "../test-utils";
|
||||
|
||||
import type { ExcalidrawImperativeAPI } from "../../types";
|
||||
|
||||
describe("event callbacks", () => {
|
||||
const h = window.h;
|
||||
|
||||
let excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
/>,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
});
|
||||
|
||||
it("should resolve editor:mount/editor:initialize when subscribed before mount", async () => {
|
||||
unmountComponent();
|
||||
|
||||
const lifecyclePromise = resolvablePromise<{
|
||||
api: ExcalidrawImperativeAPI;
|
||||
mount: Promise<{
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
container: HTMLDivElement | null;
|
||||
}>;
|
||||
initialize: Promise<ExcalidrawImperativeAPI>;
|
||||
}>();
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => {
|
||||
if (api) {
|
||||
lifecyclePromise.resolve({
|
||||
api,
|
||||
mount: api.onEvent("editor:mount"),
|
||||
initialize: api.onEvent("editor:initialize"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const { api, mount, initialize } = await lifecyclePromise;
|
||||
await expect(mount).resolves.toEqual({
|
||||
excalidrawAPI: api,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
await expect(initialize).resolves.toBe(api);
|
||||
});
|
||||
|
||||
it("should replay editor:mount/editor:initialize to late subscribers", async () => {
|
||||
const onMount = vi.fn();
|
||||
const onInitialize = vi.fn();
|
||||
|
||||
excalidrawAPI.onEvent("editor:mount", onMount);
|
||||
excalidrawAPI.onEvent("editor:initialize", onInitialize);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
expect(onMount).toHaveBeenCalledWith({
|
||||
excalidrawAPI,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
expect(onInitialize).toHaveBeenCalledTimes(1);
|
||||
expect(onInitialize).toHaveBeenCalledWith(excalidrawAPI);
|
||||
|
||||
await expect(excalidrawAPI.onEvent("editor:mount")).resolves.toEqual({
|
||||
excalidrawAPI,
|
||||
container: expect.any(HTMLDivElement),
|
||||
});
|
||||
await expect(excalidrawAPI.onEvent("editor:initialize")).resolves.toBe(
|
||||
excalidrawAPI,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onMount before onInitialize props", async () => {
|
||||
unmountComponent();
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
onMount={({ excalidrawAPI, container }) => {
|
||||
expect(excalidrawAPI).toBeDefined();
|
||||
expect(container).toBeInstanceOf(HTMLDivElement);
|
||||
calls.push("mount");
|
||||
}}
|
||||
onInitialize={() => {
|
||||
calls.push("initialize");
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(calls).toEqual(["mount", "initialize"]);
|
||||
});
|
||||
|
||||
it("should trigger onChange on render", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const origBackgroundColor = h.state.viewBackgroundColor;
|
||||
excalidrawAPI.onChange(onChange);
|
||||
API.updateScene({
|
||||
appState: { viewBackgroundColor: "red" },
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
380
packages/excalidraw/tests/pixelSnap.test.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React from "react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { snapScrollToDevicePixels } from "../renderer/helpers";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { act, GlobalTestState, render, waitFor } from "./test-utils";
|
||||
|
||||
import type { NormalizedZoomValue } from "../types";
|
||||
|
||||
type CanvasEvent = {
|
||||
type: string;
|
||||
transform: [number, number, number, number, number, number];
|
||||
props: { dx: number; dy: number; dWidth: number };
|
||||
};
|
||||
|
||||
/** the static canvas's element blits, in device pixels */
|
||||
const getBlits = () => {
|
||||
const context = GlobalTestState.canvas.getContext("2d") as any;
|
||||
return (context.__getEvents() as CanvasEvent[])
|
||||
.filter((event) => event.type === "drawImage")
|
||||
.map(({ transform: [a, b, c, d, e, f], props }) => ({
|
||||
// the matrix's scale, whatever its rotation
|
||||
scale: Math.hypot(a, b),
|
||||
x: a * props.dx + c * props.dy + e,
|
||||
y: b * props.dx + d * props.dy + f,
|
||||
width: props.dWidth,
|
||||
}));
|
||||
};
|
||||
|
||||
/** re-renders at the given viewport and returns that render's blits */
|
||||
const renderAt = async (zoom: number, scrollX: number, scrollY: number) => {
|
||||
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
|
||||
API.setAppState({
|
||||
zoom: { value: zoom as NormalizedZoomValue },
|
||||
scrollX,
|
||||
scrollY,
|
||||
});
|
||||
return waitFor(() => {
|
||||
// the static scene renders on the next frame
|
||||
const blits = getBlits().filter(
|
||||
(blit) => Math.abs(blit.scale - zoom) < 1e-9,
|
||||
);
|
||||
expect(blits.length).toBeGreaterThan(0);
|
||||
return blits;
|
||||
});
|
||||
};
|
||||
|
||||
const distanceToWholePixel = ({ x, y }: { x: number; y: number }) =>
|
||||
Math.abs(x - Math.round(x)) + Math.abs(y - Math.round(y));
|
||||
|
||||
describe("element pixel snap", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
// jsdom gives the editor no size, which culls every element
|
||||
API.setAppState({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["text", { type: "text" as const, text: "hello" }],
|
||||
["rectangle", { type: "rectangle" as const, width: 120, height: 60 }],
|
||||
])(
|
||||
"blits an unrotated %s on whole device pixels at any zoom",
|
||||
async (_, props) => {
|
||||
API.setElements([API.createElement({ x: 10.37, y: 20.61, ...props })]);
|
||||
for (const zoom of [1, 1.5, 0.73, 2.2]) {
|
||||
const blits = await renderAt(zoom, 3.3, -7.77);
|
||||
for (const blit of blits) {
|
||||
expect(distanceToWholePixel(blit)).toBeLessThan(1e-6);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([Math.PI / 2, (3 * Math.PI) / 2])(
|
||||
"snaps a right-angle rotation (%s rad) without displacing it",
|
||||
async (angle) => {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
x: 110.37,
|
||||
y: 120.61,
|
||||
width: 120,
|
||||
height: 60,
|
||||
angle: angle as any,
|
||||
}),
|
||||
]);
|
||||
const blits = await renderAt(1.5, 3.3, -7.77);
|
||||
for (const blit of blits) {
|
||||
expect(distanceToWholePixel(blit)).toBeLessThan(1e-6);
|
||||
// and it stays where the element is — a formula that divides by the
|
||||
// near-zero diagonal of a rotation sends it far offscreen
|
||||
expect(Math.abs(blit.x)).toBeLessThan(2000);
|
||||
expect(Math.abs(blit.y)).toBeLessThan(2000);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not snap while a zoom gesture keeps the cached bitmaps", async () => {
|
||||
API.setElements([
|
||||
API.createElement({ type: "text", x: 10.37, y: 20.61, text: "hello" }),
|
||||
]);
|
||||
// the bitmaps are resampled during the gesture; rounding would only
|
||||
// make elements twitch
|
||||
await renderAt(1, 0, 0);
|
||||
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
|
||||
API.setAppState({
|
||||
shouldCacheIgnoreZoom: true,
|
||||
zoom: { value: 1.5 as NormalizedZoomValue },
|
||||
scrollX: 3.3,
|
||||
scrollY: -7.77,
|
||||
});
|
||||
const [blit] = await waitFor(() => {
|
||||
const blits = getBlits();
|
||||
expect(blits.length).toBeGreaterThan(0);
|
||||
return blits;
|
||||
});
|
||||
expect(distanceToWholePixel(blit)).toBeGreaterThan(1e-3);
|
||||
API.setAppState({ shouldCacheIgnoreZoom: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
// a plain fractional offset, and one that is exactly half a device pixel
|
||||
// at 150% (51 scene units → 76.5), where the float noise of a drag used
|
||||
// to flip the rounding between two neighbors
|
||||
["fractional", { box: [100.3, 100.3], label: [150.7, 140.2] }],
|
||||
["half-pixel tie", { box: [10, 10], label: [61, 30] }],
|
||||
])(
|
||||
"keeps a label at a constant device offset from its container while dragged (%s)",
|
||||
async (_, { box, label }) => {
|
||||
const offsets = new Set<string>();
|
||||
for (const drag of [0, 0.3, 0.4, 0.7, 1.4]) {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "box",
|
||||
x: box[0] + drag,
|
||||
y: box[1] + drag,
|
||||
width: 200,
|
||||
height: 100,
|
||||
boundElements: [{ type: "text", id: "label" }],
|
||||
}),
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
x: label[0] + drag,
|
||||
y: label[1] + drag,
|
||||
text: "hi",
|
||||
fontSize: 20,
|
||||
containerId: "box",
|
||||
}),
|
||||
]);
|
||||
const blits = await renderAt(1.5, 3.3, -7.77);
|
||||
expect(blits).toHaveLength(2);
|
||||
for (const blit of blits) {
|
||||
expect(distanceToWholePixel(blit)).toBeLessThan(1e-6);
|
||||
}
|
||||
// the container's bitmap is the wider one
|
||||
const [labelBlit, boxBlit] = [...blits].sort(
|
||||
(p, q) => p.width - q.width,
|
||||
);
|
||||
offsets.add(`${labelBlit.x - boxBlit.x},${labelBlit.y - boxBlit.y}`);
|
||||
}
|
||||
expect(offsets.size).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves rotated elements alone", async () => {
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "text",
|
||||
x: 10.37,
|
||||
y: 20.61,
|
||||
text: "hello",
|
||||
angle: 0.4 as any,
|
||||
}),
|
||||
]);
|
||||
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
|
||||
API.setAppState({
|
||||
zoom: { value: 1.5 as NormalizedZoomValue },
|
||||
scrollX: 3.3,
|
||||
scrollY: -7.77,
|
||||
});
|
||||
// a rotated blit is resampled regardless; at these coordinates its
|
||||
// unsnapped offset is not a whole pixel
|
||||
const [blit] = await waitFor(() => {
|
||||
const blits = getBlits();
|
||||
expect(blits.length).toBeGreaterThan(0);
|
||||
return blits;
|
||||
});
|
||||
expect(distanceToWholePixel(blit)).toBeGreaterThan(1e-3);
|
||||
});
|
||||
|
||||
it.each([0, Math.PI / 2, 0.4])(
|
||||
"places overridden bitmaps like translated document geometry (%s rad)",
|
||||
async (angle) => {
|
||||
const ownerWindow = GlobalTestState.canvas.ownerDocument.defaultView!;
|
||||
const pixelRatioDescriptor = Object.getOwnPropertyDescriptor(
|
||||
ownerWindow,
|
||||
"devicePixelRatio",
|
||||
)!;
|
||||
try {
|
||||
for (const devicePixelRatio of [1, 2]) {
|
||||
Object.defineProperty(ownerWindow, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: devicePixelRatio,
|
||||
});
|
||||
for (const zoom of [1, 1.5, 0.73]) {
|
||||
const props = {
|
||||
type: "rectangle" as const,
|
||||
x: 110.37,
|
||||
y: 140.61,
|
||||
width: 120,
|
||||
height: 60,
|
||||
angle: angle as any,
|
||||
};
|
||||
const element = API.createElement(props);
|
||||
API.setElements([element]);
|
||||
await renderAt(zoom, 3.3, -7.77);
|
||||
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
|
||||
act(() =>
|
||||
window.h.app.api.setElementRenderOverrides(
|
||||
new Map([[element.id, { offset: { x: 13.37, y: -7.61 } }]]),
|
||||
),
|
||||
);
|
||||
const [overridden] = await waitFor(() => {
|
||||
const blits = getBlits();
|
||||
expect(blits).toHaveLength(1);
|
||||
return blits;
|
||||
});
|
||||
act(() => window.h.app.api.setElementRenderOverrides(null));
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
...props,
|
||||
x: props.x + 13.37,
|
||||
y: props.y - 7.61,
|
||||
}),
|
||||
]);
|
||||
const [translated] = await renderAt(zoom, 3.3, -7.77);
|
||||
expect(overridden.x).toBeCloseTo(translated.x, 6);
|
||||
expect(overridden.y).toBeCloseTo(translated.y, 6);
|
||||
expect(overridden.scale).toBeCloseTo(translated.scale, 6);
|
||||
if (angle !== 0.4) {
|
||||
expect(distanceToWholePixel(overridden)).toBeLessThan(1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(
|
||||
ownerWindow,
|
||||
"devicePixelRatio",
|
||||
pixelRatioDescriptor,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps bound-label snapping stable through inherited fractional offsets", async () => {
|
||||
const box = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "box",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 200,
|
||||
height: 100,
|
||||
boundElements: [{ type: "text", id: "label" }],
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
x: 61,
|
||||
y: 30,
|
||||
text: "hi",
|
||||
fontSize: 20,
|
||||
containerId: box.id,
|
||||
});
|
||||
API.setElements([box, label]);
|
||||
await renderAt(1.5, 3.3, -7.77);
|
||||
const offsets = new Set<string>();
|
||||
for (const translation of [0, 0.3, 0.4, 0.7, 1.4]) {
|
||||
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
|
||||
act(() =>
|
||||
window.h.app.api.setElementRenderOverrides(
|
||||
new Map([[box.id, { offset: { x: translation, y: translation } }]]),
|
||||
),
|
||||
);
|
||||
const blits = await waitFor(() => {
|
||||
const blits = getBlits();
|
||||
expect(blits).toHaveLength(2);
|
||||
return blits;
|
||||
});
|
||||
const [labelBlit, boxBlit] = [...blits].sort((p, q) => p.width - q.width);
|
||||
offsets.add(`${labelBlit.x - boxBlit.x},${labelBlit.y - boxBlit.y}`);
|
||||
}
|
||||
expect(offsets.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll pixel snap", () => {
|
||||
it("rounds the scroll to whole device pixels and is identity when it already is", () => {
|
||||
const zoom = { value: 1.5 as NormalizedZoomValue };
|
||||
const snapped = snapScrollToDevicePixels(
|
||||
{ scrollX: 3.3, scrollY: -7.77, zoom },
|
||||
2,
|
||||
);
|
||||
// 3.3 × 1.5 × 2 = 9.9 → 10; -7.77 × 3 = -23.31 → -23
|
||||
expect(snapped.scrollX * 3).toBeCloseTo(10, 9);
|
||||
expect(snapped.scrollY * 3).toBeCloseTo(-23, 9);
|
||||
|
||||
const whole = { scrollX: 4, scrollY: -6, zoom };
|
||||
expect(snapScrollToDevicePixels(whole, 2)).toBe(whole);
|
||||
});
|
||||
|
||||
it("blits a shape at integer coordinates on whole device pixels at any fractional scroll", async () => {
|
||||
await render(<Excalidraw />);
|
||||
API.setAppState({ width: 800, height: 600 });
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 100,
|
||||
}),
|
||||
]);
|
||||
// zooms at which the element canvas's padding is itself whole device
|
||||
// pixels, so the only fractional term left is the scroll
|
||||
for (const zoom of [1, 1.5]) {
|
||||
const [atFirst] = await renderAt(zoom, 3.3, -7.77);
|
||||
expect(distanceToWholePixel(atFirst)).toBeLessThan(1e-6);
|
||||
// a tenth of a device pixel further lands on the same pixels
|
||||
const [atSecond] = await renderAt(zoom, 3.3 + 0.1 / zoom, -7.77);
|
||||
expect(atSecond).toEqual(atFirst);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid pixel snap", () => {
|
||||
/** the static canvas's path starts, in device pixels */
|
||||
const getPathStarts = () => {
|
||||
const context = GlobalTestState.canvas.getContext("2d") as any;
|
||||
return (context.__getEvents() as CanvasEvent[])
|
||||
.filter((event) => event.type === "moveTo")
|
||||
.map(({ transform: [a, , , d, e, f], props }) => ({
|
||||
scale: a,
|
||||
x: a * (props as any).x + e,
|
||||
y: d * (props as any).y + f,
|
||||
}));
|
||||
};
|
||||
const fraction = (value: number) => value - Math.floor(value);
|
||||
|
||||
it("centers one-pixel grid lines on device pixels at any zoom", async () => {
|
||||
await render(<Excalidraw />);
|
||||
API.setAppState({ width: 800, height: 600, gridModeEnabled: true });
|
||||
// zooms at which a grid line is at least a device pixel wide — 1.9
|
||||
// included, where `(1 / zoom) × zoom` evaluates just under 1
|
||||
for (const zoom of [1, 1.5, 1.9, 2.2]) {
|
||||
(GlobalTestState.canvas.getContext("2d") as any).__clearEvents();
|
||||
API.setAppState({
|
||||
zoom: { value: zoom as NormalizedZoomValue },
|
||||
scrollX: 3.3,
|
||||
scrollY: -7.77,
|
||||
});
|
||||
const lines = await waitFor(() => {
|
||||
const starts = getPathStarts().filter(
|
||||
(start) => Math.abs(start.scale - zoom) < 1e-9,
|
||||
);
|
||||
expect(starts.length).toBeGreaterThan(0);
|
||||
return starts;
|
||||
});
|
||||
for (const line of lines) {
|
||||
// a vertical line has its x on the half pixel, a horizontal its y
|
||||
const onHalfPixel =
|
||||
Math.abs(fraction(line.x) - 0.5) < 1e-6 ||
|
||||
Math.abs(fraction(line.y) - 0.5) < 1e-6;
|
||||
expect(onHalfPixel).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
37
packages/excalidraw/tests/queries/dom.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
stripIgnoredNodesFromErrorMessage,
|
||||
trimErrorStack,
|
||||
} from "../test-utils";
|
||||
|
||||
export const TEXT_EDITOR_SELECTOR =
|
||||
".excalidraw-textEditorContainer > textarea";
|
||||
|
||||
export const getTextEditor = async ({
|
||||
selector = TEXT_EDITOR_SELECTOR,
|
||||
waitForEditor = true,
|
||||
}: { selector?: string; waitForEditor?: boolean } = {}) => {
|
||||
const error = trimErrorStack(new Error());
|
||||
try {
|
||||
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
|
||||
if (waitForEditor) {
|
||||
await waitFor(() => expect(query()).not.toBe(null));
|
||||
return query();
|
||||
}
|
||||
return query();
|
||||
} catch (err: any) {
|
||||
stripIgnoredNodesFromErrorMessage(err);
|
||||
err.stack = error.stack;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTextEditor = (
|
||||
editor: HTMLTextAreaElement | HTMLInputElement,
|
||||
value: string,
|
||||
) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
fireEvent.input(editor);
|
||||
};
|
||||
33
packages/excalidraw/tests/queries/toolQueries.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { queries, buildQueries } from "@testing-library/react";
|
||||
|
||||
import { TOOL_TYPE } from "@excalidraw/common";
|
||||
|
||||
import type { ToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => {
|
||||
const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool];
|
||||
return (
|
||||
queries
|
||||
.getAllByTestId(container, `toolbar-${toolTitle}`)
|
||||
// an open ToolPopover renders options with the same testids as the
|
||||
// toolbar buttons — "by tool name" means the toolbar-level button
|
||||
.filter((el) => !el.closest(".tool-popover-content"))
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
1200
packages/excalidraw/tests/regressionTests.test.tsx
Normal file
1282
packages/excalidraw/tests/renderOverrides.test.tsx
Normal file
85
packages/excalidraw/tests/rotate.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { reseed } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { UI } from "./helpers/ui";
|
||||
import { render, unmountComponent } from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
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: 85,
|
||||
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(132.491, 1);
|
||||
expect(arrow.height).toBeCloseTo(82.267, 1);
|
||||
});
|
||||
|
||||
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", {
|
||||
x: -10,
|
||||
y: 80,
|
||||
width: 50,
|
||||
height: 60,
|
||||
});
|
||||
const text = UI.createElement("text", {
|
||||
position: 220,
|
||||
});
|
||||
await UI.editText(text, "test");
|
||||
const textArrow = UI.createElement("arrow", {
|
||||
x: 360,
|
||||
y: 300,
|
||||
width: -140,
|
||||
height: -60,
|
||||
});
|
||||
|
||||
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(-10);
|
||||
expect(ellipseArrow.y).toEqual(80);
|
||||
expect(ellipseArrow.points[0]).toEqual([0, 0]);
|
||||
expect(ellipseArrow.points[1][0]).toBeCloseTo(66.317, 1);
|
||||
expect(ellipseArrow.points[1][1]).toBeCloseTo(144.38, 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(-95.4635969899922, 0);
|
||||
expect(textArrow.points[1][1]).toBeCloseTo(-126.8785027399889, 0);
|
||||
});
|
||||
699
packages/excalidraw/tests/scene/export.test.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
|
||||
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
BOUND_TEXT_PADDING,
|
||||
FONT_FAMILY,
|
||||
FRAME_STYLE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawTextElement,
|
||||
FractionalIndex,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { prepareElementsForExport } from "../../data";
|
||||
import * as exportUtils from "../../scene/export";
|
||||
import {
|
||||
diamondFixture,
|
||||
ellipseFixture,
|
||||
rectangleWithLinkFixture,
|
||||
textFixture,
|
||||
} from "../fixtures/elementFixture";
|
||||
import { API } from "../helpers/api";
|
||||
|
||||
describe("exportToSvg", () => {
|
||||
const ELEMENT_HEIGHT = 100;
|
||||
const ELEMENT_WIDTH = 100;
|
||||
const ELEMENTS = [
|
||||
{
|
||||
...diamondFixture,
|
||||
height: ELEMENT_HEIGHT,
|
||||
width: ELEMENT_WIDTH,
|
||||
index: "a0",
|
||||
},
|
||||
{
|
||||
...ellipseFixture,
|
||||
height: ELEMENT_HEIGHT,
|
||||
width: ELEMENT_WIDTH,
|
||||
index: "a1",
|
||||
},
|
||||
{
|
||||
...textFixture,
|
||||
height: ELEMENT_HEIGHT,
|
||||
width: ELEMENT_WIDTH,
|
||||
index: "a2",
|
||||
},
|
||||
{
|
||||
...textFixture,
|
||||
fontFamily: FONT_FAMILY.Nunito, // test embedding external font
|
||||
height: ELEMENT_HEIGHT,
|
||||
width: ELEMENT_WIDTH,
|
||||
index: "a3",
|
||||
},
|
||||
] 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 a CJK font", async () => {
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[
|
||||
...ELEMENTS,
|
||||
{
|
||||
...textFixture,
|
||||
height: ELEMENT_HEIGHT,
|
||||
width: ELEMENT_WIDTH,
|
||||
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
|
||||
originalText:
|
||||
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
|
||||
index: "a4" as FractionalIndex,
|
||||
} as NonDeleted<ExcalidrawTextElement>,
|
||||
],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(svgElement).toMatchSnapshot();
|
||||
// extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers)
|
||||
}, 30_000);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const textElements = svgElement.querySelectorAll("text");
|
||||
expect(textElements.length).toBeGreaterThan(0);
|
||||
|
||||
textElements.forEach((textEl) => {
|
||||
// fill color should be inverted in dark mode
|
||||
expect(textEl.getAttribute("fill")).toBe(
|
||||
applyDarkModeFilter(textFixture.strokeColor),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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] as NonDeletedExcalidrawElement[],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// #11439: a perfectly horizontal/vertical arrow has a zero-size bounding box.
|
||||
// The bound-text "gap" mask must use userSpaceOnUse units, otherwise its
|
||||
// objectBoundingBox region collapses to zero area and the whole arrow line
|
||||
// disappears from the SVG export (only the label remains).
|
||||
it("keeps a horizontal arrow with a bound label visible (#11439)", async () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow-11439",
|
||||
width: 200,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(200, 0)],
|
||||
boundElements: [{ type: "text", id: "label-11439" }],
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label-11439",
|
||||
text: "label",
|
||||
width: 50,
|
||||
height: 20,
|
||||
containerId: "arrow-11439",
|
||||
});
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[arrow, label] as NonDeletedExcalidrawElement[],
|
||||
DEFAULT_OPTIONS,
|
||||
null,
|
||||
);
|
||||
|
||||
const mask = svgElement.querySelector("mask");
|
||||
expect(mask).not.toBeNull();
|
||||
expect(mask?.getAttribute("maskUnits")).toBe("userSpaceOnUse");
|
||||
// a degenerate (objectBoundingBox) region would be zero-area here
|
||||
expect(Number(mask?.getAttribute("width"))).toBeGreaterThan(0);
|
||||
expect(Number(mask?.getAttribute("height"))).toBeGreaterThan(0);
|
||||
|
||||
// the masked arrow group still renders its line (not clipped away)
|
||||
expect(svgElement.querySelector("g[mask] path")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cuts the same padded hole around an arrow label as the canvas", async () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow-hole",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(200, 0)],
|
||||
boundElements: [{ type: "text", id: "label-hole" }],
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label-hole",
|
||||
text: "label",
|
||||
width: 50,
|
||||
height: 20,
|
||||
containerId: "arrow-hole",
|
||||
});
|
||||
|
||||
const svgElement = await exportUtils.exportToSvg(
|
||||
[arrow, label] as NonDeletedExcalidrawElement[],
|
||||
{ ...DEFAULT_OPTIONS, exportPadding: 0 },
|
||||
null,
|
||||
);
|
||||
|
||||
// the mask's first rect is the visible region, sized to the arrow plus
|
||||
// its offset within the export; the second is the hole cut for the label
|
||||
const [visible, hole] = Array.from(
|
||||
svgElement.querySelectorAll("mask rect"),
|
||||
);
|
||||
expect(hole).toBeDefined();
|
||||
const offsetX = Number(visible.getAttribute("width")) - 100 - arrow.width;
|
||||
const offsetY = Number(visible.getAttribute("height")) - 100 - arrow.height;
|
||||
|
||||
// the label sits centered on the arrow's midpoint; the hole is its box
|
||||
// grown by the padding on every side, in the mask's (arrow-relative)
|
||||
// coordinates
|
||||
const labelLeft = arrow.x + arrow.width / 2 - label.width / 2;
|
||||
const labelTop = arrow.y - label.height / 2;
|
||||
expect(Number(hole.getAttribute("x"))).toBe(
|
||||
offsetX + labelLeft - arrow.x - BOUND_TEXT_PADDING,
|
||||
);
|
||||
expect(Number(hole.getAttribute("y"))).toBe(
|
||||
offsetY + labelTop - arrow.y - BOUND_TEXT_PADDING,
|
||||
);
|
||||
expect(Number(hole.getAttribute("width"))).toBe(
|
||||
label.width + BOUND_TEXT_PADDING * 2,
|
||||
);
|
||||
expect(Number(hole.getAttribute("height"))).toBe(
|
||||
label.height + BOUND_TEXT_PADDING * 2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportToCanvas", () => {
|
||||
it("does not paint over elements beneath a labeled arrow's label (#11591)", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect-11591",
|
||||
x: 0,
|
||||
y: -50,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#ffc9c9",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow-11591",
|
||||
x: -50,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(300, 0)],
|
||||
boundElements: [{ type: "text", id: "label-11591" }],
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label-11591",
|
||||
text: "label",
|
||||
x: 75,
|
||||
y: -10,
|
||||
width: 50,
|
||||
height: 20,
|
||||
containerId: "arrow-11591",
|
||||
});
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements: [rectangle, arrow, label],
|
||||
files: null,
|
||||
appState: { exportBackground: true, viewBackgroundColor: "#ffffff" },
|
||||
});
|
||||
|
||||
const context = canvas.getContext("2d") as any;
|
||||
const events = context.__getEvents();
|
||||
|
||||
// the label "hole" must be cut out of the arrow's own strokes via an
|
||||
// even-odd clip — erasing (clearRect) or painting the background color
|
||||
// (fillRect) over the hole rect would destroy elements rendered beneath
|
||||
// the arrow
|
||||
expect(
|
||||
events.some(
|
||||
(event: any) =>
|
||||
event.type === "clip" && event.props.fillRule === "evenodd",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const holeWidth = label.width + BOUND_TEXT_PADDING * 2;
|
||||
const holeHeight = label.height + BOUND_TEXT_PADDING * 2;
|
||||
expect(
|
||||
events.filter(
|
||||
(event: any) =>
|
||||
(event.type === "fillRect" || event.type === "clearRect") &&
|
||||
event.props.width === holeWidth &&
|
||||
event.props.height === holeHeight,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not export frame-overlapping elements belonging to different frame", 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: 150,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 50,
|
||||
frameId: frame1.id,
|
||||
});
|
||||
const frame2Child = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 150,
|
||||
height: 100,
|
||||
x: 50,
|
||||
y: 0,
|
||||
frameId: frame2.id,
|
||||
});
|
||||
|
||||
// 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],
|
||||
{
|
||||
selectedElementIds: { [frame1.id]: true },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: exportedElements,
|
||||
files: null,
|
||||
exportPadding: 0,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
// frame shouldn't be exported
|
||||
expect(svg.querySelector(`[data-id="${frame1.id}"]`)).toBeNull();
|
||||
// frame1 child should be epxorted
|
||||
expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
|
||||
// frame2 child should not be exported even if it physically overlaps with
|
||||
// frame1
|
||||
expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).toBeNull();
|
||||
|
||||
expect(svg.getAttribute("width")).toBe(frame1.width.toString());
|
||||
expect(svg.getAttribute("height")).toBe(frame1.height.toString());
|
||||
});
|
||||
});
|
||||
});
|
||||
168
packages/excalidraw/tests/scroll.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from "react";
|
||||
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import {
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
|
||||
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("initialState.viewport on init takes precedence over scrollToContent", async () => {
|
||||
mockBoundingClientRect();
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
width: 100,
|
||||
height: 60,
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "B",
|
||||
x: 1000,
|
||||
y: 500,
|
||||
width: 200,
|
||||
height: 100,
|
||||
}),
|
||||
],
|
||||
scrollToContent: true,
|
||||
}}
|
||||
initialState={{
|
||||
viewport: {
|
||||
target: "B",
|
||||
fit: "scale-down",
|
||||
lock: { scroll: true, zoom: true, overscroll: 15 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.width).toBe(200);
|
||||
expect(h.state.height).toBe(100);
|
||||
expect(h.state.scrollX).toBe(-1000);
|
||||
expect(h.state.scrollY).toBe(-500);
|
||||
expect(h.state.scrollConstraints).toMatchObject({
|
||||
x: 1000,
|
||||
y: 500,
|
||||
width: 200,
|
||||
height: 100,
|
||||
lockScroll: true,
|
||||
lockZoom: true,
|
||||
overscroll: 15,
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
API.setAppState({ zoom: { value: (zoom * 1.1) as typeof zoom } });
|
||||
scrollTest();
|
||||
// Assert we scroll properly when zoomed out
|
||||
API.setAppState({ zoom: { value: (zoom * 0.9) as typeof zoom } });
|
||||
scrollTest();
|
||||
// Assert we scroll properly with normal zoom
|
||||
API.setAppState({ zoom: { value: zoom } });
|
||||
scrollTest();
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
});
|
||||
1326
packages/excalidraw/tests/scrollConstraints.test.tsx
Normal file
197
packages/excalidraw/tests/search.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
KEYS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { updateTextEditor } from "./queries/dom";
|
||||
import { act, render, waitFor } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const querySearchInput = async () => {
|
||||
const input =
|
||||
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
)!;
|
||||
await waitFor(() => expect(input).not.toBeNull());
|
||||
return input;
|
||||
};
|
||||
|
||||
describe("search", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally />);
|
||||
API.setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle search on cmd+f", async () => {
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
});
|
||||
|
||||
it("should refocus search input with cmd+f when search sidebar is still open", async () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
|
||||
const searchInput =
|
||||
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
searchInput?.blur();
|
||||
});
|
||||
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(searchInput?.matches(":focus")).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(searchInput?.matches(":focus")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match text and cycle through matches on Enter", async () => {
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
API.setElements([
|
||||
API.createElement({ type: "text", text: "test one" }),
|
||||
API.createElement({ type: "text", text: "test two" }),
|
||||
]);
|
||||
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
|
||||
updateTextEditor(searchInput, "test");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches?.matches.length).toBe(2);
|
||||
expect(h.app.state.searchMatches?.matches[0].focus).toBe(true);
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER, searchInput);
|
||||
expect(h.app.state.searchMatches?.matches[0].focus).toBe(false);
|
||||
expect(h.app.state.searchMatches?.matches[1].focus).toBe(true);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER, searchInput);
|
||||
expect(h.app.state.searchMatches?.matches[0].focus).toBe(true);
|
||||
expect(h.app.state.searchMatches?.matches[1].focus).toBe(false);
|
||||
});
|
||||
|
||||
it("should match text split across multiple lines", async () => {
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "text",
|
||||
text: "",
|
||||
}),
|
||||
]);
|
||||
|
||||
API.updateElement(h.elements[0] as ExcalidrawTextElement, {
|
||||
text: "t\ne\ns\nt \nt\ne\nx\nt \ns\np\nli\nt \ni\nn\nt\no\nm\nu\nlt\ni\np\nl\ne \nli\nn\ne\ns",
|
||||
originalText: "test text split into multiple lines",
|
||||
});
|
||||
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
|
||||
updateTextEditor(searchInput, "test");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches?.matches.length).toBe(1);
|
||||
expect(h.app.state.searchMatches?.matches[0]?.matchedLines?.length).toBe(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
updateTextEditor(searchInput, "ext spli");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches?.matches.length).toBe(1);
|
||||
expect(h.app.state.searchMatches?.matches[0]?.matchedLines?.length).toBe(
|
||||
6,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should match frame names", async () => {
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "frame",
|
||||
}),
|
||||
]);
|
||||
|
||||
API.updateElement(h.elements[0] as ExcalidrawFrameLikeElement, {
|
||||
name: "Frame: name test for frame, yes, frame!",
|
||||
});
|
||||
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
|
||||
updateTextEditor(searchInput, "frame");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches?.matches.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
1464
packages/excalidraw/tests/selection.test.tsx
Normal file
34
packages/excalidraw/tests/shortcuts.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1177
packages/excalidraw/tests/stickyNotes.test.tsx
Normal file
493
packages/excalidraw/tests/test-utils.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import "pepjs";
|
||||
import { act } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
queries,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import ansi from "ansicolor";
|
||||
|
||||
import { ORIG_ID, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { getSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { AllPossibleKeys } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
||||
|
||||
import { Pointer, UI } from "./helpers/ui";
|
||||
import * as toolQueries from "./queries/toolQueries";
|
||||
|
||||
import type { History } from "../history";
|
||||
|
||||
import type { RenderResult, RenderOptions } from "@testing-library/react";
|
||||
|
||||
import type { ImportedDataState } from "../data/types";
|
||||
|
||||
export { cleanup as unmountComponent };
|
||||
|
||||
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) => {
|
||||
// when tests reuse Pointer instances let's reset the last
|
||||
// pointer poisitions so there's no leak between tests
|
||||
Pointer.resetAll();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// hack-awaiting app.initialScene() which solves some test race conditions
|
||||
// (later we may switch this with proper event listener)
|
||||
if (window.h.state.isLoading) {
|
||||
throw new Error("still loading");
|
||||
}
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
) => {
|
||||
const { h } = window;
|
||||
|
||||
mockBoundingClientRect(dimensions);
|
||||
act(() => {
|
||||
h.app.refreshEditorInterface();
|
||||
h.app.refresh();
|
||||
});
|
||||
|
||||
await cb();
|
||||
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
act(() => {
|
||||
h.app.refreshEditorInterface();
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Serializer for IEE754 float pointing numbers to avoid random failures due to tiny precision differences
|
||||
*/
|
||||
expect.addSnapshotSerializer({
|
||||
serialize(val, config, indentation, depth, refs, printer) {
|
||||
return printer(val.toFixed(5), config, indentation, depth, refs);
|
||||
},
|
||||
test(val) {
|
||||
return (
|
||||
typeof val === "number" &&
|
||||
Number.isFinite(val) &&
|
||||
!Number.isNaN(val) &&
|
||||
!Number.isInteger(val)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const getCloneByOrigId = <T extends boolean = false>(
|
||||
origId: ExcalidrawElement["id"],
|
||||
returnNullIfNotExists: T = false as T,
|
||||
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
|
||||
const clonedElement = window.h.elements?.find(
|
||||
(el) => (el as any)[ORIG_ID] === origId,
|
||||
);
|
||||
if (clonedElement) {
|
||||
return clonedElement;
|
||||
}
|
||||
if (returnNullIfNotExists !== true) {
|
||||
throw new Error(`cloned element not found for origId: ${origId}`);
|
||||
}
|
||||
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assertion helper that strips the actual elements of extra attributes
|
||||
* so that diffs are easier to read in case of failure.
|
||||
*
|
||||
* Asserts element order as well, and selected element ids
|
||||
* (when `selected: true` set for given element).
|
||||
*
|
||||
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
|
||||
* If you need to refer to cloned element properties, you can use
|
||||
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
|
||||
*/
|
||||
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
|
||||
actualElements: readonly ExcalidrawElement[],
|
||||
/** array order matters */
|
||||
expectedElements: (Partial<Record<T, any>> & {
|
||||
/** meta, will be stripped for element attribute checks */
|
||||
selected?: true;
|
||||
} & (
|
||||
| {
|
||||
id: ExcalidrawElement["id"];
|
||||
}
|
||||
| { [ORIG_ID]?: string }
|
||||
))[],
|
||||
) => {
|
||||
const h = window.h;
|
||||
|
||||
const expectedElementsWithIds: (typeof expectedElements[number] & {
|
||||
id: ExcalidrawElement["id"];
|
||||
})[] = expectedElements.map((el) => {
|
||||
if ("id" in el) {
|
||||
return el;
|
||||
}
|
||||
const actualElement = actualElements.find(
|
||||
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
|
||||
);
|
||||
if (actualElement) {
|
||||
return { ...el, id: actualElement.id };
|
||||
}
|
||||
return {
|
||||
...el,
|
||||
id: "UNKNOWN_ID",
|
||||
};
|
||||
});
|
||||
|
||||
const map_expectedElements = arrayToMap(expectedElementsWithIds);
|
||||
|
||||
const selectedElementIds = expectedElementsWithIds.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, el) => {
|
||||
if (el.selected) {
|
||||
acc[el.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const mappedActualElements = actualElements.map((el) => {
|
||||
const expectedElement = map_expectedElements.get(el.id);
|
||||
if (expectedElement) {
|
||||
const pickedAttrs: Record<string, any> = {};
|
||||
|
||||
for (const key of Object.keys(expectedElement)) {
|
||||
if (key === "selected") {
|
||||
delete expectedElement.selected;
|
||||
continue;
|
||||
}
|
||||
pickedAttrs[key] = (el as any)[key];
|
||||
}
|
||||
|
||||
if (ORIG_ID in expectedElement) {
|
||||
// @ts-ignore
|
||||
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
|
||||
}
|
||||
|
||||
return pickedAttrs;
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
try {
|
||||
// testing order separately for even easier diffs
|
||||
expect(actualElements.map((x) => x.id)).toEqual(
|
||||
expectedElementsWithIds.map((x) => x.id),
|
||||
);
|
||||
} catch (err: any) {
|
||||
let errStr = "\n\nmismatched element order\n\n";
|
||||
|
||||
errStr += `actual: ${ansi.lightGray(
|
||||
`[${err.actual
|
||||
.map((id: string, index: number) => {
|
||||
const act = actualElements[index];
|
||||
|
||||
return `${
|
||||
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
|
||||
} (${act.type.slice(0, 4)}${
|
||||
ORIG_ID in act ? ` ↳ ${(act as any)[ORIG_ID]}` : ""
|
||||
})`;
|
||||
})
|
||||
.join(", ")}]`,
|
||||
)}\n${ansi.lightGray(
|
||||
`expected: [${err.expected
|
||||
.map((exp: string, index: number) => {
|
||||
const expEl = actualElements.find((el) => el.id === exp);
|
||||
const origEl =
|
||||
expEl &&
|
||||
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
|
||||
return expEl
|
||||
? `${
|
||||
exp === err.actual[index]
|
||||
? ansi.green(expEl.id)
|
||||
: ansi.red(expEl.id)
|
||||
} (${expEl.type.slice(0, 4)}${origEl ? ` ↳ ${origEl.id}` : ""})`
|
||||
: exp;
|
||||
})
|
||||
.join(", ")}]\n`,
|
||||
)}`;
|
||||
|
||||
throw trimErrorStack(new Error(errStr), 1);
|
||||
}
|
||||
|
||||
expect(mappedActualElements).toEqual(
|
||||
expect.arrayContaining(expectedElementsWithIds),
|
||||
);
|
||||
|
||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||
};
|
||||
|
||||
const stripProps = (
|
||||
deltas: Record<string, { deleted: any; inserted: any }>,
|
||||
props: string[],
|
||||
) =>
|
||||
Object.entries(deltas).reduce((acc, curr) => {
|
||||
const { inserted, deleted, ...rest } = curr[1];
|
||||
|
||||
for (const prop of props) {
|
||||
delete inserted[prop];
|
||||
delete deleted[prop];
|
||||
}
|
||||
|
||||
acc[curr[0]] = {
|
||||
inserted,
|
||||
deleted,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
export const checkpointHistory = (history: History, name: string) => {
|
||||
expect(
|
||||
history.undoStack.map((x) => ({
|
||||
...x,
|
||||
elements: {
|
||||
...x.elements,
|
||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] undo stack`);
|
||||
|
||||
expect(
|
||||
history.redoStack.map((x) => ({
|
||||
...x,
|
||||
elements: {
|
||||
...x.elements,
|
||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] redo stack`);
|
||||
};
|
||||
|
||||
/**
|
||||
* removes one or more leading stack trace lines (leading to files) from the
|
||||
* error stack trace
|
||||
*/
|
||||
export const trimErrorStack = (error: Error, range = 1) => {
|
||||
const stack = error.stack?.split("\n");
|
||||
if (stack) {
|
||||
stack.splice(1, range);
|
||||
error.stack = stack.join("\n");
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
export const stripIgnoredNodesFromErrorMessage = (error: Error) => {
|
||||
error.message = error.message.replace(/\s+Ignored nodes:[\s\S]+/, "");
|
||||
return error;
|
||||
};
|
||||
388
packages/excalidraw/tests/tool.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
resolvablePromise,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { findShapeByKey } from "../components/Tools";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Pointer } from "./helpers/ui";
|
||||
import { act, fireEvent, GlobalTestState, render } from "./test-utils";
|
||||
|
||||
import type { AppClassProperties, ExcalidrawImperativeAPI } from "../types";
|
||||
|
||||
describe("setActiveTool()", () => {
|
||||
const h = window.h;
|
||||
|
||||
let excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(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");
|
||||
act(() => {
|
||||
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");
|
||||
act(() => {
|
||||
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");
|
||||
act(() => {
|
||||
excalidrawAPI.setActiveTool({ type: "custom", customType: "comment" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("custom");
|
||||
expect(h.state.activeTool.customType).toBe("comment");
|
||||
});
|
||||
});
|
||||
describe("findShapeByKey()", () => {
|
||||
const appWithPreferredTool = (
|
||||
preferredSelectionTool: "selection" | "lasso",
|
||||
) =>
|
||||
({
|
||||
state: {
|
||||
preferredSelectionTool: {
|
||||
type: preferredSelectionTool,
|
||||
},
|
||||
},
|
||||
} as AppClassProperties);
|
||||
|
||||
it("selection shortcuts activate selection when it's preferred", () => {
|
||||
const app = appWithPreferredTool("selection");
|
||||
|
||||
expect(findShapeByKey("v", app)).toBe("selection");
|
||||
expect(findShapeByKey("1", app)).toBe("selection");
|
||||
});
|
||||
|
||||
it("selection shortcuts activate lasso when it's preferred", () => {
|
||||
const app = appWithPreferredTool("lasso");
|
||||
|
||||
expect(findShapeByKey("v", app)).toBe("lasso");
|
||||
expect(findShapeByKey("1", app)).toBe("lasso");
|
||||
});
|
||||
|
||||
it("letter shortcuts are CapsLock-insensitive", () => {
|
||||
const app = appWithPreferredTool("selection");
|
||||
|
||||
expect(findShapeByKey("V", app)).toBe("selection");
|
||||
expect(findShapeByKey("R", app)).toBe("rectangle");
|
||||
expect(findShapeByKey("X", app)).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("matches shift-bound tools only when shift is held", () => {
|
||||
const app = appWithPreferredTool("selection");
|
||||
|
||||
expect(findShapeByKey("X", app, true)).toBe("autoshape");
|
||||
expect(findShapeByKey("x", app, true)).toBe("autoshape");
|
||||
// Pressing "X" while CapsLock is active (no shift) stays freedraw
|
||||
expect(findShapeByKey("X", app, false)).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("does not match plain-bound tools when shift is held", () => {
|
||||
const app = appWithPreferredTool("selection");
|
||||
|
||||
expect(findShapeByKey("R", app, true)).toBeNull();
|
||||
expect(findShapeByKey("V", app, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("props.activeTool (forced tool)", () => {
|
||||
const h = window.h;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const queryToolButton = (type: string) =>
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLButtonElement>(
|
||||
`[data-testid="toolbar-${type}"]`,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mouse.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("forces the tool from the first render, behaving as locked", async () => {
|
||||
await render(<Excalidraw activeTool={{ type: "rectangle" }} />);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
// forcing behaves as locked without mutating the user's padlock state
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
// the cursor reflects the forced tool without any pointer interaction
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.CROSSHAIR,
|
||||
);
|
||||
|
||||
// drawing doesn't revert to selection, and the drawn element isn't
|
||||
// auto-selected (locked-tool behavior)
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("does not clobber the user's padlock preference", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
// user locks the tool (Q)
|
||||
fireEvent.keyDown(document, { key: "q", code: "KeyQ" });
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
|
||||
// forcing & unforcing leaves the preference alone
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "freedraw" }}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("freedraw");
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores user & API tool switching without side effects", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "selection" }}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
API.setElements([rect]);
|
||||
API.setAppState({ selectedElementIds: { [rect.id]: true } });
|
||||
|
||||
// the letter shortcut is refused before it can clear the selection
|
||||
fireEvent.keyDown(document, { key: "r", code: "KeyR" });
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(h.state.selectedElementIds).toEqual({ [rect.id]: true });
|
||||
|
||||
// the imperative API is refused as well — the prop is the single owner
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "freedraw" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
|
||||
it("keeps the tool in sync with prop changes, until unset", async () => {
|
||||
await render(<Excalidraw activeTool={{ type: "rectangle" }} />);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw activeTool={{ type: "custom", customType: "comment" }} />,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("custom");
|
||||
expect(h.state.activeTool.customType).toBe("comment");
|
||||
|
||||
// customType-only changes are picked up
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw activeTool={{ type: "custom", customType: "pin" }} />,
|
||||
);
|
||||
expect(h.state.activeTool.customType).toBe("pin");
|
||||
|
||||
// unset → uncontrolled: the current tool stays, switching works again
|
||||
GlobalTestState.renderResult.rerender(<Excalidraw />);
|
||||
expect(h.state.activeTool.customType).toBe("pin");
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "freedraw" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("snaps back when internal flows reset the tool", async () => {
|
||||
await render(<Excalidraw activeTool={{ type: "laser" }} />);
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
|
||||
// simulate a funnel-bypassing writer (restore on scene load,
|
||||
// actionFinalize, ...)
|
||||
act(() => {
|
||||
API.setAppState({
|
||||
activeTool: updateActiveTool(h.state, { type: "selection" }),
|
||||
});
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
|
||||
it("renders non-forced toolbar buttons disabled and gates the tool lock", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "rectangle" }}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryToolButton("rectangle")!.disabled).toBe(false);
|
||||
expect(queryToolButton("rectangle")!.getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(queryToolButton("freedraw")!.disabled).toBe(true);
|
||||
expect(queryToolButton("eraser")!.disabled).toBe(true);
|
||||
expect(queryToolButton("lock")).toBe(null);
|
||||
|
||||
// the extra-tools dropdown items are disabled as well
|
||||
fireEvent.click(
|
||||
GlobalTestState.renderResult.container.querySelector(
|
||||
".App-toolbar__extra-tools-trigger",
|
||||
)!,
|
||||
);
|
||||
const frameItem = document.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="toolbar-frame"]',
|
||||
);
|
||||
expect(frameItem).not.toBe(null);
|
||||
expect(frameItem!.disabled).toBe(true);
|
||||
const laserItem = document.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="toolbar-laser"]',
|
||||
);
|
||||
expect(laserItem!.disabled).toBe(true);
|
||||
|
||||
// Q (toggle tool lock) is ignored — locking is implied while forced
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
fireEvent.keyDown(document, { key: "q", code: "KeyQ" });
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("cannot force the image tool", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await render(<Excalidraw activeTool={{ type: "image" } as any} />);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`"image" tool cannot be forced`),
|
||||
);
|
||||
});
|
||||
|
||||
it("composes with interaction.enabled.tools (presenter → viewer)", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "laser" }}
|
||||
interaction={{ enabled: { tools: { laser: true } } }}
|
||||
/>,
|
||||
);
|
||||
// presenter: forced laser while otherwise non-interactive
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
expect(h.app.isInteractionEnabled()).toBe(false);
|
||||
expect(h.app.isToolSupported("laser")).toBe(true);
|
||||
// the laser cursor applies without any pointer interaction
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("url(");
|
||||
|
||||
// viewer: fully inert, tool resolves to the default selection
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw activeTool={{ type: "selection" }} interaction={false} />,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
|
||||
// back to presenter
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "laser" }}
|
||||
interaction={{ enabled: { tools: { laser: true } } }}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
|
||||
it("forcing a non-activatable tool resolves to selection until activatable", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await render(
|
||||
<Excalidraw activeTool={{ type: "laser" }} interaction={false} />,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
// the tool becomes activatable → the forced tool applies
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "laser" }}
|
||||
interaction={{ enabled: { tools: { laser: true } } }}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolbar", () => {
|
||||
const queryTool = (type: string) =>
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
`[data-testid="toolbar-${type}"]`,
|
||||
);
|
||||
const openExtraTools = () =>
|
||||
fireEvent.click(
|
||||
document.querySelector(".App-toolbar__extra-tools-trigger")!,
|
||||
);
|
||||
|
||||
it("keeps the image tool in the extra-tools menu", async () => {
|
||||
await render(<Excalidraw />);
|
||||
expect(queryTool("image")).toBe(null);
|
||||
|
||||
openExtraTools();
|
||||
expect(queryTool("image")).not.toBe(null);
|
||||
expect(queryTool("frame")).not.toBe(null);
|
||||
});
|
||||
|
||||
it("hides the image tool through UIOptions.tools.image", async () => {
|
||||
await render(<Excalidraw UIOptions={{ tools: { image: false } }} />);
|
||||
openExtraTools();
|
||||
expect(queryTool("image")).toBe(null);
|
||||
expect(queryTool("frame")).not.toBe(null);
|
||||
});
|
||||
|
||||
it("badges tools with their letter shortcut", async () => {
|
||||
await render(<Excalidraw />);
|
||||
const badge = (type: string) =>
|
||||
queryTool(type)?.querySelector(".ToolIcon__keybinding")?.textContent;
|
||||
expect(badge("selection")).toBe("V");
|
||||
expect(badge("rectangle")).toBe("R");
|
||||
expect(badge("text")).toBe("T");
|
||||
expect(badge("stickynote")).toBe("N");
|
||||
expect(badge("eraser")).toBe("E");
|
||||
});
|
||||
});
|
||||
163
packages/excalidraw/tests/toolDrag.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
KEYS,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawStickyNoteElement } from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, UI } from "./helpers/ui";
|
||||
import { getTextEditor } from "./queries/dom";
|
||||
import { fireEvent, GlobalTestState, render } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const POINTER = { pointerId: 1, pointerType: "mouse", isPrimary: true };
|
||||
|
||||
const stickyButton = () =>
|
||||
GlobalTestState.renderResult.getByToolName("stickynote");
|
||||
|
||||
/** press the tool button and move the pointer to (x, y) on the canvas */
|
||||
const dragOut = (x: number, y: number) => {
|
||||
fireEvent.pointerDown(stickyButton(), {
|
||||
...POINTER,
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
});
|
||||
fireEvent.pointerMove(window, { ...POINTER, clientX: x, clientY: y });
|
||||
};
|
||||
|
||||
const liveNotes = () =>
|
||||
h.elements.filter(
|
||||
(element) => element.type === "stickynote" && !element.isDeleted,
|
||||
) as ExcalidrawStickyNoteElement[];
|
||||
|
||||
describe("dragging a tool out of the toolbar", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
API.setAppState({ width: 1000, height: 800 });
|
||||
});
|
||||
|
||||
it("previews while dragging without touching the scene, and drops one note", async () => {
|
||||
// whatever tool is active must not stay armed under the dropped note
|
||||
UI.clickTool("arrow");
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
|
||||
// under the drag threshold nothing happens — a press is still a click
|
||||
fireEvent.pointerDown(stickyButton(), {
|
||||
...POINTER,
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
});
|
||||
fireEvent.pointerMove(window, { ...POINTER, clientX: 3, clientY: 3 });
|
||||
expect(h.app.toolDrag.preview).toBeNull();
|
||||
|
||||
fireEvent.pointerMove(window, { ...POINTER, clientX: 300, clientY: 260 });
|
||||
const preview = h.app.toolDrag.preview!;
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview.type).toBe("stickynote");
|
||||
expect([preview.width, preview.height]).toEqual([
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
]);
|
||||
// the preview is not an element yet: nothing for collaborators or history
|
||||
expect(h.elements).toHaveLength(0);
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
|
||||
fireEvent.pointerMove(window, { ...POINTER, clientX: 400, clientY: 350 });
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
...POINTER,
|
||||
button: 0,
|
||||
clientX: 400,
|
||||
clientY: 350,
|
||||
});
|
||||
|
||||
expect(h.app.toolDrag.preview).toBeNull();
|
||||
const [note] = liveNotes();
|
||||
expect(liveNotes()).toHaveLength(1);
|
||||
// centered on the pointer, at the default size
|
||||
const center = viewportCoordsToSceneCoords(
|
||||
{ clientX: 400, clientY: 350 },
|
||||
h.state,
|
||||
);
|
||||
expect(note.x + note.width / 2).toBeCloseTo(center.x);
|
||||
expect(note.y + note.height / 2).toBeCloseTo(center.y);
|
||||
expect(note.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
// The drop is recorded while its label is being edited.
|
||||
expect(h.state.editingTextElement).not.toBeNull();
|
||||
expect(API.getUndoStack()).toHaveLength(1);
|
||||
// back on the selection tool (the editor owns the selection meanwhile,
|
||||
// as on the click path)
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
|
||||
// Abandoning the editor leaves one visible action; undo removes the note.
|
||||
Keyboard.keyPress(KEYS.ESCAPE, await getTextEditor());
|
||||
expect(API.getUndoStack()).toHaveLength(1);
|
||||
Keyboard.undo();
|
||||
expect(liveNotes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("places the preview and dropped note on the grid", () => {
|
||||
API.setAppState({ gridModeEnabled: true, gridSize: 20 });
|
||||
dragOut(300, 300);
|
||||
expect(h.app.toolDrag.preview).toMatchObject({ x: 180, y: 180 });
|
||||
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
...POINTER,
|
||||
button: 0,
|
||||
clientX: 300,
|
||||
clientY: 300,
|
||||
});
|
||||
expect(liveNotes()[0]).toMatchObject({ x: 180, y: 180 });
|
||||
});
|
||||
|
||||
it("leaves nothing behind when Escape cancels the drag", () => {
|
||||
dragOut(300, 260);
|
||||
expect(h.app.toolDrag.preview).not.toBeNull();
|
||||
|
||||
fireEvent.keyDown(window, { key: KEYS.ESCAPE });
|
||||
expect(h.app.toolDrag.preview).toBeNull();
|
||||
|
||||
// a pointer-up arriving after the cancel is inert
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
...POINTER,
|
||||
button: 0,
|
||||
clientX: 300,
|
||||
clientY: 260,
|
||||
});
|
||||
expect(h.elements).toHaveLength(0);
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
expect(h.state.editingTextElement).toBeNull();
|
||||
});
|
||||
|
||||
it("drops nothing when released outside the canvas", () => {
|
||||
dragOut(300, 260);
|
||||
fireEvent.pointerUp(document.body, {
|
||||
...POINTER,
|
||||
button: 0,
|
||||
clientX: 300,
|
||||
clientY: 260,
|
||||
});
|
||||
expect(h.app.toolDrag.preview).toBeNull();
|
||||
expect(h.elements).toHaveLength(0);
|
||||
expect(API.getUndoStack()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a plain click selecting the tool", () => {
|
||||
const button = stickyButton();
|
||||
fireEvent.pointerDown(button, { ...POINTER, button: 0 });
|
||||
fireEvent.pointerUp(button, { ...POINTER, button: 0 });
|
||||
fireEvent.click(button);
|
||||
expect(h.state.activeTool.type).toBe("stickynote");
|
||||
expect(h.app.toolDrag.preview).toBeNull();
|
||||
expect(h.elements).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
190
packages/excalidraw/tests/viewMode.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { CURSOR_TYPE, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
GlobalTestState,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const queryContainer = (selector: string) =>
|
||||
GlobalTestState.renderResult.container.querySelector(selector);
|
||||
|
||||
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 () => {
|
||||
mockBoundingClientRect();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
await waitFor(() => expect(h.state.width).toBe(200));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("after switching to view mode – cursor type should be pointer", async () => {
|
||||
API.setAppState({ 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 () => {
|
||||
API.setAppState({ 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("dispatches pointer events to custom tools in view mode", () => {
|
||||
const onPointerDown = vi.fn();
|
||||
const onPointerUp = vi.fn();
|
||||
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
handleKeyboardGlobally={true}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
/>,
|
||||
);
|
||||
|
||||
API.setAppState({ viewModeEnabled: true });
|
||||
act(() => {
|
||||
h.app.setActiveTool({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
expect(h.app.isInteractionEnabled()).toBe(true);
|
||||
|
||||
const { scrollX, scrollY } = h.state;
|
||||
|
||||
mouse.reset();
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.upAt(100, 100);
|
||||
|
||||
expect(onPointerDown).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerDown.mock.calls[0][0]).toMatchObject({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
});
|
||||
expect(onPointerUp).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerUp.mock.calls[0][0]).toMatchObject({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
});
|
||||
expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
API.setAppState({ viewModeEnabled: true });
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits active text editing when entering view mode", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "before",
|
||||
x: 20,
|
||||
y: 20,
|
||||
});
|
||||
API.setElements([text]);
|
||||
API.setSelectedElements([text]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, "committed before view mode");
|
||||
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw viewModeEnabled={true} handleKeyboardGlobally={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.editingTextElement).toBe(null);
|
||||
expect(queryContainer(".excalidraw-wysiwyg")).toBe(null);
|
||||
});
|
||||
expect(h.elements.find((element) => element.id === text.id)).toMatchObject({
|
||||
originalText: "committed before view mode",
|
||||
});
|
||||
});
|
||||
|
||||
it("commits frame-name editing when entering view mode", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 80,
|
||||
height: 50,
|
||||
});
|
||||
API.setElements([frame]);
|
||||
API.updateElement(frame, { name: "before" });
|
||||
API.setAppState({ editingFrame: frame.id });
|
||||
|
||||
const frameNameInput = await waitFor(() => {
|
||||
const input = queryContainer(".frame-name input");
|
||||
expect(input).not.toBe(null);
|
||||
return input as HTMLInputElement;
|
||||
});
|
||||
fireEvent.change(frameNameInput, { target: { value: " committed " } });
|
||||
|
||||
API.setAppState({ viewModeEnabled: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.editingFrame).toBe(null);
|
||||
expect(queryContainer(".frame-name input")).toBe(null);
|
||||
});
|
||||
expect(h.elements.find((element) => element.id === frame.id)).toMatchObject(
|
||||
{ name: "committed" },
|
||||
);
|
||||
});
|
||||
});
|
||||
354
packages/excalidraw/tests/wheel.test.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React from "react";
|
||||
import { fireEvent, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
CLASSES,
|
||||
MIN_ZOOM,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { resolveInputDevice } from "../appState";
|
||||
import { Excalidraw } from "../index";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { GlobalTestState, mockBoundingClientRect, render } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
// `withBatchedUpdatesThrottled` — which the drag-pan applies pointer movement
|
||||
// through, once per animation frame — is synchronous under the test setup;
|
||||
// this lets a test hold the throttled calls back until it releases the frame
|
||||
const throttle = vi.hoisted(() => {
|
||||
const pending = new Set<() => void>();
|
||||
return {
|
||||
holding: false,
|
||||
pending,
|
||||
releaseFrame: () => {
|
||||
const frames = [...pending];
|
||||
pending.clear();
|
||||
frames.forEach((frame) => frame());
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../reactUtils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../reactUtils")>();
|
||||
const withBatchedUpdatesThrottled = (fn: (event?: unknown) => void) => {
|
||||
const batched = actual.withBatchedUpdates(fn);
|
||||
let run: (() => void) | null = null;
|
||||
const frame = () => {
|
||||
const pendingRun = run;
|
||||
run = null;
|
||||
pendingRun?.();
|
||||
};
|
||||
const ret = (event?: unknown) => {
|
||||
if (!throttle.holding) {
|
||||
batched(event);
|
||||
return;
|
||||
}
|
||||
run = () => batched(event);
|
||||
throttle.pending.add(frame);
|
||||
};
|
||||
ret.flush = () => {
|
||||
throttle.pending.delete(frame);
|
||||
frame();
|
||||
};
|
||||
ret.cancel = () => {
|
||||
throttle.pending.delete(frame);
|
||||
run = null;
|
||||
};
|
||||
return ret;
|
||||
};
|
||||
return { ...actual, withBatchedUpdatesThrottled };
|
||||
});
|
||||
|
||||
// the wheel listener is attached natively on the excalidraw container (not
|
||||
// via React), so we dispatch a bubbling wheel event from the canvas
|
||||
const wheel = (init: WheelEventInit) =>
|
||||
fireEvent.wheel(GlobalTestState.interactiveCanvas, init);
|
||||
|
||||
const getViewport = () => ({
|
||||
scrollX: h.state.scrollX,
|
||||
scrollY: h.state.scrollY,
|
||||
zoom: h.state.zoom.value,
|
||||
});
|
||||
|
||||
/** `MouseEvent.buttons` with only the wheel (middle) button pressed */
|
||||
const WHEEL_BUTTON = 4;
|
||||
|
||||
const wheelButtonPointer = {
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
// `button` identifies the pressed button, `buttons` the held-down set
|
||||
button: 1,
|
||||
buttons: WHEEL_BUTTON,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
};
|
||||
|
||||
describe("wheel navigation", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("pans on plain wheel, horizontally with shift, and zooms on ctrl/cmd+wheel", () => {
|
||||
const start = getViewport();
|
||||
|
||||
wheel({ deltaX: 30, deltaY: 40 });
|
||||
expect(getViewport()).toEqual({
|
||||
...start,
|
||||
scrollX: start.scrollX - 30,
|
||||
scrollY: start.scrollY - 40,
|
||||
});
|
||||
|
||||
const beforeShift = getViewport();
|
||||
wheel({ deltaY: 40, shiftKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...beforeShift,
|
||||
scrollX: beforeShift.scrollX - 40,
|
||||
});
|
||||
|
||||
const beforeZoom = getViewport();
|
||||
wheel({ deltaY: -100, ctrlKey: true });
|
||||
const zoomedIn = h.state.zoom.value;
|
||||
expect(zoomedIn).toBeGreaterThan(beforeZoom.zoom);
|
||||
wheel({ deltaY: 100, metaKey: true });
|
||||
expect(h.state.zoom.value).toBeLessThan(zoomedIn);
|
||||
});
|
||||
|
||||
it("does nothing on a zoom tick at the zoom limit", () => {
|
||||
API.setAppState({ zoom: { value: getNormalizedZoom(MIN_ZOOM) } });
|
||||
const start = getViewport();
|
||||
|
||||
wheel({ deltaY: 100, ctrlKey: true });
|
||||
|
||||
expect(getViewport()).toEqual(start);
|
||||
// in particular no bitmap-cache flip, which would redraw the scene
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
});
|
||||
|
||||
describe("wheel button held down", () => {
|
||||
it("zooms instead of panning, whatever the modifiers", () => {
|
||||
const start = getViewport();
|
||||
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON });
|
||||
const zoomedIn = h.state.zoom.value;
|
||||
expect(zoomedIn).toBeGreaterThan(start.zoom);
|
||||
|
||||
wheel({ deltaY: 100, buttons: WHEEL_BUTTON });
|
||||
const zoomedOut = h.state.zoom.value;
|
||||
expect(zoomedOut).toBeLessThan(zoomedIn);
|
||||
|
||||
// shift would otherwise pan horizontally
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON, shiftKey: true });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(zoomedOut);
|
||||
});
|
||||
|
||||
it("zooms during the wheel-button drag-pan the button started", () => {
|
||||
fireEvent.pointerDown(
|
||||
GlobalTestState.interactiveCanvas,
|
||||
wheelButtonPointer,
|
||||
);
|
||||
|
||||
// the drag-pan owns the viewport: wheel input without the button
|
||||
// held is swallowed...
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: 40 });
|
||||
expect(getViewport()).toEqual(start);
|
||||
|
||||
// ...but with the button held, it zooms
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
|
||||
fireEvent.pointerUp(window, { ...wheelButtonPointer, buttons: 0 });
|
||||
|
||||
// pan released — plain wheel pans again
|
||||
const released = getViewport();
|
||||
wheel({ deltaY: 40 });
|
||||
expect(h.state.scrollY).toBeCloseTo(
|
||||
released.scrollY - 40 / released.zoom,
|
||||
);
|
||||
});
|
||||
|
||||
describe("while moving the pointer between zooms", () => {
|
||||
const moved = { ...wheelButtonPointer, clientX: 130, clientY: 120 };
|
||||
|
||||
/** starts a wheel-button drag-pan; returns the scene point grabbed */
|
||||
const grab = () => {
|
||||
fireEvent.pointerDown(
|
||||
GlobalTestState.interactiveCanvas,
|
||||
wheelButtonPointer,
|
||||
);
|
||||
// wheel zoom anchors on the last pointer position
|
||||
fireEvent.pointerMove(
|
||||
GlobalTestState.interactiveCanvas,
|
||||
wheelButtonPointer,
|
||||
);
|
||||
return viewportCoordsToSceneCoords(wheelButtonPointer, h.state);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
// release the pan even when an assertion failed mid-way, so the
|
||||
// module-level pan state doesn't leak into the next test
|
||||
fireEvent.pointerUp(window, { ...moved, buttons: 0 });
|
||||
});
|
||||
|
||||
const expectGrabbedUnderCursor = (grabbed: { x: number; y: number }) => {
|
||||
const underCursor = viewportCoordsToSceneCoords(moved, h.state);
|
||||
expect(underCursor.x).toBeCloseTo(grabbed.x);
|
||||
expect(underCursor.y).toBeCloseTo(grabbed.y);
|
||||
};
|
||||
|
||||
it("keeps the zoom when the pan lands in the same React flush", () => {
|
||||
const grabbed = grab();
|
||||
const startZoom = h.state.zoom.value;
|
||||
|
||||
// the drag-pan's frame runs before React has flushed the zoom the
|
||||
// wheel queued, so both land in one flush — a pan written from the
|
||||
// pre-zoom state would overwrite the zoom's scroll
|
||||
React.act(() => {
|
||||
wheel({ deltaY: -100, ...wheelButtonPointer });
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, moved);
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBeGreaterThan(startZoom);
|
||||
expectGrabbedUnderCursor(grabbed);
|
||||
});
|
||||
|
||||
it("applies a move still waiting for its frame before zooming", () => {
|
||||
const grabbed = grab();
|
||||
const start = getViewport();
|
||||
|
||||
throttle.holding = true;
|
||||
try {
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, moved);
|
||||
expect(getViewport()).toEqual(start);
|
||||
|
||||
wheel({ deltaY: -100, ...moved });
|
||||
React.act(() => {
|
||||
throttle.releaseFrame();
|
||||
});
|
||||
} finally {
|
||||
throttle.holding = false;
|
||||
}
|
||||
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
expectGrabbedUnderCursor(grabbed);
|
||||
});
|
||||
|
||||
it("keeps drawing from zoom-scaled bitmaps until the gesture is over", async () => {
|
||||
grab();
|
||||
|
||||
wheel({ deltaY: -100, ...wheelButtonPointer });
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
|
||||
|
||||
// a pan frame between two ticks must not switch back to crisp
|
||||
// rasterization — that re-rasterizes every element at a zoom the
|
||||
// next tick is about to change again
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, moved);
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
|
||||
|
||||
wheel({ deltaY: -100, ...moved });
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
|
||||
|
||||
// ...only once the zoom has been idle for a moment
|
||||
await waitFor(() => {
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputDevice preference", () => {
|
||||
it("defaults to auto, which resolves to trackpad until detection exists", () => {
|
||||
expect(h.state.inputDevice).toBe("auto");
|
||||
expect(resolveInputDevice(h.state.inputDevice)).toBe("trackpad");
|
||||
});
|
||||
|
||||
describe("mouse", () => {
|
||||
beforeEach(() => {
|
||||
API.setAppState({ inputDevice: "mouse" });
|
||||
});
|
||||
|
||||
it("zooms on plain wheel and pans vertically on ctrl/cmd+wheel", () => {
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: -100 });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
|
||||
const zoomed = getViewport();
|
||||
wheel({ deltaY: 40, ctrlKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...zoomed,
|
||||
scrollY: zoomed.scrollY - 40 / zoomed.zoom,
|
||||
});
|
||||
|
||||
const panned = getViewport();
|
||||
wheel({ deltaY: 40, metaKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...panned,
|
||||
scrollY: panned.scrollY - 40 / panned.zoom,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps shift+wheel panning horizontally", () => {
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: 40, shiftKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...start,
|
||||
scrollX: start.scrollX - 40 / start.zoom,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps zooming with the wheel button held", () => {
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON, ctrlKey: true });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
});
|
||||
|
||||
it("pans sideways on a horizontal-only wheel instead of zooming", () => {
|
||||
// a tilt wheel or a sideways two-finger scroll
|
||||
const start = getViewport();
|
||||
wheel({ deltaX: 30 });
|
||||
expect(getViewport()).toEqual({
|
||||
...start,
|
||||
scrollX: start.scrollX - 30 / start.zoom,
|
||||
});
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wheel over a frame label", () => {
|
||||
beforeEach(async () => {
|
||||
// frame labels render only for frames inside the measured viewport
|
||||
mockBoundingClientRect();
|
||||
await render(<Excalidraw />);
|
||||
await waitFor(() => expect(h.state.width).toBe(200));
|
||||
});
|
||||
|
||||
it("is handled once", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 80,
|
||||
height: 50,
|
||||
});
|
||||
API.setElements([frame]);
|
||||
const label = await waitFor(() => {
|
||||
const element = document.querySelector(`.${CLASSES.FRAME_NAME}`);
|
||||
expect(element).not.toBe(null);
|
||||
return element as HTMLElement;
|
||||
});
|
||||
|
||||
// the label is DOM inside the editor container, whose wheel listener
|
||||
// accepts it; a React `onWheel` on the label itself used to handle the
|
||||
// same event a second time, panning and zooming twice per tick
|
||||
const start = getViewport();
|
||||
fireEvent.wheel(label, { deltaY: 40 });
|
||||
expect(h.state.scrollY).toBeCloseTo(start.scrollY - 40 / start.zoom);
|
||||
});
|
||||
});
|
||||