snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

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

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

View File

@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
<div
data-testid="brave-measure-text-error"
>
<p>
Looks like you are using Brave browser with the
<span
style="font-weight: 600;"
>
Aggressively Block Fingerprinting
</span>
setting enabled.
</p>
<p>
This could result in breaking the
<span
style="font-weight: 600;"
>
Text Elements
</span>
in your drawings.
</p>
<p>
We strongly recommend disabling this setting. You can follow
<a
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
>
these steps
</a>
on how to do so.
</p>
<p>
If disabling this setting doesn't fix the display of text elements, please open an
<a
href="https://github.com/excalidraw/excalidraw/issues/new"
>
issue
</a>
on our GitHub, or write us on
<a
href="https://discord.gg/UexuTaE"
>
Discord
.
</a>
</p>
</div>
`;

View File

@@ -0,0 +1,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]"
`;

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

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

View File

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,39 @@
import { getNameInitial } from "../clients";
describe("getClientInitials", () => {
it("returns substring if one name provided", () => {
expect(getNameInitial("Alan")).toBe("A");
});
it("returns initials", () => {
expect(getNameInitial("John Doe")).toBe("J");
});
it("returns correct initials if many names provided", () => {
expect(getNameInitial("John Alan Doe")).toBe("J");
});
it("returns single initial if 1 letter provided", () => {
expect(getNameInitial("z")).toBe("Z");
});
it("trims trailing whitespace", () => {
expect(getNameInitial(" q ")).toBe("Q");
});
it('returns "?" if falsey value provided', () => {
expect(getNameInitial("")).toBe("?");
expect(getNameInitial(undefined)).toBe("?");
expect(getNameInitial(null)).toBe("?");
});
it('returns "?" when value is blank', () => {
expect(getNameInitial(" ")).toBe("?");
});
it("works with multibyte strings", () => {
expect(getNameInitial("😀")).toBe("😀");
// but doesn't work with emoji ZWJ sequences
expect(getNameInitial("👨‍👩‍👦")).toBe("👨");
});
});

View File

@@ -0,0 +1,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" }),
]),
);
});
});
});

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

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

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

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

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

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

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

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

View File

@@ -0,0 +1,9 @@
export const DEER_IMAGE_DIMENSIONS = {
width: 318,
height: 335,
};
export const SMILEY_IMAGE_DIMENSIONS = {
width: 56,
height: 77,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

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

View File

@@ -0,0 +1,31 @@
{
"type": "excalidrawlib",
"version": 1,
"library": [
[
{
"type": "rectangle",
"version": 38,
"versionNonce": 1046419680,
"isDeleted": false,
"id": "A",
"fillStyle": "hachure",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 21801,
"y": 719.5,
"strokeColor": "#c92a2a",
"backgroundColor": "#e64980",
"width": 50,
"height": 30,
"seed": 117297479,
"groupIds": [],
"strokeSharpness": "sharp",
"boundElementIds": []
}
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,16 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 77">
<!-- svg-source:excalidraw -->
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nGVSy07jMFx1MDAxNN3zXHUwMDE1kdmOIE1IXztcdTAwMWVcdTAwMDNiw6ZcdTAwMTKjXHUwMDExXHUwMDFhjUxym1hcdTAwMTjbsm9pU4TEZ8xufpFPwNcpcVqyiHTPfVx1MDAxZJ9zX4+ShGFrgM1cdTAwMTNcdTAwMDabkktRWb5mP1xif1x1MDAwMeuEVj6VhdjplS1DZYNo5qenUvuGRjuc52madk0g4Vx1MDAxOVx1MDAxNDpf9uDjJHlccn+fXHUwMDExXHUwMDE1tS4up2Urr2/M9lwivV1v26vf8Pc+tIaiLy5cYlx1MDAxYozoxkPT6biPW1x1MDAxZufF5KTokbWosCE0XHUwMDE2NSDqXHUwMDA2PVZMeoyrWtL8tEdcdTAwMWNa/Vx1MDAwNJdaakt7j8tZxjNcdTAwMWVXP/LyqbZ6paq+XHUwMDA2LVfOcOufXHUwMDE565ZCylx1MDAwNbay04eXzcpcdTAwMDI72PJrR3J0gPd9Tnv9Y5dfWzdcbpzb69GGl1x1MDAwMkmCUVx1MDAxYd9BXHUwMDFjzW1cdTAwMTV0/3M4v+HW7OYwR8GAXHUwMDE5XHUwMDAw+ZJl6WSSj2dxzcD9/Fx1MDAxMLzTKlx1MDAxY0IxK/JiXHUwMDFj08Jdef8xTFxccukgykhcbv7sbqNjqVZSRtvJbk/u4/+/94GmWuFCbGHfV0Kv+bOQ7Z4sNOJcXIqaXHUwMDE4M1x0y4E3njVcbn+qfVx1MDAxYbVcdTAwMTk67EBcbkVbzkZcdTAwMDF88/+gIePGLJAj5bo7Zi9cdTAwMDLWXHUwMDE332/ieFx1MDAxOb7dVO+GqHbM6Z1HNPPtXHUwMDEz+I3nwSJ9<!-- payload-end -->
<defs>
<style>
@font-face {
font-family: "Virgil";
src: url("https://excalidraw.com/Virgil.woff2");
}
@font-face {
font-family: "Cascadia";
src: url("https://excalidraw.com/Cascadia.woff2");
}
</style>
</defs>
<rect x="0" y="0" width="56" height="77" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 18 28.5)"><text x="0" y="41" font-family="Virgil, Segoe UI Emoji" font-size="36px" fill="#c92a2a" text-anchor="start" style="white-space: pre;" direction="ltr">😀</text></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,16 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 97 77">
<!-- svg-source:excalidraw -->
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-start -->ewogICJ0eXBlIjogImV4Y2FsaWRyYXciLAogICJ2ZXJzaW9uIjogMiwKICAic291cmNlIjogImh0dHBzOi8vZXhjYWxpZHJhdy5jb20iLAogICJlbGVtZW50cyI6IFsKICAgIHsKICAgICAgImlkIjogInRabVFwa0cyQlZ2SzNxT01icHVXeiIsCiAgICAgICJ0eXBlIjogInRleHQiLAogICAgICAieCI6IDg2MS4xMTExMTExMTExMTExLAogICAgICAieSI6IDM1Ni4zMzMzMzMzMzMzMzMzLAogICAgICAid2lkdGgiOiA3NywKICAgICAgImhlaWdodCI6IDU3LAogICAgICAiYW5nbGUiOiAwLAogICAgICAic3Ryb2tlQ29sb3IiOiAiIzAwMDAwMCIsCiAgICAgICJiYWNrZ3JvdW5kQ29sb3IiOiAiIzg2OGU5NiIsCiAgICAgICJmaWxsU3R5bGUiOiAiY3Jvc3MtaGF0Y2giLAogICAgICAic3Ryb2tlV2lkdGgiOiAyLAogICAgICAic3Ryb2tlU3R5bGUiOiAic29saWQiLAogICAgICAicm91Z2huZXNzIjogMSwKICAgICAgIm9wYWNpdHkiOiAxMDAsCiAgICAgICJncm91cElkcyI6IFtdLAogICAgICAic3Ryb2tlU2hhcnBuZXNzIjogInJvdW5kIiwKICAgICAgInNlZWQiOiA0NzYzNjM3OTMsCiAgICAgICJ2ZXJzaW9uIjogMjMsCiAgICAgICJ2ZXJzaW9uTm9uY2UiOiA1OTc0MzUxMzUsCiAgICAgICJpc0RlbGV0ZWQiOiBmYWxzZSwKICAgICAgImJvdW5kRWxlbWVudElkcyI6IG51bGwsCiAgICAgICJ0ZXh0IjogInRlc3QiLAogICAgICAiZm9udFNpemUiOiAzNiwKICAgICAgImZvbnRGYW1pbHkiOiAxLAogICAgICAidGV4dEFsaWduIjogImxlZnQiLAogICAgICAidmVydGljYWxBbGlnbiI6ICJ0b3AiLAogICAgICAiYmFzZWxpbmUiOiA0MQogICAgfQogIF0sCiAgImFwcFN0YXRlIjogewogICAgInZpZXdCYWNrZ3JvdW5kQ29sb3IiOiAiI2ZmZmZmZiIsCiAgICAiZ3JpZFNpemUiOiBudWxsCiAgfQp9<!-- payload-end -->
<defs>
<style>
@font-face {
font-family: "Virgil";
src: url("https://excalidraw.com/Virgil.woff2");
}
@font-face {
font-family: "Cascadia";
src: url("https://excalidraw.com/Cascadia.woff2");
}
</style>
</defs>
<rect x="0" y="0" width="97" height="77" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 38.5 28.5)"><text x="0" y="41" font-family="Virgil, Segoe UI Emoji" font-size="36px" fill="#000000" text-anchor="start" style="white-space: pre;" direction="ltr">test</text></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,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),
);
});
});

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

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

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

View File

@@ -0,0 +1,6 @@
export const INITIALIZED_IMAGE_PROPS = {
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
};

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

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

@@ -0,0 +1,616 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div
class="dropdown-menu"
data-testid="dropdown-menu"
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;"
>
<button
class="dropdown-menu-item dropdown-menu-item-base"
type="button"
>
<div
class="dropdown-menu-item__icon"
/>
<div
class="dropdown-menu-item__text"
>
Click me
</div>
</button>
<a
class="dropdown-menu-item dropdown-menu-item-base"
href="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>
`;

View File

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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);
});

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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