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,