diff --git a/src/components/sidepanel/ImageCropDialog.tsx b/src/components/sidepanel/ImageCropDialog.tsx index cb8012ce..3770dac2 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,11 +34,21 @@ 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) 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) => { @@ -123,6 +134,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 +143,17 @@ 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)) + // '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) => { if (!containerRef.current) return @@ -262,6 +285,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 +432,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 + }) +}