| 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 |