You've already forked Nano-Banana-AI-Image-Editor
Version 1.0 Release
This commit is contained in:
12
README.md
12
README.md
@@ -1,6 +1,7 @@
|
||||
# 🍌 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 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**
|
||||
@@ -49,7 +49,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍌</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nano Banana Image Editor - AI Image Generator & Editor</title>
|
||||
<title>Nano Banana AI Image Editor - AI Image Generator & Editor</title>
|
||||
<meta name="description" content="Professional AI image generation and conversational editing powered by Gemini 2.5 Flash Image. Create, edit, and enhance images with natural language prompts." />
|
||||
<meta name="keywords" content="AI image generation, image editing, Gemini AI, text to image, image enhancement, artificial intelligence" />
|
||||
<meta name="author" content="Mark Fulton" />
|
||||
|
||||
67
src/App.tsx
Normal file
67
src/App.tsx
Normal file
@@ -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 (
|
||||
<div className="h-screen bg-gray-900 text-gray-100 flex flex-col font-sans">
|
||||
<Header />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300", !showPromptPanel && "w-8")}>
|
||||
<PromptComposer />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<ImageCanvas />
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<HistoryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContent />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
41
src/components/Header.tsx
Normal file
41
src/components/Header.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<header className="h-16 bg-gray-950 border-b border-gray-800 flex items-center justify-between px-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-2xl">🍌</div>
|
||||
<h1 className="text-xl font-semibold text-gray-100 hidden md:block">
|
||||
Nano Banana AI Image Editor
|
||||
</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-100 md:hidden">
|
||||
NB Editor
|
||||
</h1>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 bg-gray-800 px-2 py-1 rounded">
|
||||
1.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowInfoModal(true)}
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
408
src/components/HistoryPanel.tsx
Normal file
408
src/components/HistoryPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="w-8 bg-gray-950 border-l border-gray-800 flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="w-6 h-16 bg-gray-800 hover:bg-gray-700 rounded-l-lg border border-r-0 border-gray-700 flex items-center justify-center transition-colors group"
|
||||
title="Show History Panel"
|
||||
>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-gray-950 border-l border-gray-800 p-6 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<History className="h-5 w-5 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-300">History & Variants</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="h-6 w-6"
|
||||
title="Hide History Panel"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Variants Grid */}
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-3">Current Variants</h4>
|
||||
{generations.length === 0 && edits.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">🖼️</div>
|
||||
<p className="text-sm text-gray-500">No generations yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Show generations */}
|
||||
{generations.slice(-2).map((generation, index) => (
|
||||
<div
|
||||
key={generation.id}
|
||||
className={cn(
|
||||
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
|
||||
selectedGenerationId === generation.id
|
||||
? 'border-yellow-400'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
)}
|
||||
onClick={() => {
|
||||
selectGeneration(generation.id);
|
||||
if (generation.outputAssets[0]) {
|
||||
setCanvasImage(generation.outputAssets[0].url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generation.outputAssets[0] ? (
|
||||
<>
|
||||
<img
|
||||
src={generation.outputAssets[0].url}
|
||||
alt="Generated variant"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variant Number */}
|
||||
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded">
|
||||
#{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show edits */}
|
||||
{edits.slice(-2).map((edit, index) => (
|
||||
<div
|
||||
key={edit.id}
|
||||
className={cn(
|
||||
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
|
||||
selectedEditId === edit.id
|
||||
? 'border-yellow-400'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (edit.outputAssets[0]) {
|
||||
setCanvasImage(edit.outputAssets[0].url);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{edit.outputAssets[0] ? (
|
||||
<img
|
||||
src={edit.outputAssets[0].url}
|
||||
alt="Edited variant"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Label */}
|
||||
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded">
|
||||
Edit #{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Image Info */}
|
||||
{(canvasImage || imageDimensions) && (
|
||||
<div className="mb-4 p-3 bg-gray-900 rounded-lg border border-gray-700">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">Current Image</h4>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
{imageDimensions && (
|
||||
<div className="flex justify-between">
|
||||
<span>Dimensions:</span>
|
||||
<span className="text-gray-300">{imageDimensions.width} × {imageDimensions.height}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>Mode:</span>
|
||||
<span className="text-gray-300 capitalize">{selectedTool}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation Details */}
|
||||
<div className="mb-6 p-4 bg-gray-900 rounded-lg border border-gray-700 flex-1 overflow-y-auto min-h-0">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">Generation Details</h4>
|
||||
{(() => {
|
||||
const gen = generations.find(g => g.id === selectedGenerationId);
|
||||
const selectedEdit = edits.find(e => e.id === selectedEditId);
|
||||
|
||||
if (gen) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="text-gray-400">Prompt:</span>
|
||||
<p className="text-gray-300 mt-1">{gen.prompt}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Model:</span>
|
||||
<span>{gen.modelVersion}</span>
|
||||
</div>
|
||||
{gen.parameters.seed && (
|
||||
<div className="flex justify-between">
|
||||
<span>Seed:</span>
|
||||
<span>{gen.parameters.seed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reference Images */}
|
||||
{gen.sourceAssets.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">Reference Images</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{gen.sourceAssets.map((asset, index) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: asset.url,
|
||||
title: `Reference Image ${index + 1}`,
|
||||
description: 'This reference image was used to guide the generation'
|
||||
})}
|
||||
className="relative aspect-square rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={`Reference ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-gray-300">
|
||||
Ref {index + 1}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (selectedEdit) {
|
||||
const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="text-gray-400">Edit Instruction:</span>
|
||||
<p className="text-gray-300 mt-1">{selectedEdit.instruction}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Type:</span>
|
||||
<span>Image Edit</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Created:</span>
|
||||
<span>{new Date(selectedEdit.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
{selectedEdit.maskAssetId && (
|
||||
<div className="flex justify-between">
|
||||
<span>Mask:</span>
|
||||
<span className="text-purple-400">Applied</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parent Generation Reference */}
|
||||
{parentGen && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">Original Image</h5>
|
||||
<button
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: parentGen.outputAssets[0]?.url || '',
|
||||
title: 'Original Image',
|
||||
description: 'The base image that was edited'
|
||||
})}
|
||||
className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={parentGen.outputAssets[0]?.url}
|
||||
alt="Original"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mask Visualization */}
|
||||
{selectedEdit.maskReferenceAsset && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">Masked Reference</h5>
|
||||
<button
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: selectedEdit.maskReferenceAsset!.url,
|
||||
title: 'Masked Reference Image',
|
||||
description: 'This image with mask overlay was sent to the AI model to guide the edit'
|
||||
})}
|
||||
className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={selectedEdit.maskReferenceAsset.url}
|
||||
alt="Masked reference"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-purple-300">
|
||||
Mask
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<p className="text-gray-400">Select a generation or edit to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// Find the currently displayed image (either generation or edit)
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
if (selectedGenerationId) {
|
||||
const gen = generations.find(g => g.id === selectedGenerationId);
|
||||
imageUrl = gen?.outputAssets[0]?.url || null;
|
||||
} else {
|
||||
// If no generation selected, try to get the current canvas image
|
||||
const { canvasImage } = useAppStore.getState();
|
||||
imageUrl = canvasImage;
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
// Handle both data URLs and regular URLs
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
// For external URLs, we need to fetch and convert to blob
|
||||
fetch(imageUrl)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!selectedGenerationId && !useAppStore.getState().canvasImage}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
<ImagePreviewModal
|
||||
open={previewModal.open}
|
||||
onOpenChange={(open) => setPreviewModal(prev => ({ ...prev, open }))}
|
||||
imageUrl={previewModal.imageUrl}
|
||||
title={previewModal.title}
|
||||
description={previewModal.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
370
src/components/ImageCanvas.tsx
Normal file
370
src/components/ImageCanvas.tsx
Normal file
@@ -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<any>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentStroke, setCurrentStroke] = useState<number[]>([]);
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="p-3 border-b border-gray-800 bg-gray-950">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Zoom controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleZoom(-0.1)}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-gray-400 min-w-[60px] text-center">
|
||||
{Math.round(canvasZoom * 100)}%
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => handleZoom(0.1)}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right side - Tools and actions */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{selectedTool === 'mask' && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mr-2">
|
||||
<span className="text-xs text-gray-400">Brush:</span>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="50"
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(parseInt(e.target.value))}
|
||||
className="w-16 h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 w-6">{brushSize}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearBrushStrokes}
|
||||
disabled={brushStrokes.length === 0}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMasks(!showMasks)}
|
||||
className={cn(showMasks && 'bg-yellow-400/10 border-yellow-400/50')}
|
||||
>
|
||||
{showMasks ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline ml-2">Masks</span>
|
||||
</Button>
|
||||
|
||||
{canvasImage && (
|
||||
<Button variant="secondary" size="sm" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<div
|
||||
id="canvas-container"
|
||||
className="flex-1 relative overflow-hidden bg-gray-800"
|
||||
>
|
||||
{!image && !isGenerating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🍌</div>
|
||||
<h2 className="text-xl font-medium text-gray-300 mb-2">
|
||||
Welcome to Nano Banana Framework
|
||||
</h2>
|
||||
<p className="text-gray-500 max-w-md">
|
||||
{selectedTool === 'generate'
|
||||
? 'Start by describing what you want to create in the prompt box'
|
||||
: 'Upload an image to begin editing'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGenerating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-yellow-400 mb-4" />
|
||||
<p className="text-gray-300">Creating your image...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
scaleX={canvasZoom}
|
||||
scaleY={canvasZoom}
|
||||
x={canvasPan.x * canvasZoom}
|
||||
y={canvasPan.y * canvasZoom}
|
||||
draggable={selectedTool !== 'mask'}
|
||||
onDragEnd={(e) => {
|
||||
setCanvasPan({
|
||||
x: e.target.x() / canvasZoom,
|
||||
y: e.target.y() / canvasZoom
|
||||
});
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMousemove={handleMouseMove}
|
||||
onMouseup={handleMouseUp}
|
||||
style={{
|
||||
cursor: selectedTool === 'mask' ? 'crosshair' : 'default'
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
{image && (
|
||||
<KonvaImage
|
||||
image={image}
|
||||
x={(stageSize.width / canvasZoom - image.width) / 2}
|
||||
y={(stageSize.height / canvasZoom - image.height) / 2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Brush Strokes */}
|
||||
{showMasks && brushStrokes.map((stroke) => (
|
||||
<Line
|
||||
key={stroke.id}
|
||||
points={stroke.points}
|
||||
stroke="#A855F7"
|
||||
strokeWidth={stroke.brushSize}
|
||||
tension={0.5}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
globalCompositeOperation="source-over"
|
||||
opacity={0.6}
|
||||
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
|
||||
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Current stroke being drawn */}
|
||||
{isDrawing && currentStroke.length > 2 && (
|
||||
<Line
|
||||
points={currentStroke}
|
||||
stroke="#A855F7"
|
||||
strokeWidth={brushSize}
|
||||
tension={0.5}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
globalCompositeOperation="source-over"
|
||||
opacity={0.6}
|
||||
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
|
||||
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="p-3 border-t border-gray-800 bg-gray-950">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-4">
|
||||
{brushStrokes.length > 0 && (
|
||||
<span className="text-yellow-400">{brushStrokes.length} brush stroke{brushStrokes.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
© 2025 Mark Fulton -
|
||||
<a
|
||||
href="https://www.reinventing.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-yellow-400 hover:text-yellow-300 transition-colors ml-1"
|
||||
>
|
||||
Reinventing.AI Solutions
|
||||
</a>
|
||||
</span>
|
||||
<span className="text-gray-600 hidden md:inline">•</span>
|
||||
<span className="text-yellow-400 hidden md:inline">⚡</span>
|
||||
<span className="hidden md:inline">Powered by Gemini 2.5 Flash Image</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
src/components/ImagePreviewModal.tsx
Normal file
54
src/components/ImagePreviewModal.tsx
Normal file
@@ -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<ImagePreviewModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
imageUrl,
|
||||
title,
|
||||
description
|
||||
}) => {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/80 z-50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto z-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-100">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{description && (
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
className="w-full h-auto rounded-lg border border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
92
src/components/InfoModal.tsx
Normal file
92
src/components/InfoModal.tsx
Normal file
@@ -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<InfoModalProps> = ({ open, onOpenChange }) => {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-4xl z-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-100">
|
||||
About Nano Banana AI Image Editor
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 text-sm text-gray-300">
|
||||
<p>
|
||||
Developed by{' '}
|
||||
<a
|
||||
href="https://markfulton.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-yellow-400 hover:text-yellow-300 transition-colors font-semibold"
|
||||
>
|
||||
Mark Fulton
|
||||
<ExternalLink className="h-3 w-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="p-4 bg-gradient-to-br from-purple-900/30 to-indigo-900/30 rounded-lg border border-purple-500/30">
|
||||
<div className="flex items-center mb-3">
|
||||
<Lightbulb className="h-5 w-5 text-purple-400 mr-2" />
|
||||
<h4 className="text-sm font-semibold text-purple-300">
|
||||
Learn to Build AI Apps & More Solutions
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 mb-4">
|
||||
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.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.reinventing.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
Join the AI Accelerator Program
|
||||
<ExternalLink className="h-4 w-4 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-br from-yellow-900/30 to-orange-900/30 rounded-lg border border-yellow-500/30">
|
||||
<div className="flex items-center mb-3">
|
||||
<Download className="h-5 w-5 text-yellow-400 mr-2" />
|
||||
<h4 className="text-sm font-semibold text-yellow-300">
|
||||
Get a Copy of This App
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 mb-4">
|
||||
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.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
Join Vibe Coding is Life Community
|
||||
<ExternalLink className="h-4 w-4 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
36
src/components/MaskOverlay.tsx
Normal file
36
src/components/MaskOverlay.tsx
Normal file
@@ -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 (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Marching ants effect */}
|
||||
<div
|
||||
className="absolute border-2 border-yellow-400 animate-pulse"
|
||||
style={{
|
||||
left: selectedMask.bounds.x,
|
||||
top: selectedMask.bounds.y,
|
||||
width: selectedMask.bounds.width,
|
||||
height: selectedMask.bounds.height,
|
||||
borderStyle: 'dashed',
|
||||
animationDuration: '1s'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mask overlay */}
|
||||
<div
|
||||
className="absolute bg-yellow-400/20"
|
||||
style={{
|
||||
left: selectedMask.bounds.x,
|
||||
top: selectedMask.bounds.y,
|
||||
width: selectedMask.bounds.width,
|
||||
height: selectedMask.bounds.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
406
src/components/PromptComposer.tsx
Normal file
406
src/components/PromptComposer.tsx
Normal file
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="w-8 bg-gray-950 border-r border-gray-800 flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setShowPromptPanel(true)}
|
||||
className="w-6 h-16 bg-gray-800 hover:bg-gray-700 rounded-r-lg border border-l-0 border-gray-700 flex items-center justify-center transition-colors group"
|
||||
title="Show Prompt Panel"
|
||||
>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-80 lg:w-72 xl:w-80 h-full bg-gray-950 border-r border-gray-800 p-6 flex flex-col space-y-6 overflow-y-auto">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">Mode</h3>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowHintsModal(true)}
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPromptPanel(false)}
|
||||
className="h-6 w-6"
|
||||
title="Hide Prompt Panel"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => setSelectedTool(tool.id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center p-3 rounded-lg border transition-all duration-200',
|
||||
selectedTool === tool.id
|
||||
? 'bg-yellow-400/10 border-yellow-400/50 text-yellow-400'
|
||||
: 'bg-gray-900 border-gray-700 text-gray-400 hover:bg-gray-800 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
<tool.icon className="h-5 w-5 mb-1" />
|
||||
<span className="text-xs font-medium">{tool.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-300 mb-1 block">
|
||||
{selectedTool === 'generate' ? 'Reference Images' : selectedTool === 'edit' ? 'Style References' : 'Upload Image'}
|
||||
</label>
|
||||
{selectedTool === 'mask' && (
|
||||
<p className="text-xs text-gray-400 mb-3">Edit an image with masks</p>
|
||||
)}
|
||||
{selectedTool === 'generate' && (
|
||||
<p className="text-xs text-gray-500 mb-3">Optional, up to 2 images</p>
|
||||
)}
|
||||
{selectedTool === 'edit' && (
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{canvasImage ? 'Optional style references, up to 2 images' : 'Upload image to edit, up to 2 images'}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full"
|
||||
disabled={
|
||||
(selectedTool === 'generate' && uploadedImages.length >= 2) ||
|
||||
(selectedTool === 'edit' && editReferenceImages.length >= 2)
|
||||
}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
{/* Show uploaded images preview */}
|
||||
{((selectedTool === 'generate' && uploadedImages.length > 0) ||
|
||||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={`Reference ${index + 1}`}
|
||||
className="w-full h-20 object-cover rounded-lg border border-gray-700"
|
||||
/>
|
||||
<button
|
||||
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
|
||||
className="absolute top-1 right-1 bg-gray-900/80 text-gray-400 hover:text-gray-200 rounded-full p-1 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1 bg-gray-900/80 text-xs px-2 py-1 rounded text-gray-300">
|
||||
Ref {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt Input */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-300 mb-3 block">
|
||||
{selectedTool === 'generate' ? 'Describe what you want to create' : 'Describe your changes'}
|
||||
</label>
|
||||
<Textarea
|
||||
value={currentPrompt}
|
||||
onChange={(e) => setCurrentPrompt(e.target.value)}
|
||||
placeholder={
|
||||
selectedTool === 'generate'
|
||||
? 'A serene mountain landscape at sunset with a lake reflecting the golden sky...'
|
||||
: 'Make the sky more dramatic, add storm clouds...'
|
||||
}
|
||||
className="min-h-[120px] resize-none"
|
||||
/>
|
||||
|
||||
{/* Prompt Quality Indicator */}
|
||||
<button
|
||||
onClick={() => setShowHintsModal(true)}
|
||||
className="mt-2 flex items-center text-xs hover:text-gray-400 transition-colors group"
|
||||
>
|
||||
{currentPrompt.length < 20 ? (
|
||||
<HelpCircle className="h-3 w-3 mr-2 text-red-500 group-hover:text-red-400" />
|
||||
) : (
|
||||
<div className={cn(
|
||||
'h-2 w-2 rounded-full mr-2',
|
||||
currentPrompt.length < 50 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
)} />
|
||||
)}
|
||||
<span className="text-gray-500 group-hover:text-gray-400">
|
||||
{currentPrompt.length < 20 ? 'Add detail for better results' :
|
||||
currentPrompt.length < 50 ? 'Good detail level' : 'Excellent prompt detail'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !currentPrompt.trim()}
|
||||
className="w-full h-14 text-base font-medium"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
{selectedTool === 'generate' ? 'Generate' : 'Apply Edit'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Advanced Controls */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center text-sm text-gray-400 hover:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown className="h-4 w-4 mr-1" /> : <ChevronRight className="h-4 w-4 mr-1" />}
|
||||
{showAdvanced ? 'Hide' : 'Show'} Advanced Controls
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(!showClearConfirm)}
|
||||
className="flex items-center text-sm text-gray-400 hover:text-red-400 transition-colors duration-200 mt-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear Session
|
||||
</button>
|
||||
|
||||
{showClearConfirm && (
|
||||
<div className="mt-3 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<p className="text-xs text-gray-300 mb-3">
|
||||
Are you sure you want to clear this session? This will remove all uploads, prompts, and canvas content.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearSession}
|
||||
className="flex-1"
|
||||
>
|
||||
Yes, Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-2 block">
|
||||
Creativity ({temperature})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Seed */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-2 block">
|
||||
Seed (optional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={seed || ''}
|
||||
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder="Random"
|
||||
className="w-full h-8 px-2 bg-gray-900 border border-gray-700 rounded text-xs text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="pt-4 border-t border-gray-800">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">Shortcuts</h4>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div className="flex justify-between">
|
||||
<span>Generate</span>
|
||||
<span>⌘ + Enter</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Re-roll</span>
|
||||
<span>⇧ + R</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Edit mode</span>
|
||||
<span>E</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>History</span>
|
||||
<span>H</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Toggle Panel</span>
|
||||
<span>P</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Prompt Hints Modal */}
|
||||
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
src/components/PromptHints.tsx
Normal file
87
src/components/PromptHints.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { PromptHint } from '../types';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
const promptHints: PromptHint[] = [
|
||||
{
|
||||
category: 'subject',
|
||||
text: 'Be specific about the main subject',
|
||||
example: '"A vintage red bicycle" vs "bicycle"'
|
||||
},
|
||||
{
|
||||
category: 'scene',
|
||||
text: 'Describe the environment and setting',
|
||||
example: '"in a cobblestone alley during golden hour"'
|
||||
},
|
||||
{
|
||||
category: 'action',
|
||||
text: 'Include movement or activity',
|
||||
example: '"cyclist pedaling through puddles"'
|
||||
},
|
||||
{
|
||||
category: 'style',
|
||||
text: 'Specify artistic style or mood',
|
||||
example: '"cinematic photography, moody lighting"'
|
||||
},
|
||||
{
|
||||
category: 'camera',
|
||||
text: 'Add camera perspective details',
|
||||
example: '"shot with 85mm lens, shallow depth of field"'
|
||||
}
|
||||
];
|
||||
|
||||
const categoryColors = {
|
||||
subject: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
|
||||
scene: 'bg-green-500/10 border-green-500/30 text-green-400',
|
||||
action: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
|
||||
style: 'bg-orange-500/10 border-orange-500/30 text-orange-400',
|
||||
camera: 'bg-pink-500/10 border-pink-500/30 text-pink-400',
|
||||
};
|
||||
|
||||
interface PromptHintsProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PromptHints: React.FC<PromptHintsProps> = ({ open, onOpenChange }) => {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-gray-900 border border-gray-700 rounded-lg p-6 w-full max-w-md max-h-[80vh] overflow-y-auto z-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-100">
|
||||
Prompt Quality Tips
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{promptHints.map((hint, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs border ${categoryColors[hint.category]}`}>
|
||||
{hint.category}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{hint.text}</p>
|
||||
<p className="text-sm text-gray-500 italic">{hint.example}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-4 bg-gray-800 rounded-lg border border-gray-700 mt-6">
|
||||
<p className="text-sm text-gray-300">
|
||||
<strong className="text-yellow-400">Best practice:</strong> Write full sentences that describe the complete scene,
|
||||
not just keywords. Think "paint me a picture with words."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-yellow-400 text-gray-900 hover:bg-yellow-300 focus-visible:ring-yellow-400',
|
||||
secondary: 'bg-gray-800 text-gray-100 hover:bg-gray-700 focus-visible:ring-gray-600',
|
||||
outline: 'border border-gray-600 bg-transparent text-gray-300 hover:bg-gray-800 hover:text-gray-100',
|
||||
ghost: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
lg: 'h-12 px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
22
src/components/ui/Input.tsx
Normal file
22
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-600 bg-gray-900 px-3 py-2 text-sm text-gray-100 ring-offset-gray-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
21
src/components/ui/Textarea.tsx
Normal file
21
src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-lg border border-gray-600 bg-gray-900 px-3 py-2 text-sm text-gray-100 ring-offset-gray-900 placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
274
src/hooks/useImageGeneration.ts
Normal file
274
src/hooks/useImageGeneration.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
import { Generation, Edit, Asset } from '../types';
|
||||
|
||||
export const useImageGeneration = () => {
|
||||
const { addGeneration, setIsGenerating, setCanvasImage, setCurrentProject, currentProject } = useAppStore();
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async (request: GenerationRequest) => {
|
||||
const images = await geminiService.generateImage(request);
|
||||
return images;
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsGenerating(true);
|
||||
},
|
||||
onSuccess: (images, request) => {
|
||||
if (images.length > 0) {
|
||||
const outputAssets: Asset[] = images.map((base64, index) => ({
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: `data:image/png;base64,${base64}`,
|
||||
mime: 'image/png',
|
||||
width: 1024, // Default Gemini output size
|
||||
height: 1024,
|
||||
checksum: base64.slice(0, 32) // Simple checksum
|
||||
}));
|
||||
|
||||
const generation: Generation = {
|
||||
id: generateId(),
|
||||
prompt: request.prompt,
|
||||
parameters: {
|
||||
aspectRatio: '1:1',
|
||||
seed: request.seed,
|
||||
temperature: request.temperature
|
||||
},
|
||||
sourceAssets: request.referenceImage ? [{
|
||||
id: generateId(),
|
||||
type: 'original',
|
||||
url: `data:image/png;base64,${request.referenceImages[0]}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: request.referenceImages[0].slice(0, 32)
|
||||
}] : request.referenceImages ? request.referenceImages.map((img, index) => ({
|
||||
id: generateId(),
|
||||
type: 'original' as const,
|
||||
url: `data:image/png;base64,${img}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: img.slice(0, 32)
|
||||
})) : [],
|
||||
outputAssets,
|
||||
modelVersion: 'gemini-2.5-flash-image-preview',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
addGeneration(generation);
|
||||
setCanvasImage(outputAssets[0].url);
|
||||
|
||||
// Create project if none exists
|
||||
if (!currentProject) {
|
||||
const newProject = {
|
||||
id: generateId(),
|
||||
title: 'Untitled Project',
|
||||
generations: [generation],
|
||||
edits: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
setCurrentProject(newProject);
|
||||
}
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Generation failed:', error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
generate: generateMutation.mutate,
|
||||
isGenerating: generateMutation.isPending,
|
||||
error: generateMutation.error
|
||||
};
|
||||
};
|
||||
|
||||
export const useImageEditing = () => {
|
||||
const {
|
||||
addEdit,
|
||||
setIsGenerating,
|
||||
setCanvasImage,
|
||||
canvasImage,
|
||||
editReferenceImages,
|
||||
brushStrokes,
|
||||
selectedGenerationId,
|
||||
currentProject,
|
||||
seed,
|
||||
temperature
|
||||
} = useAppStore();
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (instruction: string) => {
|
||||
// Always use canvas image as primary target if available, otherwise use first uploaded image
|
||||
const sourceImage = canvasImage || uploadedImages[0];
|
||||
if (!sourceImage) throw new Error('No image to edit');
|
||||
|
||||
// Convert canvas image to base64
|
||||
const base64Image = sourceImage.includes('base64,')
|
||||
? sourceImage.split('base64,')[1]
|
||||
: sourceImage;
|
||||
|
||||
// Get reference images for style guidance
|
||||
let referenceImages = editReferenceImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
|
||||
let maskImage: string | undefined;
|
||||
let maskedReferenceImage: string | undefined;
|
||||
|
||||
// Create mask from brush strokes if any exist
|
||||
if (brushStrokes.length > 0) {
|
||||
// Create a temporary image to get actual dimensions
|
||||
const tempImg = new Image();
|
||||
tempImg.src = sourceImage;
|
||||
await new Promise<void>((resolve) => {
|
||||
tempImg.onload = () => resolve();
|
||||
});
|
||||
|
||||
// Create mask canvas with exact image dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = tempImg.width;
|
||||
canvas.height = tempImg.height;
|
||||
|
||||
// Fill with black (unmasked areas)
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw white strokes (masked areas)
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
ctx.lineWidth = stroke.brushSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(stroke.points[0], stroke.points[1]);
|
||||
|
||||
for (let i = 2; i < stroke.points.length; i += 2) {
|
||||
ctx.lineTo(stroke.points[i], stroke.points[i + 1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// Convert mask to base64
|
||||
const maskDataUrl = canvas.toDataURL('image/png');
|
||||
maskImage = maskDataUrl.split('base64,')[1];
|
||||
|
||||
// Create masked reference image (original image with mask overlay)
|
||||
const maskedCanvas = document.createElement('canvas');
|
||||
const maskedCtx = maskedCanvas.getContext('2d')!;
|
||||
maskedCanvas.width = tempImg.width;
|
||||
maskedCanvas.height = tempImg.height;
|
||||
|
||||
// Draw original image
|
||||
maskedCtx.drawImage(tempImg, 0, 0);
|
||||
|
||||
// Draw mask overlay with transparency
|
||||
maskedCtx.globalCompositeOperation = 'source-over';
|
||||
maskedCtx.globalAlpha = 0.4;
|
||||
maskedCtx.fillStyle = '#A855F7';
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
maskedCtx.lineWidth = stroke.brushSize;
|
||||
maskedCtx.strokeStyle = '#A855F7';
|
||||
maskedCtx.lineCap = 'round';
|
||||
maskedCtx.lineJoin = 'round';
|
||||
maskedCtx.beginPath();
|
||||
maskedCtx.moveTo(stroke.points[0], stroke.points[1]);
|
||||
|
||||
for (let i = 2; i < stroke.points.length; i += 2) {
|
||||
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1]);
|
||||
}
|
||||
maskedCtx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
maskedCtx.globalAlpha = 1;
|
||||
maskedCtx.globalCompositeOperation = 'source-over';
|
||||
|
||||
const maskedDataUrl = maskedCanvas.toDataURL('image/png');
|
||||
maskedReferenceImage = maskedDataUrl.split('base64,')[1];
|
||||
|
||||
// Add the masked image as a reference for the model
|
||||
referenceImages = [maskedReferenceImage, ...referenceImages];
|
||||
}
|
||||
|
||||
const request: EditRequest = {
|
||||
instruction,
|
||||
originalImage: base64Image,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
maskImage,
|
||||
temperature,
|
||||
seed
|
||||
};
|
||||
|
||||
const images = await geminiService.editImage(request);
|
||||
return { images, maskedReferenceImage };
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsGenerating(true);
|
||||
},
|
||||
onSuccess: ({ images, maskedReferenceImage }, instruction) => {
|
||||
if (images.length > 0) {
|
||||
const outputAssets: Asset[] = images.map((base64, index) => ({
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: `data:image/png;base64,${base64}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: base64.slice(0, 32)
|
||||
}));
|
||||
|
||||
// Create mask reference asset if we have one
|
||||
const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? {
|
||||
id: generateId(),
|
||||
type: 'mask',
|
||||
url: `data:image/png;base64,${maskedReferenceImage}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: maskedReferenceImage.slice(0, 32)
|
||||
} : undefined;
|
||||
|
||||
const edit: Edit = {
|
||||
id: generateId(),
|
||||
parentGenerationId: selectedGenerationId || (currentProject?.generations[currentProject.generations.length - 1]?.id || ''),
|
||||
maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
|
||||
maskReferenceAsset,
|
||||
instruction,
|
||||
outputAssets,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
addEdit(edit);
|
||||
|
||||
// Automatically load the edited image in the canvas
|
||||
const { selectEdit, selectGeneration } = useAppStore.getState();
|
||||
setCanvasImage(outputAssets[0].url);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Edit failed:', error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
edit: editMutation.mutate,
|
||||
isEditing: editMutation.isPending,
|
||||
error: editMutation.error
|
||||
};
|
||||
};
|
||||
63
src/hooks/useKeyboardShortcuts.ts
Normal file
63
src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const {
|
||||
setSelectedTool,
|
||||
setShowHistory,
|
||||
showHistory,
|
||||
setShowPromptPanel,
|
||||
showPromptPanel,
|
||||
currentPrompt,
|
||||
isGenerating
|
||||
} = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement) {
|
||||
// Only handle Cmd/Ctrl + Enter for generation
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!isGenerating && currentPrompt.trim()) {
|
||||
console.log('Generate via keyboard shortcut');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'e':
|
||||
event.preventDefault();
|
||||
setSelectedTool('edit');
|
||||
break;
|
||||
case 'g':
|
||||
event.preventDefault();
|
||||
setSelectedTool('generate');
|
||||
break;
|
||||
case 'm':
|
||||
event.preventDefault();
|
||||
setSelectedTool('mask');
|
||||
break;
|
||||
case 'h':
|
||||
event.preventDefault();
|
||||
setShowHistory(!showHistory);
|
||||
break;
|
||||
case 'p':
|
||||
event.preventDefault();
|
||||
setShowPromptPanel(!showPromptPanel);
|
||||
break;
|
||||
case 'r':
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log('Re-roll variants');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [setSelectedTool, setShowHistory, showHistory, setShowPromptPanel, showPromptPanel, currentPrompt, isGenerating]);
|
||||
};
|
||||
77
src/index.css
Normal file
77
src/index.css
Normal file
@@ -0,0 +1,77 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgb(17 24 39);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(75 85 99);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(107 114 128);
|
||||
}
|
||||
|
||||
/* Custom range slider styling */
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #FDE047;
|
||||
cursor: pointer;
|
||||
border: 2px solid #1F2937;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #FDE047;
|
||||
cursor: pointer;
|
||||
border: 2px solid #1F2937;
|
||||
}
|
||||
|
||||
/* Marching ants animation */
|
||||
@keyframes marching-ants {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: 10; }
|
||||
}
|
||||
|
||||
.marching-ants {
|
||||
stroke-dasharray: 5 5;
|
||||
animation: marching-ants 0.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #FDE047;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
71
src/services/cacheService.ts
Normal file
71
src/services/cacheService.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { get, set, del, keys } from 'idb-keyval';
|
||||
import { Project, Generation, Asset } from '../types';
|
||||
|
||||
const CACHE_PREFIX = 'nano-banana';
|
||||
const CACHE_VERSION = '1.0';
|
||||
|
||||
export class CacheService {
|
||||
private static getKey(type: string, id: string): string {
|
||||
return `${CACHE_PREFIX}-${CACHE_VERSION}-${type}-${id}`;
|
||||
}
|
||||
|
||||
// Project caching
|
||||
static async saveProject(project: Project): Promise<void> {
|
||||
await set(this.getKey('project', project.id), project);
|
||||
}
|
||||
|
||||
static async getProject(id: string): Promise<Project | null> {
|
||||
return (await get(this.getKey('project', id))) || null;
|
||||
}
|
||||
|
||||
static async getAllProjects(): Promise<Project[]> {
|
||||
const allKeys = await keys();
|
||||
const projectKeys = allKeys.filter(key =>
|
||||
typeof key === 'string' && key.includes(`${CACHE_PREFIX}-${CACHE_VERSION}-project-`)
|
||||
);
|
||||
|
||||
const projects = await Promise.all(
|
||||
projectKeys.map(key => get(key as string))
|
||||
);
|
||||
|
||||
return projects.filter(Boolean) as Project[];
|
||||
}
|
||||
|
||||
// Asset caching (for offline access)
|
||||
static async cacheAsset(asset: Asset, data: Blob): Promise<void> {
|
||||
await set(this.getKey('asset', asset.id), {
|
||||
asset,
|
||||
data,
|
||||
cachedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
static async getCachedAsset(assetId: string): Promise<{ asset: Asset; data: Blob } | null> {
|
||||
const cached = await get(this.getKey('asset', assetId));
|
||||
return cached || null;
|
||||
}
|
||||
|
||||
// Generation metadata caching
|
||||
static async cacheGeneration(generation: Generation): Promise<void> {
|
||||
await set(this.getKey('generation', generation.id), generation);
|
||||
}
|
||||
|
||||
static async getGeneration(id: string): Promise<Generation | null> {
|
||||
return (await get(this.getKey('generation', id))) || null;
|
||||
}
|
||||
|
||||
// Clear old cache entries
|
||||
static async clearOldCache(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<void> {
|
||||
const allKeys = await keys();
|
||||
const now = Date.now();
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (typeof key === 'string' && key.startsWith(CACHE_PREFIX)) {
|
||||
const cached = await get(key);
|
||||
if (cached?.cachedAt && (now - cached.cachedAt) > maxAge) {
|
||||
await del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
src/services/geminiService.ts
Normal file
169
src/services/geminiService.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
// Note: In production, this should be handled via a backend proxy
|
||||
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key';
|
||||
const genAI = new GoogleGenAI({ apiKey: API_KEY });
|
||||
|
||||
export interface GenerationRequest {
|
||||
prompt: string;
|
||||
referenceImages?: string[]; // base64 array
|
||||
temperature?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface EditRequest {
|
||||
instruction: string;
|
||||
originalImage: string; // base64
|
||||
referenceImages?: string[]; // base64 array
|
||||
maskImage?: string; // base64
|
||||
temperature?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface SegmentationRequest {
|
||||
image: string; // base64
|
||||
query: string; // "the object at pixel (x,y)" or "the red car"
|
||||
}
|
||||
|
||||
export class GeminiService {
|
||||
async generateImage(request: GenerationRequest): Promise<string[]> {
|
||||
try {
|
||||
const contents: any[] = [{ text: request.prompt }];
|
||||
|
||||
// Add reference images if provided
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
request.referenceImages.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: image,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
model: "gemini-2.5-flash-image-preview",
|
||||
contents,
|
||||
});
|
||||
|
||||
const images: string[] = [];
|
||||
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data);
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
} catch (error) {
|
||||
console.error('Error generating image:', error);
|
||||
throw new Error('Failed to generate image. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async editImage(request: EditRequest): Promise<string[]> {
|
||||
try {
|
||||
const contents = [
|
||||
{ text: this.buildEditPrompt(request) },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: request.originalImage,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Add reference images if provided
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
request.referenceImages.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: image,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (request.maskImage) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: request.maskImage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
model: "gemini-2.5-flash-image-preview",
|
||||
contents,
|
||||
});
|
||||
|
||||
const images: string[] = [];
|
||||
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data);
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
} catch (error) {
|
||||
console.error('Error editing image:', error);
|
||||
throw new Error('Failed to edit image. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async segmentImage(request: SegmentationRequest): Promise<any> {
|
||||
try {
|
||||
const prompt = [
|
||||
{ text: `Analyze this image and create a segmentation mask for: ${request.query}
|
||||
|
||||
Return a JSON object with this exact structure:
|
||||
{
|
||||
"masks": [
|
||||
{
|
||||
"label": "description of the segmented object",
|
||||
"box_2d": [x, y, width, height],
|
||||
"mask": "base64-encoded binary mask image"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Only segment the specific object or region requested. The mask should be a binary PNG where white pixels (255) indicate the selected region and black pixels (0) indicate the background.` },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: request.image,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
model: "gemini-2.5-flash-image-preview",
|
||||
contents: prompt,
|
||||
});
|
||||
|
||||
const responseText = response.candidates[0].content.parts[0].text;
|
||||
return JSON.parse(responseText);
|
||||
} catch (error) {
|
||||
console.error('Error segmenting image:', error);
|
||||
throw new Error('Failed to segment image. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private buildEditPrompt(request: EditRequest): string {
|
||||
const maskInstruction = request.maskImage
|
||||
? "\n\nIMPORTANT: Apply changes ONLY where the mask image shows white pixels (value 255). Leave all other areas completely unchanged. Respect the mask boundaries precisely and maintain seamless blending at the edges."
|
||||
: "";
|
||||
|
||||
return `Edit this image according to the following instruction: ${request.instruction}
|
||||
|
||||
Maintain the original image's lighting, perspective, and overall composition. Make the changes look natural and seamlessly integrated.${maskInstruction}
|
||||
|
||||
Preserve image quality and ensure the edit looks professional and realistic.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const geminiService = new GeminiService();
|
||||
95
src/services/imageProcessing.ts
Normal file
95
src/services/imageProcessing.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { SegmentationMask } from '../types';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
|
||||
export class ImageProcessor {
|
||||
// Interactive segmentation using click point
|
||||
static async createMaskFromClick(
|
||||
image: HTMLImageElement,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<SegmentationMask> {
|
||||
// Simulate mask creation - in production this would use MediaPipe
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
|
||||
// Draw the image
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Create a simple circular mask for demo
|
||||
const radius = 50;
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
const maskCtx = maskCanvas.getContext('2d')!;
|
||||
maskCanvas.width = image.width;
|
||||
maskCanvas.height = image.height;
|
||||
|
||||
// Fill with black (background)
|
||||
maskCtx.fillStyle = 'black';
|
||||
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
|
||||
// Draw white circle (selected region)
|
||||
maskCtx.fillStyle = 'white';
|
||||
maskCtx.beginPath();
|
||||
maskCtx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
maskCtx.fill();
|
||||
|
||||
const imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
imageData,
|
||||
bounds: {
|
||||
x: Math.max(0, x - radius),
|
||||
y: Math.max(0, y - radius),
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
},
|
||||
feather: 5
|
||||
};
|
||||
}
|
||||
|
||||
// Apply feathering to mask
|
||||
static applyFeathering(mask: SegmentationMask, featherRadius: number): ImageData {
|
||||
const { imageData } = mask;
|
||||
const data = new Uint8ClampedArray(imageData.data);
|
||||
|
||||
// Simple box blur for feathering
|
||||
for (let i = 0; i < featherRadius; i++) {
|
||||
this.boxBlur(data, imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
return new ImageData(data, imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
private static boxBlur(data: Uint8ClampedArray, width: number, height: number) {
|
||||
const temp = new Uint8ClampedArray(data);
|
||||
|
||||
for (let y = 1; y < height - 1; y++) {
|
||||
for (let x = 1; x < width - 1; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
|
||||
// Average the alpha channel (mask channel)
|
||||
const sum =
|
||||
temp[idx - 4 + 3] + temp[idx + 3] + temp[idx + 4 + 3] +
|
||||
temp[idx - width * 4 + 3] + temp[idx + 3] + temp[idx + width * 4 + 3] +
|
||||
temp[idx - width * 4 - 4 + 3] + temp[idx - width * 4 + 4 + 3] + temp[idx + width * 4 - 4 + 3];
|
||||
|
||||
data[idx + 3] = sum / 9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert ImageData to base64 for API
|
||||
static imageDataToBase64(imageData: ImageData): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
return dataUrl.split(',')[1]; // Remove data:image/png;base64, prefix
|
||||
}
|
||||
}
|
||||
164
src/store/useAppStore.ts
Normal file
164
src/store/useAppStore.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { Project, Generation, Edit, SegmentationMask, BrushStroke } from '../types';
|
||||
|
||||
interface AppState {
|
||||
// Current project
|
||||
currentProject: Project | null;
|
||||
|
||||
// Canvas state
|
||||
canvasImage: string | null;
|
||||
canvasZoom: number;
|
||||
canvasPan: { x: number; y: number };
|
||||
|
||||
// Upload state
|
||||
uploadedImages: string[];
|
||||
editReferenceImages: string[];
|
||||
|
||||
// Brush strokes for painting masks
|
||||
brushStrokes: BrushStroke[];
|
||||
brushSize: number;
|
||||
showMasks: boolean;
|
||||
|
||||
// Generation state
|
||||
isGenerating: boolean;
|
||||
currentPrompt: string;
|
||||
temperature: number;
|
||||
seed: number | null;
|
||||
|
||||
// History and variants
|
||||
selectedGenerationId: string | null;
|
||||
selectedEditId: string | null;
|
||||
showHistory: boolean;
|
||||
|
||||
// Panel visibility
|
||||
showPromptPanel: boolean;
|
||||
|
||||
// UI state
|
||||
selectedTool: 'generate' | 'edit' | 'mask';
|
||||
|
||||
// Actions
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
setCanvasImage: (url: string | null) => void;
|
||||
setCanvasZoom: (zoom: number) => void;
|
||||
setCanvasPan: (pan: { x: number; y: number }) => void;
|
||||
|
||||
addUploadedImage: (url: string) => void;
|
||||
removeUploadedImage: (index: number) => void;
|
||||
clearUploadedImages: () => void;
|
||||
|
||||
addEditReferenceImage: (url: string) => void;
|
||||
removeEditReferenceImage: (index: number) => void;
|
||||
clearEditReferenceImages: () => void;
|
||||
|
||||
addBrushStroke: (stroke: BrushStroke) => void;
|
||||
clearBrushStrokes: () => void;
|
||||
setBrushSize: (size: number) => void;
|
||||
setShowMasks: (show: boolean) => void;
|
||||
|
||||
setIsGenerating: (generating: boolean) => void;
|
||||
setCurrentPrompt: (prompt: string) => void;
|
||||
setTemperature: (temp: number) => void;
|
||||
setSeed: (seed: number | null) => void;
|
||||
|
||||
addGeneration: (generation: Generation) => void;
|
||||
addEdit: (edit: Edit) => void;
|
||||
selectGeneration: (id: string | null) => void;
|
||||
selectEdit: (id: string | null) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
|
||||
setShowPromptPanel: (show: boolean) => void;
|
||||
|
||||
setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
currentProject: null,
|
||||
canvasImage: null,
|
||||
canvasZoom: 1,
|
||||
canvasPan: { x: 0, y: 0 },
|
||||
|
||||
uploadedImages: [],
|
||||
editReferenceImages: [],
|
||||
|
||||
brushStrokes: [],
|
||||
brushSize: 20,
|
||||
showMasks: true,
|
||||
|
||||
isGenerating: false,
|
||||
currentPrompt: '',
|
||||
temperature: 0.7,
|
||||
seed: null,
|
||||
|
||||
selectedGenerationId: null,
|
||||
selectedEditId: null,
|
||||
showHistory: true,
|
||||
|
||||
showPromptPanel: true,
|
||||
|
||||
selectedTool: 'generate',
|
||||
|
||||
// Actions
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
setCanvasImage: (url) => set({ canvasImage: url }),
|
||||
setCanvasZoom: (zoom) => set({ canvasZoom: zoom }),
|
||||
setCanvasPan: (pan) => set({ canvasPan: pan }),
|
||||
|
||||
addUploadedImage: (url) => set((state) => ({
|
||||
uploadedImages: [...state.uploadedImages, url]
|
||||
})),
|
||||
removeUploadedImage: (index) => set((state) => ({
|
||||
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearUploadedImages: () => set({ uploadedImages: [] }),
|
||||
|
||||
addEditReferenceImage: (url) => set((state) => ({
|
||||
editReferenceImages: [...state.editReferenceImages, url]
|
||||
})),
|
||||
removeEditReferenceImage: (index) => set((state) => ({
|
||||
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
|
||||
|
||||
addBrushStroke: (stroke) => set((state) => ({
|
||||
brushStrokes: [...state.brushStrokes, stroke]
|
||||
})),
|
||||
clearBrushStrokes: () => set({ brushStrokes: [] }),
|
||||
setBrushSize: (size) => set({ brushSize: size }),
|
||||
setShowMasks: (show) => set({ showMasks: show }),
|
||||
|
||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
||||
setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
|
||||
setTemperature: (temp) => set({ temperature: temp }),
|
||||
setSeed: (seed) => set({ seed: seed }),
|
||||
|
||||
addGeneration: (generation) => set((state) => ({
|
||||
currentProject: state.currentProject ? {
|
||||
...state.currentProject,
|
||||
generations: [...state.currentProject.generations, generation],
|
||||
updatedAt: Date.now()
|
||||
} : null
|
||||
})),
|
||||
|
||||
addEdit: (edit) => set((state) => ({
|
||||
currentProject: state.currentProject ? {
|
||||
...state.currentProject,
|
||||
edits: [...state.currentProject.edits, edit],
|
||||
updatedAt: Date.now()
|
||||
} : null
|
||||
})),
|
||||
|
||||
selectGeneration: (id) => set({ selectedGenerationId: id }),
|
||||
selectEdit: (id) => set({ selectedEditId: id }),
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
|
||||
setShowPromptPanel: (show) => set({ showPromptPanel: show }),
|
||||
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
}),
|
||||
{ name: 'nano-banana-store' }
|
||||
)
|
||||
);
|
||||
67
src/types/index.ts
Normal file
67
src/types/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export interface Asset {
|
||||
id: string;
|
||||
type: 'original' | 'mask' | 'output';
|
||||
url: string;
|
||||
mime: string;
|
||||
width: number;
|
||||
height: number;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export interface Generation {
|
||||
id: string;
|
||||
prompt: string;
|
||||
parameters: {
|
||||
seed?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
sourceAssets: Asset[];
|
||||
outputAssets: Asset[];
|
||||
modelVersion: string;
|
||||
timestamp: number;
|
||||
costEstimate?: number;
|
||||
}
|
||||
|
||||
export interface Edit {
|
||||
id: string;
|
||||
parentGenerationId: string;
|
||||
maskAssetId?: string;
|
||||
maskReferenceAsset?: Asset;
|
||||
instruction: string;
|
||||
outputAssets: Asset[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
generations: Generation[];
|
||||
edits: Edit[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SegmentationMask {
|
||||
id: string;
|
||||
imageData: ImageData;
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
feather: number;
|
||||
}
|
||||
|
||||
export interface BrushStroke {
|
||||
id: string;
|
||||
points: number[];
|
||||
brushSize: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PromptHint {
|
||||
category: 'subject' | 'scene' | 'action' | 'style' | 'camera';
|
||||
text: string;
|
||||
example: string;
|
||||
}
|
||||
6
src/utils/cn.ts
Normal file
6
src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
63
src/utils/imageUtils.ts
Normal file
63
src/utils/imageUtils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export function base64ToBlob(base64: string, mimeType: string = 'image/png'): Blob {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: mimeType });
|
||||
}
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1]; // Remove data:image/png;base64, prefix
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function createImageFromBase64(base64: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = `data:image/png;base64,${base64}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function resizeImageToFit(
|
||||
image: HTMLImageElement,
|
||||
maxWidth: number,
|
||||
maxHeight: number
|
||||
): { width: number; height: number } {
|
||||
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height);
|
||||
return {
|
||||
width: image.width * ratio,
|
||||
height: image.height * ratio
|
||||
};
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function downloadImage(base64: string, filename: string): void {
|
||||
const blob = base64ToBlob(base64);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user