714 lines
25 KiB
TypeScript
714 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, use } from "react";
|
||
import { useRouter, useParams } from "next/navigation";
|
||
import {
|
||
ArrowLeft,
|
||
Save,
|
||
Undo2,
|
||
Redo2,
|
||
ZoomIn,
|
||
ZoomOut,
|
||
MousePointer,
|
||
Hand,
|
||
Plus,
|
||
ImagePlus,
|
||
Video,
|
||
Type,
|
||
Loader2,
|
||
Scissors,
|
||
Sparkles,
|
||
Expand,
|
||
Maximize2,
|
||
Move3D,
|
||
Layers,
|
||
PanelRightClose,
|
||
PanelRight,
|
||
Download,
|
||
Upload,
|
||
Wand2,
|
||
Grid3X3,
|
||
Settings,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { toast } from "sonner";
|
||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||
import { canvasService, imageGenerationService } from "@/services/canvas";
|
||
import { CanvasWorkspace } from "@/components/canvas";
|
||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { cn } from "@/lib/utils";
|
||
import { MessageSquare, FolderOpen, Grid2X2 } from "lucide-react";
|
||
|
||
interface PageProps {
|
||
params: Promise<{ id: string }>;
|
||
}
|
||
|
||
// 图片尺寸预设
|
||
const SIZE_PRESETS = [
|
||
{ label: "1:1", value: "1:1", width: 1024, height: 1024 },
|
||
{ label: "3:2", value: "3:2", width: 1536, height: 1024 },
|
||
{ label: "2:3", value: "2:3", width: 1024, height: 1536 },
|
||
{ label: "4:3", value: "4:3", width: 1365, height: 1024 },
|
||
{ label: "3:4", value: "3:4", width: 1024, height: 1365 },
|
||
{ label: "16:9", value: "16:9", width: 1820, height: 1024 },
|
||
{ label: "9:16", value: "9:16", width: 1024, height: 1820 },
|
||
];
|
||
|
||
export default function CanvasEditorPage({ params }: PageProps) {
|
||
// 使用 useParams 确保客户端导航时能正确获取更新的 id
|
||
const routeParams = useParams();
|
||
const id = (routeParams?.id as string) || use(params).id;
|
||
const router = useRouter();
|
||
|
||
// Store state
|
||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||
const tool = useCanvasStore(canvasSelectors.tool);
|
||
const canUndo = useCanvasStore(canvasSelectors.canUndo);
|
||
const canRedo = useCanvasStore(canvasSelectors.canRedo);
|
||
const isGenerating = useCanvasStore(canvasSelectors.isGenerating);
|
||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||
|
||
// Store actions
|
||
const setProject = useCanvasStore((state) => state.setProject);
|
||
const setProjectTitle = useCanvasStore((state) => state.setProjectTitle);
|
||
const setTool = useCanvasStore((state) => state.setTool);
|
||
const zoomIn = useCanvasStore((state) => state.zoomIn);
|
||
const zoomOut = useCanvasStore((state) => state.zoomOut);
|
||
const undo = useCanvasStore((state) => state.undo);
|
||
const redo = useCanvasStore((state) => state.redo);
|
||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||
const clearCanvas = useCanvasStore((state) => state.clearCanvas);
|
||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||
const setGenerating = useCanvasStore((state) => state.setGenerating);
|
||
|
||
// Local state
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||
const [editTitle, setEditTitle] = useState("");
|
||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
|
||
|
||
// 生成配置
|
||
const [prompt, setPrompt] = useState("");
|
||
const [negativePrompt, setNegativePrompt] = useState("");
|
||
const [aspectRatio, setAspectRatio] = useState("1:1");
|
||
const [imageCount, setImageCount] = useState(1);
|
||
|
||
// 获取选中的图片节点
|
||
const selectedImageNode = selectedNodeIds.length === 1
|
||
? nodes.find(n => n.id === selectedNodeIds[0] && n.type === 'image')
|
||
: null;
|
||
|
||
// 加载画布数据
|
||
useEffect(() => {
|
||
console.log('[Canvas] Loading project:', id);
|
||
|
||
const loadProject = async () => {
|
||
setIsLoading(true);
|
||
|
||
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||
useCanvasStore.getState().clearCanvas();
|
||
|
||
const result = await canvasService.getProject(id);
|
||
|
||
if (result.success && result.data) {
|
||
useCanvasStore.getState().setProject(id, result.data.title);
|
||
if (result.data.canvasData) {
|
||
useCanvasStore.getState().loadCanvas(result.data.canvasData);
|
||
}
|
||
} else {
|
||
// 画布不存在时,创建一个新画布
|
||
console.log('[Canvas] 画布不存在,创建新画布:', id);
|
||
useCanvasStore.getState().setProject(id, '未命名画布');
|
||
// 保存新画布到数据库
|
||
try {
|
||
await canvasService.saveCanvasState(id, { nodes: [], connections: [] });
|
||
} catch (error) {
|
||
console.error('[Canvas] 创建新画布失败:', error);
|
||
}
|
||
}
|
||
setIsLoading(false);
|
||
};
|
||
|
||
loadProject();
|
||
}, [id]); // 只依赖 id,使用 getState() 直接调用 store actions
|
||
|
||
// 自动保存逻辑
|
||
useEffect(() => {
|
||
if (isLoading) return;
|
||
|
||
const saveTimer = setTimeout(async () => {
|
||
setIsSaving(true);
|
||
const canvasData = getCanvasState();
|
||
try {
|
||
await canvasService.saveCanvasState(id, canvasData);
|
||
} catch (error) {
|
||
console.error("Auto save failed:", error);
|
||
}
|
||
setIsSaving(false);
|
||
}, 3000); // 3秒自动保存
|
||
|
||
return () => clearTimeout(saveTimer);
|
||
}, [nodes, id, isLoading, getCanvasState]);
|
||
|
||
// 智能命名与缩略图逻辑
|
||
useEffect(() => {
|
||
const autoTitleAndThumbnail = async () => {
|
||
// 如果标题是默认值,且有图片节点
|
||
if ((projectTitle === "未命名画布" || projectTitle === "新画布") && nodes.length > 0) {
|
||
const imageNode = nodes.find(n => n.type === 'image' && (n.data as any)?.src);
|
||
const imageSrc = (imageNode?.data as any)?.src;
|
||
if (imageSrc) {
|
||
try {
|
||
// 1. 设置缩略图
|
||
await canvasService.updateProject(id, { thumbnail: imageSrc });
|
||
|
||
// 2. 智能命名
|
||
const res = await fetch('/api/canvas/auto-title', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl: imageSrc })
|
||
});
|
||
const data = await res.json();
|
||
if (data.title) {
|
||
setProjectTitle(data.title);
|
||
await canvasService.updateProject(id, { title: data.title });
|
||
toast.success(`画布已自动命名为:${data.title}`);
|
||
}
|
||
} catch (error) {
|
||
console.error("Auto title failed:", error);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 使用防抖,避免频繁触发
|
||
const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作
|
||
return () => clearTimeout(timer);
|
||
}, [nodes, projectTitle, id, setProjectTitle]);
|
||
|
||
// 保存画布 (手动触发保留作为备用,但UI上移除)
|
||
const handleSave = useCallback(async () => {
|
||
setIsSaving(true);
|
||
const canvasData = getCanvasState();
|
||
const result = await canvasService.saveCanvasState(id, canvasData);
|
||
|
||
if (result.success) {
|
||
toast.success("保存成功");
|
||
} else {
|
||
toast.error(result.error || "保存失败");
|
||
}
|
||
setIsSaving(false);
|
||
}, [id, getCanvasState]);
|
||
|
||
// 更新标题
|
||
const handleUpdateTitle = async () => {
|
||
if (!editTitle.trim() || editTitle === projectTitle) {
|
||
setIsEditingTitle(false);
|
||
return;
|
||
}
|
||
|
||
const result = await canvasService.updateProject(id, { title: editTitle.trim() });
|
||
if (result.success) {
|
||
setProjectTitle(editTitle.trim());
|
||
} else {
|
||
toast.error(result.error || "更新标题失败");
|
||
}
|
||
setIsEditingTitle(false);
|
||
};
|
||
|
||
// 添加文本节点
|
||
const handleAddTextNode = () => {
|
||
addNode({
|
||
type: 'text',
|
||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||
size: { width: 200, height: 100 },
|
||
data: { content: '双击编辑文本' },
|
||
connections: [],
|
||
});
|
||
};
|
||
|
||
// 添加图片节点
|
||
const handleAddImageNode = () => {
|
||
addNode({
|
||
type: 'image',
|
||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||
size: { width: 300, height: 300 },
|
||
data: {
|
||
src: '',
|
||
status: 'pending',
|
||
prompt: '',
|
||
},
|
||
connections: [],
|
||
});
|
||
};
|
||
|
||
// 添加视频节点
|
||
const handleAddVideoNode = () => {
|
||
addNode({
|
||
type: 'video',
|
||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||
size: { width: 400, height: 300 },
|
||
data: {
|
||
src: '',
|
||
status: 'pending',
|
||
prompt: '',
|
||
},
|
||
connections: [],
|
||
});
|
||
};
|
||
|
||
// 处理图片操作(占位函数,后续实现具体逻辑)
|
||
const handleAction = (action: string) => {
|
||
if (!selectedImageNode) {
|
||
toast.error("请先选择一张图片");
|
||
return;
|
||
}
|
||
toast.info(`${action} 功能开发中...`);
|
||
// TODO: 实现具体的图片处理逻辑
|
||
};
|
||
|
||
// AI 生成图片
|
||
const handleGenerate = async () => {
|
||
if (!prompt.trim()) {
|
||
toast.error("请输入提示词");
|
||
return;
|
||
}
|
||
|
||
setGenerating(true);
|
||
|
||
// 获取尺寸预设
|
||
const sizePreset = SIZE_PRESETS.find(s => s.value === aspectRatio) || SIZE_PRESETS[0];
|
||
|
||
// 先创建占位节点(显示生成中状态)
|
||
const placeholderNodeIds: string[] = [];
|
||
for (let i = 0; i < imageCount; i++) {
|
||
const nodeId = addNode({
|
||
type: 'image',
|
||
position: {
|
||
x: 100 + (i % 3) * 320,
|
||
y: 100 + Math.floor(i / 3) * 320
|
||
},
|
||
size: { width: 300, height: 300 },
|
||
data: {
|
||
src: '',
|
||
status: 'generating',
|
||
prompt: prompt,
|
||
},
|
||
connections: [],
|
||
});
|
||
placeholderNodeIds.push(nodeId);
|
||
}
|
||
|
||
try {
|
||
// 调用 AI 图片生成 API
|
||
const result = await imageGenerationService.generateImage({
|
||
prompt,
|
||
negativePrompt,
|
||
aspectRatio,
|
||
imageCount,
|
||
});
|
||
|
||
if (result.success && result.data?.images) {
|
||
// 更新节点为生成的图片
|
||
result.data.images.forEach((image, index) => {
|
||
if (placeholderNodeIds[index]) {
|
||
updateNode(placeholderNodeIds[index], {
|
||
data: {
|
||
src: image.originalUrl || image.thumbnailUrl,
|
||
status: 'completed',
|
||
prompt: prompt,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
toast.success(`成功生成 ${result.data.images.length} 张图片`);
|
||
setPrompt(""); // 清空提示词
|
||
} else {
|
||
// 生成失败,更新节点状态
|
||
placeholderNodeIds.forEach(nodeId => {
|
||
updateNode(nodeId, {
|
||
data: {
|
||
src: '',
|
||
status: 'failed',
|
||
prompt: prompt,
|
||
},
|
||
});
|
||
});
|
||
toast.error(result.error || "图片生成失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("AI 图片生成失败:", error);
|
||
// 生成失败,更新节点状态
|
||
placeholderNodeIds.forEach(nodeId => {
|
||
updateNode(nodeId, {
|
||
data: {
|
||
src: '',
|
||
status: 'failed',
|
||
prompt: prompt,
|
||
},
|
||
});
|
||
});
|
||
toast.error("图片生成失败,请重试");
|
||
} finally {
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
// 键盘快捷键
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
// 如果正在输入则不触发快捷键
|
||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||
return;
|
||
}
|
||
|
||
// Ctrl/Cmd + S 保存
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault();
|
||
handleSave();
|
||
}
|
||
// Ctrl/Cmd + Z 撤销
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
undo();
|
||
}
|
||
// Ctrl/Cmd + Shift + Z 重做
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
|
||
e.preventDefault();
|
||
redo();
|
||
}
|
||
// V 选择工具
|
||
if (e.key === 'v' && !e.ctrlKey && !e.metaKey) {
|
||
setTool('select');
|
||
}
|
||
// H 平移工具
|
||
if (e.key === 'h' && !e.ctrlKey && !e.metaKey) {
|
||
setTool('pan');
|
||
}
|
||
// Delete 删除选中
|
||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
useCanvasStore.getState().deleteSelectedNodes();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [handleSave, undo, redo, setTool]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center bg-muted/30">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||
<span className="text-sm text-muted-foreground">加载中...</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<TooltipProvider>
|
||
<div className="relative h-dvh w-full overflow-hidden bg-background">
|
||
|
||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||
<div className="absolute inset-0 z-0" key={`workspace-${id}`}>
|
||
<CanvasWorkspace />
|
||
</div>
|
||
|
||
{/* 2. Floating Header (Top Left) */}
|
||
<div className="absolute top-16 left-4 z-20 flex items-center gap-2 bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => router.push('/')} className="rounded-lg h-8 w-8">
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>返回首页</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Separator orientation="vertical" className="h-5" />
|
||
|
||
{/* 文件列表按钮 & 标题展示 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setPanelMode('fileList');
|
||
setIsPanelOpen(true);
|
||
}}
|
||
className={cn(
|
||
"h-8 gap-2 px-2 font-normal hover:bg-muted rounded-lg",
|
||
panelMode === 'fileList' && isPanelOpen && "bg-muted"
|
||
)}
|
||
>
|
||
<Grid2X2 className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-medium max-w-[200px] truncate">
|
||
{projectTitle}
|
||
</span>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>查看文件列表</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{(isGenerating || isSaving) && (
|
||
<>
|
||
<Separator orientation="vertical" className="h-5" />
|
||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-2">
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
<span className="hidden sm:inline">
|
||
{isGenerating ? "生成中..." : "保存中..."}
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 3. Floating Actions (Top Right) */}
|
||
{!isPanelOpen && (
|
||
<div className="absolute top-16 right-4 z-20 flex items-center gap-2">
|
||
<div className="bg-background/80 backdrop-blur-md border rounded-xl p-1.5 shadow-sm flex items-center gap-1">
|
||
{/* Panel Toggle */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setIsPanelOpen(!isPanelOpen)}
|
||
className="rounded-lg h-8 w-8"
|
||
>
|
||
<PanelRight className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>显示面板</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 4. Floating Sidebar (Right) */}
|
||
<div className={cn(
|
||
"absolute top-16 right-4 bottom-4 z-10 w-[400px] transition-all duration-300 ease-in-out",
|
||
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
|
||
)}>
|
||
<div className="h-full w-full rounded-2xl border bg-background/95 backdrop-blur-md shadow-2xl overflow-hidden flex flex-col">
|
||
{panelMode === 'chat' ? (
|
||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
|
||
<CanvasChatPanel
|
||
chatId={`canvas-${id}`}
|
||
onClose={() => setIsPanelOpen(false)}
|
||
onOpenFileList={() => setPanelMode('fileList')}
|
||
/>
|
||
</div>
|
||
) : (
|
||
// 文件列表
|
||
<CanvasFileListPanel
|
||
currentId={id}
|
||
onClose={() => setPanelMode('chat')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 5. Floating Bottom Toolbar (Left) */}
|
||
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-20">
|
||
<div className="flex flex-col items-center gap-2 p-1.5 rounded-2xl border bg-background/80 backdrop-blur-md shadow-2xl">
|
||
{/* Tools: Select, Pan, Add Text, Add Image, Add Video */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant={tool === 'select' ? 'secondary' : 'ghost'}
|
||
size="icon"
|
||
onClick={() => setTool('select')}
|
||
className={cn("rounded-xl h-9 w-9", tool === 'select' && "bg-primary text-primary-foreground hover:bg-primary/90")}
|
||
>
|
||
<MousePointer className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">选择 (V)</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant={tool === 'pan' ? 'secondary' : 'ghost'}
|
||
size="icon"
|
||
onClick={() => setTool('pan')}
|
||
className={cn("rounded-xl h-9 w-9", tool === 'pan' && "bg-primary text-primary-foreground hover:bg-primary/90")}
|
||
>
|
||
<Hand className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">平移 (H)</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Separator orientation="horizontal" className="w-5 my-0.5" />
|
||
|
||
{/* 快捷工具 - 仅在选中图片时显示 */}
|
||
{selectedImageNode ? (
|
||
<>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => handleAction('rmbg')} className="rounded-xl h-9 w-9">
|
||
<Scissors className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">抠图</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => handleAction('enhance')} className="rounded-xl h-9 w-9">
|
||
<Sparkles className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">变清晰</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => handleAction('extend')} className="rounded-xl h-9 w-9">
|
||
<Expand className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">扩展</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => handleAction('resize')} className="rounded-xl h-9 w-9">
|
||
<Maximize2 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">调整</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => handleAction('angle')} className="rounded-xl h-9 w-9">
|
||
<Move3D className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">角度</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={() => handleAction('scene-fusion')} className="rounded-xl h-9 w-9">
|
||
<Layers className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">场景</TooltipContent>
|
||
</Tooltip>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={handleAddTextNode} className="rounded-xl h-9 w-9">
|
||
<Type className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">添加文本</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={handleAddImageNode} className="rounded-xl h-9 w-9">
|
||
<ImagePlus className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">添加图片</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={handleAddVideoNode} className="rounded-xl h-9 w-9">
|
||
<Video className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">添加视频</TooltipContent>
|
||
</Tooltip>
|
||
</>
|
||
)}
|
||
|
||
<Separator orientation="horizontal" className="w-5 my-0.5" />
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={undo} disabled={!canUndo} className="rounded-xl h-9 w-9">
|
||
<Undo2 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">撤销</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="ghost" size="icon" onClick={redo} disabled={!canRedo} className="rounded-xl h-9 w-9">
|
||
<Redo2 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">重做</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Separator orientation="horizontal" className="w-5 my-0.5" />
|
||
|
||
{/* Zoom Controls */}
|
||
<div className="flex flex-col items-center gap-1">
|
||
<Button variant="ghost" size="icon" onClick={zoomIn} className="h-8 w-8 rounded-lg">
|
||
<ZoomIn className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<span className="text-[10px] w-10 text-center tabular-nums font-medium">
|
||
{Math.round(viewport.zoom * 100)}%
|
||
</span>
|
||
<Button variant="ghost" size="icon" onClick={zoomOut} className="h-8 w-8 rounded-lg">
|
||
<ZoomOut className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|