Initial ecommerce standalone package
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface CameraViewport3DProps {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
roll: number;
|
||||
zoom: number;
|
||||
onOrbit: (horizontal: number, vertical: number) => void;
|
||||
onZoom: (zoom: number) => void;
|
||||
onReset: () => void;
|
||||
referenceImage?: string | null;
|
||||
}
|
||||
|
||||
interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
function rotateY(p: Vec3, angle: number): Vec3 {
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
return { x: p.x * c + p.z * s, y: p.y, z: -p.x * s + p.z * c };
|
||||
}
|
||||
|
||||
function rotateX(p: Vec3, angle: number): Vec3 {
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
return { x: p.x, y: p.y * c - p.z * s, z: p.y * s + p.z * c };
|
||||
}
|
||||
|
||||
function rotateZ(p: Vec3, angle: number): Vec3 {
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
return { x: p.x * c - p.y * s, y: p.x * s + p.y * c, z: p.z };
|
||||
}
|
||||
|
||||
function project(p: Vec3, fov: number, w: number, h: number): { x: number; y: number; depth: number } | null {
|
||||
if (p.z <= 0.1) return null;
|
||||
const scale = fov / p.z;
|
||||
return { x: w / 2 + p.x * scale, y: h / 2 - p.y * scale, depth: p.z };
|
||||
}
|
||||
|
||||
function transformPoint(p: Vec3, azimuth: number, elevation: number, rollAngle: number, dist: number): Vec3 {
|
||||
let pt = rotateY(p, -azimuth * DEG);
|
||||
pt = rotateX(pt, elevation * DEG);
|
||||
pt = rotateZ(pt, -rollAngle * DEG);
|
||||
pt = { x: pt.x, y: pt.y, z: pt.z + dist };
|
||||
return pt;
|
||||
}
|
||||
|
||||
const HUMANOID: Vec3[][] = [
|
||||
[{ x: 0, y: 1.7, z: 0 }, { x: 0, y: 1.5, z: 0 }],
|
||||
[{ x: -0.15, y: 1.7, z: 0 }, { x: 0.15, y: 1.7, z: 0 }],
|
||||
[{ x: 0, y: 1.5, z: 0 }, { x: 0, y: 0.9, z: 0 }],
|
||||
[{ x: -0.25, y: 1.5, z: 0 }, { x: 0, y: 1.5, z: 0 }],
|
||||
[{ x: 0, y: 1.5, z: 0 }, { x: 0.25, y: 1.5, z: 0 }],
|
||||
[{ x: -0.25, y: 1.5, z: 0 }, { x: -0.3, y: 1.0, z: 0 }],
|
||||
[{ x: 0.25, y: 1.5, z: 0 }, { x: 0.3, y: 1.0, z: 0 }],
|
||||
[{ x: 0, y: 0.9, z: 0 }, { x: -0.15, y: 0, z: 0 }],
|
||||
[{ x: 0, y: 0.9, z: 0 }, { x: 0.15, y: 0, z: 0 }],
|
||||
];
|
||||
|
||||
function renderScene(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
azimuth: number,
|
||||
elevation: number,
|
||||
rollAngle: number,
|
||||
focalLen: number,
|
||||
) {
|
||||
const dist = 5.5 - (focalLen - 24) * 0.02;
|
||||
const fov = w * 0.8;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background gradient
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
||||
bg.addColorStop(0, "#1a1f2e");
|
||||
bg.addColorStop(1, "#0d1117");
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Ground grid
|
||||
const gridLines: { p1: { x: number; y: number; depth: number }; p2: { x: number; y: number; depth: number }; alpha: number }[] = [];
|
||||
const gridSize = 4;
|
||||
const gridStep = 0.5;
|
||||
for (let i = -gridSize; i <= gridSize; i += gridStep) {
|
||||
const a = transformPoint({ x: i, y: 0, z: -gridSize }, azimuth, elevation, rollAngle, dist);
|
||||
const b = transformPoint({ x: i, y: 0, z: gridSize }, azimuth, elevation, rollAngle, dist);
|
||||
const pa = project(a, fov, w, h);
|
||||
const pb = project(b, fov, w, h);
|
||||
if (pa && pb) gridLines.push({ p1: pa, p2: pb, alpha: i === 0 ? 0.4 : 0.15 });
|
||||
|
||||
const c = transformPoint({ x: -gridSize, y: 0, z: i }, azimuth, elevation, rollAngle, dist);
|
||||
const d = transformPoint({ x: gridSize, y: 0, z: i }, azimuth, elevation, rollAngle, dist);
|
||||
const pc = project(c, fov, w, h);
|
||||
const pd = project(d, fov, w, h);
|
||||
if (pc && pd) gridLines.push({ p1: pc, p2: pd, alpha: i === 0 ? 0.4 : 0.15 });
|
||||
}
|
||||
|
||||
for (const line of gridLines) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(line.p1.x, line.p1.y);
|
||||
ctx.lineTo(line.p2.x, line.p2.y);
|
||||
ctx.strokeStyle = `rgba(45, 212, 191, ${line.alpha})`;
|
||||
ctx.lineWidth = line.alpha > 0.3 ? 1.2 : 0.6;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Humanoid figure
|
||||
for (const seg of HUMANOID) {
|
||||
const a = transformPoint(seg[0], azimuth, elevation, rollAngle, dist);
|
||||
const b = transformPoint(seg[1], azimuth, elevation, rollAngle, dist);
|
||||
const pa = project(a, fov, w, h);
|
||||
const pb = project(b, fov, w, h);
|
||||
if (pa && pb) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pa.x, pa.y);
|
||||
ctx.lineTo(pb.x, pb.y);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
// Head circle
|
||||
const headCenter = transformPoint({ x: 0, y: 1.8, z: 0 }, azimuth, elevation, rollAngle, dist);
|
||||
const headEdge = transformPoint({ x: 0.12, y: 1.8, z: 0 }, azimuth, elevation, rollAngle, dist);
|
||||
const ph = project(headCenter, fov, w, h);
|
||||
const pe = project(headEdge, fov, w, h);
|
||||
if (ph && pe) {
|
||||
const r = Math.hypot(pe.x - ph.x, pe.y - ph.y);
|
||||
ctx.beginPath();
|
||||
ctx.arc(ph.x, ph.y, r, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Axis indicators (bottom-left)
|
||||
const axisOrigin = { x: 44, y: h - 44 };
|
||||
const axisLen = 28;
|
||||
const axes: { dir: Vec3; color: string; label: string }[] = [
|
||||
{ dir: { x: 1, y: 0, z: 0 }, color: "#ef4444", label: "X" },
|
||||
{ dir: { x: 0, y: 1, z: 0 }, color: "#22c55e", label: "Y" },
|
||||
{ dir: { x: 0, y: 0, z: 1 }, color: "#3b82f6", label: "Z" },
|
||||
];
|
||||
for (const axis of axes) {
|
||||
let d = rotateY(axis.dir, -azimuth * DEG);
|
||||
d = rotateX(d, elevation * DEG);
|
||||
d = rotateZ(d, -rollAngle * DEG);
|
||||
const ex = axisOrigin.x + d.x * axisLen;
|
||||
const ey = axisOrigin.y - d.y * axisLen;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(axisOrigin.x, axisOrigin.y);
|
||||
ctx.lineTo(ex, ey);
|
||||
ctx.strokeStyle = axis.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = axis.color;
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
ctx.fillText(axis.label, ex + 3, ey - 3);
|
||||
}
|
||||
|
||||
// HUD info (top-left)
|
||||
ctx.fillStyle = "rgba(255,255,255,0.6)";
|
||||
ctx.font = "11px monospace";
|
||||
ctx.fillText(`方位 ${azimuth.toFixed(0)}° 俯仰 ${elevation.toFixed(0)}° 倾斜 ${rollAngle.toFixed(0)}°`, 12, 20);
|
||||
ctx.fillText(`焦距 ${focalLen}mm`, 12, 36);
|
||||
}
|
||||
|
||||
export default function CameraViewport3D({
|
||||
horizontal,
|
||||
vertical,
|
||||
roll,
|
||||
zoom,
|
||||
onOrbit,
|
||||
onZoom,
|
||||
onReset,
|
||||
referenceImage,
|
||||
}: CameraViewport3DProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const dragRef = useRef<{ startX: number; startY: number; startH: number; startV: number } | null>(null);
|
||||
const rafRef = useRef(0);
|
||||
const refImgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = rect.width * dpr;
|
||||
const h = rect.height * dpr;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
renderScene(ctx, rect.width, rect.height, horizontal, vertical, roll, zoom);
|
||||
|
||||
if (refImgRef.current && refImgRef.current.complete && refImgRef.current.naturalWidth) {
|
||||
const img = refImgRef.current;
|
||||
const thumbSize = 64;
|
||||
const aspect = img.naturalWidth / img.naturalHeight;
|
||||
const tw = aspect >= 1 ? thumbSize : thumbSize * aspect;
|
||||
const th = aspect >= 1 ? thumbSize / aspect : thumbSize;
|
||||
const tx = rect.width - tw - 10;
|
||||
const ty = 10;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.drawImage(img, tx, ty, tw, th);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = "rgba(45, 212, 191, 0.6)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(tx, ty, tw, th);
|
||||
}
|
||||
}, [horizontal, vertical, roll, zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!referenceImage) { refImgRef.current = null; return; }
|
||||
const img = new Image();
|
||||
img.src = referenceImage;
|
||||
img.onload = () => { refImgRef.current = img; draw(); };
|
||||
refImgRef.current = img;
|
||||
}, [referenceImage, draw]);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, startH: horizontal, startV: vertical };
|
||||
}, [horizontal, vertical]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
const newH = Math.max(-180, Math.min(180, dragRef.current.startH - dx * 0.5));
|
||||
const newV = Math.max(-85, Math.min(65, dragRef.current.startV + dy * 0.5));
|
||||
onOrbit(newH, newV);
|
||||
}, [onOrbit]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) canvas.releasePointerCapture(e.pointerId);
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 4 : -4;
|
||||
onZoom(Math.max(24, Math.min(100, zoom + delta)));
|
||||
}, [zoom, onZoom]);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
onReset();
|
||||
}, [onReset]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="camera-viewport-3d"
|
||||
style={{ width: "100%", height: "100%", cursor: dragRef.current ? "grabbing" : "grab", touchAction: "none" }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user