|
import { useRef, useEffect, useState, forwardRef, useImperativeHandle, useCallback } from 'react'; |
|
import { |
|
getCoordinates, |
|
drawBezierCurve, |
|
drawBezierGuides, |
|
createAnchorPoint, |
|
isNearHandle, |
|
updateHandle |
|
} from './utils/canvasUtils'; |
|
import { PencilLine, Upload, ImagePlus, LoaderCircle, Brush, AlertCircle } from 'lucide-react'; |
|
import ToolBar from './ToolBar'; |
|
import StyleSelector from './StyleSelector'; |
|
|
|
const Canvas = forwardRef(({ |
|
canvasRef, |
|
currentTool, |
|
isDrawing, |
|
startDrawing, |
|
draw, |
|
stopDrawing, |
|
handleCanvasClick, |
|
handlePenClick, |
|
handleGeneration, |
|
tempPoints, |
|
setTempPoints, |
|
handleUndo, |
|
clearCanvas, |
|
setCurrentTool, |
|
currentDimension, |
|
onImageUpload, |
|
onGenerate, |
|
isGenerating, |
|
setIsGenerating, |
|
currentColor, |
|
currentWidth, |
|
handleStrokeWidth, |
|
saveCanvasState, |
|
onDrawingChange, |
|
styleMode, |
|
setStyleMode, |
|
isSendingToDoodle, |
|
customApiKey, |
|
onOpenApiKeyModal, |
|
}, ref) => { |
|
const [showBezierGuides, setShowBezierGuides] = useState(true); |
|
const [activePoint, setActivePoint] = useState(-1); |
|
const [activeHandle, setActiveHandle] = useState(null); |
|
const [symmetric, setSymmetric] = useState(true); |
|
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }); |
|
const [hasDrawing, setHasDrawing] = useState(false); |
|
const [strokeCount, setStrokeCount] = useState(0); |
|
const fileInputRef = useRef(null); |
|
const [shapeStartPos, setShapeStartPos] = useState(null); |
|
const [previewCanvas, setPreviewCanvas] = useState(null); |
|
const [isDoodleConverting, setIsDoodleConverting] = useState(false); |
|
const [doodleError, setDoodleError] = useState(null); |
|
const [uploadedImages, setUploadedImages] = useState([]); |
|
const [draggingImage, setDraggingImage] = useState(null); |
|
const [resizingImage, setResizingImage] = useState(null); |
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); |
|
const [isDraggingFile, setIsDraggingFile] = useState(false); |
|
const canvasContainerRef = useRef(null); |
|
|
|
|
|
const prevStyleModeRef = useRef(styleMode); |
|
|
|
|
|
const handleGenerationRef = useRef(handleGeneration); |
|
useEffect(() => { |
|
handleGenerationRef.current = handleGeneration; |
|
}, [handleGeneration]); |
|
|
|
|
|
useEffect(() => { |
|
|
|
if (prevStyleModeRef.current === styleMode) { |
|
return; |
|
} |
|
|
|
|
|
prevStyleModeRef.current = styleMode; |
|
|
|
|
|
if (typeof handleGenerationRef.current === 'function') { |
|
handleGenerationRef.current(); |
|
} |
|
}, [styleMode]); |
|
|
|
|
|
useEffect(() => { |
|
|
|
const preventTouchDefault = (e) => { |
|
if (isDrawing) { |
|
e.preventDefault(); |
|
} |
|
}; |
|
|
|
|
|
const canvas = canvasRef.current; |
|
if (canvas) { |
|
canvas.addEventListener('touchstart', preventTouchDefault, { passive: false }); |
|
canvas.addEventListener('touchmove', preventTouchDefault, { passive: false }); |
|
} |
|
|
|
|
|
return () => { |
|
if (canvas) { |
|
canvas.removeEventListener('touchstart', preventTouchDefault); |
|
canvas.removeEventListener('touchmove', preventTouchDefault); |
|
} |
|
}; |
|
}, [isDrawing, canvasRef]); |
|
|
|
|
|
useEffect(() => { |
|
console.log('Canvas tool changed or isDrawing changed:', { currentTool, isDrawing }); |
|
}, [currentTool, isDrawing]); |
|
|
|
|
|
useEffect(() => { |
|
if (uploadedImages.length > 0) { |
|
renderCanvas(); |
|
} |
|
}, [uploadedImages]); |
|
|
|
|
|
useEffect(() => { |
|
if (currentTool === 'pen' && tempPoints.length > 0 && showBezierGuides) { |
|
redrawBezierGuides(); |
|
} |
|
}, [tempPoints, showBezierGuides, currentTool]); |
|
|
|
|
|
useEffect(() => { |
|
const canvas = canvasRef.current; |
|
if (!canvas) return; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); |
|
|
|
|
|
const hasNonWhitePixels = Array.from(imageData.data).some((pixel, index) => { |
|
|
|
return index % 4 !== 3 && pixel !== 255; |
|
}); |
|
|
|
setHasDrawing(hasNonWhitePixels); |
|
}, [canvasRef]); |
|
|
|
|
|
useEffect(() => { |
|
|
|
if (isDoodleConverting) { |
|
setHasDrawing(true); |
|
} |
|
}, [isDoodleConverting]); |
|
|
|
|
|
const handleFileChangeRef = useRef(null); |
|
|
|
|
|
const clearDoodleError = useCallback(() => { |
|
setDoodleError(null); |
|
}, []); |
|
|
|
|
|
const handleFileChange = useCallback(async (event) => { |
|
const file = event.target.files?.[0]; |
|
if (!file) return; |
|
|
|
|
|
const previousTool = currentTool; |
|
|
|
|
|
if (typeof onDrawingChange === 'function') { |
|
onDrawingChange(true); |
|
} |
|
|
|
|
|
setDoodleError(null); |
|
|
|
|
|
setIsDoodleConverting(true); |
|
|
|
const reader = new FileReader(); |
|
reader.onload = async (e) => { |
|
const imageDataUrl = e.target.result; |
|
|
|
try { |
|
|
|
const compressedImage = await compressImage(imageDataUrl); |
|
|
|
const response = await fetch('/api/convert-to-doodle', { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
}, |
|
body: JSON.stringify({ |
|
imageData: compressedImage.split(",")[1], |
|
customApiKey, |
|
}), |
|
}); |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (!response.ok) { |
|
let errorMessage = data.error || `Server error (${response.status})`; |
|
|
|
|
|
if (data.retries !== undefined) { |
|
errorMessage += `. Failed after ${data.retries + 1} attempts.`; |
|
} |
|
|
|
|
|
if (errorMessage.includes('overloaded') || errorMessage.includes('503')) { |
|
errorMessage = "The model is overloaded. Please try again later."; |
|
} else if (errorMessage.includes('quota') || errorMessage.includes('API key')) { |
|
errorMessage = "API quota exceeded or invalid API key."; |
|
} |
|
|
|
throw new Error(errorMessage); |
|
} |
|
|
|
|
|
if (!data.success) { |
|
let errorMessage = data.error || "Failed to convert image to doodle"; |
|
|
|
|
|
if (data.retries !== undefined) { |
|
errorMessage += `. Failed after ${data.retries + 1} attempts.`; |
|
} |
|
|
|
throw new Error(errorMessage); |
|
} |
|
|
|
|
|
if (!data.imageData) { |
|
throw new Error("No image data received from the server"); |
|
} |
|
|
|
|
|
const img = new Image(); |
|
img.onload = () => { |
|
const ctx = canvasRef.current.getContext('2d'); |
|
|
|
|
|
ctx.fillStyle = '#FFFFFF'; |
|
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); |
|
|
|
|
|
const scale = Math.min( |
|
canvasRef.current.width / img.width, |
|
canvasRef.current.height / img.height |
|
); |
|
const x = (canvasRef.current.width - img.width * scale) / 2; |
|
const y = (canvasRef.current.height - img.height * scale) / 2; |
|
|
|
|
|
ctx.drawImage(img, x, y, img.width * scale, img.height * scale); |
|
|
|
|
|
saveCanvasState(); |
|
|
|
|
|
setIsDoodleConverting(false); |
|
|
|
|
|
if (typeof onDrawingChange === 'function') { |
|
onDrawingChange(true); |
|
} |
|
|
|
|
|
handleGenerationRef.current(); |
|
}; |
|
|
|
img.src = `data:image/png;base64,${data.imageData}`; |
|
} catch (error) { |
|
console.error('Error processing image:', error); |
|
|
|
|
|
setDoodleError(error.message || "Failed to convert image. Please try again."); |
|
|
|
|
|
setTimeout(() => { |
|
setDoodleError(null); |
|
}, 5000); |
|
|
|
|
|
setIsDoodleConverting(false); |
|
|
|
|
|
setCurrentTool(previousTool); |
|
} |
|
}; |
|
|
|
reader.readAsDataURL(file); |
|
}, [canvasRef, currentTool, onDrawingChange, saveCanvasState, setCurrentTool, customApiKey]); |
|
|
|
|
|
useEffect(() => { |
|
handleFileChangeRef.current = handleFileChange; |
|
}, [handleFileChange]); |
|
|
|
|
|
useEffect(() => { |
|
const container = canvasContainerRef.current; |
|
if (!container) return; |
|
|
|
const handleDragEnter = (e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
setIsDraggingFile(true); |
|
}; |
|
|
|
const handleDragOver = (e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
if (!isDraggingFile) setIsDraggingFile(true); |
|
}; |
|
|
|
const handleDragLeave = (e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
|
|
|
|
if (e.currentTarget === container && !container.contains(e.relatedTarget)) { |
|
setIsDraggingFile(false); |
|
} |
|
}; |
|
|
|
const handleDrop = (e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
setIsDraggingFile(false); |
|
|
|
const files = e.dataTransfer.files; |
|
if (files.length > 0) { |
|
const file = files[0]; |
|
|
|
|
|
if (file.type.startsWith('image/')) { |
|
|
|
const fakeEvent = { target: { files: [file] } }; |
|
if (handleFileChangeRef.current) { |
|
handleFileChangeRef.current(fakeEvent); |
|
} |
|
} |
|
} |
|
}; |
|
|
|
container.addEventListener('dragenter', handleDragEnter); |
|
container.addEventListener('dragover', handleDragOver); |
|
container.addEventListener('dragleave', handleDragLeave); |
|
container.addEventListener('drop', handleDrop); |
|
|
|
return () => { |
|
container.removeEventListener('dragenter', handleDragEnter); |
|
container.removeEventListener('dragover', handleDragOver); |
|
container.removeEventListener('dragleave', handleDragLeave); |
|
container.removeEventListener('drop', handleDrop); |
|
}; |
|
}, [isDraggingFile]); |
|
|
|
const handleKeyDown = (e) => { |
|
|
|
if (e.key === 'Enter' || e.key === ' ') { |
|
handleCanvasClick(e); |
|
} |
|
|
|
|
|
if (e.key === 'Shift') { |
|
setSymmetric(!symmetric); |
|
} |
|
}; |
|
|
|
|
|
const redrawBezierGuides = () => { |
|
const canvas = canvasRef.current; |
|
if (!canvas) return; |
|
|
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
const canvasImage = new Image(); |
|
canvasImage.src = canvas.toDataURL(); |
|
|
|
canvasImage.onload = () => { |
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
|
|
|
ctx.drawImage(canvasImage, 0, 0); |
|
|
|
|
|
drawBezierGuides(ctx, tempPoints); |
|
}; |
|
}; |
|
|
|
|
|
const drawStar = (ctx, x, y, radius, points = 5) => { |
|
ctx.beginPath(); |
|
for (let i = 0; i <= points * 2; i++) { |
|
const r = i % 2 === 0 ? radius : radius / 2; |
|
const angle = (i * Math.PI) / points; |
|
const xPos = x + r * Math.sin(angle); |
|
const yPos = y + r * Math.cos(angle); |
|
if (i === 0) ctx.moveTo(xPos, yPos); |
|
else ctx.lineTo(xPos, yPos); |
|
} |
|
ctx.closePath(); |
|
}; |
|
|
|
|
|
const drawShape = (ctx, startPos, endPos, shape, isPreview = false) => { |
|
if (!startPos || !endPos) return; |
|
|
|
const width = endPos.x - startPos.x; |
|
const height = endPos.y - startPos.y; |
|
const radius = Math.sqrt(width * width + height * height) / 2; |
|
|
|
ctx.strokeStyle = currentColor || '#000000'; |
|
ctx.fillStyle = currentColor || '#000000'; |
|
ctx.lineWidth = currentWidth || 2; |
|
|
|
switch (shape) { |
|
case 'rect': |
|
if (isPreview) { |
|
ctx.strokeRect(startPos.x, startPos.y, width, height); |
|
} else { |
|
ctx.fillRect(startPos.x, startPos.y, width, height); |
|
} |
|
break; |
|
case 'circle': |
|
ctx.beginPath(); |
|
ctx.ellipse( |
|
startPos.x + width / 2, |
|
startPos.y + height / 2, |
|
Math.abs(width / 2), |
|
Math.abs(height / 2), |
|
0, |
|
0, |
|
2 * Math.PI |
|
); |
|
if (isPreview) { |
|
ctx.stroke(); |
|
} else { |
|
ctx.fill(); |
|
} |
|
break; |
|
case 'line': |
|
ctx.beginPath(); |
|
ctx.lineCap = 'round'; |
|
ctx.lineWidth = currentWidth * 2 || 4; |
|
ctx.moveTo(startPos.x, startPos.y); |
|
ctx.lineTo(endPos.x, endPos.y); |
|
ctx.stroke(); |
|
break; |
|
case 'star': { |
|
const centerX = startPos.x + width / 2; |
|
const centerY = startPos.y + height / 2; |
|
drawStar(ctx, centerX, centerY, radius); |
|
if (isPreview) { |
|
ctx.stroke(); |
|
} else { |
|
ctx.fill(); |
|
} |
|
break; |
|
} |
|
} |
|
}; |
|
|
|
|
|
const renderCanvas = useCallback(() => { |
|
const canvas = canvasRef.current; |
|
if (!canvas) return; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
const tempCanvas = document.createElement('canvas'); |
|
tempCanvas.width = canvas.width; |
|
tempCanvas.height = canvas.height; |
|
const tempCtx = tempCanvas.getContext('2d'); |
|
tempCtx.drawImage(canvas, 0, 0); |
|
|
|
|
|
ctx.fillStyle = '#FFFFFF'; |
|
ctx.fillRect(0, 0, canvas.width, canvas.height); |
|
|
|
|
|
ctx.drawImage(tempCanvas, 0, 0); |
|
|
|
|
|
for (const img of uploadedImages) { |
|
const imageObj = new Image(); |
|
imageObj.src = img.src; |
|
ctx.drawImage(imageObj, img.x, img.y, img.width, img.height); |
|
|
|
|
|
if (draggingImage === img.id || resizingImage === img.id) { |
|
|
|
ctx.strokeStyle = '#0080ff'; |
|
ctx.lineWidth = 2; |
|
ctx.strokeRect(img.x, img.y, img.width, img.height); |
|
|
|
|
|
ctx.fillStyle = '#0080ff'; |
|
const handleSize = 8; |
|
|
|
|
|
ctx.fillRect(img.x - handleSize/2, img.y - handleSize/2, handleSize, handleSize); |
|
|
|
ctx.fillRect(img.x + img.width - handleSize/2, img.y - handleSize/2, handleSize, handleSize); |
|
|
|
ctx.fillRect(img.x - handleSize/2, img.y + img.height - handleSize/2, handleSize, handleSize); |
|
|
|
ctx.fillRect(img.x + img.width - handleSize/2, img.y + img.height - handleSize/2, handleSize, handleSize); |
|
} |
|
} |
|
}, [canvasRef, uploadedImages, draggingImage, resizingImage]); |
|
|
|
|
|
const handleImageMouseDown = (e) => { |
|
if (currentTool !== 'selection') return false; |
|
|
|
const { x, y } = getCoordinates(e, canvasRef.current); |
|
const handleSize = 8; |
|
|
|
|
|
for (let i = uploadedImages.length - 1; i >= 0; i--) { |
|
const img = uploadedImages[i]; |
|
|
|
|
|
if ( |
|
x >= img.x + img.width - handleSize/2 - 5 && |
|
x <= img.x + img.width + handleSize/2 + 5 && |
|
y >= img.y + img.height - handleSize/2 - 5 && |
|
y <= img.y + img.height + handleSize/2 + 5 |
|
) { |
|
setResizingImage(img.id); |
|
setDragOffset({ x: x - (img.x + img.width), y: y - (img.y + img.height) }); |
|
return true; |
|
} |
|
} |
|
|
|
|
|
for (let i = uploadedImages.length - 1; i >= 0; i--) { |
|
const img = uploadedImages[i]; |
|
if ( |
|
x >= img.x && |
|
x <= img.x + img.width && |
|
y >= img.y && |
|
y <= img.y + img.height |
|
) { |
|
setDraggingImage(img.id); |
|
setDragOffset({ x: x - img.x, y: y - img.y }); |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
}; |
|
|
|
|
|
const handleImageMouseMove = (e) => { |
|
if (!draggingImage && !resizingImage) return false; |
|
|
|
const { x, y } = getCoordinates(e, canvasRef.current); |
|
|
|
if (draggingImage) { |
|
|
|
setUploadedImages(prev => prev.map(img => { |
|
if (img.id === draggingImage) { |
|
return { |
|
...img, |
|
x: x - dragOffset.x, |
|
y: y - dragOffset.y |
|
}; |
|
} |
|
return img; |
|
})); |
|
|
|
renderCanvas(); |
|
return true; |
|
} |
|
|
|
if (resizingImage) { |
|
|
|
setUploadedImages(prev => prev.map(img => { |
|
if (img.id === resizingImage) { |
|
|
|
const newWidth = Math.max(20, x - img.x - dragOffset.x + 10); |
|
const newHeight = Math.max(20, y - img.y - dragOffset.y + 10); |
|
|
|
|
|
return { |
|
...img, |
|
width: newWidth, |
|
height: newHeight |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
return img; |
|
})); |
|
|
|
renderCanvas(); |
|
return true; |
|
} |
|
|
|
return false; |
|
}; |
|
|
|
|
|
const handleImageMouseUp = () => { |
|
if (draggingImage || resizingImage) { |
|
setDraggingImage(null); |
|
setResizingImage(null); |
|
saveCanvasState(); |
|
return true; |
|
} |
|
return false; |
|
}; |
|
|
|
|
|
const deleteSelectedImage = useCallback(() => { |
|
if (draggingImage) { |
|
setUploadedImages(prev => prev.filter(img => img.id !== draggingImage)); |
|
setDraggingImage(null); |
|
renderCanvas(); |
|
saveCanvasState(); |
|
} |
|
}, [draggingImage, renderCanvas, saveCanvasState]); |
|
|
|
|
|
const handleStartDrawing = (e) => { |
|
console.log('Canvas onMouseDown', { currentTool, isDrawing }); |
|
|
|
|
|
if (handleImageMouseDown(e)) { |
|
return; |
|
} |
|
|
|
if (currentTool === 'pen') { |
|
if (!checkForPointOrHandle(e)) { |
|
handlePenToolClick(e); |
|
} |
|
return; |
|
} |
|
|
|
const { x, y } = getCoordinates(e, canvasRef.current); |
|
|
|
if (['rect', 'circle', 'line', 'star'].includes(currentTool)) { |
|
setShapeStartPos({ x, y }); |
|
|
|
|
|
if (!previewCanvas) { |
|
const canvas = document.createElement('canvas'); |
|
canvas.width = canvasRef.current.width; |
|
canvas.height = canvasRef.current.height; |
|
setPreviewCanvas(canvas); |
|
} |
|
} |
|
|
|
startDrawing(e); |
|
setHasDrawing(true); |
|
}; |
|
|
|
|
|
const handleDraw = (e) => { |
|
|
|
if (handleImageMouseMove(e)) { |
|
return; |
|
} |
|
|
|
if (currentTool === 'pen' && handleBezierMouseMove(e)) { |
|
return; |
|
} |
|
|
|
if (!isDrawing) return; |
|
|
|
const canvas = canvasRef.current; |
|
const { x, y } = getCoordinates(e, canvas); |
|
|
|
draw(e); |
|
}; |
|
|
|
|
|
const handleStopDrawing = (e) => { |
|
|
|
if (handleImageMouseUp()) { |
|
return; |
|
} |
|
|
|
console.log('handleStopDrawing called', { |
|
eventType: e?.type, |
|
currentTool, |
|
isDrawing, |
|
activePoint, |
|
activeHandle |
|
}); |
|
|
|
|
|
if (currentTool === 'pen') { |
|
|
|
if (activeHandle) { |
|
setActiveHandle(null); |
|
return; |
|
} |
|
|
|
|
|
if (activePoint !== -1) { |
|
setActivePoint(-1); |
|
return; |
|
} |
|
} |
|
|
|
stopDrawing(e); |
|
|
|
|
|
if (currentTool === 'pencil' && isDrawing && !isGenerating) { |
|
console.log(`${currentTool} tool condition met, will try to trigger generation`); |
|
|
|
|
|
if (typeof setIsGenerating === 'function') { |
|
setIsGenerating(true); |
|
} |
|
|
|
|
|
console.log('Calling handleGeneration function'); |
|
if (typeof handleGenerationRef.current === 'function') { |
|
handleGenerationRef.current(); |
|
} else { |
|
console.error('handleGeneration is not a function:', handleGenerationRef.current); |
|
} |
|
} else { |
|
console.log('Generation not triggered because:', { |
|
isPencilTool: currentTool === 'pencil', |
|
wasDrawing: isDrawing, |
|
isGenerating |
|
}); |
|
} |
|
}; |
|
|
|
|
|
useEffect(() => { |
|
const handleKeyDown = (e) => { |
|
if ((e.key === 'Delete' || e.key === 'Backspace') && draggingImage) { |
|
deleteSelectedImage(); |
|
} |
|
}; |
|
|
|
window.addEventListener('keydown', handleKeyDown); |
|
return () => { |
|
window.removeEventListener('keydown', handleKeyDown); |
|
}; |
|
}, [draggingImage, deleteSelectedImage]); |
|
|
|
|
|
const checkForPointOrHandle = (e) => { |
|
if (currentTool !== 'pen' || !showBezierGuides || tempPoints.length === 0) { |
|
return false; |
|
} |
|
|
|
const canvas = canvasRef.current; |
|
const { x, y } = getCoordinates(e, canvas); |
|
setLastMousePos({ x, y }); |
|
|
|
|
|
for (let i = 0; i < tempPoints.length; i++) { |
|
const point = tempPoints[i]; |
|
|
|
|
|
if (isNearHandle(point, 'handleIn', x, y)) { |
|
setActivePoint(i); |
|
setActiveHandle('handleIn'); |
|
return true; |
|
} |
|
|
|
|
|
if (isNearHandle(point, 'handleOut', x, y)) { |
|
setActivePoint(i); |
|
setActiveHandle('handleOut'); |
|
return true; |
|
} |
|
|
|
|
|
const distance = Math.sqrt((point.x - x) ** 2 + (point.y - y) ** 2); |
|
if (distance <= 10) { |
|
setActivePoint(i); |
|
setActiveHandle(null); |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
}; |
|
|
|
|
|
const handleBezierMouseMove = (e) => { |
|
if (currentTool !== 'pen') { |
|
return false; |
|
} |
|
|
|
const canvas = canvasRef.current; |
|
const { x, y } = getCoordinates(e, canvas); |
|
const dx = x - lastMousePos.x; |
|
const dy = y - lastMousePos.y; |
|
|
|
|
|
if (activePoint !== -1 && activeHandle) { |
|
const newPoints = [...tempPoints]; |
|
updateHandle(newPoints[activePoint], activeHandle, dx, dy, symmetric); |
|
setTempPoints(newPoints); |
|
setLastMousePos({ x, y }); |
|
return true; |
|
} |
|
|
|
|
|
if (activePoint !== -1) { |
|
const newPoints = [...tempPoints]; |
|
newPoints[activePoint].x += dx; |
|
newPoints[activePoint].y += dy; |
|
|
|
|
|
if (newPoints[activePoint].handleIn) { |
|
|
|
} |
|
|
|
if (newPoints[activePoint].handleOut) { |
|
|
|
} |
|
|
|
setTempPoints(newPoints); |
|
setLastMousePos({ x, y }); |
|
return true; |
|
} |
|
|
|
return false; |
|
}; |
|
|
|
|
|
const handlePenToolClick = (e) => { |
|
const canvas = canvasRef.current; |
|
const { x, y } = getCoordinates(e, canvas); |
|
|
|
|
|
if (tempPoints.length === 0) { |
|
|
|
const newPoint = { x, y, handleIn: null, handleOut: null }; |
|
setTempPoints([newPoint]); |
|
} else { |
|
|
|
const newPoint = createAnchorPoint(x, y, tempPoints[tempPoints.length - 1]); |
|
setTempPoints([...tempPoints, newPoint]); |
|
} |
|
|
|
|
|
setShowBezierGuides(true); |
|
}; |
|
|
|
|
|
const toggleBezierGuides = () => { |
|
setShowBezierGuides(!showBezierGuides); |
|
if (showBezierGuides) { |
|
redrawBezierGuides(); |
|
} |
|
}; |
|
|
|
|
|
const finalizeBezierCurve = () => { |
|
if (tempPoints.length < 2) { |
|
|
|
console.log('Need at least 2 control points to draw a path'); |
|
return; |
|
} |
|
|
|
const canvas = canvasRef.current; |
|
|
|
|
|
drawBezierCurve(canvas, tempPoints); |
|
|
|
|
|
setShowBezierGuides(false); |
|
setTempPoints([]); |
|
|
|
|
|
if (!isGenerating) { |
|
|
|
if (typeof setIsGenerating === 'function') { |
|
setIsGenerating(true); |
|
} |
|
|
|
if (typeof handleGenerationRef.current === 'function') { |
|
handleGenerationRef.current(); |
|
} |
|
} |
|
}; |
|
|
|
|
|
const addControlPoint = (e) => { |
|
if (currentTool !== 'pen' || tempPoints.length < 2) return; |
|
|
|
const canvas = canvasRef.current; |
|
const { x, y } = getCoordinates(e, canvas); |
|
|
|
|
|
let closestDistance = Number.POSITIVE_INFINITY; |
|
let insertIndex = -1; |
|
|
|
for (let i = 0; i < tempPoints.length - 1; i++) { |
|
const p1 = tempPoints[i]; |
|
const p2 = tempPoints[i + 1]; |
|
|
|
|
|
|
|
const lineLength = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); |
|
if (lineLength === 0) continue; |
|
|
|
|
|
const t = ((x - p1.x) * (p2.x - p1.x) + (y - p1.y) * (p2.y - p1.y)) / (lineLength * lineLength); |
|
|
|
|
|
if (t < 0 || t > 1) continue; |
|
|
|
|
|
const closestX = p1.x + t * (p2.x - p1.x); |
|
const closestY = p1.y + t * (p2.y - p1.y); |
|
|
|
|
|
const distance = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2); |
|
|
|
if (distance < closestDistance && distance < 20) { |
|
closestDistance = distance; |
|
insertIndex = i + 1; |
|
} |
|
} |
|
|
|
if (insertIndex > 0) { |
|
|
|
const newPoints = [...tempPoints]; |
|
const prevPoint = newPoints[insertIndex - 1]; |
|
const nextPoint = newPoints[insertIndex]; |
|
|
|
|
|
const newPoint = { |
|
x, |
|
y, |
|
|
|
handleIn: { |
|
x: (prevPoint.x - x) * 0.25, |
|
y: (prevPoint.y - y) * 0.25 |
|
}, |
|
handleOut: { |
|
x: (nextPoint.x - x) * 0.25, |
|
y: (nextPoint.y - y) * 0.25 |
|
} |
|
}; |
|
|
|
|
|
newPoints.splice(insertIndex, 0, newPoint); |
|
setTempPoints(newPoints); |
|
} |
|
}; |
|
|
|
|
|
const compressImage = async (dataUrl) => { |
|
return new Promise((resolve, reject) => { |
|
const img = new Image(); |
|
img.onload = () => { |
|
const canvas = document.createElement('canvas'); |
|
let width = img.width; |
|
let height = img.height; |
|
|
|
|
|
const MAX_DIMENSION = 1200; |
|
if (width > height && width > MAX_DIMENSION) { |
|
height *= MAX_DIMENSION / width; |
|
width = MAX_DIMENSION; |
|
} else if (height > MAX_DIMENSION) { |
|
width *= MAX_DIMENSION / height; |
|
height = MAX_DIMENSION; |
|
} |
|
|
|
canvas.width = width; |
|
canvas.height = height; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
ctx.fillStyle = '#FFFFFF'; |
|
ctx.fillRect(0, 0, width, height); |
|
ctx.drawImage(img, 0, 0, width, height); |
|
|
|
|
|
resolve(canvas.toDataURL('image/jpeg', 0.8)); |
|
}; |
|
img.onerror = reject; |
|
img.src = dataUrl; |
|
}); |
|
}; |
|
|
|
const handleGenerate = () => { |
|
const canvas = canvasRef.current; |
|
if (!canvas) return; |
|
|
|
|
|
if (typeof handleGenerationRef.current === 'function') { |
|
handleGenerationRef.current(); |
|
} |
|
}; |
|
|
|
const handleUploadClick = () => { |
|
fileInputRef.current?.click(); |
|
}; |
|
|
|
|
|
const handleClearCanvas = useCallback(() => { |
|
const canvas = canvasRef.current; |
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
ctx.fillStyle = '#FFFFFF'; |
|
ctx.fillRect(0, 0, canvas.width, canvas.height); |
|
|
|
|
|
setTempPoints([]); |
|
setHasDrawing(false); |
|
setUploadedImages([]); |
|
|
|
|
|
saveCanvasState(); |
|
|
|
|
|
if (typeof onDrawingChange === 'function') { |
|
onDrawingChange(false); |
|
} |
|
}, [saveCanvasState, onDrawingChange]); |
|
|
|
useImperativeHandle(ref, () => ({ |
|
canvas: canvasRef.current, |
|
clear: () => clearCanvas(true), |
|
setHasDrawing: setHasDrawing, |
|
}), [clearCanvas, setHasDrawing]); |
|
|
|
return ( |
|
<div className="flex flex-col gap-4"> |
|
{/* Canvas container with fixed aspect ratio */} |
|
<div |
|
ref={canvasContainerRef} |
|
className={`relative w-full ${isDraggingFile ? 'bg-gray-100 border-2 border-dashed border-gray-400' : ''}`} |
|
style={{ aspectRatio: `${currentDimension.width} / ${currentDimension.height}` }} |
|
> |
|
<canvas |
|
ref={canvasRef} |
|
width={currentDimension.width} |
|
height={currentDimension.height} |
|
className="absolute inset-0 w-full h-full border border-gray-300 bg-white rounded-xl shadow-soft" |
|
style={{ |
|
touchAction: 'none' |
|
}} |
|
onMouseDown={handleStartDrawing} |
|
onMouseMove={handleDraw} |
|
onMouseUp={handleStopDrawing} |
|
onMouseLeave={handleStopDrawing} |
|
onTouchStart={handleStartDrawing} |
|
onTouchMove={handleDraw} |
|
onTouchEnd={handleStopDrawing} |
|
onClick={handleCanvasClick} |
|
onKeyDown={handleKeyDown} |
|
tabIndex="0" |
|
aria-label="Drawing canvas" |
|
/> |
|
|
|
{/* Floating upload button */} |
|
<button |
|
type="button" |
|
onClick={handleUploadClick} |
|
className={`absolute bottom-2.5 right-2.5 z-10 bg-white border border-gray-200 text-gray-600 rounded-lg p-4 sm:p-3 flex items-center justify-center shadow-soft hover:bg-gray-100 transition-colors ${isDrawing ? 'pointer-events-none' : ''}`} |
|
aria-label="Upload image" |
|
title="Upload image" |
|
> |
|
<ImagePlus className="w-6 h-6 sm:w-5 sm:h-5" /> |
|
<input |
|
type="file" |
|
ref={fileInputRef} |
|
onChange={handleFileChange} |
|
className="hidden" |
|
accept="image/*" |
|
/> |
|
</button> |
|
|
|
{/* Doodle conversion loading overlay */} |
|
{isDoodleConverting && !doodleError && ( |
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-400/80 rounded-xl z-50"> |
|
<div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center"> |
|
<LoaderCircle className="w-12 h-12 text-gray-700 animate-spin mb-4" /> |
|
<p className="text-gray-900 font-medium text-lg">Converting to doodle...</p> |
|
<p className="text-gray-500 text-sm mt-2">This may take a moment</p> |
|
</div> |
|
</div> |
|
)} |
|
|
|
{/* Updated doodle conversion error overlay with dismiss button and API key button */} |
|
{doodleError && ( |
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-400/80 rounded-xl z-50"> |
|
<div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center max-w-md"> |
|
<AlertCircle className="w-12 h-12 text-red-500 mb-4" /> |
|
<p className="text-gray-900 font-medium text-lg">Failed to Convert Image</p> |
|
<p className="text-gray-700 text-center mt-2">{doodleError}</p> |
|
<p className="text-gray-500 text-sm mt-4">Try a different image or try again later</p> |
|
|
|
{/* Add buttons in a row */} |
|
<div className="flex gap-3 mt-4"> |
|
<button |
|
type="button" |
|
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 transition-colors" |
|
onClick={clearDoodleError} |
|
> |
|
Dismiss |
|
</button> |
|
|
|
{/* New API Key button that shows in grayscale */} |
|
<button |
|
type="button" |
|
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors" |
|
onClick={onOpenApiKeyModal} |
|
> |
|
Add API Key |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
)} |
|
|
|
{/* Sending back to doodle loading overlay */} |
|
{isSendingToDoodle && ( |
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-400/80 rounded-xl z-50"> |
|
<div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center"> |
|
<LoaderCircle className="w-12 h-12 text-gray-700 animate-spin mb-4" /> |
|
<p className="text-gray-900 font-medium text-lg">Sending back to doodle...</p> |
|
<p className="text-gray-500 text-sm mt-2">Converting and loading...</p> |
|
</div> |
|
</div> |
|
)} |
|
|
|
{/* Draw here placeholder */} |
|
{!hasDrawing && !isDoodleConverting && !isSendingToDoodle && ( |
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"> |
|
<PencilLine className="w-8 h-8 text-gray-400 mb-2" /> |
|
<p className="text-gray-400 text-lg font-medium">Draw here</p> |
|
</div> |
|
)} |
|
|
|
{/* Drag and drop indicator */} |
|
{isDraggingFile && ( |
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-100/80 border-2 border-dashed border-gray-400 rounded-xl z-40 pointer-events-none"> |
|
<ImagePlus className="w-12 h-12 text-gray-500 mb-4" /> |
|
<p className="text-gray-600 text-lg font-medium">Drop image to convert to doodle</p> |
|
</div> |
|
)} |
|
</div> |
|
|
|
{/* Style selector - positioned below canvas */} |
|
<div className="w-full"> |
|
<StyleSelector |
|
styleMode={styleMode} |
|
setStyleMode={setStyleMode} |
|
handleGenerate={handleGeneration} |
|
/> |
|
</div> |
|
</div> |
|
); |
|
}); |
|
|
|
Canvas.displayName = 'Canvas'; |
|
|
|
export default Canvas; |