diff --git a/README.md b/README.md index 35045f2..8989d11 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pom.xml b/pom.xml index bdface9..da14320 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.flowingcode.vaadin.addons image-crop-addon - 1.2.3-SNAPSHOT + 1.3.0-SNAPSHOT Image Crop Add-on Image Crop Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ 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 d860fed..509fa9b 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java +++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java @@ -337,6 +337,59 @@ public boolean isRuleOfThirds() { 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}. + * + *

+ * 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); + } + + /** + * 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); + } + + /** + * 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); + } + /** * Returns the cropped image data URI. * 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 index 85f9588..3c92c4f 100644 --- a/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx +++ b/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx @@ -23,6 +23,67 @@ import { JSXElementConstructor, ReactElement, useRef, useEffect } from "react"; 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> | null { @@ -41,9 +102,14 @@ class ImageCropElement extends ReactAdapterElement { const [maxWidth] = hooks.useState("maxWidth"); const [maxHeight] = hooks.useState("maxHeight"); const [ruleOfThirds] = hooks.useState("ruleOfThirds", false); - + // Output format settings; also read via this.outputMimeType / this.outputQuality at crop time. + const [outputMimeType] = hooks.useState("outputMimeType"); + const [outputQuality] = hooks.useState("outputQuality", 1.0); + // 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. @@ -118,6 +184,21 @@ class ImageCropElement extends ReactAdapterElement { 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); }; @@ -233,8 +314,9 @@ class ImageCropElement extends ReactAdapterElement { 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); // dispatch the event containing cropped image this.fireCroppedImageEvent(croppedImageDataUri); 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 64e905a..4acd8f8 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java @@ -36,6 +36,7 @@ public class ImageCropDemoView extends TabbedDemo { public ImageCropDemoView() { addDemo(BasicImageCropDemo.class); addDemo(UploadImageCropDemo.class); + addDemo(OutputFormatImageCropDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/OutputFormatImageCropDemo.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/OutputFormatImageCropDemo.java new file mode 100644 index 0000000..3bdc6ae --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/OutputFormatImageCropDemo.java @@ -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 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"; + } + +} 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 6b351d2..7c3314d 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,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; @@ -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";