import { useCallback, useRef, useState } from "react"; import type React from "react"; type ToolType = "brush" | "eraser"; interface UseCanvasDrawingOptions { baseImage: string | null; brushSize: number; activeTool: ToolType; } const MASK_PREVIEW_COLOR = "rgba(129, 236, 255, 0.48)"; const MASK_EXPORT_COLOR = "rgba(13, 148, 136, 0.88)"; type CanvasPoint = { x: number; y: number }; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function drawMaskSegment( context: CanvasRenderingContext2D, from: CanvasPoint, to: CanvasPoint, lineWidth: number, tool: ToolType, ) { context.save(); context.lineCap = "round"; context.lineJoin = "round"; context.lineWidth = lineWidth; context.globalCompositeOperation = tool === "brush" ? "source-over" : "destination-out"; context.strokeStyle = tool === "brush" ? MASK_EXPORT_COLOR : "#000000"; context.fillStyle = context.strokeStyle; context.beginPath(); context.moveTo(from.x, from.y); context.lineTo(to.x, to.y); context.stroke(); context.beginPath(); context.arc(to.x, to.y, lineWidth / 2, 0, Math.PI * 2); context.fill(); context.restore(); } function getExportFeatherRadius(width: number, height: number): number { const radius = Math.round(Math.min(width, height) * 0.004); return clamp(radius, 2, 18); } function createExportMaskCanvas( maskCanvas: HTMLCanvasElement, width: number, height: number, ): HTMLCanvasElement | null { const normalized = document.createElement("canvas"); normalized.width = width; normalized.height = height; const nCtx = normalized.getContext("2d"); if (!nCtx) return null; nCtx.drawImage(maskCanvas, 0, 0, width, height); const out = document.createElement("canvas"); out.width = width; out.height = height; const oCtx = out.getContext("2d"); if (!oCtx) return null; oCtx.fillStyle = "#000000"; oCtx.fillRect(0, 0, width, height); const feather = getExportFeatherRadius(width, height); if (feather > 0) { oCtx.save(); oCtx.filter = `blur(${feather}px)`; oCtx.globalAlpha = 0.72; oCtx.drawImage(normalized, 0, 0); oCtx.restore(); } oCtx.globalAlpha = 1; oCtx.filter = "none"; oCtx.drawImage(normalized, 0, 0); return out; } function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => resolve(img); img.onerror = () => reject(new Error("图片加载失败")); img.src = src; }); } export function useCanvasDrawing({ baseImage, brushSize, activeTool }: UseCanvasDrawingOptions) { const [imageSize, setImageSize] = useState({ width: 0, height: 0 }); const [sourceSize, setSourceSize] = useState({ width: 0, height: 0 }); const [hasMask, setHasMask] = useState(false); const [isDrawing, setIsDrawing] = useState(false); const originalImgRef = useRef(null); const maskCanvasRef = useRef(null); const overlayRef = useRef(null); const lastPointRef = useRef(null); const hasVisibleRef = useRef(false); const loadTokenRef = useRef(0); const metricsRef = useRef({ srcW: 0, srcH: 0, dispW: 0, dispH: 0 }); const ensureMaskCanvas = useCallback((w: number, h: number) => { if (!maskCanvasRef.current) maskCanvasRef.current = document.createElement("canvas"); const c = maskCanvasRef.current; if (c.width !== w) c.width = w; if (c.height !== h) c.height = h; return c; }, []); const renderComposite = useCallback((canvas: HTMLCanvasElement | null) => { if (!canvas || !originalImgRef.current) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(originalImgRef.current, 0, 0, canvas.width, canvas.height); const mask = maskCanvasRef.current; if (!mask || !hasVisibleRef.current) return; if (!overlayRef.current) overlayRef.current = document.createElement("canvas"); const ov = overlayRef.current; if (ov.width !== canvas.width) ov.width = canvas.width; if (ov.height !== canvas.height) ov.height = canvas.height; const oCtx = ov.getContext("2d"); if (!oCtx) return; oCtx.clearRect(0, 0, ov.width, ov.height); oCtx.drawImage(mask, 0, 0, ov.width, ov.height); oCtx.globalCompositeOperation = "source-in"; oCtx.fillStyle = MASK_PREVIEW_COLOR; oCtx.fillRect(0, 0, ov.width, ov.height); oCtx.globalCompositeOperation = "source-over"; ctx.drawImage(ov, 0, 0, canvas.width, canvas.height); }, []); const initCanvas = useCallback((canvas: HTMLCanvasElement | null) => { if (!baseImage || !canvas) return; const token = ++loadTokenRef.current; loadImage(baseImage).then((img) => { if (loadTokenRef.current !== token) return; originalImgRef.current = img; const srcW = img.naturalWidth || img.width; const srcH = img.naturalHeight || img.height; const maxW = 800, maxH = 500; let dW = srcW, dH = srcH; if (dW > maxW || dH > maxH) { const r = Math.min(maxW / dW, maxH / dH); dW = Math.round(dW * r); dH = Math.round(dH * r); } metricsRef.current = { srcW, srcH, dispW: dW, dispH: dH }; setSourceSize({ width: srcW, height: srcH }); setImageSize({ width: dW, height: dH }); canvas.width = dW; canvas.height = dH; ensureMaskCanvas(srcW, srcH); hasVisibleRef.current = false; setHasMask(false); renderComposite(canvas); }).catch(() => {}); }, [baseImage, ensureMaskCanvas, renderComposite]); const resolveMaskPoint = useCallback((canvas: HTMLCanvasElement, clientX: number, clientY: number) => { const rect = canvas.getBoundingClientRect(); const scaleX = rect.width > 0 ? canvas.width / rect.width : 1; const scaleY = rect.height > 0 ? canvas.height / rect.height : 1; const dispX = clamp((clientX - rect.left) * scaleX, 0, canvas.width); const dispY = clamp((clientY - rect.top) * scaleY, 0, canvas.height); const mask = maskCanvasRef.current; const mScaleX = mask ? mask.width / Math.max(1, canvas.width) : 1; const mScaleY = mask ? mask.height / Math.max(1, canvas.height) : 1; return { point: { x: dispX * mScaleX, y: dispY * mScaleY }, brushScale: Math.max(mScaleX, mScaleY), }; }, []); const paintAt = useCallback((canvas: HTMLCanvasElement, clientX: number, clientY: number) => { const { srcW, srcH } = metricsRef.current; const mask = ensureMaskCanvas(srcW, srcH); const ctx = mask.getContext("2d", { willReadFrequently: true }); if (!ctx) return; const { point, brushScale } = resolveMaskPoint(canvas, clientX, clientY); const prev = lastPointRef.current || point; const naturalBrush = Math.max(1, brushSize * brushScale); drawMaskSegment(ctx, prev, point, naturalBrush, activeTool); if (activeTool === "brush") { hasVisibleRef.current = true; setHasMask(true); } lastPointRef.current = point; renderComposite(canvas); }, [activeTool, brushSize, ensureMaskCanvas, renderComposite, resolveMaskPoint]); const startDrawing = useCallback((e: React.MouseEvent) => { e.preventDefault(); setIsDrawing(true); paintAt(e.currentTarget, e.clientX, e.clientY); }, [paintAt]); const draw = useCallback((e: React.MouseEvent) => { if (!isDrawing) return; e.preventDefault(); paintAt(e.currentTarget, e.clientX, e.clientY); }, [isDrawing, paintAt]); const finishDrawing = useCallback((canvas: HTMLCanvasElement | null) => { if (!isDrawing) { lastPointRef.current = null; setIsDrawing(false); return; } setIsDrawing(false); lastPointRef.current = null; if (canvas) renderComposite(canvas); }, [isDrawing, renderComposite]); const stopDrawing = useCallback((e: React.MouseEvent) => { finishDrawing(e.currentTarget); }, [finishDrawing]); const handleTouchStart = useCallback((e: React.TouchEvent) => { if (e.touches.length !== 1) return; e.preventDefault(); setIsDrawing(true); paintAt(e.currentTarget, e.touches[0].clientX, e.touches[0].clientY); }, [paintAt]); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (!isDrawing || e.touches.length !== 1) return; e.preventDefault(); paintAt(e.currentTarget, e.touches[0].clientX, e.touches[0].clientY); }, [isDrawing, paintAt]); const handleTouchEnd = useCallback((e: React.TouchEvent) => { finishDrawing(e.currentTarget); }, [finishDrawing]); const clearMask = useCallback((canvas: HTMLCanvasElement | null) => { const mask = maskCanvasRef.current; if (mask) { const ctx = mask.getContext("2d"); if (ctx) ctx.clearRect(0, 0, mask.width, mask.height); } hasVisibleRef.current = false; setHasMask(false); lastPointRef.current = null; if (canvas) renderComposite(canvas); }, [renderComposite]); const exportMaskDataUrl = useCallback((): string | null => { const mask = maskCanvasRef.current; if (!mask || !hasVisibleRef.current) return null; const { srcW, srcH } = metricsRef.current; const exportCanvas = createExportMaskCanvas(mask, srcW, srcH); return (exportCanvas || mask).toDataURL("image/png"); }, []); return { imageSize, sourceSize, hasMask, isDrawing, initCanvas, renderComposite, startDrawing, draw, stopDrawing, handleTouchStart, handleTouchMove, handleTouchEnd, clearMask, exportMaskDataUrl, }; }