diff --git a/IFLOW.md b/IFLOW.md new file mode 100644 index 0000000..2b459ea --- /dev/null +++ b/IFLOW.md @@ -0,0 +1,142 @@ +# Nano Banana AI Image Editor - iFlow 文档 + +## 项目概述 + +Nano Banana AI Image Editor 是一个基于 React 的 AI 图像编辑工具,用户可以通过直观的界面与 Google 的 Gemini AI 模型进行交互,实现图像生成和编辑功能。 + +## 技术栈 + +- **核心框架**: React 18.x (TypeScript) +- **构建工具**: Vite 5.x +- **语言**: TypeScript (ES2020) +- **状态管理**: Zustand +- **数据获取**: TanStack Query (React Query) +- **UI 框架**: + - Tailwind CSS 3.x (样式框架) + - Radix UI (无样式的可访问 UI 组件) + - Lucide React (图标库) +- **图像处理**: + - Konva (2D 画布库) + - react-konva (Konva 的 React 封装) +- **本地存储**: IndexedDB (通过 idb-keyval 库操作) +- **AI 集成**: Google Generative AI SDK (@google/genai) +- **工具库**: + - class-variance-authority (组件变体管理) + - clsx + tailwind-merge (CSS 类名合并) + - fabric.js (备用画布库) + +## 代码风格和命名规范 + +### 代码风格 +- 使用 TypeScript 严格模式 (strict: true) +- 函数式组件为主,使用 React Hooks +- 组件文件使用 .tsx 扩展名 +- 工具函数文件使用 .ts 扩展名 +- 使用 ESLint 进行代码检查 +- 启用严格的 TypeScript 编译选项 + +### 命名规范 +- 组件文件和组件名使用 PascalCase (如 `Header.tsx`, `ImageCanvas`) +- 工具函数和普通文件使用 camelCase (如 `imageUtils.ts`, `useAppStore.ts`) +- 常量使用 UPPER_SNAKE_CASE +- 变量和函数使用 camelCase +- 组件 Props 接口命名为组件名 + Props (如 `ButtonProps`) +- Hook 函数以 use 开头 (如 `useAppStore`, `useImageGeneration`) + +## 样式和 UI 框架 + +### Tailwind CSS +- 使用自定义颜色方案,以香蕉黄 (banana) 为主题色 +- 定义了 light 主题颜色 (背景、面板、边框、文字等) +- 使用自定义间距、阴影和动画 +- 使用 tailwind-merge 和 clsx 进行类名合并 + +### 组件设计 +- 使用 Radix UI 构建无样式的可访问组件 +- 自定义 UI 组件位于 `src/components/ui` 目录 +- 组件变体使用 class-variance-authority 管理 +- 图标使用 Lucide React + +## 项目结构 + +``` +Nano-Banana-AI-Image-Editor/ +├── src/ +│ ├── components/ # React 组件 +│ │ ├── ui/ # 基础 UI 组件 +│ │ ├── Header.tsx # 应用头部 +│ │ ├── ImageCanvas.tsx # 图像画布 +│ │ ├── PromptComposer.tsx # 提示词编辑器 +│ │ ├── HistoryPanel.tsx # 历史记录面板 +│ │ └── ... # 其他组件 +│ ├── hooks/ # 自定义 React Hooks +│ │ ├── useImageGeneration.ts # 图像生成 Hook +│ │ ├── useIndexedDBListener.ts # IndexedDB 监听 Hook +│ │ └── useKeyboardShortcuts.ts # 键盘快捷键 Hook +│ ├── services/ # 业务逻辑服务 +│ │ ├── geminiService.ts # Gemini AI 服务 +│ │ ├── indexedDBService.ts # IndexedDB 操作服务 +│ │ ├── uploadService.ts # 文件上传服务 +│ │ └── cacheService.ts # 缓存服务 +│ ├── store/ # 状态管理 +│ │ └── useAppStore.ts # 全局状态管理 (Zustand) +│ ├── utils/ # 工具函数 +│ │ ├── cn.ts # 类名合并工具 +│ │ └── imageUtils.ts # 图像处理工具 +│ ├── types/ # TypeScript 类型定义 +│ ├── App.tsx # 根组件 +│ ├── main.tsx # 应用入口 +│ └── vite-env.d.ts # Vite 类型声明 +├── public/ # 静态资源 +├── node_modules/ # 依赖包 +├── index.html # HTML 模板 +├── package.json # 项目配置 +├── tsconfig.json # TypeScript 配置 +├── vite.config.ts # Vite 配置 +├── tailwind.config.js # Tailwind 配置 +├── postcss.config.js # PostCSS 配置 +├── eslint.config.js # ESLint 配置 +└── README.md # 项目说明 +``` + +## 核心功能模块 + +### 1. 图像画布 (ImageCanvas) +- 使用 Konva 和 react-konva 实现图像显示和编辑 +- 支持图像缩放、平移 +- 实现画笔工具进行遮罩绘制 +- 支持图像下载功能 + +### 2. 提示词编辑 (PromptComposer) +- 用户输入提示词生成图像 +- 提供提示词建议功能 +- 集成 AI 模型参数调整 (如风格、质量等) + +### 3. 历史记录 (HistoryPanel) +- 显示生成的图像历史 +- 支持历史图像的查看和重新编辑 +- 使用 IndexedDB 存储历史数据 + +### 4. 状态管理 (useAppStore) +- 使用 Zustand 管理全局状态 +- 存储画布状态、用户设置、历史记录等 +- 提供状态操作方法 + +### 5. AI 服务 (geminiService) +- 集成 Google Gemini AI 模型 +- 实现图像生成和编辑功能 +- 处理与 AI 模型的交互 + +## 开发环境配置 + +1. 安装依赖: `npm install` +2. 启动开发服务器: `npm run dev` +3. 构建生产版本: `npm run build` +4. 代码检查: `npm run lint` + +## 注意事项 + +- 项目使用 IndexedDB 存储图像数据,需要注意存储空间管理 +- AI 功能需要配置 Google API 密钥 +- 图像处理功能依赖浏览器 Canvas API +- 移动端适配需要特别关注界面布局和交互 \ No newline at end of file diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 37ef7bd..7dfbce5 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useAppStore } from '../store/useAppStore'; import { Button } from './ui/Button'; -import { History, Download, Image as ImageIcon } from 'lucide-react'; +import { History, Download, Image as ImageIcon, Trash2 } from 'lucide-react'; import { cn } from '../utils/cn'; import { ImagePreviewModal } from './ImagePreviewModal'; import * as indexedDBService from '../services/indexedDBService'; @@ -20,7 +20,11 @@ export const HistoryPanel: React.FC = () => { showHistory, setShowHistory, setCanvasImage, - selectedTool + selectedTool, + deleteGeneration, + deleteEdit, + deleteGenerations, + deleteEdits } = useAppStore(); const { getBlob } = useAppStore.getState(); @@ -37,6 +41,19 @@ export const HistoryPanel: React.FC = () => { description: '' }); + // 删除确认对话框状态 + const [deleteConfirm, setDeleteConfirm] = React.useState<{ + open: boolean; + ids: string[]; + type: 'generation' | 'edit' | 'multiple'; + count: number; + }>({ + open: false, + ids: [], + type: 'generation', + count: 0 + }); + // 存储从Blob URL解码的图像数据 const [decodedImages, setDecodedImages] = useState>({}); @@ -479,9 +496,32 @@ export const HistoryPanel: React.FC = () => {

变体

- - {filteredGenerations.length + filteredEdits.length}/100 - +
+ + {filteredGenerations.length + filteredEdits.length}/100 + + {(filteredGenerations.length > 0 || filteredEdits.length > 0) && ( + + )} +
{filteredGenerations.length === 0 && filteredEdits.length === 0 ? (
@@ -697,6 +737,23 @@ export const HistoryPanel: React.FC = () => {
G{globalIndex + 1}
+ + {/* 删除按钮 */} +
); }); @@ -903,6 +960,23 @@ export const HistoryPanel: React.FC = () => {
E{globalIndex + 1}
+ + {/* 删除按钮 */} +
); }); @@ -1010,6 +1084,53 @@ export const HistoryPanel: React.FC = () => { )} + {/* 生成结果图像 */} + {gen.outputAssets && gen.outputAssets.length > 0 && ( +
+
生成结果
+
+ {gen.outputAssets.length} 个生成结果 +
+
+ {gen.outputAssets.slice(0, 4).map((asset: any, index: number) => { + // 获取上传后的远程链接(如果存在) + const uploadedUrl = gen.uploadResults && gen.uploadResults[index] && gen.uploadResults[index].success + ? `${gen.uploadResults[index].url}?x-oss-process=image/quality,q_30` + : null; + + const displayUrl = uploadedUrl || asset.url; + + return ( +
{ + e.stopPropagation(); + setPreviewModal({ + open: true, + imageUrl: displayUrl, + title: `生成结果 ${index + 1}`, + description: `${asset.width} × ${asset.height}` + }); + }} + > + {`生成结果 +
+ ); + })} + {gen.outputAssets.length > 4 && ( +
+ +{gen.outputAssets.length - 4} +
+ )} +
+
+ )} + {/* 参考图像信息 */} {gen.sourceAssets && gen.sourceAssets.length > 0 && (
@@ -1020,10 +1141,15 @@ export const HistoryPanel: React.FC = () => {
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => { // 获取上传后的远程链接(如果存在) - // 参考图像在uploadResults中从索引1开始(索引0是生成的图像) - const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success - ? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` + // 参考图像在uploadResults中从索引outputAssets.length开始 + // 但由于gen可能是轻量级记录,我们需要从dbGenerations中获取完整的记录 + const fullGen = dbGenerations.find(g => g.id === gen.id) || gen; + const outputAssetsCount = fullGen.outputAssets?.length || 0; + + const uploadedUrl = gen.uploadResults && gen.uploadResults[outputAssetsCount + index] && gen.uploadResults[outputAssetsCount + index].success + ? `${gen.uploadResults[outputAssetsCount + index].url}?x-oss-process=image/quality,q_30` : null; + const displayUrl = uploadedUrl || asset.url; return ( @@ -1128,9 +1254,10 @@ export const HistoryPanel: React.FC = () => {
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => { // 获取上传后的远程链接(如果存在) - // 参考图像在uploadResults中从索引1开始(索引0是生成的图像) - const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success - ? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` + // 参考图像在uploadResults中从索引outputAssets.length开始 + const outputAssetsCount = parentGen.outputAssets?.length || 0; + const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[outputAssetsCount + index] && parentGen.uploadResults[outputAssetsCount + index].success + ? `${parentGen.uploadResults[outputAssetsCount + index].url}?x-oss-process=image/quality,q_30` : null; const displayUrl = uploadedUrl || asset.url; @@ -1189,6 +1316,68 @@ export const HistoryPanel: React.FC = () => { description={previewModal.description} /> + {/* 删除确认对话框 */} + {deleteConfirm.open && ( +
+
+
+
+ +
+

确认删除

+

+ {deleteConfirm.count > 1 + ? `确定要删除这 ${deleteConfirm.count} 条历史记录吗?此操作无法撤销。` + : '确定要删除这条历史记录吗?此操作无法撤销。'} +

+
+ + +
+
+
+
+ )} + {/* 悬浮预览 */} {hoveredImage && (
{ }; const handleDownload = () => { - // 直接下载当前画布内容 + // 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL + const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState(); + + // 获取当前选中的记录 + let selectedRecord = null; + if (selectedGenerationId && currentProject) { + selectedRecord = currentProject.generations.find(g => g.id === selectedGenerationId); + } else if (selectedEditId && currentProject) { + selectedRecord = currentProject.edits.find(e => e.id === selectedEditId); + } + + // 如果有选中的记录且有上传结果,尝试下载上传后的图像 + if (selectedRecord && selectedRecord.uploadResults && selectedRecord.uploadResults.length > 0) { + // 下载第一个上传结果(通常是生成的图像) + const uploadResult = selectedRecord.uploadResults[0]; + if (uploadResult.success && uploadResult.url) { + // 使用async IIFE处理异步操作 + (async () => { + try { + // 首先尝试使用fetch获取图像数据 + const response = await fetch(uploadResult.url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blob = await response.blob(); + + // 创建下载链接 + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + + console.log('上传后的图像下载成功:', uploadResult.url); + } catch (error) { + console.error('使用fetch下载上传后的图像时出错:', error); + // 如果fetch失败(可能是跨域问题),使用Canvas方案 + try { + const img = new Image(); + img.crossOrigin = 'Anonymous'; // 设置跨域属性 + img.onload = () => { + try { + // 创建canvas并绘制图像 + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + + // 将canvas转换为blob并下载 + canvas.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + + console.log('使用Canvas方案下载成功'); + } else { + console.error('Canvas转换为blob失败'); + } + }, 'image/png'); + } catch (canvasError) { + console.error('Canvas处理失败:', canvasError); + } + }; + + img.onerror = (imgError) => { + console.error('图像加载失败:', imgError); + console.log('下载失败,未执行回退方案'); + }; + + img.src = uploadResult.url; + } catch (canvasError) { + console.error('Canvas方案也失败了:', canvasError); + console.log('下载失败,未执行回退方案'); + } + } + })(); + + // 立即返回,让异步操作在后台进行 + return; + } + } + + // 如果没有上传后的URL或下载失败,回退到下载当前画布内容 const stage = stageRef.current; if (stage) { try { @@ -350,9 +446,14 @@ export const ImageCanvas: React.FC = () => { document.body.removeChild(link); } else if (canvasImage.startsWith('blob:')) { // Blob URL格式 - fetch(canvasImage) - .then(response => response.blob()) - .then(blob => { + // 使用async IIFE处理异步操作 + (async () => { + try { + const response = await fetch(canvasImage); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -362,15 +463,66 @@ export const ImageCanvas: React.FC = () => { document.body.removeChild(link); // 清理创建的URL setTimeout(() => URL.revokeObjectURL(url), 100); - }) - .catch(error => { + } catch (error) { console.error('下载Blob图像时出错:', error); - }); + // 如果fetch失败(可能是跨域问题),使用Canvas方案 + try { + const img = new Image(); + img.onload = () => { + try { + // 创建canvas并绘制图像 + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + + // 将canvas转换为blob并下载 + canvas.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + + console.log('使用Canvas方案下载成功'); + } else { + console.error('Canvas转换为blob失败'); + } + }, 'image/png'); + } catch (canvasError) { + console.error('Canvas处理失败:', canvasError); + } + }; + + img.onerror = (imgError) => { + console.error('图像加载失败:', imgError); + console.log('下载失败,未执行回退方案'); + }; + + img.src = canvasImage; + } catch (canvasError) { + console.error('Canvas方案也失败了:', canvasError); + console.log('下载失败,未执行回退方案'); + } + } + })(); } else { // 普通URL格式 - fetch(canvasImage) - .then(response => response.blob()) - .then(blob => { + // 使用async IIFE处理异步操作 + (async () => { + try { + const response = await fetch(canvasImage); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -380,81 +532,60 @@ export const ImageCanvas: React.FC = () => { document.body.removeChild(link); // 清理创建的URL setTimeout(() => URL.revokeObjectURL(url), 100); - }) - .catch(error => { + } catch (error) { console.error('下载图像时出错:', error); - // 如果fetch失败,尝试直接下载 - const link = document.createElement('a'); - link.href = canvasImage; - link.download = `nano-banana-${Date.now()}.png`; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }); + // 如果fetch失败(可能是跨域问题),使用Canvas方案 + try { + const img = new Image(); + img.crossOrigin = 'Anonymous'; // 设置跨域属性 + img.onload = () => { + try { + // 创建canvas并绘制图像 + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + + // 将canvas转换为blob并下载 + canvas.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + + console.log('使用Canvas方案下载成功'); + } else { + console.error('Canvas转换为blob失败'); + } + }, 'image/png'); + } catch (canvasError) { + console.error('Canvas处理失败:', canvasError); + } + }; + + img.onerror = (imgError) => { + console.error('图像加载失败:', imgError); + console.log('下载失败,未执行回退方案'); + }; + + img.src = canvasImage; + } catch (canvasError) { + console.error('Canvas方案也失败了:', canvasError); + console.log('下载失败,未执行回退方案'); + } + } + })(); } } } - } else { - console.warn('Stage未初始化,无法下载画布内容'); - - // 回退到下载原始图像 - if (canvasImage) { - // 处理不同类型的URL - if (canvasImage.startsWith('data:')) { - // base64格式 - const link = document.createElement('a'); - link.href = canvasImage; - link.download = `nano-banana-${Date.now()}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } else if (canvasImage.startsWith('blob:')) { - // Blob URL格式 - fetch(canvasImage) - .then(response => response.blob()) - .then(blob => { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `nano-banana-${Date.now()}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - // 清理创建的URL - setTimeout(() => URL.revokeObjectURL(url), 100); - }) - .catch(error => { - console.error('下载Blob图像时出错:', error); - }); - } else { - // 普通URL格式 - fetch(canvasImage) - .then(response => response.blob()) - .then(blob => { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `nano-banana-${Date.now()}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - // 清理创建的URL - setTimeout(() => URL.revokeObjectURL(url), 100); - }) - .catch(error => { - console.error('下载图像时出错:', error); - // 如果fetch失败,尝试直接下载 - const link = document.createElement('a'); - link.href = canvasImage; - link.download = `nano-banana-${Date.now()}.png`; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }); - } - } } }; diff --git a/src/hooks/useImageGeneration.ts b/src/hooks/useImageGeneration.ts index 302f2c9..0411cfe 100644 --- a/src/hooks/useImageGeneration.ts +++ b/src/hooks/useImageGeneration.ts @@ -107,11 +107,21 @@ export const useImageGeneration = () => { // 上传参考图像(如果存在,使用缓存机制) let referenceUploadResults: any[] = []; if (request.referenceImages && request.referenceImages.length > 0) { - // 将参考图像也转换为Blob URL - const referenceUrls = await Promise.all(request.referenceImages.map(async (blob) => { - return useAppStore.getState().addBlob(blob); + // 将参考图像转换为base64字符串格式上传(与老版本保持一致) + const referenceBase64s = await Promise.all(request.referenceImages.map(async (blob) => { + if (typeof blob === 'string') { + // 如果已经是base64字符串,直接返回 + return blob; + } else { + // 如果是Blob对象,转换为base64字符串 + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } })); - referenceUploadResults = await uploadImages(referenceUrls, accessToken, false); + referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false); } // 合并上传结果 @@ -186,6 +196,10 @@ export const useImageGeneration = () => { addGeneration(generation); setCanvasImage(outputAssets[0].url); + + // 自动选择新生成的记录 + const { selectGeneration } = useAppStore.getState(); + selectGeneration(generation.id); } setIsGenerating(false); }, @@ -482,7 +496,24 @@ export const useImageEditing = () => { try { const imageUrls = outputAssets.map(asset => asset.url); // 上传编辑后的图像(跳过缓存,因为这些是新生成的图像) - uploadResults = await uploadImages(imageUrls, accessToken, true); + const outputUploadResults = await uploadImages(imageUrls, accessToken, true); + + // 上传参考图像(如果存在,使用缓存机制) + let referenceUploadResults: any[] = []; + if (referenceImageBlobs.length > 0) { + // 将参考图像转换为base64字符串格式上传(与老版本保持一致) + const referenceBase64s = await Promise.all(referenceImageBlobs.map(async (blob) => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + })); + referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false); + } + + // 合并上传结果 + uploadResults = [...outputUploadResults, ...referenceUploadResults]; // 检查上传结果 const failedUploads = uploadResults.filter(r => !r.success); diff --git a/src/services/indexedDBService.ts b/src/services/indexedDBService.ts index d048984..74c6c23 100644 --- a/src/services/indexedDBService.ts +++ b/src/services/indexedDBService.ts @@ -410,6 +410,74 @@ export const cleanupBase64Data = async (): Promise => { } }; +/** + * 删除指定的生成记录 + */ +export const deleteGeneration = async (id: string): Promise => { + const db = getDB(); + const transaction = db.transaction([GENERATIONS_STORE], 'readwrite'); + const store = transaction.objectStore(GENERATIONS_STORE); + + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +}; + +/** + * 删除指定的编辑记录 + */ +export const deleteEdit = async (id: string): Promise => { + const db = getDB(); + const transaction = db.transaction([EDITS_STORE], 'readwrite'); + const store = transaction.objectStore(EDITS_STORE); + + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +}; + +/** + * 批量删除生成记录 + */ +export const deleteGenerations = async (ids: string[]): Promise => { + const db = getDB(); + const transaction = db.transaction([GENERATIONS_STORE], 'readwrite'); + const store = transaction.objectStore(GENERATIONS_STORE); + + const promises = ids.map(id => { + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + + return Promise.all(promises).then(() => undefined); +}; + +/** + * 批量删除编辑记录 + */ +export const deleteEdits = async (ids: string[]): Promise => { + const db = getDB(); + const transaction = db.transaction([EDITS_STORE], 'readwrite'); + const store = transaction.objectStore(EDITS_STORE); + + const promises = ids.map(id => { + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + + return Promise.all(promises).then(() => undefined); +}; + /** * 清空所有记录 */ diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index abe1f8c..451e596 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -167,9 +167,12 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, blob = imageData; } - // 创建FormData对象 + // 创建FormData对象,使用唯一文件名 + const timestamp = Date.now(); + const random = Math.floor(Math.random() * 10000); + const fileName = `image-${timestamp}-${random}.png`; const formData = new FormData(); - formData.append('file', blob, 'generated-image.png'); + formData.append('file', blob, fileName); // 发送POST请求 const response = await fetch(UPLOAD_URL, { diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index b2ec279..32e359b 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -119,6 +119,12 @@ interface AppState { setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void; + // 删除历史记录 + deleteGeneration: (id: string) => void; + deleteEdit: (id: string) => void; + deleteGenerations: (ids: string[]) => void; + deleteEdits: (ids: string[]) => void; + // Blob存储操作 addBlob: (blob: Blob) => string; getBlob: (url: string) => Blob | undefined; @@ -643,7 +649,243 @@ export const useAppStore = create()( set({ blobStore: newBlobStore }); } }); - } + }, + + // 删除单个生成记录 + deleteGeneration: (id) => set((state) => { + if (!state.currentProject) return {}; + + // 找到要删除的记录 + const generationToDelete = state.currentProject.generations.find(gen => gen.id === id); + if (!generationToDelete) return {}; + + // 收集需要释放的Blob URLs + const urlsToRevoke: string[] = []; + + // 收集生成记录中的Blob URLs + generationToDelete.sourceAssets.forEach(asset => { + if (asset.blobUrl.startsWith('blob:')) { + urlsToRevoke.push(asset.blobUrl); + } + }); + generationToDelete.outputAssetsBlobUrls.forEach(url => { + if (url.startsWith('blob:')) { + urlsToRevoke.push(url); + } + }); + + // 从IndexedDB中删除记录 + indexedDBService.deleteGeneration(id).catch(err => { + console.error('从IndexedDB删除生成记录失败:', err); + }); + + // 释放Blob URLs + if (urlsToRevoke.length > 0) { + set((innerState) => { + urlsToRevoke.forEach(url => { + URL.revokeObjectURL(url); + const newBlobStore = new Map(innerState.blobStore); + newBlobStore.delete(url); + innerState = { ...innerState, blobStore: newBlobStore }; + }); + return innerState; + }); + } + + // 如果删除的是当前选中的记录,清除选择 + let selectedGenerationId = state.selectedGenerationId; + if (selectedGenerationId === id) { + selectedGenerationId = null; + } + + // 更新项目中的生成记录列表 + const updatedGenerations = state.currentProject.generations.filter(gen => gen.id !== id); + + return { + currentProject: { + ...state.currentProject, + generations: updatedGenerations, + updatedAt: Date.now() + }, + selectedGenerationId + }; + }), + + // 删除单个编辑记录 + deleteEdit: (id) => set((state) => { + if (!state.currentProject) return {}; + + // 找到要删除的记录 + const editToDelete = state.currentProject.edits.find(edit => edit.id === id); + if (!editToDelete) return {}; + + // 收集需要释放的Blob URLs + const urlsToRevoke: string[] = []; + + // 收集编辑记录中的Blob URLs + if (editToDelete.maskReferenceAssetBlobUrl && editToDelete.maskReferenceAssetBlobUrl.startsWith('blob:')) { + urlsToRevoke.push(editToDelete.maskReferenceAssetBlobUrl); + } + editToDelete.outputAssetsBlobUrls.forEach(url => { + if (url.startsWith('blob:')) { + urlsToRevoke.push(url); + } + }); + + // 从IndexedDB中删除记录 + indexedDBService.deleteEdit(id).catch(err => { + console.error('从IndexedDB删除编辑记录失败:', err); + }); + + // 释放Blob URLs + if (urlsToRevoke.length > 0) { + set((innerState) => { + urlsToRevoke.forEach(url => { + URL.revokeObjectURL(url); + const newBlobStore = new Map(innerState.blobStore); + newBlobStore.delete(url); + innerState = { ...innerState, blobStore: newBlobStore }; + }); + return innerState; + }); + } + + // 如果删除的是当前选中的记录,清除选择 + let selectedEditId = state.selectedEditId; + if (selectedEditId === id) { + selectedEditId = null; + } + + // 更新项目中的编辑记录列表 + const updatedEdits = state.currentProject.edits.filter(edit => edit.id !== id); + + return { + currentProject: { + ...state.currentProject, + edits: updatedEdits, + updatedAt: Date.now() + }, + selectedEditId + }; + }), + + // 批量删除生成记录 + deleteGenerations: (ids) => set((state) => { + if (!state.currentProject) return {}; + + // 收集需要释放的Blob URLs + const urlsToRevoke: string[] = []; + + // 收集所有要删除记录中的Blob URLs + state.currentProject.generations.forEach(gen => { + if (ids.includes(gen.id)) { + gen.sourceAssets.forEach(asset => { + if (asset.blobUrl.startsWith('blob:')) { + urlsToRevoke.push(asset.blobUrl); + } + }); + gen.outputAssetsBlobUrls.forEach(url => { + if (url.startsWith('blob:')) { + urlsToRevoke.push(url); + } + }); + } + }); + + // 从IndexedDB中批量删除记录 + indexedDBService.deleteGenerations(ids).catch(err => { + console.error('从IndexedDB批量删除生成记录失败:', err); + }); + + // 释放Blob URLs + if (urlsToRevoke.length > 0) { + set((innerState) => { + urlsToRevoke.forEach(url => { + URL.revokeObjectURL(url); + const newBlobStore = new Map(innerState.blobStore); + newBlobStore.delete(url); + innerState = { ...innerState, blobStore: newBlobStore }; + }); + return innerState; + }); + } + + // 如果删除的是当前选中的记录,清除选择 + let selectedGenerationId = state.selectedGenerationId; + if (selectedGenerationId && ids.includes(selectedGenerationId)) { + selectedGenerationId = null; + } + + // 更新项目中的生成记录列表 + const updatedGenerations = state.currentProject.generations.filter(gen => !ids.includes(gen.id)); + + return { + currentProject: { + ...state.currentProject, + generations: updatedGenerations, + updatedAt: Date.now() + }, + selectedGenerationId + }; + }), + + // 批量删除编辑记录 + deleteEdits: (ids) => set((state) => { + if (!state.currentProject) return {}; + + // 收集需要释放的Blob URLs + const urlsToRevoke: string[] = []; + + // 收集所有要删除记录中的Blob URLs + state.currentProject.edits.forEach(edit => { + if (ids.includes(edit.id)) { + if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) { + urlsToRevoke.push(edit.maskReferenceAssetBlobUrl); + } + edit.outputAssetsBlobUrls.forEach(url => { + if (url.startsWith('blob:')) { + urlsToRevoke.push(url); + } + }); + } + }); + + // 从IndexedDB中批量删除记录 + indexedDBService.deleteEdits(ids).catch(err => { + console.error('从IndexedDB批量删除编辑记录失败:', err); + }); + + // 释放Blob URLs + if (urlsToRevoke.length > 0) { + set((innerState) => { + urlsToRevoke.forEach(url => { + URL.revokeObjectURL(url); + const newBlobStore = new Map(innerState.blobStore); + newBlobStore.delete(url); + innerState = { ...innerState, blobStore: newBlobStore }; + }); + return innerState; + }); + } + + // 如果删除的是当前选中的记录,清除选择 + let selectedEditId = state.selectedEditId; + if (selectedEditId && ids.includes(selectedEditId)) { + selectedEditId = null; + } + + // 更新项目中的编辑记录列表 + const updatedEdits = state.currentProject.edits.filter(edit => !ids.includes(edit.id)); + + return { + currentProject: { + ...state.currentProject, + edits: updatedEdits, + updatedAt: Date.now() + }, + selectedEditId + }; + }) }), { name: 'nano-banana-store', diff --git a/v1/src/App.tsx b/v1/src/App.tsx new file mode 100644 index 0000000..eafda7d --- /dev/null +++ b/v1/src/App.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cn } from './utils/cn'; +import { Header } from './components/Header'; +import { PromptComposer } from './components/PromptComposer'; +import { ImageCanvas } from './components/ImageCanvas'; +import { HistoryPanel } from './components/HistoryPanel'; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import { useAppStore } from './store/useAppStore'; +import { ToastProvider } from './components/ToastContext'; +import * as indexedDBService from './services/indexedDBService'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5分钟 + retry: 2, + }, + }, +}); + +function AppContent() { + useKeyboardShortcuts(); + + const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore(); + const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null); + const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null); + const [isPreviewVisible, setIsPreviewVisible] = useState(false); + + // 在挂载时初始化IndexedDB并清理base64数据 + useEffect(() => { + const init = async () => { + try { + await indexedDBService.initDB(); + // 清理已有的base64数据 + await indexedDBService.cleanupBase64Data(); + } catch (err) { + console.error('初始化IndexedDB或清理base64数据失败:', err); + } + }; + + init(); + }, []); + + // 在挂载时设置移动设备默认值 + React.useEffect(() => { + const checkMobile = () => { + const isMobile = window.innerWidth < 768; + if (isMobile) { + setShowPromptPanel(false); + setShowHistory(false); + } + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, [setShowPromptPanel, setShowHistory]); + + // 定期清理旧的历史记录 + useEffect(() => { + const interval = setInterval(() => { + useAppStore.getState().cleanupOldHistory(); + }, 30000); // 每30秒清理一次 + + return () => clearInterval(interval); + }, []); + + // 定期清理未使用的Blob URL + useEffect(() => { + const interval = setInterval(() => { + useAppStore.getState().scheduleBlobCleanup(); + }, 60000); // 每分钟清理一次 + + return () => clearInterval(interval); + }, []); + + // 控制预览窗口的显示和隐藏动画 + useEffect(() => { + if (hoveredImage) { + // 延迟一小段时间后设置为可见,以触发动画 + const timer = setTimeout(() => { + setIsPreviewVisible(true); + }, 10); + return () => clearTimeout(timer); + } else { + setIsPreviewVisible(false); + } + }, [hoveredImage]); + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ + {/* 悬浮预览 */} + {hoveredImage && ( +
+
+
+ {hoveredImage.title} +
+
+ 预览 +
+ {/* 图像信息 */} +
+ {hoveredImage.width && hoveredImage.height && ( +
+ 尺寸: + {hoveredImage.width} × {hoveredImage.height} +
+ )} +
+
+
+ )} +
+ ); +} + +function App() { + return ( + + + + + + ); +} + +export default App; \ No newline at end of file diff --git a/v1/src/components/Header.tsx b/v1/src/components/Header.tsx new file mode 100644 index 0000000..2cceb8e --- /dev/null +++ b/v1/src/components/Header.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { Button } from './ui/Button'; +import { HelpCircle } from 'lucide-react'; +import { InfoModal } from './InfoModal'; + +export const Header: React.FC = () => { + const [showInfoModal, setShowInfoModal] = useState(false); + + return ( + <> +
+
+
+
🍌
+
+

+ Nano Banana +

+
+ +
+ +
+
+ + + + ); +}; \ No newline at end of file diff --git a/v1/src/components/HistoryPanel.tsx b/v1/src/components/HistoryPanel.tsx new file mode 100644 index 0000000..c9ea7b3 --- /dev/null +++ b/v1/src/components/HistoryPanel.tsx @@ -0,0 +1,1117 @@ +import React, { useState, useEffect } from 'react'; +import { useAppStore } from '../store/useAppStore'; +import { Button } from './ui/Button'; +import { History, Download, Trash2, Image as ImageIcon } from 'lucide-react'; +import { cn } from '../utils/cn'; +import { ImagePreviewModal } from './ImagePreviewModal'; +import * as indexedDBService from '../services/indexedDBService'; +import { useIndexedDBListener } from '../hooks/useIndexedDBListener'; +import { DayPicker } from 'react-day-picker'; +import zhCN from 'react-day-picker/dist/locale/zh-CN'; + +export const HistoryPanel: React.FC<{ + setHoveredImage: (image: {url: string, title: string, width?: number, height?: number} | null) => void, + setPreviewPosition?: (position: {x: number, y: number} | null) => void +}> = ({ setHoveredImage, setPreviewPosition }) => { + const { + currentProject, + canvasImage, + selectedGenerationId, + selectedEditId, + selectGeneration, + selectEdit, + showHistory, + setShowHistory, + setCanvasImage, + selectedTool, + removeGeneration, + removeEdit + } = useAppStore(); + + const { getBlob } = useAppStore.getState(); + + const [previewModal, setPreviewModal] = React.useState<{ + open: boolean; + imageUrl: string; + title: string; + description?: string; + }>({ + open: false, + imageUrl: '', + title: '', + description: '' + }); + + // 存储从Blob URL解码的图像数据 + const [decodedImages, setDecodedImages] = useState>({}); + + // 使用自定义hook获取IndexedDB记录 + const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener(); + + // 跟踪当前悬停的记录 + const [hoveredRecord, setHoveredRecord] = useState<{type: 'generation' | 'edit', id: string} | null>(null); + + // 筛选和搜索状态 + const [startDate, setStartDate] = useState(() => { + const today = new Date(); + return today.toISOString().split('T')[0]; // 默认为今天 + }); + const [endDate, setEndDate] = useState(() => { + const today = new Date(); + return today.toISOString().split('T')[0]; // 默认为今天 + }); + const [searchTerm, setSearchTerm] = useState(''); + const [showDatePicker, setShowDatePicker] = useState(false); // 控制日期选择器的显示 + const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({ + from: new Date(new Date().setHours(0, 0, 0, 0)), + to: new Date(new Date().setHours(0, 0, 0, 0)) + }); + + // 分页状态 + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; // 减少每页显示的项目数 + + + + const generations = currentProject?.generations || []; + const edits = currentProject?.edits || []; + + // 获取上传后的图片链接 + const getUploadedImageUrl = (generationOrEdit: any, index: number) => { + if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) { + const uploadResult = generationOrEdit.uploadResults[index]; + if (uploadResult.success && uploadResult.url) { + // 添加参数以降低图片质量 + return `${uploadResult.url}?x-oss-process=image/quality,q_30`; // 降低质量到30% + } + } + return null; + }; + + // 获取当前图像尺寸 + const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null); + + React.useEffect(() => { + if (canvasImage) { + const img = new Image(); + img.onload = () => { + setImageDimensions({ width: img.width, height: img.height }); + }; + img.src = canvasImage; + } else { + setImageDimensions(null); + } + }, [canvasImage]); + + // 错误处理显示 + if (error) { + return ( +
+
+
+ +

历史记录和变体

+
+ +
+
+

加载历史记录时出错: {error}

+ +
+
+ ); + } + + // 筛选记录的函数 + const filterRecords = (records: any[], isGeneration: boolean) => { + return records.filter(record => { + // 日期筛选 - 修复日期比较逻辑 + const recordDate = new Date(record.timestamp); + const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD + + if (startDate && recordDateStr < startDate) return false; + if (endDate && recordDateStr > endDate) return false; + + // 搜索词筛选 + if (searchTerm) { + if (isGeneration) { + // 生成记录按提示词搜索 + return record.prompt.toLowerCase().includes(searchTerm.toLowerCase()); + } else { + // 编辑记录按指令搜索 + return record.instruction.toLowerCase().includes(searchTerm.toLowerCase()); + } + } + + return true; + }); + }; + + // 筛选后的记录 + const filteredGenerations = filterRecords(dbGenerations, true); + const filteredEdits = filterRecords(dbEdits, false); + + // 当项目变化时,解码Blob图像 + useEffect(() => { + const decodeBlobImages = async () => { + const newDecodedImages: Record = {}; + + // 解码生成记录的输出图像 + for (const gen of generations) { + if (Array.isArray(gen.outputAssetsBlobUrls)) { + for (const blobUrl of gen.outputAssetsBlobUrls) { + if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) { + const blob = getBlob(blobUrl); + if (blob) { + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + newDecodedImages[blobUrl] = dataUrl; + } + } + } + } + } + + // 解码编辑记录的输出图像 + for (const edit of edits) { + if (Array.isArray(edit.outputAssetsBlobUrls)) { + for (const blobUrl of edit.outputAssetsBlobUrls) { + if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) { + const blob = getBlob(blobUrl); + if (blob) { + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + newDecodedImages[blobUrl] = dataUrl; + } + } + } + } + } + + if (Object.keys(newDecodedImages).length > 0) { + setDecodedImages(prev => ({ ...prev, ...newDecodedImages })); + } + }; + + decodeBlobImages(); + }, [generations, edits, getBlob, decodedImages]); + + // 监听鼠标离开窗口事件,确保悬浮预览正确关闭 + useEffect(() => { + const handleMouseLeave = (e: MouseEvent) => { + // 当鼠标离开浏览器窗口时,关闭悬浮预览 + if (e.relatedTarget === null) { + setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } + } + }; + + const handleBlur = () => { + // 当窗口失去焦点时,关闭悬浮预览 + setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } + }; + + window.addEventListener('mouseleave', handleMouseLeave); + window.addEventListener('blur', handleBlur); + + return () => { + window.removeEventListener('mouseleave', handleMouseLeave); + window.removeEventListener('blur', handleBlur); + }; + }, [setHoveredImage, setPreviewPosition]); + + if (!showHistory) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 头部 */} +
+
+ +

历史记录

+
+
+ + +
+
+ + {/* 筛选和搜索控件 */} +
+
+
+ + + {showDatePicker && ( +
+ + { + if (range) { + setDateRange(range); + // 更新字符串格式的日期用于筛选 + if (range.from) { + setStartDate(range.from.toISOString().split('T')[0]); + } + if (range.to) { + setEndDate(range.to.toISOString().split('T')[0]); + } + } + }} + numberOfMonths={2} + className="border-0" + locale={zhCN} + /> +
+ +
+
+ )} +
+
+
+ setSearchTerm(e.target.value)} + className="flex-1 text-xs p-1.5 border border-gray-200 rounded-l-lg bg-gray-50 focus:outline-none focus:ring-1 focus:ring-yellow-400 card" + placeholder="搜索提示词..." + /> + +
+
+ + {/* 变体网格 */} +
+
+

变体

+ + {filteredGenerations.length + filteredEdits.length}/100 + +
+ {filteredGenerations.length === 0 && filteredEdits.length === 0 ? ( +
+
🖼️
+

暂无历史记录

+
+ ) : ( +
+ {/* 显示生成记录 */} + {(() => { + const sortedGenerations = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedGenerations = sortedGenerations.slice(startIndex, endIndex); + + return paginatedGenerations.map((generation, index) => { + // 计算全局索引用于显示编号 + const globalIndex = startIndex + index; + + return ( +
{ + selectGeneration(generation.id); + // 设置画布图像为第一个输出资产 + if (generation.outputAssets && generation.outputAssets.length > 0) { + const asset = generation.outputAssets[0]; + if (asset.url) { + setCanvasImage(asset.url); + } + } + }} + onMouseEnter={(e) => { + // 设置当前悬停的记录 + setHoveredRecord({type: 'generation', id: generation.id}); + + // 优先使用上传后的远程链接,如果没有则使用原始链接 + let imageUrl = getUploadedImageUrl(generation, 0); + if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) { + imageUrl = generation.outputAssets[0].url; + } + if (imageUrl) { + // 创建图像对象以获取尺寸 + const img = new Image(); + img.onload = () => { + setHoveredImage({ + url: imageUrl, + title: `生成记录 G${globalIndex + 1}`, + width: img.width, + height: img.height + }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } + }; + img.onerror = (error) => { + console.error('图像加载失败:', error); + // 即使图像加载失败,也显示预览 + setHoveredImage({ + url: imageUrl, + title: `生成记录 G${globalIndex + 1}`, + width: 0, + height: 0 + }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } + }; + img.src = imageUrl; + } + }} + onMouseMove={() => { + // 不需要处理鼠标移动事件,因为预览现在在页面中心显示 + }} + onMouseLeave={() => { + // 清除当前悬停的记录 + setHoveredRecord(null); + + setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } + }} + > + {(() => { + // 优先使用上传后的远程链接 + const imageUrl = getUploadedImageUrl(generation, 0) || + (generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null); + + if (imageUrl) { + return 生成的变体; + } else { + return ( +
+ +
+ ); + } + })()} + + {/* 变体编号 */} +
+ G{globalIndex + 1} +
+ + {/* 悬停时显示的按钮 */} + {hoveredRecord && hoveredRecord.type === 'generation' && hoveredRecord.id === generation.id && ( +
+ + +
+ )} +
+ ); + }); + })()} + + {/* 显示编辑记录 */} + {(() => { + const sortedEdits = [...filteredEdits].sort((a, b) => b.timestamp - a.timestamp); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedEdits = sortedEdits.slice(startIndex, endIndex); + + // 计算生成记录的数量,用于编辑记录的编号 + const generationCount = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).length; + + return paginatedEdits.map((edit, index) => { + // 计算全局索引用于显示编号 + const globalIndex = startIndex + index; + + return ( +
{ + selectEdit(edit.id); + selectGeneration(null); + // 设置画布图像为第一个输出资产 + if (edit.outputAssets && edit.outputAssets.length > 0) { + const asset = edit.outputAssets[0]; + if (asset.url) { + setCanvasImage(asset.url); + } + } + }} + onMouseEnter={(e) => { + // 设置当前悬停的记录 + setHoveredRecord({type: 'edit', id: edit.id}); + + // 优先使用上传后的远程链接,如果没有则使用原始链接 + let imageUrl = getUploadedImageUrl(edit, 0); + if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) { + imageUrl = edit.outputAssets[0].url; + } + + if (imageUrl) { + // 创建图像对象以获取尺寸 + const img = new Image(); + img.onload = () => { + setHoveredImage({ + url: imageUrl, + title: `编辑记录 E${globalIndex + 1}`, + width: img.width, + height: img.height + }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } + }; + img.onerror = (error) => { + console.error('图像加载失败:', error); + // 即使图像加载失败,也显示预览 + setHoveredImage({ + url: imageUrl, + title: `编辑记录 E${globalIndex + 1}`, + width: 0, + height: 0 + }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } + }; + img.src = imageUrl; + } + }} + onMouseMove={() => { + // 不需要处理鼠标移动事件,因为预览现在在页面中心显示 + }} + onMouseLeave={() => { + // 清除当前悬停的记录 + setHoveredRecord(null); + + setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } + }} + > + {(() => { + // 优先使用上传后的远程链接 + const imageUrl = getUploadedImageUrl(edit, 0) || + (edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null); + + if (imageUrl) { + return 编辑的变体; + } else { + return ( +
+ +
+ ); + } + })()} + + {/* 编辑标签 */} +
+ E{globalIndex + 1} +
+ + {/* 悬停时显示的按钮 */} + {hoveredRecord && hoveredRecord.type === 'edit' && hoveredRecord.id === edit.id && ( +
+ + +
+ )} +
+ ); + }); + })()} +
+ )} +
+ + {/* 分页控件 */} + {(() => { + const totalItems = filteredGenerations.length + filteredEdits.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); + + // 只在有多页时显示分页控件 + if (totalPages > 1) { + return ( +
+ + +
+ 第 {currentPage} 页,共 {totalPages} 页 +
+ + +
+ ); + } + + return null; + })()} + + {/* 生成详情 */} +
+

详情

+ {(() => { + const gen = filteredGenerations.find(g => g.id === selectedGenerationId) || dbGenerations.find(g => g.id === selectedGenerationId); + const selectedEdit = filteredEdits.find(e => e.id === selectedEditId) || dbEdits.find(e => e.id === selectedEditId); + + if (gen) { + return ( +
+
+
+ 提示: +

{gen.prompt}

+
+
+ 模型: + {gen.modelVersion} +
+ {gen.parameters.seed && ( +
+ 种子: + {gen.parameters.seed} +
+ )} +
+ 时间: + {new Date(gen.timestamp).toLocaleString()} +
+
+ + {/* 上传结果 */} + {gen.uploadResults && gen.uploadResults.length > 0 && ( +
+
上传结果
+
+ {gen.uploadResults.map((result, index) => ( +
+
+ 图像 {index + 1}: + + {result.success ? '成功' : '失败'} + +
+ {result.success && result.url && ( +
+ {result.url.split('/').pop()} +
+ )} + {result.error && ( +
+ {result.error} +
+ )} +
+ ))} +
+
+ )} + + {/* 参考图像信息 */} + {gen.sourceAssets && gen.sourceAssets.length > 0 && ( +
+
参考图像
+
+ {gen.sourceAssets.length} 个参考图像 +
+
+ {gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => { + // 获取上传后的远程链接(如果存在) + // 参考图像在uploadResults中从索引1开始(索引0是生成的图像) + const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success + ? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` + : null; + // 对于Blob URL,我们需要从decodedImages中获取解码后的图像数据 + const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url; + + return ( +
{ + e.stopPropagation(); + setPreviewModal({ + open: true, + imageUrl: displayUrl, + title: `参考图像 ${index + 1}`, + description: `${asset.width} × ${asset.height}` + }); + }} + > + {`参考图像 +
+ ); + })} + {gen.sourceAssets.length > 4 && ( +
+ +{gen.sourceAssets.length - 4} +
+ )} +
+
+ )} +
+ ); + } else if (selectedEdit) { + const parentGen = filteredGenerations.find(g => g.id === selectedEdit.parentGenerationId) || dbGenerations.find(g => g.id === selectedEdit.parentGenerationId); + return ( +
+
+
+ 编辑指令: +

{selectedEdit.instruction}

+
+
+ 类型: + 图像编辑 +
+
+ 创建时间: + {new Date(selectedEdit.timestamp).toLocaleString()} +
+ {selectedEdit.maskAssetId && ( +
+ 遮罩: + 已应用 +
+ )} +
+ + {/* 上传结果 */} + {selectedEdit.uploadResults && selectedEdit.uploadResults.length > 0 && ( +
+
上传结果
+
+ {selectedEdit.uploadResults.map((result, index) => ( +
+
+ 图像 {index + 1}: + + {result.success ? '成功' : '失败'} + +
+ {result.success && result.url && ( +
+ {result.url.split('/').pop()} +
+ )} + {result.error && ( +
+ {result.error} +
+ )} +
+ ))} +
+
+ )} + + {/* 原始生成参考 */} + {parentGen && ( +
+
原始生成
+
+ 基于: G{dbGenerations.findIndex(g => g.id === parentGen.id) + 1} +
+ {/* 显示原始生成的参考图像 */} + {parentGen.sourceAssets && parentGen.sourceAssets.length > 0 && ( +
+
+ 原始参考图像: +
+
+ {parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => { + // 获取上传后的远程链接(如果存在) + // 参考图像在uploadResults中从索引1开始(索引0是生成的图像) + const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success + ? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` + : null; + // 对于Blob URL,我们需要从decodedImages中获取解码后的图像数据 + const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url; + + return ( +
{ + e.stopPropagation(); + setPreviewModal({ + open: true, + imageUrl: displayUrl, + title: `原始参考图像 ${index + 1}`, + description: `${asset.width} × ${asset.height}` + }); + }} + > + {`原始参考图像 +
+ ); + })} + {parentGen.sourceAssets.length > 4 && ( +
+ +{parentGen.sourceAssets.length - 4} +
+ )} +
+
+ )} +
+ )} +
+ ); + } else { + return ( +
+

选择一个记录以查看详细信息

+
+ ); + } + })()} +
+ + + + {/* 图像预览模态框 */} + setPreviewModal(prev => ({ ...prev, open }))} + imageUrl={previewModal.imageUrl} + title={previewModal.title} + description={previewModal.description} + /> +
+ ); +}; \ No newline at end of file diff --git a/v1/src/components/ImageCanvas.tsx b/v1/src/components/ImageCanvas.tsx new file mode 100644 index 0000000..ce86973 --- /dev/null +++ b/v1/src/components/ImageCanvas.tsx @@ -0,0 +1,629 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva'; +import { useAppStore } from '../store/useAppStore'; +import { Button } from './ui/Button'; +import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react'; +import { cn } from '../utils/cn'; + +export const ImageCanvas: React.FC = () => { + const { + canvasImage, + canvasZoom, + setCanvasZoom, + canvasPan, + setCanvasPan, + brushStrokes, + addBrushStroke, + clearBrushStrokes, + showMasks, + setShowMasks, + selectedTool, + isGenerating, + brushSize, + setBrushSize, + showHistory, + showPromptPanel + } = useAppStore(); + + const stageRef = useRef(null); + const [image, setImage] = useState(null); + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + const [isDrawing, setIsDrawing] = useState(false); + const [currentStroke, setCurrentStroke] = useState([]); + + const handleZoom = useCallback((delta: number) => { + const stage = stageRef.current; + if (stage) { + const currentZoom = stage.scaleX(); + const newZoom = Math.max(0.1, Math.min(3, currentZoom + delta)); + + // 先更新React状态以确保Konva Image组件使用正确的缩放值 + setCanvasZoom(newZoom); + + // 使用setTimeout确保DOM已更新后再设置Stage + setTimeout(() => { + const stage = stageRef.current; + if (stage) { + // 直接通过stageRef控制Stage + stage.scale({ x: newZoom, y: newZoom }); + stage.batchDraw(); + } + }, 0); + } + }, []); + + // 加载图像 + useEffect(() => { + let img: HTMLImageElement | null = null; + + if (canvasImage) { + console.log('开始加载图像,URL:', canvasImage); + + img = new window.Image(); + let isCancelled = false; + + img.onload = () => { + // 检查是否已取消 + if (isCancelled) { + console.log('图像加载被取消'); + return; + } + + console.log('图像加载成功,尺寸:', img.width, 'x', img.height); + setImage(img); + + // 只在图像首次加载时自动适应画布 + if (!isCancelled && img) { + const isMobile = window.innerWidth < 768; + const padding = isMobile ? 0.9 : 0.8; + + const scaleX = (stageSize.width * padding) / img.width; + const scaleY = (stageSize.height * padding) / img.height; + + const maxZoom = isMobile ? 0.3 : 0.8; + const optimalZoom = Math.min(scaleX, scaleY, maxZoom); + + // 立即更新React状态以确保Konva Image组件使用正确的缩放值 + setCanvasZoom(optimalZoom); + setCanvasPan({ x: 0, y: 0 }); + + // 使用setTimeout确保DOM已更新后再设置Stage + setTimeout(() => { + if (!isCancelled && img) { + // 直接设置缩放,但保持Stage居中 + const stage = stageRef.current; + if (stage) { + stage.scale({ x: optimalZoom, y: optimalZoom }); + // 重置Stage位置以确保居中 + stage.position({ x: 0, y: 0 }); + stage.batchDraw(); + } + + console.log('图像自动适应画布完成,缩放:', optimalZoom); + } + }, 0); + } + }; + + img.onerror = (error) => { + if (!isCancelled) { + console.error('图像加载失败:', error); + console.error('图像URL:', canvasImage); + + // 检查是否是Blob URL + if (canvasImage.startsWith('blob:')) { + console.log('正在检查Blob URL是否有效...'); + + // 检查Blob URL是否仍然有效 + fetch(canvasImage) + .then(response => { + if (!response.ok) { + console.error('Blob URL无法访问:', response.status, response.statusText); + } else { + console.log('Blob URL可以访问'); + } + }) + .catch(err => { + console.error('检查Blob URL时出错:', err); + // 尝试从AppStore重新获取Blob + import('../store/useAppStore').then((module) => { + const useAppStore = module.useAppStore; + const blob = useAppStore.getState().getBlob(canvasImage); + if (blob) { + console.log('从AppStore找到Blob,尝试重新创建URL...'); + // 重新创建Blob URL并重试加载 + const newUrl = URL.createObjectURL(blob); + console.log('创建新的Blob URL:', newUrl); + // 更新canvasImage为新的URL + useAppStore.getState().setCanvasImage(newUrl); + } else { + console.error('AppStore中未找到Blob'); + } + }).catch(err => { + console.error('导入AppStore时出错:', err); + }); + }); + } + } + }; + + img.src = canvasImage; + } else { + console.log('没有图像需要加载'); + // 当没有图像时,清理之前的图像对象 + if (image) { + // 清理图像对象以释放内存 + image.onload = null; + image.onerror = null; + image.src = ''; + } + setImage(null); + } + + // 清理函数 + return () => { + console.log('清理图像加载资源'); + // 取消图像加载 + if (img) { + img.onload = null; + img.onerror = null; + // 清理图像源以释放内存 + img.src = ''; + } + + // 清理之前的图像对象 + if (image) { + image.onload = null; + image.onerror = null; + image.src = ''; + } + }; + }, [canvasImage]); // 只依赖canvasImage,避免其他依赖引起循环 + + // 处理舞台大小调整 + useEffect(() => { + const updateSize = () => { + const container = document.getElementById('canvas-container'); + if (container) { + setStageSize({ + width: container.offsetWidth, + height: container.offsetHeight + }); + } + }; + + updateSize(); + window.addEventListener('resize', updateSize); + return () => window.removeEventListener('resize', updateSize); + }, []); + + // 监听面板状态变化以调整画布大小 + useEffect(() => { + // 使用 setTimeout 确保 DOM 已更新 + const timer = setTimeout(() => { + const container = document.getElementById('canvas-container'); + if (container) { + setStageSize({ + width: container.offsetWidth, + height: container.offsetHeight + }); + } + }, 100); + + return () => clearTimeout(timer); + }, [showPromptPanel, showHistory]); + + // 处理鼠标滚轮缩放 + useEffect(() => { + const container = document.getElementById('canvas-container'); + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + handleZoom(delta); + }; + + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + }, [canvasZoom]); + + const handleMouseDown = (e: any) => { + if (selectedTool !== 'mask' || !image) return; + + setIsDrawing(true); + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + + // 使用 Konva 的 getRelativePointerPosition 获取准确坐标 + const relativePos = stage.getRelativePointerPosition(); + + // 计算图像在舞台上的边界 + const imageX = (stageSize.width / canvasZoom - image.width) / 2; + const imageY = (stageSize.height / canvasZoom - image.height) / 2; + + // 转换为相对于图像的坐标 + const relativeX = relativePos.x - imageX; + const relativeY = relativePos.y - imageY; + + // 检查点击是否在图像边界内 + if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) { + setCurrentStroke([relativeX, relativeY]); + } + }; + + const handleMouseMove = (e: any) => { + if (!isDrawing || selectedTool !== 'mask' || !image) return; + + const stage = e.target.getStage(); + const pos = stage.getPointerPosition(); + + // 使用 Konva 的 getRelativePointerPosition 获取准确坐标 + const relativePos = stage.getRelativePointerPosition(); + + // 计算图像在舞台上的边界 + const imageX = (stageSize.width / canvasZoom - image.width) / 2; + const imageY = (stageSize.height / canvasZoom - image.height) / 2; + + // 转换为相对于图像的坐标 + const relativeX = relativePos.x - imageX; + const relativeY = relativePos.y - imageY; + + // 检查是否在图像边界内 + if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) { + setCurrentStroke([...currentStroke, relativeX, relativeY]); + } + }; + + const handleMouseUp = () => { + if (!isDrawing || currentStroke.length < 4) { + setIsDrawing(false); + setCurrentStroke([]); + return; + } + + setIsDrawing(false); + addBrushStroke({ + id: `stroke-${Date.now()}`, + points: currentStroke, + brushSize, + }); + setCurrentStroke([]); + }; + + const handleReset = () => { + if (image) { + const isMobile = window.innerWidth < 768; + const padding = isMobile ? 0.9 : 0.8; + const scaleX = (stageSize.width * padding) / image.width; + const scaleY = (stageSize.height * padding) / image.height; + const maxZoom = isMobile ? 0.3 : 0.8; + const optimalZoom = Math.min(scaleX, scaleY, maxZoom); + + // 同时更新React状态以确保Konva Image组件使用正确的缩放值 + setCanvasZoom(optimalZoom); + setCanvasPan({ x: 0, y: 0 }); + + // 使用setTimeout确保DOM已更新后再设置Stage + setTimeout(() => { + // 直接通过stageRef控制Stage + const stage = stageRef.current; + if (stage) { + stage.scale({ x: optimalZoom, y: optimalZoom }); + stage.position({ x: 0, y: 0 }); + stage.batchDraw(); + } + }, 0); + } + }; + + const handleDownload = () => { + // 直接下载当前画布内容 + const stage = stageRef.current; + if (stage) { + try { + // 使用Konva的toDataURL方法获取画布内容 + const dataURL = stage.toDataURL(); + + // 创建下载链接 + 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); + + console.log('画布内容下载成功'); + } catch (error) { + console.error('下载画布内容时出错:', error); + + // 如果Konva下载失败,回退到下载原始图像 + if (canvasImage) { + // 处理不同类型的URL + if (canvasImage.startsWith('data:')) { + // base64格式 + const link = document.createElement('a'); + link.href = canvasImage; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else if (canvasImage.startsWith('blob:')) { + // Blob URL格式 + fetch(canvasImage) + .then(response => response.blob()) + .then(blob => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + }) + .catch(error => { + console.error('下载Blob图像时出错:', error); + }); + } else { + // 普通URL格式 + fetch(canvasImage) + .then(response => response.blob()) + .then(blob => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + }) + .catch(error => { + console.error('下载图像时出错:', error); + // 如果fetch失败,尝试直接下载 + const link = document.createElement('a'); + link.href = canvasImage; + link.download = `nano-banana-${Date.now()}.png`; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + } + } + } + } else { + console.warn('Stage未初始化,无法下载画布内容'); + + // 回退到下载原始图像 + if (canvasImage) { + // 处理不同类型的URL + if (canvasImage.startsWith('data:')) { + // base64格式 + const link = document.createElement('a'); + link.href = canvasImage; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else if (canvasImage.startsWith('blob:')) { + // Blob URL格式 + fetch(canvasImage) + .then(response => response.blob()) + .then(blob => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + }) + .catch(error => { + console.error('下载Blob图像时出错:', error); + }); + } else { + // 普通URL格式 + fetch(canvasImage) + .then(response => response.blob()) + .then(blob => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `nano-banana-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // 清理创建的URL + setTimeout(() => URL.revokeObjectURL(url), 100); + }) + .catch(error => { + console.error('下载图像时出错:', error); + // 如果fetch失败,尝试直接下载 + const link = document.createElement('a'); + link.href = canvasImage; + link.download = `nano-banana-${Date.now()}.png`; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + } + } + } + }; + + return ( +
+ {/* 工具栏 */} + + + {/* 画布区域 */} +
+ {!image && !isGenerating && ( +
+
+
🍌
+

+ Nano Banana AI +

+

+ {selectedTool === 'generate' + ? '在提示框中描述您想要创建的内容' + : '上传图像开始编辑' + } +

+
+
+ )} + + {isGenerating && ( +
+
+
+

正在创建图像...

+
+
+ )} + + { + // 通过stageRef直接获取和设置位置 + const stage = stageRef.current; + if (stage) { + const scale = stage.scaleX(); + setCanvasPan({ + x: stage.x() / scale, + y: stage.y() / scale + }); + } + }} + onMouseDown={handleMouseDown} + onMousemove={handleMouseMove} + onMouseup={handleMouseUp} + style={{ + cursor: selectedTool === 'mask' ? 'crosshair' : 'default', + zIndex: 10 + }} + > + + {image && ( + { + console.log('KonvaImage组件渲染完成'); + }} + /> + )} + + {/* 画笔描边 */} + {showMasks && brushStrokes.map((stroke) => ( + + ))} + + {/* 正在绘制的当前描边 */} + {isDrawing && currentStroke.length > 2 && ( + + )} + + + + {/* 悬浮操作按钮 */} + {image && !isGenerating && ( +
+ + + {Math.round(canvasZoom * 100)}% + + + +
+ +
+ )} +
+ + {/* 状态栏 */} +
+
+
+ {brushStrokes.length > 0 && ( + {brushStrokes.length} 个描边 + )} +
+ +
+ + © 2025 Mark Fulton + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/v1/src/components/ImagePreviewModal.tsx b/v1/src/components/ImagePreviewModal.tsx new file mode 100644 index 0000000..06408b4 --- /dev/null +++ b/v1/src/components/ImagePreviewModal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { Button } from './ui/Button'; + +interface ImagePreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + imageUrl: string; + title: string; + description?: string; +} + +export const ImagePreviewModal: React.FC = ({ + open, + onOpenChange, + imageUrl, + title, + description +}) => { + return ( + + + + +
+ + {title} + + + + +
+ +
+ {description && ( +

{description}

+ )} + +
+ {title} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/v1/src/components/InfoModal.tsx b/v1/src/components/InfoModal.tsx new file mode 100644 index 0000000..ae06d21 --- /dev/null +++ b/v1/src/components/InfoModal.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { X, ExternalLink, Lightbulb, Download } from 'lucide-react'; +import { Button } from './ui/Button'; + +interface InfoModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const InfoModal: React.FC = ({ open, onOpenChange }) => { + return ( + + + + +
+ + 关于 Nano Banana AI 图像编辑器 + + + + +
+ +
+
+

+ 由{' '} + + Mark Fulton + + + 开发 +

+ +
+
+
+ +

+ 学习构建AI应用和其他解决方案 +

+
+

+ 学习编写像这样的应用程序,掌握AI自动化,构建智能代理,并创建推动实际业务成果的前沿解决方案。 +

+ + 加入AI加速器计划 + + +
+ +
+
+ +

+ 获取此应用程序的副本 +

+
+

+ 通过加入Vibe Coding is Life Skool社区获取此应用程序的副本。现场构建会话、应用程序项目、资源等,这是网络上最好的氛围编码社区。 +

+ + 加入Vibe Coding is Life社区 + + +
+
+
+ + {/* 键盘快捷键 */} +
+

键盘快捷键

+
+
+ 生成图像 + ⌘ + Enter +
+
+ 重新生成 + ⇧ + R +
+
+ 编辑模式 + E +
+
+ 历史记录 + H +
+
+ 切换面板 + P +
+
+ 中断生成 + Esc +
+
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/v1/src/components/MaskOverlay.tsx b/v1/src/components/MaskOverlay.tsx new file mode 100644 index 0000000..2abaedc --- /dev/null +++ b/v1/src/components/MaskOverlay.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useAppStore } from '../store/useAppStore'; + +export const MaskOverlay: React.FC = () => { + const { selectedMask, showMasks } = useAppStore(); + + if (!showMasks || !selectedMask) return null; + + return ( +
+ {/* Marching ants effect */} +
+ + {/* Mask overlay */} +
+
+ ); +}; \ No newline at end of file diff --git a/v1/src/components/PromptComposer.tsx b/v1/src/components/PromptComposer.tsx new file mode 100644 index 0000000..835275d --- /dev/null +++ b/v1/src/components/PromptComposer.tsx @@ -0,0 +1,483 @@ +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 [showClearConfirm, setShowClearConfirm] = useState(false); + const [showHintsModal, setShowHintsModal] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(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 { + // 从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) => { + const file = event.target.files?.[0]; + if (file) { + await handleFileUpload(file); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => { + setIsDragOver(false); + }; + + const handleDrop = async (event: React.DragEvent) => { + 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 ( +
+ +
+ ); + } + + return ( + <> +
+
+
+

模式

+
+ + +
+
+
+ {tools.map((tool) => ( + + ))} +
+
+ + {/* 文件上传 */} +
+
+ + {selectedTool === 'mask' && ( +

使用遮罩编辑图像

+ )} + {selectedTool === 'generate' && ( +

可选,最多2张图像

+ )} + {selectedTool === 'edit' && ( +

+ {canvasImage ? '可选样式参考,最多2张图像' : '上传要编辑的图像,最多2张图像'} +

+ )} + + + +
+ +
+

+ {isDragOver ? "释放文件以上传" : "拖拽或点击上传"} +

+

+ 支持 JPG, PNG, GIF +

+
+ +
+
+ + {/* Show uploaded images preview */} + {((selectedTool === 'generate' && uploadedImages.length > 0) || + (selectedTool === 'edit' && editReferenceImages.length > 0)) && ( +
+ {(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => ( +
+ {`参考图像 + +
+ 参考 {index + 1} +
+
+ ))} +
+ )} +
+ + {/* 提示输入 */} +
+ +