Added stopPropagation to prevent Rnd click handler from intercepting the file input click event. Added an explicit button for better UX.
288 lines
9.6 KiB
TypeScript
288 lines
9.6 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>
|
|
) : (
|
|
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
|
|
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
|
|
<span className="text-sm text-muted-foreground mb-3">点击上传图片</span>
|
|
<label
|
|
className="cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
/>
|
|
<span className="px-4 py-2 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/90 transition-colors">
|
|
选择文件
|
|
</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* 处理中遮罩 */}
|
|
{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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|