diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index a21b3e1e88..9816e726df 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -5,6 +5,13 @@ import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; +import { isImageElement } from "../../element/typeChecks"; +import { + MINIMAL_CROP_SIZE, + getUncroppedWidthAndHeight, +} from "../../element/cropElement"; +import { mutateElement } from "../../element/mutateElement"; +import { clamp, round } from "../../../math"; interface DimensionDragInputProps { property: "width" | "height"; @@ -27,6 +34,8 @@ const handleDimensionChange: DragInputCallbackType< shouldChangeByStepSize, nextValue, property, + originalAppState, + instantChange, scene, }) => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -37,6 +46,107 @@ const handleDimensionChange: DragInputCallbackType< shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); const aspectRatio = origElement.width / origElement.height; + if (originalAppState.croppingElementId === origElement.id) { + const element = elementsMap.get(origElement.id); + + if (!element || !isImageElement(element) || !element.crop) { + return; + } + + const crop = element.crop; + let nextCrop = { ...crop }; + + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + + const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth; + const naturalToUncroppedHeightRatio = + crop.naturalHeight / uncroppedHeight; + + const MAX_POSSIBLE_WIDTH = isFlippedByX + ? crop.width + crop.x + : crop.naturalWidth - crop.x; + + const MAX_POSSIBLE_HEIGHT = isFlippedByY + ? crop.height + crop.y + : crop.naturalHeight - crop.y; + + const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio; + const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio; + + if (nextValue !== undefined) { + if (property === "width") { + const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio; + + const nextCropWidth = clamp( + nextValueInNatural, + MIN_WIDTH, + MAX_POSSIBLE_WIDTH, + ); + + nextCrop = { + ...nextCrop, + width: nextCropWidth, + x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x, + }; + } else if (property === "height") { + const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio; + const nextCropHeight = clamp( + nextValueInNatural, + MIN_HEIGHT, + MAX_POSSIBLE_HEIGHT, + ); + + nextCrop = { + ...nextCrop, + height: nextCropHeight, + y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y, + }; + } + + mutateElement(element, { + crop: nextCrop, + width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), + height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), + }); + return; + } + + const changeInWidth = property === "width" ? instantChange : 0; + const changeInHeight = property === "height" ? instantChange : 0; + + const nextCropWidth = clamp( + crop.width + changeInWidth, + MIN_WIDTH, + MAX_POSSIBLE_WIDTH, + ); + + const nextCropHeight = clamp( + crop.height + changeInHeight, + MIN_WIDTH, + MAX_POSSIBLE_HEIGHT, + ); + + nextCrop = { + ...crop, + x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x, + y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y, + width: nextCropWidth, + height: nextCropHeight, + }; + + mutateElement(element, { + crop: nextCrop, + width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), + height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), + }); + + return; + } + if (nextValue !== undefined) { const nextWidth = Math.max( property === "width" @@ -117,9 +227,25 @@ const DimensionDragInput = ({ scene, appState, }: DimensionDragInputProps) => { - const value = - Math.round((property === "width" ? element.width : element.height) * 100) / - 100; + let value = round(property === "width" ? element.width : element.height, 2); + + if ( + appState.croppingElementId && + appState.croppingElementId === element.id && + isImageElement(element) && + element.crop + ) { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + if (property === "width") { + const ratio = uncroppedWidth / element.crop.naturalWidth; + value = round(element.crop.width * ratio, 2); + } + if (property === "height") { + const ratio = uncroppedHeight / element.crop.naturalHeight; + value = round(element.crop.height * ratio, 2); + } + } return ( = ({ accumulatedChange, + instantChange, originalElements, originalElementsMap, shouldChangeByStepSize, nextValue, property, scene, + originalAppState, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const elements = scene.getNonDeletedElements(); @@ -38,6 +46,82 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ origElement.angle, ); + if (originalAppState.croppingElementId === origElement.id) { + const element = elementsMap.get(origElement.id); + + if (!element || !isImageElement(element) || !element.crop) { + return; + } + + const crop = element.crop; + let nextCrop = crop; + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + + if (nextValue !== undefined) { + if (property === "x") { + const nextValueInNatural = + nextValue * (crop.naturalWidth / uncroppedWidth); + + if (isFlippedByX) { + nextCrop = { + ...crop, + x: clamp( + crop.naturalWidth - nextValueInNatural - crop.width, + 0, + crop.naturalWidth - crop.width, + ), + }; + } else { + nextCrop = { + ...crop, + x: clamp( + nextValue * (crop.naturalWidth / uncroppedWidth), + 0, + crop.naturalWidth - crop.width, + ), + }; + } + } + + if (property === "y") { + nextCrop = { + ...crop, + y: clamp( + nextValue * (crop.naturalHeight / uncroppedHeight), + 0, + crop.naturalHeight - crop.height, + ), + }; + } + + mutateElement(element, { + crop: nextCrop, + }); + + return; + } + + const changeInX = + (property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1); + const changeInY = + (property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1); + + nextCrop = { + ...crop, + x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width), + y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height), + }; + + mutateElement(element, { + crop: nextCrop, + }); + + return; + } + if (nextValue !== undefined) { const newTopLeftX = property === "x" ? nextValue : topLeftX; const newTopLeftY = property === "y" ? nextValue : topLeftY; @@ -97,8 +181,22 @@ const Position = ({ pointFrom(element.x + element.width / 2, element.y + element.height / 2), element.angle, ); - const value = - Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; + let value = round(property === "x" ? topLeftX : topLeftY, 2); + + if ( + appState.croppingElementId === element.id && + isImageElement(element) && + element.crop + ) { + const flipAdjustedPosition = getFlipAdjustedCropPosition(element); + + if (flipAdjustedPosition) { + value = round( + property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y, + 2, + ); + } + } return ( 1 ? selectedElements : null; + const cropMode = + appState.croppingElementId && isImageElement(singleElement); + + const unCroppedDimension = cropMode + ? getUncroppedWidthAndHeight(singleElement) + : null; + const [sceneDimension, setSceneDimension] = useState<{ width: number; height: number; @@ -244,8 +253,34 @@ export const StatsInner = memo( {singleElement && ( <> + {cropMode && ( + + {t("labels.unCroppedDimension")} + + )} + + {appState.croppingElementId && + isImageElement(singleElement) && + unCroppedDimension && ( + +
{t("stats.width")}
+
{round(unCroppedDimension.width, 2)}
+
+ )} + + {appState.croppingElementId && + isImageElement(singleElement) && + unCroppedDimension && ( + +
{t("stats.height")}
+
{round(unCroppedDimension.height, 2)}
+
+ )} + - {t(`element.${singleElement.type}`)} + {appState.croppingElementId + ? t("labels.imageCropping") + : t(`element.${singleElement.type}`)} @@ -387,7 +422,8 @@ export const StatsInner = memo( prev.selectedElements === next.selectedElements && prev.appState.stats.panels === next.appState.stats.panels && prev.gridModeEnabled === next.gridModeEnabled && - prev.appState.gridStep === next.appState.gridStep + prev.appState.gridStep === next.appState.gridStep && + prev.appState.croppingElementId === next.appState.croppingElementId ); }, ); diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts index da88df4992..20ebb8d794 100644 --- a/packages/excalidraw/element/cropElement.ts +++ b/packages/excalidraw/element/cropElement.ts @@ -26,7 +26,7 @@ import { getResizedElementAbsoluteCoords, } from "./bounds"; -const MINIMAL_CROP_SIZE = 10; +export const MINIMAL_CROP_SIZE = 10; export const cropElement = ( element: ExcalidrawImageElement, @@ -585,3 +585,41 @@ const adjustCropPosition = ( cropY, }; }; + +export const getFlipAdjustedCropPosition = ( + element: ExcalidrawImageElement, + natural = false, +) => { + const crop = element.crop; + if (!crop) { + return null; + } + + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + + let cropX = crop.x; + let cropY = crop.y; + + if (isFlippedByX) { + cropX = crop.naturalWidth - crop.width - crop.x; + } + + if (isFlippedByY) { + cropY = crop.naturalHeight - crop.height - crop.y; + } + + if (natural) { + return { + x: cropX, + y: cropY, + }; + } + + const { width, height } = getUncroppedWidthAndHeight(element); + + return { + x: cropX / (crop.naturalWidth / width), + y: cropY / (crop.naturalHeight / height), + }; +}; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 85c5076f3e..d03d683074 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -157,6 +157,8 @@ "zoomToFit": "Zoom to fit all elements", "installPWA": "Install Excalidraw locally (PWA)", "autoResize": "Enable text auto-resizing", + "imageCropping": "Image cropping", + "unCroppedDimension": "Uncropped dimension", "copyElementLink": "Copy link to object", "linkToElement": "Link to object" }, diff --git a/packages/excalidraw/tests/cropElement.test.tsx b/packages/excalidraw/tests/cropElement.test.tsx index 9b03c5261c..8163b0e012 100644 --- a/packages/excalidraw/tests/cropElement.test.tsx +++ b/packages/excalidraw/tests/cropElement.test.tsx @@ -186,14 +186,14 @@ describe("Crop an image", () => { // 50 x 50 square UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]); UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true); - expect(image.width).toEqual(image.height); + expect(image.width).toBeCloseTo(image.height); // image is at the corner, not space to its right to expand, should not be able to resize expect(image.height).toBeCloseTo(50); UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true); - expect(image.width).toEqual(image.height); + expect(image.width).toBeCloseTo(image.height); // max height should be reached - expect(image.height).toEqual(initialHeight); + expect(image.height).toBeCloseTo(initialHeight); expect(image.width).toBe(initialHeight); }); });