Your Name dbdd188c9e
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: 使用 Portal 渲染工具栏避免被裁剪
1. 工具栏使用 createPortal 渲染到 body 级别
2. 根据图片位置动态计算工具栏定位
3. z-index 设为 9999 确保最顶层显示

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-01 15:29:11 +08:00

655 lines
22 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.

"use client";
import React, { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 [resizeDialogOpen, setResizeDialogOpen] = useState(false);
const [resizeWidth, setResizeWidth] = useState(1024);
const [resizeHeight, setResizeHeight] = useState(1024);
const [isUploading, setIsUploading] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState<{ top: number; left: number } | null>(null);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
// 计算工具栏位置
useEffect(() => {
if (isSelected && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setToolbarPosition({
top: rect.top - 50, // 工具栏在图片上方 50px
left: rect.left + rect.width / 2, // 居中
});
} else {
setToolbarPosition(null);
}
}, [isSelected, node.position, node.size]);
// 触发文件选择
const handleSelectFile = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
fileInputRef.current?.click();
};
// 处理图片上传 - 上传到存储桶,不使用 Base64
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.error('请上传图片文件');
return;
}
// 验证文件大小 (8MB)
if (file.size > 8 * 1024 * 1024) {
toast.error('图片大小不能超过 8MB');
return;
}
setIsUploading(true);
// 先显示上传中状态
updateNode(node.id, {
data: { ...data, status: 'generating' },
});
try {
// 上传到存储桶
const result = await imageGenerationService.uploadImage(file);
if (result.success && result.data?.url) {
updateNode(node.id, {
data: {
...data,
src: result.data.url,
status: 'completed'
},
});
toast.success('图片上传成功');
} else {
updateNode(node.id, {
data: { ...data, status: 'failed' },
});
toast.error(result.error || '图片上传失败');
}
} catch (error) {
console.error('图片上传失败:', error);
updateNode(node.id, {
data: { ...data, status: 'failed' },
});
toast.error('图片上传失败,请重试');
} finally {
setIsUploading(false);
}
// 重置 input
e.target.value = '';
};
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
'rmbg': '抠图',
'enhance': '变清晰',
'extend': '图片扩展',
'resize': '尺寸调整',
'angle': '角度编辑',
'scene-fusion': '详情替换',
};
return names[action] || action;
};
// 处理图片编辑操作 - 集成我们现有的 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') {
setResizeDialogOpen(true);
return;
}
const actionName = getActionName(action);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = 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: '', // 暂时没有图片
status: 'generating',
prompt: `${actionName}处理中...`,
originalSrc: data.src,
},
connections: [node.id],
});
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:
// 删除创建的节点
if (newNodeId) deleteNode(newNodeId);
return;
}
if (result?.success && result.data?.url) {
// 更新节点为完成状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: 'completed',
prompt: `${actionName}结果`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: `${actionName}失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: `${actionName}失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
}
};
// 角度编辑完成回调
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 handleResize = async () => {
if (!data.src) return;
setResizeDialogOpen(false);
// 创建处理中节点
const newNodeId = addNode({
type: 'image',
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y
},
size: { width: resizeWidth / 3, height: resizeHeight / 3 }, // 按比例显示
data: {
src: '',
status: 'generating',
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const result = await imageGenerationService.resizeImage(data.src, resizeWidth, resizeHeight);
if (result?.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: 'completed',
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
originalSrc: data.src,
},
});
toast.success('尺寸调整完成');
} else {
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: '尺寸调整失败',
originalSrc: data.src,
},
});
toast.error(result?.error || '尺寸调整失败');
}
} catch (error) {
console.error('尺寸调整失败:', error);
updateNode(newNodeId, {
data: {
src: '',
status: 'failed',
prompt: '尺寸调整失败',
originalSrc: data.src,
},
});
toast.error('尺寸调整失败');
}
};
// 下载图片
const handleDownload = () => {
if (data.src) {
const link = document.createElement('a');
link.href = data.src;
link.download = `image-${node.id}.png`;
link.click();
}
};
return (
<>
{/* 选中时在图片上方显示标题和尺寸 */}
{isSelected && data.src && (
<div className="absolute -top-7 left-0 right-0 flex items-center justify-between text-xs text-muted-foreground px-1 z-10">
<div className="flex items-center gap-1">
<ImagePlus className="h-3 w-3" />
<span className="truncate max-w-[150px]">{data.prompt || '图片'}</span>
</div>
<span>{node.size.width} × {node.size.height}</span>
</div>
)}
<div
ref={containerRef}
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors",
isSelected ? "border-primary" : "border-transparent"
)}
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>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
{/* 选择文件按钮 */}
<button
type="button"
onClick={handleSelectFile}
className="px-4 py-2 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/90 transition-colors cursor-pointer"
>
选择文件
</button>
</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>
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{/* 悬浮时的更多操作按钮 (右上角) */}
{isHovered && data.src && !isProcessing && !isSelected && (
<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}
/>
{/* 尺寸调整对话框 */}
<Dialog open={resizeDialogOpen} onOpenChange={setResizeDialogOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Maximize2 className="h-5 w-5" />
尺寸调整
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width">宽度 (px)</Label>
<Input
id="width"
type="number"
value={resizeWidth}
onChange={(e) => setResizeWidth(parseInt(e.target.value) || 1024)}
min={100}
max={4096}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height">高度 (px)</Label>
<Input
id="height"
type="number"
value={resizeHeight}
onChange={(e) => setResizeHeight(parseInt(e.target.value) || 1024)}
min={100}
max={4096}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(512); setResizeHeight(512); }}>512x512</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1024); setResizeHeight(1024); }}>1024x1024</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1920); setResizeHeight(1080); }}>1920x1080</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1080); setResizeHeight(1920); }}>1080x1920</Button>
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(2048); setResizeHeight(2048); }}>2048x2048</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResizeDialogOpen(false)}>取消</Button>
<Button onClick={handleResize}>确定</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && data.src && !isProcessing && toolbarPosition && typeof document !== 'undefined' && createPortal(
<div
className="fixed flex items-center gap-0.5 bg-white border border-gray-200 rounded-full shadow-xl px-3 py-1.5 z-[9999]"
style={{
top: toolbarPosition.top,
left: toolbarPosition.left,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 放大/高清 */}
<Button
variant="ghost"
size="sm"
className="h-9 px-3 text-xs gap-1.5 rounded-full hover:bg-gray-100"
onClick={() => handleAction('enhance')}
title="高清放大"
>
<span className="text-[10px] font-medium text-gray-500 border border-gray-300 rounded px-1">HD</span>
<span>放大</span>
</Button>
{/* 移除背景/抠图 */}
<Button
variant="ghost"
size="sm"
className="h-9 px-3 text-xs gap-1.5 rounded-full hover:bg-gray-100"
onClick={() => handleAction('rmbg')}
title="移除背景"
>
<Scissors className="h-4 w-4" />
<span>移除背景</span>
</Button>
{/* 场景融合 */}
<Button
variant="ghost"
size="sm"
className="h-9 px-3 text-xs gap-1.5 rounded-full hover:bg-gray-100"
onClick={() => handleAction('scene-fusion')}
title="场景融合"
>
<Layers className="h-4 w-4" />
<span>场景</span>
</Button>
{/* 角度编辑 */}
<Button
variant="ghost"
size="sm"
className="h-9 px-3 text-xs gap-1.5 rounded-full hover:bg-gray-100"
onClick={() => handleAction('angle')}
title="角度编辑"
>
<Move3D className="h-4 w-4" />
<span>角度</span>
</Button>
{/* 扩展 */}
<Button
variant="ghost"
size="sm"
className="h-9 px-3 text-xs gap-1.5 rounded-full hover:bg-gray-100"
onClick={() => handleAction('extend')}
title="图片扩展"
>
<Expand className="h-4 w-4" />
<span>扩展</span>
</Button>
{/* 尺寸调整 */}
<Button
variant="ghost"
size="sm"
className="h-9 px-3 text-xs gap-1.5 rounded-full hover:bg-gray-100"
onClick={() => handleAction('resize')}
title="尺寸调整"
>
<Maximize2 className="h-4 w-4" />
<span>调整</span>
</Button>
{/* 更多操作 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-9 px-2 rounded-full hover:bg-gray-100"
title="更多"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载图片
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => deleteNode(node.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>,
document.body
)}
</>
);
}