Spaces:
Running
Running
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); | |
// Create a ref to track the previous style mode | |
const prevStyleModeRef = useRef(styleMode); | |
// Stable callback reference for handleGeneration | |
const handleGenerationRef = useRef(handleGeneration); | |
useEffect(() => { | |
handleGenerationRef.current = handleGeneration; | |
}, [handleGeneration]); | |
// Add effect to watch for styleMode changes and trigger generation | |
useEffect(() => { | |
// Skip the first render | |
if (prevStyleModeRef.current === styleMode) { | |
return; | |
} | |
// Update the ref to current value | |
prevStyleModeRef.current = styleMode; | |
// When styleMode changes, trigger generation | |
if (typeof handleGenerationRef.current === 'function') { | |
handleGenerationRef.current(); | |
} | |
}, [styleMode]); | |
// Add touch event prevention function | |
useEffect(() => { | |
// Function to prevent default touch behavior on canvas | |
const preventTouchDefault = (e) => { | |
if (isDrawing) { | |
e.preventDefault(); | |
} | |
}; | |
// Add event listener when component mounts | |
const canvas = canvasRef.current; | |
if (canvas) { | |
canvas.addEventListener('touchstart', preventTouchDefault, { passive: false }); | |
canvas.addEventListener('touchmove', preventTouchDefault, { passive: false }); | |
} | |
// Remove event listener when component unmounts | |
return () => { | |
if (canvas) { | |
canvas.removeEventListener('touchstart', preventTouchDefault); | |
canvas.removeEventListener('touchmove', preventTouchDefault); | |
} | |
}; | |
}, [isDrawing, canvasRef]); | |
// Add debugging info to console | |
useEffect(() => { | |
console.log('Canvas tool changed or isDrawing changed:', { currentTool, isDrawing }); | |
}, [currentTool, isDrawing]); | |
// Add effect to rerender when uploadedImages change | |
useEffect(() => { | |
if (uploadedImages.length > 0) { | |
renderCanvas(); | |
} | |
}, [uploadedImages]); | |
// Redraw bezier guides and control points when tempPoints change | |
useEffect(() => { | |
if (currentTool === 'pen' && tempPoints.length > 0 && showBezierGuides) { | |
redrawBezierGuides(); | |
} | |
}, [tempPoints, showBezierGuides, currentTool]); | |
// Add useEffect to check if canvas has content | |
useEffect(() => { | |
const canvas = canvasRef.current; | |
if (!canvas) return; | |
const ctx = canvas.getContext('2d'); | |
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
// Check if canvas has any non-white pixels (i.e., has a drawing) | |
const hasNonWhitePixels = Array.from(imageData.data).some((pixel, index) => { | |
// Check only RGB values (skip alpha) | |
return index % 4 !== 3 && pixel !== 255; | |
}); | |
setHasDrawing(hasNonWhitePixels); | |
}, [canvasRef]); | |
// Add this near your other useEffects | |
useEffect(() => { | |
// When isDoodleConverting becomes true, also set hasDrawing to true | |
if (isDoodleConverting) { | |
setHasDrawing(true); | |
} | |
}, [isDoodleConverting]); | |
// Create a stable ref for handleFileChange to avoid dependency cycles | |
const handleFileChangeRef = useRef(null); | |
// Add clearDoodleError function | |
const clearDoodleError = useCallback(() => { | |
setDoodleError(null); | |
}, []); | |
// Update handleFileChange function | |
const handleFileChange = useCallback(async (event) => { | |
const file = event.target.files?.[0]; | |
if (!file) return; | |
// Store the current tool | |
const previousTool = currentTool; | |
// Hide the placeholder immediately when upload begins | |
if (typeof onDrawingChange === 'function') { | |
onDrawingChange(true); | |
} | |
// Clear previous errors | |
setDoodleError(null); | |
// Show loading state | |
setIsDoodleConverting(true); | |
const reader = new FileReader(); | |
reader.onload = async (e) => { | |
const imageDataUrl = e.target.result; | |
try { | |
// Compress the image before sending | |
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, | |
}), | |
}); | |
// Get response data | |
const data = await response.json(); | |
// Check for API errors (non-200 status) | |
if (!response.ok) { | |
let errorMessage = data.error || `Server error (${response.status})`; | |
// Check if the response contains details about retry attempts | |
if (data.retries !== undefined) { | |
errorMessage += `. Failed after ${data.retries + 1} attempts.`; | |
} | |
// Check for specific error types from the server | |
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); | |
} | |
// Check for API response with success: false | |
if (!data.success) { | |
let errorMessage = data.error || "Failed to convert image to doodle"; | |
// Check if the response contains details about retry attempts | |
if (data.retries !== undefined) { | |
errorMessage += `. Failed after ${data.retries + 1} attempts.`; | |
} | |
throw new Error(errorMessage); | |
} | |
// Check if we have image data | |
if (!data.imageData) { | |
throw new Error("No image data received from the server"); | |
} | |
// Process successful response | |
const img = new Image(); | |
img.onload = () => { | |
const ctx = canvasRef.current.getContext('2d'); | |
// Clear canvas | |
ctx.fillStyle = '#FFFFFF'; | |
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); | |
// Calculate dimensions | |
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; | |
// Draw doodle | |
ctx.drawImage(img, x, y, img.width * scale, img.height * scale); | |
// Save canvas state | |
saveCanvasState(); | |
// Hide loading state | |
setIsDoodleConverting(false); | |
// Ensure placeholder is hidden | |
if (typeof onDrawingChange === 'function') { | |
onDrawingChange(true); | |
} | |
// Automatically trigger generation | |
handleGenerationRef.current(); | |
}; | |
img.src = `data:image/png;base64,${data.imageData}`; | |
} catch (error) { | |
console.error('Error processing image:', error); | |
// Set error state with message | |
setDoodleError(error.message || "Failed to convert image. Please try again."); | |
// Schedule error message to disappear after 5 seconds (was 3 seconds) | |
setTimeout(() => { | |
setDoodleError(null); | |
}, 5000); | |
// Hide loading state | |
setIsDoodleConverting(false); | |
// Restore previous tool even if there's an error | |
setCurrentTool(previousTool); | |
} | |
}; | |
reader.readAsDataURL(file); | |
}, [canvasRef, currentTool, onDrawingChange, saveCanvasState, setCurrentTool, customApiKey]); | |
// Keep the ref updated | |
useEffect(() => { | |
handleFileChangeRef.current = handleFileChange; | |
}, [handleFileChange]); | |
// Add drag and drop event handlers | |
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(); | |
// Only set to false if we're leaving the container (not entering a child) | |
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]; | |
// Check if it's an image | |
if (file.type.startsWith('image/')) { | |
// Create a fake event object to reuse the existing handleFileChange function | |
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) => { | |
// Add keyboard accessibility | |
if (e.key === 'Enter' || e.key === ' ') { | |
handleCanvasClick(e); | |
} | |
// Toggle symmetric handles with Shift key | |
if (e.key === 'Shift') { | |
setSymmetric(!symmetric); | |
} | |
}; | |
// Draw bezier control points and guide lines | |
const redrawBezierGuides = () => { | |
const canvas = canvasRef.current; | |
if (!canvas) return; | |
// Get the canvas context | |
const ctx = canvas.getContext('2d'); | |
// Save the current canvas state to redraw later | |
const canvasImage = new Image(); | |
canvasImage.src = canvas.toDataURL(); | |
canvasImage.onload = () => { | |
// Clear canvas | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
// Redraw the canvas content | |
ctx.drawImage(canvasImage, 0, 0); | |
// Draw the control points and guide lines | |
drawBezierGuides(ctx, tempPoints); | |
}; | |
}; | |
// Function to draw a star shape | |
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(); | |
}; | |
// Function to draw shapes | |
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; // Make lines thicker | |
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; | |
} | |
} | |
}; | |
// Add this new renderCanvas function after handleFileChange | |
const renderCanvas = useCallback(() => { | |
const canvas = canvasRef.current; | |
if (!canvas) return; | |
const ctx = canvas.getContext('2d'); | |
// Store current canvas state in a temporary canvas to preserve drawings | |
const tempCanvas = document.createElement('canvas'); | |
tempCanvas.width = canvas.width; | |
tempCanvas.height = canvas.height; | |
const tempCtx = tempCanvas.getContext('2d'); | |
tempCtx.drawImage(canvas, 0, 0); | |
// Clear canvas | |
ctx.fillStyle = '#FFFFFF'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
// Redraw original content | |
ctx.drawImage(tempCanvas, 0, 0); | |
// Draw all uploaded images | |
for (const img of uploadedImages) { | |
const imageObj = new Image(); | |
imageObj.src = img.src; | |
ctx.drawImage(imageObj, img.x, img.y, img.width, img.height); | |
// Draw selection handles if dragging or resizing this image | |
if (draggingImage === img.id || resizingImage === img.id) { | |
// Draw border | |
ctx.strokeStyle = '#0080ff'; | |
ctx.lineWidth = 2; | |
ctx.strokeRect(img.x, img.y, img.width, img.height); | |
// Draw corner resize handles | |
ctx.fillStyle = '#0080ff'; | |
const handleSize = 8; | |
// Top-left | |
ctx.fillRect(img.x - handleSize/2, img.y - handleSize/2, handleSize, handleSize); | |
// Top-right | |
ctx.fillRect(img.x + img.width - handleSize/2, img.y - handleSize/2, handleSize, handleSize); | |
// Bottom-left | |
ctx.fillRect(img.x - handleSize/2, img.y + img.height - handleSize/2, handleSize, handleSize); | |
// Bottom-right | |
ctx.fillRect(img.x + img.width - handleSize/2, img.y + img.height - handleSize/2, handleSize, handleSize); | |
} | |
} | |
}, [canvasRef, uploadedImages, draggingImage, resizingImage]); | |
// Handle mouse down for image interaction | |
const handleImageMouseDown = (e) => { | |
if (currentTool !== 'selection') return false; | |
const { x, y } = getCoordinates(e, canvasRef.current); | |
const handleSize = 8; | |
// Check if clicked on any image handle first (for resizing) | |
for (let i = uploadedImages.length - 1; i >= 0; i--) { | |
const img = uploadedImages[i]; | |
// Check if clicked on bottom-right resize handle | |
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; | |
} | |
} | |
// If not resizing, check if clicked on any image (for dragging) | |
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; | |
}; | |
// Handle mouse move for image interaction | |
const handleImageMouseMove = (e) => { | |
if (!draggingImage && !resizingImage) return false; | |
const { x, y } = getCoordinates(e, canvasRef.current); | |
if (draggingImage) { | |
// Update position of dragged image | |
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) { | |
// Update size of resized image | |
setUploadedImages(prev => prev.map(img => { | |
if (img.id === resizingImage) { | |
// Calculate new width and height | |
const newWidth = Math.max(20, x - img.x - dragOffset.x + 10); | |
const newHeight = Math.max(20, y - img.y - dragOffset.y + 10); | |
// Option 1: Free resize | |
return { | |
...img, | |
width: newWidth, | |
height: newHeight | |
}; | |
// Option 2: Maintain aspect ratio (uncomment if needed) | |
/* | |
const aspectRatio = img.originalWidth / img.originalHeight; | |
const newHeight = newWidth / aspectRatio; | |
return { | |
...img, | |
width: newWidth, | |
height: newHeight | |
}; | |
*/ | |
} | |
return img; | |
})); | |
renderCanvas(); | |
return true; | |
} | |
return false; | |
}; | |
// Handle mouse up for image interaction | |
const handleImageMouseUp = () => { | |
if (draggingImage || resizingImage) { | |
setDraggingImage(null); | |
setResizingImage(null); | |
saveCanvasState(); | |
return true; | |
} | |
return false; | |
}; | |
// Function to delete the selected image | |
const deleteSelectedImage = useCallback(() => { | |
if (draggingImage) { | |
setUploadedImages(prev => prev.filter(img => img.id !== draggingImage)); | |
setDraggingImage(null); | |
renderCanvas(); | |
saveCanvasState(); | |
} | |
}, [draggingImage, renderCanvas, saveCanvasState]); | |
// Modify existing startDrawing to check for image interaction first | |
const handleStartDrawing = (e) => { | |
console.log('Canvas onMouseDown', { currentTool, isDrawing }); | |
// Check if we're interacting with an image first | |
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 }); | |
// Create preview canvas if it doesn't exist | |
if (!previewCanvas) { | |
const canvas = document.createElement('canvas'); | |
canvas.width = canvasRef.current.width; | |
canvas.height = canvasRef.current.height; | |
setPreviewCanvas(canvas); | |
} | |
} | |
startDrawing(e); | |
setHasDrawing(true); | |
}; | |
// Modify existing draw to handle image interaction | |
const handleDraw = (e) => { | |
// Handle image dragging/resizing first | |
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); | |
}; | |
// Modify existing stopDrawing to handle image interaction | |
const handleStopDrawing = (e) => { | |
// Handle image release first | |
if (handleImageMouseUp()) { | |
return; | |
} | |
console.log('handleStopDrawing called', { | |
eventType: e?.type, | |
currentTool, | |
isDrawing, | |
activePoint, | |
activeHandle | |
}); | |
// If we're using the pen tool with active point or handle | |
if (currentTool === 'pen') { | |
// If we were dragging a handle, just release it | |
if (activeHandle) { | |
setActiveHandle(null); | |
return; | |
} | |
// If we were dragging an anchor point, just release it | |
if (activePoint !== -1) { | |
setActivePoint(-1); | |
return; | |
} | |
} | |
stopDrawing(e); | |
// If using the pencil tool and we've just finished a drag, trigger generation | |
if (currentTool === 'pencil' && isDrawing && !isGenerating) { | |
console.log(`${currentTool} tool condition met, will try to trigger generation`); | |
// Set generating flag to prevent multiple calls | |
if (typeof setIsGenerating === 'function') { | |
setIsGenerating(true); | |
} | |
// Generate immediately - no timeout needed | |
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 | |
}); | |
} | |
}; | |
// Handle keyboard events for image deletion | |
useEffect(() => { | |
const handleKeyDown = (e) => { | |
if ((e.key === 'Delete' || e.key === 'Backspace') && draggingImage) { | |
deleteSelectedImage(); | |
} | |
}; | |
window.addEventListener('keydown', handleKeyDown); | |
return () => { | |
window.removeEventListener('keydown', handleKeyDown); | |
}; | |
}, [draggingImage, deleteSelectedImage]); | |
// Check if we clicked on an existing point or handle | |
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 }); | |
// Check if we clicked on a handle | |
for (let i = 0; i < tempPoints.length; i++) { | |
const point = tempPoints[i]; | |
// Check for handleIn | |
if (isNearHandle(point, 'handleIn', x, y)) { | |
setActivePoint(i); | |
setActiveHandle('handleIn'); | |
return true; | |
} | |
// Check for handleOut | |
if (isNearHandle(point, 'handleOut', x, y)) { | |
setActivePoint(i); | |
setActiveHandle('handleOut'); | |
return true; | |
} | |
// Check for the anchor point itself | |
const distance = Math.sqrt((point.x - x) ** 2 + (point.y - y) ** 2); | |
if (distance <= 10) { | |
setActivePoint(i); | |
setActiveHandle(null); | |
return true; | |
} | |
} | |
return false; | |
}; | |
// Handle mouse move for bezier control point or handle dragging | |
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 we're dragging a handle | |
if (activePoint !== -1 && activeHandle) { | |
const newPoints = [...tempPoints]; | |
updateHandle(newPoints[activePoint], activeHandle, dx, dy, symmetric); | |
setTempPoints(newPoints); | |
setLastMousePos({ x, y }); | |
return true; | |
} | |
// If we're dragging an anchor point | |
if (activePoint !== -1) { | |
const newPoints = [...tempPoints]; | |
newPoints[activePoint].x += dx; | |
newPoints[activePoint].y += dy; | |
// If this point has handles, move them with the point | |
if (newPoints[activePoint].handleIn) { | |
// No need to change the handle's offset, just move with the point | |
} | |
if (newPoints[activePoint].handleOut) { | |
// No need to change the handle's offset, just move with the point | |
} | |
setTempPoints(newPoints); | |
setLastMousePos({ x, y }); | |
return true; | |
} | |
return false; | |
}; | |
// Handle clicks for bezier curve tool | |
const handlePenToolClick = (e) => { | |
const canvas = canvasRef.current; | |
const { x, y } = getCoordinates(e, canvas); | |
// Add a new point | |
if (tempPoints.length === 0) { | |
// First point has no handles initially | |
const newPoint = { x, y, handleIn: null, handleOut: null }; | |
setTempPoints([newPoint]); | |
} else { | |
// Create a new point with handles relative to the last point | |
const newPoint = createAnchorPoint(x, y, tempPoints[tempPoints.length - 1]); | |
setTempPoints([...tempPoints, newPoint]); | |
} | |
// Always show guides when adding points | |
setShowBezierGuides(true); | |
}; | |
// Toggle bezier guide visibility | |
const toggleBezierGuides = () => { | |
setShowBezierGuides(!showBezierGuides); | |
if (showBezierGuides) { | |
redrawBezierGuides(); | |
} | |
}; | |
// Draw the final bezier curve and clear control points | |
const finalizeBezierCurve = () => { | |
if (tempPoints.length < 2) { | |
// Need at least 2 points for a path | |
console.log('Need at least 2 control points to draw a path'); | |
return; | |
} | |
const canvas = canvasRef.current; | |
// Draw the actual bezier curve | |
drawBezierCurve(canvas, tempPoints); | |
// Hide guides and reset control points | |
setShowBezierGuides(false); | |
setTempPoints([]); | |
// Trigger generation only if not already generating | |
if (!isGenerating) { | |
// Set generating flag to prevent multiple calls | |
if (typeof setIsGenerating === 'function') { | |
setIsGenerating(true); | |
} | |
if (typeof handleGenerationRef.current === 'function') { | |
handleGenerationRef.current(); | |
} | |
} | |
}; | |
// Add control point to segment | |
const addControlPoint = (e) => { | |
if (currentTool !== 'pen' || tempPoints.length < 2) return; | |
const canvas = canvasRef.current; | |
const { x, y } = getCoordinates(e, canvas); | |
// Find the closest segment to add a point to | |
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]; | |
// Calculate distance from click to line between points | |
// This is a simplified distance calculation for demo purposes | |
const lineLength = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); | |
if (lineLength === 0) continue; | |
// Project point onto line | |
const t = ((x - p1.x) * (p2.x - p1.x) + (y - p1.y) * (p2.y - p1.y)) / (lineLength * lineLength); | |
// If projection is outside the line segment, skip | |
if (t < 0 || t > 1) continue; | |
// Calculate closest point on line | |
const closestX = p1.x + t * (p2.x - p1.x); | |
const closestY = p1.y + t * (p2.y - p1.y); | |
// Calculate distance to closest point | |
const distance = Math.sqrt((x - closestX) ** 2 + (y - closestY) ** 2); | |
if (distance < closestDistance && distance < 20) { | |
closestDistance = distance; | |
insertIndex = i + 1; | |
} | |
} | |
if (insertIndex > 0) { | |
// Create a new array with the new point inserted | |
const newPoints = [...tempPoints]; | |
const prevPoint = newPoints[insertIndex - 1]; | |
const nextPoint = newPoints[insertIndex]; | |
// Create a new point at the click position with automatically calculated handles | |
const newPoint = { | |
x, | |
y, | |
// Calculate handles based on the positions of adjacent points | |
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 | |
} | |
}; | |
// Insert the new point | |
newPoints.splice(insertIndex, 0, newPoint); | |
setTempPoints(newPoints); | |
} | |
}; | |
// Add image compression utility | |
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; | |
// Calculate new dimensions while maintaining aspect ratio | |
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); | |
// Compress as JPEG with 0.8 quality | |
resolve(canvas.toDataURL('image/jpeg', 0.8)); | |
}; | |
img.onerror = reject; | |
img.src = dataUrl; | |
}); | |
}; | |
const handleGenerate = () => { | |
const canvas = canvasRef.current; | |
if (!canvas) return; | |
// Use the ref to ensure we have the latest handler | |
if (typeof handleGenerationRef.current === 'function') { | |
handleGenerationRef.current(); | |
} | |
}; | |
const handleUploadClick = () => { | |
fileInputRef.current?.click(); | |
}; | |
// Add custom clearCanvas implementation | |
const handleClearCanvas = useCallback(() => { | |
const canvas = canvasRef.current; | |
const ctx = canvas.getContext('2d'); | |
// Clear the canvas with white background | |
ctx.fillStyle = '#FFFFFF'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
// Reset states | |
setTempPoints([]); | |
setHasDrawing(false); | |
setUploadedImages([]); | |
// Save the cleared state | |
saveCanvasState(); | |
// Notify about drawing change | |
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; |