返回 AiToEarn
useBrushEditor.ts
1 /**
2 * useBrushEditor - 画笔编辑器核心逻辑 Hook
3 * 处理画笔绑制、颜色、大小、撤销等功能
4 */
5
6 import type { ImageExportOptions } from './imageExport'
7
8 import { useCallback, useRef, useState } from 'react'
9 import { getOssUrl } from '@/utils/oss'
10 import { DEFAULT_IMAGE_EXPORT_FORMAT, exportCanvasLayers, IMAGE_EXPORT_QUALITY } from './imageExport'
11
12 /** 预设颜色 */
13 export const PRESET_COLORS = [
14 '#EF4444', // 红色
15 '#F97316', // 橙色
16 '#EAB308', // 黄色
17 '#22C55E', // 绿色
18 '#3B82F6', // 蓝色
19 '#000000', // 黑色
20 '#FFFFFF', // 白色
21 ] as const
22
23 /** 笔刷大小配置 */
24 export const BRUSH_SIZE = {
25 min: 2,
26 max: 20,
27 default: 5,
28 } as const
29
30 /** 绘制工具类型 */
31 export type DrawToolType = 'brush' | 'rectangle' | 'ellipse' | 'crop'
32
33 /** 最大历史记录数 */
34 const MAX_HISTORY = 30
35
36 export interface UseBrushEditorOptions {
37 /** 图片加载完成回调 */
38 onImageLoad?: (width: number, height: number) => void
39 }
40
41 export function useBrushEditor(imageUrl: string, options?: UseBrushEditorOptions) {
42 // Canvas refs
43 const imageCanvasRef = useRef<HTMLCanvasElement>(null)
44 const drawCanvasRef = useRef<HTMLCanvasElement>(null)
45
46 // 画笔状态
47 const [brushColor, setBrushColor] = useState<string>(PRESET_COLORS[0])
48 const [brushSize, setBrushSize] = useState<number>(BRUSH_SIZE.default)
49 const [toolType, setToolType] = useState<DrawToolType>('brush')
50
51 // 绘制状态
52 const [isDrawing, setIsDrawing] = useState(false)
53 const [imageLoaded, setImageLoaded] = useState(false)
54
55 // 历史记录(用于撤销)
56 const historyRef = useRef<ImageData[]>([])
57 const [canUndo, setCanUndo] = useState(false)
58
59 // 图片原始尺寸
60 const imageSizeRef = useRef({ width: 0, height: 0 })
61
62 // 形状绘制起始点和预览状态
63 const startPointRef = useRef<{ x: number, y: number } | null>(null)
64 const previewImageDataRef = useRef<ImageData | null>(null)
65
66 /** 保存当前状态到历史 */
67 const saveToHistory = useCallback(() => {
68 const ctx = drawCanvasRef.current?.getContext('2d')
69 if (!ctx)
70 return
71
72 const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
73
74 historyRef.current.push(imageData)
75
76 // 限制历史记录数量
77 if (historyRef.current.length > MAX_HISTORY) {
78 historyRef.current.shift()
79 }
80
81 setCanUndo(true)
82 }, [])
83
84 /** 加载图片到底层 Canvas */
85 const loadImage = useCallback(() => {
86 const imageCanvas = imageCanvasRef.current
87 const drawCanvas = drawCanvasRef.current
88 if (!imageCanvas || !drawCanvas)
89 return
90
91 const img = new Image()
92 img.crossOrigin = 'anonymous'
93
94 img.onload = () => {
95 // 保存原始尺寸
96 imageSizeRef.current = { width: img.width, height: img.height }
97
98 // 设置 Canvas 尺寸
99 imageCanvas.width = img.width
100 imageCanvas.height = img.height
101 drawCanvas.width = img.width
102 drawCanvas.height = img.height
103
104 // 绘制图片到底层 Canvas
105 const ctx = imageCanvas.getContext('2d')
106 if (ctx) {
107 ctx.drawImage(img, 0, 0)
108 }
109
110 setImageLoaded(true)
111 options?.onImageLoad?.(img.width, img.height)
112 }
113
114 img.onerror = () => {
115 console.error('Failed to load image:', imageUrl)
116 }
117
118 // 使用代理路径避免跨域问题
119 img.src = getOssUrl(imageUrl) || imageUrl
120 }, [imageUrl, options])
121
122 /** 获取指针位置(相对于 Canvas) */
123 const getPointerPosition = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
124 const canvas = drawCanvasRef.current
125 if (!canvas)
126 return { x: 0, y: 0 }
127
128 const rect = canvas.getBoundingClientRect()
129 const scaleX = canvas.width / rect.width
130 const scaleY = canvas.height / rect.height
131
132 return {
133 x: (e.clientX - rect.left) * scaleX,
134 y: (e.clientY - rect.top) * scaleY,
135 }
136 }, [])
137
138 /** 绘制形状(矩形或椭圆) */
139 const drawShape = useCallback(
140 (
141 ctx: CanvasRenderingContext2D,
142 start: { x: number, y: number },
143 end: { x: number, y: number },
144 type: 'rectangle' | 'ellipse',
145 ) => {
146 ctx.strokeStyle = brushColor
147 ctx.lineWidth = brushSize
148 ctx.lineCap = 'round'
149 ctx.lineJoin = 'round'
150
151 if (type === 'rectangle') {
152 const width = end.x - start.x
153 const height = end.y - start.y
154 ctx.strokeRect(start.x, start.y, width, height)
155 }
156 else {
157 // 椭圆:以起点和终点为对角线的矩形内切椭圆
158 const centerX = (start.x + end.x) / 2
159 const centerY = (start.y + end.y) / 2
160 const radiusX = Math.abs(end.x - start.x) / 2
161 const radiusY = Math.abs(end.y - start.y) / 2
162
163 ctx.beginPath()
164 ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2)
165 ctx.stroke()
166 }
167 },
168 [brushColor, brushSize],
169 )
170
171 /** 裁剪后更新 Canvas */
172 const updateCanvasAfterCrop = useCallback((cropRect: { x: number, y: number, width: number, height: number }) => {
173 const imageCanvas = imageCanvasRef.current
174 const drawCanvas = drawCanvasRef.current
175 if (!imageCanvas || !drawCanvas)
176 return
177
178 const { x, y, width, height } = cropRect
179 const w = Math.round(width)
180 const h = Math.round(height)
181 const sx = Math.round(x)
182 const sy = Math.round(y)
183
184 // 裁剪 imageCanvas
185 const imgCtx = imageCanvas.getContext('2d')
186 if (imgCtx) {
187 const imgData = imgCtx.getImageData(sx, sy, w, h)
188 imageCanvas.width = w
189 imageCanvas.height = h
190 imgCtx.putImageData(imgData, 0, 0)
191 }
192
193 // 裁剪 drawCanvas
194 const drawCtx = drawCanvas.getContext('2d')
195 if (drawCtx) {
196 const drawData = drawCtx.getImageData(sx, sy, w, h)
197 drawCanvas.width = w
198 drawCanvas.height = h
199 drawCtx.putImageData(drawData, 0, 0)
200 }
201
202 // 更新尺寸
203 imageSizeRef.current = { width: w, height: h }
204
205 // 清空历史(裁剪不可撤销)
206 historyRef.current = []
207 setCanUndo(false)
208 }, [])
209
210 /** 开始绘制 */
211 const startDrawing = useCallback(
212 (e: React.PointerEvent<HTMLCanvasElement>) => {
213 // 裁剪模式下禁用绘制
214 if (toolType === 'crop')
215 return
216
217 const ctx = drawCanvasRef.current?.getContext('2d')
218 if (!ctx)
219 return
220
221 // 保存当前状态到历史
222 saveToHistory()
223
224 setIsDrawing(true)
225 const { x, y } = getPointerPosition(e)
226
227 if (toolType === 'brush') {
228 // 画笔:现有逻辑
229 ctx.beginPath()
230 ctx.moveTo(x, y)
231 ctx.strokeStyle = brushColor
232 ctx.lineWidth = brushSize
233 ctx.lineCap = 'round'
234 ctx.lineJoin = 'round'
235 }
236 else {
237 // 形状:保存起始点和当前 Canvas 状态(用于预览时恢复)
238 startPointRef.current = { x, y }
239 previewImageDataRef.current = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
240 }
241 },
242 [toolType, brushColor, brushSize, getPointerPosition, saveToHistory],
243 )
244
245 /** 绘制中 */
246 const draw = useCallback(
247 (e: React.PointerEvent<HTMLCanvasElement>) => {
248 if (!isDrawing || toolType === 'crop')
249 return
250
251 const ctx = drawCanvasRef.current?.getContext('2d')
252 if (!ctx)
253 return
254
255 const { x, y } = getPointerPosition(e)
256
257 if (toolType === 'brush') {
258 // 画笔:现有逻辑
259 ctx.lineTo(x, y)
260 ctx.stroke()
261 }
262 else if (startPointRef.current && previewImageDataRef.current) {
263 // 形状:恢复状态 + 绘制预览
264 ctx.putImageData(previewImageDataRef.current, 0, 0)
265 drawShape(ctx, startPointRef.current, { x, y }, toolType)
266 }
267 },
268 [isDrawing, toolType, getPointerPosition, drawShape],
269 )
270
271 /** 结束绘制 */
272 const stopDrawing = useCallback(() => {
273 if (!isDrawing)
274 return
275
276 // 清除临时状态
277 startPointRef.current = null
278 previewImageDataRef.current = null
279 setIsDrawing(false)
280 }, [isDrawing])
281
282 /** 撤销 */
283 const undo = useCallback(() => {
284 if (historyRef.current.length === 0)
285 return
286
287 const ctx = drawCanvasRef.current?.getContext('2d')
288 if (!ctx)
289 return
290
291 const prevState = historyRef.current.pop()
292 if (prevState) {
293 ctx.putImageData(prevState, 0, 0)
294 }
295
296 setCanUndo(historyRef.current.length > 0)
297 }, [])
298
299 /** 清除所有绘制 */
300 const clearAll = useCallback(() => {
301 const ctx = drawCanvasRef.current?.getContext('2d')
302 if (!ctx)
303 return
304
305 // 先保存当前状态
306 saveToHistory()
307
308 // 清除绘制层
309 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
310 }, [saveToHistory])
311
312 /** 合并两层 Canvas 并导出为 Blob */
313 const exportImage = useCallback(async (options?: Partial<ImageExportOptions>): Promise<Blob> => {
314 const imageCanvas = imageCanvasRef.current
315 const drawCanvas = drawCanvasRef.current
316 if (!imageCanvas || !drawCanvas) {
317 throw new Error('Canvas not ready')
318 }
319
320 return exportCanvasLayers(imageCanvas, drawCanvas, {
321 format: options?.format ?? DEFAULT_IMAGE_EXPORT_FORMAT,
322 quality: options?.quality ?? IMAGE_EXPORT_QUALITY.default,
323 })
324 }, [])
325
326 return {
327 // Refs
328 imageCanvasRef,
329 drawCanvasRef,
330
331 // 状态
332 brushColor,
333 brushSize,
334 toolType,
335 isDrawing,
336 imageLoaded,
337 canUndo,
338 imageSize: imageSizeRef.current,
339
340 // 设置方法
341 setBrushColor,
342 setBrushSize,
343 setToolType,
344
345 // 操作方法
346 loadImage,
347 undo,
348 clearAll,
349 exportImage,
350 updateCanvasAfterCrop,
351
352 // 绑制事件处理
353 drawHandlers: {
354 onPointerDown: startDrawing,
355 onPointerMove: draw,
356 onPointerUp: stopDrawing,
357 onPointerLeave: stopDrawing,
358 },
359 }
360 }
361
362 export type UseBrushEditorReturn = ReturnType<typeof useBrushEditor>
363
363 lines TYPESCRIPT