diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 1dc6c6f46..52c1ad7ba 100644 --- a/excalidraw-app/app_constants.ts +++ b/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; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 2c07631a7..6b190de1b 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -1126,7 +1126,9 @@ export interface BoundingBox { } export const getCommonBoundingBox = ( - elements: ExcalidrawElement[] | readonly NonDeleted[], + elements: + | readonly ExcalidrawElement[] + | readonly NonDeleted[], ): BoundingBox => { const [minX, minY, maxX, maxY] = getCommonBounds(elements); return { diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 9bf5214d0..4fc1ef557 100644 --- a/packages/element/src/index.ts +++ b/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"; diff --git a/packages/element/src/positionElementsOnGrid.ts b/packages/element/src/positionElementsOnGrid.ts new file mode 100644 index 000000000..017ee1fd9 --- /dev/null +++ b/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 = ( + 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), + ); + }); + + // 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; +}; diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 99b7d41f4..ceaee38c8 100644 --- a/packages/excalidraw/clipboard.ts +++ b/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 + ); +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 337fe180a..bf838b1c3 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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 { } }; - // 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>, + 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("")) { + // 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[] = []; + 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 { 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("")) { - // 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 { } } - 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[] = []; - 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 { } }), ); - let y = sceneY; - let firstImageYOffsetDone = false; - const nextSelectedIds: Record = {}; - 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 { this.setState({ suggestedBindings: [] }); } if (nextActiveTool.type === "image") { - this.onImageAction(); + this.onImageToolbarButtonClick(); } this.setState((prevState) => { @@ -7842,16 +7836,14 @@ class App extends React.Component { 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 { 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 { width: placeholderSize, height: placeholderSize, }); - - const initializedImageElement = await this.insertImageElement( - placeholderImageElement, - imageFile, - ); - - return initializedImageElement; }; private handleLinearElementOnPointerDown = ( @@ -10215,64 +10200,7 @@ class App extends React.Component { ); }; - /** - * 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 { 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 { } }; + 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) => { - // 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 - // --------------------------------------------------------------------- + // must be retrieved first, in the same frame + const filesData = await getFilesFromEvent(event); - 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")); - } + 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 }); - - return; } - } catch (error: any) { - return this.setState({ - isLoading: false, - errorMessage: error.message, - }); + } + + const imageFiles = filesData + .map((data) => data.file) + .filter((file): file is File => isSupportedImageFile(file)); + + 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 { 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")) { diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index d990fd050..dc65cf0d3 100644 --- a/packages/excalidraw/data/blob.ts +++ b/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, +export const getFilesFromEvent = async ( + event: React.DragEvent | ClipboardEvent, ) => { - const file = event.dataTransfer.files.item(0); - const fileHandle = await getFileHandle(event); + let fileList: FileList | undefined = undefined; + 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; + 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 ( - event: React.DragEvent, + event: DragEvent | React.DragEvent | DataTransferItem, ): Promise => { 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) { diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c31b9ea7c..dbe5e3858 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/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] number of elements 1`] = `1`; +exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "id": "id1", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "updated": 1, + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", +} +`; -exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `7`; +exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `2`; + +exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `8`; exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`; @@ -12837,6 +12872,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "deleted": { "selectedElementIds": { "id0": true, + "id1": true, }, }, "inserted": { @@ -12854,7 +12890,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12875,20 +12911,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeStyle": "solid", "strokeWidth": 2, "type": "image", - "version": 5, + "version": 7, "width": 318, - "x": -159, + "x": -212, "y": "-167.50000", }, "inserted": { "isDeleted": true, - "version": 4, + "version": 6, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", + }, + "inserted": { + "isDeleted": true, + "version": 6, }, }, }, "updated": {}, }, - "id": "id4", + "id": "id7", }, ] `; @@ -12964,10 +13038,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "openMenu": null, "openPopup": null, "openSidebar": null, - "originSnapOffset": { - "x": 0, - "y": 0, - }, + "originSnapOffset": null, "pasteDialog": { "data": null, "shown": false, @@ -12981,6 +13052,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "searchMatches": null, "selectedElementIds": { "id0": true, + "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -13015,11 +13087,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 77, + "height": 335, "id": "id0", "index": "a0", "isDeleted": false, @@ -13038,16 +13110,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeWidth": 2, "type": "image", "updated": 1, - "version": 5, - "width": 56, - "x": -28, - "y": "-38.50000", + "version": 7, + "width": 318, + "x": -212, + "y": "-167.50000", } `; -exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `1`; +exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] element 1 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "id": "id1", + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "updated": 1, + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", +} +`; -exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `7`; +exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `2`; + +exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `8`; exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`; @@ -13059,6 +13168,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "deleted": { "selectedElementIds": { "id0": true, + "id1": true, }, }, "inserted": { @@ -13076,11 +13186,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "boundElements": null, "crop": null, "customData": undefined, - "fileId": "fileId", + "fileId": "id2", "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 77, + "height": 335, "index": "a0", "isDeleted": false, "link": null, @@ -13097,20 +13207,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "strokeStyle": "solid", "strokeWidth": 2, "type": "image", - "version": 5, - "width": 56, - "x": -28, - "y": "-38.50000", + "version": 7, + "width": 318, + "x": -212, + "y": "-167.50000", }, "inserted": { "isDeleted": true, - "version": 4, + "version": 6, + }, + }, + "id1": { + "deleted": { + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "crop": null, + "customData": undefined, + "fileId": "id3", + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 77, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "scale": [ + 1, + 1, + ], + "status": "pending", + "strokeColor": "transparent", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "image", + "version": 7, + "width": 56, + "x": 156, + "y": "-167.50000", + }, + "inserted": { + "isDeleted": true, + "version": 6, }, }, }, "updated": {}, }, - "id": "id4", + "id": "id7", }, ] `; diff --git a/packages/excalidraw/tests/fixtures/constants.ts b/packages/excalidraw/tests/fixtures/constants.ts new file mode 100644 index 000000000..04a246fd0 --- /dev/null +++ b/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, +}; diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index e965a0068..7e175e614 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/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, ); }); diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index f37807953..2de2a2890 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/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((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((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); }; diff --git a/packages/excalidraw/tests/helpers/constants.ts b/packages/excalidraw/tests/helpers/constants.ts new file mode 100644 index 000000000..c12b8b8d8 --- /dev/null +++ b/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(), +}; diff --git a/packages/excalidraw/tests/helpers/mocks.ts b/packages/excalidraw/tests/helpers/mocks.ts index e7fa62911..a2f3e8f3f 100644 --- a/packages/excalidraw/tests/helpers/mocks.ts +++ b/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); + }); + } + }, + ); +}; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 47d87ce6d..ed9d5137a 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/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(); - - // 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(); @@ -730,54 +664,29 @@ describe("history", () => { ]); }); - it("should create new history entry on image paste", async () => { - await render( - , - ); - - // it's necessary to specify the height in order to calculate natural dimensions of the image - h.state.height = 1000; - - const smileyImageDimensions = { - width: 56, - height: 77, - }; - - mockHTMLImageElement( - smileyImageDimensions.width, - smileyImageDimensions.height, - ); - - document.dispatchEvent( - createPasteEvent({ - files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")], - }), - ); + const setupImageTest = () => + _setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]); + const assertImageTest = async () => { await waitFor(() => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(0); - expect(h.elements).toEqual([ + + // need to check that delta actually contains initialized image elements (with fileId & natural dimensions) + expect( + Object.values(h.history.undoStack[0].elements.removed).map( + (val) => val.deleted, + ), + ).toEqual([ expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - ...smileyImageDimensions, + ...INITIALIZED_IMAGE_PROPS, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + ...SMILEY_IMAGE_DIMENSIONS, }), ]); - // need to check that delta actually contains initialized image element (with fileId & natural dimensions) - expect( - Object.values(h.history.undoStack[0].elements.removed)[0].deleted, - ).toEqual( - expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), - ...smileyImageDimensions, - }), - ); }); Keyboard.undo(); @@ -785,12 +694,14 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), + ...INITIALIZED_IMAGE_PROPS, isDeleted: true, - ...smileyImageDimensions, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + isDeleted: true, + ...SMILEY_IMAGE_DIMENSIONS, }), ]); @@ -799,14 +710,44 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(h.elements).toEqual([ expect.objectContaining({ - type: "image", - fileId: expect.any(String), - x: expect.toBeNonNaNNumber(), - y: expect.toBeNonNaNNumber(), + ...INITIALIZED_IMAGE_PROPS, isDeleted: false, - ...smileyImageDimensions, + ...DEER_IMAGE_DIMENSIONS, + }), + expect.objectContaining({ + ...INITIALIZED_IMAGE_PROPS, + isDeleted: false, + ...SMILEY_IMAGE_DIMENSIONS, }), ]); + }; + + it("should create new history entry on image drag&drop", async () => { + await setupImageTest(); + + await API.drop( + await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]), + ); + + await assertImageTest(); + }); + + it("should create new history entry on image paste", async () => { + await setupImageTest(); + + document.dispatchEvent( + createPasteEvent({ + files: await Promise.all([ + API.loadFile("./fixtures/deer.png"), + API.loadFile("./fixtures/smiley.png"), + ]), + }), + ); + + await assertImageTest(); }); it("should create new history entry on embeddable link paste", async () => { diff --git a/packages/excalidraw/tests/image.test.tsx b/packages/excalidraw/tests/image.test.tsx new file mode 100644 index 000000000..f9a372ed6 --- /dev/null +++ b/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(); + + 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(); + }); +});