返回 AiToEarn
1 /**
2 * BrushEditor - 画笔编辑器主组件
3 * 提供图片画笔标注功能,支持颜色、大小、撤销、保存等操作
4 */
5
6 'use client'
7
8 import type { ImageExportFormat } from './imageExport'
9 import { AnimatePresence, motion } from 'framer-motion'
10 import { Loader2, X } from 'lucide-react'
11 import { memo, useCallback, useEffect, useState } from 'react'
12 import { createPortal } from 'react-dom'
13 import { uploadToOss } from '@/api/materials/material.api'
14 import { useTransClient } from '@/app/i18n/client'
15 import { Button } from '@/components/ui/button'
16 import { getOssUrl } from '@/utils/oss'
17 import { toast } from '@/utils/ui/toast'
18 import { BrushCanvas } from './BrushCanvas'
19 import { BrushToolbar } from './BrushToolbar'
20 import { createEditedImageFileName, DEFAULT_IMAGE_EXPORT_FORMAT, IMAGE_EXPORT_QUALITY } from './imageExport'
21 import { ImageExportControls } from './ImageExportControls'
22 import { useBrushEditor } from './useBrushEditor'
23 import { useCropEditor } from './useCropEditor'
24
25 export interface BrushEditorProps {
26 /** 是否打开 */
27 open: boolean
28 /** 图片 URL */
29 imageUrl: string
30 /** 关闭编辑器 */
31 onClose: () => void
32 /** 保存完成回调,返回新的图片 URL、Blob 和导出信息 */
33 onSave: (newUrl: string, blob: Blob, meta: BrushEditorSaveMeta) => void
34 }
35
36 export interface BrushEditorSaveMeta {
37 /** 导出的文件名 */
38 fileName: string
39 /** 导出的图片格式 */
40 format: ImageExportFormat
41 /** JPEG 导出质量 */
42 quality: number
43 }
44
45 /** 内部组件:编辑器内容 */
46 const BrushEditorContent = memo(({ imageUrl, onClose, onSave }: Omit<BrushEditorProps, 'open'>) => {
47 const { t } = useTransClient('common')
48 const [isSaving, setIsSaving] = useState(false)
49 const [exportFormat, setExportFormat] = useState<ImageExportFormat>(DEFAULT_IMAGE_EXPORT_FORMAT)
50 const [exportQuality, setExportQuality] = useState<number>(IMAGE_EXPORT_QUALITY.default)
51
52 const editor = useBrushEditor(imageUrl)
53
54 const {
55 toolType,
56 setToolType,
57 brushColor,
58 setBrushColor,
59 brushSize,
60 setBrushSize,
61 canUndo,
62 undo,
63 clearAll,
64 exportImage,
65 updateCanvasAfterCrop,
66 imageSize,
67 } = editor
68
69 const cropEditor = useCropEditor({
70 getCanvasSize: () => imageSize,
71 onCropConfirm: updateCanvasAfterCrop,
72 })
73
74 /** 切换工具类型,裁剪工具需要启动裁剪模式 */
75 const handleSetToolType = useCallback((type: typeof toolType) => {
76 if (cropEditor.isCropping)
77 return
78
79 setToolType(type)
80 if (type === 'crop') {
81 cropEditor.startCrop()
82 }
83 }, [setToolType, cropEditor])
84
85 /** 保存图片 */
86 const handleSave = useCallback(async () => {
87 try {
88 setIsSaving(true)
89
90 // 导出合成后的图片
91 const blob = await exportImage({
92 format: exportFormat,
93 quality: exportQuality,
94 })
95
96 // 创建 File 对象用于上传
97 const fileName = createEditedImageFileName(exportFormat)
98 const file = new File([blob], fileName, {
99 type: exportFormat,
100 })
101
102 // 上传到 OSS
103 const ossUrl = await uploadToOss(file)
104
105 // 获取完整 URL
106 const fullUrl = getOssUrl(ossUrl as string)
107
108 toast.success({ content: t('brushEditor.saveSuccess') })
109 onSave(fullUrl, blob, {
110 fileName,
111 format: exportFormat,
112 quality: exportQuality,
113 })
114 onClose()
115 }
116 catch (error) {
117 console.error('Failed to save edited image:', error)
118 toast.error({ content: t('brushEditor.saveFailed') })
119 }
120 finally {
121 setIsSaving(false)
122 }
123 }, [exportFormat, exportImage, exportQuality, onSave, onClose, t])
124
125 /** 键盘快捷键:Ctrl+Z / Cmd+Z 撤销 */
126 useEffect(() => {
127 const handleKeyDown = (e: KeyboardEvent) => {
128 // Ctrl+Z (Windows/Linux) 或 Cmd+Z (Mac)
129 if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
130 e.preventDefault()
131 undo()
132 }
133 }
134
135 window.addEventListener('keydown', handleKeyDown)
136 return () => window.removeEventListener('keydown', handleKeyDown)
137 }, [undo])
138
139 /** 阻止背景点击关闭 */
140 const handleBackdropClick = (e: React.MouseEvent) => {
141 if (e.target === e.currentTarget && !isSaving) {
142 onClose()
143 }
144 }
145
146 // SSR 检查
147 if (typeof window === 'undefined')
148 return null
149
150 return createPortal(
151 <motion.div
152 initial={{ opacity: 0 }}
153 animate={{ opacity: 1 }}
154 exit={{ opacity: 0 }}
155 transition={{ duration: 0.2 }}
156 className="fixed inset-0 z-[10000] flex flex-col items-center justify-center"
157 style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
158 onClick={handleBackdropClick}
159 >
160 {/* 顶部栏 */}
161 <div
162 className="absolute top-0 left-0 right-0 min-h-12 sm:min-h-14 flex items-center justify-between gap-2 px-3 py-2 sm:px-4 z-10"
163 style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
164 >
165 <h2 className="text-white font-medium text-sm sm:text-base flex-shrink-0">{t('brushEditor.title')}</h2>
166 <div className="flex min-w-0 flex-1 justify-center">
167 <ImageExportControls
168 format={exportFormat}
169 onFormatChange={setExportFormat}
170 quality={exportQuality}
171 onQualityChange={setExportQuality}
172 disabled={isSaving}
173 className="max-w-full"
174 />
175 </div>
176 <button
177 type="button"
178 onClick={onClose}
179 disabled={isSaving}
180 className="flex flex-shrink-0 items-center justify-center w-8 h-8 sm:w-10 sm:h-10 text-white/80 hover:text-white transition-colors cursor-pointer disabled:opacity-50"
181 aria-label={t('brushEditor.cancel')}
182 >
183 <X className="w-5 h-5 sm:w-6 sm:h-6" />
184 </button>
185 </div>
186
187 {/* Canvas 区域 */}
188 <div className="flex-1 flex items-center justify-center w-full pt-14 pb-24 px-2 sm:pt-16 sm:pb-32 sm:px-4 overflow-hidden">
189 <BrushCanvas
190 editor={editor}
191 cropEditor={cropEditor}
192 maxWidth={Math.min(window.innerWidth - 16, 900)}
193 maxHeight={Math.min(window.innerHeight - 160, 600)}
194 />
195 </div>
196
197 {/* 工具栏 */}
198 <div className="absolute bottom-10 sm:bottom-16 left-2 right-2 sm:left-4 sm:right-4 flex justify-center">
199 <BrushToolbar
200 toolType={toolType}
201 setToolType={handleSetToolType}
202 brushColor={brushColor}
203 setBrushColor={setBrushColor}
204 brushSize={brushSize}
205 setBrushSize={setBrushSize}
206 canUndo={canUndo}
207 onUndo={undo}
208 onClearAll={clearAll}
209 cropEditor={cropEditor}
210 />
211 </div>
212
213 {/* 底部按钮 */}
214 <div
215 className="absolute bottom-0 left-0 right-0 h-10 sm:h-14 flex items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4 z-10"
216 style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
217 >
218 <Button
219 variant="outline"
220 onClick={onClose}
221 disabled={isSaving}
222 className="min-w-[80px] sm:min-w-[100px] h-8 sm:h-9 text-sm cursor-pointer"
223 >
224 {t('brushEditor.cancel')}
225 </Button>
226 <Button onClick={handleSave} disabled={isSaving} className="min-w-[80px] sm:min-w-[100px] h-8 sm:h-9 text-sm cursor-pointer">
227 {isSaving ? (
228 <>
229 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
230 {t('brushEditor.saving')}
231 </>
232 ) : (
233 t('brushEditor.save')
234 )}
235 </Button>
236 </div>
237 </motion.div>,
238 document.body,
239 )
240 })
241
242 /**
243 * BrushEditor - 画笔编辑器组件
244 * 使用两层组件结构避免 i18n namespace 动态加载导致的闪烁
245 */
246 export function BrushEditor({ open, ...props }: BrushEditorProps) {
247 // 只在打开时渲染内部组件
248 if (!open)
249 return null
250
251 return (
252 <AnimatePresence>
253 <BrushEditorContent {...props} />
254 </AnimatePresence>
255 )
256 }
257
258 export default BrushEditor
259
259 lines Plain Text