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:
@@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DEER_IMAGE_DIMENSIONS = {
|
||||
width: 318,
|
||||
height: 335,
|
||||
};
|
||||
|
||||
export const SMILEY_IMAGE_DIMENSIONS = {
|
||||
width: 56,
|
||||
height: 77,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user