Explorar o código

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 <[email protected]>
Omar Brikaa hai 1 semana
pai
achega
3bdaafe4b5

+ 2 - 1
excalidraw-app/app_constants.ts

@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
 export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
 export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
 
-export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
+// should be aligned with MAX_ALLOWED_FILE_BYTES
+export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
 // 1 year (https://stackoverflow.com/a/25201898/927631)
 export const FILE_CACHE_MAX_AGE_SEC = 31536000;
 

+ 3 - 1
packages/element/src/bounds.ts

@@ -1126,7 +1126,9 @@ export interface BoundingBox {
 }
 
 export const getCommonBoundingBox = (
-  elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
+  elements:
+    | readonly ExcalidrawElement[]
+    | readonly NonDeleted<ExcalidrawElement>[],
 ): BoundingBox => {
   const [minX, minY, maxX, maxY] = getCommonBounds(elements);
   return {

+ 1 - 0
packages/element/src/index.ts

@@ -97,6 +97,7 @@ export * from "./image";
 export * from "./linearElementEditor";
 export * from "./mutateElement";
 export * from "./newElement";
+export * from "./positionElementsOnGrid";
 export * from "./renderElement";
 export * from "./resizeElements";
 export * from "./resizeTest";

+ 112 - 0
packages/element/src/positionElementsOnGrid.ts

@@ -0,0 +1,112 @@
+import { getCommonBounds } from "./bounds";
+import { type ElementUpdate, newElementWith } from "./mutateElement";
+
+import type { ExcalidrawElement } from "./types";
+
+// TODO rewrite (mostly vibe-coded)
+export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
+  elements: TElement[] | TElement[][],
+  centerX: number,
+  centerY: number,
+  padding = 50,
+): TElement[] => {
+  // Ensure there are elements to position
+  if (!elements || elements.length === 0) {
+    return [];
+  }
+
+  const res: TElement[] = [];
+  // Normalize input to work with atomic units (groups of elements)
+  // If elements is a flat array, treat each element as its own atomic unit
+  const atomicUnits: TElement[][] = Array.isArray(elements[0])
+    ? (elements as TElement[][])
+    : (elements as TElement[]).map((element) => [element]);
+
+  // Determine the number of columns for atomic units
+  // A common approach for a "grid-like" layout without specific column constraints
+  // is to aim for a roughly square arrangement.
+  const numUnits = atomicUnits.length;
+  const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
+
+  // Group atomic units into rows based on the calculated number of columns
+  const rows: TElement[][][] = [];
+  for (let i = 0; i < numUnits; i += numColumns) {
+    rows.push(atomicUnits.slice(i, i + numColumns));
+  }
+
+  // Calculate properties for each row (total width, max height)
+  // and the total actual height of all row content.
+  let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
+  const rowProperties = rows.map((rowUnits) => {
+    let rowWidth = 0;
+    let maxUnitHeightInRow = 0;
+
+    const unitBounds = rowUnits.map((unit) => {
+      const [minX, minY, maxX, maxY] = getCommonBounds(unit);
+      return {
+        elements: unit,
+        bounds: [minX, minY, maxX, maxY] as const,
+        width: maxX - minX,
+        height: maxY - minY,
+      };
+    });
+
+    unitBounds.forEach((unitBound, index) => {
+      rowWidth += unitBound.width;
+      // Add padding between units in the same row, but not after the last one
+      if (index < unitBounds.length - 1) {
+        rowWidth += padding;
+      }
+      if (unitBound.height > maxUnitHeightInRow) {
+        maxUnitHeightInRow = unitBound.height;
+      }
+    });
+
+    totalGridActualHeight += maxUnitHeightInRow;
+    return {
+      unitBounds,
+      width: rowWidth,
+      maxHeight: maxUnitHeightInRow,
+    };
+  });
+
+  // Calculate the total height of the grid including padding between rows
+  const totalGridHeightWithPadding =
+    totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
+
+  // Calculate the starting Y position to center the entire grid vertically around centerY
+  let currentY = centerY - totalGridHeightWithPadding / 2;
+
+  // Position atomic units row by row
+  rowProperties.forEach((rowProp) => {
+    const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
+
+    // Calculate the starting X for the current row to center it horizontally around centerX
+    let currentX = centerX - rowWidth / 2;
+
+    unitBounds.forEach((unitBound) => {
+      // Calculate the offset needed to position this atomic unit
+      const [originalMinX, originalMinY] = unitBound.bounds;
+      const offsetX = currentX - originalMinX;
+      const offsetY = currentY - originalMinY;
+
+      // Apply the offset to all elements in this atomic unit
+      unitBound.elements.forEach((element) => {
+        res.push(
+          newElementWith(element, {
+            x: element.x + offsetX,
+            y: element.y + offsetY,
+          } as ElementUpdate<TElement>),
+        );
+      });
+
+      // Move X for the next unit in the row
+      currentX += unitBound.width + padding;
+    });
+
+    // Move Y to the starting position for the next row
+    // This accounts for the tallest unit in the current row and the inter-row padding
+    currentY += rowMaxHeight + padding;
+  });
+  return res;
+};

+ 13 - 1
packages/excalidraw/clipboard.ts

@@ -5,6 +5,7 @@ import {
   arrayToMap,
   isMemberOf,
   isPromiseLike,
+  EVENT,
 } from "@excalidraw/common";
 
 import { mutateElement } from "@excalidraw/element";
@@ -92,7 +93,7 @@ export const createPasteEvent = ({
     console.warn("createPasteEvent: no types or files provided");
   }
 
-  const event = new ClipboardEvent("paste", {
+  const event = new ClipboardEvent(EVENT.PASTE, {
     clipboardData: new DataTransfer(),
   });
 
@@ -519,3 +520,14 @@ const copyTextViaExecCommand = (text: string | null) => {
 
   return success;
 };
+
+export const isClipboardEvent = (
+  event: React.SyntheticEvent | Event,
+): event is ClipboardEvent => {
+  /** not using instanceof ClipboardEvent due to tests (jsdom) */
+  return (
+    event.type === EVENT.PASTE ||
+    event.type === EVENT.COPY ||
+    event.type === EVENT.CUT
+  );
+};

+ 281 - 306
packages/excalidraw/components/App.tsx

@@ -237,6 +237,7 @@ import {
   isSimpleArrow,
   StoreDelta,
   type ApplyToOptions,
+  positionElementsOnGrid,
 } from "@excalidraw/element";
 
 import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -345,7 +346,7 @@ import {
   generateIdFromFile,
   getDataURL,
   getDataURL_sync,
-  getFileFromEvent,
+  getFilesFromEvent,
   ImageURLToFile,
   isImageFileHandle,
   isSupportedImageFile,
@@ -432,7 +433,7 @@ import type {
   ScrollBars,
 } from "../scene/types";
 
-import type { PastedMixedContent } from "../clipboard";
+import type { ClipboardData, PastedMixedContent } from "../clipboard";
 import type { ExportedElements } from "../data";
 import type { ContextMenuItems } from "./ContextMenu";
 import type { FileSystemHandle } from "../data/filesystem";
@@ -3066,7 +3067,168 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
-  // TODO: this is so spaghetti, we should refactor it and cover it with tests
+  // TODO: Cover with tests
+  private async insertClipboardContent(
+    data: ClipboardData,
+    filesData: Awaited<ReturnType<typeof getFilesFromEvent>>,
+    isPlainPaste: boolean,
+  ) {
+    const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+      {
+        clientX: this.lastViewportPosition.x,
+        clientY: this.lastViewportPosition.y,
+      },
+      this.state,
+    );
+
+    // ------------------- Error -------------------
+    if (data.errorMessage) {
+      this.setState({ errorMessage: data.errorMessage });
+      return;
+    }
+
+    // ------------------- Mixed content with no files -------------------
+    if (filesData.length === 0 && !isPlainPaste && data.mixedContent) {
+      await this.addElementsFromMixedContentPaste(data.mixedContent, {
+        isPlainPaste,
+        sceneX,
+        sceneY,
+      });
+      return;
+    }
+
+    // ------------------- Spreadsheet -------------------
+    if (data.spreadsheet && !isPlainPaste) {
+      this.setState({
+        pasteDialog: {
+          data: data.spreadsheet,
+          shown: true,
+        },
+      });
+      return;
+    }
+
+    // ------------------- Images or SVG code -------------------
+    const imageFiles = filesData
+      .map((data) => data.file)
+      .filter((file): file is File => isSupportedImageFile(file));
+
+    if (imageFiles.length === 0 && data.text && !isPlainPaste) {
+      const trimmedText = data.text.trim();
+      if (trimmedText.startsWith("<svg") && trimmedText.endsWith("</svg>")) {
+        // ignore SVG validation/normalization which will be done during image
+        // initialization
+        imageFiles.push(SVGStringToFile(trimmedText));
+      }
+    }
+
+    if (imageFiles.length > 0) {
+      if (this.isToolSupported("image")) {
+        await this.insertImages(imageFiles, sceneX, sceneY);
+      } else {
+        this.setState({ errorMessage: t("errors.imageToolNotSupported") });
+      }
+      return;
+    }
+
+    // ------------------- Elements -------------------
+    if (data.elements) {
+      const elements = (
+        data.programmaticAPI
+          ? convertToExcalidrawElements(
+              data.elements as ExcalidrawElementSkeleton[],
+            )
+          : data.elements
+      ) as readonly ExcalidrawElement[];
+      // TODO: remove formatting from elements if isPlainPaste
+      this.addElementsFromPasteOrLibrary({
+        elements,
+        files: data.files || null,
+        position: this.isMobileOrTablet() ? "center" : "cursor",
+        retainSeed: isPlainPaste,
+      });
+      return;
+    }
+
+    // ------------------- Only textual stuff remaining -------------------
+    if (!data.text) {
+      return;
+    }
+
+    // ------------------- Successful Mermaid -------------------
+    if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
+      const api = await import("@excalidraw/mermaid-to-excalidraw");
+      try {
+        const { elements: skeletonElements, files } =
+          await api.parseMermaidToExcalidraw(data.text);
+
+        const elements = convertToExcalidrawElements(skeletonElements, {
+          regenerateIds: true,
+        });
+
+        this.addElementsFromPasteOrLibrary({
+          elements,
+          files,
+          position: this.isMobileOrTablet() ? "center" : "cursor",
+        });
+
+        return;
+      } catch (err: any) {
+        console.warn(
+          `parsing pasted text as mermaid definition failed: ${err.message}`,
+        );
+      }
+    }
+
+    // ------------------- Pure embeddable URLs -------------------
+    const nonEmptyLines = normalizeEOL(data.text)
+      .split(/\n+/)
+      .map((s) => s.trim())
+      .filter(Boolean);
+    const embbeddableUrls = nonEmptyLines
+      .map((str) => maybeParseEmbedSrc(str))
+      .filter(
+        (string) =>
+          embeddableURLValidator(string, this.props.validateEmbeddable) &&
+          (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
+            getEmbedLink(string)?.type === "video"),
+      );
+
+    if (
+      !isPlainPaste &&
+      embbeddableUrls.length > 0 &&
+      embbeddableUrls.length === nonEmptyLines.length
+    ) {
+      const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
+      for (const url of embbeddableUrls) {
+        const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
+          embeddables[embeddables.length - 1];
+        const embeddable = this.insertEmbeddableElement({
+          sceneX: prevEmbeddable
+            ? prevEmbeddable.x + prevEmbeddable.width + 20
+            : sceneX,
+          sceneY,
+          link: normalizeLink(url),
+        });
+        if (embeddable) {
+          embeddables.push(embeddable);
+        }
+      }
+      if (embeddables.length) {
+        this.store.scheduleCapture();
+        this.setState({
+          selectedElementIds: Object.fromEntries(
+            embeddables.map((embeddable) => [embeddable.id, true]),
+          ),
+        });
+      }
+      return;
+    }
+
+    // ------------------- Text -------------------
+    this.addTextFromPaste(data.text, isPlainPaste);
+  }
+
   public pasteFromClipboard = withBatchedUpdates(
     async (event: ClipboardEvent) => {
       const isPlainPaste = !!IS_PLAIN_PASTE;
@@ -3091,47 +3253,11 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
-      const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
-        {
-          clientX: this.lastViewportPosition.x,
-          clientY: this.lastViewportPosition.y,
-        },
-        this.state,
-      );
-
       // must be called in the same frame (thus before any awaits) as the paste
       // event else some browsers (FF...) will clear the clipboardData
       // (something something security)
-      let file = event?.clipboardData?.files[0];
+      const filesData = await getFilesFromEvent(event);
       const data = await parseClipboard(event, isPlainPaste);
-      if (!file && !isPlainPaste) {
-        if (data.mixedContent) {
-          return this.addElementsFromMixedContentPaste(data.mixedContent, {
-            isPlainPaste,
-            sceneX,
-            sceneY,
-          });
-        } else if (data.text) {
-          const string = data.text.trim();
-          if (string.startsWith("<svg") && string.endsWith("</svg>")) {
-            // ignore SVG validation/normalization which will be done during image
-            // initialization
-            file = SVGStringToFile(string);
-          }
-        }
-      }
-
-      // prefer spreadsheet data over image file (MS Office/Libre Office)
-      if (isSupportedImageFile(file) && !data.spreadsheet) {
-        if (!this.isToolSupported("image")) {
-          this.setState({ errorMessage: t("errors.imageToolNotSupported") });
-          return;
-        }
-
-        this.createImageElement({ sceneX, sceneY, imageFile: file });
-
-        return;
-      }
 
       if (this.props.onPaste) {
         try {
@@ -3143,105 +3269,7 @@ class App extends React.Component<AppProps, AppState> {
         }
       }
 
-      if (data.errorMessage) {
-        this.setState({ errorMessage: data.errorMessage });
-      } else if (data.spreadsheet && !isPlainPaste) {
-        this.setState({
-          pasteDialog: {
-            data: data.spreadsheet,
-            shown: true,
-          },
-        });
-      } else if (data.elements) {
-        const elements = (
-          data.programmaticAPI
-            ? convertToExcalidrawElements(
-                data.elements as ExcalidrawElementSkeleton[],
-              )
-            : data.elements
-        ) as readonly ExcalidrawElement[];
-        // TODO remove formatting from elements if isPlainPaste
-        this.addElementsFromPasteOrLibrary({
-          elements,
-          files: data.files || null,
-          position: this.isMobileOrTablet() ? "center" : "cursor",
-          retainSeed: isPlainPaste,
-        });
-      } else if (data.text) {
-        if (data.text && isMaybeMermaidDefinition(data.text)) {
-          const api = await import("@excalidraw/mermaid-to-excalidraw");
-
-          try {
-            const { elements: skeletonElements, files } =
-              await api.parseMermaidToExcalidraw(data.text);
-
-            const elements = convertToExcalidrawElements(skeletonElements, {
-              regenerateIds: true,
-            });
-
-            this.addElementsFromPasteOrLibrary({
-              elements,
-              files,
-              position: this.isMobileOrTablet() ? "center" : "cursor",
-            });
-
-            return;
-          } catch (err: any) {
-            console.warn(
-              `parsing pasted text as mermaid definition failed: ${err.message}`,
-            );
-          }
-        }
-
-        const nonEmptyLines = normalizeEOL(data.text)
-          .split(/\n+/)
-          .map((s) => s.trim())
-          .filter(Boolean);
-
-        const embbeddableUrls = nonEmptyLines
-          .map((str) => maybeParseEmbedSrc(str))
-          .filter((string) => {
-            return (
-              embeddableURLValidator(string, this.props.validateEmbeddable) &&
-              (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
-                getEmbedLink(string)?.type === "video")
-            );
-          });
-
-        if (
-          !IS_PLAIN_PASTE &&
-          embbeddableUrls.length > 0 &&
-          // if there were non-embeddable text (lines) mixed in with embeddable
-          // urls, ignore and paste as text
-          embbeddableUrls.length === nonEmptyLines.length
-        ) {
-          const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
-          for (const url of embbeddableUrls) {
-            const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
-              embeddables[embeddables.length - 1];
-            const embeddable = this.insertEmbeddableElement({
-              sceneX: prevEmbeddable
-                ? prevEmbeddable.x + prevEmbeddable.width + 20
-                : sceneX,
-              sceneY,
-              link: normalizeLink(url),
-            });
-            if (embeddable) {
-              embeddables.push(embeddable);
-            }
-          }
-          if (embeddables.length) {
-            this.store.scheduleCapture();
-            this.setState({
-              selectedElementIds: Object.fromEntries(
-                embeddables.map((embeddable) => [embeddable.id, true]),
-              ),
-            });
-          }
-          return;
-        }
-        this.addTextFromPaste(data.text, isPlainPaste);
-      }
+      await this.insertClipboardContent(data, filesData, isPlainPaste);
       this.setActiveTool({ type: this.defaultSelectionTool }, true);
       event?.preventDefault();
     },
@@ -3431,45 +3459,11 @@ class App extends React.Component<AppProps, AppState> {
           }
         }),
       );
-      let y = sceneY;
-      let firstImageYOffsetDone = false;
-      const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
-      for (const response of responses) {
-        if (response.file) {
-          const initializedImageElement = await this.createImageElement({
-            sceneX,
-            sceneY: y,
-            imageFile: response.file,
-          });
-
-          if (initializedImageElement) {
-            // vertically center first image in the batch
-            if (!firstImageYOffsetDone) {
-              firstImageYOffsetDone = true;
-              y -= initializedImageElement.height / 2;
-            }
-            // hack to reset the `y` coord because we vertically center during
-            // insertImageElement
-            this.scene.mutateElement(
-              initializedImageElement,
-              { y },
-              { informMutation: false, isDragging: false },
-            );
-
-            y = initializedImageElement.y + initializedImageElement.height + 25;
-
-            nextSelectedIds[initializedImageElement.id] = true;
-          }
-        }
-      }
-
-      this.setState({
-        selectedElementIds: makeNextSelectedElementIds(
-          nextSelectedIds,
-          this.state,
-        ),
-      });
 
+      const imageFiles = responses
+        .filter((response): response is { file: File } => !!response.file)
+        .map((response) => response.file);
+      await this.insertImages(imageFiles, sceneX, sceneY);
       const error = responses.find((response) => !!response.errorMessage);
       if (error && error.errorMessage) {
         this.setState({ errorMessage: error.errorMessage });
@@ -4806,7 +4800,7 @@ class App extends React.Component<AppProps, AppState> {
       this.setState({ suggestedBindings: [] });
     }
     if (nextActiveTool.type === "image") {
-      this.onImageAction();
+      this.onImageToolbarButtonClick();
     }
 
     this.setState((prevState) => {
@@ -7842,16 +7836,14 @@ class App extends React.Component<AppProps, AppState> {
     return element;
   };
 
-  private createImageElement = async ({
+  private newImagePlaceholder = ({
     sceneX,
     sceneY,
     addToFrameUnderCursor = true,
-    imageFile,
   }: {
     sceneX: number;
     sceneY: number;
     addToFrameUnderCursor?: boolean;
-    imageFile: File;
   }) => {
     const [gridX, gridY] = getGridPoint(
       sceneX,
@@ -7870,7 +7862,7 @@ class App extends React.Component<AppProps, AppState> {
 
     const placeholderSize = 100 / this.state.zoom.value;
 
-    const placeholderImageElement = newImageElement({
+    return newImageElement({
       type: "image",
       strokeColor: this.state.currentItemStrokeColor,
       backgroundColor: this.state.currentItemBackgroundColor,
@@ -7887,13 +7879,6 @@ class App extends React.Component<AppProps, AppState> {
       width: placeholderSize,
       height: placeholderSize,
     });
-
-    const initializedImageElement = await this.insertImageElement(
-      placeholderImageElement,
-      imageFile,
-    );
-
-    return initializedImageElement;
   };
 
   private handleLinearElementOnPointerDown = (
@@ -10215,64 +10200,7 @@ class App extends React.Component<AppProps, AppState> {
     );
   };
 
-  /**
-   * inserts image into elements array and rerenders
-   */
-  private insertImageElement = async (
-    placeholderImageElement: ExcalidrawImageElement,
-    imageFile: File,
-  ) => {
-    // we should be handling all cases upstream, but in case we forget to handle
-    // a future case, let's throw here
-    if (!this.isToolSupported("image")) {
-      this.setState({ errorMessage: t("errors.imageToolNotSupported") });
-      return;
-    }
-
-    this.scene.insertElement(placeholderImageElement);
-
-    try {
-      const initializedImageElement = await this.initializeImage(
-        placeholderImageElement,
-        imageFile,
-      );
-
-      const nextElements = this.scene
-        .getElementsIncludingDeleted()
-        .map((element) => {
-          if (element.id === initializedImageElement.id) {
-            return initializedImageElement;
-          }
-
-          return element;
-        });
-
-      this.updateScene({
-        captureUpdate: CaptureUpdateAction.IMMEDIATELY,
-        elements: nextElements,
-        appState: {
-          selectedElementIds: makeNextSelectedElementIds(
-            { [initializedImageElement.id]: true },
-            this.state,
-          ),
-        },
-      });
-
-      return initializedImageElement;
-    } catch (error: any) {
-      this.store.scheduleAction(CaptureUpdateAction.NEVER);
-      this.scene.mutateElement(placeholderImageElement, {
-        isDeleted: true,
-      });
-      this.actionManager.executeAction(actionFinalize);
-      this.setState({
-        errorMessage: error.message || t("errors.imageInsertError"),
-      });
-      return null;
-    }
-  };
-
-  private onImageAction = async () => {
+  private onImageToolbarButtonClick = async () => {
     try {
       const clientX = this.state.width / 2 + this.state.offsetLeft;
       const clientY = this.state.height / 2 + this.state.offsetTop;
@@ -10282,24 +10210,15 @@ class App extends React.Component<AppProps, AppState> {
         this.state,
       );
 
-      const imageFile = await fileOpen({
+      const imageFiles = await fileOpen({
         description: "Image",
         extensions: Object.keys(
           IMAGE_MIME_TYPES,
         ) as (keyof typeof IMAGE_MIME_TYPES)[],
+        multiple: true,
       });
 
-      await this.createImageElement({
-        sceneX: x,
-        sceneY: y,
-        addToFrameUnderCursor: false,
-        imageFile,
-      });
-
-      // avoid being batched (just in case)
-      this.setState({}, () => {
-        this.actionManager.executeAction(actionFinalize);
-      });
+      this.insertImages(imageFiles, x, y);
     } catch (error: any) {
       if (error.name !== "AbortError") {
         console.error(error);
@@ -10496,60 +10415,113 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
+  private insertImages = async (
+    imageFiles: File[],
+    sceneX: number,
+    sceneY: number,
+  ) => {
+    const gridPadding = 50 / this.state.zoom.value;
+    // Create, position, and insert placeholders
+    const placeholders = positionElementsOnGrid(
+      imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })),
+      sceneX,
+      sceneY,
+      gridPadding,
+    );
+    placeholders.forEach((el) => this.scene.insertElement(el));
+
+    // Create, position, insert and select initialized (replacing placeholders)
+    const initialized = await Promise.all(
+      placeholders.map(async (placeholder, i) => {
+        try {
+          return await this.initializeImage(placeholder, imageFiles[i]);
+        } catch (error: any) {
+          this.setState({
+            errorMessage: error.message || t("errors.imageInsertError"),
+          });
+          return newElementWith(placeholder, { isDeleted: true });
+        }
+      }),
+    );
+    const initializedMap = arrayToMap(initialized);
+
+    const positioned = positionElementsOnGrid(
+      initialized.filter((el) => !el.isDeleted),
+      sceneX,
+      sceneY,
+      gridPadding,
+    );
+    const positionedMap = arrayToMap(positioned);
+
+    const nextElements = this.scene
+      .getElementsIncludingDeleted()
+      .map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el);
+
+    this.updateScene({
+      appState: {
+        selectedElementIds: makeNextSelectedElementIds(
+          Object.fromEntries(positioned.map((el) => [el.id, true])),
+          this.state,
+        ),
+      },
+      elements: nextElements,
+      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+    });
+
+    this.setState({}, () => {
+      // actionFinalize after all state values have been updated
+      this.actionManager.executeAction(actionFinalize);
+    });
+  };
+
   private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
-    // must be retrieved first, in the same frame
-    const { file, fileHandle } = await getFileFromEvent(event);
     const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
       event,
       this.state,
     );
 
-    try {
-      // if image tool not supported, don't show an error here and let it fall
-      // through so we still support importing scene data from images. If no
-      // scene data encoded, we'll show an error then
-      if (isSupportedImageFile(file) && this.isToolSupported("image")) {
-        // first attempt to decode scene from the image if it's embedded
-        // ---------------------------------------------------------------------
-
-        if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
-          try {
-            const scene = await loadFromBlob(
-              file,
-              this.state,
-              this.scene.getElementsIncludingDeleted(),
-              fileHandle,
-            );
-            this.syncActionResult({
-              ...scene,
-              appState: {
-                ...(scene.appState || this.state),
-                isLoading: false,
-              },
-              replaceFiles: true,
-              captureUpdate: CaptureUpdateAction.IMMEDIATELY,
-            });
-            return;
-          } catch (error: any) {
-            // Don't throw for image scene daa
-            if (error.name !== "EncodingError") {
-              throw new Error(t("alerts.couldNotLoadInvalidFile"));
-            }
+    // must be retrieved first, in the same frame
+    const filesData = await getFilesFromEvent(event);
+
+    if (filesData.length === 1) {
+      const { file, fileHandle } = filesData[0];
+
+      if (
+        file &&
+        (file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg)
+      ) {
+        try {
+          const scene = await loadFromBlob(
+            file,
+            this.state,
+            this.scene.getElementsIncludingDeleted(),
+            fileHandle,
+          );
+          this.syncActionResult({
+            ...scene,
+            appState: {
+              ...(scene.appState || this.state),
+              isLoading: false,
+            },
+            replaceFiles: true,
+            captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+          });
+          return;
+        } catch (error: any) {
+          if (error.name !== "EncodingError") {
+            throw new Error(t("alerts.couldNotLoadInvalidFile"));
           }
+          // if EncodingError, fall through to insert as regular image
         }
+      }
+    }
 
-        // if no scene is embedded or we fail for whatever reason, fall back
-        // to importing as regular image
-        // ---------------------------------------------------------------------
-        this.createImageElement({ sceneX, sceneY, imageFile: file });
+    const imageFiles = filesData
+      .map((data) => data.file)
+      .filter((file): file is File => isSupportedImageFile(file));
 
-        return;
-      }
-    } catch (error: any) {
-      return this.setState({
-        isLoading: false,
-        errorMessage: error.message,
-      });
+    if (imageFiles.length > 0 && this.isToolSupported("image")) {
+      return this.insertImages(imageFiles, sceneX, sceneY);
     }
 
     const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
@@ -10567,9 +10539,12 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    if (file) {
-      // Attempt to parse an excalidraw/excalidrawlib file
-      await this.loadFileToCanvas(file, fileHandle);
+    if (filesData.length > 0) {
+      const { file, fileHandle } = filesData[0];
+      if (file) {
+        // Attempt to parse an excalidraw/excalidrawlib file
+        await this.loadFileToCanvas(file, fileHandle);
+      }
     }
 
     if (event.dataTransfer?.types?.includes("text/plain")) {

+ 41 - 8
packages/excalidraw/data/blob.ts

@@ -18,6 +18,8 @@ import { CanvasError, ImageSceneDataError } from "../errors";
 import { calculateScrollCenter } from "../scene";
 import { decodeSvgBase64Payload } from "../scene/export";
 
+import { isClipboardEvent } from "../clipboard";
+
 import { base64ToString, stringToBase64, toByteString } from "./encode";
 import { nativeFileSystemSupported } from "./filesystem";
 import { isValidExcalidrawData, isValidLibrary } from "./json";
@@ -389,23 +391,54 @@ export const ImageURLToFile = async (
   throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
 };
 
-export const getFileFromEvent = async (
-  event: React.DragEvent<HTMLDivElement>,
+export const getFilesFromEvent = async (
+  event: React.DragEvent<HTMLDivElement> | ClipboardEvent,
 ) => {
-  const file = event.dataTransfer.files.item(0);
-  const fileHandle = await getFileHandle(event);
+  let fileList: FileList | undefined = undefined;
+  let items: DataTransferItemList | undefined = undefined;
+
+  if (isClipboardEvent(event)) {
+    fileList = event.clipboardData?.files;
+    items = event.clipboardData?.items;
+  } else {
+    const dragEvent = event as React.DragEvent<HTMLDivElement>;
+    fileList = dragEvent.dataTransfer?.files;
+    items = dragEvent.dataTransfer?.items;
+  }
 
-  return { file: file ? await normalizeFile(file) : null, fileHandle };
+  const files: (File | null)[] = Array.from(fileList || []);
+
+  return await Promise.all(
+    files.map(async (file, idx) => {
+      const dataTransferItem = items?.[idx];
+      const fileHandle = dataTransferItem
+        ? getFileHandle(dataTransferItem)
+        : null;
+      return file
+        ? {
+            file: await normalizeFile(file),
+            fileHandle: await fileHandle,
+          }
+        : {
+            file: null,
+            fileHandle: null,
+          };
+    }),
+  );
 };
 
 export const getFileHandle = async (
-  event: React.DragEvent<HTMLDivElement>,
+  event: DragEvent | React.DragEvent | DataTransferItem,
 ): Promise<FileSystemHandle | null> => {
   if (nativeFileSystemSupported) {
     try {
-      const item = event.dataTransfer.items[0];
+      const dataTransferItem =
+        event instanceof DataTransferItem
+          ? event
+          : (event as DragEvent).dataTransfer?.items?.[0];
+
       const handle: FileSystemHandle | null =
-        (await (item as any).getAsFileSystemHandle()) || null;
+        (await (dataTransferItem as any).getAsFileSystemHandle()) || null;
 
       return handle;
     } catch (error: any) {

+ 180 - 32
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -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] 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 elements 1`] = `1`;
+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`] = `7`;
+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,
+  "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] 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": -28,
-  "y": "-38.50000",
+  "x": 156,
+  "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] 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`] = `7`;
+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,
+            "version": 7,
+            "width": 318,
+            "x": -212,
+            "y": "-167.50000",
+          },
+          "inserted": {
+            "isDeleted": true,
+            "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": -28,
-            "y": "-38.50000",
+            "x": 156,
+            "y": "-167.50000",
           },
           "inserted": {
             "isDeleted": true,
-            "version": 4,
+            "version": 6,
           },
         },
       },
       "updated": {},
     },
-    "id": "id4",
+    "id": "id7",
   },
 ]
 `;

+ 9 - 0
packages/excalidraw/tests/fixtures/constants.ts

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

+ 3 - 7
packages/excalidraw/tests/flip.test.tsx

@@ -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
packages/excalidraw/tests/helpers/api.ts

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

+ 6 - 0
packages/excalidraw/tests/helpers/constants.ts

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

+ 32 - 0
packages/excalidraw/tests/helpers/mocks.ts

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

+ 67 - 126
packages/excalidraw/tests/history.test.tsx

@@ -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,
           }),
-        ]);
-        // 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,
+            ...INITIALIZED_IMAGE_PROPS,
+            ...SMILEY_IMAGE_DIMENSIONS,
           }),
-        );
+        ]);
       });
 
       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,
+          ...DEER_IMAGE_DIMENSIONS,
+        }),
+        expect.objectContaining({
+          ...INITIALIZED_IMAGE_PROPS,
           isDeleted: false,
-          ...smileyImageDimensions,
+          ...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 - 0
packages/excalidraw/tests/image.test.tsx

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