From 98797b938578a6f8889de3c28f024816bed0385e Mon Sep 17 00:00:00 2001 From: markfulton Date: Sun, 31 Aug 2025 23:42:08 +0700 Subject: [PATCH] Version 1.0 Release --- README.md | 14 +- index.html | 2 +- src/App.tsx | 67 +++++ src/components/Header.tsx | 41 +++ src/components/HistoryPanel.tsx | 408 +++++++++++++++++++++++++++ src/components/ImageCanvas.tsx | 370 ++++++++++++++++++++++++ src/components/ImagePreviewModal.tsx | 54 ++++ src/components/InfoModal.tsx | 92 ++++++ src/components/MaskOverlay.tsx | 36 +++ src/components/PromptComposer.tsx | 406 ++++++++++++++++++++++++++ src/components/PromptHints.tsx | 87 ++++++ src/components/ui/Button.tsx | 46 +++ src/components/ui/Input.tsx | 22 ++ src/components/ui/Textarea.tsx | 21 ++ src/hooks/useImageGeneration.ts | 274 ++++++++++++++++++ src/hooks/useKeyboardShortcuts.ts | 63 +++++ src/index.css | 77 +++++ src/main.tsx | 10 + src/services/cacheService.ts | 71 +++++ src/services/geminiService.ts | 169 +++++++++++ src/services/imageProcessing.ts | 95 +++++++ src/store/useAppStore.ts | 164 +++++++++++ src/types/index.ts | 67 +++++ src/utils/cn.ts | 6 + src/utils/imageUtils.ts | 63 +++++ src/vite-env.d.ts | 1 + 26 files changed, 2718 insertions(+), 8 deletions(-) create mode 100644 src/App.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/HistoryPanel.tsx create mode 100644 src/components/ImageCanvas.tsx create mode 100644 src/components/ImagePreviewModal.tsx create mode 100644 src/components/InfoModal.tsx create mode 100644 src/components/MaskOverlay.tsx create mode 100644 src/components/PromptComposer.tsx create mode 100644 src/components/PromptHints.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/components/ui/Textarea.tsx create mode 100644 src/hooks/useImageGeneration.ts create mode 100644 src/hooks/useKeyboardShortcuts.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/services/cacheService.ts create mode 100644 src/services/geminiService.ts create mode 100644 src/services/imageProcessing.ts create mode 100644 src/store/useAppStore.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/cn.ts create mode 100644 src/utils/imageUtils.ts create mode 100644 src/vite-env.d.ts diff --git a/README.md b/README.md index 817f1b2..c07812f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# 🍌 Nano Banana Image Editor +# 🍌 Nano Banana Image Editor +Release Version: (v1.0) -> **🚀 Get Your Free Copy!** Join the [Vibe Coding is Life Skool Community](https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41) and get a **1-click Bolt.new installation clone** of this app, plus access to live build sessions, exclusive project downloads, AI prompts, masterclasses, and the best vibe coding community on the web! +> **⏬ Get Your Free 1-Click Install Copy!** Join the [Vibe Coding is Life Skool Community](https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41) and get a **1-click Bolt.new installation clone** of this app, plus access to live build sessions, exclusive project downloads, AI prompts, masterclasses, and the best vibe coding community on the web! --- @@ -8,7 +9,7 @@ A production-ready React + TypeScript application for delightful image generation and conversational, region-aware revisions using Google's Gemini 2.5 Flash Image model. Built with modern web technologies and designed for both creators and developers. -![Nano Banana Image Editor](https://images.pexels.com/photos/1037992/pexels-photo-1037992.jpeg?auto=compress&cs=tinysrgb&w=1200&h=400&fit=crop) +![Nano Banana Image Editor](https://getsmartgpt.com/nano-banana-editor.jpg) ## ✨ Key Features @@ -207,17 +208,16 @@ We welcome contributions! Please: - **Client-side API calls** - Currently uses direct API calls (implement backend proxy for production) - **Browser compatibility** - Requires modern browsers with Canvas and WebGL support - **Rate limits** - Subject to Google AI Studio rate limits -- **Image size** - Optimized for 1024×1024 outputs (Gemini model limitation) +- **Image size** - Optimized for 1024×1024 outputs (Gemini model output dimensions may vary) -## 🎯 Roadmap +## 🎯 Suggested Updates - [ ] Backend API proxy implementation - [ ] User authentication and project sharing - [ ] Advanced brush tools and selection methods -- [ ] Batch processing capabilities - [ ] Plugin system for custom filters - [ ] Integration with cloud storage providers --- -**Built with ❤️ by the Vibe Coding community** | **Powered by Gemini 2.5 Flash Image** | **Made with Bolt.new** +**Built by [Mark Fulton](https://markfulton.com) ** | **Powered by Gemini 2.5 Flash Image** | **Made with Bolt.new** \ No newline at end of file diff --git a/index.html b/index.html index 6e17394..1b959b6 100644 --- a/index.html +++ b/index.html @@ -49,7 +49,7 @@ - Nano Banana Image Editor - AI Image Generator & Editor + Nano Banana AI Image Editor - AI Image Generator & Editor diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..375bfb4 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cn } from './utils/cn'; +import { Header } from './components/Header'; +import { PromptComposer } from './components/PromptComposer'; +import { ImageCanvas } from './components/ImageCanvas'; +import { HistoryPanel } from './components/HistoryPanel'; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import { useAppStore } from './store/useAppStore'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 2, + }, + }, +}); + +function AppContent() { + useKeyboardShortcuts(); + + const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore(); + + // Set mobile defaults on mount + React.useEffect(() => { + const checkMobile = () => { + const isMobile = window.innerWidth < 768; + if (isMobile) { + setShowPromptPanel(false); + setShowHistory(false); + } + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, [setShowPromptPanel, setShowHistory]); + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ); +} + +function App() { + return ( + + + + ); +} + +export default App; \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..44b82b2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { Button } from './ui/Button'; +import { HelpCircle } from 'lucide-react'; +import { InfoModal } from './InfoModal'; + +export const Header: React.FC = () => { + const [showInfoModal, setShowInfoModal] = useState(false); + + return ( + <> +
+
+
+
🍌
+

+ Nano Banana AI Image Editor +

+

+ NB Editor +

+
+
+ 1.0 +
+
+ +
+ +
+
+ + + + ); +}; \ No newline at end of file diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx new file mode 100644 index 0000000..77a2129 --- /dev/null +++ b/src/components/HistoryPanel.tsx @@ -0,0 +1,408 @@ +import React from 'react'; +import { useAppStore } from '../store/useAppStore'; +import { Button } from './ui/Button'; +import { History, Download, Image as ImageIcon, Layers } from 'lucide-react'; +import { cn } from '../utils/cn'; +import { ImagePreviewModal } from './ImagePreviewModal'; + +export const HistoryPanel: React.FC = () => { + const { + currentProject, + canvasImage, + selectedGenerationId, + selectedEditId, + selectGeneration, + selectEdit, + showHistory, + setShowHistory, + setCanvasImage, + selectedTool + } = useAppStore(); + + const [previewModal, setPreviewModal] = React.useState<{ + open: boolean; + imageUrl: string; + title: string; + description?: string; + }>({ + open: false, + imageUrl: '', + title: '', + description: '' + }); + + const generations = currentProject?.generations || []; + const edits = currentProject?.edits || []; + + // Get current image dimensions + const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null); + + React.useEffect(() => { + if (canvasImage) { + const img = new Image(); + img.onload = () => { + setImageDimensions({ width: img.width, height: img.height }); + }; + img.src = canvasImage; + } else { + setImageDimensions(null); + } + }, [canvasImage]); + + if (!showHistory) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

History & Variants

+
+ +
+ + {/* Variants Grid */} +
+

Current Variants

+ {generations.length === 0 && edits.length === 0 ? ( +
+
🖼️
+

No generations yet

+
+ ) : ( +
+ {/* Show generations */} + {generations.slice(-2).map((generation, index) => ( +
{ + selectGeneration(generation.id); + if (generation.outputAssets[0]) { + setCanvasImage(generation.outputAssets[0].url); + } + }} + > + {generation.outputAssets[0] ? ( + <> + Generated variant + + ) : ( +
+
+
+ )} + + {/* Variant Number */} +
+ #{index + 1} +
+
+ ))} + + {/* Show edits */} + {edits.slice(-2).map((edit, index) => ( +
{ + if (edit.outputAssets[0]) { + setCanvasImage(edit.outputAssets[0].url); + selectEdit(edit.id); + selectGeneration(null); + } + }} + > + {edit.outputAssets[0] ? ( + Edited variant + ) : ( +
+
+
+ )} + + {/* Edit Label */} +
+ Edit #{index + 1} +
+
+ ))} +
+ )} +
+ + {/* Current Image Info */} + {(canvasImage || imageDimensions) && ( +
+

Current Image

+
+ {imageDimensions && ( +
+ Dimensions: + {imageDimensions.width} × {imageDimensions.height} +
+ )} +
+ Mode: + {selectedTool} +
+
+
+ )} + + {/* Generation Details */} +
+

Generation Details

+ {(() => { + const gen = generations.find(g => g.id === selectedGenerationId); + const selectedEdit = edits.find(e => e.id === selectedEditId); + + if (gen) { + return ( +
+
+
+ Prompt: +

{gen.prompt}

+
+
+ Model: + {gen.modelVersion} +
+ {gen.parameters.seed && ( +
+ Seed: + {gen.parameters.seed} +
+ )} +
+ + {/* Reference Images */} + {gen.sourceAssets.length > 0 && ( +
+
Reference Images
+
+ {gen.sourceAssets.map((asset, index) => ( + + ))} +
+
+ )} +
+ ); + } else if (selectedEdit) { + const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId); + return ( +
+
+
+ Edit Instruction: +

{selectedEdit.instruction}

+
+
+ Type: + Image Edit +
+
+ Created: + {new Date(selectedEdit.timestamp).toLocaleTimeString()} +
+ {selectedEdit.maskAssetId && ( +
+ Mask: + Applied +
+ )} +
+ + {/* Parent Generation Reference */} + {parentGen && ( +
+
Original Image
+ +
+ )} + + {/* Mask Visualization */} + {selectedEdit.maskReferenceAsset && ( +
+
Masked Reference
+ +
+ )} +
+ ); + } else { + return ( +
+

Select a generation or edit to view details

+
+ ); + } + })()} +
+ + {/* Actions */} +
+ +
+ + {/* Image Preview Modal */} + setPreviewModal(prev => ({ ...prev, open }))} + imageUrl={previewModal.imageUrl} + title={previewModal.title} + description={previewModal.description} + /> +
+ ); +}; \ No newline at end of file diff --git a/src/components/ImageCanvas.tsx b/src/components/ImageCanvas.tsx new file mode 100644 index 0000000..c42c402 --- /dev/null +++ b/src/components/ImageCanvas.tsx @@ -0,0 +1,370 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva'; +import { useAppStore } from '../store/useAppStore'; +import { Button } from './ui/Button'; +import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react'; +import { cn } from '../utils/cn'; + +export const ImageCanvas: React.FC = () => { + const { + canvasImage, + canvasZoom, + setCanvasZoom, + canvasPan, + setCanvasPan, + brushStrokes, + addBrushStroke, + clearBrushStrokes, + showMasks, + setShowMasks, + selectedTool, + isGenerating, + brushSize, + setBrushSize + } = useAppStore(); + + const stageRef = useRef(null); + const [image, setImage] = useState(null); + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + const [isDrawing, setIsDrawing] = useState(false); + const [currentStroke, setCurrentStroke] = useState([]); + + // Load image and auto-fit when canvasImage changes + useEffect(() => { + if (canvasImage) { + const img = new window.Image(); + img.onload = () => { + setImage(img); + + // Only auto-fit if this is a new image (no existing zoom/pan state) + if (canvasZoom === 1 && canvasPan.x === 0 && canvasPan.y === 0) { + // Auto-fit image to canvas + const isMobile = window.innerWidth < 768; + const padding = isMobile ? 0.9 : 0.8; // Use more of the screen on mobile + + const scaleX = (stageSize.width * padding) / img.width; + const scaleY = (stageSize.height * padding) / img.height; + + const maxZoom = isMobile ? 0.3 : 0.8; + const optimalZoom = Math.min(scaleX, scaleY, maxZoom); + + setCanvasZoom(optimalZoom); + + // Center the image + setCanvasPan({ x: 0, y: 0 }); + } + }; + img.src = canvasImage; + } else { + setImage(null); + } + }, [canvasImage, stageSize, setCanvasZoom, setCanvasPan, canvasZoom, canvasPan]); + + // Handle stage resize + useEffect(() => { + const updateSize = () => { + const container = document.getElementById('canvas-container'); + if (container) { + setStageSize({ + width: container.offsetWidth, + height: container.offsetHeight + }); + } + }; + + updateSize(); + window.addEventListener('resize', updateSize); + return () => window.removeEventListener('resize', updateSize); + }, []); + + const handleMouseDown = (e: any) => { + if (selectedTool !== 'mask' || !image) return; + + setIsDrawing(true); + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + + // Use Konva's getRelativePointerPosition for accurate coordinates + const relativePos = stage.getRelativePointerPosition(); + + // Calculate image bounds on the stage + const imageX = (stageSize.width / canvasZoom - image.width) / 2; + const imageY = (stageSize.height / canvasZoom - image.height) / 2; + + // Convert to image-relative coordinates + const relativeX = relativePos.x - imageX; + const relativeY = relativePos.y - imageY; + + // Check if click is within image bounds + if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) { + setCurrentStroke([relativeX, relativeY]); + } + }; + + const handleMouseMove = (e: any) => { + if (!isDrawing || selectedTool !== 'mask' || !image) return; + + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + + // Use Konva's getRelativePointerPosition for accurate coordinates + const relativePos = stage.getRelativePointerPosition(); + + // Calculate image bounds on the stage + const imageX = (stageSize.width / canvasZoom - image.width) / 2; + const imageY = (stageSize.height / canvasZoom - image.height) / 2; + + // Convert to image-relative coordinates + const relativeX = relativePos.x - imageX; + const relativeY = relativePos.y - imageY; + + // Check if within image bounds + if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) { + setCurrentStroke([...currentStroke, relativeX, relativeY]); + } + }; + + const handleMouseUp = () => { + if (!isDrawing || currentStroke.length < 4) { + setIsDrawing(false); + setCurrentStroke([]); + return; + } + + setIsDrawing(false); + addBrushStroke({ + id: `stroke-${Date.now()}`, + points: currentStroke, + brushSize, + }); + setCurrentStroke([]); + }; + + const handleZoom = (delta: number) => { + const newZoom = Math.max(0.1, Math.min(3, canvasZoom + delta)); + setCanvasZoom(newZoom); + }; + + const handleReset = () => { + if (image) { + const isMobile = window.innerWidth < 768; + const padding = isMobile ? 0.9 : 0.8; + const scaleX = (stageSize.width * padding) / image.width; + const scaleY = (stageSize.height * padding) / image.height; + const maxZoom = isMobile ? 0.3 : 0.8; + const optimalZoom = Math.min(scaleX, scaleY, maxZoom); + + setCanvasZoom(optimalZoom); + setCanvasPan({ x: 0, y: 0 }); + } + }; + + const handleDownload = () => { + if (canvasImage) { + if (canvasImage.startsWith('data:')) { + const link = document.createElement('a'); + link.href = canvasImage; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } + }; + + return ( +
+ {/* Toolbar */} +
+
+ {/* Left side - Zoom controls */} +
+ + + {Math.round(canvasZoom * 100)}% + + + +
+ + {/* Right side - Tools and actions */} +
+ {selectedTool === 'mask' && ( + <> +
+ Brush: + setBrushSize(parseInt(e.target.value))} + className="w-16 h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer slider" + /> + {brushSize} +
+ + + )} + + + + {canvasImage && ( + + )} +
+
+
+ + {/* Canvas Area */} +
+ {!image && !isGenerating && ( +
+
+
🍌
+

+ Welcome to Nano Banana Framework +

+

+ {selectedTool === 'generate' + ? 'Start by describing what you want to create in the prompt box' + : 'Upload an image to begin editing' + } +

+
+
+ )} + + {isGenerating && ( +
+
+
+

Creating your image...

+
+
+ )} + + { + setCanvasPan({ + x: e.target.x() / canvasZoom, + y: e.target.y() / canvasZoom + }); + }} + onMouseDown={handleMouseDown} + onMousemove={handleMouseMove} + onMouseup={handleMouseUp} + style={{ + cursor: selectedTool === 'mask' ? 'crosshair' : 'default' + }} + > + + {image && ( + + )} + + {/* Brush Strokes */} + {showMasks && brushStrokes.map((stroke) => ( + + ))} + + {/* Current stroke being drawn */} + {isDrawing && currentStroke.length > 2 && ( + + )} + + +
+ + {/* Status Bar */} +
+
+
+ {brushStrokes.length > 0 && ( + {brushStrokes.length} brush stroke{brushStrokes.length !== 1 ? 's' : ''} + )} +
+ +
+ + © 2025 Mark Fulton - + + Reinventing.AI Solutions + + + + + Powered by Gemini 2.5 Flash Image +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ImagePreviewModal.tsx b/src/components/ImagePreviewModal.tsx new file mode 100644 index 0000000..28dcdd7 --- /dev/null +++ b/src/components/ImagePreviewModal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { Button } from './ui/Button'; + +interface ImagePreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + imageUrl: string; + title: string; + description?: string; +} + +export const ImagePreviewModal: React.FC = ({ + open, + onOpenChange, + imageUrl, + title, + description +}) => { + return ( + + + + +
+ + {title} + + + + +
+ +
+ {description && ( +

{description}

+ )} + +
+ {title} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/InfoModal.tsx b/src/components/InfoModal.tsx new file mode 100644 index 0000000..cc0cd3f --- /dev/null +++ b/src/components/InfoModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { X, ExternalLink, Lightbulb, Download } from 'lucide-react'; +import { Button } from './ui/Button'; + +interface InfoModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const InfoModal: React.FC = ({ open, onOpenChange }) => { + return ( + + + + +
+ + About Nano Banana AI Image Editor + + + + +
+ +
+
+

+ Developed by{' '} + + Mark Fulton + + +

+ +
+
+
+ +

+ Learn to Build AI Apps & More Solutions +

+
+

+ Learn to vibe code apps like this one and master AI automation, build intelligent agents, and create cutting-edge solutions that drive real business results. +

+ + Join the AI Accelerator Program + + +
+ +
+
+ +

+ Get a Copy of This App +

+
+

+ Get a copy of this app by joining the Vibe Coding is Life Skool community. Live build sessions, app projects, resources and more in the best vibe coding community on the web. +

+ + Join Vibe Coding is Life Community + + +
+
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/MaskOverlay.tsx b/src/components/MaskOverlay.tsx new file mode 100644 index 0000000..2abaedc --- /dev/null +++ b/src/components/MaskOverlay.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useAppStore } from '../store/useAppStore'; + +export const MaskOverlay: React.FC = () => { + const { selectedMask, showMasks } = useAppStore(); + + if (!showMasks || !selectedMask) return null; + + return ( +
+ {/* Marching ants effect */} +
+ + {/* Mask overlay */} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/PromptComposer.tsx b/src/components/PromptComposer.tsx new file mode 100644 index 0000000..7bcee62 --- /dev/null +++ b/src/components/PromptComposer.tsx @@ -0,0 +1,406 @@ +import React, { useState, useRef } from 'react'; +import { Textarea } from './ui/Textarea'; +import { Button } from './ui/Button'; +import { useAppStore } from '../store/useAppStore'; +import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration'; +import { Upload, Wand2, Edit3, MousePointer, HelpCircle, Menu, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'; +import { blobToBase64 } from '../utils/imageUtils'; +import { PromptHints } from './PromptHints'; +import { cn } from '../utils/cn'; + +export const PromptComposer: React.FC = () => { + const { + currentPrompt, + setCurrentPrompt, + selectedTool, + setSelectedTool, + temperature, + setTemperature, + seed, + setSeed, + isGenerating, + uploadedImages, + addUploadedImage, + removeUploadedImage, + clearUploadedImages, + editReferenceImages, + addEditReferenceImage, + removeEditReferenceImage, + clearEditReferenceImages, + canvasImage, + setCanvasImage, + showPromptPanel, + setShowPromptPanel, + clearBrushStrokes, + } = useAppStore(); + + const { generate } = useImageGeneration(); + const { edit } = useImageEditing(); + const [showAdvanced, setShowAdvanced] = useState(false); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [showHintsModal, setShowHintsModal] = useState(false); + const fileInputRef = useRef(null); + + const handleGenerate = () => { + if (!currentPrompt.trim()) return; + + if (selectedTool === 'generate') { + const referenceImages = uploadedImages + .filter(img => img.includes('base64,')) + .map(img => img.split('base64,')[1]); + + generate({ + prompt: currentPrompt, + referenceImages: referenceImages.length > 0 ? referenceImages : undefined, + temperature, + seed: seed || undefined + }); + } else if (selectedTool === 'edit' || selectedTool === 'mask') { + edit(currentPrompt); + } + }; + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file && file.type.startsWith('image/')) { + try { + const base64 = await blobToBase64(file); + const dataUrl = `data:${file.type};base64,${base64}`; + + if (selectedTool === 'generate') { + // Add to reference images (max 2) + if (uploadedImages.length < 2) { + addUploadedImage(dataUrl); + } + } else if (selectedTool === 'edit') { + // For edit mode, add to separate edit reference images (max 2) + if (editReferenceImages.length < 2) { + addEditReferenceImage(dataUrl); + } + // Set as canvas image if none exists + if (!canvasImage) { + setCanvasImage(dataUrl); + } + } else if (selectedTool === 'mask') { + // For mask mode, set as canvas image immediately + clearUploadedImages(); + addUploadedImage(dataUrl); + setCanvasImage(dataUrl); + } + } catch (error) { + console.error('Failed to upload image:', error); + } + } + }; + + const handleClearSession = () => { + setCurrentPrompt(''); + clearUploadedImages(); + clearEditReferenceImages(); + clearBrushStrokes(); + setCanvasImage(null); + setSeed(null); + setTemperature(0.7); + setShowClearConfirm(false); + }; + + const tools = [ + { id: 'generate', icon: Wand2, label: 'Generate', description: 'Create from text' }, + { id: 'edit', icon: Edit3, label: 'Edit', description: 'Modify existing' }, + { id: 'mask', icon: MousePointer, label: 'Select', description: 'Click to select' }, + ] as const; + + if (!showPromptPanel) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+
+

Mode

+
+ + +
+
+
+ {tools.map((tool) => ( + + ))} +
+
+ + {/* File Upload */} +
+
+ + {selectedTool === 'mask' && ( +

Edit an image with masks

+ )} + {selectedTool === 'generate' && ( +

Optional, up to 2 images

+ )} + {selectedTool === 'edit' && ( +

+ {canvasImage ? 'Optional style references, up to 2 images' : 'Upload image to edit, up to 2 images'} +

+ )} + + + + {/* Show uploaded images preview */} + {((selectedTool === 'generate' && uploadedImages.length > 0) || + (selectedTool === 'edit' && editReferenceImages.length > 0)) && ( +
+ {(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => ( +
+ {`Reference + +
+ Ref {index + 1} +
+
+ ))} +
+ )} +
+
+ + {/* Prompt Input */} +
+ +