You've already forked Nano-Banana-AI-Image-Editor
520 lines
20 KiB
TypeScript
520 lines
20 KiB
TypeScript
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; |