diff --git a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java index ccb2b65..ced0a85 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java @@ -26,8 +26,9 @@ * The crop dimensions are defined by the unit, x and y coordinates, width, and * height. * - * @param unit the unit of the crop dimensions, can be 'px' (pixels) or '%' - * (percentage). + * @param unit the unit of the crop dimensions, can be 'px' or '%'. A '%' crop + * is resolution-independent; a 'px' crop is interpreted in the + * image's source (natural) pixels (see issue #33). * @param x the x-coordinate of the cropped area. * @param y the y-coordinate of the cropped area. * @param width the width of the cropped area diff --git a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java index d93706c..1f6d769 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java @@ -135,7 +135,15 @@ public String getImageAlt() { /** * Defines the crop dimensions. - * + * + *

+ * A {@code %} crop is resolution-independent and is recommended for + * programmatic use. A {@code px} crop is interpreted in the image's + * source (natural) pixels, so the cropped output has a deterministic + * size regardless of how the image is scaled on screen (see issue #33). Note + * that this differs from {@link #setCropMinWidth(Integer) the min/max crop + * constraints}, which are expressed in rendered (on-screen) pixels. + * * @param crop the crop dimensions */ public void setCrop(Crop crop) { @@ -247,8 +255,13 @@ public boolean isLocked() { } /** - * Sets a minimum crop width, in pixels. - * + * Sets a minimum crop width, in rendered (on-screen) pixels. + * + *

+ * This constraint is applied by react-image-crop in the image's displayed + * pixels, unlike a {@code px} {@link #setCrop(Crop) crop}, which is expressed in + * source (natural) pixels. + * * @param minWidth the minimum crop width */ public void setCropMinWidth(Integer minWidth) { diff --git a/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx b/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx index 3c92c4f..f57b248 100644 --- a/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx +++ b/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx @@ -106,82 +106,75 @@ class ImageCropElement extends ReactAdapterElement { const [outputMimeType] = hooks.useState("outputMimeType"); const [outputQuality] = hooks.useState("outputQuality", 1.0); - // Track previous image dimensions to adjust crop proportionally when resizing - const prevImgSize = useRef<{ width: number; height: number } | null>(null); // Skip the first run of the output-format effect (initial encoding is handled on image load) const didMountRef = useRef(false); /** - * Handles intial calculations on image load. + * Converts a value expressed in source (natural) pixels to a percentage of + * the given dimension. */ - const onImageLoad = () => { - if (imgRef.current) { - const { width, height } = imgRef.current; - prevImgSize.current = { width, height }; - if (crop) { - const newcrop = centerCrop( - makeAspectCrop( - { - unit: crop.unit, - width: crop.width, - height: crop.height, - x: crop.x, - y: crop.y - }, - aspect, - width, - height - ), - width, - height - ) - setCrop(newcrop); - this._updateCroppedImage(newcrop); - } - } - }; + const toPercent = (value: number, dimension: number) => + dimension ? (value / dimension) * 100 : 0; /** - * Adjusts the crop size proportionally when the image is resized. + * Normalizes the configured crop when the image loads. The crop is kept as a + * percentage of the image's natural size, so both the on-screen selection and + * the exported image are independent of how the browser scales the image on + * screen. A configured "px" crop is interpreted as source (natural) pixels, + * which makes the exported size deterministic (see issue #33). */ - const resizeCrop = (newWidth: number, newHeight: number) => { - if (!crop || !prevImgSize.current) return; - const { width: oldWidth, height: oldHeight } = prevImgSize.current; - - const scaleX = newWidth / oldWidth; - const scaleY = newHeight / oldHeight; - - const resizedCrop: Crop = { - unit: crop.unit, - width: crop.width * scaleX, - height: crop.height * scaleY, - x: crop.x * scaleX, - y: crop.y * scaleY, - }; + const onImageLoad = () => { + const img = imgRef.current; + if (!img || !crop) { + return; + } + const { naturalWidth, naturalHeight } = img; + + // Work in "%": a "px" crop is treated as source pixels and converted. + let normalized: PercentCrop = crop.unit === "%" + ? { unit: "%", x: crop.x, y: crop.y, width: crop.width, height: crop.height } + : { + unit: "%", + x: toPercent(crop.x, naturalWidth), + y: toPercent(crop.y, naturalHeight), + width: toPercent(crop.width, naturalWidth), + height: toPercent(crop.height, naturalHeight), + }; + + // Enforce the aspect ratio when configured, then center the selection. + if (aspect) { + normalized = makeAspectCrop( + { unit: "%", width: normalized.width, x: normalized.x, y: normalized.y }, + aspect, + naturalWidth, + naturalHeight + ); + } + normalized = centerCrop(normalized, naturalWidth, naturalHeight); - setCrop(resizedCrop); - prevImgSize.current = { width: newWidth, height: newHeight }; + setCrop(normalized); + this._updateCroppedImage(normalized); }; /** - * Observes image resizing and updates crop size dynamically. + * Normalizes a programmatic "px" crop that the server sets after the image + * has loaded. onImageLoad only runs on the initial load, so without this a + * later setCrop("px", ...) would be rendered by ReactCrop as on-screen pixels + * and the selection box would diverge from the natural-pixel export (issue + * #33). The configured x/y are preserved (no centering) since the crop is + * explicitly positioned. */ useEffect(() => { - if (!imgRef.current) return; - - const resizeObserver = new ResizeObserver(() => { - if (imgRef.current && prevImgSize.current) { - const { width, height } = imgRef.current; - if (width != prevImgSize.current.width && - height != prevImgSize.current.height) { - resizeCrop(width, height); - } - } - }); - - resizeObserver.observe(imgRef.current); - - return () => resizeObserver.disconnect(); + const img = imgRef.current; + if (crop && crop.unit !== "%" && img && img.naturalWidth) { + setCrop({ + unit: "%", + x: toPercent(crop.x, img.naturalWidth), + y: toPercent(crop.y, img.naturalHeight), + width: toPercent(crop.width, img.naturalWidth), + height: toPercent(crop.height, img.naturalHeight), + }); + } }, [crop]); /** @@ -199,19 +192,22 @@ class ImageCropElement extends ReactAdapterElement { } }, [outputMimeType, outputQuality]); - const onChange = (c: Crop) => { - setCrop(c); + // Keep the crop state in "%" so it stays valid regardless of the image's + // on-screen size; that scale-invariance is why no ResizeObserver is needed + // to rescale it on layout changes (see issue #33). + const onChange = (_pixelCrop: PixelCrop, percentCrop: PercentCrop) => { + setCrop(percentCrop); }; - const onComplete = (c: PixelCrop) => { - this._updateCroppedImage(c); + const onComplete = (_pixelCrop: PixelCrop, percentCrop: PercentCrop) => { + this._updateCroppedImage(percentCrop); }; return ( onChange(c)} - onComplete={(c: PixelCrop) => onComplete(c)} + onChange={(c: PixelCrop, pc: PercentCrop) => onChange(c, pc)} + onComplete={(c: PixelCrop, pc: PercentCrop) => onComplete(c, pc)} circularCrop={circularCrop} aspect={aspect} keepSelection={keepSelection} @@ -246,43 +242,34 @@ class ImageCropElement extends ReactAdapterElement { * Draws the selected crop region onto an off-screen canvas and dispatches the * resulting data URI through a {@code cropped-image} event. * - *

The crop rectangle reported by react-image-crop is expressed in the - * image's displayed (rendered) pixels, which can be smaller or larger - * than the image's intrinsic resolution when the browser scales it to fit the - * layout. The selected region is mapped back to the source's natural - * pixels using {@code scaleX}/{@code scaleY} for both the source rectangle and - * the output canvas, so the cropped image keeps the original resolution of the - * selected area rather than the (smaller or larger) on-screen size (see issue - * #26).

+ *

The crop is mapped to the image's natural (intrinsic) pixels: + * {@code convertToPixelCrop} scales a {@code %} crop against + * {@code naturalWidth}/{@code naturalHeight}, while a {@code px} crop is taken + * as source (natural) pixels directly. Because the mapping never depends on the + * image's on-screen size, the exported dimensions are deterministic regardless + * of how the browser scaled the image when the crop was set (see issues #26 and + * #33).

* - *

Note: a {@code px} crop is measured in rendered pixels, so the exported - * size is the rendered crop scaled to natural resolution, not necessarily the - * configured pixel value. The output is not multiplied by - * {@code window.devicePixelRatio}, so the original pixels are used verbatim - * instead of being upsampled on high-density displays (see issue #21).

+ *

Note: the output is not multiplied by {@code window.devicePixelRatio}, so + * the original pixels are used verbatim instead of being upsampled on + * high-density displays (see issue #21).

*/ public _updateCroppedImage(crop: PixelCrop|PercentCrop) { const image = this.querySelector("img"); if (crop && image) { - crop = convertToPixelCrop(crop, image.width, image.height); + // Map the crop to the image's natural pixels. A "%" crop scales to the + // natural resolution; a "px" crop is interpreted as source pixels. + const ccrop = convertToPixelCrop(crop, image.naturalWidth, image.naturalHeight); // create a canvas element to draw the cropped image const canvas = document.createElement("canvas"); - - // draw the image on the canvas - const ccrop = crop; - - // Ratio between the image's natural resolution and its displayed size. - // Greater than 1 when the image is scaled down to fit the screen. - const scaleX = image.naturalWidth / image.width; - const scaleY = image.naturalHeight / image.height; const ctx = canvas.getContext("2d"); - // Size the output in the crop region's natural pixels so the cropped + // The output is sized in the crop region's natural pixels, so the cropped // image keeps the source's resolution rather than the on-screen size. - const outWidth = Math.round(ccrop.width * scaleX); - const outHeight = Math.round(ccrop.height * scaleY); + const outWidth = Math.round(ccrop.width); + const outHeight = Math.round(ccrop.height); // Setting canvas dimensions resets the 2D context, so it must happen // before any drawing/clipping state is configured below. @@ -302,10 +289,10 @@ class ImageCropElement extends ReactAdapterElement { ctx.drawImage( image, - ccrop.x * scaleX, - ccrop.y * scaleY, - ccrop.width * scaleX, - ccrop.height * scaleY, + ccrop.x, + ccrop.y, + ccrop.width, + ccrop.height, 0, 0, outWidth,