Skip to content

fix: make px crops export at a deterministic size - #35

Open
paodb wants to merge 5 commits into
masterfrom
issue-33
Open

fix: make px crops export at a deterministic size#35
paodb wants to merge 5 commits into
masterfrom
issue-33

Conversation

@paodb

@paodb paodb commented Jul 20, 2026

Copy link
Copy Markdown
Member

Configured px crops mapped to the exported image via rendered (on-screen) pixels, so the output size varied with how the browser scaled the image at crop time (e.g. a 500×500 px crop could export at ~500 or ~667 px). This makes the mapping deterministic.

Changes

  • Store the crop as % (resolution-independent): onChange/onComplete now use react-image-crop's percentCrop.
  • onImageLoad normalizes the configured crop against the image's natural size; a px crop is interpreted as source (natural) pixels.
  • _updateCroppedImage maps the crop with convertToPixelCrop against naturalWidth/naturalHeight, dropping the rendered→natural rescaling.
  • Remove the now-redundant ResizeObserver/resizeCrop workaround (a % crop needs no rescaling on layout changes) and guard makeAspectCrop against an unset aspect.
  • Document that crop units are source pixels while the min/max crop constraints remain in rendered pixels.

Close #33

Summary by CodeRabbit

  • Bug Fixes

    • Improved crop stability across responsive layouts by keeping crop selections consistent through layout changes.
    • Ensured crop rendering and generated output are mapped using the image’s natural dimensions.
    • Improved handling when setting crop programmatically, including correct percent/% vs pixel conversions and aspect-ratio enforcement.
  • Documentation

    • Clarified meaning of % (resolution-independent) versus px (based on the image’s natural/source pixels).
    • Documented how crop min/max constraints are applied in rendered/on-screen pixels.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 90cd6aeb-9fb7-46c8-8991-9212821a1cce

Walkthrough

Changes

Crop normalization

Layer / File(s) Summary
Crop unit semantics
src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java, src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java
Javadocs clarify that % crops are resolution-independent, px crops use natural image pixels, and min/max constraints use rendered pixels.
Percent crop rendering
src/main/resources/META-INF/resources/frontend/src/image-crop.tsx
Crop initialization and ReactCrop state now use percentages, while canvas rendering converts the crop directly to natural-pixel coordinates.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: javier-godoy

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code and docs implement issue #33 by using percent crops, natural dimensions, and removing the resize workaround.
Out of Scope Changes check ✅ Passed The changes stay within the issue scope and only add related crop-handling and documentation updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: making px crop exports deterministic.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-33

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/resources/META-INF/resources/frontend/src/image-crop.tsx`:
- Around line 144-157: Update the onImageLoad normalization to preserve
explicitly supplied x and y coordinates by removing the unconditional
makeAspectCrop and centerCrop calls. Move px-to-% conversion into a useEffect
that observes the crop state and image dimensions, converting programmatic
server updates before passing the crop to ReactCrop while leaving percentage
crops unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44225dc7-6409-410f-952f-89ba91e1c764

📥 Commits

Reviewing files that changed from the base of the PR and between c9c2cc8 and a7c450c.

📒 Files selected for processing (3)
  • src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java
  • src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java
  • src/main/resources/META-INF/resources/frontend/src/image-crop.tsx

Comment thread src/main/resources/META-INF/resources/frontend/src/image-crop.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/resources/META-INF/resources/frontend/src/image-crop.tsx (1)

271-278: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Prevent IndexSizeError crash on zero-dimension crops.

If the crop region has a width or height of zero (e.g., from an uninitialized state or programmatic update), outWidth or outHeight will evaluate to 0. Calling drawImage with a zero source width or height throws an IndexSizeError (or InvalidStateError) in browsers, which crashes the script execution and prevents subsequent logic from running. Consider adding an early return to handle this gracefully.

🛡️ Proposed fix
 				const outWidth = Math.round(ccrop.width);
 				const outHeight = Math.round(ccrop.height);
 
+				if (outWidth <= 0 || outHeight <= 0) {
+					return;
+				}
+
 				// Setting canvas dimensions resets the 2D context, so it must happen
 				// before any drawing/clipping state is configured below.
 				canvas.width = outWidth;
 				canvas.height = outHeight;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/META-INF/resources/frontend/src/image-crop.tsx` around
lines 271 - 278, Add an early return in the crop-rendering flow after computing
outWidth and outHeight, before assigning canvas dimensions or calling drawImage,
when either dimension is zero or otherwise non-positive. Preserve the existing
rendering path for positive dimensions and allow subsequent logic to continue
without throwing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/resources/META-INF/resources/frontend/src/image-crop.tsx`:
- Around line 271-278: Add an early return in the crop-rendering flow after
computing outWidth and outHeight, before assigning canvas dimensions or calling
drawImage, when either dimension is zero or otherwise non-positive. Preserve the
existing rendering path for positive dimensions and allow subsequent logic to
continue without throwing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 808376d8-5c44-4867-9897-a49725681424

📥 Commits

Reviewing files that changed from the base of the PR and between a7c450c and 1464c6a.

📒 Files selected for processing (3)
  • src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java
  • src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java
  • src/main/resources/META-INF/resources/frontend/src/image-crop.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java

@paodb
paodb marked this pull request as ready for review July 20, 2026 21:01
@paodb
paodb requested review from javier-godoy and scardanzan July 20, 2026 21:01

@javier-godoy javier-godoy left a comment

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.

Automated review of the changes introduced by this PR (px/percent crop normalization rework).

/**
* 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

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).

/**
* 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

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.

* 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.

* 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.

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.

@javier-godoy javier-godoy moved this from To Do to In Progress in Flowing Code Addons Aug 11, 2026
@sonarqubecloud

Copy link
Copy Markdown

@paodb
paodb requested a review from javier-godoy August 18, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Configured px crops don't map deterministically to the output size

2 participants