feat: [cont.] support inserting multiple images (#9875)

* feat: support inserting multiple images

* Initial

* handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard

* Initial get history working

* insertMultipleImages -> insertImages

* Bug fixes, improvements

* Remove redundant branch

* Refactor addElementsFromMixedContentPaste

* History, drag & drop bug fixes

* Update snapshots

* Remove redundant try-catch

* Refactor pasteFromClipboard

* Plain paste check in mermaid paste

* Move comment

* processClipboardData -> insertClipboardContent

* Redundant variable

* Redundant variable

* Refactor insertImages

* createImagePlaceholder -> newImagePlaceholder

* Get rid of unneeded NEVER schedule, filter out failed images

* Trigger CI

* Position placeholders before initializing

* Don't mutate scene with positionElementsOnGrid, captureUpdate: CaptureUpdateAction.IMMEDIATELY

* Comment

* Move positionOnGrid out of file

* Rename file

* Get rid of generic

* Initial tests

* More asserts, test paste

* Test image tool

* De-duplicate

* Stricter assert, move rest of logic outside of waitFor

* Modify history tests

* De-duplicate update snapshots

* Trigger CI

* Fix package build

* Make setupImageTest more explicit

* Re-introduce generic to use latest placeholder versions

* newElementWith instead of mutateElement to delete failed placeholder

* Insert failed images separately with CaptureUpdateAction.NEVER

* Refactor

* Don't re-order elements

* WIP

* Get rid of 'never' for failed

* refactor type check

* align max file size constant

* make grid padding scale to zoom

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Omar Brikaa
2025-09-01 18:31:24 +03:00
committed by GitHub
parent ae89608985
commit 3bdaafe4b5
15 changed files with 895 additions and 502 deletions
@@ -12534,10 +12534,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -12759,6 +12756,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"searchMatches": null,
"selectedElementIds": {
"id0": true,
"id1": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@@ -12793,7 +12791,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "fileId",
"fileId": "id2",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
@@ -12816,16 +12814,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeWidth": 2,
"type": "image",
"updated": 1,
"version": 5,
"version": 7,
"width": 318,
"x": -159,
"x": -212,
"y": "-167.50000",
}
`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] element 1 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"id": "id1",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"updated": 1,
"version": 7,
"width": 56,
"x": 156,
"y": "-167.50000",
}
`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `7`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `2`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `8`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`;
@@ -12837,6 +12872,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"deleted": {
"selectedElementIds": {
"id0": true,
"id1": true,
},
},
"inserted": {
@@ -12854,7 +12890,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "fileId",
"fileId": "id2",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
@@ -12875,20 +12911,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"version": 5,
"version": 7,
"width": 318,
"x": -159,
"x": -212,
"y": "-167.50000",
},
"inserted": {
"isDeleted": true,
"version": 4,
"version": 6,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"version": 7,
"width": 56,
"x": 156,
"y": "-167.50000",
},
"inserted": {
"isDeleted": true,
"version": 6,
},
},
},
"updated": {},
},
"id": "id4",
"id": "id7",
},
]
`;
@@ -12964,10 +13038,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
@@ -12981,6 +13052,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"searchMatches": null,
"selectedElementIds": {
"id0": true,
"id1": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@@ -13015,11 +13087,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "fileId",
"fileId": "id2",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"height": 335,
"id": "id0",
"index": "a0",
"isDeleted": false,
@@ -13038,16 +13110,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeWidth": 2,
"type": "image",
"updated": 1,
"version": 5,
"width": 56,
"x": -28,
"y": "-38.50000",
"version": 7,
"width": 318,
"x": -212,
"y": "-167.50000",
}
`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] element 1 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"id": "id1",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"updated": 1,
"version": 7,
"width": 56,
"x": 156,
"y": "-167.50000",
}
`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `7`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `2`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `8`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`;
@@ -13059,6 +13168,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"deleted": {
"selectedElementIds": {
"id0": true,
"id1": true,
},
},
"inserted": {
@@ -13076,11 +13186,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "fileId",
"fileId": "id2",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"height": 335,
"index": "a0",
"isDeleted": false,
"link": null,
@@ -13097,20 +13207,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"version": 5,
"width": 56,
"x": -28,
"y": "-38.50000",
"version": 7,
"width": 318,
"x": -212,
"y": "-167.50000",
},
"inserted": {
"isDeleted": true,
"version": 4,
"version": 6,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"version": 7,
"width": 56,
"x": 156,
"y": "-167.50000",
},
"inserted": {
"isDeleted": true,
"version": 6,
},
},
},
"updated": {},
},
"id": "id4",
"id": "id7",
},
]
`;
+9
View File
@@ -0,0 +1,9 @@
export const DEER_IMAGE_DIMENSIONS = {
width: 318,
height: 335,
};
export const SMILEY_IMAGE_DIMENSIONS = {
width: 56,
height: 77,
};
+3 -7
View File
@@ -25,6 +25,7 @@ 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 {
@@ -744,11 +745,6 @@ describe("freedraw", () => {
//image
//TODO: currently there is no test for pixel colors at flipped positions.
describe("image", () => {
const smileyImageDimensions = {
width: 56,
height: 77,
};
beforeEach(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
@@ -756,8 +752,8 @@ describe("image", () => {
beforeAll(() => {
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
SMILEY_IMAGE_DIMENSIONS.width,
SMILEY_IMAGE_DIMENSIONS.height,
);
});
+27 -17
View File
@@ -478,33 +478,43 @@ export class API {
});
};
static drop = async (blob: Blob) => {
static drop = async (_blobs: Blob[] | Blob) => {
const blobs = Array.isArray(_blobs) ? _blobs : [_blobs];
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const text = await new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.readAsText(blob);
} catch (error: any) {
reject(error);
}
});
const texts = await Promise.all(
blobs.map(
(blob) =>
new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.readAsText(blob);
} catch (error: any) {
reject(error);
}
}),
),
);
const files = [blob] as File[] & { item: (index: number) => File };
const files = blobs as File[] & { item: (index: number) => File };
files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", {
value: {
files,
getData: (type: string) => {
if (type === blob.type || type === "text") {
return text;
const idx = blobs.findIndex((b) => b.type === type);
if (idx >= 0) {
return texts[idx];
}
if (type === "text") {
return texts.join("\n");
}
return "";
},
types: [blob.type],
types: Array.from(new Set(blobs.map((b) => b.type))),
},
});
Object.defineProperty(fileDropEvent, "clientX", {
@@ -513,7 +523,7 @@ export class API {
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
};
@@ -0,0 +1,6 @@
export const INITIALIZED_IMAGE_PROPS = {
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
};
@@ -58,3 +58,35 @@ export const mockHTMLImageElement = (
},
);
};
// 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);
});
}
},
);
};
+68 -127
View File
@@ -20,6 +20,7 @@ import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
randomId,
} from "@excalidraw/common";
import "@excalidraw/utils/test-utils";
@@ -58,9 +59,13 @@ import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { mockHTMLImageElement } from "./helpers/mocks";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
import {
GlobalTestState,
act,
@@ -71,6 +76,7 @@ import {
checkpointHistory,
unmountComponent,
} from "./test-utils";
import { setupImageTest as _setupImageTest } from "./image.test";
import type { AppState } from "../types";
@@ -123,7 +129,9 @@ describe("history", () => {
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
generateIdSpy.mockImplementation(() =>
Promise.resolve(randomId() as FileId),
);
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
@@ -612,80 +620,6 @@ describe("history", () => {
]);
});
it("should create new history entry on image drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const deerImageDimensions = {
width: 318,
height: 335,
};
mockHTMLImageElement(
deerImageDimensions.width,
deerImageDimensions.height,
);
await API.drop(await API.loadFile("./fixtures/deer.png"));
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...deerImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...deerImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
@@ -730,54 +664,29 @@ describe("history", () => {
]);
});
it("should create new history entry on image paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const smileyImageDimensions = {
width: 56,
height: 77,
};
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
document.dispatchEvent(
createPasteEvent({
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
}),
);
const setupImageTest = () =>
_setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
const assertImageTest = async () => {
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
// need to check that delta actually contains initialized image elements (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed).map(
(val) => val.deleted,
),
).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
...INITIALIZED_IMAGE_PROPS,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
);
});
Keyboard.undo();
@@ -785,12 +694,14 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...smileyImageDimensions,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
@@ -799,14 +710,44 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...smileyImageDimensions,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
};
it("should create new history entry on image drag&drop", async () => {
await setupImageTest();
await API.drop(
await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
);
await assertImageTest();
});
it("should create new history entry on image paste", async () => {
await setupImageTest();
document.dispatchEvent(
createPasteEvent({
files: await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
}),
);
await assertImageTest();
});
it("should create new history entry on embeddable link paste", async () => {
+115
View File
@@ -0,0 +1,115 @@
import { 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";
const { h } = window;
export const setupImageTest = async (
sizes: { width: number; height: number }[],
) => {
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
h.state.height = 1000;
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
};
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);
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.clickTool("image");
await assert();
});
});