diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 96dbdef3f..65842dfcf 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9398,7 +9398,7 @@ class App extends React.Component { /** * inserts image into elements array and rerenders */ - private insertImageElement = async ( + insertImageElement = async ( imageElement: ExcalidrawImageElement, imageFile: File, showCursorImagePreview?: boolean, @@ -9551,7 +9551,7 @@ class App extends React.Component { } }; - private initializeImageDimensions = ( + initializeImageDimensions = ( imageElement: ExcalidrawImageElement, forceNaturalSize = false, ) => { @@ -10161,19 +10161,34 @@ class App extends React.Component { this.getEffectiveGridSize(), ); - if (transformHandleType) { - cropElement( - this.state.croppingElement, - this.scene.getNonDeletedElementsMap(), - this.imageCache, - transformHandleType, - x, - y, - ); + const element = this.state.croppingElement; - this.setState({ - isCropping: transformHandleType && transformHandleType !== "rotation", - }); + if (transformHandleType) { + const image = + isInitializedImageElement(element) && + this.imageCache.get(element.fileId)?.image; + + if (image && !(image instanceof Promise)) { + mutateElement( + element, + cropElement( + element, + transformHandleType, + image.naturalWidth, + image.naturalHeight, + x, + y, + ), + ); + + updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { + oldSize: { width: element.width, height: element.height }, + }); + + this.setState({ + isCropping: transformHandleType && transformHandleType !== "rotation", + }); + } return true; } diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts index fece3f6ce..7d4c95c98 100644 --- a/packages/excalidraw/element/cropElement.ts +++ b/packages/excalidraw/element/cropElement.ts @@ -12,8 +12,6 @@ import { pointFromVector, clamp, } from "../../math"; -import { updateBoundElements } from "./binding"; -import { mutateElement } from "./mutateElement"; import type { TransformHandleType } from "./transformHandles"; import type { ElementsMap, @@ -21,16 +19,13 @@ import type { ExcalidrawImageElement, ImageCrop, NonDeleted, - NonDeletedSceneElementsMap, } from "./types"; import { getElementAbsoluteCoords, getResizedElementAbsoluteCoords, } from "./bounds"; -import type { AppClassProperties } from "../types"; -import { isInitializedImageElement } from "./typeChecks"; -const _cropElement = ( +export const cropElement = ( element: ExcalidrawImageElement, transformHandle: TransformHandleType, naturalWidth: number, @@ -152,36 +147,6 @@ const _cropElement = ( }; }; -export const cropElement = ( - element: ExcalidrawImageElement, - elementsMap: NonDeletedSceneElementsMap, - imageCache: AppClassProperties["imageCache"], - transformHandle: TransformHandleType, - pointerX: number, - pointerY: number, -) => { - const image = - isInitializedImageElement(element) && imageCache.get(element.fileId)?.image; - - if (image && !(image instanceof Promise)) { - mutateElement( - element, - _cropElement( - element, - transformHandle, - image.naturalWidth, - image.naturalHeight, - pointerX, - pointerY, - ), - ); - - updateBoundElements(element, elementsMap, { - oldSize: { width: element.width, height: element.height }, - }); - } -}; - const recomputeOrigin = ( stateAtCropStart: NonDeleted, transformHandle: TransformHandleType, diff --git a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap index 8bcdb9c4e..fd4f902b2 100644 --- a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`export > exporting svg containing transformed images > svg export output 1`] = ` -" +" diff --git a/packages/excalidraw/tests/cropElement.test.tsx b/packages/excalidraw/tests/cropElement.test.tsx new file mode 100644 index 000000000..2129b5c0b --- /dev/null +++ b/packages/excalidraw/tests/cropElement.test.tsx @@ -0,0 +1,285 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { vi } from "vitest"; +import { Keyboard, Pointer, UI } from "./helpers/ui"; +import type { ExcalidrawImageElement, ImageCrop } from "../element/types"; +import { GlobalTestState, render } from "./test-utils"; +import { Excalidraw, exportToCanvas, exportToSvg } from ".."; +import { API } from "./helpers/api"; +import type { NormalizedZoomValue } from "../types"; +import { KEYS } from "../keys"; +import { duplicateElement } from "../element"; +import { cloneJSON } from "../utils"; +import { actionFlipHorizontal, actionFlipVertical } from "../actions"; + +const { h } = window; +const mouse = new Pointer("mouse"); + +beforeEach(async () => { + // Unmount ReactDOM from root + ReactDOM.unmountComponentAtNode(document.getElementById("root")!); + + mouse.reset(); + localStorage.clear(); + sessionStorage.clear(); + vi.clearAllMocks(); + + Object.assign(document, { + elementFromPoint: () => GlobalTestState.canvas, + }); + await render(); + API.setAppState({ + zoom: { + value: 1 as NormalizedZoomValue, + }, + }); + + const image = API.createElement({ type: "image", width: 200, height: 100 }); + API.setElements([image]); + API.setAppState({ + selectedElementIds: { + [image.id]: true, + }, + }); +}); + +const generateRandomNaturalWidthAndHeight = (image: ExcalidrawImageElement) => { + const initialWidth = image.width; + const initialHeight = image.height; + + const scale = 1 + Math.random() * 5; + + return { + naturalWidth: initialWidth * scale, + naturalHeight: initialHeight * scale, + }; +}; + +const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => { + (Object.keys(cropA) as [keyof ImageCrop]).forEach((key) => { + const propA = cropA[key]; + const propB = cropB[key]; + + if (key === "naturalDimension") { + const [naturalWidthA, naturalHeightA] = propA as [number, number]; + const [naturalWidthB, naturalHeightB] = propB as [number, number]; + + expect(naturalWidthA).toBeCloseTo(naturalWidthB); + expect(naturalHeightA).toBeCloseTo(naturalHeightB); + } else { + expect(propA as number).toBeCloseTo(propB as number); + } + }); +}; + +describe("Enter and leave the crop editor", () => { + it("enter the editor by double clicking", () => { + const image = h.elements[0]; + expect(h.state.croppingElement).toBe(null); + mouse.doubleClickOn(image); + expect(h.state.croppingElement).not.toBe(null); + expect(h.state.croppingElement?.id).toBe(image.id); + }); + + it("enter the editor by pressing enter", () => { + const image = h.elements[0]; + expect(h.state.croppingElement).toBe(null); + Keyboard.keyDown(KEYS.ENTER); + expect(h.state.croppingElement).not.toBe(null); + expect(h.state.croppingElement?.id).toBe(image.id); + }); + + it("leave the editor by clicking outside", () => { + const image = h.elements[0]; + Keyboard.keyDown(KEYS.ENTER); + expect(h.state.croppingElement).not.toBe(null); + + mouse.click(image.x - 20, image.y - 20); + expect(h.state.croppingElement).toBe(null); + }); + + it("leave the editor by pressing escape", () => { + const image = h.elements[0]; + mouse.doubleClickOn(image); + expect(h.state.croppingElement).not.toBe(null); + + Keyboard.keyDown(KEYS.ESCAPE); + expect(h.state.croppingElement).toBe(null); + }); +}); + +describe("Crop an image", () => { + it("Cropping changes the dimension", async () => { + const image = h.elements[0] as ExcalidrawImageElement; + + const initialWidth = image.width; + const initialHeight = image.height; + + const { naturalWidth, naturalHeight } = + generateRandomNaturalWidthAndHeight(image); + + UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 2, 0]); + + expect(image.width).toBeLessThan(initialWidth); + UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight / 2]); + expect(image.height).toBeLessThan(initialHeight); + }); + + it("Cropping has minimal sizes", async () => { + const image = h.elements[0] as ExcalidrawImageElement; + const initialWidth = image.width; + const initialHeight = image.height; + + const { naturalWidth, naturalHeight } = + generateRandomNaturalWidthAndHeight(image); + + UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth, 0]); + expect(image.width).toBeLessThan(initialWidth); + expect(image.width).toBeGreaterThan(0); + UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth, 0]); + UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight]); + expect(image.height).toBeLessThan(initialHeight); + expect(image.height).toBeGreaterThan(0); + }); +}); + +describe("Cropping and other features", async () => { + it("Cropping works independently of duplication", async () => { + const image = h.elements[0] as ExcalidrawImageElement; + const initialWidth = image.width; + const initialHeight = image.height; + + const { naturalWidth, naturalHeight } = + generateRandomNaturalWidthAndHeight(image); + + UI.crop(image, "nw", naturalWidth, naturalHeight, [ + initialWidth / 2, + initialHeight / 2, + ]); + Keyboard.keyDown(KEYS.ESCAPE); + const duplicatedImage = duplicateElement(null, new Map(), image, {}); + h.app.scene.insertElement(duplicatedImage); + + expect(duplicatedImage.width).toBe(image.width); + expect(duplicatedImage.height).toBe(image.height); + + UI.crop(duplicatedImage, "nw", naturalWidth, naturalHeight, [ + -initialWidth / 2, + -initialHeight / 2, + ]); + expect(duplicatedImage.width).toBe(initialWidth); + expect(duplicatedImage.height).toBe(initialHeight); + const resizedWidth = image.width; + const resizedHeight = image.height; + + expect(image.width).not.toBe(duplicatedImage.width); + expect(image.height).not.toBe(duplicatedImage.height); + UI.crop(duplicatedImage, "se", naturalWidth, naturalHeight, [ + -initialWidth / 1.5, + -initialHeight / 1.5, + ]); + expect(duplicatedImage.width).not.toBe(initialWidth); + expect(image.width).toBe(resizedWidth); + expect(duplicatedImage.height).not.toBe(initialHeight); + expect(image.height).toBe(resizedHeight); + }); + + it("Resizing should not affect crop", async () => { + const image = h.elements[0] as ExcalidrawImageElement; + const initialWidth = image.width; + const initialHeight = image.height; + + const { naturalWidth, naturalHeight } = + generateRandomNaturalWidthAndHeight(image); + + UI.crop(image, "nw", naturalWidth, naturalHeight, [ + initialWidth / 2, + initialHeight / 2, + ]); + const cropBeforeResizing = image.crop; + const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop; + expect(cropBeforeResizing).not.toBe(null); + + UI.crop(image, "e", naturalWidth, naturalHeight, [200, 0]); + expect(cropBeforeResizing).toBe(image.crop); + compareCrops(cropBeforeResizingCloned, image.crop!); + + UI.resize(image, "s", [0, -100]); + expect(cropBeforeResizing).toBe(image.crop); + compareCrops(cropBeforeResizingCloned, image.crop!); + + UI.resize(image, "ne", [-50, -50]); + expect(cropBeforeResizing).toBe(image.crop); + compareCrops(cropBeforeResizingCloned, image.crop!); + }); + + it("Flipping does not change crop", async () => { + const image = h.elements[0] as ExcalidrawImageElement; + const initialWidth = image.width; + const initialHeight = image.height; + + const { naturalWidth, naturalHeight } = + generateRandomNaturalWidthAndHeight(image); + + mouse.doubleClickOn(image); + expect(h.state.croppingElement).not.toBe(null); + UI.crop(image, "nw", naturalWidth, naturalHeight, [ + initialWidth / 2, + initialHeight / 2, + ]); + Keyboard.keyDown(KEYS.ESCAPE); + const cropBeforeResizing = image.crop; + const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop; + + API.executeAction(actionFlipHorizontal); + expect(image.crop).toBe(cropBeforeResizing); + compareCrops(cropBeforeResizingCloned, image.crop!); + + API.executeAction(actionFlipVertical); + expect(image.crop).toBe(cropBeforeResizing); + compareCrops(cropBeforeResizingCloned, image.crop!); + }); + + it("Exports should preserve crops", async () => { + const image = h.elements[0] as ExcalidrawImageElement; + const initialWidth = image.width; + const initialHeight = image.height; + + const { naturalWidth, naturalHeight } = + generateRandomNaturalWidthAndHeight(image); + + mouse.doubleClickOn(image); + expect(h.state.croppingElement).not.toBe(null); + UI.crop(image, "nw", naturalWidth, naturalHeight, [ + initialWidth / 2, + initialHeight / 4, + ]); + Keyboard.keyDown(KEYS.ESCAPE); + const widthToHeightRatio = image.width / image.height; + + const canvas = await exportToCanvas({ + elements: [image], + appState: h.state, + files: h.app.files, + exportPadding: 0, + }); + const exportedCanvasRatio = canvas.width / canvas.height; + + expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio); + + const svg = await exportToSvg({ + elements: [image], + appState: h.state, + files: h.app.files, + exportPadding: 0, + }); + const svgWidth = svg.getAttribute("width"); + const svgHeight = svg.getAttribute("height"); + + expect(svgWidth).toBeDefined(); + expect(svgHeight).toBeDefined(); + + const exportedSvgRatio = Number(svgWidth) / Number(svgHeight); + expect(widthToHeightRatio).toBeCloseTo(exportedSvgRatio); + }); +}); diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 721982212..1c133e36b 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -1,4 +1,3 @@ -import type { ToolType } from "../../types"; import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -9,6 +8,7 @@ import type { ExcalidrawDiamondElement, ExcalidrawTextContainer, ExcalidrawTextElementWithContainer, + ExcalidrawImageElement, } from "../../element/types"; import type { TransformHandleType } from "../../element/transformHandles"; import { @@ -35,6 +35,7 @@ import { arrayToMap } from "../../utils"; import { createTestHook } from "../../components/App"; import type { GlobalPoint, LocalPoint, Radians } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math"; +import { cropElement } from "../../element/cropElement"; // so that window.h is available when App.tsx is not imported as well. createTestHook(); @@ -561,6 +562,36 @@ export class UI { return transform(element, handle, mouseMove, keyboardModifiers); } + static crop( + element: ExcalidrawImageElement, + handle: TransformHandleDirection, + naturalWidth: number, + naturalHeight: number, + mouseMove: [deltaX: number, deltaY: number], + ) { + const handleCoords = getTransformHandles( + element, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + {}, + )[handle]!; + + const clientX = handleCoords[0] + handleCoords[2] / 2; + const clientY = handleCoords[1] + handleCoords[3] / 2; + + const mutations = cropElement( + element, + handle, + naturalWidth, + naturalHeight, + clientX + mouseMove[0], + clientY + mouseMove[1], + ); + + mutateElement(element, mutations); + } + static rotate( element: ExcalidrawElement | ExcalidrawElement[], mouseMove: [deltaX: number, deltaY: number], diff --git a/setupTests.ts b/setupTests.ts index 56a16db5c..54501037b 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -104,7 +104,7 @@ console.error = (...args) => { // the react's act() warning usually doesn't contain any useful stack trace // so we're catching the log and re-logging the message with the test name, // also stripping the actual component stack trace as it's not useful - if (args[0]?.includes("act(")) { + if (args[0]?.includes?.("act(")) { _consoleError( yellow( `<<< WARNING: test "${