lovart3/components/canvas/nodes/ImageNode.tsx
Your Name c7ffcda87b feat: add infinite canvas feature with AI image generation
- Add canvas project and node database tables
- Create canvas workspace with drag, resize, zoom and pan
- Add ImageNode with background removal, enhance, extend, angle edit, scene fusion
- Add VideoNode with playback controls
- Add TextNode for text editing
- Integrate AI image generation API (Gemini/Alibaba)
- Implement viewport culling for performance optimization
- Add canvas entry in top navigation bar
2026-01-24 19:48:13 +08:00

280 lines
9.3 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { imageGenerationService } from "@/services/canvas";
import type { CanvasNode, ImageNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
}
export function ImageNode({ node, isSelected }: ImageNodeProps) {
const data = node.data as ImageNodeData;
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
// 处理图片上传
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
updateNode(node.id, {
data: { ...data, src, status: 'completed' },
});
};
reader.readAsDataURL(file);
};
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
// 对话框类型的操作
if (action === 'angle') {
setAngleDialogOpen(true);
return;
}
if (action === 'scene-fusion') {
setSceneFusionDialogOpen(true);
return;
}
if (action === 'resize') {
toast.info("尺寸调整功能即将上线");
return;
}
setIsProcessing(true);
let result;
try {
switch (action) {
case 'rmbg':
result = await imageGenerationService.removeBackground(data.src);
break;
case 'enhance':
result = await imageGenerationService.enhanceImage(data.src);
break;
case 'extend':
result = await imageGenerationService.extendImage(data.src);
break;
default:
setIsProcessing(false);
return;
}
if (result?.success && result.data?.url) {
// 创建新的图片节点显示处理结果
addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: node.size.width, height: node.size.height },
data: {
src: result.data.url,
status: 'completed',
prompt: `${action} 处理结果`,
originalSrc: data.src,
},
connections: [node.id],
});
toast.success(`${action} 处理完成`);
} else {
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
toast.error("处理失败,请重试");
} finally {
setIsProcessing(false);
}
};
// 角度编辑完成回调
const handleAngleEditComplete = (resultUrl: string) => {
// 创建新的图片节点显示结果
addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: node.size.width, height: node.size.height },
data: {
src: resultUrl,
status: 'completed',
prompt: '角度编辑结果',
originalSrc: data.src,
},
connections: [node.id],
});
};
// 下载图片
const handleDownload = () => {
if (data.src) {
const link = document.createElement('a');
link.href = data.src;
link.download = `image-${node.id}.png`;
link.click();
}
};
return (
<>
<div
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片内容 */}
{data.src ? (
<img
src={data.src}
alt={data.prompt || "图片"}
className="w-full h-full object-cover"
draggable={false}
/>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</span>
</div>
) : (
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors">
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">点击上传图片</span>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
</label>
)}
{/* 处理中遮罩 */}
{isProcessing && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="h-8 w-8 animate-spin text-white mb-2" />
<span className="text-sm text-white">处理中...</span>
</div>
</div>
)}
{/* 悬浮操作栏 */}
{isHovered && data.src && !isProcessing && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleAction('rmbg')}>
<Scissors className="h-4 w-4 mr-2" />
抠图
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('enhance')}>
<Sparkles className="h-4 w-4 mr-2" />
变清晰
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('extend')}>
<Expand className="h-4 w-4 mr-2" />
图片扩展
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('resize')}>
<Maximize2 className="h-4 w-4 mr-2" />
尺寸调整
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}>
<Move3D className="h-4 w-4 mr-2" />
角度编辑
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('scene-fusion')}>
<Layers className="h-4 w-4 mr-2" />
场景裂变
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* 提示词显示 */}
{data.prompt && isHovered && !isProcessing && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
</div>
)}
</div>
{/* 角度编辑对话框 */}
{data.src && (
<AngleEditDialog
open={angleDialogOpen}
onOpenChange={setAngleDialogOpen}
imageUrl={data.src}
onComplete={handleAngleEditComplete}
/>
)}
{/* 场景裂变对话框 */}
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
/>
</>
);
}