| 1 | /** |
| 2 | * AvatarCropModal - 头像裁剪弹窗组件 |
| 3 | * 基于 react-easy-crop 实现圆形头像裁剪功能 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import type { Area } from 'react-easy-crop' |
| 9 | import { Loader2, RotateCcw, RotateCw, ZoomIn, ZoomOut } from 'lucide-react' |
| 10 | import { useCallback, useEffect, useState } from 'react' |
| 11 | import Cropper from 'react-easy-crop' |
| 12 | import { useTransClient } from '@/app/i18n/client' |
| 13 | import { Button } from '@/components/ui/button' |
| 14 | import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' |
| 15 | import { cn } from '@/utils/className' |
| 16 | import styles from './avatarCropModal.module.scss' |
| 17 | |
| 18 | export interface AvatarCropModalProps { |
| 19 | /** 是否显示弹窗 */ |
| 20 | open: boolean |
| 21 | /** 关闭弹窗回调 */ |
| 22 | onClose: () => void |
| 23 | /** 图片文件 */ |
| 24 | imageFile: File | null |
| 25 | /** 裁剪完成回调,返回裁剪后的 Blob */ |
| 26 | onCropComplete: (blob: Blob) => void |
| 27 | /** 是否正在上传 */ |
| 28 | isUploading?: boolean |
| 29 | /** 弹窗标题 */ |
| 30 | title?: string |
| 31 | /** 裁剪比例 */ |
| 32 | aspect?: number |
| 33 | /** 裁剪形状 */ |
| 34 | cropShape?: 'rect' | 'round' |
| 35 | /** 是否显示裁剪网格 */ |
| 36 | showGrid?: boolean |
| 37 | /** 输出尺寸,传 null 时使用实际裁剪尺寸 */ |
| 38 | outputSize?: { width: number, height: number } | null |
| 39 | /** 图片加载中文案 */ |
| 40 | imageLoadingText?: string |
| 41 | /** 取消按钮文案 */ |
| 42 | cancelText?: string |
| 43 | /** 确认按钮文案 */ |
| 44 | confirmText?: string |
| 45 | /** 处理中按钮文案 */ |
| 46 | processingText?: string |
| 47 | /** 上传中按钮文案 */ |
| 48 | uploadingText?: string |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * 根据裁剪区域从图片中提取裁剪后的图像(支持旋转) |
| 53 | */ |
| 54 | async function getCroppedImg( |
| 55 | imageSrc: string, |
| 56 | pixelCrop: Area, |
| 57 | rotation: number = 0, |
| 58 | outputSize?: { width: number, height: number } | null, |
| 59 | ): Promise<Blob> { |
| 60 | const image = await createImage(imageSrc) |
| 61 | const canvas = document.createElement('canvas') |
| 62 | const ctx = canvas.getContext('2d') |
| 63 | |
| 64 | if (!ctx) { |
| 65 | throw new Error('No 2d context') |
| 66 | } |
| 67 | |
| 68 | // 计算旋转后的边界框 |
| 69 | const rotRad = (rotation * Math.PI) / 180 |
| 70 | const { width: bBoxWidth, height: bBoxHeight } = getRotatedBoundingBox( |
| 71 | image.width, |
| 72 | image.height, |
| 73 | rotation, |
| 74 | ) |
| 75 | |
| 76 | // 设置 canvas 大小以容纳旋转后的图像 |
| 77 | canvas.width = bBoxWidth |
| 78 | canvas.height = bBoxHeight |
| 79 | |
| 80 | // 将旋转中心移动到 canvas 中心 |
| 81 | ctx.translate(bBoxWidth / 2, bBoxHeight / 2) |
| 82 | ctx.rotate(rotRad) |
| 83 | ctx.translate(-image.width / 2, -image.height / 2) |
| 84 | |
| 85 | // 绘制旋转后的图像 |
| 86 | ctx.drawImage(image, 0, 0) |
| 87 | |
| 88 | // 从旋转后的 canvas 中提取裁剪区域 |
| 89 | const croppedCanvas = document.createElement('canvas') |
| 90 | const croppedCtx = croppedCanvas.getContext('2d') |
| 91 | |
| 92 | if (!croppedCtx) { |
| 93 | throw new Error('No 2d context') |
| 94 | } |
| 95 | |
| 96 | const targetWidth = outputSize === null ? Math.round(pixelCrop.width) : outputSize?.width ?? 400 |
| 97 | const targetHeight = outputSize === null ? Math.round(pixelCrop.height) : outputSize?.height ?? 400 |
| 98 | croppedCanvas.width = targetWidth |
| 99 | croppedCanvas.height = targetHeight |
| 100 | |
| 101 | // 绘制裁剪后的图像 |
| 102 | croppedCtx.drawImage( |
| 103 | canvas, |
| 104 | pixelCrop.x, |
| 105 | pixelCrop.y, |
| 106 | pixelCrop.width, |
| 107 | pixelCrop.height, |
| 108 | 0, |
| 109 | 0, |
| 110 | targetWidth, |
| 111 | targetHeight, |
| 112 | ) |
| 113 | |
| 114 | return new Promise((resolve, reject) => { |
| 115 | croppedCanvas.toBlob( |
| 116 | (blob) => { |
| 117 | if (blob) { |
| 118 | resolve(blob) |
| 119 | } |
| 120 | else { |
| 121 | reject(new Error('Canvas is empty')) |
| 122 | } |
| 123 | }, |
| 124 | 'image/png', |
| 125 | 1, |
| 126 | ) |
| 127 | }) |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * 计算旋转后的边界框尺寸 |
| 132 | */ |
| 133 | function getRotatedBoundingBox( |
| 134 | width: number, |
| 135 | height: number, |
| 136 | rotation: number, |
| 137 | ): { width: number, height: number } { |
| 138 | const rotRad = (rotation * Math.PI) / 180 |
| 139 | return { |
| 140 | width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), |
| 141 | height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * 创建 Image 对象 |
| 147 | */ |
| 148 | function createImage(url: string): Promise<HTMLImageElement> { |
| 149 | return new Promise((resolve, reject) => { |
| 150 | const image = new Image() |
| 151 | image.addEventListener('load', () => resolve(image)) |
| 152 | image.addEventListener('error', error => reject(error)) |
| 153 | image.crossOrigin = 'anonymous' |
| 154 | image.src = url |
| 155 | }) |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * AvatarCropModal 头像裁剪弹窗组件 |
| 160 | */ |
| 161 | export function AvatarCropModal({ |
| 162 | open, |
| 163 | onClose, |
| 164 | imageFile, |
| 165 | onCropComplete, |
| 166 | isUploading = false, |
| 167 | title, |
| 168 | aspect = 1, |
| 169 | cropShape = 'round', |
| 170 | showGrid = false, |
| 171 | outputSize, |
| 172 | imageLoadingText, |
| 173 | cancelText, |
| 174 | confirmText, |
| 175 | processingText, |
| 176 | uploadingText, |
| 177 | }: AvatarCropModalProps) { |
| 178 | const { t } = useTransClient('settings') |
| 179 | const dialogTitle = title || t('profile.cropAvatar') |
| 180 | |
| 181 | // 图片 URL |
| 182 | const [imageUrl, setImageUrl] = useState<string>('') |
| 183 | // 图片加载状态 |
| 184 | const [isImageLoading, setIsImageLoading] = useState(false) |
| 185 | // 裁剪区域状态 |
| 186 | const [crop, setCrop] = useState({ x: 0, y: 0 }) |
| 187 | const [zoom, setZoom] = useState(1) |
| 188 | const [rotation, setRotation] = useState(0) |
| 189 | const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null) |
| 190 | // 确认处理状态 |
| 191 | const [isProcessing, setIsProcessing] = useState(false) |
| 192 | |
| 193 | // 当图片文件变化时,创建预览 URL |
| 194 | useEffect(() => { |
| 195 | if (imageFile) { |
| 196 | setIsImageLoading(true) |
| 197 | const url = URL.createObjectURL(imageFile) |
| 198 | setImageUrl(url) |
| 199 | // 重置裁剪状态 |
| 200 | setCrop({ x: 0, y: 0 }) |
| 201 | setZoom(1) |
| 202 | setRotation(0) |
| 203 | setCroppedAreaPixels(null) |
| 204 | return () => { |
| 205 | URL.revokeObjectURL(url) |
| 206 | } |
| 207 | } |
| 208 | else { |
| 209 | setImageUrl('') |
| 210 | setIsImageLoading(false) |
| 211 | setCroppedAreaPixels(null) |
| 212 | } |
| 213 | }, [imageFile]) |
| 214 | |
| 215 | // 裁剪完成回调 |
| 216 | const onCropCompleteCallback = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { |
| 217 | setCroppedAreaPixels(croppedAreaPixels) |
| 218 | }, []) |
| 219 | |
| 220 | // 图片加载完成 |
| 221 | const handleMediaLoaded = useCallback(() => { |
| 222 | setIsImageLoading(false) |
| 223 | }, []) |
| 224 | |
| 225 | // 旋转图片 |
| 226 | const handleRotate = (degree: number) => { |
| 227 | setRotation(prev => (prev + degree) % 360) |
| 228 | } |
| 229 | |
| 230 | // 缩放图片 |
| 231 | const handleZoom = (delta: number) => { |
| 232 | setZoom(prev => Math.min(3, Math.max(1, prev + delta))) |
| 233 | } |
| 234 | |
| 235 | // 确认裁剪 |
| 236 | const handleConfirm = async () => { |
| 237 | if (!croppedAreaPixels || !imageUrl) |
| 238 | return |
| 239 | |
| 240 | setIsProcessing(true) |
| 241 | try { |
| 242 | const croppedBlob = await getCroppedImg(imageUrl, croppedAreaPixels, rotation, outputSize) |
| 243 | onCropComplete(croppedBlob) |
| 244 | } |
| 245 | catch (error) { |
| 246 | console.error('裁剪失败:', error) |
| 247 | } |
| 248 | finally { |
| 249 | setIsProcessing(false) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // 是否禁用操作 |
| 254 | const isDisabled = isUploading || isProcessing |
| 255 | |
| 256 | return ( |
| 257 | <Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}> |
| 258 | <DialogContent |
| 259 | className="max-w-[520px] p-0 gap-0 overflow-hidden" |
| 260 | aria-describedby={undefined} |
| 261 | > |
| 262 | {/* 无障碍标题 */} |
| 263 | <DialogTitle className="sr-only">{dialogTitle}</DialogTitle> |
| 264 | |
| 265 | {/* 顶部标题栏 */} |
| 266 | <div className="flex items-center px-6 py-4 border-b border-border"> |
| 267 | <h2 className="text-lg font-medium text-foreground">{dialogTitle}</h2> |
| 268 | </div> |
| 269 | |
| 270 | {/* 裁剪区域 */} |
| 271 | <div className={cn(styles.cropContainer, 'relative bg-foreground')}> |
| 272 | {/* 图片加载中状态 */} |
| 273 | {isImageLoading && ( |
| 274 | <div className="absolute inset-0 flex items-center justify-center bg-foreground z-10"> |
| 275 | <div className="flex flex-col items-center gap-3"> |
| 276 | <Loader2 size={32} className="animate-spin text-white/60" /> |
| 277 | <span className="text-sm text-white/60">{imageLoadingText || t('profile.imageLoading')}</span> |
| 278 | </div> |
| 279 | </div> |
| 280 | )} |
| 281 | |
| 282 | {imageUrl && ( |
| 283 | <Cropper |
| 284 | image={imageUrl} |
| 285 | crop={crop} |
| 286 | zoom={zoom} |
| 287 | rotation={rotation} |
| 288 | aspect={aspect} |
| 289 | cropShape={cropShape} |
| 290 | showGrid={showGrid} |
| 291 | onCropChange={setCrop} |
| 292 | onZoomChange={setZoom} |
| 293 | onCropComplete={onCropCompleteCallback} |
| 294 | onMediaLoaded={handleMediaLoaded} |
| 295 | classes={{ |
| 296 | containerClassName: styles.cropperContainer, |
| 297 | mediaClassName: styles.cropperMedia, |
| 298 | }} |
| 299 | /> |
| 300 | )} |
| 301 | </div> |
| 302 | |
| 303 | {/* 工具栏 */} |
| 304 | <div className="flex items-center justify-center gap-2 py-3 border-t border-border bg-muted"> |
| 305 | <Button |
| 306 | variant="ghost" |
| 307 | size="sm" |
| 308 | onClick={() => handleRotate(-90)} |
| 309 | disabled={isDisabled || isImageLoading} |
| 310 | className="h-9 w-9 p-0" |
| 311 | title={t('profile.rotateLeft')} |
| 312 | > |
| 313 | <RotateCcw size={18} /> |
| 314 | </Button> |
| 315 | <Button |
| 316 | variant="ghost" |
| 317 | size="sm" |
| 318 | onClick={() => handleRotate(90)} |
| 319 | disabled={isDisabled || isImageLoading} |
| 320 | className="h-9 w-9 p-0" |
| 321 | title={t('profile.rotateRight')} |
| 322 | > |
| 323 | <RotateCw size={18} /> |
| 324 | </Button> |
| 325 | <div className="w-px h-5 bg-border mx-2" /> |
| 326 | <Button |
| 327 | variant="ghost" |
| 328 | size="sm" |
| 329 | onClick={() => handleZoom(-0.2)} |
| 330 | disabled={isDisabled || isImageLoading || zoom <= 1} |
| 331 | className="h-9 w-9 p-0" |
| 332 | title={t('profile.zoomOut')} |
| 333 | > |
| 334 | <ZoomOut size={18} /> |
| 335 | </Button> |
| 336 | <Button |
| 337 | variant="ghost" |
| 338 | size="sm" |
| 339 | onClick={() => handleZoom(0.2)} |
| 340 | disabled={isDisabled || isImageLoading || zoom >= 3} |
| 341 | className="h-9 w-9 p-0" |
| 342 | title={t('profile.zoomIn')} |
| 343 | > |
| 344 | <ZoomIn size={18} /> |
| 345 | </Button> |
| 346 | </div> |
| 347 | |
| 348 | {/* 底部按钮 */} |
| 349 | <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border"> |
| 350 | <Button variant="outline" onClick={onClose} disabled={isDisabled}> |
| 351 | {cancelText || t('profile.cancel')} |
| 352 | </Button> |
| 353 | <Button onClick={handleConfirm} disabled={isDisabled || isImageLoading}> |
| 354 | {isDisabled ? ( |
| 355 | <> |
| 356 | <Loader2 size={16} className="mr-2 animate-spin" /> |
| 357 | {isProcessing ? (processingText || t('profile.processing')) : (uploadingText || t('profile.uploading'))} |
| 358 | </> |
| 359 | ) : ( |
| 360 | confirmText || t('profile.confirm') |
| 361 | )} |
| 362 | </Button> |
| 363 | </div> |
| 364 | </DialogContent> |
| 365 | </Dialog> |
| 366 | ) |
| 367 | } |
| 368 | |
| 369 | export default AvatarCropModal |
| 370 |