Files
Nano-Banana-AI-Image-Editor/src/components/PromptComposer.tsx
2025-09-20 00:38:35 +08:00

520 lines
20 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, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { blobToBase64, urlToBlob } from '../utils/imageUtils';
import { PromptHints } from './PromptHints';
import { PromptSuggestions } from './PromptSuggestions';
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,
addBlob
} = useAppStore();
const { generate, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing();
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPromptSuggestions, setShowPromptSuggestions] = useState(true);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [showHintsModal, setShowHintsModal] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleGenerate = async () => {
if (!currentPrompt.trim()) return;
if (selectedTool === 'generate') {
// 将上传的图像转换为Blob对象
const referenceImageBlobs: Blob[] = [];
for (const img of uploadedImages) {
if (img.startsWith('data:')) {
// 从base64数据创建Blob
const base64 = img.split('base64,')[1];
const byteString = atob(base64);
const mimeString = img.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
} else if (img.startsWith('blob:')) {
// 从Blob URL获取Blob
const { getBlob } = useAppStore.getState();
const blob = getBlob(img);
if (blob) {
referenceImageBlobs.push(blob);
} else {
// 如果在AppStore中找不到Blob尝试重新创建
try {
const response = await fetch(img);
if (response.ok) {
const blob = await response.blob();
// 重新添加到AppStore
const newUrl = useAppStore.getState().addBlob(blob);
referenceImageBlobs.push(blob);
// 更新uploadedImages中的URL
const index = uploadedImages.indexOf(img);
if (index !== -1) {
const newImages = [...uploadedImages];
newImages[index] = newUrl;
useAppStore.getState().clearUploadedImages();
newImages.forEach(imageUrl => useAppStore.getState().addUploadedImage(imageUrl));
}
}
} catch (error) {
console.warn('无法重新获取参考图像:', img, error);
}
}
} else {
// 从URL获取Blob
try {
const blob = await urlToBlob(img);
referenceImageBlobs.push(blob);
} catch (error) {
console.warn('无法获取参考图像:', img, error);
}
}
}
generate({
prompt: currentPrompt,
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt);
}
};
const handleFileUpload = async (file: File) => {
if (file && file.type.startsWith('image/')) {
try {
// 直接使用Blob创建URL
const blobUrl = addBlob(file);
if (selectedTool === 'generate') {
// 添加到参考图像最多2张
if (uploadedImages.length < 2) {
addUploadedImage(blobUrl);
}
} else if (selectedTool === 'edit') {
// 编辑模式下添加到单独的编辑参考图像最多2张
if (editReferenceImages.length < 2) {
addEditReferenceImage(blobUrl);
}
// 如果没有画布图像,则设置为画布图像
if (!canvasImage) {
setCanvasImage(blobUrl);
}
} else if (selectedTool === 'mask') {
// 遮罩模式下,立即设置为画布图像
clearUploadedImages();
addUploadedImage(blobUrl);
setCanvasImage(blobUrl);
}
} 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-all duration-300 ease-in-out 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-5 flex flex-col overflow-y-auto space-y-5">
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide"></h3>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowHintsModal(true)}
className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
>
<HelpCircle className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPromptPanel(false)}
className="h-7 w-7 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-2.5">
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => setSelectedTool(tool.id)}
className={cn(
'flex flex-col items-center p-3 rounded-xl border transition-all duration-200 hover:scale-105',
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-5 w-5 mb-1.5" />
<span className="text-xs font-medium">{tool.label}</span>
</button>
))}
</div>
</div>
{/* 文件上传 */}
<div className="space-y-3">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"border-2 border-dashed rounded-xl p-5 text-center transition-colors",
isDragOver
? "border-yellow-400 bg-yellow-400/10"
: "border-gray-300 hover:border-yellow-400"
)}
>
<label className="text-sm font-semibold text-gray-700 mb-2 block">
{selectedTool === 'generate' ? '参考图像' : selectedTool === 'edit' ? '样式参考' : '上传图像'}
</label>
{selectedTool === 'mask' && (
<p className="text-xs text-gray-500 mb-3">使</p>
)}
{selectedTool === 'generate' && (
<p className="text-xs text-gray-500 mb-3">2</p>
)}
{selectedTool === 'edit' && (
<p className="text-xs text-gray-500 mb-3">
{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-3">
<Upload className={cn("h-7 w-7", 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.5 w-3.5 mr-1.5" />
</Button>
</div>
</div>
{/* Show uploaded images preview */}
{((selectedTool === 'generate' && uploadedImages.length > 0) ||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
<div className="space-y-2.5">
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`参考图像 ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
/>
<button
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
{index + 1}
</div>
</div>
))}
</div>
)}
</div>
{/* 提示输入 */}
<div className="flex-grow space-y-3">
<label className="text-xs font-semibold text-gray-500 block uppercase tracking-wide">
{selectedTool === 'generate' ? '提示词' : '编辑指令'}
</label>
<Textarea
value={currentPrompt}
onChange={(e) => setCurrentPrompt(e.target.value)}
placeholder={
selectedTool === 'generate'
? '描述您想要创建的内容...'
: '描述您想要的修改...'
}
className="min-h-[180px] resize-none text-sm rounded-xl"
/>
{/* 提示质量指示器 */}
<button
onClick={() => setShowHintsModal(true)}
className="flex items-center text-xs hover:text-gray-700 transition-colors group"
>
{currentPrompt.length < 20 ? (
<HelpCircle className="h-4 w-4 mr-2 text-red-400 group-hover:text-red-500" />
) : (
<div className={cn(
'h-2.5 w-2.5 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>
{/* 生成按钮 */}
<div className="flex-shrink-0">
{isGenerating ? (
<div className="flex gap-3">
<Button
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
className="flex-1 h-14 text-base font-semibold bg-red-500 hover:bg-red-600 rounded-xl card"
>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2" />
</Button>
</div>
) : (
<Button
onClick={handleGenerate}
disabled={!currentPrompt.trim()}
className="w-full h-14 text-base font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card"
>
<Wand2 className="h-5 w-5 mr-2" />
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
</Button>
)}
</div>
{/* 常用提示词 */}
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
<button
onClick={() => setShowPromptSuggestions(!showPromptSuggestions)}
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors duration-200"
>
{showPromptSuggestions ? <ChevronDown className="h-4 w-4 mr-1.5" /> : <ChevronRight className="h-4 w-4 mr-1.5" />}
</button>
{showPromptSuggestions && (
<div className="mt-4 animate-in slide-down duration-300">
<PromptSuggestions
onWordSelect={(word) => {
setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word);
}}
minFrequency={3}
showTitle={false}
/>
</div>
)}
</div>
{/* 高级控制 */}
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors duration-200"
>
{showAdvanced ? <ChevronDown className="h-4 w-4 mr-1.5" /> : <ChevronRight className="h-4 w-4 mr-1.5" />}
</button>
{showAdvanced && (
<div className="mt-4 space-y-4 animate-in slide-down duration-300">
{/* 创造力 */}
<div className="space-y-2">
<label className="text-sm text-gray-500 block flex justify-between">
<span className="font-medium"></span>
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{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-2 bg-gray-200 rounded-full appearance-none cursor-pointer slider"
/>
</div>
{/* 种子 */}
<div className="space-y-2">
<label className="text-sm text-gray-500 block font-medium">
()
</label>
<input
type="number"
value={seed || ''}
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
placeholder="随机"
className="w-full h-10 px-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:border-transparent"
/>
</div>
</div>
)}
<button
onClick={() => setShowClearConfirm(!showClearConfirm)}
className="flex items-center text-sm text-gray-500 hover:text-red-500 transition-colors duration-200 mt-4"
>
<RotateCcw className="h-4 w-4 mr-2" />
</button>
{showClearConfirm && (
<div className="mt-3 p-4 bg-red-50 rounded-xl border border-red-100 animate-in slide-down duration-300">
<p className="text-sm text-red-700 mb-4">
</p>
<div className="flex space-x-3">
<Button
variant="destructive"
onClick={handleClearSession}
className="flex-1 h-10 text-sm font-semibold card text-gray-700"
>
</Button>
<Button
variant="outline"
onClick={() => setShowClearConfirm(false)}
className="flex-1 h-10 text-sm font-semibold border-gray-300 text-gray-700 hover:bg-gray-100 card"
>
</Button>
</div>
</div>
)}
</div>
</div>
{/* 提示提示模态框 */}
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
</>
);
};
export default PromptComposer;