Files
DiscordClone/packages/shared/src/components/AvatarCropModal.jsx
Bryan1029384756 fe869a3222
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
feat: Add new emoji assets and an UpdateBanner component.
2026-02-13 12:20:40 -06:00

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;