Skip to content
Merged
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
84 changes: 81 additions & 3 deletions src/components/sidepanel/ImageCropDialog.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Comment thread
HugoGresse marked this conversation as resolved.

const imageRef = useRef<HTMLImageElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -399,7 +432,9 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC

return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth sx={{ zIndex: 1500 }}>
<DialogTitle>Crop Image {imageType !== 'unknown' ? `(${imageType.toUpperCase()})` : ''}</DialogTitle>
<DialogTitle>
Crop & resize image {imageType !== 'unknown' ? `(${imageType.toUpperCase()})` : ''}
</DialogTitle>
<DialogContent>
<Box
ref={containerRef}
Expand Down Expand Up @@ -523,11 +558,54 @@ export const ImageCropDialog = ({ open, onClose, imageSrc, onApplyCrop }: ImageC
{/* Hidden canvas for cropping */}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</Box>

{imageLoaded && (
<Box marginTop={3}>
<Typography fontWeight="bold">Image size</Typography>
{canResize ? (
<>
<Typography variant="body2" color="text.secondary">
Slide left to make the saved image smaller. Smaller images load faster on your
website; 100% keeps the original quality.
</Typography>
<Box paddingX={2}>
<Slider
value={sizePercent}
min={10}
max={100}
step={5}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
]}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
onChange={(_, value) => setSizePercent(value as number)}
/>
</Box>
<Typography variant="body2">
The image will be saved at{' '}
<strong>
{outputWidth} × {outputHeight} px
</strong>
{sizePercent < 100 ? ` — ${sizePercent}% of the selected area` : ' (original size)'}
</Typography>
</>
) : (
<Typography variant="body2" color="text.secondary">
This image is an SVG (vector) file: it scales without any quality loss, so there is no
size to reduce.
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleApplyCrop} disabled={isProcessing || !imageLoaded}>
{isProcessing ? 'Processing...' : 'Apply Crop'}
{isProcessing ? 'Processing...' : 'Apply'}
</Button>
</DialogActions>
</Dialog>
Expand Down
27 changes: 27 additions & 0 deletions src/utils/images/imageCrop/downscaleImage.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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
})
}