Files
Nano-Banana-AI-Image-Editor/src/components/ImageCanvas.tsx
袁涛 8d31b98736 修复(项目): 优化动态导入和测试配置
- 移除ImageCanvas和HistoryPanel中不必要的useAppStore动态导入
- 添加缺失的Jest测试依赖(jest, ts-jest, jest-environment-jsdom, identity-obj-proxy)
- 修复ImageCanvas测试中的React引用问题和forwardRef支持
- 清理因移除动态导入导致的语法错误
- 优化代码结构,提高构建性能

验证:
- 构建成功通过
- 所有5个测试套件通过(34个测试)
- TypeScript类型检查无错误
2025-12-22 21:12:40 +08:00

616 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};