返回 AiToEarn
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
370 lines Plain Text