fix: 使用 Portal 渲染工具栏避免被裁剪
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

1. 工具栏使用 createPortal 渲染到 body 级别
2. 根据图片位置动态计算工具栏定位
3. z-index 设为 9999 确保最顶层显示

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name 2026-02-01 15:29:11 +08:00
parent 58b9c1cbd5
commit dbdd188c9e

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useRef } from "react";
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 {
@ -42,13 +43,28 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
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) => {
@ -324,7 +340,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<>
{/* 选中时在图片上方显示标题和尺寸 */}
{isSelected && data.src && (
<div className="absolute -top-7 left-0 right-0 flex items-center justify-between text-xs text-muted-foreground px-1">
<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>
@ -334,6 +350,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
)}
<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"
@ -392,117 +409,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</div>
)}
{/* 选中时显示的顶部工具栏 - 显示在图片外部上方 */}
{isSelected && data.src && !isProcessing && (
<div
className="absolute -top-12 left-1/2 -translate-x-1/2 flex items-center gap-0.5 bg-white border border-gray-200 rounded-full shadow-xl px-3 py-1.5 z-[100]"
style={{
pointerEvents: 'auto',
minWidth: 'max-content',
}}
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>
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{/* 悬浮时的更多操作按钮 (右上角) */}
{isHovered && data.src && !isProcessing && !isSelected && (
@ -627,6 +534,121 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</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
)}
</>
);
}