Spaces:
Running
Running
| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import Canvas from "./Canvas"; | |
| import DisplayCanvas from "./DisplayCanvas"; | |
| import ToolBar from "./ToolBar"; | |
| import StyleSelector from "./StyleSelector"; | |
| import { getPromptForStyle, styleOptions, addMaterialToLibrary } from "./StyleSelector"; | |
| import ActionBar from "./ActionBar"; | |
| import ErrorModal from "./ErrorModal"; | |
| import TextInput from "./TextInput"; | |
| import Header from "./Header"; | |
| import DimensionSelector from "./DimensionSelector"; | |
| import HistoryModal from "./HistoryModal"; | |
| import BottomToolBar from "./BottomToolBar"; | |
| import LibraryPage from "./LibraryPage"; | |
| import { | |
| getCoordinates, | |
| initializeCanvas, | |
| drawImageToCanvas, | |
| drawBezierCurve, | |
| } from "./utils/canvasUtils"; | |
| import { toast } from "react-hot-toast"; | |
| import { Download, History as HistoryIcon, RefreshCw as RefreshIcon, Library as LibraryIcon, LoaderCircle } from "lucide-react"; | |
| import OutputOptionsBar from "./OutputOptionsBar"; | |
| import ApiKeyModal from "./ApiKeyModal"; | |
| import HeaderButtons from "./HeaderButtons"; | |
| const CanvasContainer = () => { | |
| // Check if the device is mobile based on screen width | |
| const isMobileDevice = () => { | |
| if (typeof window !== 'undefined') { | |
| return window.innerWidth < 768; // Common breakpoint for mobile devices | |
| } | |
| return false; // Default to desktop on server-side | |
| }; | |
| // Get default dimensions based on device type | |
| const getDefaultDimension = () => { | |
| if (isMobileDevice()) { | |
| // Square (1:1) for mobile | |
| return { | |
| id: "square", | |
| label: "1:1", | |
| width: 1000, | |
| height: 1000, | |
| }; | |
| } else { | |
| // Landscape (3:2) for desktop | |
| return { | |
| id: "landscape", | |
| label: "3:2", | |
| width: 1500, | |
| height: 1000, | |
| }; | |
| } | |
| }; | |
| const canvasRef = useRef(null); | |
| const canvasComponentRef = useRef(null); | |
| const displayCanvasRef = useRef(null); | |
| const backgroundImageRef = useRef(null); | |
| const [currentDimension, setCurrentDimension] = useState(getDefaultDimension()); | |
| const [isDrawing, setIsDrawing] = useState(false); | |
| const [penColor, setPenColor] = useState("#000000"); | |
| const [penWidth, setPenWidth] = useState(2); | |
| const colorInputRef = useRef(null); | |
| const [prompt, setPrompt] = useState(""); | |
| const [generatedImage, setGeneratedImage] = useState(null); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [showErrorModal, setShowErrorModal] = useState(false); | |
| const [errorMessage, setErrorMessage] = useState(""); | |
| const [customApiKey, setCustomApiKey] = useState(""); | |
| const [debugMode, setDebugMode] = useState(false); | |
| const [styleMode, setStyleMode] = useState("material"); | |
| const [strokeCount, setStrokeCount] = useState(0); | |
| const strokeTimeoutRef = useRef(null); | |
| const [lastRequestTime, setLastRequestTime] = useState(0); | |
| const MIN_REQUEST_INTERVAL = 2000; // Minimum 2 seconds between requests | |
| const [currentTool, setCurrentTool] = useState("pencil"); // 'pencil', 'pen', 'eraser', 'text', 'rect', 'circle', 'line', 'star' | |
| const [isTyping, setIsTyping] = useState(false); | |
| const [undoStack, setUndoStack] = useState([]); | |
| const [bezierPoints, setBezierPoints] = useState([]); | |
| const [textInput, setTextInput] = useState(""); | |
| const [textPosition, setTextPosition] = useState({ x: 0, y: 0 }); | |
| const textInputRef = useRef(null); | |
| const [isPenDrawing, setIsPenDrawing] = useState(false); | |
| const [currentBezierPath, setCurrentBezierPath] = useState([]); | |
| const [tempPoints, setTempPoints] = useState([]); | |
| const [hasGeneratedContent, setHasGeneratedContent] = useState(false); | |
| const [imageHistory, setImageHistory] = useState([]); | |
| const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false); | |
| const [hasDrawing, setHasDrawing] = useState(false); | |
| // Add a ref to track style changes that need regeneration | |
| const needsRegenerationRef = useRef(false); | |
| // Add a ref to track if regeneration was manually triggered | |
| const isManualRegenerationRef = useRef(false); | |
| const [isSendingToDoodle, setIsSendingToDoodle] = useState(false); | |
| // Add state for API key modal | |
| const [showApiKeyModal, setShowApiKeyModal] = useState(false); | |
| const [showLibrary, setShowLibrary] = useState(false); | |
| // Add state for template loading | |
| const [isTemplateLoading, setIsTemplateLoading] = useState(false); | |
| const [templateLoadingMessage, setTemplateLoadingMessage] = useState(""); | |
| // Load saved API key from localStorage on component mount | |
| useEffect(() => { | |
| const savedApiKey = localStorage.getItem("geminiApiKey"); | |
| if (savedApiKey) { | |
| setCustomApiKey(savedApiKey); | |
| // Validate the API key silently | |
| validateApiKey(savedApiKey); | |
| } | |
| // Check if debug mode is enabled in localStorage or URL | |
| const debugParam = new URLSearchParams(window.location.search).get('debug'); | |
| // Only look at localStorage if debug parameter is not explicitly set to false | |
| const savedDebug = debugParam !== "false" && localStorage.getItem("debugMode") === "true"; | |
| if (debugParam === "true" || savedDebug) { | |
| // Set debug mode to true AND show error modal | |
| setDebugMode(true); | |
| setShowErrorModal(true); | |
| } else { | |
| // Ensure debug mode is OFF by default | |
| setDebugMode(false); | |
| // Also clean up any stale localStorage value | |
| if (localStorage.getItem("debugMode") === "true") { | |
| localStorage.setItem("debugMode", "false"); | |
| } | |
| } | |
| }, []); | |
| // Add effect to save debug mode to localStorage | |
| useEffect(() => { | |
| // Always save the current state to localStorage | |
| localStorage.setItem("debugMode", debugMode.toString()); | |
| // ONLY auto-show error modal when debug mode is enabled | |
| if (debugMode === true) { | |
| setShowErrorModal(true); | |
| } | |
| }, [debugMode]); | |
| // Add a function to validate the API key | |
| const validateApiKey = async (apiKey) => { | |
| if (!apiKey) return; | |
| try { | |
| const response = await fetch("/api/validate-key", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ apiKey }), | |
| }); | |
| const data = await response.json(); | |
| if (!data.valid) { | |
| console.warn("Invalid API key detected, will be cleared"); | |
| // Clear the invalid key | |
| localStorage.removeItem("geminiApiKey"); | |
| setCustomApiKey(""); | |
| // Don't show error to user for now - they'll see it when trying to use the app | |
| } | |
| } catch (error) { | |
| console.error("Error validating API key:", error); | |
| // Don't clear the key on connection errors | |
| } | |
| }; | |
| // Load background image when generatedImage changes | |
| useEffect(() => { | |
| if (generatedImage && canvasRef.current) { | |
| // Use the window.Image constructor to avoid conflict with Next.js Image component | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| backgroundImageRef.current = img; | |
| drawImageToCanvas(canvasRef.current, backgroundImageRef.current); | |
| }; | |
| img.src = generatedImage; | |
| } | |
| }, [generatedImage]); | |
| // Initialize canvas with white background when component mounts | |
| useEffect(() => { | |
| if (canvasRef.current) { | |
| initializeCanvas(canvasRef.current); | |
| } | |
| // Also initialize the display canvas | |
| if (displayCanvasRef.current) { | |
| const displayCtx = displayCanvasRef.current.getContext("2d"); | |
| displayCtx.fillStyle = "#FFFFFF"; | |
| displayCtx.fillRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| } | |
| }, []); | |
| // Add resize listener to update dimensions when switching between mobile and desktop | |
| useEffect(() => { | |
| let isMobile = isMobileDevice(); | |
| const handleResize = () => { | |
| const newIsMobile = isMobileDevice(); | |
| // Only update dimensions if the device type changed (mobile <-> desktop) | |
| if (newIsMobile !== isMobile) { | |
| isMobile = newIsMobile; | |
| // Only update dimensions if the canvas is empty (no drawing) | |
| if (canvasRef.current && !hasDrawing && !hasGeneratedContent) { | |
| setCurrentDimension(getDefaultDimension()); | |
| } | |
| } | |
| }; | |
| window.addEventListener('resize', handleResize); | |
| return () => window.removeEventListener('resize', handleResize); | |
| }, [hasDrawing, hasGeneratedContent]); | |
| // Add an effect to sync canvas dimensions when they change | |
| useEffect(() => { | |
| if (canvasRef.current && displayCanvasRef.current) { | |
| // Ensure both canvases have the same dimensions | |
| canvasRef.current.width = currentDimension.width; | |
| canvasRef.current.height = currentDimension.height; | |
| displayCanvasRef.current.width = currentDimension.width; | |
| displayCanvasRef.current.height = currentDimension.height; | |
| // Initialize both canvases with white backgrounds | |
| initializeCanvas(canvasRef.current); | |
| const displayCtx = displayCanvasRef.current.getContext("2d"); | |
| displayCtx.fillStyle = "#FFFFFF"; | |
| displayCtx.fillRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| } | |
| }, [currentDimension]); | |
| const startDrawing = (e) => { | |
| const { x, y } = getCoordinates(e, canvasRef.current); | |
| if (e.type === "touchstart") { | |
| e.preventDefault(); | |
| } | |
| console.log("startDrawing called", { currentTool, x, y }); | |
| const ctx = canvasRef.current.getContext("2d"); | |
| // Set up the line style at the start of drawing | |
| ctx.lineWidth = currentTool === "eraser" ? 20 : penWidth; | |
| ctx.lineCap = "round"; | |
| ctx.lineJoin = "round"; | |
| ctx.strokeStyle = currentTool === "eraser" ? "#FFFFFF" : penColor; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y); | |
| setIsDrawing(true); | |
| setStrokeCount((prev) => prev + 1); | |
| // Save canvas state before drawing | |
| saveCanvasState(); | |
| }; | |
| const draw = (e) => { | |
| if (!isDrawing) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| const { x, y } = getCoordinates(e, canvas); | |
| // Occasionally log drawing activity | |
| if (Math.random() < 0.05) { | |
| // Only log ~5% of move events to avoid console spam | |
| console.log("draw called", { currentTool, isDrawing, x, y }); | |
| } | |
| // Set up the line style before drawing | |
| ctx.lineWidth = currentTool === "eraser" ? 60 : penWidth * 4; // Pen width now 4x original size | |
| ctx.lineCap = "round"; | |
| ctx.lineJoin = "round"; | |
| if (currentTool === "eraser") { | |
| ctx.strokeStyle = "#FFFFFF"; | |
| } else { | |
| ctx.strokeStyle = penColor; | |
| } | |
| if (currentTool === "pen") { | |
| // Show preview line while moving | |
| if (tempPoints.length > 0) { | |
| const lastPoint = tempPoints[tempPoints.length - 1]; | |
| ctx.beginPath(); | |
| ctx.moveTo(lastPoint.x, lastPoint.y); | |
| ctx.lineTo(x, y); | |
| ctx.stroke(); | |
| } | |
| } else { | |
| ctx.lineTo(x, y); | |
| ctx.stroke(); | |
| } | |
| }; | |
| const stopDrawing = async (e) => { | |
| console.log("stopDrawing called in CanvasContainer", { | |
| isDrawing, | |
| currentTool, | |
| hasEvent: !!e, | |
| eventType: e ? e.type : "none", | |
| }); | |
| if (!isDrawing) return; | |
| setIsDrawing(false); | |
| // Remove the timeout-based generation | |
| if (strokeTimeoutRef.current) { | |
| clearTimeout(strokeTimeoutRef.current); | |
| strokeTimeoutRef.current = null; | |
| } | |
| // The Canvas component will handle generation for pen and pencil tools directly | |
| // This function now primarily handles stroke counting for other tools | |
| // Only generate on mouse/touch up events when not using the pen or pencil tool | |
| // (since those are handled by the Canvas component) | |
| if ( | |
| e && | |
| (e.type === "mouseup" || e.type === "touchend") && | |
| currentTool !== "pen" && | |
| currentTool !== "pencil" | |
| ) { | |
| console.log("stopDrawing: detected mouseup/touchend event", { | |
| strokeCount, | |
| }); | |
| // Check if we have enough strokes to generate (increased to 10 from 3) | |
| if (strokeCount >= 10) { | |
| console.log( | |
| "stopDrawing: calling handleGeneration due to stroke count" | |
| ); | |
| await handleGeneration(); | |
| setStrokeCount(0); | |
| } | |
| } | |
| }; | |
| const clearCanvas = () => { | |
| // If we have a ref to our Canvas component, use its custom clear method | |
| if (canvasComponentRef.current?.handleClearCanvas) { | |
| canvasComponentRef.current.handleClearCanvas(); | |
| return; | |
| } | |
| // Fallback to original implementation | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| initializeCanvas(canvas); | |
| setGeneratedImage(null); | |
| backgroundImageRef.current = null; | |
| // Also clear the display canvas and reset generated content flag | |
| if (displayCanvasRef.current) { | |
| const displayCtx = displayCanvasRef.current.getContext("2d"); | |
| displayCtx.clearRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| displayCtx.fillStyle = "#FFFFFF"; | |
| displayCtx.fillRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| setHasGeneratedContent(false); | |
| } | |
| // Save empty canvas state | |
| saveCanvasState(); | |
| }; | |
| const handleGeneration = useCallback( | |
| async (isManualRegeneration = false) => { | |
| console.log("handleGeneration called", { isManualRegeneration }); | |
| // Set our ref if this is a manual regeneration | |
| if (isManualRegeneration) { | |
| isManualRegenerationRef.current = true; | |
| } | |
| // Remove the time throttling for automatic generation after doodle conversion | |
| // but keep it for manual generations | |
| const isAutoGeneration = !lastRequestTime && !isManualRegeneration; | |
| if (!isAutoGeneration) { | |
| const now = Date.now(); | |
| if (now - lastRequestTime < MIN_REQUEST_INTERVAL) { | |
| console.log("Request throttled - too soon after last request"); | |
| return; | |
| } | |
| setLastRequestTime(now); | |
| } | |
| if (!canvasRef.current) return; | |
| console.log("Starting generation process"); | |
| // Check if we're already in a loading state before setting it | |
| if (!isLoading) { | |
| setIsLoading(true); | |
| } | |
| try { | |
| const canvas = canvasRef.current; | |
| const tempCanvas = document.createElement("canvas"); | |
| tempCanvas.width = canvas.width; | |
| tempCanvas.height = canvas.height; | |
| const tempCtx = tempCanvas.getContext("2d"); | |
| tempCtx.fillStyle = "#FFFFFF"; | |
| tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); | |
| tempCtx.drawImage(canvas, 0, 0); | |
| const drawingData = tempCanvas.toDataURL("image/png").split(",")[1]; | |
| const materialPrompt = getPromptForStyle(styleMode); | |
| const requestPayload = { | |
| prompt: materialPrompt, | |
| drawingData, | |
| customApiKey, | |
| }; | |
| console.log("Making API request with style:", styleMode); | |
| console.log(`Using prompt: ${materialPrompt.substring(0, 100)}...`); | |
| const response = await fetch("/api/generate", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(requestPayload), | |
| }); | |
| console.log("API response received, status:", response.status); | |
| const data = await response.json(); | |
| if (data.success && data.imageData) { | |
| console.log("Image generated successfully"); | |
| const imageUrl = `data:image/png;base64,${data.imageData}`; | |
| // Draw the generated image to the display canvas | |
| const displayCanvas = displayCanvasRef.current; | |
| if (!displayCanvas) { | |
| console.error("Display canvas ref is null"); | |
| return; | |
| } | |
| const displayCtx = displayCanvas.getContext("2d"); | |
| // Clear the display canvas first | |
| displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| displayCtx.fillStyle = "#FFFFFF"; | |
| displayCtx.fillRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| // Create and load the new image | |
| const img = new Image(); | |
| // Set up the onload handler before setting the src | |
| img.onload = () => { | |
| console.log("Generated image loaded, drawing to display canvas"); | |
| // Clear the canvas first | |
| displayCtx.clearRect( | |
| 0, | |
| 0, | |
| displayCanvas.width, | |
| displayCanvas.height | |
| ); | |
| // Fill with black background for letterboxing | |
| displayCtx.fillStyle = "#000000"; | |
| displayCtx.fillRect( | |
| 0, | |
| 0, | |
| displayCanvas.width, | |
| displayCanvas.height | |
| ); | |
| // Calculate aspect ratios | |
| const imgRatio = img.width / img.height; | |
| const canvasRatio = displayCanvas.width / displayCanvas.height; | |
| let drawWidth, drawHeight, x, y; | |
| if (imgRatio > canvasRatio) { | |
| // Image is wider than canvas (relative to height) | |
| drawWidth = displayCanvas.width; | |
| drawHeight = displayCanvas.width / imgRatio; | |
| x = 0; | |
| y = (displayCanvas.height - drawHeight) / 2; | |
| } else { | |
| // Image is taller than canvas (relative to width) | |
| drawHeight = displayCanvas.height; | |
| drawWidth = displayCanvas.height * imgRatio; | |
| x = (displayCanvas.width - drawWidth) / 2; | |
| y = 0; | |
| } | |
| // Draw the image with letterboxing | |
| displayCtx.drawImage(img, x, y, drawWidth, drawHeight); | |
| // Update our state to indicate we have generated content | |
| setHasGeneratedContent(true); | |
| // Add to history | |
| setImageHistory((prev) => [ | |
| ...prev, | |
| { | |
| imageUrl, | |
| timestamp: Date.now(), | |
| drawingData: canvas.toDataURL(), | |
| styleMode, | |
| dimensions: currentDimension, | |
| }, | |
| ]); | |
| }; | |
| // Set the src to trigger loading | |
| img.src = imageUrl; | |
| } else { | |
| console.error("Failed to generate image:", data.error); | |
| // When generation fails, ensure display canvas is cleared | |
| if (displayCanvasRef.current) { | |
| const displayCtx = displayCanvasRef.current.getContext("2d"); | |
| displayCtx.clearRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| displayCtx.fillStyle = "#FFFFFF"; | |
| displayCtx.fillRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| } | |
| // Make sure we mark that we don't have generated content | |
| setHasGeneratedContent(false); | |
| // Check for quota or API key errors | |
| if ( | |
| data.error && | |
| (data.error.includes("Resource has been exhausted") || | |
| data.error.includes("quota") || | |
| data.error.includes("exceeded") || | |
| response.status === 429) | |
| ) { | |
| // Show API key modal instead of error modal for quota issues | |
| setShowApiKeyModal(true); | |
| } else if (response.status === 500) { | |
| // Show regular error modal for other server errors | |
| setErrorMessage(data.error); | |
| setShowErrorModal(true); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error generating image:", error); | |
| // Check for quota-related errors in the catch block too | |
| if ( | |
| error.message && | |
| (error.message.includes("Resource has been exhausted") || | |
| error.message.includes("quota") || | |
| error.message.includes("exceeded") || | |
| error.message.includes("429")) | |
| ) { | |
| // Show API key modal for quota issues | |
| setShowApiKeyModal(true); | |
| } else { | |
| // Show regular error modal for other errors | |
| setErrorMessage(error.message || "An unexpected error occurred."); | |
| setShowErrorModal(true); | |
| } | |
| // When generation errors, ensure display canvas is cleared | |
| if (displayCanvasRef.current) { | |
| const displayCtx = displayCanvasRef.current.getContext("2d"); | |
| displayCtx.clearRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| displayCtx.fillStyle = "#FFFFFF"; | |
| displayCtx.fillRect( | |
| 0, | |
| 0, | |
| displayCanvasRef.current.width, | |
| displayCanvasRef.current.height | |
| ); | |
| } | |
| // Make sure we mark that we don't have generated content | |
| setHasGeneratedContent(false); | |
| } finally { | |
| setIsLoading(false); | |
| console.log("Generation process completed"); | |
| } | |
| }, | |
| [canvasRef, isLoading, styleMode, customApiKey, lastRequestTime] | |
| ); | |
| // Close the error modal | |
| const closeErrorModal = () => { | |
| setShowErrorModal(false); | |
| }; | |
| // Handle the custom API key submission | |
| const handleApiKeySubmit = (apiKey) => { | |
| setCustomApiKey(apiKey); | |
| // Save to localStorage for persistence | |
| localStorage.setItem("geminiApiKey", apiKey); | |
| // Close the API key modal | |
| setShowApiKeyModal(false); | |
| // Also close error modal if it was open | |
| setShowErrorModal(false); | |
| // Show confirmation toast | |
| toast.success("API key saved successfully"); | |
| }; | |
| // Add this function to handle undo | |
| const handleUndo = () => { | |
| if (undoStack.length > 0) { | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| const previousState = undoStack[undoStack.length - 2]; // Get second to last state | |
| if (previousState) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(img, 0, 0); | |
| }; | |
| img.src = previousState; | |
| } else { | |
| // If no previous state, clear to white | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| } | |
| setUndoStack((prev) => prev.slice(0, -1)); | |
| } | |
| }; | |
| // Add this function to save canvas state | |
| const saveCanvasState = () => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const dataURL = canvas.toDataURL(); | |
| setUndoStack((prev) => [...prev, dataURL]); | |
| }; | |
| // Add this function to handle text input | |
| const handleTextInput = (e) => { | |
| if (e.key === "Enter") { | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.font = "24px Arial"; | |
| ctx.fillStyle = "#000000"; | |
| ctx.fillText(textInput, textPosition.x, textPosition.y); | |
| setTextInput(""); | |
| setIsTyping(false); | |
| saveCanvasState(); | |
| } | |
| }; | |
| // Modify the canvas click handler to handle text placement | |
| const handleCanvasClick = (e) => { | |
| if (currentTool === "text") { | |
| const { x, y } = getCoordinates(e, canvasRef.current); | |
| setTextPosition({ x, y }); | |
| setIsTyping(true); | |
| if (textInputRef.current) { | |
| textInputRef.current.focus(); | |
| } | |
| } | |
| }; | |
| // Handle pen click for bezier curve tool | |
| const handlePenClick = (e) => { | |
| if (currentTool !== "pen") return; | |
| // Note: Actual point creation is now handled in the Canvas component | |
| // This function is primarily used as a callback to inform the CanvasContainer | |
| // that a pen action happened | |
| console.log("handlePenClick called in CanvasContainer"); | |
| // Set isDrawing flag to true when using pen tool | |
| // This ensures handleStopDrawing knows we're in drawing mode with the pen | |
| setIsDrawing(true); | |
| // Save canvas state when adding new points | |
| saveCanvasState(); | |
| }; | |
| // Add this new function near your other utility functions | |
| const handleSaveImage = useCallback(() => { | |
| if (displayCanvasRef.current && hasGeneratedContent) { | |
| const canvas = displayCanvasRef.current; | |
| const link = document.createElement('a'); | |
| // Create timestamp in format: YYYYMMDD_HHMM | |
| const now = new Date(); | |
| const timestamp = now.toISOString() | |
| .replace(/[-:T]/g, '') // Remove all separators | |
| .slice(0, 12); // Keep only YYYYMMDDHHMM | |
| // Get the actual material name from styleOptions | |
| const materialName = styleOptions[styleMode]?.name || styleMode; | |
| // Create filename: timestamp_materialname.png | |
| const filename = `${timestamp}_${materialName}.png`; | |
| link.download = filename; | |
| link.href = canvas.toDataURL('image/png'); | |
| link.click(); | |
| toast.success(`Saved as "${filename}"`); | |
| } else { | |
| toast.error("No generated image to save."); | |
| } | |
| }, [displayCanvasRef, hasGeneratedContent, styleMode]); | |
| // Add this function to handle regeneration | |
| const handleRegenerate = async () => { | |
| if (canvasRef.current) { | |
| // Set flag to prevent useEffect hooks from triggering additional generations | |
| isManualRegenerationRef.current = true; | |
| await handleGeneration(true); | |
| } | |
| }; | |
| // Add useEffect to watch for styleMode changes and regenerate | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| useEffect(() => { | |
| // Skip if this was triggered by a manual regeneration | |
| if (isManualRegenerationRef.current) { | |
| console.log("Skipping automatic generation due to manual regeneration"); | |
| return; | |
| } | |
| // Only trigger if we have something drawn (check if canvas is not empty) | |
| // Note: handleGeneration is intentionally omitted from dependencies to prevent infinite loops | |
| const checkCanvasAndGenerate = async () => { | |
| if (!canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| // Check if canvas has any non-white pixels | |
| const hasDrawing = Array.from(imageData.data).some((pixel, index) => { | |
| // Check only RGB values (skip alpha) | |
| return index % 4 !== 3 && pixel !== 255; | |
| }); | |
| // Only generate if there's a drawing AND we don't already have generated content | |
| if (hasDrawing && !hasGeneratedContent) { | |
| await handleGeneration(); | |
| } else if (hasDrawing) { | |
| // Mark that regeneration is needed when style changes but we already have content | |
| needsRegenerationRef.current = true; | |
| } | |
| }; | |
| // Skip on first render | |
| if (styleMode) { | |
| checkCanvasAndGenerate(); | |
| } | |
| }, [styleMode, hasGeneratedContent]); // Removed handleGeneration from dependencies to prevent loop | |
| // Add new useEffect to handle regeneration when hasGeneratedContent changes to false | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| useEffect(() => { | |
| // Skip if this was triggered by a manual regeneration | |
| if (isManualRegenerationRef.current) { | |
| console.log("Skipping automatic generation due to manual regeneration"); | |
| // Reset the flag after the first render with it set | |
| isManualRegenerationRef.current = false; | |
| return; | |
| } | |
| // Note: handleGeneration is intentionally omitted from dependencies to prevent infinite loops | |
| // If we need regeneration and the generated content was cleared | |
| if (needsRegenerationRef.current && !hasGeneratedContent) { | |
| const checkDrawingAndRegenerate = async () => { | |
| if (!canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| // Check if canvas has any non-white pixels | |
| const hasDrawing = Array.from(imageData.data).some((pixel, index) => { | |
| // Check only RGB values (skip alpha) | |
| return index % 4 !== 3 && pixel !== 255; | |
| }); | |
| if (hasDrawing) { | |
| needsRegenerationRef.current = false; | |
| await handleGeneration(); | |
| } | |
| }; | |
| checkDrawingAndRegenerate(); | |
| } | |
| }, [hasGeneratedContent]); | |
| // Cleanup function - keep this to prevent memory leaks | |
| useEffect(() => { | |
| return () => { | |
| if (strokeTimeoutRef.current) { | |
| clearTimeout(strokeTimeoutRef.current); | |
| strokeTimeoutRef.current = null; | |
| } | |
| }; | |
| }, []); | |
| // Handle dimension change | |
| const handleDimensionChange = (newDimension) => { | |
| console.log("Changing dimensions to:", newDimension); | |
| // Clear both canvases | |
| if (canvasRef.current) { | |
| const canvas = canvasRef.current; | |
| canvas.width = newDimension.width; | |
| canvas.height = newDimension.height; | |
| initializeCanvas(canvas); | |
| } | |
| if (displayCanvasRef.current) { | |
| const displayCanvas = displayCanvasRef.current; | |
| displayCanvas.width = newDimension.width; | |
| displayCanvas.height = newDimension.height; | |
| const ctx = displayCanvas.getContext("2d"); | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.fillRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| } | |
| // Reset generation state | |
| setHasGeneratedContent(false); | |
| setGeneratedImage(null); | |
| backgroundImageRef.current = null; | |
| // Update dimension state AFTER canvas dimensions are updated | |
| setCurrentDimension(newDimension); | |
| }; | |
| // Add new function to handle selecting a historical image | |
| const handleSelectHistoricalImage = (historyItem) => { | |
| // First set the dimensions and wait for canvases to update | |
| if (historyItem.dimensions) { | |
| // Update canvas dimensions first | |
| if (canvasRef.current) { | |
| canvasRef.current.width = historyItem.dimensions.width; | |
| canvasRef.current.height = historyItem.dimensions.height; | |
| } | |
| if (displayCanvasRef.current) { | |
| displayCanvasRef.current.width = historyItem.dimensions.width; | |
| displayCanvasRef.current.height = historyItem.dimensions.height; | |
| } | |
| // Then update the dimension state | |
| setCurrentDimension(historyItem.dimensions); | |
| } | |
| // Use Promise to ensure images are loaded after dimensions are set | |
| Promise.resolve().then(() => { | |
| // Draw the original drawing to the canvas | |
| const drawingImg = new Image(); | |
| drawingImg.onload = () => { | |
| const canvas = canvasRef.current; | |
| if (canvas) { | |
| const ctx = canvas.getContext("2d"); | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(drawingImg, 0, 0, canvas.width, canvas.height); | |
| } | |
| }; | |
| drawingImg.src = historyItem.drawingData; | |
| // Draw the generated image to the display canvas | |
| const generatedImg = new Image(); | |
| generatedImg.onload = () => { | |
| const displayCanvas = displayCanvasRef.current; | |
| if (displayCanvas) { | |
| const ctx = displayCanvas.getContext("2d"); | |
| ctx.fillStyle = "#000000"; // Black background for letterboxing | |
| ctx.fillRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| // Calculate aspect ratios | |
| const imgRatio = generatedImg.width / generatedImg.height; | |
| const canvasRatio = displayCanvas.width / displayCanvas.height; | |
| let drawWidth, drawHeight, x, y; | |
| if (imgRatio > canvasRatio) { | |
| // Image is wider than canvas | |
| drawWidth = displayCanvas.width; | |
| drawHeight = displayCanvas.width / imgRatio; | |
| x = 0; | |
| y = (displayCanvas.height - drawHeight) / 2; | |
| } else { | |
| // Image is taller than canvas | |
| drawHeight = displayCanvas.height; | |
| drawWidth = displayCanvas.height * imgRatio; | |
| x = (displayCanvas.width - drawWidth) / 2; | |
| y = 0; | |
| } | |
| // Draw the image with letterboxing | |
| ctx.drawImage(generatedImg, x, y, drawWidth, drawHeight); | |
| setHasGeneratedContent(true); | |
| } | |
| }; | |
| generatedImg.src = historyItem.imageUrl; | |
| }); | |
| // Close the history modal | |
| setIsHistoryModalOpen(false); | |
| }; | |
| // Add new function to handle image refinement | |
| const handleImageRefinement = async (refinementPrompt) => { | |
| if (!displayCanvasRef.current || !hasGeneratedContent) return; | |
| console.log("Starting image refinement with prompt:", refinementPrompt); | |
| setIsLoading(true); | |
| try { | |
| // Get the current image data | |
| const displayCanvas = displayCanvasRef.current; | |
| const imageData = displayCanvas.toDataURL("image/png").split(",")[1]; | |
| const requestPayload = { | |
| prompt: refinementPrompt, | |
| imageData, | |
| customApiKey, | |
| }; | |
| console.log("Making refinement API request"); | |
| const response = await fetch("/api/refine", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(requestPayload), | |
| }); | |
| console.log("Refinement API response received, status:", response.status); | |
| const data = await response.json(); | |
| if (data.success && data.imageData) { | |
| console.log("Image refined successfully"); | |
| const imageUrl = `data:image/png;base64,${data.imageData}`; | |
| // Draw the refined image to the display canvas | |
| const displayCtx = displayCanvas.getContext("2d"); | |
| const img = new Image(); | |
| img.onload = () => { | |
| console.log("Refined image loaded, drawing to display canvas"); | |
| // Clear the canvas | |
| displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| // Fill with black background for letterboxing | |
| displayCtx.fillStyle = "#000000"; | |
| displayCtx.fillRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| // Calculate aspect ratios | |
| const imgRatio = img.width / img.height; | |
| const canvasRatio = displayCanvas.width / displayCanvas.height; | |
| let drawWidth, drawHeight, x, y; | |
| if (imgRatio > canvasRatio) { | |
| // Image is wider than canvas (relative to height) | |
| drawWidth = displayCanvas.width; | |
| drawHeight = displayCanvas.width / imgRatio; | |
| x = 0; | |
| y = (displayCanvas.height - drawHeight) / 2; | |
| } else { | |
| // Image is taller than canvas (relative to width) | |
| drawHeight = displayCanvas.height; | |
| drawWidth = displayCanvas.height * imgRatio; | |
| x = (displayCanvas.width - drawWidth) / 2; | |
| y = 0; | |
| } | |
| // Draw the image with letterboxing | |
| displayCtx.drawImage(img, x, y, drawWidth, drawHeight); | |
| // Add to history | |
| setImageHistory((prev) => [ | |
| ...prev, | |
| { | |
| imageUrl, | |
| timestamp: Date.now(), | |
| drawingData: canvasRef.current.toDataURL(), | |
| styleMode, | |
| dimensions: currentDimension, | |
| }, | |
| ]); | |
| }; | |
| img.src = imageUrl; | |
| } else { | |
| console.error("Failed to refine image:", data.error); | |
| // Check for quota or API key errors | |
| if ( | |
| data.error && | |
| (data.error.includes("Resource has been exhausted") || | |
| data.error.includes("quota") || | |
| data.error.includes("exceeded") || | |
| response.status === 429) | |
| ) { | |
| // Show API key modal instead of error modal for quota issues | |
| setShowApiKeyModal(true); | |
| } else { | |
| // Show regular error modal for other errors | |
| setErrorMessage(data.error || "Failed to refine image. Please try again."); | |
| setShowErrorModal(true); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error during refinement:", error); | |
| // Check for quota-related errors in the catch block | |
| if ( | |
| error.message && | |
| (error.message.includes("Resource has been exhausted") || | |
| error.message.includes("quota") || | |
| error.message.includes("exceeded") || | |
| error.message.includes("429")) | |
| ) { | |
| // Show API key modal for quota issues | |
| setShowApiKeyModal(true); | |
| } else { | |
| // Show regular error modal for other errors | |
| setErrorMessage("An error occurred during refinement. Please try again."); | |
| setShowErrorModal(true); | |
| } | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Add onImageUpload function | |
| const handleImageUpload = (imageDataUrl) => { | |
| if (!canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Clear the canvas | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Calculate dimensions to maintain aspect ratio and fit within canvas | |
| const scale = Math.min( | |
| canvas.width / img.width, | |
| canvas.height / img.height | |
| ); | |
| const x = (canvas.width - img.width * scale) / 2; | |
| const y = (canvas.height - img.height * scale) / 2; | |
| // Draw the image centered and scaled | |
| ctx.drawImage(img, x, y, img.width * scale, img.height * scale); | |
| // Save canvas state after uploading image | |
| saveCanvasState(); | |
| setHasGeneratedContent(true); | |
| }; | |
| img.src = imageDataUrl; | |
| }; | |
| // Add stroke width handler | |
| const handleStrokeWidth = (width) => { | |
| setPenWidth(width); | |
| }; | |
| // Function to handle sending the generated image back to the doodle canvas | |
| const handleSendToDoodle = useCallback( | |
| async (imageDataUrl) => { | |
| if (!imageDataUrl || isSendingToDoodle) return; | |
| console.log("Sending image back to doodle canvas..."); | |
| setIsSendingToDoodle(true); | |
| let response; // Define response outside try | |
| try { | |
| const base64Data = imageDataUrl.split(",")[1]; | |
| response = await fetch("/api/convert-to-doodle", { | |
| // Assign to outer scope variable | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| imageData: base64Data, | |
| customApiKey // Pass the custom API key | |
| }), | |
| }); | |
| // Check for non-OK HTTP status first | |
| if (!response.ok) { | |
| let errorBody = await response.text(); // Get raw text first | |
| let errorMessage = `API Error: ${response.status}`; | |
| try { | |
| // Try parsing error response as JSON | |
| const errorData = JSON.parse(errorBody); | |
| errorMessage = errorData.error || errorMessage; | |
| } catch (parseError) { | |
| // If response wasn't JSON (like the "Body exceeded" error) | |
| console.error("API response was not valid JSON:", errorBody); | |
| // Use truncated raw text in the error message | |
| errorMessage = `${errorMessage}. Response: ${errorBody.substring( | |
| 0, | |
| 100 | |
| )}${errorBody.length > 100 ? "..." : ""}`; | |
| } | |
| // Check if this is a quota error | |
| if ( | |
| errorMessage.includes("quota") || | |
| errorMessage.includes("exceeded") || | |
| errorMessage.includes("Resource has been exhausted") || | |
| response.status === 429 | |
| ) { | |
| // Show API key modal for quota issues | |
| setShowApiKeyModal(true); | |
| setIsSendingToDoodle(false); | |
| return; | |
| } | |
| throw new Error(errorMessage); // Throw error to be caught below | |
| } | |
| // If response.ok, proceed to parse the JSON body | |
| const result = await response.json(); | |
| if (result.success && result.imageData) { | |
| const mainCtx = canvasRef.current?.getContext("2d"); | |
| if (mainCtx && canvasRef.current) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Clear canvas without triggering state updates | |
| mainCtx.fillStyle = '#FFFFFF'; | |
| mainCtx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); | |
| // Draw the new image | |
| mainCtx.drawImage( | |
| img, | |
| 0, | |
| 0, | |
| canvasRef.current.width, | |
| canvasRef.current.height | |
| ); | |
| // Batch our state updates | |
| Promise.resolve().then(() => { | |
| setTempPoints([]); | |
| if (canvasRef.current.setHasDrawing) { | |
| canvasRef.current.setHasDrawing(true); | |
| } | |
| // Save canvas state after all state updates are complete | |
| requestAnimationFrame(() => { | |
| saveCanvasState(); | |
| toast.success("Image sent back to doodle canvas!"); | |
| setIsSendingToDoodle(false); | |
| }); | |
| }); | |
| }; | |
| img.onerror = (err) => { | |
| console.error("Error loading converted doodle image:", err); | |
| toast.error("Failed to load the converted doodle."); | |
| setIsSendingToDoodle(false); // Turn off loading on image load error | |
| }; | |
| img.src = `data:image/png;base64,${result.imageData}`; | |
| } else { | |
| throw new Error("Main canvas context not available."); | |
| } | |
| } else { | |
| // Handle cases where API returns success: false or missing imageData | |
| throw new Error( | |
| result.error || "API returned success:false or missing data." | |
| ); | |
| } | |
| } catch (error) { | |
| // This catches errors from fetch, response.ok check, response.json(), or explicit throws | |
| console.error("Error sending image back to doodle:", error); | |
| // Check for quota errors in catch block | |
| if ( | |
| error.message && | |
| (error.message.includes("quota") || | |
| error.message.includes("exceeded") || | |
| error.message.includes("Resource has been exhausted") || | |
| error.message.includes("429")) | |
| ) { | |
| // Show API key modal for quota issues | |
| setShowApiKeyModal(true); | |
| } else { | |
| toast.error(`Error: ${error.message || "An unknown error occurred."}`); | |
| } | |
| // Ensure loading state is turned off in *any* error scenario | |
| setIsSendingToDoodle(false); | |
| } | |
| }, | |
| [isSendingToDoodle, clearCanvas, saveCanvasState, setTempPoints, toast, customApiKey] | |
| ); | |
| // Function to open history modal | |
| const openHistoryModal = () => { | |
| setIsHistoryModalOpen(true); | |
| }; | |
| // Updated function for library button | |
| const toggleLibrary = () => { | |
| setShowLibrary(prev => !prev); | |
| }; | |
| // Calculate if history exists | |
| const hasHistory = imageHistory && imageHistory.length > 0; | |
| // Add this helper function for image compression | |
| const compressImage = useCallback(async (dataUrl, maxWidth = 1200) => { | |
| return new Promise((resolve) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Create a canvas to resize the image | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Calculate new dimensions | |
| let width = img.width; | |
| let height = img.height; | |
| if (width > maxWidth) { | |
| height = (height * maxWidth) / width; | |
| width = maxWidth; | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| // Draw and export as JPEG with lower quality | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fillRect(0, 0, width, height); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| resolve(canvas.toDataURL('image/jpeg', 0.85)); | |
| }; | |
| img.src = dataUrl; | |
| }); | |
| }, []); | |
| // Add this new function to handle using a library image as template | |
| const handleUseAsTemplate = useCallback(async (imageUrl) => { | |
| console.log('Using library image as template:', imageUrl); | |
| // Show loading state with specific messages for each step | |
| setTemplateLoadingMessage("Preparing template..."); | |
| setIsTemplateLoading(true); | |
| try { | |
| // 1. Create a material from the image | |
| // First, fetch the image and convert to base64 | |
| const response = await fetch(imageUrl); | |
| const blob = await response.blob(); | |
| // Convert blob to base64 | |
| const reader = new FileReader(); | |
| const imageDataPromise = new Promise((resolve) => { | |
| reader.onloadend = () => resolve(reader.result); | |
| reader.readAsDataURL(blob); | |
| }); | |
| const imageDataUrl = await imageDataPromise; | |
| // Process with visual-enhance-prompt API (compress the image first) | |
| setTemplateLoadingMessage("Analyzing image..."); | |
| const compressedImage = await compressImage(imageDataUrl, 1200); | |
| // Get custom API key if it exists | |
| const customApiKey = localStorage.getItem("geminiApiKey"); | |
| // Call the visual-enhance-prompt API | |
| const promptResponse = await fetch('/api/visual-enhance-prompt', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| image: compressedImage, | |
| customApiKey, | |
| basePrompt: 'Transform this sketch into a material with professional studio lighting against a pure black background. Render it in Cinema 4D with Octane for a high-end 3D visualization.' | |
| }), | |
| }); | |
| if (!promptResponse.ok) { | |
| throw new Error(`API returned ${promptResponse.status}`); | |
| } | |
| const promptData = await promptResponse.json(); | |
| // 2. Add material to StyleSelector | |
| if (promptData.enhancedPrompt && promptData.suggestedName) { | |
| setTemplateLoadingMessage("Creating material..."); | |
| // Create material object - use a smaller compressed image for thumbnail | |
| const thumbnailImage = await compressImage(imageDataUrl, 300); | |
| const materialObj = { | |
| name: promptData.suggestedName, | |
| prompt: promptData.enhancedPrompt, | |
| image: thumbnailImage // Use compressed thumbnail | |
| }; | |
| // Add material to library and get the key | |
| const materialKey = addMaterialToLibrary(materialObj); | |
| // Select this new material | |
| setStyleMode(materialKey); | |
| // 3. Convert the library image to a doodle and render in Canvas | |
| setTemplateLoadingMessage("Converting to doodle..."); | |
| const doodleResponse = await fetch('/api/convert-to-doodle', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| imageData: compressedImage.split(',')[1], | |
| customApiKey | |
| }), | |
| }); | |
| if (!doodleResponse.ok) { | |
| throw new Error(`Doodle conversion API returned ${doodleResponse.status}`); | |
| } | |
| const doodleData = await doodleResponse.json(); | |
| if (doodleData.success && doodleData.imageData) { | |
| // Render the doodle on the canvas | |
| const mainCtx = canvasRef.current?.getContext("2d"); | |
| if (mainCtx && canvasRef.current) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Clear canvas | |
| mainCtx.fillStyle = '#FFFFFF'; | |
| mainCtx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); | |
| // Calculate appropriate dimensions while maintaining aspect ratio | |
| // and respecting the current canvas dimensions | |
| const canvasWidth = canvasRef.current.width; | |
| const canvasHeight = canvasRef.current.height; | |
| const imgRatio = img.width / img.height; | |
| const canvasRatio = canvasWidth / canvasHeight; | |
| // Declare variables separately to fix linter warning | |
| let drawWidth = 0; | |
| let drawHeight = 0; | |
| let x = 0; | |
| let y = 0; | |
| if (imgRatio > canvasRatio) { | |
| // Image is wider relative to canvas | |
| drawWidth = canvasWidth * 0.8; | |
| drawHeight = drawWidth / imgRatio; | |
| x = canvasWidth * 0.1; | |
| y = (canvasHeight - drawHeight) / 2; | |
| } else { | |
| // Image is taller relative to canvas | |
| drawHeight = canvasHeight * 0.8; | |
| drawWidth = drawHeight * imgRatio; | |
| x = (canvasWidth - drawWidth) / 2; | |
| y = canvasHeight * 0.1; | |
| } | |
| // Draw doodle | |
| mainCtx.drawImage(img, x, y, drawWidth, drawHeight); | |
| // Save canvas state | |
| if (typeof saveCanvasState === 'function') { | |
| saveCanvasState(); | |
| } | |
| // Mark as having drawing | |
| setHasDrawing(true); | |
| // 4. Show the original image in the display canvas first | |
| setTemplateLoadingMessage("Generating material preview..."); | |
| // Draw the original image to the display canvas | |
| if (displayCanvasRef.current) { | |
| const displayCtx = displayCanvasRef.current.getContext("2d"); | |
| if (displayCtx) { | |
| // Create a new image for the display canvas | |
| const displayImg = new Image(); | |
| displayImg.onload = () => { | |
| // Clear display canvas first | |
| displayCtx.clearRect(0, 0, displayCanvasRef.current.width, displayCanvasRef.current.height); | |
| // Fill with black background for letterboxing | |
| displayCtx.fillStyle = "#000000"; | |
| displayCtx.fillRect(0, 0, displayCanvasRef.current.width, displayCanvasRef.current.height); | |
| // Calculate aspect ratios for display canvas | |
| const imgRatio = displayImg.width / displayImg.height; | |
| const canvasRatio = displayCanvasRef.current.width / displayCanvasRef.current.height; | |
| // Declare variables separately | |
| let dispDrawWidth = 0; | |
| let dispDrawHeight = 0; | |
| let dispX = 0; | |
| let dispY = 0; | |
| if (imgRatio > canvasRatio) { | |
| // Image is wider than canvas | |
| dispDrawWidth = displayCanvasRef.current.width; | |
| dispDrawHeight = displayCanvasRef.current.width / imgRatio; | |
| dispX = 0; | |
| dispY = (displayCanvasRef.current.height - dispDrawHeight) / 2; | |
| } else { | |
| // Image is taller than canvas | |
| dispDrawHeight = displayCanvasRef.current.height; | |
| dispDrawWidth = displayCanvasRef.current.height * imgRatio; | |
| dispX = (displayCanvasRef.current.width - dispDrawWidth) / 2; | |
| dispY = 0; | |
| } | |
| // Draw the image with letterboxing | |
| displayCtx.drawImage(displayImg, dispX, dispY, dispDrawWidth, dispDrawHeight); | |
| // Set flag to indicate we have generated content | |
| setHasGeneratedContent(true); | |
| // 5. Finally, trigger generation to show the styled version | |
| // Close the library view and finish the template process | |
| setShowLibrary(false); | |
| // Slight delay before starting generation | |
| setTimeout(() => { | |
| handleGeneration(); | |
| // Turn off template loading | |
| setIsTemplateLoading(false); | |
| setTemplateLoadingMessage(""); | |
| }, 500); | |
| }; | |
| // Load the original image for display | |
| displayImg.src = imageUrl; | |
| } | |
| } else { | |
| // If no display canvas, just trigger generation and finish | |
| handleGeneration(); | |
| setShowLibrary(false); | |
| setIsTemplateLoading(false); | |
| setTemplateLoadingMessage(""); | |
| } | |
| }; | |
| img.src = `data:image/png;base64,${doodleData.imageData}`; | |
| } else { | |
| throw new Error("Canvas context unavailable"); | |
| } | |
| } else { | |
| throw new Error("Failed to convert to doodle"); | |
| } | |
| } else { | |
| throw new Error("Failed to analyze image"); | |
| } | |
| } catch (error) { | |
| console.error('Error using image as template:', error); | |
| toast.error('Failed to use image as template'); | |
| setIsTemplateLoading(false); | |
| setTemplateLoadingMessage(""); | |
| } | |
| }, [compressImage, handleGeneration]); | |
| return ( | |
| <div className="flex min-h-screen flex-col items-center justify-start bg-gray-50 p-2 md:p-4 overflow-y-auto"> | |
| {showLibrary ? ( | |
| <LibraryPage onBack={toggleLibrary} onUseAsTemplate={handleUseAsTemplate} /> | |
| ) : ( | |
| <div className="w-full max-w-[1800px] mx-auto pb-4"> | |
| <div className="space-y-1"> | |
| <div className="flex flex-col sm:flex-row items-start justify-between gap-2"> | |
| <div className="flex-shrink-0"> | |
| <Header /> | |
| </div> | |
| {/* Header Buttons Section - only visible on desktop */} | |
| <div className="hidden md:flex items-center gap-2 mt-auto sm:mt-8"> | |
| <HeaderButtons | |
| hasHistory={hasHistory} | |
| openHistoryModal={openHistoryModal} | |
| toggleLibrary={toggleLibrary} | |
| handleSaveImage={handleSaveImage} | |
| isLoading={isLoading} | |
| hasGeneratedContent={hasGeneratedContent} | |
| /> | |
| </div> | |
| </div> | |
| {/* New single row layout */} | |
| <div className="flex flex-col md:flex-row items-stretch gap-4 w-full md:mt-4"> | |
| {/* Toolbar - fixed width on desktop, full width horizontal on mobile */} | |
| <div className="w-full md:w-[60px] md:flex-shrink-0"> | |
| {/* Mobile toolbar (horizontal) */} | |
| <div className="block md:hidden w-fit"> | |
| <ToolBar | |
| currentTool={currentTool} | |
| setCurrentTool={setCurrentTool} | |
| handleUndo={handleUndo} | |
| clearCanvas={clearCanvas} | |
| orientation="horizontal" | |
| currentWidth={penWidth} | |
| setStrokeWidth={handleStrokeWidth} | |
| currentDimension={currentDimension} | |
| onDimensionChange={handleDimensionChange} | |
| /> | |
| </div> | |
| {/* Desktop toolbar (vertical) */} | |
| <div className="hidden md:block"> | |
| <ToolBar | |
| currentTool={currentTool} | |
| setCurrentTool={setCurrentTool} | |
| handleUndo={handleUndo} | |
| clearCanvas={clearCanvas} | |
| orientation="vertical" | |
| currentWidth={penWidth} | |
| setStrokeWidth={handleStrokeWidth} | |
| currentDimension={currentDimension} | |
| onDimensionChange={handleDimensionChange} | |
| /> | |
| </div> | |
| </div> | |
| {/* Main content area */} | |
| <div className="flex-1 flex flex-col gap-4"> | |
| {/* Canvas row */} | |
| <div className="flex flex-col md:flex-row gap-2"> | |
| {/* Canvas */} | |
| <div className="flex-1 w-full relative"> | |
| <Canvas | |
| ref={canvasComponentRef} | |
| canvasRef={canvasRef} | |
| currentTool={currentTool} | |
| isDrawing={isDrawing} | |
| startDrawing={startDrawing} | |
| draw={draw} | |
| stopDrawing={stopDrawing} | |
| handleCanvasClick={handleCanvasClick} | |
| handlePenClick={handlePenClick} | |
| handleGeneration={handleGeneration} | |
| tempPoints={tempPoints} | |
| setTempPoints={setTempPoints} | |
| handleUndo={handleUndo} | |
| clearCanvas={clearCanvas} | |
| setCurrentTool={setCurrentTool} | |
| currentDimension={currentDimension} | |
| currentColor={penColor} | |
| currentWidth={penWidth} | |
| onImageUpload={handleImageUpload} | |
| onGenerate={handleGeneration} | |
| isGenerating={isLoading} | |
| setIsGenerating={setIsLoading} | |
| saveCanvasState={saveCanvasState} | |
| onDrawingChange={setHasDrawing} | |
| styleMode={styleMode} | |
| setStyleMode={setStyleMode} | |
| isSendingToDoodle={isSendingToDoodle} | |
| customApiKey={customApiKey} | |
| onOpenApiKeyModal={() => setShowApiKeyModal(true)} | |
| /> | |
| </div> | |
| {/* Display Canvas */} | |
| <div className="flex-1 w-full"> | |
| <DisplayCanvas | |
| displayCanvasRef={displayCanvasRef} | |
| isLoading={isLoading} | |
| handleRegenerate={handleRegenerate} | |
| hasGeneratedContent={hasGeneratedContent} | |
| currentDimension={currentDimension} | |
| onOpenHistory={openHistoryModal} | |
| onRefineImage={handleImageRefinement} | |
| onSendToDoodle={handleSendToDoodle} | |
| hasHistory={hasHistory} | |
| openHistoryModal={openHistoryModal} | |
| toggleLibrary={toggleLibrary} | |
| handleSaveImage={handleSaveImage} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <ErrorModal | |
| showErrorModal={showErrorModal} | |
| closeErrorModal={closeErrorModal} | |
| customApiKey={customApiKey} | |
| setCustomApiKey={setCustomApiKey} | |
| handleApiKeySubmit={handleApiKeySubmit} | |
| debugMode={debugMode} | |
| setDebugMode={setDebugMode} | |
| /> | |
| <ApiKeyModal | |
| isOpen={showApiKeyModal} | |
| onClose={() => setShowApiKeyModal(false)} | |
| onSubmit={handleApiKeySubmit} | |
| initialValue={customApiKey} | |
| /> | |
| <TextInput | |
| isTyping={isTyping} | |
| textInputRef={textInputRef} | |
| textInput={textInput} | |
| setTextInput={setTextInput} | |
| handleTextInput={handleTextInput} | |
| textPosition={textPosition} | |
| /> | |
| <HistoryModal | |
| isOpen={isHistoryModalOpen} | |
| onClose={() => setIsHistoryModalOpen(false)} | |
| history={imageHistory} | |
| onSelectImage={handleSelectHistoricalImage} | |
| currentDimension={currentDimension} | |
| /> | |
| {/* Template loading overlay */} | |
| {isTemplateLoading && ( | |
| <div className="fixed inset-0 flex flex-col items-center justify-center bg-black/50 z-50"> | |
| <div className="bg-white shadow-lg rounded-xl p-6 flex flex-col items-center max-w-md"> | |
| <LoaderCircle className="w-12 h-12 text-blue-600 animate-spin mb-4" /> | |
| <p className="text-gray-900 font-medium text-lg">{templateLoadingMessage || "Processing template..."}</p> | |
| <p className="text-gray-500 text-sm mt-2">This may take a moment</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default CanvasContainer; | |