Integrar recorte y optimización a WebP en las subidas de imagen del panel
Los tres uploaders del panel (logo, "quiénes somos" y galería) ahora abren un recorte interactivo (zoom + encuadre) y reescalan en el navegador antes de subir: logo y "quiénes somos" a máx. 500 px, galería a máx. 1200 px, reencodando a WebP (calidad 0.82). El SVG del logo se sube tal cual para no rasterizar el vector. Esto reduce el peso de los data URIs base64 que se guardan en Postgres y se inlinean en el funnel. Nueva dependencia react-easy-crop: librería de recorte ligera, sin estado global, compatible con React 19; el reescalado y reencodado se hacen con canvas nativo (lib/image/crop.ts), sin dependencias extra.
This commit is contained in:
21
mvp/b2c/package-lock.json
generated
21
mvp/b2c/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-easy-crop": "^5.5.7",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -7209,6 +7210,12 @@
|
|||||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-wheel": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -7635,6 +7642,20 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-easy-crop": {
|
||||||
|
"version": "5.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
|
||||||
|
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"normalize-wheel": "^1.0.1",
|
||||||
|
"tslib": "^2.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.4.0",
|
||||||
|
"react-dom": ">=16.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-easy-crop": "^5.5.7",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
quitarAboutFoto,
|
quitarAboutFoto,
|
||||||
type LogoResult,
|
type LogoResult,
|
||||||
} from '@/app/panel/empresa/actions';
|
} from '@/app/panel/empresa/actions';
|
||||||
|
import ImageCropperField from '@/components/panel/ImageCropperField';
|
||||||
|
|
||||||
export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null }) {
|
export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null }) {
|
||||||
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(
|
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(
|
||||||
@@ -34,16 +35,18 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<ImageCropperField
|
||||||
type="file"
|
|
||||||
name="aboutFoto"
|
name="aboutFoto"
|
||||||
|
maxDimension={500}
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
|
buttonLabel="Elegir foto"
|
||||||
|
previewClassName="h-24 w-24 rounded-lg"
|
||||||
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
className="rounded-lg bg-primary-700 px-4 py-1.5 text-sm font-medium text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{pending ? 'Subiendo…' : 'Subir foto'}
|
{pending ? 'Subiendo…' : 'Subir foto'}
|
||||||
</button>
|
</button>
|
||||||
@@ -51,7 +54,9 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null
|
|||||||
|
|
||||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||||
{state?.ok && <p className="text-sm text-green-600">Foto actualizada ✓</p>}
|
{state?.ok && <p className="text-sm text-green-600">Foto actualizada ✓</p>}
|
||||||
<p className="text-xs text-gray-400">PNG, JPG o WEBP · máx. 1,5 MB.</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 500 px).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useActionState, useEffect, useRef } from 'react';
|
import { useActionState, useEffect, useRef } from 'react';
|
||||||
import { subirFotoGaleria, type GaleriaResult } from '@/app/panel/galeria/actions';
|
import { subirFotoGaleria, type GaleriaResult } from '@/app/panel/galeria/actions';
|
||||||
|
import ImageCropperField from '@/components/panel/ImageCropperField';
|
||||||
|
|
||||||
export default function GaleriaUploader({
|
export default function GaleriaUploader({
|
||||||
total,
|
total,
|
||||||
@@ -32,12 +33,14 @@ export default function GaleriaUploader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form ref={formRef} action={formAction} className="flex flex-col gap-3">
|
<form ref={formRef} action={formAction} className="flex flex-col gap-3">
|
||||||
<input
|
<ImageCropperField
|
||||||
type="file"
|
key={total}
|
||||||
name="foto"
|
name="foto"
|
||||||
|
maxDimension={1200}
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
buttonLabel="Elegir foto"
|
||||||
|
previewClassName="h-24 w-32 rounded-lg"
|
||||||
disabled={lleno || pending}
|
disabled={lleno || pending}
|
||||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-primary-700 file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium disabled:opacity-50"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -63,7 +66,9 @@ export default function GaleriaUploader({
|
|||||||
)}
|
)}
|
||||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||||
{state?.ok && <p className="text-sm text-green-600">Foto añadida ✓</p>}
|
{state?.ok && <p className="text-sm text-green-600">Foto añadida ✓</p>}
|
||||||
<p className="text-xs text-gray-400">PNG, JPG o WEBP · máx. 2 MB.</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 1200 px).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
211
mvp/b2c/src/components/panel/ImageCropperField.tsx
Normal file
211
mvp/b2c/src/components/panel/ImageCropperField.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import Cropper from 'react-easy-crop';
|
||||||
|
import { getCroppedWebp, type CropArea } from '@/lib/image/crop';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
maxDimension: number;
|
||||||
|
accept: string;
|
||||||
|
buttonLabel?: string;
|
||||||
|
previewClassName?: string;
|
||||||
|
previewImgClassName?: string;
|
||||||
|
allowSvgPassthrough?: boolean;
|
||||||
|
quality?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Campo de imagen con recorte (zoom + pan) y reescalado a WebP en el navegador.
|
||||||
|
// El archivo optimizado se inyecta en un <input type="file"> oculto con `name`,
|
||||||
|
// de modo que el formulario padre lo envía como si fuera la selección original.
|
||||||
|
export default function ImageCropperField({
|
||||||
|
name,
|
||||||
|
maxDimension,
|
||||||
|
accept,
|
||||||
|
buttonLabel = 'Elegir imagen',
|
||||||
|
previewClassName = 'h-24 w-24 rounded-lg',
|
||||||
|
previewImgClassName = 'h-full w-full object-cover',
|
||||||
|
allowSvgPassthrough = false,
|
||||||
|
quality = 0.82,
|
||||||
|
disabled = false,
|
||||||
|
}: Props) {
|
||||||
|
const pickerRef = useRef<HTMLInputElement>(null);
|
||||||
|
const outputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const objectUrls = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const [modalSrc, setModalSrc] = useState<string | null>(null);
|
||||||
|
const [aspect, setAspect] = useState(1);
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [area, setArea] = useState<CropArea | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const track = useCallback((url: string) => {
|
||||||
|
objectUrls.current.add(url);
|
||||||
|
return url;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const revoke = useCallback((url: string | null) => {
|
||||||
|
if (url && objectUrls.current.has(url)) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
objectUrls.current.delete(url);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urls = objectUrls.current;
|
||||||
|
return () => {
|
||||||
|
for (const url of urls) URL.revokeObjectURL(url);
|
||||||
|
urls.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function setOutputFile(file: File) {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
if (outputRef.current) outputRef.current.files = dt.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setError(null);
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ''; // permite re-elegir el mismo archivo
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (allowSvgPassthrough && file.type === 'image/svg+xml') {
|
||||||
|
setOutputFile(file);
|
||||||
|
revoke(previewUrl);
|
||||||
|
setPreviewUrl(track(URL.createObjectURL(file)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = track(URL.createObjectURL(file));
|
||||||
|
try {
|
||||||
|
const dims = await loadDims(src);
|
||||||
|
setAspect(dims.width / dims.height || 1);
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
setZoom(1);
|
||||||
|
setArea(null);
|
||||||
|
setModalSrc(src);
|
||||||
|
} catch {
|
||||||
|
revoke(src);
|
||||||
|
setError('No se pudo abrir la imagen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cerrarModal() {
|
||||||
|
revoke(modalSrc);
|
||||||
|
setModalSrc(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aplicar() {
|
||||||
|
if (!modalSrc || !area) return;
|
||||||
|
setWorking(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const blob = await getCroppedWebp(modalSrc, area, maxDimension, quality);
|
||||||
|
setOutputFile(new File([blob], `${name}.webp`, { type: 'image/webp' }));
|
||||||
|
revoke(previewUrl);
|
||||||
|
setPreviewUrl(track(URL.createObjectURL(blob)));
|
||||||
|
cerrarModal();
|
||||||
|
} catch {
|
||||||
|
setError('No se pudo procesar la imagen. Prueba con otra.');
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input ref={outputRef} type="file" name={name} accept="image/webp,image/svg+xml" className="hidden" />
|
||||||
|
<input
|
||||||
|
ref={pickerRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={onPick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{previewUrl && (
|
||||||
|
<span className={`overflow-hidden border border-gray-200 bg-gray-50 ${previewClassName}`}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={previewUrl} alt="Previsualización" className={previewImgClassName} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickerRef.current?.click()}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-1.5 text-sm font-medium text-gray-700 transition hover:border-primary-700 hover:text-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{previewUrl ? 'Cambiar imagen' : buttonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
{modalSrc && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl bg-white p-5 shadow-xl">
|
||||||
|
<h3 className="font-display text-2xl tracking-tight text-black">Ajusta el encuadre</h3>
|
||||||
|
<div className="relative h-[55vh] w-full overflow-hidden rounded-xl bg-gray-900">
|
||||||
|
<Cropper
|
||||||
|
image={modalSrc}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={aspect}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={(_area, areaPixels) => setArea(areaPixels)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-3 text-sm text-gray-600">
|
||||||
|
Zoom
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-[#2f5c46]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cerrarModal}
|
||||||
|
disabled={working}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={aplicar}
|
||||||
|
disabled={working || !area}
|
||||||
|
className="rounded-lg bg-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{working ? 'Procesando…' : 'Aplicar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDims(src: string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
img.onerror = () => reject(new Error('No se pudo cargar la imagen.'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions';
|
import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions';
|
||||||
|
import ImageCropperField from '@/components/panel/ImageCropperField';
|
||||||
|
|
||||||
export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||||
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(subirLogo, null);
|
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(subirLogo, null);
|
||||||
@@ -27,16 +28,20 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<ImageCropperField
|
||||||
type="file"
|
|
||||||
name="logo"
|
name="logo"
|
||||||
|
maxDimension={500}
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
|
buttonLabel="Elegir logo"
|
||||||
|
previewClassName="h-20 w-32 rounded-lg"
|
||||||
|
previewImgClassName="h-full w-full object-contain"
|
||||||
|
allowSvgPassthrough
|
||||||
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
className="rounded-lg bg-primary-700 px-4 py-1.5 text-sm font-medium text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{pending ? 'Subiendo…' : 'Subir logo'}
|
{pending ? 'Subiendo…' : 'Subir logo'}
|
||||||
</button>
|
</button>
|
||||||
@@ -44,7 +49,9 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
|||||||
|
|
||||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||||
{state?.ok && <p className="text-sm text-green-600">Logo actualizado ✓</p>}
|
{state?.ok && <p className="text-sm text-green-600">Logo actualizado ✓</p>}
|
||||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP o SVG · máx. 500 KB.</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
PNG, JPG, WEBP o SVG. Se recorta y optimiza a WebP (máx. 500 px). El SVG se sube tal cual.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
mvp/b2c/src/lib/image/crop.ts
Normal file
38
mvp/b2c/src/lib/image/crop.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type CropArea = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
// Recorta la región indicada (en píxeles del original) y reescala el lado más
|
||||||
|
// largo a `maxDimension`, devolviendo un WebP ya optimizado listo para subir.
|
||||||
|
export async function getCroppedWebp(
|
||||||
|
src: string,
|
||||||
|
area: CropArea,
|
||||||
|
maxDimension: number,
|
||||||
|
quality = 0.82,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const img = await loadImage(src);
|
||||||
|
const scale = Math.min(1, maxDimension / Math.max(area.width, area.height));
|
||||||
|
const outW = Math.max(1, Math.round(area.width * scale));
|
||||||
|
const outH = Math.max(1, Math.round(area.height * scale));
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = outW;
|
||||||
|
canvas.height = outH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('No se pudo crear el lienzo.');
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, outW, outH);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, 'image/webp', quality),
|
||||||
|
);
|
||||||
|
if (!blob) throw new Error('No se pudo procesar la imagen.');
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('No se pudo cargar la imagen.'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user