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

View File

@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day 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) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
arrayToMap, arrayToMap,
isMemberOf, isMemberOf,
isPromiseLike, isPromiseLike,
EVENT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element";
@@ -92,7 +93,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided"); console.warn("createPasteEvent: no types or files provided");
} }
const event = new ClipboardEvent("paste", { const event = new ClipboardEvent(EVENT.PASTE, {
clipboardData: new DataTransfer(), clipboardData: new DataTransfer(),
}); });
@@ -519,3 +520,14 @@ const copyTextViaExecCommand = (text: string | null) => {
return success; 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
);
};

View File

@@ -237,6 +237,7 @@ import {
isSimpleArrow, isSimpleArrow,
StoreDelta, StoreDelta,
type ApplyToOptions, type ApplyToOptions,
positionElementsOnGrid,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -345,7 +346,7 @@ import {
generateIdFromFile, generateIdFromFile,
getDataURL, getDataURL,
getDataURL_sync, getDataURL_sync,
getFileFromEvent, getFilesFromEvent,
ImageURLToFile, ImageURLToFile,
isImageFileHandle, isImageFileHandle,
isSupportedImageFile, isSupportedImageFile,
@@ -432,7 +433,7 @@ import type {
ScrollBars, ScrollBars,
} from "../scene/types"; } from "../scene/types";
import type { PastedMixedContent } from "../clipboard"; import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@@ -3066,31 +3067,12 @@ 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
public pasteFromClipboard = withBatchedUpdates( private async insertClipboardContent(
async (event: ClipboardEvent) => { data: ClipboardData,
const isPlainPaste = !!IS_PLAIN_PASTE; filesData: Awaited<ReturnType<typeof getFilesFromEvent>>,
isPlainPaste: boolean,
// #686
const target = document.activeElement;
const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) {
return;
}
const elementUnderCursor = document.elementFromPoint(
this.lastViewportPosition.x,
this.lastViewportPosition.y,
);
if (
event &&
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
isWritableElement(target))
) { ) {
return;
}
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ {
clientX: this.lastViewportPosition.x, clientX: this.lastViewportPosition.x,
@@ -3099,60 +3081,58 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
// must be called in the same frame (thus before any awaits) as the paste // ------------------- Error -------------------
// event else some browsers (FF...) will clear the clipboardData if (data.errorMessage) {
// (something something security) this.setState({ errorMessage: data.errorMessage });
let file = event?.clipboardData?.files[0]; return;
const data = await parseClipboard(event, isPlainPaste); }
if (!file && !isPlainPaste) {
if (data.mixedContent) { // ------------------- Mixed content with no files -------------------
return this.addElementsFromMixedContentPaste(data.mixedContent, { if (filesData.length === 0 && !isPlainPaste && data.mixedContent) {
await this.addElementsFromMixedContentPaste(data.mixedContent, {
isPlainPaste, isPlainPaste,
sceneX, sceneX,
sceneY, 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; return;
} }
this.createImageElement({ sceneX, sceneY, imageFile: file }); // ------------------- Spreadsheet -------------------
if (data.spreadsheet && !isPlainPaste) {
return;
}
if (this.props.onPaste) {
try {
if ((await this.props.onPaste(data, event)) === false) {
return;
}
} catch (error: any) {
console.error(error);
}
}
if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
} else if (data.spreadsheet && !isPlainPaste) {
this.setState({ this.setState({
pasteDialog: { pasteDialog: {
data: data.spreadsheet, data: data.spreadsheet,
shown: true, shown: true,
}, },
}); });
} else if (data.elements) { 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 = ( const elements = (
data.programmaticAPI data.programmaticAPI
? convertToExcalidrawElements( ? convertToExcalidrawElements(
@@ -3160,17 +3140,24 @@ class App extends React.Component<AppProps, AppState> {
) )
: data.elements : data.elements
) as readonly ExcalidrawElement[]; ) as readonly ExcalidrawElement[];
// TODO remove formatting from elements if isPlainPaste // TODO: remove formatting from elements if isPlainPaste
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files: data.files || null, files: data.files || null,
position: this.isMobileOrTablet() ? "center" : "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste, retainSeed: isPlainPaste,
}); });
} else if (data.text) { return;
if (data.text && isMaybeMermaidDefinition(data.text)) { }
const api = await import("@excalidraw/mermaid-to-excalidraw");
// ------------------- Only textual stuff remaining -------------------
if (!data.text) {
return;
}
// ------------------- Successful Mermaid -------------------
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try { try {
const { elements: skeletonElements, files } = const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text); await api.parseMermaidToExcalidraw(data.text);
@@ -3193,26 +3180,23 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
// ------------------- Pure embeddable URLs -------------------
const nonEmptyLines = normalizeEOL(data.text) const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/) .split(/\n+/)
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const embbeddableUrls = nonEmptyLines const embbeddableUrls = nonEmptyLines
.map((str) => maybeParseEmbedSrc(str)) .map((str) => maybeParseEmbedSrc(str))
.filter((string) => { .filter(
return ( (string) =>
embeddableURLValidator(string, this.props.validateEmbeddable) && embeddableURLValidator(string, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
getEmbedLink(string)?.type === "video") getEmbedLink(string)?.type === "video"),
); );
});
if ( if (
!IS_PLAIN_PASTE && !isPlainPaste &&
embbeddableUrls.length > 0 && embbeddableUrls.length > 0 &&
// if there were non-embeddable text (lines) mixed in with embeddable
// urls, ignore and paste as text
embbeddableUrls.length === nonEmptyLines.length embbeddableUrls.length === nonEmptyLines.length
) { ) {
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = []; const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
@@ -3240,8 +3224,52 @@ class App extends React.Component<AppProps, AppState> {
} }
return; return;
} }
// ------------------- Text -------------------
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
} }
public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent) => {
const isPlainPaste = !!IS_PLAIN_PASTE;
// #686
const target = document.activeElement;
const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) {
return;
}
const elementUnderCursor = document.elementFromPoint(
this.lastViewportPosition.x,
this.lastViewportPosition.y,
);
if (
event &&
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
isWritableElement(target))
) {
return;
}
// 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)
const filesData = await getFilesFromEvent(event);
const data = await parseClipboard(event, isPlainPaste);
if (this.props.onPaste) {
try {
if ((await this.props.onPaste(data, event)) === false) {
return;
}
} catch (error: any) {
console.error(error);
}
}
await this.insertClipboardContent(data, filesData, isPlainPaste);
this.setActiveTool({ type: this.defaultSelectionTool }, true); this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault(); 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); const error = responses.find((response) => !!response.errorMessage);
if (error && error.errorMessage) { if (error && error.errorMessage) {
this.setState({ errorMessage: error.errorMessage }); this.setState({ errorMessage: error.errorMessage });
@@ -4806,7 +4800,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
} }
if (nextActiveTool.type === "image") { if (nextActiveTool.type === "image") {
this.onImageAction(); this.onImageToolbarButtonClick();
} }
this.setState((prevState) => { this.setState((prevState) => {
@@ -7842,16 +7836,14 @@ class App extends React.Component<AppProps, AppState> {
return element; return element;
}; };
private createImageElement = async ({ private newImagePlaceholder = ({
sceneX, sceneX,
sceneY, sceneY,
addToFrameUnderCursor = true, addToFrameUnderCursor = true,
imageFile,
}: { }: {
sceneX: number; sceneX: number;
sceneY: number; sceneY: number;
addToFrameUnderCursor?: boolean; addToFrameUnderCursor?: boolean;
imageFile: File;
}) => { }) => {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
sceneX, sceneX,
@@ -7870,7 +7862,7 @@ class App extends React.Component<AppProps, AppState> {
const placeholderSize = 100 / this.state.zoom.value; const placeholderSize = 100 / this.state.zoom.value;
const placeholderImageElement = newImageElement({ return newImageElement({
type: "image", type: "image",
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
@@ -7887,13 +7879,6 @@ class App extends React.Component<AppProps, AppState> {
width: placeholderSize, width: placeholderSize,
height: placeholderSize, height: placeholderSize,
}); });
const initializedImageElement = await this.insertImageElement(
placeholderImageElement,
imageFile,
);
return initializedImageElement;
}; };
private handleLinearElementOnPointerDown = ( private handleLinearElementOnPointerDown = (
@@ -10215,64 +10200,7 @@ class App extends React.Component<AppProps, AppState> {
); );
}; };
/** private onImageToolbarButtonClick = async () => {
* 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 () => {
try { try {
const clientX = this.state.width / 2 + this.state.offsetLeft; const clientX = this.state.width / 2 + this.state.offsetLeft;
const clientY = this.state.height / 2 + this.state.offsetTop; const clientY = this.state.height / 2 + this.state.offsetTop;
@@ -10282,24 +10210,15 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
const imageFile = await fileOpen({ const imageFiles = await fileOpen({
description: "Image", description: "Image",
extensions: Object.keys( extensions: Object.keys(
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[], ) as (keyof typeof IMAGE_MIME_TYPES)[],
multiple: true,
}); });
await this.createImageElement({ this.insertImages(imageFiles, x, y);
sceneX: x,
sceneY: y,
addToFrameUnderCursor: false,
imageFile,
});
// avoid being batched (just in case)
this.setState({}, () => {
this.actionManager.executeAction(actionFinalize);
});
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
console.error(error); console.error(error);
@@ -10496,23 +10415,81 @@ 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>) => { 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( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
); );
try { // must be retrieved first, in the same frame
// if image tool not supported, don't show an error here and let it fall const filesData = await getFilesFromEvent(event);
// 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) { if (filesData.length === 1) {
const { file, fileHandle } = filesData[0];
if (
file &&
(file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg)
) {
try { try {
const scene = await loadFromBlob( const scene = await loadFromBlob(
file, file,
@@ -10531,25 +10508,20 @@ class App extends React.Component<AppProps, AppState> {
}); });
return; return;
} catch (error: any) { } catch (error: any) {
// Don't throw for image scene daa
if (error.name !== "EncodingError") { if (error.name !== "EncodingError") {
throw new Error(t("alerts.couldNotLoadInvalidFile")); 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 const imageFiles = filesData
// to importing as regular image .map((data) => data.file)
// --------------------------------------------------------------------- .filter((file): file is File => isSupportedImageFile(file));
this.createImageElement({ sceneX, sceneY, imageFile: file });
return; if (imageFiles.length > 0 && this.isToolSupported("image")) {
} return this.insertImages(imageFiles, sceneX, sceneY);
} catch (error: any) {
return this.setState({
isLoading: false,
errorMessage: error.message,
});
} }
const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib); const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
@@ -10567,10 +10539,13 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (filesData.length > 0) {
const { file, fileHandle } = filesData[0];
if (file) { if (file) {
// Attempt to parse an excalidraw/excalidrawlib file // Attempt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle); await this.loadFileToCanvas(file, fileHandle);
} }
}
if (event.dataTransfer?.types?.includes("text/plain")) { if (event.dataTransfer?.types?.includes("text/plain")) {
const text = event.dataTransfer?.getData("text"); const text = event.dataTransfer?.getData("text");

View File

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

View File

@@ -12534,10 +12534,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": { "originSnapOffset": null,
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@@ -12759,6 +12756,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -12793,7 +12791,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "fileId", "fileId": "id2",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -12816,16 +12814,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"updated": 1, "updated": 1,
"version": 5, "version": 7,
"width": 318, "width": 318,
"x": -159, "x": -212,
"y": "-167.50000", "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`] = `[]`; 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": { "deleted": {
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
}, },
"inserted": { "inserted": {
@@ -12854,7 +12890,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "fileId", "fileId": "id2",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -12875,20 +12911,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"version": 5, "version": 7,
"width": 318, "width": 318,
"x": -159, "x": -212,
"y": "-167.50000", "y": "-167.50000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "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": {}, "updated": {},
}, },
"id": "id4", "id": "id7",
}, },
] ]
`; `;
@@ -12964,10 +13038,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": { "originSnapOffset": null,
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@@ -12981,6 +13052,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -13015,11 +13087,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "fileId", "fileId": "id2",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 77, "height": 335,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@@ -13038,16 +13110,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"updated": 1, "updated": 1,
"version": 5, "version": 7,
"width": 56, "width": 318,
"x": -28, "x": -212,
"y": "-38.50000", "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`] = `[]`; 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": { "deleted": {
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
}, },
"inserted": { "inserted": {
@@ -13076,11 +13186,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "fileId", "fileId": "id2",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 77, "height": 335,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -13097,20 +13207,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"version": 5, "version": 7,
"width": 56, "width": 318,
"x": -28, "x": -212,
"y": "-38.50000", "y": "-167.50000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "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": {}, "updated": {},
}, },
"id": "id4", "id": "id7",
}, },
] ]
`; `;

View File

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

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) // 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 * as blobModule from "../data/blob";
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { import {
@@ -744,11 +745,6 @@ describe("freedraw", () => {
//image //image
//TODO: currently there is no test for pixel colors at flipped positions. //TODO: currently there is no test for pixel colors at flipped positions.
describe("image", () => { describe("image", () => {
const smileyImageDimensions = {
width: 56,
height: 77,
};
beforeEach(() => { beforeEach(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image // it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000; h.state.height = 1000;
@@ -756,8 +752,8 @@ describe("image", () => {
beforeAll(() => { beforeAll(() => {
mockHTMLImageElement( mockHTMLImageElement(
smileyImageDimensions.width, SMILEY_IMAGE_DIMENSIONS.width,
smileyImageDimensions.height, SMILEY_IMAGE_DIMENSIONS.height,
); );
}); });

View File

@@ -478,9 +478,13 @@ 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 fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const text = await new Promise<string>((resolve, reject) => { const texts = await Promise.all(
blobs.map(
(blob) =>
new Promise<string>((resolve, reject) => {
try { try {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
@@ -490,21 +494,27 @@ export class API {
} catch (error: any) { } catch (error: any) {
reject(error); 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]; files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", { Object.defineProperty(fileDropEvent, "dataTransfer", {
value: { value: {
files, files,
getData: (type: string) => { getData: (type: string) => {
if (type === blob.type || type === "text") { const idx = blobs.findIndex((b) => b.type === type);
return text; if (idx >= 0) {
return texts[idx];
}
if (type === "text") {
return texts.join("\n");
} }
return ""; return "";
}, },
types: [blob.type], types: Array.from(new Set(blobs.map((b) => b.type))),
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", { Object.defineProperty(fileDropEvent, "clientX", {

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed, reseed,
randomId,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@@ -58,9 +59,13 @@ import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob"; import * as blobModule from "../data/blob";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { mockHTMLImageElement } from "./helpers/mocks"; import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
import { import {
GlobalTestState, GlobalTestState,
act, act,
@@ -71,6 +76,7 @@ import {
checkpointHistory, checkpointHistory,
unmountComponent, unmountComponent,
} from "./test-utils"; } from "./test-utils";
import { setupImageTest as _setupImageTest } from "./image.test";
import type { AppState } from "../types"; import type { AppState } from "../types";
@@ -123,7 +129,9 @@ describe("history", () => {
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile"); const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile"); 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)); resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, { 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 () => { it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
@@ -730,54 +664,29 @@ describe("history", () => {
]); ]);
}); });
it("should create new history entry on image paste", async () => { const setupImageTest = () =>
await render( _setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
<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 assertImageTest = async () => {
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); 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({ expect.objectContaining({
type: "image", ...INITIALIZED_IMAGE_PROPS,
fileId: expect.any(String), ...DEER_IMAGE_DIMENSIONS,
x: expect.toBeNonNaNNumber(), }),
y: expect.toBeNonNaNNumber(), expect.objectContaining({
...smileyImageDimensions, ...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(); Keyboard.undo();
@@ -785,12 +694,14 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
type: "image", ...INITIALIZED_IMAGE_PROPS,
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true, 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(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
type: "image", ...INITIALIZED_IMAGE_PROPS,
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false, 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 () => { it("should create new history entry on embeddable link paste", async () => {

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