Skip to content
Merged
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,16 @@ add(imageCrop);
* Get cropped image

```java
Image croppedImage = new Image(imageCrop.getCroppedImageDataUri(), "cropped image")
String dataUri = imageCrop.getCroppedImageDataUri(); // null until the image is cropped
if (dataUri != null) {
add(new Image(dataUri, "cropped image"));
}
```

The cropped image is `null` until the image is cropped, either by the user or by an initial crop. To get the full image without user interaction, set an initial crop that covers it:

```java
imageCrop.setCrop(new Crop("%", 0, 0, 100, 100));
```

## Special configuration when using Spring
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,35 @@

/**
* Represents an event triggered when an image is cropped and encoded.
* This event is for internal use only.
*/
@DomEvent("cropped-image")
public class CroppedImageEvent extends ComponentEvent<ImageCrop> {

private String croppedImageDataUri;

Check warning on line 34 in src/main/java/com/flowingcode/vaadin/addons/imagecrop/CroppedImageEvent.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this private field "final", it is never reassigned.

See more on https://sonarcloud.io/project/issues?id=FlowingCode_ImageCrop&issues=AaFqOFRh2rLupZiu83d1&open=AaFqOFRh2rLupZiu83d1&pullRequest=39

private int imageRevision;

Check warning on line 36 in src/main/java/com/flowingcode/vaadin/addons/imagecrop/CroppedImageEvent.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this private field "final", it is never reassigned.

See more on https://sonarcloud.io/project/issues?id=FlowingCode_ImageCrop&issues=AaFqOFRh2rLupZiu83d2&open=AaFqOFRh2rLupZiu83d2&pullRequest=39

/**
* Constructs a new CroppedImageEvent.
*
* @param source the source of the event
* @param fromClient <code>true</code> if the event originated from the client-side,
* <code>false</code> otherwise
* @param croppedImageDataUri the data URL of the cropped image
* @param imageRevision the revision of the image that was cropped
*/
public CroppedImageEvent(ImageCrop source, boolean fromClient,
Comment thread
javier-godoy marked this conversation as resolved.
Resolved
@EventData("event.detail.croppedImageDataUri") String croppedImageDataUri) {
@EventData("event.detail.croppedImageDataUri") String croppedImageDataUri,
@EventData("event.detail.imageRevision") int imageRevision) {
super(source, fromClient);
this.croppedImageDataUri = croppedImageDataUri;
this.imageRevision = imageRevision;
}

/** Returns the revision of the image that was cropped. */
int getImageRevision() {
return this.imageRevision;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@
import java.util.Objects;

import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.react.ReactAdapterComponent;
import com.vaadin.flow.server.streams.DownloadHandler;
import com.vaadin.flow.shared.Registration;

/**
* Component for cropping images based on
Expand All @@ -53,17 +51,24 @@ public class ImageCrop extends ReactAdapterComponent {

private static final String IMG_SRC_RESOURCE_ATTRIBUTE = "img-src";

private static final String IMG_SRC_STATE = "imgSrc";

private String croppedImageDataUri;

// incremented each time the image changes, so that a crop can be matched to its image
private int imageRevision;

// revision of the image that croppedImageDataUri was cropped from
private int croppedImageRevision;

/**
* Constructs an ImageCrop component with the given image URL.
*
* @param src the URL of the image to be cropped
*/
public ImageCrop(String src) {
this.setImageSrc(src);
this.addCroppedImageListener(this::updateCroppedImage);
this.croppedImageDataUri = src;
Comment thread
javier-godoy marked this conversation as resolved.
Resolved
addListener(CroppedImageEvent.class, this::updateCroppedImage);
}

/**
Expand All @@ -76,20 +81,7 @@ public ImageCrop(String src) {
*/
public ImageCrop(DownloadHandler downloadHandler) {
this.setImageSrc(downloadHandler);
this.addCroppedImageListener(this::updateCroppedImage);
}

/**
* Adds a listener for the {@link CroppedImageEvent} fired when the
* cropped image is updated.
*
* @param listener the listener to be added
* @return a registration for the listener, which can be used to remove the
* listener
*/
protected Registration addCroppedImageListener(
ComponentEventListener<CroppedImageEvent> listener) {
return this.addListener(CroppedImageEvent.class, listener);
addListener(CroppedImageEvent.class, this::updateCroppedImage);
}

/**
Expand All @@ -99,15 +91,23 @@ protected Registration addCroppedImageListener(
*/
private void updateCroppedImage(CroppedImageEvent event) {
this.croppedImageDataUri = event.getCroppedImageDataUri();
this.croppedImageRevision = event.getImageRevision();
}

/** Marks the image as changed, so that crops of the previous image are discarded. */
private void nextImageRevision() {
imageRevision++;
setState("imgRev", imageRevision);
}

@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
// the resource URL is regenerated on attach (e.g. with a new UI id after a
// refresh with @PreserveOnRefresh), so the image source must follow it
// refresh with @PreserveOnRefresh), so the image source must follow it; the
// image itself is the same, so its revision is kept
if (getElement().hasAttribute(IMG_SRC_RESOURCE_ATTRIBUTE)) {
setState("imgSrc", getElement().getAttribute(IMG_SRC_RESOURCE_ATTRIBUTE));
setState(IMG_SRC_STATE, getElement().getAttribute(IMG_SRC_RESOURCE_ATTRIBUTE));
}
}

Expand All @@ -117,8 +117,12 @@ protected void onAttach(AttachEvent attachEvent) {
* @param imageSrc the image source
*/
public void setImageSrc(String imageSrc) {
// the browser only reloads (and re-crops) the image when its source changes
if (!Objects.equals(imageSrc, getImageSrc())) {
nextImageRevision();
}
getElement().removeAttribute(IMG_SRC_RESOURCE_ATTRIBUTE);
setState("imgSrc", imageSrc);
setState(IMG_SRC_STATE, imageSrc);
}

/**
Expand Down Expand Up @@ -148,9 +152,10 @@ public void setImageSrc(String imageSrc) {
*/
public void setImageSrc(DownloadHandler downloadHandler) {
Objects.requireNonNull(downloadHandler, "downloadHandler must not be null");
// each handler is served under a new URL
nextImageRevision();
getElement().setAttribute(IMG_SRC_RESOURCE_ATTRIBUTE, downloadHandler);
setState("imgSrc", getElement().getAttribute(IMG_SRC_RESOURCE_ATTRIBUTE));
croppedImageDataUri = null;
setState(IMG_SRC_STATE, getElement().getAttribute(IMG_SRC_RESOURCE_ATTRIBUTE));
}

/**
Expand All @@ -165,7 +170,7 @@ public void setImageSrc(DownloadHandler downloadHandler) {
* @return the image source
*/
public String getImageSrc() {
return getState("imgSrc", String.class);
return getState(IMG_SRC_STATE, String.class);
}

/**
Expand Down Expand Up @@ -488,13 +493,16 @@ public double getOutputQuality() {
* Returns the cropped image data URI.
*
* <p>
* When the component is created with a {@link DownloadHandler}, this is {@code null}
* until the image is cropped for the first time.
* Only a crop of the current {@linkplain #getImageSrc() image source} is returned, so
* this is {@code null} until the current image is cropped for the first time. After
* the image source changes, it is set again when the user crops the new image, or as
* soon as the new image loads if a crop is already defined (either with
* {@link #setCrop(Crop)} or the selection kept from the previous image).
*
* @return the cropped image data URI, or {@code null} if not available yet
*/
public String getCroppedImageDataUri() {
return this.croppedImageDataUri;
return croppedImageRevision == imageRevision ? this.croppedImageDataUri : null;
}

/**
Expand Down Expand Up @@ -522,7 +530,8 @@ public void setImageFullHeight(Boolean fullHeight) {
* https://github.com/F0rce/cropper
* </p>
*
* @return byte[] the decoded byte array of the cropped image
* @return the decoded bytes of the cropped image, or {@code null} if the image has not
* been cropped yet
*/
public byte[] getCroppedImageBase64() {
String croppedDataUri = this.getCroppedImageDataUri();
Expand Down
25 changes: 22 additions & 3 deletions src/main/resources/META-INF/frontend/src/image-crop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ class ImageCropElement extends ReactAdapterElement {

const [crop, setCrop] = hooks.useState<Crop>("crop");
const [imgSrc] = hooks.useState<string>("imgSrc");
// incremented by the server each time the image changes
const [imgRev] = hooks.useState<number>("imgRev", 0);
const imgRef = useRef<HTMLImageElement>(null);
const [imgAlt] = hooks.useState<string>("imgAlt");
const [aspect] = hooks.useState<number>("aspect");
Expand Down Expand Up @@ -156,6 +158,10 @@ class ImageCropElement extends ReactAdapterElement {
* below). setCrop's Javadoc documents the difference.
*/
const onImageLoad = () => {
// The image whose pixels are now drawn; until the next load, every
// cropped image comes from it, even if imgSrc has already changed.
this.loadedImgSrc = imgSrc;
this.loadedImgRev = imgRev;
const img = imgRef.current;
if (!img || !crop) {
return;
Expand Down Expand Up @@ -245,11 +251,23 @@ class ImageCropElement extends ReactAdapterElement {
);
}

/**
* Source of the image currently drawn, as set by the server (imgSrc). Recorded
* on load rather than read from image.src, which already points to the new
* source while the previous image is still displayed (see issue #41).
*/
private loadedImgSrc?: string;

/** Revision (imgRev) of the image currently drawn, recorded on load like loadedImgSrc. */
private loadedImgRev?: number;

private fireCroppedImageEvent(croppedImageDataUri: string) {
this.dispatchEvent(
new CustomEvent("cropped-image", {
detail: {
croppedImageDataUri: croppedImageDataUri
croppedImageDataUri: croppedImageDataUri,
// lets the server discard a crop of an image that is no longer the source
imageRevision: this.loadedImgRev
},
})
);
Expand Down Expand Up @@ -281,7 +299,7 @@ class ImageCropElement extends ReactAdapterElement {
// convertToPixelCrop returns a "px" crop unchanged: it would keep the
// configured size, pass the check below and draw a not-yet-loaded image,
// firing a fully transparent image through "cropped-image".
if (!image.naturalWidth || !image.naturalHeight) {
if (!image.naturalWidth || !image.naturalHeight || this.loadedImgRev === undefined) {
return;
}

Expand Down Expand Up @@ -336,7 +354,8 @@ class ImageCropElement extends ReactAdapterElement {
ctx.restore();

// encode the cropped image using the resolved output format
const outputType = resolveOutputType(this.outputMimeType, image.src, this.circularCrop);
// detected from the image actually drawn, not from image.src (see loadedImgSrc)
const outputType = resolveOutputType(this.outputMimeType, this.loadedImgSrc, this.circularCrop);
let croppedImageDataUri = canvas.toDataURL(outputType, this.outputQuality ?? 1.0);

// dispatch the event containing cropped image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@

import com.flowingcode.vaadin.addons.demo.DemoSource;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
Expand All @@ -35,7 +33,7 @@
@Route(value = "image-crop/basic", layout = ImageCropDemoView.class)
public class BasicImageCropDemo extends VerticalLayout {

private Div croppedResultDiv = new Div();
private CroppedImageResult croppedResult = new CroppedImageResult();

public BasicImageCropDemo() {
add(new Span("Select a portion of the picture to crop: "));
Expand All @@ -46,14 +44,9 @@ public BasicImageCropDemo() {

Button getCropButton = new Button("Get Cropped Image");

croppedResultDiv.setId("result-cropped-image-div");
getCropButton.addClickListener(e -> croppedResult.show(imageCrop.getCroppedImageDataUri()));

getCropButton.addClickListener(e -> {
croppedResultDiv.removeAll();
croppedResultDiv.add(new Image(imageCrop.getCroppedImageDataUri(), "cropped image"));
});

add(getCropButton, new Span("Crop Result:"), croppedResultDiv);
add(getCropButton, new Span("Crop Result:"), croppedResult);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*-
* #%L
* Image Crop Add-on
* %%
* Copyright (C) 2024-2026 Flowing Code
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/

package com.flowingcode.vaadin.addons.imagecrop;

import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Span;

/** Shows the cropped image of a demo, or asks the user to select an area first. */
@SuppressWarnings("serial")
class CroppedImageResult extends Div {

CroppedImageResult() {
setId("result-cropped-image-div");
}

/**
* Replaces the content with the given cropped image.
*
* @param croppedImageDataUri the cropped image, or {@code null} if there is none yet
* @return whether there was a cropped image to show
*/
boolean show(String croppedImageDataUri) {
removeAll();
// there is no cropped image until the user selects an area
if (croppedImageDataUri == null) {
add(new Span("Select an area first"));
return false;
}
add(new Image(croppedImageDataUri, "cropped image"));
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@

import com.flowingcode.vaadin.addons.demo.DemoSource;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
Expand All @@ -36,7 +34,7 @@
@Route(value = "image-crop/download-handler", layout = ImageCropDemoView.class)
public class DownloadHandlerImageCropDemo extends VerticalLayout {

private Div croppedResultDiv = new Div();
private CroppedImageResult croppedResult = new CroppedImageResult();

public DownloadHandlerImageCropDemo() {
add(new Span("Select a portion of the picture to crop: "));
Expand All @@ -50,17 +48,9 @@ public DownloadHandlerImageCropDemo() {

Button getCropButton = new Button("Get Cropped Image");

croppedResultDiv.setId("result-cropped-image-div");
getCropButton.addClickListener(e -> croppedResult.show(imageCrop.getCroppedImageDataUri()));

getCropButton.addClickListener(e -> {
croppedResultDiv.removeAll();
// there is no cropped image until the user selects an area
if (imageCrop.getCroppedImageDataUri() != null) {
croppedResultDiv.add(new Image(imageCrop.getCroppedImageDataUri(), "cropped image"));
}
});

add(getCropButton, new Span("Crop Result:"), croppedResultDiv);
add(getCropButton, new Span("Crop Result:"), croppedResult);
}

}
Loading
Loading