fix: 使用 Portal 渲染工具栏避免被裁剪
1. 工具栏使用 createPortal 渲染到 body 级别 2. 根据图片位置动态计算工具栏定位 3. z-index 设为 9999 确保最顶层显示 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
58b9c1cbd5
commit
dbdd188c9e
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -42,13 +43,28 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const [resizeWidth, setResizeWidth] = useState(1024);
|
const [resizeWidth, setResizeWidth] = useState(1024);
|
||||||
const [resizeHeight, setResizeHeight] = useState(1024);
|
const [resizeHeight, setResizeHeight] = useState(1024);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [toolbarPosition, setToolbarPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
// 文件输入的 ref
|
// 文件输入的 ref
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
const addNode = useCanvasStore((state) => state.addNode);
|
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) => {
|
const handleSelectFile = (e: React.MouseEvent) => {
|
||||||
@ -324,7 +340,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
<>
|
<>
|
||||||
{/* 选中时在图片上方显示标题和尺寸 */}
|
{/* 选中时在图片上方显示标题和尺寸 */}
|
||||||
{isSelected && data.src && (
|
{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">
|
<div className="flex items-center gap-1">
|
||||||
<ImagePlus className="h-3 w-3" />
|
<ImagePlus className="h-3 w-3" />
|
||||||
<span className="truncate max-w-[150px]">{data.prompt || '图片'}</span>
|
<span className="truncate max-w-[150px]">{data.prompt || '图片'}</span>
|
||||||
@ -334,6 +350,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors",
|
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors",
|
||||||
isSelected ? "border-primary" : "border-transparent"
|
isSelected ? "border-primary" : "border-transparent"
|
||||||
@ -392,117 +409,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 选中时显示的顶部工具栏 - 显示在图片外部上方 */}
|
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 悬浮时的更多操作按钮 (右上角) */}
|
{/* 悬浮时的更多操作按钮 (右上角) */}
|
||||||
{isHovered && data.src && !isProcessing && !isSelected && (
|
{isHovered && data.src && !isProcessing && !isSelected && (
|
||||||
@ -627,6 +534,121 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user