From 4ab60b5680387699bac82e0c2ceba0bf9016f5bb Mon Sep 17 00:00:00 2001 From: Hugo Gresse Date: Wed, 19 Aug 2026 09:45:34 +0200 Subject: [PATCH 1/2] Add an image size slider to the crop dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crop dialog could only cut images, not shrink them — reducing a photo's weight required an external tool. The dialog now has an "Image size" section written for non-technical organizers: a 10-100% slider with a plain explanation ("smaller images load faster on your website") and a live "will be saved at W × H px" readout. The scaling is applied to the cropped output through a canvas downscale (high quality interpolation, JPEG at 0.9), so picking a smaller size works with or without moving the crop selection. SVG images show a note instead: vectors scale without quality loss, there is nothing to reduce. Co-Authored-By: Claude Fable 5 --- src/components/sidepanel/ImageCropDialog.tsx | 74 +++++++++++++++++++- src/utils/images/imageCrop/downscaleImage.ts | 27 +++++++ 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/utils/images/imageCrop/downscaleImage.ts diff --git a/src/components/sidepanel/ImageCropDialog.tsx b/src/components/sidepanel/ImageCropDialog.tsx index cb8012ce..7311fae1 100644 --- a/src/components/sidepanel/ImageCropDialog.tsx +++ b/src/components/sidepanel/ImageCropDialog.tsx @@ -1,9 +1,10 @@ -import { Box, Button, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material' +import { Box, Button, Dialog, DialogTitle, DialogContent, DialogActions, Slider, Typography } from '@mui/material' import React, { useState, useRef, useEffect } from 'react' import { cropJpegImage } from '../../utils/images/imageCrop/cropJpegImage' import { cropPngImage } from '../../utils/images/imageCrop/cropPngImage' import { cropSvgImage } from '../../utils/images/imageCrop/cropSvgImage' import { detectImageType } from '../../utils/images/imageCrop/detectImageType' +import { downscaleImage } from '../../utils/images/imageCrop/downscaleImage' import { isImageCrossOrigin } from '../../utils/images/loadImageWithCORS' // Define a more complete file type to return @@ -33,6 +34,8 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC const [imageName, setImageName] = useState('image') // Default name const [naturalAspectRatio, setNaturalAspectRatio] = useState(0) // Store image's natural aspect ratio const [isCrossOrigin, setIsCrossOrigin] = useState(false) + const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }) + const [sizePercent, setSizePercent] = useState(100) const imageRef = useRef(null) const canvasRef = useRef(null) @@ -123,6 +126,7 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC const aspectRatio = naturalWidth / naturalHeight setNaturalAspectRatio(aspectRatio) + setNaturalSize({ width: naturalWidth, height: naturalHeight }) setImageSize({ width: imageRef.current.clientWidth || imageRef.current.width, height: imageRef.current.clientHeight || imageRef.current.height, @@ -131,6 +135,15 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC } } + // Dimensions of the crop selection in real (natural) pixels, before/after the size slider + const selectionNaturalWidth = imageSize.width ? Math.round(crop.width * (naturalSize.width / imageSize.width)) : 0 + const selectionNaturalHeight = imageSize.height + ? Math.round(crop.height * (naturalSize.height / imageSize.height)) + : 0 + const outputWidth = Math.max(1, Math.round((selectionNaturalWidth * sizePercent) / 100)) + const outputHeight = Math.max(1, Math.round((selectionNaturalHeight * sizePercent) / 100)) + const canResize = imageType !== 'svg' && imageType !== 'unknown' + // Mouse events for crop area manipulation const handleMouseDown = (e: React.MouseEvent) => { if (!containerRef.current) return @@ -262,6 +275,16 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC throw new Error('Failed to crop image. This may be due to cross-origin restrictions.') } + // Shrink the result when the user picked a smaller size (raster images only) + if (canResize && sizePercent < 100) { + croppedImageData = await downscaleImage( + croppedImageData, + (scaledCrop.width * sizePercent) / 100, + (scaledCrop.height * sizePercent) / 100, + mimeType + ) + } + if (onApplyCrop && croppedImageData) { // Create a proper filename based on original name and type const extension = mimeType.split('/')[1] @@ -399,7 +422,9 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC return ( - Crop Image {imageType !== 'unknown' ? `(${imageType.toUpperCase()})` : ''} + + Crop & resize image {imageType !== 'unknown' ? `(${imageType.toUpperCase()})` : ''} + + + {imageLoaded && ( + + Image size + {canResize ? ( + <> + + Slide left to make the saved image smaller. Smaller images load faster on your + website; 100% keeps the original quality. + + + `${value}%`} + onChange={(_, value) => setSizePercent(value as number)} + /> + + + The image will be saved at{' '} + + {outputWidth} × {outputHeight} px + + {sizePercent < 100 ? ` — ${sizePercent}% of the selected area` : ' (original size)'} + + + ) : ( + + This image is an SVG (vector) file: it scales without any quality loss, so there is no + size to reduce. + + )} + + )} diff --git a/src/utils/images/imageCrop/downscaleImage.ts b/src/utils/images/imageCrop/downscaleImage.ts new file mode 100644 index 00000000..dcf8d800 --- /dev/null +++ b/src/utils/images/imageCrop/downscaleImage.ts @@ -0,0 +1,27 @@ +// Downscale a raster image data URL to the given dimensions using a canvas. +// Used by the crop dialog's "image size" slider; not meaningful for SVG (vector). +export const downscaleImage = ( + dataUrl: string, + targetWidth: number, + targetHeight: number, + mimeType: string +): Promise => { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(targetWidth)) + canvas.height = Math.max(1, Math.round(targetHeight)) + const context = canvas.getContext('2d') + if (!context) { + reject(new Error('Canvas 2D context unavailable')) + return + } + context.imageSmoothingQuality = 'high' + context.drawImage(image, 0, 0, canvas.width, canvas.height) + resolve(canvas.toDataURL(mimeType, mimeType === 'image/jpeg' ? 0.9 : undefined)) + } + image.onerror = () => reject(new Error('Failed to load image for resizing')) + image.src = dataUrl + }) +} From a503abcba5bf675e4a92c9ddb0586f169cff8624 Mon Sep 17 00:00:00 2001 From: Hugo Gresse Date: Wed, 19 Aug 2026 09:56:48 +0200 Subject: [PATCH 2/2] Fix size slider on blob previews and reset it between opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings from Bugbot: - Dropzone and clipboard previews are blob: URLs, which detectImageType classifies as 'unknown' — the slider was hidden and the SVG note shown on the main upload flow. 'unknown' is now resizable: the crop path already rasterizes that case as PNG, so the downscale applies identically. - The dialog stays mounted between opens, so a previous reduction stayed selected and would silently re-shrink an already-reduced image. The percentage now resets to 100 whenever the dialog opens or the source image changes. Co-Authored-By: Claude Fable 5 --- src/components/sidepanel/ImageCropDialog.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/sidepanel/ImageCropDialog.tsx b/src/components/sidepanel/ImageCropDialog.tsx index 7311fae1..3770dac2 100644 --- a/src/components/sidepanel/ImageCropDialog.tsx +++ b/src/components/sidepanel/ImageCropDialog.tsx @@ -41,6 +41,14 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC const canvasRef = useRef(null) const containerRef = useRef(null) + // The dialog stays mounted between opens: reset the size choice so a previous + // reduction is not silently re-applied to an already-shrunk image + useEffect(() => { + if (open) { + setSizePercent(100) + } + }, [open, imageSrc]) + // Detect image type and name when imageSrc changes useEffect(() => { detectImageType(imageSrc).then((type) => { @@ -142,7 +150,9 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC : 0 const outputWidth = Math.max(1, Math.round((selectionNaturalWidth * sizePercent) / 100)) const outputHeight = Math.max(1, Math.round((selectionNaturalHeight * sizePercent) / 100)) - const canResize = imageType !== 'svg' && imageType !== 'unknown' + // 'unknown' covers blob: previews from the dropzone/clipboard; the crop path + // rasterizes those as PNG, so they can be resized like any raster image + const canResize = imageType !== 'svg' // Mouse events for crop area manipulation const handleMouseDown = (e: React.MouseEvent) => {