Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,22 @@ public String getImageAlt() {

/**
* Defines the crop dimensions.
*
*
* <p>
* A {@code %} crop is resolution-independent and is recommended for
* programmatic use. A {@code px} crop is interpreted in the image's
* <em>source</em> (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.
*
* <p>
* A {@code %} crop is measured against the image's natural size when the output
* is generated, but against its rendered box when the selection is drawn. Both
* agree as long as the rendered image keeps the source's aspect ratio; forcing
* both a width and a height on the image (or an {@code object-fit} that crops or
* stretches it) makes the on-screen selection diverge from the exported region.
*
* @param crop the crop dimensions
*/
public void setCrop(Crop crop) {
Expand All @@ -145,6 +160,15 @@ public void setCrop(Crop crop) {

/**
* Gets the crop dimensions.
*
* <p>
* Once the image has loaded, the crop is normalized to a percentage of the
* image's natural size, so the returned unit is {@code %} even when
* {@link #setCrop(Crop)} was given a {@code px} crop. Because {@link Crop} holds
* integer values, those percentages are rounded to whole units, which on a large
* image is coarser than the configured pixel values.
*
* @return the crop dimensions
*/
public Crop getCrop() {
return getState("crop", Crop.class);
Expand Down Expand Up @@ -247,8 +271,13 @@ public boolean isLocked() {
}

/**
* Sets a minimum crop width, in pixels.
*
* Sets a minimum crop width, in rendered (on-screen) pixels.
*
* <p>
* 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) {
Expand Down
200 changes: 105 additions & 95 deletions src/main/resources/META-INF/resources/frontend/src/image-crop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { ReactAdapterElement, RenderHooks } from 'Frontend/generated/flow/ReactAdapter';
import { JSXElementConstructor, ReactElement, useRef, useEffect } from "react";
import React from 'react';
import { type Crop, ReactCrop, PixelCrop, PercentCrop, makeAspectCrop, centerCrop, convertToPixelCrop } from "react-image-crop";
import { type Crop, ReactCrop, PixelCrop, PercentCrop, makeAspectCrop, centerCrop, convertToPixelCrop, convertToPercentCrop } from "react-image-crop";

// MIME types that HTMLCanvasElement.toDataURL can actually encode across browsers.
// Anything else silently falls back to image/png, so we never emit it.
Expand Down Expand Up @@ -106,82 +106,88 @@ class ImageCropElement extends ReactAdapterElement {
const [outputMimeType] = hooks.useState<string>("outputMimeType");
const [outputQuality] = hooks.useState<number>("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.
* Normalizes a configured crop to a "%" crop of the image's natural size: a
* "px" crop is interpreted as source (natural) pixels, while a "%" crop is
* already resolution-independent and is returned unchanged.
*
* Returns null while the image has no intrinsic size (naturalWidth /
* naturalHeight are 0 before it loads, and stay 0 for a source without an
* intrinsic size), since every conversion against a zero dimension is
* meaningless.
*/
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 toPercentCrop = (configured: Crop, img: HTMLImageElement): PercentCrop | null => {
const { naturalWidth, naturalHeight } = img;
if (!naturalWidth || !naturalHeight) {
return null;
}
return convertToPercentCrop(configured, naturalWidth, naturalHeight);
};

/**
* Adjusts the crop size proportionally when the image is resized.
* Enforces the configured aspect ratio on a "%" crop by deriving its height
* from its width, leaving the position untouched.
*
* No-op when no aspect is configured: makeAspectCrop divides the width by the
* aspect, so passing an undefined one collapses the crop to zero.
*/
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 applyAspect = (percentCrop: PercentCrop, img: HTMLImageElement): PercentCrop =>
aspect
? makeAspectCrop(
{ unit: "%", width: percentCrop.width, x: percentCrop.x, y: percentCrop.y },
aspect,
img.naturalWidth,
img.naturalHeight
)
: percentCrop;

const resizedCrop: Crop = {
unit: crop.unit,
width: crop.width * scaleX,
height: crop.height * scaleY,
x: crop.x * scaleX,
y: crop.y * scaleY,
};
/**
* 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toPercent returns 0 when dimension is falsy:

const toPercent = (value: number, dimension: number) =>
    dimension ? (value / dimension) * 100 : 0;

For an image whose naturalWidth/naturalHeight is 0 even after load fires (e.g. an SVG source without width/height/viewBox), all four toPercent calls in onImageLoad collapse to 0, producing a zero-size crop that gets fed into _updateCroppedImage — an empty/invalid exported image instead of the configured crop, with no error surfaced.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed — though the guard belongs further down: with a zero naturalWidth/naturalHeight any crop maps to zero, % included, so patching toPercent alone wouldn't have covered it. _updateCroppedImage now bails out instead of drawing a 0×0 canvas and firing a blank data:, URI (4ec6120), and the normalization helper returns null while the image has no intrinsic size (4bed3ba).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This px→percent conversion is hand-rolled here and duplicated again in the useEffect below (normalizing a late setCrop). Consider sharing one helper (or using react-image-crop's own convertToPercentCrop, already available alongside convertToPixelCrop) so the two normalization paths can't drift out of sync — which is exactly what happened with the missing aspect-ratio step noted in the other comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in 4bed3ba: both paths now go through a single helper built on convertToPercentCrop, which also passes a % crop through untouched, so the ternary and toPercent are gone. One thing worth noting: its own zero-guard yields Infinity rather than 0 for a zero-size image, so the helper checks the natural size before calling it.

* 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 onImageLoad = () => {
const img = imgRef.current;
if (!img || !crop) {
return;
}
// Work in "%": a "px" crop is treated as source pixels and converted.
let normalized = toPercentCrop(crop, img);
if (!normalized) {
return;
}
// Enforce the aspect ratio when configured, then center the selection.
normalized = applyAspect(normalized, img);
normalized = centerCrop(normalized, img.naturalWidth, img.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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike onImageLoad (which calls makeAspectCrop before centerCrop), this effect never re-applies the configured aspect when normalizing a late programmatic px crop.

Repro: configure aspect={1} with the image already loaded, then call setCrop(new Crop("px", 10, 10, 200, 50)) (non-square). onImageLoad won't re-run since the image is already loaded, so only this effect fires — it converts x/y/width/height to percent verbatim with no aspect enforcement, leaving the crop box (and exported image) non-square despite aspect=1, until the user manually drags a handle.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with that exact repro, and fixed in 26b6213: aspect enforcement is now a shared applyAspect helper called from both the load path and this effect. A late px crop of 200×50 with aspect=1 now comes out 200×200 with the configured x/y preserved. The aspect ? guard stays, since makeAspectCrop with an undefined aspect returns height: 0.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This normalization always rewrites the crop's unit to "%", so getCrop() on the Java side can now return a different unit and different numeric values than what was passed to setCrop().

On master, onImageLoad preserved crop.unit verbatim, so a px crop stayed px after load. With this change, setCrop(new Crop("px", 100, 100, 300, 300)) followed by getCrop() (after load or any interaction) returns unit % with fractional values instead of the original px values — silently breaking any caller code that persists getCrop() and re-applies it later, or that branches on crop.unit().equals("px").

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: after this PR, getCrop() returns a % crop even when setCrop() was given px.

One correction on the premise, though — master didn't preserve the unit either. Its onChange stored react-image-crop's PixelCrop argument, so as soon as the user dragged the selection, a caller-configured % crop came back from getCrop() as px in rendered pixels. The unit was never stable across a round trip; this PR only changes which unit it settles on.

What I do think is worth acting on is precision rather than the unit. Crop stores x/y/width/height as int, so a % crop gets rounded to whole percentages. On a 4000 px-wide image 1% is 40 source pixels, where the old rendered-pixel values were off by about 1 — so getCrop() → persist → setCrop() later now moves and resizes the selection noticeably.

Fixing that means changing Crop to double, which breaks a public record and is outside #33. For this PR I'll document on getCrop() that the crop comes back normalized to %, and open a follow-up for the intdouble change.

* explicitly positioned, but the aspect ratio is enforced just as it is on
* load, so a crop that does not match the configured aspect is corrected
* instead of staying off-ratio until the user drags a handle.
*/
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) {
return;
}
const normalized = toPercentCrop(crop, img);
if (normalized) {
setCrop(applyAspect(normalized, img));
}
}, [crop]);

/**
Expand All @@ -199,19 +205,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 (
<ReactCrop
crop={crop}
onChange={(c: Crop) => 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}
Expand Down Expand Up @@ -246,43 +255,44 @@ 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.
*
* <p>The crop rectangle reported by react-image-crop is expressed in the
* image's <em>displayed</em> (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 <em>natural</em>
* 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).</p>
* <p>The crop is mapped to the image's <em>natural</em> (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).</p>
*
* <p>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).</p>
* <p>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).</p>
*/
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);

// Nothing to export: the image has no intrinsic size (naturalWidth /
// naturalHeight are still 0 before it loads, and stay 0 for a source
// without an intrinsic size, which collapses any crop to zero) or the
// selection itself is empty. Bail out instead of drawing a 0x0 canvas
// and firing an event with a blank "data:," URI.
if (!Number.isFinite(outWidth) || !Number.isFinite(outHeight)
|| outWidth <= 0 || outHeight <= 0) {
return;
}

// Setting canvas dimensions resets the 2D context, so it must happen
// before any drawing/clipping state is configured below.
Expand All @@ -302,10 +312,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,
Expand Down
Loading