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:
co-authored by
Cursor
parent
b743c09e4d
commit
06e2442aaf
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user