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
+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)