You've already forked Nano-Banana-AI-Image-Editor
- 移除ImageCanvas和HistoryPanel中不必要的useAppStore动态导入 - 添加缺失的Jest测试依赖(jest, ts-jest, jest-environment-jsdom, identity-obj-proxy) - 修复ImageCanvas测试中的React引用问题和forwardRef支持 - 清理因移除动态导入导致的语法错误 - 优化代码结构,提高构建性能 验证: - 构建成功通过 - 所有5个测试套件通过(34个测试) - TypeScript类型检查无错误
616 lines
20 KiB
TypeScript
616 lines
20 KiB
TypeScript
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
|
||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||
import type { Stage as StageType } from 'konva/lib/Stage';
|
||
import { useAppStore } from '../store/useAppStore';
|
||
import { Button } from './ui/Button';
|
||
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
|
||
import { downloadImage } from '../utils/imageUtils';
|
||
|
||
export const ImageCanvas: React.FC = () => {
|
||
const {
|
||
canvasImage,
|
||
canvasZoom,
|
||
// canvasPan,
|
||
setCanvasZoom,
|
||
setCanvasPan,
|
||
brushStrokes,
|
||
addBrushStroke,
|
||
showMasks,
|
||
selectedTool,
|
||
isGenerating,
|
||
isContinuousGenerating,
|
||
retryCount,
|
||
brushSize,
|
||
showHistory,
|
||
showPromptPanel
|
||
} = useAppStore();
|
||
|
||
const stageRef = useRef<StageType>(null);
|
||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||
const [isDrawing, setIsDrawing] = useState(false);
|
||
const [currentStroke, setCurrentStroke] = useState<number[]>([]);
|
||
|
||
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);
|
||
}
|
||
}, [setCanvasZoom]);
|
||
|
||
// 加载图像
|
||
useEffect(() => {
|
||
console.log('useEffect triggered, canvasImage:', canvasImage);
|
||
|
||
// 如果没有图像URL,直接返回
|
||
if (!canvasImage) {
|
||
console.log('没有图像需要加载');
|
||
setImage(null);
|
||
return;
|
||
}
|
||
|
||
let img: HTMLImageElement | null = null;
|
||
let isCancelled = false;
|
||
|
||
console.log('开始加载图像,URL:', canvasImage);
|
||
|
||
img = new window.Image();
|
||
|
||
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) {
|
||
return;
|
||
}
|
||
|
||
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) {
|
||
return;
|
||
}
|
||
|
||
console.error('图像加载失败:', error);
|
||
console.error('图像URL:', canvasImage);
|
||
|
||
// 检查是否是IndexedDB URL
|
||
if (canvasImage.startsWith('indexeddb://')) {
|
||
console.log('正在处理IndexedDB图像...');
|
||
|
||
// 从IndexedDB获取图像并创建Blob URL
|
||
const imageId = canvasImage.replace('indexeddb://', '');
|
||
import('../services/referenceImageService').then((module) => {
|
||
const referenceImageService = module;
|
||
referenceImageService.getReferenceImage(imageId)
|
||
.then(blob => {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
console.log('从IndexedDB创建新的Blob URL:', newUrl);
|
||
// 更新canvasImage为新的URL
|
||
// 检查是否已取消
|
||
if (!isCancelled) {
|
||
useAppStore.getState().setCanvasImage(newUrl);
|
||
}
|
||
} else {
|
||
console.error('IndexedDB中未找到图像');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
console.error('从IndexedDB获取图像时出错:', err);
|
||
});
|
||
}).catch(err => {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
console.error('导入referenceImageService时出错:', err);
|
||
});
|
||
}
|
||
// 检查是否是Blob URL
|
||
else if (canvasImage.startsWith('blob:')) {
|
||
console.log('正在检查Blob URL是否有效...');
|
||
|
||
// 尝试从AppStore重新获取Blob并创建新的URL
|
||
const blob = useAppStore.getState().getBlob(canvasImage);
|
||
if (blob) {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
||
// 重新创建Blob URL并重试加载
|
||
const newUrl = URL.createObjectURL(blob);
|
||
console.log('创建新的Blob URL:', newUrl);
|
||
// 更新canvasImage为新的URL
|
||
useAppStore.getState().setCanvasImage(newUrl);
|
||
} else {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
console.error('AppStore中未找到Blob');
|
||
// 如果AppStore中也没有,尝试通过fetch检查URL
|
||
fetch(canvasImage)
|
||
.then(response => {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
console.error('Blob URL无法访问:', response.status, response.statusText);
|
||
} else {
|
||
console.log('Blob URL可以访问,但图像加载仍然失败');
|
||
}
|
||
})
|
||
.catch(fetchErr => {
|
||
// 检查是否已取消
|
||
if (isCancelled) {
|
||
return;
|
||
}
|
||
|
||
console.error('检查Blob URL时出错:', fetchErr);
|
||
});
|
||
}
|
||
|
||
}
|
||
};
|
||
|
||
img.src = canvasImage;
|
||
|
||
// 清理函数
|
||
return () => {
|
||
console.log('清理图像加载资源');
|
||
// 标记为已取消
|
||
isCancelled = true;
|
||
// 取消图像加载
|
||
if (img) {
|
||
img.onload = null;
|
||
img.onerror = null;
|
||
// 清理图像源以释放内存
|
||
img.src = '';
|
||
}
|
||
};
|
||
}, [canvasImage, setCanvasZoom, setCanvasPan, stageSize.height, stageSize.width]); // 移除image依赖项
|
||
|
||
// 处理舞台大小调整
|
||
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);
|
||
}, [showPromptPanel, showHistory]);
|
||
|
||
// 监听面板状态变化以调整画布大小
|
||
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, handleZoom]);
|
||
|
||
const handleMouseDown = (e: KonvaEventObject<MouseEvent>) => {
|
||
if (selectedTool !== 'mask' || !image) return;
|
||
|
||
setIsDrawing(true);
|
||
const stage = e.target.getStage();
|
||
if (!stage) return;
|
||
|
||
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
|
||
const relativePos = stage.getRelativePointerPosition();
|
||
if (!relativePos) return;
|
||
|
||
// 计算图像在舞台上的边界
|
||
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: KonvaEventObject<MouseEvent>) => {
|
||
if (!isDrawing || selectedTool !== 'mask' || !image) return;
|
||
|
||
const stage = e.target.getStage();
|
||
if (!stage) return;
|
||
|
||
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
|
||
const relativePos = stage.getRelativePointerPosition();
|
||
if (!relativePos) return;
|
||
|
||
// 计算图像在舞台上的边界
|
||
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,
|
||
color: '#A855F7',
|
||
});
|
||
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 = async () => {
|
||
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的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) {
|
||
try {
|
||
await downloadImage(uploadResult.url, `nano-banana-${Date.now()}.png`);
|
||
} catch (error) {
|
||
console.error('下载图像失败:', error);
|
||
}
|
||
// 立即返回
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 如果没有上传后的URL或下载失败,回退到下载当前画布内容
|
||
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) {
|
||
try {
|
||
await downloadImage(canvasImage, `nano-banana-${Date.now()}.png`);
|
||
} catch (error) {
|
||
console.error('下载图像失败:', error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};;
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* 工具栏 */}
|
||
|
||
|
||
{/* 画布区域 */}
|
||
<div
|
||
id="canvas-container"
|
||
className="flex-1 relative overflow-hidden bg-gray-100 rounded-lg"
|
||
>
|
||
{!image && !isGenerating && (
|
||
<div className="absolute inset-0 flex items-center justify-center z-0">
|
||
<div className="text-center max-w-xs">
|
||
<div className="text-5xl mb-3">🍌</div>
|
||
<h2 className="text-lg font-medium text-gray-400 mb-1">
|
||
Nano Banana AI
|
||
</h2>
|
||
<p className="text-gray-500 text-sm">
|
||
{selectedTool === 'generate'
|
||
? '在提示框中描述您想要创建的内容'
|
||
: '上传图像开始编辑'
|
||
}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isGenerating && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/40 z-50 backdrop-blur-sm rounded-lg animate-in fade-in duration-300">
|
||
<div className="text-center bg-white/90 rounded-xl p-6 card-lg backdrop-blur-sm animate-in scale-in duration-200">
|
||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-yellow-400 border-t-transparent mx-auto mb-3" />
|
||
<p className="text-gray-700 text-sm font-medium">正在创建图像...</p>
|
||
{/* 显示重试次数 */}
|
||
{isContinuousGenerating && (
|
||
<p className="text-gray-500 text-xs mt-2">
|
||
重试次数: {retryCount}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Stage
|
||
ref={stageRef}
|
||
width={stageSize.width}
|
||
height={stageSize.height}
|
||
draggable={selectedTool !== 'mask'}
|
||
onDragEnd={() => {
|
||
// 通过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
|
||
}}
|
||
>
|
||
<Layer>
|
||
{image && (
|
||
<KonvaImage
|
||
image={image}
|
||
x={(stageSize.width / canvasZoom - image.width) / 2}
|
||
y={(stageSize.height / canvasZoom - image.height) / 2}
|
||
onRender={() => {
|
||
console.log('KonvaImage组件渲染完成');
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 画笔描边 */}
|
||
{showMasks && brushStrokes.map((stroke) => (
|
||
<Line
|
||
key={stroke.id}
|
||
points={stroke.points}
|
||
stroke="#A855F7"
|
||
strokeWidth={stroke.brushSize}
|
||
tension={0.5}
|
||
lineCap="round"
|
||
lineJoin="round"
|
||
globalCompositeOperation="source-over"
|
||
opacity={0.6}
|
||
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
|
||
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
|
||
/>
|
||
))}
|
||
|
||
{/* 正在绘制的当前描边 */}
|
||
{isDrawing && currentStroke.length > 2 && (
|
||
<Line
|
||
points={currentStroke}
|
||
stroke="#A855F7"
|
||
strokeWidth={brushSize}
|
||
tension={0.5}
|
||
lineCap="round"
|
||
lineJoin="round"
|
||
globalCompositeOperation="source-over"
|
||
opacity={0.6}
|
||
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
|
||
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
|
||
/>
|
||
)}
|
||
</Layer>
|
||
</Stage>
|
||
|
||
{/* 悬浮操作按钮 */}
|
||
{image && !isGenerating && (
|
||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-white/80 backdrop-blur-sm rounded-full card border border-gray-200 px-3 py-2 flex items-center space-x-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleZoom(-0.1)}
|
||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||
>
|
||
<ZoomOut className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-xs text-gray-500 min-w-[40px] text-center">
|
||
{Math.round(canvasZoom * 100)}%
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleZoom(0.1)}
|
||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||
>
|
||
<ZoomIn className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleReset}
|
||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</Button>
|
||
<div className="w-px h-6 bg-gray-200"></div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleDownload}
|
||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 状态栏 */}
|
||
<div className="p-2 border-t border-gray-200 bg-white">
|
||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||
<div className="flex items-center space-x-2">
|
||
{brushStrokes.length > 0 && (
|
||
<span className="text-yellow-400">{brushStrokes.length} 个描边</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-1">
|
||
<span className="text-xs text-gray-500">
|
||
© 2025 Mark Fulton
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}; |