diff --git a/README.md b/README.md index 8989d11..f937b9d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ or as a Base64 encoded byte array by using `getCroppedImageBase64` method. ## Supported versions -Supported versions: Vaadin 24-25 (add-on version 1.x) +Supported versions: +- Vaadin 24.8+ and 25: add-on version 2.x +- Vaadin 24 and 25: add-on version 1.x (without `DownloadHandler` support) ## Online demo @@ -112,8 +114,16 @@ Image Crop Add-on is written by Flowing Code S.A. * Basic use ```java -Image image = new Image("images/empty-plant.png", "image to crop"); -ImageCrop imageCrop = new ImageCrop(image); +ImageCrop imageCrop = new ImageCrop("images/empty-plant.png"); +imageCrop.setImageAlt("image to crop"); +add(imageCrop); +``` + +* Image served by a `DownloadHandler` + +```java +ImageCrop imageCrop = new ImageCrop(DownloadHandler.forClassResource(getClass(), + "/META-INF/resources/images/empty-plant.png", "empty-plant.png").inline()); add(imageCrop); ``` diff --git a/pom.xml b/pom.xml index b6cb737..f01ad82 100644 --- a/pom.xml +++ b/pom.xml @@ -4,13 +4,13 @@ com.flowingcode.vaadin.addons image-crop-addon - 1.3.1-SNAPSHOT + 2.0.0-SNAPSHOT Image Crop Add-on Image Crop Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ - 24.4.6 + 24.8.4 4.10.0 17 17 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 5d9b353..fa05a70 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java @@ -2,7 +2,7 @@ * #%L * Image Crop Add-on * %% - * Copyright (C) 2024-2025 Flowing Code + * 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. @@ -22,14 +22,16 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; +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.html.Image; import com.vaadin.flow.component.react.ReactAdapterComponent; +import com.vaadin.flow.server.streams.DownloadHandler; import com.vaadin.flow.shared.Registration; /** @@ -49,6 +51,8 @@ public class ImageCrop extends ReactAdapterComponent { private static final String IMG_FULL_HEIGHT_CLASS_NAME = "img-full-height"; + private static final String IMG_SRC_RESOURCE_ATTRIBUTE = "img-src"; + private String croppedImageDataUri; /** @@ -63,16 +67,16 @@ public ImageCrop(String src) { } /** - * Constructs an ImageCrop component with the given image. + * Constructs an ImageCrop component whose image is served by the given + * {@link DownloadHandler}. See {@link #setImageSrc(DownloadHandler)} for the + * conditions under which the image is served. * - * @param image the image to be cropped - * @deprecated This constructor only preserves the image URL and {@linkplain #setImageAlt(String) - * alternate text}. Use {@link #ImageCrop(String)} instead. + * @param downloadHandler the handler that serves the image to be cropped + * @see #setImageSrc(DownloadHandler) */ - @Deprecated(forRemoval = true, since = "1.2.0") - public ImageCrop(Image image) { - this(image.getSrc()); - image.getAlt().ifPresent(a -> this.setImageAlt(a)); + public ImageCrop(DownloadHandler downloadHandler) { + this.setImageSrc(downloadHandler); + this.addCroppedImageListener(this::updateCroppedImage); } /** @@ -97,18 +101,67 @@ private void updateCroppedImage(CroppedImageEvent event) { this.croppedImageDataUri = event.getCroppedImageDataUri(); } + @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 + if (getElement().hasAttribute(IMG_SRC_RESOURCE_ATTRIBUTE)) { + setState("imgSrc", getElement().getAttribute(IMG_SRC_RESOURCE_ATTRIBUTE)); + } + } + /** * Sets the source of the image to be cropped. * * @param imageSrc the image source */ public void setImageSrc(String imageSrc) { + getElement().removeAttribute(IMG_SRC_RESOURCE_ATTRIBUTE); setState("imgSrc", imageSrc); } + /** + * Sets the source of the image to be cropped to a resource served by the given + * {@link DownloadHandler}. + * + *

+ * The handler is registered to this component, so the image is served while this + * component is attached and visible, independently of any other component. Requests + * are also rejected while this component is disabled or inert (for instance, behind a + * modal dialog), unless the handler overrides + * {@link DownloadHandler#getDisabledUpdateMode()} or + * {@link DownloadHandler#isAllowInert()}. + * + *

+ * Use an {@code inline()} handler, since the image is displayed rather than + * downloaded. When the {@linkplain #setOutputMimeType(String) output MIME type} is not + * set, it is detected from the file extension of the handler's + * {@linkplain DownloadHandler#getUrlPostfix() URL postfix}; handlers without one (such + * as {@code DownloadHandler.fromInputStream(...)}) fall back to {@code image/png}. + * + *

+ * As with any {@code DownloadHandler} set on an element, this method must be called + * while a {@link com.vaadin.flow.component.UI UI} is current. + * + * @param downloadHandler the handler that serves the image to be cropped + */ + public void setImageSrc(DownloadHandler downloadHandler) { + Objects.requireNonNull(downloadHandler, "downloadHandler must not be null"); + getElement().setAttribute(IMG_SRC_RESOURCE_ATTRIBUTE, downloadHandler); + setState("imgSrc", getElement().getAttribute(IMG_SRC_RESOURCE_ATTRIBUTE)); + croppedImageDataUri = null; + } + /** * Gets the source of the image being cropped. * + *

+ * When the source was set with a {@link DownloadHandler}, this returns the internal + * URL of the registered resource. Passing that URL back to + * {@link #setImageSrc(String)} unregisters the handler, so the URL stops being + * served. + * * @return the image source */ public String getImageSrc() { @@ -434,7 +487,11 @@ public double getOutputQuality() { /** * Returns the cropped image data URI. * - * @return the cropped image data URI + *

+ * When the component is created with a {@link DownloadHandler}, this is {@code null} + * until the image is cropped for the first time. + * + * @return the cropped image data URI, or {@code null} if not available yet */ public String getCroppedImageDataUri() { return this.croppedImageDataUri; diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java index c25566f..d689c95 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java @@ -2,7 +2,7 @@ * #%L * Image Crop Add-on * %% - * Copyright (C) 2024 Flowing Code + * 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. @@ -40,15 +40,13 @@ public class BasicImageCropDemo extends VerticalLayout { public BasicImageCropDemo() { add(new Span("Select a portion of the picture to crop: ")); - Image image = new Image("images/empty-plant.png", "image to crop"); - ImageCrop imageCrop = new ImageCrop(image); + ImageCrop imageCrop = new ImageCrop("images/empty-plant.png"); + imageCrop.setImageAlt("image to crop"); add(imageCrop); Button getCropButton = new Button("Get Cropped Image"); croppedResultDiv.setId("result-cropped-image-div"); - croppedResultDiv.setWidth(image.getWidth()); - croppedResultDiv.setHeight(image.getHeight()); getCropButton.addClickListener(e -> { croppedResultDiv.removeAll(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/DownloadHandlerImageCropDemo.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/DownloadHandlerImageCropDemo.java new file mode 100644 index 0000000..cbb38b6 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/DownloadHandlerImageCropDemo.java @@ -0,0 +1,66 @@ +/*- + * #%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.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; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.streams.DownloadHandler; + +@DemoSource +@PageTitle("Image Crop with DownloadHandler") +@SuppressWarnings("serial") +@Route(value = "image-crop/download-handler", layout = ImageCropDemoView.class) +public class DownloadHandlerImageCropDemo extends VerticalLayout { + + private Div croppedResultDiv = new Div(); + + public DownloadHandlerImageCropDemo() { + add(new Span("Select a portion of the picture to crop: ")); + + // The image is served by a DownloadHandler registered to the ImageCrop itself + DownloadHandler handler = DownloadHandler.forClassResource(getClass(), + "/META-INF/resources/images/empty-plant.png", "empty-plant.png").inline(); + ImageCrop imageCrop = new ImageCrop(handler); + imageCrop.setImageAlt("image to crop"); + add(imageCrop); + + Button getCropButton = new Button("Get Cropped Image"); + + croppedResultDiv.setId("result-cropped-image-div"); + + 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); + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java index 4acd8f8..20ce13c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java @@ -2,7 +2,7 @@ * #%L * Image Crop Add-on * %% - * Copyright (C) 2024 Flowing Code + * 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. @@ -37,6 +37,7 @@ public ImageCropDemoView() { addDemo(BasicImageCropDemo.class); addDemo(UploadImageCropDemo.class); addDemo(OutputFormatImageCropDemo.class); + addDemo(DownloadHandlerImageCropDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java index 7c3314d..faac7c7 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java @@ -3,25 +3,36 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; import java.util.Base64; +import org.junit.After; import org.junit.Before; import org.junit.Test; import com.flowingcode.vaadin.addons.imagecrop.Crop; import com.flowingcode.vaadin.addons.imagecrop.CroppedImageEvent; import com.flowingcode.vaadin.addons.imagecrop.ImageCrop; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadResponse; public class ImageCropTest { private ImageCrop imageCrop; + // keeps the current UI strongly reachable during the test + private UI ui; + @Before public void setUp() { + ui = new UI(); + UI.setCurrent(ui); imageCrop = new ImageCrop("dummyImageSrc"); } @@ -32,6 +43,45 @@ public void testSetAndGetImageSrc() { assertEquals(expectedSrc, imageCrop.getImageSrc()); } + @Test + public void testSetImageSrcWithDownloadHandler() { + imageCrop.setImageSrc(imageHandler()); + assertNotNull(imageCrop.getImageSrc()); + assertEquals(imageCrop.getImageSrc(), imageCrop.getElement().getAttribute("img-src")); + } + + @Test + public void testSetImageSrcWithStringReleasesDownloadHandler() { + imageCrop.setImageSrc(imageHandler()); + imageCrop.setImageSrc("newImageSrc"); + assertEquals("newImageSrc", imageCrop.getImageSrc()); + assertFalse(imageCrop.getElement().hasAttribute("img-src")); + } + + @Test + public void testSetImageSrcWithDownloadHandlerResetsCroppedImage() { + imageCrop.setImageSrc(imageHandler()); + assertNull(imageCrop.getCroppedImageDataUri()); + } + + @Test + public void testDownloadHandlerConstructorHasNoCroppedImage() { + imageCrop = new ImageCrop(imageHandler()); + assertNotNull(imageCrop.getImageSrc()); + assertNull(imageCrop.getCroppedImageDataUri()); + assertNull(imageCrop.getCroppedImageBase64()); + } + + @After + public void tearDown() { + UI.setCurrent(null); + } + + private static DownloadHandler imageHandler() { + return DownloadHandler.fromInputStream(event -> new DownloadResponse( + new ByteArrayInputStream(new byte[0]), "image.png", "image/png", 0)).inline(); + } + @Test public void testSetAndGetCrop() { Crop expectedCrop = new Crop("%", 10, 10, 200, 200); diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java index 37fafe2..f2d0b01 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java @@ -2,7 +2,7 @@ * #%L * Image Crop Add-on * %% - * Copyright (C) 2024 Flowing Code + * 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. @@ -28,7 +28,9 @@ import org.junit.Test; import com.flowingcode.vaadin.addons.imagecrop.ImageCrop; -import com.vaadin.flow.component.html.Image; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadResponse; public class SerializationTest { @@ -46,9 +48,25 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - testSerializationOf(new ImageCrop(new Image())); + testSerializationOf(new ImageCrop("")); } catch (Exception e) { Assert.fail("Problem while testing serialization: " + e.getMessage()); } } + + @Test + public void testSerializationWithDownloadHandler() throws ClassNotFoundException, IOException { + UI ui = new UI(); + UI.setCurrent(ui); + try { + byte[] content = new byte[0]; + // unattached, so the pending resource registration is serialized too + testSerializationOf(new ImageCrop(DownloadHandler.fromInputStream(event -> new DownloadResponse( + new ByteArrayInputStream(content), "image.png", "image/png", content.length)).inline())); + } catch (Exception e) { + Assert.fail("Problem while testing serialization: " + e.getMessage()); + } finally { + UI.setCurrent(null); + } + } }