You've already forked Nano-Banana-AI-Image-Editor
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
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 (
|
||
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
|
||
<div className="card card-lg rounded-none">
|
||
<Header />
|
||
</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 overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
|
||
<div className={cn("h-full", showPromptPanel ? "card card-lg" : "")}>
|
||
<PromptComposer />
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="h-full card card-lg">
|
||
<ImageCanvas />
|
||
</div>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
|
||
{/* 悬浮预览 */}
|
||
{hoveredImage && (
|
||
<div
|
||
className="fixed inset-0 z-[99999] flex items-center justify-center pointer-events-none"
|
||
>
|
||
<div
|
||
className="bg-white rounded-xl shadow-2xl border border-gray-300 overflow-hidden max-w-2xl max-h-[80vh] flex flex-col transition-all duration-200 ease-out"
|
||
style={{
|
||
transform: isPreviewVisible ? 'scale(1)' : 'scale(0.8)',
|
||
opacity: isPreviewVisible ? 1 : 0,
|
||
transformOrigin: previewPosition ? `${previewPosition.x}px ${previewPosition.y}px` : 'center'
|
||
}}
|
||
>
|
||
<div className="bg-gray-900 text-white text-sm p-3 truncate font-medium">
|
||
{hoveredImage.title}
|
||
</div>
|
||
<div className="flex-1 flex items-center justify-center p-4">
|
||
<img
|
||
src={hoveredImage.url}
|
||
alt="预览"
|
||
className="max-w-full max-h-[60vh] object-contain"
|
||
/>
|
||
</div>
|
||
{/* 图像信息 */}
|
||
<div className="p-3 bg-gray-50 border-t border-gray-200 text-sm">
|
||
{hoveredImage.width && hoveredImage.height && (
|
||
<div className="flex justify-between text-gray-600">
|
||
<span>尺寸:</span>
|
||
<span className="text-gray-800 font-medium">{hoveredImage.width} × {hoveredImage.height}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
return (
|
||
<QueryClientProvider client={queryClient}>
|
||
<ToastProvider>
|
||
<AppContent />
|
||
</ToastProvider>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
export default App; |