135 lines
4.5 KiB
JavaScript
135 lines
4.5 KiB
JavaScript
import React, { useState, useCallback, useEffect } from 'react';
|
|
import Cropper from 'react-easy-crop';
|
|
|
|
function getCroppedImg(imageSrc, pixelCrop) {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.crossOrigin = 'anonymous';
|
|
image.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 256;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(
|
|
image,
|
|
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
|
|
0, 0, 256, 256
|
|
);
|
|
canvas.toBlob((blob) => {
|
|
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
|
resolve(blob);
|
|
}, 'image/png');
|
|
};
|
|
image.onerror = reject;
|
|
image.src = imageSrc;
|
|
});
|
|
}
|
|
|
|
const AvatarCropModal = ({ imageUrl, onApply, onCancel, cropShape = 'round' }) => {
|
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|
const [zoom, setZoom] = useState(1);
|
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
|
|
|
|
const onCropComplete = useCallback((_croppedArea, croppedPixels) => {
|
|
setCroppedAreaPixels(croppedPixels);
|
|
}, []);
|
|
|
|
const handleApply = useCallback(async () => {
|
|
if (!croppedAreaPixels) return;
|
|
const blob = await getCroppedImg(imageUrl, croppedAreaPixels);
|
|
onApply(blob);
|
|
}, [imageUrl, croppedAreaPixels, onApply]);
|
|
|
|
useEffect(() => {
|
|
const handleKey = (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onCancel();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKey, true);
|
|
return () => window.removeEventListener('keydown', handleKey, true);
|
|
}, [onCancel]);
|
|
|
|
return (
|
|
<div className="avatar-crop-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
<div className="avatar-crop-dialog">
|
|
{/* Header */}
|
|
<div className="avatar-crop-header">
|
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'var(--header-primary)' }}>
|
|
Edit Image
|
|
</h2>
|
|
<button
|
|
onClick={onCancel}
|
|
style={{
|
|
background: 'none', border: 'none', color: 'var(--header-secondary)',
|
|
fontSize: '24px', cursor: 'pointer', padding: '4px', lineHeight: 1,
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* Crop area */}
|
|
<div className="avatar-crop-area">
|
|
<Cropper
|
|
image={imageUrl}
|
|
crop={crop}
|
|
zoom={zoom}
|
|
aspect={1}
|
|
cropShape={cropShape}
|
|
showGrid={false}
|
|
onCropChange={setCrop}
|
|
onZoomChange={setZoom}
|
|
onCropComplete={onCropComplete}
|
|
/>
|
|
</div>
|
|
|
|
{/* Zoom slider */}
|
|
<div className="avatar-crop-slider-row">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
|
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
|
</svg>
|
|
<input
|
|
type="range"
|
|
min={1}
|
|
max={3}
|
|
step={0.01}
|
|
value={zoom}
|
|
onChange={(e) => setZoom(Number(e.target.value))}
|
|
className="avatar-crop-slider"
|
|
/>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
|
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="avatar-crop-actions">
|
|
<button
|
|
onClick={onCancel}
|
|
style={{
|
|
background: 'none', border: 'none', color: 'var(--header-primary)',
|
|
cursor: 'pointer', fontSize: '14px', fontWeight: 500, padding: '8px 16px',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleApply}
|
|
style={{
|
|
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
|
borderRadius: '4px', padding: '8px 24px', cursor: 'pointer',
|
|
fontSize: '14px', fontWeight: 500,
|
|
}}
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AvatarCropModal;
|