Skip to content
Draft
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The component allows to crop images and configure the following properties for a
* max width (maximum crop width)
* max height (maximum crop height)
* rule of thirds (to show rule of thirds lines in the cropped area)
* output MIME type (the format used to encode the cropped image: `image/png`, `image/jpeg` or `image/webp`; when unset it is auto-detected from the image source, and circular crops always use a transparency-capable format)
* output quality (encoding quality between 0 and 1 for lossy formats such as `image/jpeg`)

The cropped image result can be obtain as a URI using `getCroppedImageDataUri` method
or as a Base64 encoded byte array by using `getCroppedImageBase64` method.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.flowingcode.vaadin.addons</groupId>
<artifactId>image-crop-addon</artifactId>
<version>1.2.3-SNAPSHOT</version>
<version>1.3.0-SNAPSHOT</version>
<name>Image Crop Add-on</name>
<description>Image Crop Add-on for Vaadin Flow</description>
<url>https://www.flowingcode.com/en/open-source/</url>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,59 @@
return getState("ruleOfThirds", Boolean.class);
}

/**
* Sets the MIME type used to encode the cropped image (for example
* {@code "image/jpeg"} or {@code "image/png"}). Supported types are
* {@code image/png}, {@code image/jpeg} and {@code image/webp}; any other or
* unsupported value falls back to {@code image/png}.
*
* <p>
* When left unset, the output format is auto-detected from the image source (its
* data URL MIME type or file extension). Circular crops are always encoded in a
* transparency-capable format regardless of this setting.
*
* @param outputMimeType the MIME type used to encode the cropped image
*/
public void setOutputMimeType(String outputMimeType) {
setState("outputMimeType", outputMimeType);

Check failure on line 354 in src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "outputMimeType" 3 times.

See more on https://sonarcloud.io/project/issues?id=FlowingCode_ImageCrop&issues=AZ8kQDwIt3rOAWHGwayS&open=AZ8kQDwIt3rOAWHGwayS&pullRequest=34
}

/**
* Gets the MIME type used to encode the cropped image, or {@code null} if it is
* auto-detected from the image source.
*
* @return the configured output MIME type, or {@code null}
*/
public String getOutputMimeType() {
if (getElement().getPropertyRaw("outputMimeType") == null) {
return null;
}
return getState("outputMimeType", String.class);
}

/**
* Sets the encoding quality used for lossy output formats such as
* {@code image/jpeg} and {@code image/webp}. Ignored for {@code image/png}.
* Defaults to {@code 1.0}.
*
* @param outputQuality a value between 0 and 1
*/
public void setOutputQuality(double outputQuality) {
setState("outputQuality", outputQuality);

Check failure on line 378 in src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "outputQuality" 3 times.

See more on https://sonarcloud.io/project/issues?id=FlowingCode_ImageCrop&issues=AZ8kQDwIt3rOAWHGwayT&open=AZ8kQDwIt3rOAWHGwayT&pullRequest=34
}

/**
* Gets the encoding quality used for lossy output formats.
*
* @return the output quality, between 0 and 1
*/
public double getOutputQuality() {
if (getElement().getPropertyRaw("outputQuality") == null) {
return 1.0;
}
return getState("outputQuality", Double.class);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Returns the cropped image data URI.
*
Expand Down
88 changes: 85 additions & 3 deletions src/main/resources/META-INF/resources/frontend/src/image-crop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,67 @@
import React from 'react';
import { type Crop, ReactCrop, PixelCrop, PercentCrop, makeAspectCrop, centerCrop, convertToPixelCrop } from "react-image-crop";

// MIME types that HTMLCanvasElement.toDataURL can actually encode across browsers.
// Anything else silently falls back to image/png, so we never emit it.
const SUPPORTED_OUTPUT_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);

/** Normalizes a MIME type: lowercased, parameters stripped (e.g. "image/JPEG; charset=x" -> "image/jpeg"). */
function normalizeMimeType(mime: string | null | undefined): string | undefined {
if (!mime) {
return undefined;
}
const normalized = mime.toLowerCase().split(";")[0].trim();
return normalized || undefined;
}

/**
* Zero-network detection of the image MIME type from its source: reads it from a
* data URL prefix, or infers it from the file extension of a regular URL.
*/
function detectMimeTypeFromSrc(src: string | null | undefined): string | undefined {
if (!src) {
return undefined;
}
if (src.startsWith("data:")) {
// e.g. "data:image/png;base64,..." or "data:image/svg+xml,..."
const sep = src.search(/[;,]/);
return sep > 5 ? normalizeMimeType(src.substring(5, sep)) : undefined;
}
// Strip query/hash, then read the file extension.
const path = src.split(/[?#]/)[0];
const ext = path.substring(path.lastIndexOf(".") + 1).toLowerCase();
switch (ext) {
case "png": return "image/png";
case "jpg":
case "jpeg": return "image/jpeg";
case "webp": return "image/webp";
default: return undefined;
}
}

/**
* Resolves the MIME type used to encode the cropped output.
*
* Uses the explicitly requested type when given (normalized + validated against
* the supported set); otherwise auto-detects it from the image source. Falls
* back to "image/png" when the result is empty or unsupported. Circular crops
* are forced to a transparency-capable format, since JPEG has no alpha channel
* and would render the rounded corners black.
*/
function resolveOutputType(requested: string | null | undefined, src: string | null | undefined, circular: boolean): string {
let type = normalizeMimeType(requested);
if (!type) {
type = detectMimeTypeFromSrc(src);
}
if (!type || !SUPPORTED_OUTPUT_TYPES.has(type)) {
type = "image/png";
}
if (circular && type === "image/jpeg") {
type = "image/png";
}
return type;
}

class ImageCropElement extends ReactAdapterElement {

protected render(hooks: RenderHooks): ReactElement<any, string | JSXElementConstructor<any>> | null {
Expand All @@ -41,9 +102,14 @@
const [maxWidth] = hooks.useState<number>("maxWidth");
const [maxHeight] = hooks.useState<number>("maxHeight");
const [ruleOfThirds] = hooks.useState<boolean>("ruleOfThirds", false);

// Output format settings; also read via this.outputMimeType / this.outputQuality at crop time.
const [outputMimeType] = hooks.useState<string>("outputMimeType");
const [outputQuality] = hooks.useState<number>("outputQuality", 1.0);

Check warning on line 107 in src/main/resources/META-INF/resources/frontend/src/image-crop.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use a zero fraction in the number.

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

// Track previous image dimensions to adjust crop proportionally when resizing
const prevImgSize = useRef<{ width: number; height: number } | null>(null);
// Skip the first run of the output-format effect (initial encoding is handled on image load)
const didMountRef = useRef(false);

/**
* Handles intial calculations on image load.
Expand Down Expand Up @@ -118,6 +184,21 @@
return () => resizeObserver.disconnect();
}, [crop]);

/**
* Re-encodes the current crop on the client when the output format or quality
* changes, so the result stays in sync without relying on server-side
* state/JS ordering. Skips the initial mount (handled by onImageLoad).
*/
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
if (crop) {
this._updateCroppedImage(crop);
}
}, [outputMimeType, outputQuality]);

const onChange = (c: Crop) => {
setCrop(c);
};
Expand Down Expand Up @@ -233,8 +314,9 @@

ctx.restore();

// get the cropped image
let croppedImageDataUri = canvas.toDataURL("image/png", 1.0);
// encode the cropped image using the resolved output format
const outputType = resolveOutputType(this.outputMimeType, image.src, this.circularCrop);
let croppedImageDataUri = canvas.toDataURL(outputType, this.outputQuality ?? 1.0);

Check warning on line 319 in src/main/resources/META-INF/resources/frontend/src/image-crop.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use a zero fraction in the number.

See more on https://sonarcloud.io/project/issues?id=FlowingCode_ImageCrop&issues=AZ8kB0WicMwXWqX680af&open=AZ8kB0WicMwXWqX680af&pullRequest=34
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// dispatch the event containing cropped image
this.fireCroppedImageEvent(croppedImageDataUri);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class ImageCropDemoView extends TabbedDemo {
public ImageCropDemoView() {
addDemo(BasicImageCropDemo.class);
addDemo(UploadImageCropDemo.class);
addDemo(OutputFormatImageCropDemo.class);
setSizeFull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*-
* #%L
* Image Crop Add-on
* %%
* Copyright (C) 2024-2025 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.component.select.Select;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;

@DemoSource
@PageTitle("Image Crop Output Format")
@SuppressWarnings("serial")
@Route(value = "image-crop/output-format", layout = ImageCropDemoView.class)
public class OutputFormatImageCropDemo extends VerticalLayout {

private static final String AUTO_DETECT = "Auto-detect";

private Div croppedResultDiv = new Div();

public OutputFormatImageCropDemo() {
add(new Span("Choose an output format and crop the image. "
+ "When left on \"Auto-detect\", the format is inferred from the image source."));

ImageCrop imageCrop = new ImageCrop("images/empty-plant.png");
imageCrop.setCrop(new Crop("%", 25, 25, 50, 50)); // start with a centered selection

// Pick the MIME type used to encode the cropped image.
Select<String> formatSelect = new Select<>();
formatSelect.setLabel("Output format");
formatSelect.setItems(AUTO_DETECT, "image/png", "image/jpeg", "image/webp");
formatSelect.setValue(AUTO_DETECT);
formatSelect.addValueChangeListener(e -> {
String value = e.getValue();
// Changing the format re-encodes the current crop automatically (client-side effect).
imageCrop.setOutputMimeType(AUTO_DETECT.equals(value) ? null : value);
});

add(formatSelect, imageCrop);

Button getCropButton = new Button("Get Cropped Image");
croppedResultDiv.setId("result-cropped-image-div");

getCropButton.addClickListener(e -> {
croppedResultDiv.removeAll();
String dataUri = imageCrop.getCroppedImageDataUri();
Span mimeType = new Span("Result MIME type: " + extractMimeType(dataUri));
mimeType.getStyle().set("display", "block");
croppedResultDiv.add(mimeType);
croppedResultDiv.add(new Image(dataUri, "cropped image"));
});

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

/** Reads the MIME type from the prefix of a {@code data:} URI, for display purposes. */
private static String extractMimeType(String dataUri) {
if (dataUri != null && dataUri.startsWith("data:")) {
int sep = dataUri.indexOf(';', 5);
if (sep < 0) {
sep = dataUri.indexOf(',', 5);
}
if (sep > 5) {
return dataUri.substring(5, sep);
}
}
return "unknown";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -45,6 +46,30 @@ public void testSetAndGetAspect() {
assertEquals(expectedAspect, Double.valueOf(imageCrop.getAspect()));
}

@Test
public void testSetAndGetOutputMimeType() {
String expectedMimeType = "image/jpeg";
imageCrop.setOutputMimeType(expectedMimeType);
assertEquals(expectedMimeType, imageCrop.getOutputMimeType());
}

@Test
public void testSetAndGetOutputQuality() {
double expectedQuality = 0.8;
imageCrop.setOutputQuality(expectedQuality);
assertEquals(expectedQuality, imageCrop.getOutputQuality(), 0.0);
}

@Test
public void testGetOutputMimeTypeDefaultsToNullWhenUnset() {
assertNull(imageCrop.getOutputMimeType());
}

@Test
public void testGetOutputQualityDefaultsToOneWhenUnset() {
assertEquals(1.0, imageCrop.getOutputQuality(), 0.0);
}

@Test
public void testEncodedCroppedImageEvent() {
String expectedCroppedImageUri = "croppedImageUri";
Expand Down
Loading