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