Files
Nano-Banana-AI-Image-Editor/src/components/ImageCanvas.tsx
袁涛 260a7e4f0f 新增 现在参考图可以拖动排序了;
修复 双参考图生成结果显示问题;
2025-09-22 22:39:45 +08:00

710 lines
24 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 { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
export const ImageCanvas: React.FC = () => {
const {
canvasImage,
canvasZoom,
canvasPan,
setCanvasZoom,
setCanvasPan,
brushStrokes,
addBrushStroke,
showMasks,
selectedTool,
isGenerating,
brushSize,
showHistory,
showPromptPanel
} = useAppStore();
const stageRef = useRef<any>(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
import('../store/useAppStore').then((storeModule) => {
const useAppStore = storeModule.useAppStore;
// 检查是否已取消
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
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
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);
});
}
}).catch(err => {
// 检查是否已取消
if (isCancelled) {
return;
}
console.error('导入AppStore时出错:', err);
});
}
};
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: Konva.KonvaEventObject<MouseEvent>) => {
if (selectedTool !== 'mask' || !image) return;
setIsDrawing(true);
const stage = e.target.getStage();
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
// 计算图像在舞台上的边界
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: Konva.KonvaEventObject<MouseEvent>) => {
if (!isDrawing || selectedTool !== 'mask' || !image) return;
const stage = e.target.getStage();
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
// 计算图像在舞台上的边界
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,
});
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 = () => {
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的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) {
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(uploadResult.url, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = uploadResult.url;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 立即返回
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) {
// 处理不同类型的URL
if (canvasImage.startsWith('data:')) {
// base64格式
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else if (canvasImage.startsWith('blob:')) {
// Blob URL格式
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(canvasImage, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
} else {
// 普通URL格式
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(canvasImage, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
}
}
}
};
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>
</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>
);
};