From acda195b821d4fb3a363a3eeb1d3ae22bd2e38bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 18:03:45 +0000 Subject: [PATCH 1/2] Add Canva-like text overlay feature with full styling capabilities Features implemented: - Text tool mode with interactive text layer management - TextStylePanel component with comprehensive controls: * Font selection (14 Google Fonts) * Font size slider (12-200px) * Color picker with hex values * Text alignment (left, center, right) * Text styling (bold, italic, underline) * Multiple text layers support - Canvas text rendering with Konva: * Drag to reposition text * Resize and rotate with transformer * Click to select text layers * Visual feedback for selected text - Export functionality includes text overlays - Keyboard shortcuts: * T - Switch to text tool * Delete/Backspace - Remove selected text * Escape - Deselect text - Responsive UI with tool grid (2x2 layout) - Dynamic Google Fonts loading Dependencies added: - react-colorful for color picker Files modified: - src/types/index.ts - Added TextLayer interface - src/store/useAppStore.ts - Text layer state management - src/components/TextStylePanel.tsx - New component - src/components/ImageCanvas.tsx - Text rendering & interaction - src/components/PromptComposer.tsx - Text tool button - src/App.tsx - Conditional TextStylePanel rendering - src/hooks/useKeyboardShortcuts.ts - Text tool shortcuts - package.json - Added react-colorful dependency --- package-lock.json | 11 + package.json | 1 + src/App.tsx | 7 +- src/components/ImageCanvas.tsx | 119 ++++++++++- src/components/PromptComposer.tsx | 9 +- src/components/TextStylePanel.tsx | 320 ++++++++++++++++++++++++++++++ src/hooks/useKeyboardShortcuts.ts | 26 ++- src/store/useAppStore.ts | 43 +++- src/types/index.ts | 17 ++ 9 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 src/components/TextStylePanel.tsx diff --git a/package-lock.json b/package-lock.json index 32b397e3..eaa87c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,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", @@ -5267,6 +5268,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/package.json b/package.json index 7264fb67..6e5bb855 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 375bfb4e..2eee9abb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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(() => { @@ -49,7 +50,7 @@ function AppContent() {
- + {selectedTool === 'text' ? : }
diff --git a/src/components/ImageCanvas.tsx b/src/components/ImageCanvas.tsx index c42c4020..554a726b 100644 --- a/src/components/ImageCanvas.tsx +++ b/src/components/ImageCanvas.tsx @@ -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'; @@ -20,10 +21,16 @@ export const ImageCanvas: React.FC = () => { selectedTool, isGenerating, brushSize, - setBrushSize + setBrushSize, + textLayers, + selectedTextId, + updateTextLayer, + selectTextLayer, } = useAppStore(); const stageRef = useRef(null); + const transformerRef = useRef(null); + const textRefs = useRef<{ [key: string]: any }>({}); const [image, setImage] = useState(null); const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); const [isDrawing, setIsDrawing] = useState(false); @@ -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(); @@ -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; @@ -334,6 +371,80 @@ export const ImageCanvas: React.FC = () => { y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2} /> )} + + {/* Text Layers */} + {textLayers.map((textLayer) => ( + { + 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' && ( + { + // Limit resize + if (newBox.width < 5 || newBox.height < 5) { + return oldBox; + } + return newBox; + }} + /> + )} diff --git a/src/components/PromptComposer.tsx b/src/components/PromptComposer.tsx index 7bcee629..2f10f2b3 100644 --- a/src/components/PromptComposer.tsx +++ b/src/components/PromptComposer.tsx @@ -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'; @@ -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) { @@ -154,7 +155,7 @@ export const PromptComposer: React.FC = () => { -
+
{tools.map((tool) => (
+
+ Text mode + T +
History H diff --git a/src/components/TextStylePanel.tsx b/src/components/TextStylePanel.tsx new file mode 100644 index 00000000..d9b6fc0b --- /dev/null +++ b/src/components/TextStylePanel.tsx @@ -0,0 +1,320 @@ +import React, { useState } from 'react'; +import { useAppStore } from '../store/useAppStore'; +import { Button } from './ui/Button'; +import { HexColorPicker } from 'react-colorful'; +import { + Type, + AlignLeft, + AlignCenter, + AlignRight, + Bold, + Italic, + Underline, + Trash2, + Plus, +} from 'lucide-react'; +import { cn } from '../utils/cn'; + +const AVAILABLE_FONTS = [ + { name: 'Inter', value: 'Inter, sans-serif' }, + { name: 'Arial', value: 'Arial, sans-serif' }, + { name: 'Roboto', value: 'Roboto, sans-serif' }, + { name: 'Open Sans', value: 'Open Sans, sans-serif' }, + { name: 'Montserrat', value: 'Montserrat, sans-serif' }, + { name: 'Poppins', value: 'Poppins, sans-serif' }, + { name: 'Playfair Display', value: 'Playfair Display, serif' }, + { name: 'Merriweather', value: 'Merriweather, serif' }, + { name: 'Lora', value: 'Lora, serif' }, + { name: 'Bebas Neue', value: 'Bebas Neue, cursive' }, + { name: 'Pacifico', value: 'Pacifico, cursive' }, + { name: 'Dancing Script', value: 'Dancing Script, cursive' }, + { name: 'Courier New', value: 'Courier New, monospace' }, + { name: 'Source Code Pro', value: 'Source Code Pro, monospace' }, +]; + +export const TextStylePanel: React.FC = () => { + const { + textLayers, + selectedTextId, + addTextLayer, + updateTextLayer, + deleteTextLayer, + selectTextLayer, + } = useAppStore(); + + const [showColorPicker, setShowColorPicker] = useState(false); + + const selectedText = textLayers.find((layer) => layer.id === selectedTextId); + + const handleAddText = () => { + const newText = { + id: `text-${Date.now()}`, + text: 'Double click to edit', + x: 100, + y: 100, + fontSize: 48, + fontFamily: 'Inter, sans-serif', + fill: '#FFFFFF', + align: 'left' as const, + fontStyle: 'normal' as const, + textDecoration: '' as const, + rotation: 0, + width: 400, + }; + addTextLayer(newText); + }; + + const handleFontChange = (fontFamily: string) => { + if (selectedTextId) { + updateTextLayer(selectedTextId, { fontFamily }); + + // Load Google Font dynamically + const fontName = fontFamily.split(',')[0].replace(/['"]/g, ''); + const link = document.createElement('link'); + link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}:wght@400;700&display=swap`; + link.rel = 'stylesheet'; + if (!document.querySelector(`link[href*="${fontName.replace(/ /g, '+')}"]`)) { + document.head.appendChild(link); + } + } + }; + + const handleToggleBold = () => { + if (selectedTextId && selectedText) { + const isBold = selectedText.fontStyle === 'bold'; + updateTextLayer(selectedTextId, { + fontStyle: isBold ? 'normal' : 'bold', + }); + } + }; + + const handleToggleItalic = () => { + if (selectedTextId && selectedText) { + const isItalic = selectedText.fontStyle === 'italic'; + updateTextLayer(selectedTextId, { + fontStyle: isItalic ? 'normal' : 'italic', + }); + } + }; + + const handleToggleUnderline = () => { + if (selectedTextId && selectedText) { + const hasUnderline = selectedText.textDecoration === 'underline'; + updateTextLayer(selectedTextId, { + textDecoration: hasUnderline ? '' : 'underline', + }); + } + }; + + const handleAlignChange = (align: 'left' | 'center' | 'right') => { + if (selectedTextId) { + updateTextLayer(selectedTextId, { align }); + } + }; + + const handleDeleteText = () => { + if (selectedTextId) { + deleteTextLayer(selectedTextId); + } + }; + + return ( +
+
+

Text Tool

+ +
+ + {selectedText && ( + <> + {/* Text Input */} +
+ +