Files
Nano-Banana-AI-Image-Editor/src/components/PromptComposer.tsx
2025-09-16 13:05:57 +08:00

466 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing();
const [showAdvanced, setShowAdvanced] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [showHintsModal, setShowHintsModal] = useState(false);
const [isDragOver, setIsDragOver] = 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 (file: File) => {
if (file && file.type.startsWith('image/')) {
try {
const base64 = await blobToBase64(file);
const dataUrl = `data:${file.type};base64,${base64}`;
if (selectedTool === 'generate') {
// 添加到参考图像最多2张
if (uploadedImages.length < 2) {
addUploadedImage(dataUrl);
}
} else if (selectedTool === 'edit') {
// 编辑模式下添加到单独的编辑参考图像最多2张
if (editReferenceImages.length < 2) {
addEditReferenceImage(dataUrl);
}
// 如果没有画布图像,则设置为画布图像
if (!canvasImage) {
setCanvasImage(dataUrl);
}
} else if (selectedTool === 'mask') {
// 遮罩模式下,立即设置为画布图像
clearUploadedImages();
addUploadedImage(dataUrl);
setCanvasImage(dataUrl);
}
} catch (error) {
console.error('上传图像失败:', error);
}
}
};
const handleFileInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
await handleFileUpload(file);
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
const file = event.dataTransfer.files?.[0];
if (file) {
await handleFileUpload(file);
}
};
const handleClearSession = () => {
setCurrentPrompt('');
clearUploadedImages();
clearEditReferenceImages();
clearBrushStrokes();
setCanvasImage(null);
setSeed(null);
setTemperature(0.7);
setShowClearConfirm(false);
};
const tools = [
{ id: 'generate', icon: Wand2, label: '生成', description: '从文本创建' },
{ id: 'edit', icon: Edit3, label: '编辑', description: '修改现有图像' },
{ id: 'mask', icon: MousePointer, label: '选择', description: '点击选择' },
] as const;
if (!showPromptPanel) {
return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl">
<button
onClick={() => setShowPromptPanel(true)}
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-r-lg flex items-center justify-center transition-colors group"
title="显示提示面板"
>
<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-72 h-full bg-white p-4 flex flex-col space-y-5 overflow-y-auto">
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide"></h3>
<div className="flex items-center space-x-0.5">
<Button
variant="ghost"
size="icon"
onClick={() => setShowHintsModal(true)}
className="h-6 w-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
>
<HelpCircle className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPromptPanel(false)}
className="h-6 w-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
title="隐藏面板"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-1.5">
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => setSelectedTool(tool.id)}
className={cn(
'flex flex-col items-center p-2 rounded-lg border transition-all duration-200',
selectedTool === tool.id
? 'bg-yellow-50 border-yellow-300 text-yellow-700 shadow-sm'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
)}
>
<tool.icon className="h-4 w-4 mb-1" />
<span className="text-xs font-medium">{tool.label}</span>
</button>
))}
</div>
</div>
{/* 文件上传 */}
<div>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"border border-dashed rounded-md p-4 text-center transition-colors",
isDragOver
? "border-yellow-400 bg-yellow-400/10"
: "border-gray-300 hover:border-yellow-400"
)}
>
<label className="text-sm font-medium text-gray-700 mb-1 block">
{selectedTool === 'generate' ? '参考图像' : selectedTool === 'edit' ? '样式参考' : '上传图像'}
</label>
{selectedTool === 'mask' && (
<p className="text-xs text-gray-500 mb-2">使</p>
)}
{selectedTool === 'generate' && (
<p className="text-xs text-gray-500 mb-2">2</p>
)}
{selectedTool === 'edit' && (
<p className="text-xs text-gray-500 mb-2">
{canvasImage ? '可选样式参考最多2张图像' : '上传要编辑的图像最多2张图像'}
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
/>
<div className="flex flex-col items-center justify-center space-y-2">
<Upload className={cn("h-6 w-6", isDragOver ? "text-yellow-500" : "text-gray-400")} />
<div>
<p className={cn(
"text-sm font-medium",
isDragOver ? "text-yellow-700" : "text-gray-600"
)}>
{isDragOver ? "释放文件以上传" : "拖拽或点击上传"}
</p>
<p className="text-xs text-gray-500 mt-1">
JPG, PNG, GIF
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="mt-1 card"
disabled={
(selectedTool === 'generate' && uploadedImages.length >= 2) ||
(selectedTool === 'edit' && editReferenceImages.length >= 2)
}
>
<Upload className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
{/* Show uploaded images preview */}
{((selectedTool === 'generate' && uploadedImages.length > 0) ||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
<div className="mt-2 space-y-2">
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`参考图像 ${index + 1}`}
className="w-full h-16 object-cover rounded border border-gray-300"
/>
<button
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
className="absolute top-1 right-1 bg-gray-100/80 text-gray-600 hover:text-gray-800 rounded-full p-1 transition-colors"
>
×
</button>
<div className="absolute bottom-1 left-1 bg-gray-100/80 text-xs px-1 py-0.5 rounded text-gray-700">
{index + 1}
</div>
</div>
))}
</div>
)}
</div>
{/* 提示输入 */}
<div>
<label className="text-xs font-medium text-gray-500 mb-2 block uppercase tracking-wide">
{selectedTool === 'generate' ? '提示词' : '编辑指令'}
</label>
<Textarea
value={currentPrompt}
onChange={(e) => setCurrentPrompt(e.target.value)}
placeholder={
selectedTool === 'generate'
? '描述您想要创建的内容...'
: '描述您想要的修改...'
}
className="min-h-[100px] resize-none text-sm"
/>
{/* 提示质量指示器 */}
<button
onClick={() => setShowHintsModal(true)}
className="mt-2 flex items-center text-xs hover:text-gray-700 transition-colors group"
>
{currentPrompt.length < 20 ? (
<HelpCircle className="h-3 w-3 mr-2 text-red-400 group-hover:text-red-500" />
) : (
<div className={cn(
'h-2 w-2 rounded-full mr-2',
currentPrompt.length < 50 ? 'bg-yellow-400' : 'bg-green-400'
)} />
)}
<span className="text-gray-500 group-hover:text-gray-700">
{currentPrompt.length < 20 ? '添加更多细节' :
currentPrompt.length < 50 ? '细节良好' : '细节优秀'}
</span>
</button>
</div>
{/* 生成按钮 */}
{isGenerating ? (
<div className="flex gap-2">
<Button
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
className="flex-1 h-12 text-sm font-medium bg-red-500 hover:bg-red-600 rounded-lg card"
>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
</Button>
</div>
) : (
<Button
onClick={handleGenerate}
disabled={!currentPrompt.trim()}
className="w-full h-12 text-sm font-medium rounded-lg shadow-sm hover:shadow-md transition-shadow card"
>
<Wand2 className="h-4 w-4 mr-2" />
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
</Button>
)}
{/* 高级控制 */}
<div className="pt-2 border-t border-gray-100">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center text-xs text-gray-500 hover:text-gray-700 transition-colors duration-200"
>
{showAdvanced ? <ChevronDown className="h-3 w-3 mr-1" /> : <ChevronRight className="h-3 w-3 mr-1" />}
</button>
{showAdvanced && (
<div className="mt-3 space-y-3">
{/* 创造力 */}
<div>
<label className="text-xs text-gray-500 mb-1.5 block flex justify-between">
<span></span>
<span className="font-mono">{temperature}</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer slider"
/>
</div>
{/* 种子 */}
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
()
</label>
<input
type="number"
value={seed || ''}
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
placeholder="随机"
className="w-full h-8 px-2.5 bg-gray-50 border border-gray-200 rounded-md text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-yellow-400"
/>
</div>
</div>
)}
<button
onClick={() => setShowClearConfirm(!showClearConfirm)}
className="flex items-center text-xs text-gray-500 hover:text-red-500 transition-colors duration-200 mt-3"
>
<RotateCcw className="h-3 w-3 mr-1.5" />
</button>
{showClearConfirm && (
<div className="mt-2 p-3 bg-red-50 rounded-lg border border-red-100">
<p className="text-xs text-red-700 mb-3">
</p>
<div className="flex space-x-2">
<Button
variant="destructive"
size="sm"
onClick={handleClearSession}
className="flex-1 h-8 text-xs card"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowClearConfirm(false)}
className="flex-1 h-8 text-xs border-gray-200 card"
>
</Button>
</div>
</div>
)}
</div>
{/* 键盘快捷键 */}
<div className="pt-3 border-t border-gray-100">
<h4 className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide"></h4>
<div className="space-y-1.5 text-xs text-gray-600">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700"> + Enter</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700"> + R</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">E</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">H</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">P</span>
</div>
</div>
</div>
</div>
{/* 提示提示模态框 */}
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
</>
);
};