import { Spin } from 'antd'; import classNames from 'classnames'; import dayjs from 'dayjs'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import useDrawing from './hooks/use-drawing'; import useZoom from './hooks/use-zoom'; import './index.less'; import { ImageActionsBar, ToolsBar } from './tools-bar'; const LoadWrapper = styled.div<{ width?: number; height?: number }>` position: absolute; top: 0; bottom: 0; display: flex; align-items: center; justify-content: center; height: 100%; width: ${(props) => `${props.width}px` || '100%'}; z-index: 100; `; const Loading = (props: { width: number; height: number }) => { const { width, height } = props; return ( ); }; type Point = { x: number; y: number; lineWidth: number }; type Stroke = Point[]; type CanvasImageEditorProps = { ref?: any; loading: boolean; imageSrc: string; disabled?: boolean; imguid: string | number; maskUpload?: any[]; clearUploadMask?: () => void; onSave: (imageData: { mask: string | null; img: string }) => void; onScaleImageSize?: (data: { width: number; height: number }) => void; handleUpdateImageList: (fileList: any[]) => void; handleUpdateMaskList: (fileList: any[]) => void; uploadButton?: React.ReactNode; accept?: string; imageStatus: { isOriginal: boolean; isResetNeeded: boolean; width: number; height: number; }; }; const COLOR = 'rgba(0, 0, 255, 0.3)'; const CanvasImageEditor: React.FC = forwardRef( ( { loading, imageSrc, disabled: isDisabled, imageStatus, maskUpload, accept, onSave, onScaleImageSize, handleUpdateImageList, handleUpdateMaskList }, ref ) => { const invertWorkerRef = useRef(null); const loadMaksWorkerRef = useRef(null); const containerRef = useRef(null); const [lineWidth, setLineWidth] = useState(60); const translatePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const negativeMaskRef = useRef(false); const [invertMask, setInvertMask] = useState(false); const timer = useRef(null); const [loadingSize, setLoadingSize] = useState({ width: 0, height: 0, loading: false }); const { canvasRef, overlayCanvasRef, offscreenCanvasRef, cursorRef, strokesRef, currentStroke, mouseDownState, autoScale, baseScale, maskStorkeRef, isLoadingMaskRef, setMaskStrokes, draw, drawStroke, startDrawing, endDrawing, handleMouseMove, handleMouseLeave, handleMouseEnter, saveImage, resetCanvas, creatOffscreenCanvas, generateMask, getTransformLineWidth, getTransformedPoint, setStrokes, setTransform, clearOverlayCanvas, clearCanvas, fitView } = useDrawing({ lineWidth: lineWidth, translatePos: translatePos, isDisabled: isDisabled, invertMask, maskUpload, onSave: onSave }); const { handleOnWheel, setActiveScale, activeScale } = useZoom({ overlayCanvasRef, canvasRef, offscreenCanvasRef, cursorRef, lineWidth, translatePos, autoScale, baseScale, isLoadingMaskRef }); const disabled = useMemo(() => { return isDisabled || invertMask || !!maskUpload?.length; }, [isDisabled, invertMask, maskUpload]); // update the canvas size const updateCanvasSize = useCallback(() => { const canvas = canvasRef.current!; const overlayCanvas = overlayCanvasRef.current!; const offscreenCanvas = offscreenCanvasRef.current!; overlayCanvas.width = canvas.width; overlayCanvas.height = canvas.height; offscreenCanvas.width = canvas.width; offscreenCanvas.height = canvas.height; }, []); const downloadMask = useCallback(() => { const mask = generateMask(); const link = document.createElement('a'); link.download = `mask_${dayjs().format('YYYYMMDDHHmmss')}.png`; link.href = mask || ''; link.click(); }, [generateMask]); const onReset = useCallback(() => { clearOverlayCanvas(); setStrokes([]); setMaskStrokes([]); saveImage(); currentStroke.current = []; console.log('Resetting strokes', currentStroke.current); }, []); const loadMaskPixs = (maskStrokes: Stroke[], mainStrokes?: Stroke[]) => { try { if (!maskStrokes.length && !mainStrokes?.length) { return; } isLoadingMaskRef.current = true; const offscreenCanvas = new OffscreenCanvas( overlayCanvasRef.current!.width, overlayCanvasRef.current!.height ); if (!loadMaksWorkerRef.current) { loadMaksWorkerRef.current = new Worker( new URL('./offscreen-worker.ts', import.meta.url), { type: 'module' } ); } loadMaksWorkerRef.current!.onmessage = (event: any) => { if (event.data.type === 'done' && event.data.imageData) { console.log('load mask done'); // draw the data to the overlay canvas const ctx = overlayCanvasRef.current!.getContext('2d')!; ctx.putImageData(event.data.imageData, 0, 0); isLoadingMaskRef.current = false; saveImage(); } }; // send offscreen canvas to worker loadMaksWorkerRef.current?.postMessage( { canvas: offscreenCanvas, type: 'init' }, [offscreenCanvas] ); // send draw data to worker loadMaksWorkerRef.current?.postMessage({ type: 'draw', maskStrokes: maskStrokes, strokes: mainStrokes || [] }); } catch (error) { console.log('error---', error); } }; const redrawStrokes = async (strokes: Stroke[]) => { if (!strokes.length && !maskStorkeRef.current.length) { clearOverlayCanvas(); return; } loadMaskPixs(maskStorkeRef.current, strokes); }; const undo = useCallback(() => { if ( strokesRef.current.length === 0 && maskStorkeRef.current.length === 0 ) { clearOverlayCanvas(); return; } const lastlength = strokesRef.current.length; const newStrokes = strokesRef.current.slice(0, -1); setStrokes(newStrokes); if ( !newStrokes.length && lastlength === 0 && maskStorkeRef.current.length ) { setMaskStrokes([]); } clearTimeout(timer.current); timer.current = setTimeout(() => { redrawStrokes(newStrokes); }, 100); }, []); const downloadOriginImage = () => { const canvas = canvasRef.current!; const link = document.createElement('a'); link.download = `image_${dayjs().format('YYYYMMDDHHmmss')}.png`; link.href = canvas.toDataURL('image/png'); link.click(); link.remove(); }; const downloadNewImage = () => { if (!imageSrc) return; const img = new Image(); img.src = imageSrc; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = imageStatus.width; canvas.height = imageStatus.height; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const url = canvas.toDataURL('image/png'); const filename = `${canvas.width}x${canvas.height}_${dayjs().format('YYYYMMDDHHmmss')}.png`; const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); }; }; const download = () => { if (imageStatus.isOriginal) { downloadOriginImage(); } else { downloadNewImage(); } }; const drawImage = useCallback(async () => { if (!containerRef.current || !canvasRef.current) return; return new Promise((resolve) => { const img = new Image(); img.src = imageSrc; img.onload = () => { const canvas = canvasRef.current!; const ctx = canvas!.getContext('2d'); const container = containerRef.current; baseScale.current = Math.min( container!.offsetWidth / img.width, container!.offsetHeight / img.height, 1 ); // if need to fit the image to the container, show * baseScale.current canvas!.width = img.width; canvas!.height = img.height; // fit the image to the container autoScale.current = 1; creatOffscreenCanvas(); updateCanvasSize(); clearCanvas(); ctx!.drawImage(img, 0, 0, canvas!.width, canvas!.height); resolve(); }; }); }, [imageSrc]); const invertPainting = (isChecked: boolean) => { const ctx = overlayCanvasRef.current!.getContext('2d'); if (!ctx) return; if (isChecked) { const canvasWidth = overlayCanvasRef.current!.width; const canvasHeight = overlayCanvasRef.current!.height; clearOverlayCanvas(); const offscreenCanvas = new OffscreenCanvas( overlayCanvasRef.current!.width, overlayCanvasRef.current!.height ); if (!invertWorkerRef.current) { invertWorkerRef.current = new Worker( new URL('./invert-worker.ts', import.meta.url), { type: 'module' } ); } invertWorkerRef.current.onmessage = (event: any) => { if (event.data.type === 'done' && event.data.imageData) { // draw the data to the overlay canvas const ctx = overlayCanvasRef.current!.getContext('2d')!; ctx.putImageData(event.data.imageData, 0, 0); isLoadingMaskRef.current = false; saveImage(); } }; // send offscreen canvas to worker invertWorkerRef.current?.postMessage( { canvas: offscreenCanvas, type: 'init' }, [offscreenCanvas] ); // send draw data to worker const imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight); invertWorkerRef.current?.postMessage({ type: 'draw', width: imageData.width, height: imageData.height, strokes: strokesRef.current, maskStrokes: maskStorkeRef.current }); } else { redrawStrokes(strokesRef.current); } }; const updateCursorSize = () => { cursorRef.current!.style.width = `${lineWidth * autoScale.current}px`; cursorRef.current!.style.height = `${lineWidth * autoScale.current}px`; }; const initializeImage = useCallback(async () => { await drawImage(); onScaleImageSize?.({ width: canvasRef.current!.width, height: canvasRef.current!.height }); if (imageStatus.isResetNeeded) { onReset(); resetCanvas(); } else if ( (strokesRef.current.length || maskStorkeRef.current.length) && imageStatus.isOriginal && !negativeMaskRef.current ) { redrawStrokes(strokesRef.current); } else if ( (strokesRef.current.length || maskStorkeRef.current.length) && imageStatus.isOriginal && negativeMaskRef.current ) { invertPainting(true); } console.log('Image status:', imageStatus, negativeMaskRef.current); updateCursorSize(); }, [drawImage, imageStatus.isOriginal, imageStatus.isResetNeeded]); const handleFitView = () => { fitView(); setActiveScale(autoScale.current); updateCursorSize(); redrawStrokes(strokesRef.current); }; const handleBrushSizeChange = (value: number) => { setLineWidth(value); cursorRef.current!.style.width = `${value}px`; cursorRef.current!.style.height = `${value}px`; }; const handleOnChangeMask = (e: any) => { negativeMaskRef.current = e.target.checked; invertPainting(e.target.checked); setInvertMask(e.target.checked); }; useEffect(() => { initializeImage(); }, [initializeImage]); useEffect(() => { const handleUndoShortcut = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z') { undo(); } }; const handleMouseDown = (e: MouseEvent) => { mouseDownState.current = true; }; const handleMouseUp = (e: MouseEvent) => { mouseDownState.current = false; }; window.addEventListener('keydown', handleUndoShortcut); // mouse down window.addEventListener('mousedown', handleMouseDown); // mouse up window.addEventListener('mouseup', handleMouseUp); return () => { clearTimeout(timer.current); window.removeEventListener('keydown', handleUndoShortcut); window.removeEventListener('mousedown', handleMouseDown); window.removeEventListener('mouseup', handleMouseUp); }; }, []); const handleDeleteMask = () => { setInvertMask(false); redrawStrokes(strokesRef.current); saveImage(); }; useImperativeHandle(ref, () => ({ clearMask: handleDeleteMask, loadMaskPixs: async function (strokes: Stroke[]) { setMaskStrokes(strokes); setStrokes([]); setTransform(); clearOverlayCanvas(); loadMaskPixs(strokes); } })); useEffect(() => { invertWorkerRef.current = new Worker( new URL('./invert-worker.ts', import.meta.url), { type: 'module' } ); loadMaksWorkerRef.current = new Worker( new URL('./offscreen-worker.ts', import.meta.url), { type: 'module' } ); return () => { invertWorkerRef.current?.terminate(); loadMaksWorkerRef.current?.terminate(); }; }, []); useEffect(() => { if (maskUpload?.length) { // clear the overlay canvas clearOverlayCanvas(); } }, [maskUpload]); useEffect(() => { if (disabled && cursorRef.current) { cursorRef.current!.style.display = 'none'; } }, [disabled]); return ( {loadingSize.loading && ( )} { if (isLoadingMaskRef.current) { return; } mouseDownState.current = true; startDrawing(event); }} onMouseUp={(event) => { if (isLoadingMaskRef.current) { return; } mouseDownState.current = false; endDrawing(event); }} onMouseEnter={handleMouseEnter} onWheel={handleOnWheel} onMouseMove={(e) => { if (isLoadingMaskRef.current) { return; } handleMouseMove(e); draw(e); }} onMouseLeave={(e) => { if (isLoadingMaskRef.current) { return; } endDrawing(e); handleMouseLeave(); }} /> ); } ); export default React.memo(CanvasImageEditor);