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; |