gemini-3d-drawing / components /CanvasContainer.js
Trudy's picture
passing custom api key
84cbf90
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;