bedee3ba8d
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
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<HTMLImageElement> {
|
|
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<HTMLImageElement | null>(null);
|
|
const maskCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const overlayRef = useRef<HTMLCanvasElement | null>(null);
|
|
const lastPointRef = useRef<CanvasPoint | null>(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<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
setIsDrawing(true);
|
|
paintAt(e.currentTarget, e.clientX, e.clientY);
|
|
}, [paintAt]);
|
|
|
|
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
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<HTMLCanvasElement>) => {
|
|
finishDrawing(e.currentTarget);
|
|
}, [finishDrawing]);
|
|
|
|
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
|
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<HTMLCanvasElement>) => {
|
|
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<HTMLCanvasElement>) => {
|
|
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,
|
|
};
|
|
}
|