返回 AiToEarn
BrushToolbar.tsx
1 /**
2 * BrushToolbar - 画笔工具栏组件
3 * 包含工具选择、颜色选择、笔刷大小、撤销、清除等操作
4 */
5
6 'use client'
7
8 import type { DrawToolType } from './useBrushEditor'
9 import type { UseCropEditorReturn } from './useCropEditor'
10 import { Check, Circle, Crop, Eraser, Pencil, Square, Undo2, X } from 'lucide-react'
11 import { memo, useMemo } from 'react'
12 import { useTransClient } from '@/app/i18n/client'
13 import { Button } from '@/components/ui/button'
14 import { Slider } from '@/components/ui/slider'
15 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
16 import { cn } from '@/utils/className'
17 import { BRUSH_SIZE, PRESET_COLORS } from './useBrushEditor'
18 import { CROP_RATIOS } from './useCropEditor'
19
20 /** 比例示意形状:纯 div + border 实现 */
21 const RatioShape = memo(({ ratio, isActive }: { ratio: number, isActive: boolean }) => {
22 const { width, height } = useMemo(() => {
23 const maxH = 14
24 const maxW = 22
25 if (ratio === 0) {
26 // free 模式用正方形
27 return { width: maxH, height: maxH }
28 }
29 if (ratio >= 1) {
30 // 横向:以宽度为基准
31 const w = Math.min(maxW, maxH * ratio)
32 return { width: w, height: w / ratio }
33 }
34 // 纵向:以高度为基准
35 const h = maxH
36 return { width: h * ratio, height: h }
37 }, [ratio])
38
39 return (
40 <div
41 className={cn(
42 'rounded-[1px] flex-shrink-0 transition-colors',
43 isActive ? 'bg-primary/20 border-primary' : 'border-muted-foreground/50',
44 ratio === 0 ? 'border border-dashed' : 'border-[1.5px] border-solid',
45 )}
46 style={{ width, height }}
47 />
48 )
49 })
50
51 /** 工具配置 */
52 const TOOLS: { type: DrawToolType, icon: typeof Pencil, labelKey: string }[] = [
53 { type: 'brush', icon: Pencil, labelKey: 'brushEditor.brush' },
54 { type: 'rectangle', icon: Square, labelKey: 'brushEditor.rectangle' },
55 { type: 'ellipse', icon: Circle, labelKey: 'brushEditor.ellipse' },
56 { type: 'crop', icon: Crop, labelKey: 'brushEditor.crop' },
57 ]
58
59 export interface BrushToolbarProps {
60 /** 当前工具类型 */
61 toolType: DrawToolType
62 /** 设置工具类型 */
63 setToolType: (type: DrawToolType) => void
64 /** 当前画笔颜色 */
65 brushColor: string
66 /** 设置画笔颜色 */
67 setBrushColor: (color: string) => void
68 /** 当前画笔大小 */
69 brushSize: number
70 /** 设置画笔大小 */
71 setBrushSize: (size: number) => void
72 /** 是否可以撤销 */
73 canUndo: boolean
74 /** 撤销操作 */
75 onUndo: () => void
76 /** 清除所有操作 */
77 onClearAll: () => void
78 /** 裁剪编辑器 */
79 cropEditor?: UseCropEditorReturn
80 /** 自定义类名 */
81 className?: string
82 }
83
84 export const BrushToolbar = memo(
85 ({
86 toolType,
87 setToolType,
88 brushColor,
89 setBrushColor,
90 brushSize,
91 setBrushSize,
92 canUndo,
93 onUndo,
94 onClearAll,
95 cropEditor,
96 className,
97 }: BrushToolbarProps) => {
98 const { t } = useTransClient('common')
99 const isCropping = cropEditor?.isCropping
100
101 return (
102 <div
103 className={cn(
104 'flex flex-col sm:flex-row sm:flex-wrap items-center justify-center gap-1.5 p-2 sm:gap-4 sm:p-3 bg-background/95 backdrop-blur-sm rounded-lg border border-border max-w-[calc(100vw-16px)] sm:max-w-none',
105 className,
106 )}
107 >
108 {/* 第一行(移动端):工具选择 + 操作按钮 */}
109 <div className="flex items-center justify-between w-full sm:w-auto sm:justify-center gap-1">
110 {/* 工具图标 */}
111 <div className="flex items-center gap-1">
112 {TOOLS.map(({ type, icon: Icon, labelKey }) => (
113 <TooltipProvider key={type} delayDuration={300}>
114 <Tooltip>
115 <TooltipTrigger asChild>
116 <button
117 type="button"
118 onClick={() => setToolType(type)}
119 disabled={isCropping}
120 className={cn(
121 'w-7 h-7 sm:w-8 sm:h-8 flex items-center justify-center rounded cursor-pointer transition-all',
122 'hover:bg-accent focus:outline-none',
123 toolType === type && 'bg-accent outline outline-2 outline-primary',
124 isCropping && type !== 'crop' && 'opacity-50 cursor-not-allowed',
125 )}
126 >
127 <Icon className="w-4 h-4" />
128 </button>
129 </TooltipTrigger>
130 <TooltipContent className="z-[10001]">
131 <p>{t(labelKey)}</p>
132 </TooltipContent>
133 </Tooltip>
134 </TooltipProvider>
135 ))}
136 </div>
137
138 {/* 移动端操作按钮(靠右) */}
139 {isCropping && cropEditor
140 ? (
141 <div className="flex items-center gap-1 sm:hidden">
142 <Button
143 variant="ghost"
144 size="sm"
145 onClick={cropEditor.cancelCrop}
146 className="h-7 px-2 cursor-pointer"
147 >
148 <X className="w-4 h-4" />
149 <span className="ml-1">{t('brushEditor.cropCancel')}</span>
150 </Button>
151 <Button
152 size="sm"
153 onClick={cropEditor.confirmCrop}
154 className="h-7 px-2 cursor-pointer"
155 >
156 <Check className="w-4 h-4" />
157 <span className="ml-1">{t('brushEditor.cropConfirm')}</span>
158 </Button>
159 </div>
160 )
161 : (
162 <div className="flex items-center gap-1 sm:hidden">
163 <Button
164 variant="ghost"
165 size="sm"
166 onClick={onUndo}
167 disabled={!canUndo}
168 className="h-7 px-2 cursor-pointer"
169 >
170 <Undo2 className="w-4 h-4" />
171 </Button>
172 <Button
173 variant="ghost"
174 size="sm"
175 onClick={onClearAll}
176 className="h-7 px-2 cursor-pointer"
177 >
178 <Eraser className="w-4 h-4" />
179 </Button>
180 </div>
181 )}
182 </div>
183
184 {/* 裁剪模式:第二行(移动端)比例选择 + 桌面端确认/取消 */}
185 {isCropping && cropEditor && (
186 <>
187 {/* 分隔线 - 仅桌面 */}
188 <div className="w-px h-6 bg-border hidden sm:block" />
189
190 {/* 比例选择 - 移动端横向滚动占满整行 */}
191 <div className="flex items-center gap-1 overflow-x-auto flex-nowrap w-full sm:w-auto scrollbar-none py-0.5">
192 <span className="text-xs text-muted-foreground mr-1 hidden sm:inline flex-shrink-0">
193 {t('brushEditor.cropRatio')}
194 </span>
195 {CROP_RATIOS.map(({ label, value }) => (
196 <button
197 key={label}
198 type="button"
199 onClick={() => cropEditor.changeAspectRatio(value)}
200 className={cn(
201 'flex flex-col items-center gap-1 px-1.5 sm:px-2 py-1 text-xs rounded cursor-pointer transition-colors flex-shrink-0',
202 'hover:bg-accent focus:outline-none',
203 cropEditor.aspectRatio === value && 'bg-accent outline outline-1 outline-primary',
204 )}
205 >
206 <RatioShape ratio={value} isActive={cropEditor.aspectRatio === value} />
207 <span className="leading-none">{label === 'free' ? t('brushEditor.cropFree') : label}</span>
208 </button>
209 ))}
210 </div>
211
212 {/* 分隔线 - 仅桌面 */}
213 <div className="w-px h-6 bg-border hidden sm:block" />
214
215 {/* 确认/取消 - 仅桌面(移动端在第一行右侧) */}
216 <div className="hidden sm:flex items-center gap-1 flex-shrink-0">
217 <Button
218 variant="ghost"
219 size="sm"
220 onClick={cropEditor.cancelCrop}
221 className="h-8 px-2 cursor-pointer"
222 >
223 <X className="w-4 h-4" />
224 <span className="ml-1">{t('brushEditor.cropCancel')}</span>
225 </Button>
226 <Button
227 size="sm"
228 onClick={cropEditor.confirmCrop}
229 className="h-8 px-2 cursor-pointer"
230 >
231 <Check className="w-4 h-4" />
232 <span className="ml-1">{t('brushEditor.cropConfirm')}</span>
233 </Button>
234 </div>
235 </>
236 )}
237
238 {/* 非裁剪模式:第二行(移动端)颜色/大小 + 桌面端操作按钮 */}
239 {!isCropping && (
240 <>
241 {/* 分隔线 - 仅桌面 */}
242 <div className="w-px h-6 bg-border hidden sm:block" />
243
244 {/* 颜色 + 大小:移动端占满整行 */}
245 <div className="flex items-center gap-2 w-full sm:w-auto justify-center">
246 {/* 颜色选择 */}
247 <div className="flex items-center gap-1 sm:gap-1.5">
248 <span className="text-xs text-muted-foreground mr-1 hidden sm:inline">
249 {t('brushEditor.color')}
250 </span>
251 {PRESET_COLORS.map(color => (
252 <button
253 key={color}
254 type="button"
255 onClick={() => setBrushColor(color)}
256 className={cn(
257 'w-5 h-5 sm:w-6 sm:h-6 rounded-full cursor-pointer transition-all',
258 'hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary/50',
259 brushColor === color && 'ring-2 ring-primary ring-offset-2 ring-offset-background',
260 color === '#FFFFFF' && 'border border-border',
261 )}
262 style={{ backgroundColor: color }}
263 aria-label={`Select color ${color}`}
264 />
265 ))}
266 </div>
267
268 {/* 分隔线 - 仅桌面 */}
269 <div className="w-px h-6 bg-border hidden sm:block" />
270
271 {/* 笔刷大小 */}
272 <div className="flex items-center gap-2">
273 <span className="text-xs text-muted-foreground hidden sm:inline">
274 {t('brushEditor.size')}
275 </span>
276 <div className="flex items-center gap-2 min-w-[100px]">
277 <Slider
278 value={[brushSize]}
279 onValueChange={([value]) => setBrushSize(value)}
280 min={BRUSH_SIZE.min}
281 max={BRUSH_SIZE.max}
282 step={1}
283 className="w-20"
284 />
285 <span className="text-xs text-muted-foreground w-6 text-center">{brushSize}</span>
286 </div>
287 </div>
288 </div>
289
290 {/* 分隔线 - 仅桌面 */}
291 <div className="w-px h-6 bg-border hidden sm:block" />
292
293 {/* 操作按钮 - 仅桌面(移动端在第一行右侧) */}
294 <div className="hidden sm:flex items-center gap-1">
295 <TooltipProvider delayDuration={300}>
296 <Tooltip>
297 <TooltipTrigger asChild>
298 <Button
299 variant="ghost"
300 size="sm"
301 onClick={onUndo}
302 disabled={!canUndo}
303 className="h-8 px-2 cursor-pointer"
304 >
305 <Undo2 className="w-4 h-4" />
306 <span className="ml-1">{t('brushEditor.undo')}</span>
307 </Button>
308 </TooltipTrigger>
309 <TooltipContent className="z-[10001]">
310 <p>{t('brushEditor.undo')}</p>
311 </TooltipContent>
312 </Tooltip>
313 </TooltipProvider>
314
315 <TooltipProvider delayDuration={300}>
316 <Tooltip>
317 <TooltipTrigger asChild>
318 <Button
319 variant="ghost"
320 size="sm"
321 onClick={onClearAll}
322 className="h-8 px-2 cursor-pointer"
323 >
324 <Eraser className="w-4 h-4" />
325 <span className="ml-1">{t('brushEditor.clearAll')}</span>
326 </Button>
327 </TooltipTrigger>
328 <TooltipContent>
329 <p>{t('brushEditor.clearAll')}</p>
330 </TooltipContent>
331 </Tooltip>
332 </TooltipProvider>
333 </div>
334 </>
335 )}
336 </div>
337 )
338 },
339 )
340
341 export default BrushToolbar
342
342 lines Plain Text