You've already forked Nano-Banana-AI-Image-Editor
新增 历史记录持久化功能;
新增 增加最大历史记录条数为10条;
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Button } from './ui/Button';
|
||||
import { History, Download, Image as ImageIcon, Layers } from 'lucide-react';
|
||||
import { History, Download, Image as ImageIcon } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||||
|
||||
@@ -19,6 +19,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
selectedTool
|
||||
} = useAppStore();
|
||||
|
||||
const { getBlob } = useAppStore.getState();
|
||||
|
||||
const [previewModal, setPreviewModal] = React.useState<{
|
||||
open: boolean;
|
||||
imageUrl: string;
|
||||
@@ -31,6 +33,9 @@ export const HistoryPanel: React.FC = () => {
|
||||
description: ''
|
||||
});
|
||||
|
||||
// 存储从Blob URL解码的图像数据
|
||||
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
|
||||
|
||||
const generations = currentProject?.generations || [];
|
||||
const edits = currentProject?.edits || [];
|
||||
|
||||
@@ -49,6 +54,57 @@ export const HistoryPanel: React.FC = () => {
|
||||
}
|
||||
}, [canvasImage]);
|
||||
|
||||
// 当项目变化时,解码Blob图像
|
||||
useEffect(() => {
|
||||
const decodeBlobImages = async () => {
|
||||
const newDecodedImages: Record<string, string> = {};
|
||||
|
||||
// 解码生成记录的输出图像
|
||||
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<string>((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<string>((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]);
|
||||
|
||||
if (!showHistory) {
|
||||
return (
|
||||
<div className="w-8 bg-white border-l border-gray-200 flex flex-col items-center justify-center">
|
||||
@@ -88,85 +144,124 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
{/* 变体网格 */}
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-3">当前变体</h4>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-medium text-gray-400">当前变体</h4>
|
||||
<span className="text-xs text-gray-500">
|
||||
{generations.length + edits.length}/10
|
||||
</span>
|
||||
</div>
|
||||
{generations.length === 0 && edits.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">🖼️</div>
|
||||
<p className="text-sm text-gray-500">暂无生成记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 max-h-80 overflow-y-auto">
|
||||
{/* 显示生成记录 */}
|
||||
{generations.slice(-2).map((generation, index) => (
|
||||
{[...generations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 10).map((generation, index) => (
|
||||
<div
|
||||
key={generation.id}
|
||||
className={cn(
|
||||
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
|
||||
selectedGenerationId === generation.id
|
||||
? 'border-yellow-400'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
onClick={() => {
|
||||
selectGeneration(generation.id);
|
||||
if (generation.outputAssets[0]) {
|
||||
setCanvasImage(generation.outputAssets[0].url);
|
||||
// 设置画布图像为第一个输出资产
|
||||
if (generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0) {
|
||||
const blobUrl = generation.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
setCanvasImage(decodedUrl);
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
// 如果不是Blob URL,直接使用
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generation.outputAssets[0] ? (
|
||||
<>
|
||||
<img
|
||||
src={generation.outputAssets[0].url}
|
||||
alt="生成的变体"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</>
|
||||
{generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
const blobUrl = generation.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
return <img src={decodedUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
return <img src={blobUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
} else {
|
||||
return (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变体编号 */}
|
||||
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded">
|
||||
#{index + 1}
|
||||
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded text-white">
|
||||
G{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 显示编辑记录 */}
|
||||
{edits.slice(-2).map((edit, index) => (
|
||||
{[...edits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 10).map((edit, index) => (
|
||||
<div
|
||||
key={edit.id}
|
||||
className={cn(
|
||||
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
|
||||
selectedEditId === edit.id
|
||||
? 'border-yellow-400'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
? 'border-purple-400'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (edit.outputAssets[0]) {
|
||||
setCanvasImage(edit.outputAssets[0].url);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
// 设置画布图像为第一个输出资产
|
||||
if (edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0) {
|
||||
const blobUrl = edit.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
setCanvasImage(decodedUrl);
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
// 如果不是Blob URL,直接使用
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{edit.outputAssets[0] ? (
|
||||
<img
|
||||
src={edit.outputAssets[0].url}
|
||||
alt="编辑的变体"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
const blobUrl = edit.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
return <img src={decodedUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
return <img src={blobUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
} else {
|
||||
return (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑标签 */}
|
||||
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded">
|
||||
编辑 #{index + 1}
|
||||
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded text-white">
|
||||
E{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -176,26 +271,26 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
{/* 当前图像信息 */}
|
||||
{(canvasImage || imageDimensions) && (
|
||||
<div className="mb-4 p-3 bg-gray-100 rounded-lg border border-gray-300">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">当前图像</h4>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h4 className="text-xs font-medium text-gray-500 mb-2">当前图像</h4>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
{imageDimensions && (
|
||||
<div className="flex justify-between">
|
||||
<span>尺寸:</span>
|
||||
<span className="text-gray-300">{imageDimensions.width} × {imageDimensions.height}</span>
|
||||
<span className="text-gray-800">{imageDimensions.width} × {imageDimensions.height}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>模式:</span>
|
||||
<span className="text-gray-300 capitalize">{selectedTool}</span>
|
||||
<span className="text-gray-800 capitalize">{selectedTool}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成详情 */}
|
||||
<div className="mb-6 p-4 bg-gray-100 rounded-lg border border-gray-300 flex-1 overflow-y-auto min-h-0">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">生成详情</h4>
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200 flex-1 overflow-y-auto min-h-0">
|
||||
<h4 className="text-xs font-medium text-gray-500 mb-2">生成详情</h4>
|
||||
{(() => {
|
||||
const gen = generations.find(g => g.id === selectedGenerationId);
|
||||
const selectedEdit = edits.find(e => e.id === selectedEditId);
|
||||
@@ -203,10 +298,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
if (gen) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-400">提示:</span>
|
||||
<p className="text-gray-300 mt-1">{gen.prompt}</p>
|
||||
<span className="text-gray-500">提示:</span>
|
||||
<p className="text-gray-800 mt-1">{gen.prompt}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>模型:</span>
|
||||
@@ -218,37 +313,18 @@ export const HistoryPanel: React.FC = () => {
|
||||
<span>{gen.parameters.seed}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>时间:</span>
|
||||
<span>{new Date(gen.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参考图像 */}
|
||||
{/* 参考图像信息 */}
|
||||
{gen.sourceAssets.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">参考图像</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{gen.sourceAssets.map((asset, index) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: asset.url,
|
||||
title: `参考图像 ${index + 1}`,
|
||||
description: '此参考图像用于指导生成'
|
||||
})}
|
||||
className="relative aspect-square rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={`参考 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-gray-300">
|
||||
参考 {index + 1}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">参考图像</h5>
|
||||
<div className="text-xs text-gray-600">
|
||||
{gen.sourceAssets.length} 个参考图像
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -258,10 +334,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-400">编辑指令:</span>
|
||||
<p className="text-gray-300 mt-1">{selectedEdit.instruction}</p>
|
||||
<span className="text-gray-500">编辑指令:</span>
|
||||
<p className="text-gray-800 mt-1">{selectedEdit.instruction}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>类型:</span>
|
||||
@@ -269,12 +345,12 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>创建时间:</span>
|
||||
<span>{new Date(selectedEdit.timestamp).toLocaleTimeString()}</span>
|
||||
<span>{new Date(selectedEdit.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
{selectedEdit.maskAssetId && (
|
||||
<div className="flex justify-between">
|
||||
<span>遮罩:</span>
|
||||
<span className="text-purple-400">已应用</span>
|
||||
<span className="text-purple-600">已应用</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -282,53 +358,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
{/* 原始生成参考 */}
|
||||
{parentGen && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">原始图像</h5>
|
||||
<button
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: parentGen.outputAssets[0]?.url || '',
|
||||
title: '原始图像',
|
||||
description: '被编辑的基础图像'
|
||||
})}
|
||||
className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={parentGen.outputAssets[0]?.url}
|
||||
alt="原始"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 遮罩可视化 */}
|
||||
{selectedEdit.maskReferenceAsset && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">遮罩参考</h5>
|
||||
<button
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: selectedEdit.maskReferenceAsset!.url,
|
||||
title: '遮罩参考图像',
|
||||
description: '带有遮罩叠加的图像被发送到AI模型以指导编辑'
|
||||
})}
|
||||
className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={selectedEdit.maskReferenceAsset.url}
|
||||
alt="遮罩参考"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-purple-300">
|
||||
遮罩
|
||||
</div>
|
||||
</button>
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">原始生成</h5>
|
||||
<div className="text-xs text-gray-600">
|
||||
基于: G{generations.length - generations.indexOf(parentGen)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -336,7 +369,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<p className="text-gray-400">选择一个生成记录或编辑记录以查看详细信息</p>
|
||||
<p className="text-gray-500">选择一个生成记录或编辑记录以查看详细信息</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -354,8 +387,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
if (selectedGenerationId) {
|
||||
const gen = generations.find(g => g.id === selectedGenerationId);
|
||||
imageUrl = gen?.outputAssets[0]?.url || null;
|
||||
const { canvasImage } = useAppStore.getState();
|
||||
imageUrl = canvasImage;
|
||||
} else {
|
||||
// 如果没有选择生成记录,尝试获取当前画布图像
|
||||
const { canvasImage } = useAppStore.getState();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Generation, Edit, Asset } from '../types';
|
||||
import { useToast } from '../components/ToastContext';
|
||||
|
||||
export const useImageGeneration = () => {
|
||||
const { addGeneration, setIsGenerating, setCanvasImage, setCurrentProject, currentProject } = useAppStore();
|
||||
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const generateMutation = useMutation({
|
||||
@@ -81,7 +81,6 @@ export const useImageEditing = () => {
|
||||
editReferenceImages,
|
||||
brushStrokes,
|
||||
selectedGenerationId,
|
||||
currentProject,
|
||||
seed,
|
||||
temperature,
|
||||
uploadedImages
|
||||
@@ -229,7 +228,7 @@ export const useImageEditing = () => {
|
||||
|
||||
const edit: Edit = {
|
||||
id: generateId(),
|
||||
parentGenerationId: selectedGenerationId || (currentProject?.generations[currentProject.generations.length - 1]?.id || ''),
|
||||
parentGenerationId: selectedGenerationId || '',
|
||||
maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
|
||||
maskReferenceAsset,
|
||||
instruction,
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { Project, Generation, Edit, SegmentationMask, BrushStroke } from '../types';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
|
||||
// 定义不包含图像数据的轻量级项目结构
|
||||
interface LightweightProject {
|
||||
id: string;
|
||||
title: string;
|
||||
generations: Array<{
|
||||
id: string;
|
||||
prompt: string;
|
||||
parameters: Generation['parameters'];
|
||||
sourceAssets: Array<{
|
||||
id: string;
|
||||
type: 'original';
|
||||
mime: string;
|
||||
width: number;
|
||||
height: number;
|
||||
checksum: string;
|
||||
// 存储Blob URL而不是base64数据
|
||||
blobUrl: string;
|
||||
}>;
|
||||
// 存储输出资产的Blob URL
|
||||
outputAssetsBlobUrls: string[];
|
||||
modelVersion: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
edits: Array<{
|
||||
id: string;
|
||||
parentGenerationId: string;
|
||||
maskAssetId?: string;
|
||||
// 存储遮罩参考资产的Blob URL
|
||||
maskReferenceAssetBlobUrl?: string;
|
||||
instruction: string;
|
||||
// 存储输出资产的Blob URL
|
||||
outputAssetsBlobUrls: string[];
|
||||
timestamp: number;
|
||||
}>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// 当前项目
|
||||
currentProject: Project | null;
|
||||
// 当前项目(轻量级版本,不包含实际图像数据)
|
||||
currentProject: LightweightProject | null;
|
||||
|
||||
// 画布状态
|
||||
canvasImage: string | null;
|
||||
@@ -38,8 +76,11 @@ interface AppState {
|
||||
// UI状态
|
||||
selectedTool: 'generate' | 'edit' | 'mask';
|
||||
|
||||
// 存储Blob对象的Map
|
||||
blobStore: Map<string, Blob>;
|
||||
|
||||
// 操作
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
setCurrentProject: (project: LightweightProject | null) => void;
|
||||
setCanvasImage: (url: string | null) => void;
|
||||
setCanvasZoom: (zoom: number) => void;
|
||||
setCanvasPan: (pan: { x: number; y: number }) => void;
|
||||
@@ -71,103 +112,325 @@ interface AppState {
|
||||
setShowPromptPanel: (show: boolean) => void;
|
||||
|
||||
setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
|
||||
|
||||
// Blob存储操作
|
||||
addBlob: (blob: Blob) => string;
|
||||
getBlob: (url: string) => Blob | undefined;
|
||||
cleanupOldHistory: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// 初始状态
|
||||
currentProject: null,
|
||||
canvasImage: null,
|
||||
canvasZoom: 1,
|
||||
canvasPan: { x: 0, y: 0 },
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 初始状态
|
||||
currentProject: null,
|
||||
canvasImage: null,
|
||||
canvasZoom: 1,
|
||||
canvasPan: { x: 0, y: 0 },
|
||||
|
||||
uploadedImages: [],
|
||||
editReferenceImages: [],
|
||||
uploadedImages: [],
|
||||
editReferenceImages: [],
|
||||
|
||||
brushStrokes: [],
|
||||
brushSize: 20,
|
||||
showMasks: true,
|
||||
brushStrokes: [],
|
||||
brushSize: 20,
|
||||
showMasks: true,
|
||||
|
||||
isGenerating: false,
|
||||
currentPrompt: '',
|
||||
temperature: 0.7,
|
||||
seed: null,
|
||||
isGenerating: false,
|
||||
currentPrompt: '',
|
||||
temperature: 0.7,
|
||||
seed: null,
|
||||
|
||||
selectedGenerationId: null,
|
||||
selectedEditId: null,
|
||||
showHistory: true,
|
||||
selectedGenerationId: null,
|
||||
selectedEditId: null,
|
||||
showHistory: true,
|
||||
|
||||
showPromptPanel: true,
|
||||
showPromptPanel: true,
|
||||
|
||||
selectedTool: 'generate',
|
||||
selectedTool: 'generate',
|
||||
|
||||
// 操作
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
setCanvasImage: (url) => set({ canvasImage: url }),
|
||||
setCanvasZoom: (zoom) => set({ canvasZoom: zoom }),
|
||||
setCanvasPan: (pan) => set({ canvasPan: pan }),
|
||||
// Blob存储(不在持久化中保存)
|
||||
blobStore: new Map(),
|
||||
|
||||
addUploadedImage: (url) => set((state) => ({
|
||||
uploadedImages: [...state.uploadedImages, url]
|
||||
})),
|
||||
removeUploadedImage: (index) => set((state) => ({
|
||||
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearUploadedImages: () => set({ uploadedImages: [] }),
|
||||
// 操作
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
setCanvasImage: (url) => set({ canvasImage: url }),
|
||||
setCanvasZoom: (zoom) => set({ canvasZoom: zoom }),
|
||||
setCanvasPan: (pan) => set({ canvasPan: pan }),
|
||||
|
||||
addEditReferenceImage: (url) => set((state) => ({
|
||||
editReferenceImages: [...state.editReferenceImages, url]
|
||||
})),
|
||||
removeEditReferenceImage: (index) => set((state) => ({
|
||||
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
|
||||
addUploadedImage: (url) => set((state) => ({
|
||||
uploadedImages: [...state.uploadedImages, url]
|
||||
})),
|
||||
removeUploadedImage: (index) => set((state) => ({
|
||||
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearUploadedImages: () => set({ uploadedImages: [] }),
|
||||
|
||||
addBrushStroke: (stroke) => set((state) => ({
|
||||
brushStrokes: [...state.brushStrokes, stroke]
|
||||
})),
|
||||
clearBrushStrokes: () => set({ brushStrokes: [] }),
|
||||
setBrushSize: (size) => set({ brushSize: size }),
|
||||
setShowMasks: (show) => set({ showMasks: show }),
|
||||
addEditReferenceImage: (url) => set((state) => ({
|
||||
editReferenceImages: [...state.editReferenceImages, url]
|
||||
})),
|
||||
removeEditReferenceImage: (index) => set((state) => ({
|
||||
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
|
||||
|
||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
||||
setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
|
||||
setTemperature: (temp) => set({ temperature: temp }),
|
||||
setSeed: (seed) => set({ seed: seed }),
|
||||
addBrushStroke: (stroke) => set((state) => ({
|
||||
brushStrokes: [...state.brushStrokes, stroke]
|
||||
})),
|
||||
clearBrushStrokes: () => set({ brushStrokes: [] }),
|
||||
setBrushSize: (size) => set({ brushSize: size }),
|
||||
setShowMasks: (show) => set({ showMasks: show }),
|
||||
|
||||
addGeneration: (generation) => set((state) => ({
|
||||
currentProject: state.currentProject ? {
|
||||
...state.currentProject,
|
||||
generations: [...state.currentProject.generations, generation],
|
||||
updatedAt: Date.now()
|
||||
} : {
|
||||
// 如果没有项目,创建一个新项目包含此生成记录
|
||||
id: generateId(),
|
||||
title: '未命名项目',
|
||||
generations: [generation],
|
||||
edits: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
})),
|
||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
||||
setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
|
||||
setTemperature: (temp) => set({ temperature: temp }),
|
||||
setSeed: (seed) => set({ seed: seed }),
|
||||
|
||||
addEdit: (edit) => set((state) => ({
|
||||
currentProject: state.currentProject ? {
|
||||
...state.currentProject,
|
||||
edits: [...state.currentProject.edits, edit],
|
||||
updatedAt: Date.now()
|
||||
} : null
|
||||
})),
|
||||
// 添加Blob到存储并返回URL
|
||||
addBlob: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
set((state) => {
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.set(url, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
return url;
|
||||
},
|
||||
|
||||
selectGeneration: (id) => set({ selectedGenerationId: id }),
|
||||
selectEdit: (id) => set({ selectedEditId: id }),
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
// 从存储中获取Blob
|
||||
getBlob: (url: string) => {
|
||||
return get().blobStore.get(url);
|
||||
},
|
||||
|
||||
setShowPromptPanel: (show) => set({ showPromptPanel: show }),
|
||||
addGeneration: (generation) => set((state) => {
|
||||
// 将base64图像数据转换为Blob并存储
|
||||
const sourceAssets = generation.sourceAssets.map(asset => {
|
||||
if (asset.url.startsWith('data:')) {
|
||||
// 从base64创建Blob
|
||||
const base64 = asset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = asset.url.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);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
}),
|
||||
{ name: 'nano-banana-store' }
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(blobUrl, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum,
|
||||
blobUrl
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum,
|
||||
blobUrl: asset.url
|
||||
};
|
||||
});
|
||||
|
||||
// 将输出资产转换为Blob URL
|
||||
const outputAssetsBlobUrls = generation.outputAssets.map(asset => {
|
||||
if (asset.url.startsWith('data:')) {
|
||||
// 从base64创建Blob
|
||||
const base64 = asset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = asset.url.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);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(blobUrl, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
return asset.url;
|
||||
});
|
||||
|
||||
// 创建轻量级生成记录
|
||||
const lightweightGeneration = {
|
||||
id: generation.id,
|
||||
prompt: generation.prompt,
|
||||
parameters: generation.parameters,
|
||||
sourceAssets,
|
||||
outputAssetsBlobUrls,
|
||||
modelVersion: generation.modelVersion,
|
||||
timestamp: generation.timestamp
|
||||
};
|
||||
|
||||
const updatedProject = state.currentProject ? {
|
||||
...state.currentProject,
|
||||
generations: [...state.currentProject.generations, lightweightGeneration],
|
||||
updatedAt: Date.now()
|
||||
} : {
|
||||
// 如果没有项目,创建一个新项目包含此生成记录
|
||||
id: generateId(),
|
||||
title: '未命名项目',
|
||||
generations: [lightweightGeneration],
|
||||
edits: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.generations.length > 10) {
|
||||
updatedProject.generations.splice(0, updatedProject.generations.length - 10);
|
||||
}
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
addEdit: (edit) => set((state) => {
|
||||
// 将遮罩参考资产转换为Blob URL(如果存在)
|
||||
let maskReferenceAssetBlobUrl: string | undefined;
|
||||
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url.startsWith('data:')) {
|
||||
const base64 = edit.maskReferenceAsset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = edit.maskReferenceAsset.url.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);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
maskReferenceAssetBlobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(maskReferenceAssetBlobUrl!, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
} else if (edit.maskReferenceAsset) {
|
||||
maskReferenceAssetBlobUrl = edit.maskReferenceAsset.url;
|
||||
}
|
||||
|
||||
// 将输出资产转换为Blob URL
|
||||
const outputAssetsBlobUrls = edit.outputAssets.map(asset => {
|
||||
if (asset.url.startsWith('data:')) {
|
||||
// 从base64创建Blob
|
||||
const base64 = asset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = asset.url.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);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(blobUrl, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
return asset.url;
|
||||
});
|
||||
|
||||
// 创建轻量级编辑记录
|
||||
const lightweightEdit = {
|
||||
id: edit.id,
|
||||
parentGenerationId: edit.parentGenerationId,
|
||||
maskAssetId: edit.maskAssetId,
|
||||
maskReferenceAssetBlobUrl,
|
||||
instruction: edit.instruction,
|
||||
outputAssetsBlobUrls,
|
||||
timestamp: edit.timestamp
|
||||
};
|
||||
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
edits: [...state.currentProject.edits, lightweightEdit],
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.edits.length > 10) {
|
||||
updatedProject.edits.splice(0, updatedProject.edits.length - 10);
|
||||
}
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
selectGeneration: (id) => set({ selectedGenerationId: id }),
|
||||
selectEdit: (id) => set({ selectedEditId: id }),
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
|
||||
setShowPromptPanel: (show) => set({ showPromptPanel: show }),
|
||||
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
|
||||
// 清理旧的历史记录,保留最多10条
|
||||
cleanupOldHistory: () => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
const generations = [...state.currentProject.generations];
|
||||
const edits = [...state.currentProject.edits];
|
||||
|
||||
// 如果生成记录超过10条,只保留最新的10条
|
||||
if (generations.length > 10) {
|
||||
generations.splice(0, generations.length - 10);
|
||||
}
|
||||
|
||||
// 如果编辑记录超过10条,只保留最新的10条
|
||||
if (edits.length > 10) {
|
||||
edits.splice(0, edits.length - 10);
|
||||
}
|
||||
|
||||
return {
|
||||
currentProject: {
|
||||
...state.currentProject,
|
||||
generations,
|
||||
edits,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
};
|
||||
})
|
||||
}),
|
||||
{
|
||||
name: 'nano-banana-store',
|
||||
partialize: (state) => ({
|
||||
currentProject: state.currentProject,
|
||||
// 我们只持久化轻量级项目数据,不包含Blob对象
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user