Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"konva": "^9.3.22",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-konva": "^18.2.10",
"tailwind-merge": "^3.3.1",
Expand Down
7 changes: 4 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Header } from './components/Header';
import { PromptComposer } from './components/PromptComposer';
import { ImageCanvas } from './components/ImageCanvas';
import { HistoryPanel } from './components/HistoryPanel';
import { TextStylePanel } from './components/TextStylePanel';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useAppStore } from './store/useAppStore';

Expand All @@ -19,8 +20,8 @@ const queryClient = new QueryClient({

function AppContent() {
useKeyboardShortcuts();
const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();

const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory, selectedTool } = useAppStore();

// Set mobile defaults on mount
React.useEffect(() => {
Expand Down Expand Up @@ -49,7 +50,7 @@ function AppContent() {
<ImageCanvas />
</div>
<div className="flex-shrink-0">
<HistoryPanel />
{selectedTool === 'text' ? <TextStylePanel /> : <HistoryPanel />}
</div>
</div>
</div>
Expand Down
119 changes: 115 additions & 4 deletions src/components/ImageCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState } from 'react';
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
import { Stage, Layer, Image as KonvaImage, Line, Text, Transformer } from 'react-konva';
import Konva from 'konva';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react';
Expand All @@ -20,10 +21,16 @@ export const ImageCanvas: React.FC = () => {
selectedTool,
isGenerating,
brushSize,
setBrushSize
setBrushSize,
textLayers,
selectedTextId,
updateTextLayer,
selectTextLayer,
} = useAppStore();

const stageRef = useRef<any>(null);
const transformerRef = useRef<any>(null);
const textRefs = useRef<{ [key: string]: any }>({});
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [isDrawing, setIsDrawing] = useState(false);
Expand Down Expand Up @@ -77,9 +84,27 @@ export const ImageCanvas: React.FC = () => {
return () => window.removeEventListener('resize', updateSize);
}, []);

// Attach transformer to selected text
useEffect(() => {
if (transformerRef.current && selectedTextId && textRefs.current[selectedTextId]) {
transformerRef.current.nodes([textRefs.current[selectedTextId]]);
transformerRef.current.getLayer()?.batchDraw();
} else if (transformerRef.current) {
transformerRef.current.nodes([]);
}
}, [selectedTextId]);

const handleMouseDown = (e: any) => {
// Handle text deselection when clicking on empty canvas
if (selectedTool === 'text') {
const clickedOnEmpty = e.target === e.target.getStage();
if (clickedOnEmpty) {
selectTextLayer(null);
}
}

if (selectedTool !== 'mask' || !image) return;

setIsDrawing(true);
const stage = e.target.getStage();
const pos = stage.getPointerPosition();
Expand Down Expand Up @@ -160,7 +185,19 @@ export const ImageCanvas: React.FC = () => {
};

const handleDownload = () => {
if (canvasImage) {
if (stageRef.current) {
// Export the entire stage (including text layers)
const dataURL = stageRef.current.toDataURL({
pixelRatio: 2, // Higher quality export
});
const link = document.createElement('a');
link.href = dataURL;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else if (canvasImage) {
// Fallback to just the image if stage not available
if (canvasImage.startsWith('data:')) {
const link = document.createElement('a');
link.href = canvasImage;
Expand Down Expand Up @@ -334,6 +371,80 @@ export const ImageCanvas: React.FC = () => {
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
/>
)}

{/* Text Layers */}
{textLayers.map((textLayer) => (
<Text
key={textLayer.id}
ref={(node) => {
if (node) {
textRefs.current[textLayer.id] = node;
}
}}
text={textLayer.text}
x={textLayer.x}
y={textLayer.y}
fontSize={textLayer.fontSize}
fontFamily={textLayer.fontFamily}
fill={textLayer.fill}
align={textLayer.align}
fontStyle={textLayer.fontStyle}
textDecoration={textLayer.textDecoration}
rotation={textLayer.rotation}
width={textLayer.width}
scaleX={textLayer.scaleX || 1}
scaleY={textLayer.scaleY || 1}
draggable={selectedTool === 'text'}
onClick={() => {
if (selectedTool === 'text') {
selectTextLayer(textLayer.id);
}
}}
onTap={() => {
if (selectedTool === 'text') {
selectTextLayer(textLayer.id);
}
}}
onDragEnd={(e) => {
updateTextLayer(textLayer.id, {
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={(e) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();

// Reset scale and apply it to width
node.scaleX(1);
node.scaleY(1);

updateTextLayer(textLayer.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, node.width() * scaleX),
scaleX: 1,
scaleY: 1,
});
}}
/>
))}

{/* Transformer for selected text */}
{selectedTool === 'text' && (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// Limit resize
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
/>
)}
</Layer>
</Stage>
</div>
Expand Down
9 changes: 7 additions & 2 deletions src/components/PromptComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { Upload, Wand2, Edit3, MousePointer, Type, HelpCircle, Menu, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { blobToBase64 } from '../utils/imageUtils';
import { PromptHints } from './PromptHints';
import { cn } from '../utils/cn';
Expand Down Expand Up @@ -108,6 +108,7 @@ export const PromptComposer: React.FC = () => {
{ 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' },
{ id: 'text', icon: Type, label: 'Text', description: 'Add text overlay' },
] as const;

if (!showPromptPanel) {
Expand Down Expand Up @@ -154,7 +155,7 @@ export const PromptComposer: React.FC = () => {
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2">
{tools.map((tool) => (
<button
key={tool.id}
Expand Down Expand Up @@ -388,6 +389,10 @@ export const PromptComposer: React.FC = () => {
<span>Edit mode</span>
<span>E</span>
</div>
<div className="flex justify-between">
<span>Text mode</span>
<span>T</span>
</div>
<div className="flex justify-between">
<span>History</span>
<span>H</span>
Expand Down
Loading