You've already forked Nano-Banana-AI-Image-Editor
新增 历史记录删除功能
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -22,7 +22,7 @@ const queryClient = new QueryClient({
|
||||
function AppContent() {
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const { showPromptPanel, setShowPromptPanel, setShowHistory } = useAppStore();
|
||||
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);
|
||||
@@ -95,8 +95,8 @@ function AppContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out", !showPromptPanel && "w-8")}>
|
||||
<div className="h-full card card-lg">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
|
||||
<div className={cn("h-full", showPromptPanel ? "card card-lg" : "")}>
|
||||
<PromptComposer />
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,8 +105,8 @@ function AppContent() {
|
||||
<ImageCanvas />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-full card card-lg">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showHistory ? "w-72" : "w-8")}>
|
||||
<div className={cn("h-full", showHistory ? "card card-lg" : "")}>
|
||||
<HistoryPanel setHoveredImage={setHoveredImage} setPreviewPosition={setPreviewPosition} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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, Trash2, Image as ImageIcon } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
@@ -23,7 +23,9 @@ export const HistoryPanel: React.FC<{
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
setCanvasImage,
|
||||
selectedTool
|
||||
selectedTool,
|
||||
removeGeneration,
|
||||
removeEdit
|
||||
} = useAppStore();
|
||||
|
||||
const { getBlob } = useAppStore.getState();
|
||||
@@ -46,6 +48,9 @@ export const HistoryPanel: React.FC<{
|
||||
// 使用自定义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<string>(() => {
|
||||
const today = new Date();
|
||||
@@ -212,18 +217,47 @@ export const HistoryPanel: React.FC<{
|
||||
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 (
|
||||
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl">
|
||||
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-lg flex items-center justify-center transition-colors group"
|
||||
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-lg flex items-center justify-center transition-all duration-300 ease-in-out group"
|
||||
title="显示历史面板"
|
||||
>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -522,6 +556,9 @@ export const HistoryPanel: React.FC<{
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// 设置当前悬停的记录
|
||||
setHoveredRecord({type: 'generation', id: generation.id});
|
||||
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
let imageUrl = getUploadedImageUrl(generation, 0);
|
||||
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
|
||||
@@ -563,6 +600,9 @@ export const HistoryPanel: React.FC<{
|
||||
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// 清除当前悬停的记录
|
||||
setHoveredRecord(null);
|
||||
|
||||
setHoveredImage(null);
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition(null);
|
||||
@@ -589,6 +629,47 @@ export const HistoryPanel: React.FC<{
|
||||
<div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white">
|
||||
G{globalIndex + 1}
|
||||
</div>
|
||||
|
||||
{/* 悬停时显示的按钮 */}
|
||||
{hoveredRecord && hoveredRecord.type === 'generation' && hoveredRecord.id === generation.id && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center gap-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 下载图像
|
||||
const imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
if (imageUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `generation-G${globalIndex + 1}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}}
|
||||
title="下载图像"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-red-500/90 hover:bg-red-500 text-white rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 删除记录
|
||||
removeGeneration(generation.id);
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -629,6 +710,9 @@ export const HistoryPanel: React.FC<{
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// 设置当前悬停的记录
|
||||
setHoveredRecord({type: 'edit', id: edit.id});
|
||||
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
let imageUrl = getUploadedImageUrl(edit, 0);
|
||||
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
|
||||
@@ -671,6 +755,9 @@ export const HistoryPanel: React.FC<{
|
||||
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// 清除当前悬停的记录
|
||||
setHoveredRecord(null);
|
||||
|
||||
setHoveredImage(null);
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition(null);
|
||||
@@ -697,6 +784,47 @@ export const HistoryPanel: React.FC<{
|
||||
<div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white">
|
||||
E{globalIndex + 1}
|
||||
</div>
|
||||
|
||||
{/* 悬停时显示的按钮 */}
|
||||
{hoveredRecord && hoveredRecord.type === 'edit' && hoveredRecord.id === edit.id && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center gap-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 下载图像
|
||||
const imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
if (imageUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `edit-E${globalIndex + 1}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}}
|
||||
title="下载图像"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-red-500/90 hover:bg-red-500 text-white rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 删除记录
|
||||
removeEdit(edit.id);
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -818,7 +946,8 @@ export const HistoryPanel: React.FC<{
|
||||
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;
|
||||
const displayUrl = uploadedUrl || asset.url;
|
||||
// 对于Blob URL,我们需要从decodedImages中获取解码后的图像数据
|
||||
const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -926,7 +1055,8 @@ export const HistoryPanel: React.FC<{
|
||||
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;
|
||||
const displayUrl = uploadedUrl || asset.url;
|
||||
// 对于Blob URL,我们需要从decodedImages中获取解码后的图像数据
|
||||
const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -168,16 +168,16 @@ export const PromptComposer: React.FC = () => {
|
||||
|
||||
if (!showPromptPanel) {
|
||||
return (
|
||||
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl">
|
||||
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowPromptPanel(true)}
|
||||
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-r-lg flex items-center justify-center transition-all duration-300 ease-in-out group"
|
||||
title="显示提示面板"
|
||||
>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -338,8 +338,8 @@ export const PromptComposer: React.FC = () => {
|
||||
selectedTool === 'generate'
|
||||
? '描述您想要创建的内容...'
|
||||
: '描述您想要的修改...'
|
||||
}
|
||||
className="min-h-[120px] resize-none text-sm rounded-xl"
|
||||
}
|
||||
className="min-h-[180px] resize-none text-sm rounded-xl"
|
||||
/>
|
||||
|
||||
{/* 常用提示词 */}
|
||||
|
||||
@@ -171,7 +171,7 @@ export const useImageGeneration = () => {
|
||||
id: generateId(),
|
||||
type: 'original' as const,
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
mime: blob.type || 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
|
||||
@@ -90,34 +90,13 @@ async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
|
||||
const blob = useAppStore.getState().getBlob(blobUrl)
|
||||
|
||||
if (!blob) {
|
||||
// 如果AppStore中没有找到Blob,尝试从URL获取
|
||||
console.warn('无法从AppStore获取Blob,尝试从URL获取:', blobUrl)
|
||||
try {
|
||||
const response = await fetch(blobUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return await response.blob()
|
||||
} catch (error) {
|
||||
console.error('从URL获取Blob失败:', error)
|
||||
throw new Error('无法从Blob URL获取图像数据')
|
||||
}
|
||||
throw new Error('无法从AppStore获取Blob,Blob可能已被清理');
|
||||
}
|
||||
|
||||
return blob
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error('从AppStore获取Blob时出错:', error)
|
||||
// 如果导入AppStore失败,直接尝试从URL获取
|
||||
try {
|
||||
const response = await fetch(blobUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return await response.blob()
|
||||
} catch (fetchError) {
|
||||
console.error('从URL获取Blob失败:', fetchError)
|
||||
throw new Error('无法从Blob URL获取图像数据')
|
||||
}
|
||||
console.error('从AppStore获取Blob时出错:', error);
|
||||
throw new Error('无法从Blob URL获取图像数据');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ interface AppState {
|
||||
|
||||
addGeneration: (generation: Generation) => void;
|
||||
addEdit: (edit: Edit) => void;
|
||||
removeGeneration: (id: string) => void;
|
||||
removeEdit: (id: string) => void;
|
||||
selectGeneration: (id: string | null) => void;
|
||||
selectEdit: (id: string | null) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
@@ -259,6 +261,17 @@ export const useAppStore = create<AppState>()(
|
||||
};
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
// 同时确保存储在blobStore中
|
||||
set((innerState) => {
|
||||
const blob = innerState.blobStore.get(asset.url);
|
||||
if (blob) {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(asset.url, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
}
|
||||
return innerState;
|
||||
});
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
@@ -269,7 +282,7 @@ export const useAppStore = create<AppState>()(
|
||||
blobUrl: asset.url
|
||||
};
|
||||
}
|
||||
// 对于其他URL类型,创建一个新的Blob URL
|
||||
// 对于其他URL类型,直接使用URL
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
@@ -519,6 +532,98 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
|
||||
// 删除生成记录
|
||||
removeGeneration: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationToRemove = state.currentProject.generations.find(gen => gen.id === id);
|
||||
|
||||
if (generationToRemove) {
|
||||
// 收集要删除的生成记录中的Blob URLs
|
||||
generationToRemove.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
generationToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 从项目中移除生成记录
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
generations: state.currentProject.generations.filter(gen => gen.id !== id),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
// 删除编辑记录
|
||||
removeEdit: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const editToRemove = state.currentProject.edits.find(edit => edit.id === id);
|
||||
|
||||
if (editToRemove) {
|
||||
// 收集要删除的编辑记录中的Blob URLs
|
||||
if (editToRemove.maskReferenceAssetBlobUrl && editToRemove.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(editToRemove.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
editToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 从项目中移除编辑记录
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
edits: state.currentProject.edits.filter(edit => edit.id !== id),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
// 清理旧的历史记录
|
||||
cleanupOldHistory: () => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
Reference in New Issue
Block a user