diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index f804cc1..4080748 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: Bug Report -description: Please report issues related to TEMPLATE_ADDON here. +description: Please report issues related to Image Crop add-on here. body: - type: textarea id: problem-description diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 4d37c3b..65d3328 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,5 +1,5 @@ name: Feature Request -description: Please add feature suggestions related to TEMPLATE_ADDON here. +description: Please add feature suggestions related to Image Crop add-on here. body: - type: textarea id: feature-proposal diff --git a/.gitignore b/.gitignore index 8a30ffa..2201857 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ drivers tsconfig.json .idea types.d.ts -/frontend/generated -/frontend/index.html vite.generated.ts vite.config.ts -/src/main/dev-bundle \ No newline at end of file +/src/main/dev-bundle +/src/main/bundles +/src/main/frontend/generated +/src/main/frontend/index.html \ No newline at end of file diff --git a/README.md b/README.md index 44743af..1530538 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,37 @@ -[![Published on Vaadin Directory](https://img.shields.io/badge/Vaadin%20Directory-published-00b4f0.svg)](https://vaadin.com/directory/component/template-addon) -[![Stars on vaadin.com/directory](https://img.shields.io/vaadin-directory/star/template-addon.svg)](https://vaadin.com/directory/component/template-addon) -[![Build Status](https://jenkins.flowingcode.com/job/template-addon/badge/icon)](https://jenkins.flowingcode.com/job/template-addon) -[![Maven Central](https://img.shields.io/maven-central/v/com.flowingcode.vaadin.addons/template-addon)](https://mvnrepository.com/artifact/com.flowingcode.vaadin.addons/template-addon) +[![Published on Vaadin Directory](https://img.shields.io/badge/Vaadin%20Directory-published-00b4f0.svg)](https://vaadin.com/directory/component/image-crop-add-on) +[![Stars on vaadin.com/directory](https://img.shields.io/vaadin-directory/star/image-crop-add-on.svg)](https://vaadin.com/directory/component/image-crop-add-on) +[![Build Status](https://jenkins.flowingcode.com/job/ImageCrop-addon/badge/icon)](https://jenkins.flowingcode.com/job/ImageCrop-addon) +[![Maven Central](https://img.shields.io/maven-central/v/com.flowingcode.vaadin.addons/image-crop-addon)](https://mvnrepository.com/artifact/com.flowingcode.vaadin.addons/image-crop-addon) -# Template Add-on +# Image Crop Add-on -This is a template project for building new Vaadin 24 add-ons +Component for cropping images. Wrapper for React component [react-image-crop](https://www.npmjs.com/package/react-image-crop). ## Features -* List the features of your add-on in here +The component allows to crop images and configure the following properties for a customized crop: +* crop dimensions (unit, x and y coordinates, width, and height) +* aspect ratio (for example, 1 for a square or 16/9 for landscape) +* circular crop (selection are has circular shape) +* keep selection (selection can't be disabled if the user clicks outside the selection area) +* disabled (cannot resize or draw a new crop) +* locked (cannot create or resize a crop, but can still drag the existing crop around) +* min width (minimum crop width) +* min height (minimum crop height) +* max width (maximum crop width) +* max height (maximum crop height) +* rule of thirds (to show rule of thirds lines in the cropped area) + +The cropped image result can be obtain as a URI using `getCroppedImageDataUri` method +or as a Base64 encoded byte array by using `getCroppedImageBase64` method. ## Online demo -[Online demo here](http://addonsv24.flowingcode.com/template) +[Online demo here](http://addonsv24.flowingcode.com/image-crop) ## Download release -[Available in Vaadin Directory](https://vaadin.com/directory/component/template-addon) +[Available in Vaadin Directory](https://vaadin.com/directory/component/image-crop-add-on) ### Maven install @@ -26,7 +40,7 @@ Add the following dependencies in your pom.xml file: ```xml com.flowingcode.vaadin.addons - template-addon + image-crop-addon X.Y.Z ``` @@ -50,7 +64,7 @@ To see the demo, navigate to http://localhost:8080/ ## Release notes -See [here](https://github.com/FlowingCode/TemplateAddon/releases) +See [here](https://github.com/FlowingCode/ImageCrop/releases) ## Issue tracking @@ -75,20 +89,32 @@ Then, follow these steps for creating a contribution: This add-on is distributed under Apache License 2.0. For license terms, see LICENSE.txt. -TEMPLATE_ADDON is written by Flowing Code S.A. +Image Crop Add-on is written by Flowing Code S.A. # Developer Guide ## Getting started -Add your code samples in this section +* Basic use + +```java +Image image = new Image("images/empty-plant.png", "image to crop"); +ImageCrop imageCrop = new ImageCrop(image); +add(imageCrop); +``` + +* Get cropped image + +```java +Image croppedImage = new Image(imageCrop.getCroppedImageDataUri(), "cropped image") +``` ## Special configuration when using Spring By default, Vaadin Flow only includes ```com/vaadin/flow/component``` to be always scanned for UI components and views. For this reason, the add-on might need to be whitelisted in order to display correctly. -To do so, just add ```com.flowingcode``` to the ```vaadin.whitelisted-packages``` property in ```src/main/resources/application.properties```, like: +To do so, just add ```com.flowingcode``` to the ```vaadin.allowed-packages``` property in ```src/main/resources/application.properties```, like: -```vaadin.whitelisted-packages = com.vaadin,org.vaadin,dev.hilla,com.flowingcode``` +```vaadin.allowed-packages = com.vaadin,org.vaadin,dev.hilla,com.flowingcode``` More information on Spring whitelisted configuration [here](https://vaadin.com/docs/latest/integrations/spring/configuration/#configure-the-scanning-of-packages). diff --git a/pom.xml b/pom.xml index 227f24a..b978e99 100644 --- a/pom.xml +++ b/pom.xml @@ -5,22 +5,22 @@ 4.0.0 com.flowingcode.vaadin.addons - template-addon + image-crop-addon 1.0.0-SNAPSHOT - Template Add-on - Template Add-on for Vaadin Flow + Image Crop Add-on + Image Crop Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ - 24.3.0 + 24.4.6 4.10.0 17 17 UTF-8 UTF-8 ${project.basedir}/drivers - 11.0.12 - 3.9.0 + 11.0.21 + 4.1.0 true @@ -29,7 +29,7 @@ https://www.flowingcode.com - 2023 + 2024 Apache 2 @@ -39,9 +39,9 @@ - https://github.com/FlowingCode/AddonStarter24 - scm:git:git://github.com/FlowingCode/AddonStarter24.git - scm:git:ssh://git@github.com:/FlowingCode/AddonStarter24.git + https://github.com/FlowingCode/ImageCrop + scm:git:git://github.com/FlowingCode/ImageCrop.git + scm:git:ssh://git@github.com:/FlowingCode/ImageCrop.git master @@ -156,6 +156,12 @@ 5.1.1 test + + org.mockito + mockito-core + 3.12.4 + test + @@ -254,7 +260,7 @@ jetty-maven-plugin ${jetty.version} - 3 + 3 true @@ -363,7 +369,7 @@ jetty-maven-plugin ${jetty.version} - 0 + 0 jar diff --git a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java new file mode 100644 index 0000000..ccb2b65 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/Crop.java @@ -0,0 +1,50 @@ +/*- + * #%L + * Image Crop Add-on + * %% + * Copyright (C) 2024 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; + +/** + * Represents crop dimensions. + *

+ * 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 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 + * @param height the height of the cropped area + */ +public record Crop(String unit, int x, int y, int width, int height) { + + /** + * Returns a string representation of the Crop object. + * + * @return A string representing the crop dimensions in the format: + * "{ unit: %s, x: %s, y: %s, width: %s, height: %s }" + * where %s is replaced by the corresponding value. + */ + @Override + public final String toString() { + return "{ unit: %s, x: %s, y: %s, width: %s, height: %s }".formatted(unit, x, y, width, height); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/CroppedImageEvent.java b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/CroppedImageEvent.java new file mode 100644 index 0000000..6f49b54 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/CroppedImageEvent.java @@ -0,0 +1,57 @@ +/*- + * #%L + * Image Crop Add-on + * %% + * Copyright (C) 2024 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.ComponentEvent; +import com.vaadin.flow.component.DomEvent; +import com.vaadin.flow.component.EventData; + +/** + * Represents an event triggered when an image is cropped and encoded. + */ +@DomEvent("cropped-image") +public class CroppedImageEvent extends ComponentEvent { + + private String croppedImageDataUri; + + /** + * Constructs a new CroppedImageEvent. + * + * @param source the source of the event + * @param fromClient true if the event originated from the client-side, + * false otherwise + * @param croppedImageDataUri the data URL of the cropped image + */ + public CroppedImageEvent(ImageCrop source, boolean fromClient, + @EventData("event.detail.croppedImageDataUri") String croppedImageDataUri) { + super(source, fromClient); + this.croppedImageDataUri = croppedImageDataUri; + } + + /** + * Returns the cropped image data URL. + * + * @return the cropped image data URL + */ + public String getCroppedImageDataUri() { + return this.croppedImageDataUri; + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java new file mode 100644 index 0000000..10a0a80 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java @@ -0,0 +1,372 @@ +/*- + * #%L + * Image Crop Add-on + * %% + * Copyright (C) 2024 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 java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.commons.lang3.StringUtils; + +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.shared.Registration; + +/** + * Component for cropping images based on + * react-image-crop + * library. + * This component allows users to define and manipulate crop areas on images. + * + * @author Paola De Bartolo / Flowing Code + */ +@NpmPackage(value = "react-image-crop", version = "11.0.6") +@JsModule("./src/image-crop.tsx") +@Tag("image-crop") +@CssImport("react-image-crop/dist/ReactCrop.css") +public class ImageCrop extends ReactAdapterComponent { + + private String croppedImageDataUri; + + /** + * 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; + } + + /** + * Constructs an ImageCrop component with the given image. + * + * @param image the image to be cropped + */ + public ImageCrop(Image image) { + this(image.getSrc()); + image.getAlt().ifPresent(a -> this.setImageAlt(a)); + } + + /** + * 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 listener) { + return this.addListener(CroppedImageEvent.class, listener); + } + + /** + * Updates the cropped image data URI based on the event data. + * + * @param event the event containing the new cropped image data URI + */ + private void updateCroppedImage(CroppedImageEvent event) { + this.croppedImageDataUri = event.getCroppedImageDataUri(); + } + + /** + * Sets the source of the image to be cropped. + * + * @param imageSrc the image source + */ + public void setImageSrc(String imageSrc) { + setState("imgSrc", imageSrc); + } + + /** + * Gets the source of the image being cropped. + * + * @return the image source + */ + public String getImageSrc() { + return getState("imgSrc", String.class); + } + + /** + * Sets the alternative information of the image to be cropped. + * + * @param imageAlt the image alternative information + */ + public void setImageAlt(String imageAlt) { + setState("imgAlt", imageAlt); + } + + /** + * Gets the alternative information of the image being cropped. + * + * @return the image alternative information + */ + public String getImageAlt() { + return getState("imgAlt", String.class); + } + + /** + * Defines the crop dimensions. + * + * @param crop the crop dimensions + */ + public void setCrop(Crop crop) { + setState("crop", crop); + } + + /** + * Gets the crop dimensions. + * + * @param crop the current crop dimensions + */ + public Crop getCrop() { + return getState("crop", Crop.class); + } + + /** + * Sets the aspect ratio of the crop. + * For example, 1 for a square or 16/9 for landscape. + * + * @param aspect the aspect ratio of the crop + */ + public void setAspect(double aspect) { + setState("aspect", aspect); + } + + /** + * Gets the aspect ratio of the crop. + * + * @return the aspect ratio + */ + public double getAspect() { + return getState("aspect", Double.class); + } + + /** + * Sets whether the crop area should be shown as a circle. + * If the aspect ratio is not 1, the circle will be warped into an oval shape. + * Defaults to false. + * + * @param circularCrop true to show the crop area as a circle, false otherwise + */ + public void setCircularCrop(boolean circularCrop) { + setState("circularCrop", circularCrop); + } + + /** + * Gets whether the crop area is shown as a circle. + * + * @return true if the crop area is a circle, false otherwise + */ + public boolean isCircularCrop() { + return getState("circularCrop", Boolean.class); + } + + /** + * Sets whether the selection can't be disabled if the user clicks outside + * the selection area. Defaults to false. + * + * @param keepSelection true so selection can't be disabled if the user clicks + * outside the selection area, false otherwise. + */ + public void setKeepSelection(boolean keepSelection) { + setState("keepSelection", keepSelection); + } + + /** + * Gets whether the selection is enabled. + * + * @return true if the selection is enabled, false otherwise + */ + public boolean isKeepSelection() { + return getState("keepSelection", Boolean.class); + } + + /** + * Sets whether the user cannot resize or draw a new crop. Defaults to false. + * + * @param disabled true to disable crop resizing and drawing, false otherwise + */ + public void setDisabled(boolean disabled) { + setState("disabled", disabled); + } + + /** + * Gets whether the crop resizing and drawing is disabled. + * + * @return true if disabled, false otherwise + */ + public boolean isDisabled() { + return getState("disabled", Boolean.class); + } + + /** + * Sets whether the user cannot create or resize a crop, but can still drag the + * existing crop around. Defaults to false. + * + * @param locked true to lock the crop, false otherwise + */ + public void setLocked(boolean locked) { + setState("locked", locked); + } + + /** + * Gets whether the crop is locked. + * + * @return true if the crop is locked, false otherwise + */ + public boolean isLocked() { + return getState("locked", Boolean.class); + } + + /** + * Sets a minimum crop width, in pixels. + * + * @param minWidth the minimum crop width + */ + public void setCropMinWidth(Integer minWidth) { + setState("minWidth", minWidth); + } + + /** + * Gets the minimum crop width, in pixels. + * + * @return the minimum crop width + */ + public Integer getCropMinWidth() { + return getState("minWidth", Integer.class); + } + + /** + * Sets a minimum crop height, in pixels. + * + * @param minHeight the minimum crop height + */ + public void setCropMinHeight(Integer minHeight) { + setState("minHeight", minHeight); + } + + /** + * Gets the minimum crop height, in pixels. + * + * @return the minimum crop height + */ + public Integer getCropMinHeight() { + return getState("minHeight", Integer.class); + } + + /** + * Sets a maximum crop width, in pixels. + * + * @param maxWidth the maximum crop width + */ + public void setCropMaxWidth(Integer maxWidth) { + setState("maxWidth", maxWidth); + } + + /** + * Gets the maximum crop width, in pixels. + * + * @return the maximum crop width + */ + public Integer getCropMaxWidth() { + return getState("maxWidth", Integer.class); + } + + /** + * Sets a maximum crop height, in pixels. + * + * @param maxHeight the maximum crop height + */ + public void setCropMaxHeight(Integer maxHeight) { + setState("maxHeight", maxHeight); + } + + /** + * Gets the maximum crop height, in pixels. + * + * @return the maximum crop height + */ + public Integer getCropMaxHeight() { + return getState("maxHeight", Integer.class); + } + + /** + * Sets whether to show rule of thirds lines in the cropped area. Defaults to + * false. + * + * @param ruleOfThirds true to show rule of thirds lines, false otherwise + */ + public void setRuleOfThirds(boolean ruleOfThirds) { + setState("ruleOfThirds", ruleOfThirds); + } + + /** + * Gets whether rule of thirds lines are shown in the cropped area. + * + * @return true if rule of thirds lines are shown, false otherwise + */ + public boolean isRuleOfThirds() { + return getState("ruleOfThirds", Boolean.class); + } + + /** + * Returns the cropped image data URI. + * + * @return the cropped image data URI + */ + public String getCroppedImageDataUri() { + return this.croppedImageDataUri; + } + + /** + * Decodes the cropped image data URI and returns it as a byte array. If the image data URI is not + * in the format "data:image/*;base64,", it will be decoded assuming it is a Base64 encoded + * string. + * + *

+ * This method incorporates work licensed under MIT. Copyright 2021-2023 David "F0rce" Dodlek + * https://github.com/F0rce/cropper + *

+ * + * @return byte[] the decoded byte array of the cropped image + */ + public byte[] getCroppedImageBase64() { + String croppedDataUri = this.getCroppedImageDataUri(); + if (StringUtils.isBlank(croppedDataUri)) { + return null; + } + + String base64Data = croppedDataUri; + if (croppedDataUri.contains("base64,")) { + base64Data = croppedDataUri.split(",")[1]; + } + + return Base64.getDecoder().decode(base64Data.getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java b/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java deleted file mode 100644 index ab06f5a..0000000 --- a/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java +++ /dev/null @@ -1,32 +0,0 @@ -/*- - * #%L - * Template Add-on - * %% - * Copyright (C) 2023 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.template; - -import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.dependency.JsModule; -import com.vaadin.flow.component.dependency.NpmPackage; -import com.vaadin.flow.component.html.Div; - -@SuppressWarnings("serial") -@NpmPackage(value = "@polymer/paper-input", version = "3.2.1") -@JsModule("@polymer/paper-input/paper-input.js") -@Tag("paper-input") -public class TemplateAddon extends Div {} 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 new file mode 100644 index 0000000..54f5d7c --- /dev/null +++ b/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx @@ -0,0 +1,172 @@ +/*- + * #%L + * Image Crop Add-on + * %% + * Copyright (C) 2024 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% + */ + +import { ReactAdapterElement, RenderHooks } from 'Frontend/generated/flow/ReactAdapter'; +import { JSXElementConstructor, ReactElement, useRef } from "react"; +import React from 'react'; +import { type Crop, ReactCrop, PixelCrop, makeAspectCrop, centerCrop } from "react-image-crop"; + +class ImageCropElement extends ReactAdapterElement { + + protected render(hooks: RenderHooks): ReactElement> | null { + + const [crop, setCrop] = hooks.useState("crop"); + const [imgSrc] = hooks.useState("imgSrc"); + const imgRef = useRef(null); + const [imgAlt] = hooks.useState("imgAlt"); + const [aspect] = hooks.useState("aspect"); + const [circularCrop] = hooks.useState("circularCrop", false); + const [keepSelection] = hooks.useState("keepSelection", false); + const [disabled] = hooks.useState("disabled", false); + const [locked] = hooks.useState("locked", false); + const [minWidth] = hooks.useState("minWidth"); + const [minHeight] = hooks.useState("minHeight"); + const [maxWidth] = hooks.useState("maxWidth"); + const [maxHeight] = hooks.useState("maxHeight"); + const [ruleOfThirds] = hooks.useState("ruleOfThirds", false); + + const onImageLoad = () => { + if (imgRef.current && crop) { + const { width, height } = imgRef.current; + 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); + } + }; + + const onChange = (c: Crop) => { + setCrop(c); + }; + + const onComplete = (c: PixelCrop) => { + croppedImageEncode(c); + }; + + const croppedImageEncode = (completedCrop: PixelCrop) => { + if (completedCrop) { + + // get the image element + const image = imgRef.current; + + // create a canvas element to draw the cropped image + const canvas = document.createElement("canvas"); + + // draw the image on the canvas + if (image) { + const ccrop = completedCrop; + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const ctx = canvas.getContext("2d"); + const pixelRatio = window.devicePixelRatio; + canvas.width = ccrop.width * pixelRatio * scaleX; + canvas.height = ccrop.height * pixelRatio * scaleY; + + if (ctx) { + ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + ctx.imageSmoothingQuality = "high"; + + ctx.save(); + + if (circularCrop) { + canvas.width = ccrop.width; + canvas.height = ccrop.height; + + ctx.beginPath(); + + ctx.arc(ccrop.width / 2, ccrop.height / 2, ccrop.height / 2, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.clip(); + } + + ctx.drawImage( + image, + ccrop.x * scaleX, + ccrop.y * scaleY, + ccrop.width * scaleX, + ccrop.height * scaleY, + 0, + 0, + ccrop.width, + ccrop.height + ); + + ctx.restore(); + } + + // get the cropped image + let croppedImageDataUri = canvas.toDataURL("image/png", 1.0); + + // dispatch the event containing cropped image + this.fireCroppedImageEvent(croppedImageDataUri); + } + } + } + + return ( + onChange(c)} + onComplete={(c: PixelCrop) => onComplete(c)} + circularCrop={circularCrop} + aspect={aspect} + keepSelection={keepSelection} + disabled={disabled} + locked={locked} + minWidth={minWidth} + minHeight={minHeight} + maxWidth={maxWidth} + maxHeight={maxHeight} + ruleOfThirds={ruleOfThirds} + > + {imgAlt} + + ); + } + + private fireCroppedImageEvent(croppedImageDataUri: string) { + this.dispatchEvent( + new CustomEvent("cropped-image", { + detail: { + croppedImageDataUri: croppedImageDataUri + }, + }) + ); + } +} + +customElements.define("image-crop", ImageCropElement); \ No newline at end of file diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java index 8d29aba..48be86d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java +++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java @@ -1,8 +1,8 @@ /*- * #%L - * Template Add-on + * Image Crop Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2024 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java new file mode 100644 index 0000000..c25566f --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/BasicImageCropDemo.java @@ -0,0 +1,61 @@ +/*- + * #%L + * Image Crop Add-on + * %% + * Copyright (C) 2024 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; + +@DemoSource +@PageTitle("Basic Image Crop") +@SuppressWarnings("serial") +@Route(value = "image-crop/basic", layout = ImageCropDemoView.class) +public class BasicImageCropDemo extends VerticalLayout { + + private Div croppedResultDiv = new Div(); + + 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); + 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(); + 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/template/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/DemoView.java similarity index 86% rename from src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java rename to src/test/java/com/flowingcode/vaadin/addons/imagecrop/DemoView.java index a6a1d28..c698d23 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/DemoView.java @@ -1,8 +1,8 @@ /*- * #%L - * Template Add-on + * Image Crop Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2024 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ * #L% */ -package com.flowingcode.vaadin.addons.template; +package com.flowingcode.vaadin.addons.imagecrop; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.BeforeEnterEvent; @@ -31,6 +31,6 @@ public class DemoView extends VerticalLayout implements BeforeEnterObserver { @Override public void beforeEnter(BeforeEnterEvent event) { - event.forwardTo(TemplateDemoView.class); + event.forwardTo(ImageCropDemoView.class); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java similarity index 67% rename from src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java rename to src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java index 1c3aecd..53a5e3d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java @@ -1,8 +1,8 @@ /*- * #%L - * Template Add-on + * Image Crop Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2024 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,9 +17,10 @@ * limitations under the License. * #L% */ -package com.flowingcode.vaadin.addons.template; +package com.flowingcode.vaadin.addons.imagecrop; import com.flowingcode.vaadin.addons.DemoLayout; +import com.flowingcode.vaadin.addons.GithubBranch; import com.flowingcode.vaadin.addons.GithubLink; import com.flowingcode.vaadin.addons.demo.TabbedDemo; import com.vaadin.flow.router.ParentLayout; @@ -27,12 +28,14 @@ @SuppressWarnings("serial") @ParentLayout(DemoLayout.class) -@Route("template") -@GithubLink("https://github.com/FlowingCode/AddonStarter24") -public class TemplateDemoView extends TabbedDemo { +@Route("image-crop") +@GithubBranch("initial-implementation") +@GithubLink("https://github.com/FlowingCode/ImageCrop") +public class ImageCropDemoView extends TabbedDemo { - public TemplateDemoView() { - addDemo(TemplateDemo.class); + public ImageCropDemoView() { + addDemo(BasicImageCropDemo.class); + addDemo(UploadImageCropDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/UploadImageCropDemo.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/UploadImageCropDemo.java new file mode 100644 index 0000000..cb91d8e --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/UploadImageCropDemo.java @@ -0,0 +1,131 @@ +/*- + * #%L + * Image Crop Add-on + * %% + * Copyright (C) 2024 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 java.io.ByteArrayOutputStream; +import java.util.Base64; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.vaadin.flow.component.avatar.Avatar; +import com.vaadin.flow.component.avatar.AvatarVariant; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; +import com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.upload.Upload; +import com.vaadin.flow.component.upload.receivers.MemoryBuffer; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; + +@DemoSource +@PageTitle("Image Crop with Upload") +@SuppressWarnings("serial") +@Route(value = "image-crop/upload", layout = ImageCropDemoView.class) +public class UploadImageCropDemo extends Div { + + private static final String[] ACCEPTED_MIME_TYPES = + {"image/gif", "image/png", "image/jpeg", "image/bmp", "image/webp"}; + + private Avatar avatar = new Avatar(); + private ImageCrop imageCrop = null; + private byte[] newCroppedPicture = null; + + public UploadImageCropDemo() { + avatar.addThemeVariants(AvatarVariant.LUMO_XLARGE); + avatar.setHeight("12em"); + avatar.setWidth("12em"); + Div avatarDiv = new Div(avatar); + + MemoryBuffer buffer = new MemoryBuffer(); + Upload uploadComponent = new Upload(buffer); + uploadComponent.setMaxFiles(1); + uploadComponent.setMaxFileSize(1024 * 1024 * 10); + uploadComponent.setAcceptedFileTypes(ACCEPTED_MIME_TYPES); + + Span uploadCaption = new Span("Upload an image to crop and set as avatar:"); + + HorizontalLayout avatarLayout = + new HorizontalLayout(avatarDiv, new VerticalLayout(uploadCaption, uploadComponent)); + + avatarLayout.setAlignItems(Alignment.CENTER); + + uploadComponent.addFinishedListener(e -> { + openCropDialog(((ByteArrayOutputStream) buffer.getFileData().getOutputBuffer()), + e.getMIMEType()); + }); + + uploadComponent.addFileRejectedListener(event -> { + String errorMessage = event.getErrorMessage(); + Notification notification = + Notification.show(errorMessage, 5000, Notification.Position.MIDDLE); + notification.addThemeVariants(NotificationVariant.LUMO_ERROR); + }); + + add(avatarLayout); + } + + private void openCropDialog(ByteArrayOutputStream outputStream, String mimeType) { + // Set up image crop dialog + Dialog dialog = new Dialog(); + dialog.setCloseOnOutsideClick(false); + dialog.setMaxHeight("100%"); + dialog.setMaxWidth(dialog.getHeight()); + + Button cropButton = new Button("Crop image"); + Button dialogCancelButton = new Button("Cancel"); + dialogCancelButton.addThemeVariants(ButtonVariant.LUMO_ERROR); + + String src = getImageAsBase64(outputStream.toByteArray(), mimeType); + imageCrop = new ImageCrop(src); + imageCrop.setAspect(1.0); + imageCrop.setCircularCrop(true); + imageCrop.setCrop(new Crop("%", 25, 25, 50, 50)); // centered crop + imageCrop.setKeepSelection(true); + + cropButton.addClickListener(event -> { + newCroppedPicture = imageCrop.getCroppedImageBase64(); + avatar.setImage(imageCrop.getCroppedImageDataUri()); + dialog.close(); + }); + dialogCancelButton.addClickListener(c -> dialog.close()); + + HorizontalLayout buttonLayout = new HorizontalLayout(dialogCancelButton, cropButton); + Div dialogLayout = new Div(imageCrop); + dialogLayout.setSizeFull(); + buttonLayout.setWidthFull(); + buttonLayout.setJustifyContentMode(JustifyContentMode.END); + dialog.add(dialogLayout); + dialog.getFooter().add(buttonLayout); + dialog.open(); + } + + private String getImageAsBase64(byte[] src, String mimeType) { + return src != null ? "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(src) + : null; + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/it/AbstractViewTest.java similarity index 96% rename from src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java rename to src/test/java/com/flowingcode/vaadin/addons/imagecrop/it/AbstractViewTest.java index c2fda15..fed403a 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/it/AbstractViewTest.java @@ -1,8 +1,8 @@ /*- * #%L - * Template Add-on + * Image Crop Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2024 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ * #L% */ -package com.flowingcode.vaadin.addons.template.it; +package com.flowingcode.vaadin.addons.imagecrop.it; import com.vaadin.testbench.ScreenshotOnFailureRule; import com.vaadin.testbench.TestBench; diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/it/ViewIT.java similarity index 87% rename from src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java rename to src/test/java/com/flowingcode/vaadin/addons/imagecrop/it/ViewIT.java index 16a2a12..13609cf 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/it/ViewIT.java @@ -1,8 +1,8 @@ /*- * #%L - * Template Add-on + * Image Crop Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2024 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,15 +18,15 @@ * #L% */ -package com.flowingcode.vaadin.addons.template.it; +package com.flowingcode.vaadin.addons.imagecrop.it; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -import static org.junit.Assert.assertThat; import com.vaadin.testbench.TestBenchElement; import org.hamcrest.Description; import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.junit.Test; @@ -58,7 +58,7 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr @Test public void componentWorks() { - TestBenchElement element = $("paper-input").first(); - assertThat(element, hasBeenUpgradedToCustomElement); + TestBenchElement element = $("image-crop").first(); + MatcherAssert.assertThat(element, hasBeenUpgradedToCustomElement); } } 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 new file mode 100644 index 0000000..6b351d2 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java @@ -0,0 +1,67 @@ +package com.flowingcode.vaadin.addons.imagecrop.test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Base64; + +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; + +public class ImageCropTest { + + private ImageCrop imageCrop; + + @Before + public void setUp() { + imageCrop = new ImageCrop("dummyImageSrc"); + } + + @Test + public void testSetAndGetImageSrc() { + String expectedSrc = "newImageSrc"; + imageCrop.setImageSrc(expectedSrc); + assertEquals(expectedSrc, imageCrop.getImageSrc()); + } + + @Test + public void testSetAndGetCrop() { + Crop expectedCrop = new Crop("%", 10, 10, 200, 200); + imageCrop.setCrop(expectedCrop); + assertEquals(expectedCrop, imageCrop.getCrop()); + } + + @Test + public void testSetAndGetAspect() { + Double expectedAspect = 16.0 / 9.0; + imageCrop.setAspect(expectedAspect); + assertEquals(expectedAspect, Double.valueOf(imageCrop.getAspect())); + } + + @Test + public void testEncodedCroppedImageEvent() { + String expectedCroppedImageUri = "croppedImageUri"; + CroppedImageEvent event = mock(CroppedImageEvent.class); + when(event.getCroppedImageDataUri()).thenReturn(expectedCroppedImageUri); + imageCrop = mock(ImageCrop.class); + when(imageCrop.getCroppedImageDataUri()).thenReturn(expectedCroppedImageUri); + assertEquals(expectedCroppedImageUri, imageCrop.getCroppedImageDataUri()); + } + + @Test + public void testGetCroppedImageBase64() { + byte[] expectedCroppedImageBytes = Base64.getDecoder().decode("SGVsbG8gV29ybGQ="); + imageCrop = mock(ImageCrop.class); + when(imageCrop.getCroppedImageBase64()).thenReturn(expectedCroppedImageBytes); + byte[] actualCroppedImageBytes = imageCrop.getCroppedImageBase64(); + assertNotNull(actualCroppedImageBytes); + assertArrayEquals(expectedCroppedImageBytes, actualCroppedImageBytes); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java similarity index 84% rename from src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java rename to src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java index dcf9b4e..37fafe2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/SerializationTest.java @@ -1,8 +1,8 @@ /*- * #%L - * Template Add-on + * Image Crop Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2024 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,9 +17,8 @@ * limitations under the License. * #L% */ -package com.flowingcode.vaadin.addons.template.test; +package com.flowingcode.vaadin.addons.imagecrop.test; -import com.flowingcode.vaadin.addons.template.TemplateAddon; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -28,6 +27,9 @@ import org.junit.Assert; import org.junit.Test; +import com.flowingcode.vaadin.addons.imagecrop.ImageCrop; +import com.vaadin.flow.component.html.Image; + public class SerializationTest { private void testSerializationOf(Object obj) throws IOException, ClassNotFoundException { @@ -44,7 +46,7 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - testSerializationOf(new TemplateAddon()); + testSerializationOf(new ImageCrop(new Image())); } catch (Exception e) { Assert.fail("Problem while testing serialization: " + e.getMessage()); } diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java b/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java deleted file mode 100644 index 5f6e6ee..0000000 --- a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.flowingcode.vaadin.addons.template; - -import com.flowingcode.vaadin.addons.demo.DemoSource; -import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.router.PageTitle; -import com.vaadin.flow.router.Route; - -@DemoSource -@PageTitle("Template Add-on Demo") -@SuppressWarnings("serial") -@Route(value = "demo", layout = TemplateDemoView.class) -public class TemplateDemo extends Div { - - public TemplateDemo() { - add(new TemplateAddon()); - } -} diff --git a/src/test/resources/META-INF/frontend/styles/shared-styles.css b/src/test/resources/META-INF/frontend/styles/shared-styles.css deleted file mode 100644 index 6680e2d..0000000 --- a/src/test/resources/META-INF/frontend/styles/shared-styles.css +++ /dev/null @@ -1 +0,0 @@ -/*Demo styles*/ \ No newline at end of file diff --git a/src/test/resources/META-INF/resources/images/empty-plant.png b/src/test/resources/META-INF/resources/images/empty-plant.png new file mode 100644 index 0000000..9777f26 Binary files /dev/null and b/src/test/resources/META-INF/resources/images/empty-plant.png differ