Flatten transparent product images to white and allow cropper unzoom.

PNG/WebP uploads no longer bake to black on JPEG; product croppers can zoom out with white padding, and SEO tools move into the product overflow menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-19 08:39:32 +03:30
co-authored by Cursor
parent b743c09e4d
commit 06e2442aaf
17 changed files with 470 additions and 102 deletions
+25 -4
View File
@@ -10,6 +10,11 @@ interface ImageCropperProps {
onChange: (value: string | null) => void
aspect?: number
outputFormat?: 'jpeg' | 'png'
/** Fill color for JPEG and for crop areas outside the image. */
fillColor?: string
/** Minimum zoom. Values below 1 shrink the image inside the crop frame. */
minZoom?: number
maxZoom?: number
accept?: string
uploadLabel?: string
hint?: string
@@ -21,6 +26,9 @@ export function ImageCropper({
onChange,
aspect = 1,
outputFormat = 'jpeg',
fillColor,
minZoom = 1,
maxZoom = 3,
accept = 'image/*',
uploadLabel,
hint,
@@ -30,6 +38,8 @@ export function ImageCropper({
const resolvedUploadLabel = uploadLabel ?? t('myProducts.images.thumbnailUpload')
const resolvedHint = hint ?? t('myProducts.images.thumbnailHint')
const resolvedChangeLabel = changeLabel ?? t('myProducts.images.thumbnailChange')
const resolvedFillColor = fillColor ?? (outputFormat === 'jpeg' ? '#ffffff' : undefined)
const cropSurfaceColor = resolvedFillColor ?? '#1e293b'
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
@@ -61,7 +71,12 @@ export function ImageCropper({
async function applyCrop() {
if (!imageSrc || !croppedArea) return
const cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat)
const cropped = await getCroppedImage(
imageSrc,
croppedArea,
outputFormat,
resolvedFillColor,
)
onChange(cropped)
setImageSrc(null)
setZoom(1)
@@ -122,17 +137,23 @@ export function ImageCropper({
<div className={styles.cropPanel}>
<div
className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`}
style={frameStyle}
style={{ ...frameStyle, background: cropSurfaceColor }}
>
<Cropper
key={String(aspect)}
image={imageSrc}
crop={crop}
zoom={zoom}
minZoom={minZoom}
maxZoom={maxZoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
restrictPosition={minZoom >= 1}
style={{
containerStyle: { background: cropSurfaceColor },
}}
/>
</div>
<div className={styles.cropControls}>
@@ -140,8 +161,8 @@ export function ImageCropper({
{t('myProducts.images.zoom')}
<input
type="range"
min={1}
max={3}
min={minZoom}
max={maxZoom}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
@@ -713,6 +713,7 @@ export function AddMyProductPage() {
}
}}
aspect={1}
minZoom={0.4}
hint={t('myProducts.images.thumbnailHint')}
/>
</div>
+44 -15
View File
@@ -9,34 +9,63 @@ function createImage(url: string): Promise<HTMLImageElement> {
})
}
/**
* Crop `imageSrc` to `pixelCrop`. Areas outside the source image use `fillColor`
* when provided. JPEG always defaults to white so transparent PNG/WebP uploads
* do not become black.
*/
export async function getCroppedImage(
imageSrc: string,
pixelCrop: Area,
format: 'jpeg' | 'png' = 'jpeg',
fillColor?: string,
): Promise<string> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const outW = Math.max(1, Math.round(pixelCrop.width))
const outH = Math.max(1, Math.round(pixelCrop.height))
canvas.width = outW
canvas.height = outH
if (format === 'png') {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const background = format === 'jpeg' ? (fillColor ?? '#ffffff') : fillColor
if (background) {
ctx.fillStyle = background
ctx.fillRect(0, 0, outW, outH)
} else {
ctx.clearRect(0, 0, outW, outH)
}
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
)
const srcX = pixelCrop.x
const srcY = pixelCrop.y
const srcW = pixelCrop.width
const srcH = pixelCrop.height
const intersectLeft = Math.max(0, srcX)
const intersectTop = Math.max(0, srcY)
const intersectRight = Math.min(image.naturalWidth, srcX + srcW)
const intersectBottom = Math.min(image.naturalHeight, srcY + srcH)
const intersectW = intersectRight - intersectLeft
const intersectH = intersectBottom - intersectTop
if (intersectW > 0 && intersectH > 0) {
const destX = intersectLeft - srcX
const destY = intersectTop - srcY
ctx.drawImage(
image,
intersectLeft,
intersectTop,
intersectW,
intersectH,
destX,
destY,
intersectW,
intersectH,
)
}
if (format === 'png') {
return canvas.toDataURL('image/png')
+16 -4
View File
@@ -37,15 +37,25 @@ export function dataUrlToFile(dataUrl: string, filename: string): File {
return new File([copy], filename, { type: mime })
}
/**
* Prepare a data URL (or image URL) for upload. When the target is JPEG,
* transparent PNG/WebP pixels are flattened onto white — never black.
*/
export async function ensureUploadFile(dataUrl: string, filename: string): Promise<File> {
const wantsPng = filename.toLowerCase().endsWith('.png')
if (dataUrl.startsWith('data:')) {
const mime = dataUrl.slice(5, dataUrl.indexOf(';'))
const normalized = mime === 'image/jpg' ? 'image/jpeg' : mime
if (ALLOWED_IMAGE_TYPES.has(normalized)) {
const file = dataUrlToFile(dataUrl, filename)
if (file.size > 0) {
return file
const alreadyPng = wantsPng && normalized === 'image/png'
const alreadyJpeg = !wantsPng && normalized === 'image/jpeg'
if (alreadyPng || alreadyJpeg) {
const file = dataUrlToFile(dataUrl, filename)
if (file.size > 0) {
return file
}
}
}
}
@@ -57,12 +67,14 @@ export async function ensureUploadFile(dataUrl: string, filename: string): Promi
throw new Error('Could not prepare image for upload')
}
const wantsPng = filename.toLowerCase().endsWith('.png')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
if (wantsPng) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
} else {
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(image, 0, 0)