|
|
|
|
@ -1,308 +1,374 @@
|
|
|
|
|
import {
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
FormatPainterOutlined,
|
|
|
|
|
SaveOutlined,
|
|
|
|
|
SyncOutlined,
|
|
|
|
|
UndoOutlined
|
|
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
import { Button, Slider, Tooltip } from 'antd';
|
|
|
|
|
import _ from 'lodash';
|
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
|
import IconFont from '../icon-font';
|
|
|
|
|
import './index.less';
|
|
|
|
|
|
|
|
|
|
type Point = { x: number; y: number };
|
|
|
|
|
type Stroke = { start: Point; end: Point };
|
|
|
|
|
type Stroke = Point[];
|
|
|
|
|
|
|
|
|
|
type CanvasImageEditorProps = {
|
|
|
|
|
imageSrc: string;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
onSave: (imageData: string) => void;
|
|
|
|
|
uploadButton: React.ReactNode;
|
|
|
|
|
imageStatus: {
|
|
|
|
|
isOriginal: boolean;
|
|
|
|
|
isResetNeeded: boolean;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const COLOR = 'rgba(0, 0, 255, 0.3)';
|
|
|
|
|
|
|
|
|
|
const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
imageSrc,
|
|
|
|
|
disabled,
|
|
|
|
|
imageStatus,
|
|
|
|
|
onSave,
|
|
|
|
|
uploadButton
|
|
|
|
|
}) => {
|
|
|
|
|
const COLOR = 'rgba(0, 0, 255, 0.3)';
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [lineWidth, setLineWidth] = useState<number>(30);
|
|
|
|
|
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
|
|
|
|
const isDrawing = useRef<boolean>(false);
|
|
|
|
|
const lastPoint = useRef<Point | null>(null);
|
|
|
|
|
const imgLoaded = useRef<boolean>(false);
|
|
|
|
|
const currentPoint = useRef<Point | null>(null);
|
|
|
|
|
const [cursorVisible, setCursorVisible] = useState(false);
|
|
|
|
|
const [cursorPosition, setCursorPosition] = useState<Point | null>(null);
|
|
|
|
|
const currentStroke = useRef<Point[]>([]);
|
|
|
|
|
const resizeObserver = useRef<ResizeObserver | null>(null);
|
|
|
|
|
const strokesRef = useRef<Stroke[]>([]);
|
|
|
|
|
const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
|
const autoScale = useRef<number>(1);
|
|
|
|
|
const cursorRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [imgLoaded, setImgLoaded] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
console.log('Image src:', imageSrc);
|
|
|
|
|
if (!containerRef.current || !canvasRef.current) return;
|
|
|
|
|
imgLoaded.current = false;
|
|
|
|
|
const canvas = canvasRef.current;
|
|
|
|
|
const overlayCanvas = overlayCanvasRef.current;
|
|
|
|
|
const ctx = canvas!.getContext('2d');
|
|
|
|
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.src = imageSrc;
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
const scale = Math.min(
|
|
|
|
|
container!.offsetWidth / img.width,
|
|
|
|
|
container!.offsetHeight / img.height,
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
canvas!.width = img.width * scale;
|
|
|
|
|
canvas!.height = img.height * scale;
|
|
|
|
|
overlayCanvas!.width = canvas!.width;
|
|
|
|
|
overlayCanvas!.height = canvas!.height;
|
|
|
|
|
|
|
|
|
|
ctx!.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
ctx!.drawImage(img, 0, 0, canvas!.width, canvas!.height);
|
|
|
|
|
imgLoaded.current = true;
|
|
|
|
|
};
|
|
|
|
|
}, [imageSrc, containerRef.current, canvasRef.current]);
|
|
|
|
|
const getTransformedPoint = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
const overlayCanvas = overlayCanvasRef.current!;
|
|
|
|
|
const rect = overlayCanvas.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
const handleResize = useCallback(() => {
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
const canvas = canvasRef.current;
|
|
|
|
|
const overlayCanvas = overlayCanvasRef.current;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!container ||
|
|
|
|
|
!canvas ||
|
|
|
|
|
!overlayCanvas ||
|
|
|
|
|
!overlayCanvas.width ||
|
|
|
|
|
!overlayCanvas.height
|
|
|
|
|
)
|
|
|
|
|
return;
|
|
|
|
|
console.log(
|
|
|
|
|
'disconnect:',
|
|
|
|
|
container,
|
|
|
|
|
canvas,
|
|
|
|
|
overlayCanvas,
|
|
|
|
|
overlayCanvas.width,
|
|
|
|
|
overlayCanvas.height
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Save current overlay content
|
|
|
|
|
const imgData = overlayCanvas
|
|
|
|
|
.getContext('2d')!
|
|
|
|
|
.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
|
|
|
const x = event.clientX - rect.left;
|
|
|
|
|
const y = event.clientY - rect.top;
|
|
|
|
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.src = imageSrc;
|
|
|
|
|
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
// Recalculate scale
|
|
|
|
|
const scale = Math.min(
|
|
|
|
|
container.offsetWidth / img.width,
|
|
|
|
|
container.offsetHeight / img.height,
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
const transformedX = x - overlayCanvas.width / 2;
|
|
|
|
|
const transformedY = y - overlayCanvas.height / 2;
|
|
|
|
|
|
|
|
|
|
// Update canvas dimensions
|
|
|
|
|
canvas.width = img.width * scale;
|
|
|
|
|
canvas.height = img.height * scale;
|
|
|
|
|
overlayCanvas.width = canvas.width;
|
|
|
|
|
overlayCanvas.height = canvas.height;
|
|
|
|
|
|
|
|
|
|
// Redraw background image
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
ctx!.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
ctx!.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
// Restore overlay content
|
|
|
|
|
overlayCanvas.getContext('2d')!.putImageData(imgData, 0, 0);
|
|
|
|
|
};
|
|
|
|
|
}, [imageSrc, containerRef.current, canvasRef.current]);
|
|
|
|
|
console.log('Mouse Coordinates (Transformed):', transformedX, transformedY);
|
|
|
|
|
|
|
|
|
|
const handleMouseEnter = () => {
|
|
|
|
|
setCursorVisible(true);
|
|
|
|
|
return { x: transformedX, y: transformedY };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mapMousePosition = (
|
|
|
|
|
e: React.MouseEvent<HTMLCanvasElement>,
|
|
|
|
|
canvas: HTMLCanvasElement
|
|
|
|
|
) => {
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
const scaleX = canvas.width / rect.width;
|
|
|
|
|
const scaleY = canvas.height / rect.height;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: (e.clientX - rect.left) * scaleX,
|
|
|
|
|
y: (e.clientY - rect.top) * scaleY
|
|
|
|
|
};
|
|
|
|
|
const handleMouseEnter = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
overlayCanvasRef.current!.style.cursor = 'none';
|
|
|
|
|
cursorRef.current!.style.display = 'block';
|
|
|
|
|
cursorRef.current!.style.top = `${e.clientY - lineWidth / 2}px`;
|
|
|
|
|
cursorRef.current!.style.left = `${e.clientX - lineWidth / 2}px`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
const { offsetX, offsetY } = e.nativeEvent;
|
|
|
|
|
setCursorPosition({ x: offsetX, y: offsetY });
|
|
|
|
|
overlayCanvasRef.current!.style.cursor = 'none';
|
|
|
|
|
cursorRef.current!.style.display = 'block';
|
|
|
|
|
cursorRef.current!.style.top = `${e.clientY - lineWidth / 2}px`;
|
|
|
|
|
cursorRef.current!.style.left = `${e.clientX - lineWidth / 2}px`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseLeave = () => {
|
|
|
|
|
setCursorVisible(false);
|
|
|
|
|
setCursorPosition(null);
|
|
|
|
|
overlayCanvasRef.current!.style.cursor = 'default';
|
|
|
|
|
cursorRef.current!.style.display = 'none';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
handleResize();
|
|
|
|
|
});
|
|
|
|
|
const createOffscreenCanvas = () => {
|
|
|
|
|
if (offscreenCanvasRef.current === null) {
|
|
|
|
|
offscreenCanvasRef.current = document.createElement('canvas');
|
|
|
|
|
offscreenCanvasRef.current.width = overlayCanvasRef.current!.width;
|
|
|
|
|
offscreenCanvasRef.current.height = overlayCanvasRef.current!.height;
|
|
|
|
|
const offscreenCtx = offscreenCanvasRef.current.getContext('2d')!;
|
|
|
|
|
offscreenCtx.translate(
|
|
|
|
|
overlayCanvasRef.current!.width / 2,
|
|
|
|
|
overlayCanvasRef.current!.height / 2
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
if (container) resizeObserver.observe(container);
|
|
|
|
|
const setCanvasCenter = useCallback(() => {
|
|
|
|
|
if (!canvasRef.current || !overlayCanvasRef.current) return;
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
resizeObserver.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [handleResize, strokes, containerRef.current]);
|
|
|
|
|
const overlayCtx = overlayCanvasRef.current!.getContext('2d');
|
|
|
|
|
const ctx = canvasRef.current!.getContext('2d');
|
|
|
|
|
const offscreenCtx = offscreenCanvasRef.current!.getContext('2d');
|
|
|
|
|
|
|
|
|
|
const generateMask = useCallback(() => {
|
|
|
|
|
const canvas = overlayCanvasRef.current!;
|
|
|
|
|
const finalImage = document.createElement('canvas');
|
|
|
|
|
finalImage.width = canvas.width;
|
|
|
|
|
finalImage.height = canvas.height;
|
|
|
|
|
const finalCtx = finalImage.getContext('2d');
|
|
|
|
|
// Set the origin to the center
|
|
|
|
|
overlayCtx!.translate(ctx!.canvas.width / 2, ctx!.canvas.height / 2);
|
|
|
|
|
ctx!.translate(ctx!.canvas.width / 2, ctx!.canvas.height / 2);
|
|
|
|
|
offscreenCtx!.translate(ctx!.canvas.width / 2, ctx!.canvas.height / 2);
|
|
|
|
|
}, [canvasRef.current, overlayCanvasRef.current]);
|
|
|
|
|
|
|
|
|
|
// Create the transparent overlay
|
|
|
|
|
finalCtx!.fillStyle = 'black';
|
|
|
|
|
finalCtx!.fillRect(0, 0, finalImage.width, finalImage.height);
|
|
|
|
|
finalCtx!.globalCompositeOperation = 'destination-out';
|
|
|
|
|
finalCtx!.drawImage(canvas, 0, 0);
|
|
|
|
|
const scaleCanvasSize = useCallback(() => {
|
|
|
|
|
const canvas = canvasRef.current!;
|
|
|
|
|
const offscreenCanvas = offscreenCanvasRef.current!;
|
|
|
|
|
const overlayCanvas = overlayCanvasRef.current!;
|
|
|
|
|
|
|
|
|
|
onSave(finalImage.toDataURL('image/png'));
|
|
|
|
|
}, [onSave]);
|
|
|
|
|
overlayCanvas.width = canvas.width;
|
|
|
|
|
overlayCanvas.height = canvas.height;
|
|
|
|
|
|
|
|
|
|
const downloadMask = useCallback(() => {
|
|
|
|
|
const canvas = overlayCanvasRef.current!;
|
|
|
|
|
offscreenCanvas.width = canvas.width;
|
|
|
|
|
offscreenCanvas.height = canvas.height;
|
|
|
|
|
}, [canvasRef.current, overlayCanvasRef.current, offscreenCanvasRef.current]);
|
|
|
|
|
|
|
|
|
|
const setStrokes = (strokes: Stroke[]) => {
|
|
|
|
|
strokesRef.current = strokes;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scaleLineWidth = useCallback(() => {
|
|
|
|
|
// setLineWidth(lineWidth * autoScale.current);
|
|
|
|
|
}, [lineWidth]);
|
|
|
|
|
|
|
|
|
|
const generateMask = useCallback(() => {
|
|
|
|
|
const overlayCanvas = overlayCanvasRef.current!;
|
|
|
|
|
const maskCanvas = document.createElement('canvas');
|
|
|
|
|
maskCanvas.width = canvas.width;
|
|
|
|
|
maskCanvas.height = canvas.height;
|
|
|
|
|
maskCanvas.width = overlayCanvas.width;
|
|
|
|
|
maskCanvas.height = overlayCanvas.height;
|
|
|
|
|
const maskCtx = maskCanvas.getContext('2d');
|
|
|
|
|
|
|
|
|
|
// set the background to black
|
|
|
|
|
// Create the transparent overlay
|
|
|
|
|
maskCtx!.fillStyle = 'black';
|
|
|
|
|
maskCtx!.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
|
|
|
|
|
|
|
|
maskCtx!.globalCompositeOperation = 'destination-out';
|
|
|
|
|
maskCtx!.drawImage(canvas, 0, 0);
|
|
|
|
|
maskCtx!.drawImage(overlayCanvas, 0, 0);
|
|
|
|
|
|
|
|
|
|
return maskCanvas.toDataURL('image/png');
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const saveMask = useCallback(() => {
|
|
|
|
|
const mask = generateMask();
|
|
|
|
|
onSave(mask);
|
|
|
|
|
}, [onSave, generateMask]);
|
|
|
|
|
|
|
|
|
|
const downloadMask = useCallback(() => {
|
|
|
|
|
const mask = generateMask();
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
link.download = 'mask.png';
|
|
|
|
|
link.href = maskCanvas.toDataURL('image/png');
|
|
|
|
|
link.href = mask;
|
|
|
|
|
link.click();
|
|
|
|
|
}, []);
|
|
|
|
|
}, [generateMask]);
|
|
|
|
|
|
|
|
|
|
const drawStroke = useCallback(
|
|
|
|
|
(
|
|
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
|
stroke: Stroke | Point[],
|
|
|
|
|
options: {
|
|
|
|
|
lineWidth: number;
|
|
|
|
|
color: string;
|
|
|
|
|
compositeOperation: 'source-over' | 'destination-out';
|
|
|
|
|
}
|
|
|
|
|
) => {
|
|
|
|
|
const { lineWidth, color, compositeOperation } = options;
|
|
|
|
|
ctx.lineWidth = lineWidth;
|
|
|
|
|
ctx.lineCap = 'round';
|
|
|
|
|
ctx.lineJoin = 'round';
|
|
|
|
|
ctx.globalCompositeOperation = compositeOperation;
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
|
|
stroke.forEach((point, i) => {
|
|
|
|
|
console.log('Drawing Point:', point);
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
ctx.moveTo(point.x, point.y);
|
|
|
|
|
} else {
|
|
|
|
|
ctx.lineTo(point.x, point.y);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (compositeOperation === 'source-over') {
|
|
|
|
|
ctx.strokeStyle = color;
|
|
|
|
|
}
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
},
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
if (!isDrawing.current || !lastPoint.current || !currentPoint.current)
|
|
|
|
|
return;
|
|
|
|
|
const drawLine = useCallback(
|
|
|
|
|
(
|
|
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
|
point: Point,
|
|
|
|
|
options: {
|
|
|
|
|
lineWidth: number;
|
|
|
|
|
color: string;
|
|
|
|
|
compositeOperation: 'source-over' | 'destination-out';
|
|
|
|
|
}
|
|
|
|
|
) => {
|
|
|
|
|
const { lineWidth, color, compositeOperation } = options;
|
|
|
|
|
|
|
|
|
|
const { offsetX, offsetY } = e.nativeEvent;
|
|
|
|
|
currentPoint.current = { x: offsetX, y: offsetY };
|
|
|
|
|
const ctx = overlayCanvasRef.current!.getContext('2d');
|
|
|
|
|
ctx.lineWidth = lineWidth;
|
|
|
|
|
ctx.lineCap = 'round';
|
|
|
|
|
ctx.lineJoin = 'round';
|
|
|
|
|
ctx.globalCompositeOperation = compositeOperation;
|
|
|
|
|
|
|
|
|
|
ctx!.save(); // 保存当前状态
|
|
|
|
|
ctx.lineTo(point.x, point.y);
|
|
|
|
|
if (compositeOperation === 'source-over') {
|
|
|
|
|
ctx.strokeStyle = color;
|
|
|
|
|
}
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
},
|
|
|
|
|
[lineWidth]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 1. 清除当前路径重叠的部分
|
|
|
|
|
ctx!.globalCompositeOperation = 'destination-out';
|
|
|
|
|
ctx!.lineWidth = lineWidth;
|
|
|
|
|
ctx!.lineCap = 'round';
|
|
|
|
|
ctx!.lineJoin = 'round';
|
|
|
|
|
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
if (!isDrawing.current) return;
|
|
|
|
|
|
|
|
|
|
ctx!.beginPath();
|
|
|
|
|
ctx!.moveTo(lastPoint.current.x, lastPoint.current.y);
|
|
|
|
|
ctx!.lineTo(offsetX, offsetY);
|
|
|
|
|
ctx!.stroke();
|
|
|
|
|
const { x, y } = getTransformedPoint(e);
|
|
|
|
|
console.log('Drawing:', e.nativeEvent, { x, y });
|
|
|
|
|
currentStroke.current.push({
|
|
|
|
|
x,
|
|
|
|
|
y
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. 重新绘制带颜色的路径
|
|
|
|
|
ctx!.globalCompositeOperation = 'source-over';
|
|
|
|
|
ctx!.strokeStyle = COLOR;
|
|
|
|
|
ctx!.beginPath();
|
|
|
|
|
ctx!.moveTo(lastPoint.current.x, lastPoint.current.y);
|
|
|
|
|
ctx!.lineTo(offsetX, offsetY);
|
|
|
|
|
ctx!.stroke();
|
|
|
|
|
const ctx = overlayCanvasRef.current!.getContext('2d');
|
|
|
|
|
|
|
|
|
|
ctx!.save();
|
|
|
|
|
|
|
|
|
|
drawLine(
|
|
|
|
|
ctx!,
|
|
|
|
|
{ x, y },
|
|
|
|
|
{ lineWidth, color: COLOR, compositeOperation: 'destination-out' }
|
|
|
|
|
);
|
|
|
|
|
drawLine(
|
|
|
|
|
ctx!,
|
|
|
|
|
{ x, y },
|
|
|
|
|
{ lineWidth, color: COLOR, compositeOperation: 'source-over' }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ctx!.restore(); // 恢复状态
|
|
|
|
|
// lastPoint.current = { x: offsetX, y: offsetY };
|
|
|
|
|
ctx!.restore();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
isDrawing.current = true;
|
|
|
|
|
lastPoint.current = null;
|
|
|
|
|
currentPoint.current = null;
|
|
|
|
|
const { offsetX, offsetY } = e.nativeEvent;
|
|
|
|
|
console.log('Start Drawing:', { x: offsetX, y: offsetY });
|
|
|
|
|
lastPoint.current = { x: offsetX, y: offsetY };
|
|
|
|
|
currentPoint.current = { x: offsetX, y: offsetY };
|
|
|
|
|
|
|
|
|
|
currentStroke.current = [];
|
|
|
|
|
const { x, y } = getTransformedPoint(e);
|
|
|
|
|
currentStroke.current.push({
|
|
|
|
|
x,
|
|
|
|
|
y
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ctx = overlayCanvasRef.current!.getContext('2d');
|
|
|
|
|
ctx!.beginPath();
|
|
|
|
|
ctx!.moveTo(x, y);
|
|
|
|
|
|
|
|
|
|
draw(e);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const endDrawing = () => {
|
|
|
|
|
console.log('End Drawing:', lastPoint.current);
|
|
|
|
|
if (!lastPoint.current || !currentPoint.current) return;
|
|
|
|
|
const endDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
console.log('End Drawing:', e);
|
|
|
|
|
if (!isDrawing.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isDrawing.current = false;
|
|
|
|
|
const copyLastPoint = { ...lastPoint.current };
|
|
|
|
|
const copyCurrentPoint = { ...currentPoint.current };
|
|
|
|
|
setStrokes((prevStrokes) => [
|
|
|
|
|
...prevStrokes,
|
|
|
|
|
{
|
|
|
|
|
start: { x: copyLastPoint!.x, y: copyLastPoint!.y },
|
|
|
|
|
end: { x: copyCurrentPoint!.x, y: copyCurrentPoint!.y }
|
|
|
|
|
}
|
|
|
|
|
]);
|
|
|
|
|
lastPoint.current = null;
|
|
|
|
|
currentPoint.current = null;
|
|
|
|
|
|
|
|
|
|
generateMask();
|
|
|
|
|
strokesRef.current.push(_.cloneDeep(currentStroke.current));
|
|
|
|
|
|
|
|
|
|
currentStroke.current = [];
|
|
|
|
|
|
|
|
|
|
saveMask();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onClear = () => {
|
|
|
|
|
const clearOverlayCanvas = useCallback(() => {
|
|
|
|
|
const ctx = overlayCanvasRef.current!.getContext('2d');
|
|
|
|
|
ctx!.clearRect(
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
-overlayCanvasRef.current!.width / 2,
|
|
|
|
|
-overlayCanvasRef.current!.height / 2,
|
|
|
|
|
overlayCanvasRef.current!.width,
|
|
|
|
|
overlayCanvasRef.current!.height
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearCanvas = useCallback(() => {
|
|
|
|
|
const canvas = canvasRef.current!;
|
|
|
|
|
const ctx = canvasRef.current!.getContext('2d');
|
|
|
|
|
ctx!.clearRect(
|
|
|
|
|
-canvas.width / 2,
|
|
|
|
|
-canvas.height / 2,
|
|
|
|
|
canvas.width,
|
|
|
|
|
canvas.height
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearOffscreenCanvas = useCallback(() => {
|
|
|
|
|
const offscreenCanvas = offscreenCanvasRef.current!;
|
|
|
|
|
const offscreenCtx = offscreenCanvas.getContext('2d')!;
|
|
|
|
|
|
|
|
|
|
offscreenCtx.clearRect(
|
|
|
|
|
-offscreenCanvas.width / 2,
|
|
|
|
|
-offscreenCanvas.height / 2,
|
|
|
|
|
offscreenCanvas.width,
|
|
|
|
|
offscreenCanvas.height
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const onReset = useCallback(() => {
|
|
|
|
|
clearOverlayCanvas();
|
|
|
|
|
console.log('Resetting strokes');
|
|
|
|
|
setStrokes([]);
|
|
|
|
|
lastPoint.current = null;
|
|
|
|
|
};
|
|
|
|
|
currentStroke.current = [];
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const redrawStrokes = useCallback(
|
|
|
|
|
(strokes: Stroke[], type?: string) => {
|
|
|
|
|
console.log('Redrawing strokes:', strokes, type);
|
|
|
|
|
if (!offscreenCanvasRef.current) {
|
|
|
|
|
createOffscreenCanvas();
|
|
|
|
|
}
|
|
|
|
|
if (!strokes.length) {
|
|
|
|
|
clearOverlayCanvas();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const offscreenCanvas = offscreenCanvasRef.current!;
|
|
|
|
|
const overlayCanvas = overlayCanvasRef.current!;
|
|
|
|
|
const offscreenCtx = offscreenCanvas.getContext('2d')!;
|
|
|
|
|
const overlayCtx = overlayCanvas!.getContext('2d')!;
|
|
|
|
|
|
|
|
|
|
offscreenCanvas.width = overlayCanvas!.width;
|
|
|
|
|
offscreenCanvas.height = overlayCanvas!.height;
|
|
|
|
|
|
|
|
|
|
// clear offscreen canvas
|
|
|
|
|
|
|
|
|
|
clearOverlayCanvas();
|
|
|
|
|
|
|
|
|
|
strokes?.forEach((stroke: Point[], index) => {
|
|
|
|
|
overlayCtx.save();
|
|
|
|
|
drawStroke(overlayCtx, stroke, {
|
|
|
|
|
lineWidth,
|
|
|
|
|
color: COLOR,
|
|
|
|
|
compositeOperation: 'destination-out'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
drawStroke(overlayCtx, stroke, {
|
|
|
|
|
lineWidth,
|
|
|
|
|
color: COLOR,
|
|
|
|
|
compositeOperation: 'source-over'
|
|
|
|
|
});
|
|
|
|
|
overlayCtx.restore();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[lineWidth, drawStroke]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const undo = () => {
|
|
|
|
|
if (strokes.length === 0) return;
|
|
|
|
|
if (strokesRef.current.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const newStrokes = strokes.slice(0, -1);
|
|
|
|
|
const newStrokes = strokesRef.current.slice(0, -1);
|
|
|
|
|
console.log('New strokes:', newStrokes, strokesRef.current);
|
|
|
|
|
setStrokes(newStrokes);
|
|
|
|
|
|
|
|
|
|
const ctx = overlayCanvasRef.current!.getContext('2d');
|
|
|
|
|
ctx!.clearRect(
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
overlayCanvasRef.current!.width,
|
|
|
|
|
overlayCanvasRef.current!.height
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
newStrokes.forEach((stroke) => {
|
|
|
|
|
ctx!.globalCompositeOperation = 'source-over';
|
|
|
|
|
ctx!.strokeStyle = COLOR;
|
|
|
|
|
ctx!.lineWidth = lineWidth;
|
|
|
|
|
ctx!.lineCap = 'round';
|
|
|
|
|
ctx!.lineJoin = 'round';
|
|
|
|
|
|
|
|
|
|
ctx!.beginPath();
|
|
|
|
|
ctx!.moveTo(stroke.start.x, stroke.start.y);
|
|
|
|
|
ctx!.lineTo(stroke.end.x, stroke.end.y);
|
|
|
|
|
ctx!.stroke();
|
|
|
|
|
});
|
|
|
|
|
redrawStrokes(newStrokes);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const download = () => {
|
|
|
|
|
// download the canvasRef as an image
|
|
|
|
|
const canvas = canvasRef.current!;
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
link.download = 'image.png';
|
|
|
|
|
@ -311,7 +377,111 @@ const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
link.remove();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scaleStrokes = (scale: number): Stroke[] => {
|
|
|
|
|
const strokes: Stroke[] = _.cloneDeep(strokesRef.current);
|
|
|
|
|
const newStrokes = strokes.map((stroke) => {
|
|
|
|
|
return stroke.map((point) => {
|
|
|
|
|
return {
|
|
|
|
|
x: point.x * scale,
|
|
|
|
|
y: point.y * scale
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
setStrokes(newStrokes);
|
|
|
|
|
return newStrokes;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const drawImage = useCallback(async () => {
|
|
|
|
|
if (!containerRef.current || !canvasRef.current) return;
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.src = imageSrc;
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
const canvas = canvasRef.current!;
|
|
|
|
|
const ctx = canvas!.getContext('2d');
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
const scale = Math.min(
|
|
|
|
|
container!.offsetWidth / img.width,
|
|
|
|
|
container!.offsetHeight / img.height,
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
canvas!.width = img.width * scale;
|
|
|
|
|
canvas!.height = img.height * scale;
|
|
|
|
|
|
|
|
|
|
autoScale.current = scale / autoScale.current;
|
|
|
|
|
|
|
|
|
|
scaleLineWidth();
|
|
|
|
|
scaleCanvasSize();
|
|
|
|
|
setCanvasCenter();
|
|
|
|
|
|
|
|
|
|
clearCanvas();
|
|
|
|
|
|
|
|
|
|
ctx!.drawImage(
|
|
|
|
|
img,
|
|
|
|
|
-canvas.width / 2,
|
|
|
|
|
-canvas.height / 2,
|
|
|
|
|
canvas!.width,
|
|
|
|
|
canvas!.height
|
|
|
|
|
);
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}, [
|
|
|
|
|
imageSrc,
|
|
|
|
|
containerRef.current,
|
|
|
|
|
canvasRef.current,
|
|
|
|
|
scaleCanvasSize,
|
|
|
|
|
setCanvasCenter,
|
|
|
|
|
scaleLineWidth
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const handleResize = useCallback(
|
|
|
|
|
async (entries: ResizeObserverEntry[]) => {
|
|
|
|
|
const contentRect = entries[0].contentRect;
|
|
|
|
|
if (!contentRect.width || !contentRect.height || !imgLoaded) return;
|
|
|
|
|
await drawImage();
|
|
|
|
|
console.log('Image Loaded:', imageStatus, strokesRef.current);
|
|
|
|
|
if (imageStatus.isOriginal) {
|
|
|
|
|
redrawStrokes(strokesRef.current, 'resize');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[drawImage, scaleStrokes, redrawStrokes, onReset, imageStatus, imgLoaded]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const initializeImage = useCallback(async () => {
|
|
|
|
|
setImgLoaded(false);
|
|
|
|
|
await drawImage();
|
|
|
|
|
setImgLoaded(true);
|
|
|
|
|
console.log('Image Loaded:', imageStatus, strokesRef.current);
|
|
|
|
|
if (imageStatus.isOriginal) {
|
|
|
|
|
redrawStrokes(strokesRef.current, 'initialize');
|
|
|
|
|
} else if (imageStatus.isResetNeeded) {
|
|
|
|
|
onReset();
|
|
|
|
|
}
|
|
|
|
|
}, [drawImage, onReset, redrawStrokes, imageStatus]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
initializeImage();
|
|
|
|
|
}, [initializeImage]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
if (container) {
|
|
|
|
|
resizeObserver.current = new ResizeObserver(
|
|
|
|
|
_.throttle(handleResize, 100)
|
|
|
|
|
);
|
|
|
|
|
resizeObserver.current.observe(container);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
resizeObserver.current?.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [handleResize, containerRef.current]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
createOffscreenCanvas();
|
|
|
|
|
const handleUndoShortcut = (e: KeyboardEvent) => {
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
|
|
|
|
undo();
|
|
|
|
|
@ -322,7 +492,7 @@ const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('keydown', handleUndoShortcut);
|
|
|
|
|
};
|
|
|
|
|
}, [strokes]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="editor-wrapper">
|
|
|
|
|
@ -370,7 +540,7 @@ const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
{uploadButton}
|
|
|
|
|
<Tooltip title="Reset">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={onClear}
|
|
|
|
|
onClick={onReset}
|
|
|
|
|
size="middle"
|
|
|
|
|
type="text"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
@ -382,7 +552,7 @@ const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
<div className="tools">
|
|
|
|
|
<Tooltip title="Save Mask">
|
|
|
|
|
<Button onClick={downloadMask} size="middle" type="text">
|
|
|
|
|
<SaveOutlined className="font-size-14" />
|
|
|
|
|
<IconFont className="font-size-14" type="icon-save1"></IconFont>
|
|
|
|
|
</Button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
<Tooltip title="Download">
|
|
|
|
|
@ -395,7 +565,12 @@ const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
<div
|
|
|
|
|
className="editor-content"
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
style={{ position: 'relative', width: '100%', height: '100%', flex: 1 }}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'relative',
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '100%',
|
|
|
|
|
flex: 1
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<canvas ref={canvasRef} style={{ position: 'absolute', zIndex: 1 }} />
|
|
|
|
|
<canvas
|
|
|
|
|
@ -403,28 +578,29 @@ const CanvasImageEditor: React.FC<CanvasImageEditorProps> = ({
|
|
|
|
|
style={{ position: 'absolute', zIndex: 2 }}
|
|
|
|
|
onMouseDown={startDrawing}
|
|
|
|
|
onMouseUp={endDrawing}
|
|
|
|
|
onMouseEnter={handleMouseEnter}
|
|
|
|
|
onMouseMove={(e) => {
|
|
|
|
|
handleMouseMove(e);
|
|
|
|
|
draw(e);
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
endDrawing();
|
|
|
|
|
handleMouseLeave();
|
|
|
|
|
endDrawing(e);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
ref={cursorRef}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'none',
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
width: lineWidth,
|
|
|
|
|
height: lineWidth,
|
|
|
|
|
backgroundColor: COLOR,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
zIndex: 3
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{cursorVisible && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: cursorPosition?.y,
|
|
|
|
|
left: cursorPosition?.x,
|
|
|
|
|
width: lineWidth,
|
|
|
|
|
height: lineWidth,
|
|
|
|
|
backgroundColor: COLOR,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
zIndex: 3
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|