ai-chatbot/components/canvas/nodes/World3DNode.tsx
Your Name dee8019efd
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf: speed up world viewer loading
2026-05-14 11:46:16 +08:00

801 lines
27 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
Box,
Camera,
Copy,
Crosshair,
Download,
ImageIcon,
Loader2,
MoreVertical,
Sparkles,
Trash2,
Waypoints,
type LucideIcon,
} from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import useSWR from "swr";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
pickPreferredInlineWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import { getNodeContentScale } from "@/lib/canvas/node-content-scale";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import { toast } from "sonner";
import { imageGenerationService, worldLabsGenerationService } from "@/services/canvas";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
import { cn, fetcher } from "@/lib/utils";
interface World3DNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
const VIEW_META: Array<{
id: World3DViewMode;
label: string;
icon: LucideIcon;
toolbarKey: CanvasToolbarItemKey;
}> = [
{ id: "splat", label: "W", icon: Sparkles, toolbarKey: "world3dViewSplat" },
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
];
const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60;
function resolveAvailableViews(data: World3DNodeData) {
const views: World3DViewMode[] = [];
if (isArchivedWorldLabsAssetUrl(data.splatUrl)) views.push("splat");
if (data.imageUrl || data.previewImage) views.push("image");
if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh");
return views;
}
function resolveActiveView(data: World3DNodeData) {
const availableViews = resolveAvailableViews(data);
if (data.activeView && availableViews.includes(data.activeView)) {
return data.activeView;
}
return availableViews[0] || "splat";
}
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null;
const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null;
if (activeView === "splat" && splatUrl) {
return { url: splatUrl, filename: "world.spz" };
}
if (activeView === "mesh" && meshUrl) {
return { url: meshUrl, filename: "world.glb" };
}
if (splatUrl) {
return { url: splatUrl, filename: "world.spz" };
}
if (meshUrl) {
return { url: meshUrl, filename: "world.glb" };
}
if (data.imageUrl) {
return { url: data.imageUrl, filename: "world.jpg" };
}
if (data.worldUrl) {
return { url: data.worldUrl, filename: "world.url" };
}
return null;
}
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null;
const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null;
switch (activeView) {
case "world":
return splatUrl || meshUrl || data.imageUrl || null;
case "image":
return data.imageUrl || data.previewImage || meshUrl || splatUrl || null;
case "mesh":
return meshUrl || splatUrl || data.imageUrl || null;
case "splat":
default:
return splatUrl || data.imageUrl || meshUrl || null;
}
}
function readStringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readRecordValue(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, any>)
: {};
}
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function World3DNode({
node,
isSelected,
canvasToolbarConfig,
}: World3DNodeProps) {
const data = node.data as World3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
const [viewerCapture, setViewerCapture] = useState<(() => Promise<Blob | null>) | null>(
null
);
const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false);
const hydrationAttemptRef = useRef(0);
const hydrationTaskRef = useRef<string | null>(null);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const addNode = useCanvasStore((state) => state.addNode);
const selectNode = useCanvasStore((state) => state.selectNode);
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const world3dFirstPersonItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dFirstPerson"
);
const world3dHotspotItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dHotspot"
);
const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore");
const world3dOpenMeshItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dOpenMesh"
);
const world3dOpenImageItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dOpenImage"
);
const world3dDownloadItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dDownload"
);
const world3dCopyItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dCopy");
const world3dDeleteItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dDelete"
);
const metadata = useMemo(() => readRecordValue(data.metadata), [data.metadata]);
const metadataAssetPayload = useMemo(
() => readRecordValue(metadata.assetPayload),
[metadata.assetPayload]
);
const hydrationTaskId = readStringValue(metadata.taskId);
const hydrationProviderId =
readStringValue(metadataAssetPayload.providerId) ||
readStringValue(metadata.providerId);
const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean;
data?: PublicThreeDModelAliasConfig;
}>("/api/canvas/model-aliases", fetcher);
const activeView = useMemo(() => resolveActiveView(data), [data]);
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
const renderableSplatUrl = useMemo(() => {
const assetPayloadSplatUrl = pickPreferredInlineWorldLabsSplatUrl(
metadataAssetPayload.spzUrls as Record<string, string> | null | undefined
);
if (isArchivedWorldLabsAssetUrl(assetPayloadSplatUrl)) {
return assetPayloadSplatUrl;
}
return isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : undefined;
}, [data.splatUrl, metadataAssetPayload.spzUrls]);
const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl)
? data.meshUrl
: undefined;
const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const worldModelLabel = useMemo(
() =>
resolveThreeDModelDisplayName(
data.modelVersion,
publicModelAliasConfig.worldModels,
"原生世界节点",
),
[data.modelVersion, publicModelAliasConfig.worldModels],
);
const primaryUrl = resolvePrimaryUrl(data, activeView);
const downloadTarget = inferDownloadTarget(data, activeView);
const hotspotCount = data.hotspots?.length || 0;
const placeholderSizeScale = useMemo(
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
[node.size.height, node.size.width]
);
const overlayChromeScale = useMemo(
() =>
clampNumber(
getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48) *
Math.max(viewportZoom, 0.1),
0.1,
2.4
),
[node.size.height, node.size.width, viewportZoom]
);
const placeholderViewportScale = useMemo(
() =>
clampNumber(
placeholderSizeScale * Math.max(viewportZoom, 0.1),
0.1,
2.4
),
[placeholderSizeScale, viewportZoom]
);
const placeholderCenterStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "center center",
}),
[placeholderViewportScale]
);
const placeholderBadgeStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "top left",
}),
[placeholderViewportScale]
);
const topLeftControlsStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
transformOrigin: "top left",
}),
[overlayChromeScale]
);
const topRightControlsStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
transformOrigin: "top right",
}),
[overlayChromeScale]
);
const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh");
const showTopLeftControls = data.status === "completed";
useEffect(() => {
if (data.status !== "completed") return;
if (renderableSplatUrl || renderableMeshUrl) return;
if (!hydrationTaskId) return;
if (
readStringValue(metadata.provider) !== "worldlabs-3d" &&
readStringValue(metadataAssetPayload.provider) !== "worldlabs-3d"
) {
return;
}
const taskId = hydrationTaskId;
const providerId = hydrationProviderId;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (hydrationTaskRef.current !== taskId) {
hydrationTaskRef.current = taskId;
hydrationAttemptRef.current = 0;
}
const scheduleNextPoll = () => {
if (cancelled) return;
if (hydrationAttemptRef.current >= WORLD_ASSET_HYDRATION_MAX_ATTEMPTS) return;
timer = setTimeout(() => {
void pollWorldAssets();
}, WORLD_ASSET_HYDRATION_INTERVAL_MS);
};
async function pollWorldAssets() {
if (cancelled) return;
hydrationAttemptRef.current += 1;
const result = await worldLabsGenerationService.getTask(taskId, {
providerId,
});
if (cancelled) return;
if (!result.success || !result.data) {
scheduleNextPoll();
return;
}
const assetPayload = readRecordValue(result.data.assetPayload);
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined
);
const panoUrl = readStringValue(assetPayload.panoUrl);
const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl);
const previewImageUrl = readStringValue(result.data.previewImageUrl);
const rawMeshUrl =
readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl);
const meshUrl = isArchivedWorldLabsAssetUrl(rawMeshUrl) ? rawMeshUrl : null;
const imageUrl = panoUrl || thumbnailUrl || previewImageUrl;
if (!splatUrl && !meshUrl && !imageUrl) {
scheduleNextPoll();
return;
}
if (
!splatUrl &&
!meshUrl &&
(!imageUrl || imageUrl === data.imageUrl || imageUrl === data.previewImage)
) {
scheduleNextPoll();
return;
}
updateNode(node.id, {
data: {
...data,
worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl,
imageUrl: imageUrl || data.imageUrl,
meshUrl: meshUrl || renderableMeshUrl,
splatUrl: splatUrl || renderableSplatUrl,
previewImage: previewImageUrl || imageUrl || data.previewImage,
status: "completed",
activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView,
firstPerson: data.firstPerson ?? false,
semanticsMetadata:
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
? (assetPayload.semanticsMetadata as Record<string, any>)
: data.semanticsMetadata,
metadata: {
...metadata,
assetPayload: {
...metadataAssetPayload,
...assetPayload,
},
hydratedAt: new Date().toISOString(),
},
} as World3DNodeData,
});
if (!splatUrl && !meshUrl) {
scheduleNextPoll();
}
}
void pollWorldAssets();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [
data,
hydrationProviderId,
hydrationTaskId,
metadata,
metadataAssetPayload,
node.id,
renderableMeshUrl,
renderableSplatUrl,
updateNode,
]);
const handleSetView = (view: World3DViewMode) => {
updateNode(node.id, {
data: {
...data,
activeView: view,
} as World3DNodeData,
});
};
const handleToggleFirstPerson = () => {
const nextFirstPerson = !data.firstPerson;
updateNode(node.id, {
data: {
...data,
firstPerson: nextFirstPerson,
} as World3DNodeData,
});
if (nextFirstPerson) {
setInteractionMode("navigate");
}
};
const handleToggleHotspotMode = () => {
const nextMode = interactionMode === "hotspot" ? "navigate" : "hotspot";
setInteractionMode(nextMode);
if (nextMode === "hotspot" && data.firstPerson) {
updateNode(node.id, {
data: {
...data,
firstPerson: false,
} as World3DNodeData,
});
}
};
const handlePick = (pick: { point: [number, number, number] }) => {
if (interactionMode !== "hotspot") return;
const nextHotspot: World3DHotspot = {
id: crypto.randomUUID(),
label: `热点 ${hotspotCount + 1}`,
position: {
x: pick.point[0],
y: pick.point[1],
z: pick.point[2],
},
createdAt: new Date().toISOString(),
};
updateNode(node.id, {
data: {
...data,
hotspots: [...(data.hotspots || []), nextHotspot],
} as World3DNodeData,
});
setInteractionMode("navigate");
toast.success("已添加世界热点");
};
const handleCopy = async () => {
if (!primaryUrl) {
toast.error("暂无可复制的世界地址");
return;
}
try {
await navigator.clipboard.writeText(primaryUrl);
toast.success("已复制世界链接");
} catch {
toast.error("复制失败");
}
};
const handleDownload = () => {
if (!downloadTarget) {
toast.error("暂无可下载的世界资产");
return;
}
const link = document.createElement("a");
link.href = downloadTarget.url;
link.download = downloadTarget.filename;
link.click();
};
const handleViewerCaptureReady = useCallback(
(capture: (() => Promise<Blob | null>) | null) => {
setViewerCapture(() => capture);
},
[]
);
const handleCaptureSnapshot = useCallback(async () => {
if (!viewerCapture) {
toast.error("当前视图暂不支持拍照");
return;
}
setIsCapturingSnapshot(true);
try {
const blob = await viewerCapture();
if (!blob) {
throw new Error("当前世界画面抓取失败");
}
const file = new File([blob], `world-snapshot-${Date.now()}.png`, {
type: blob.type || "image/png",
});
const uploadResult = await imageGenerationService.uploadImage(file);
if (!uploadResult.success || !uploadResult.data?.url) {
throw new Error(uploadResult.error || "上传拍照结果失败");
}
const snapshotWidth = Math.max(320, Math.min(640, Math.round(node.size.width * 0.9)));
const snapshotHeight = Math.max(
220,
Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1)))
);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y + 12,
},
size: { width: snapshotWidth, height: snapshotHeight },
data: {
src: uploadResult.data.url,
assetId: uploadResult.data.assetId,
title: `${(data.prompt || "3D世界").trim() || "3D世界"}-拍照`,
prompt: data.prompt || "3D 世界拍照",
status: "completed",
originalSrc: uploadResult.data.url,
metadata: {
sourceWorldNodeId: node.id,
sourceWorldView: activeView,
sourceWorldPrompt: data.prompt || "",
captureMode: "world3d",
},
},
connections: [node.id],
});
selectNode(newNodeId);
toast.success("已将当前世界视角添加到画布");
} catch (error) {
const message = error instanceof Error ? error.message : "世界拍照失败";
toast.error(message);
} finally {
setIsCapturingSnapshot(false);
}
}, [
activeView,
addNode,
data.prompt,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
selectNode,
viewerCapture,
]);
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-xl border bg-card shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#f7f9fc] dark:bg-zinc-900">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#dde5f4] bg-white text-[#566374] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<Sparkles className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-cyan-200">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
3D 世界生成中...
</span>
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
{worldModelLabel || "世界资产正在构建中"}
</span>
</div>
</div>
) : data.status === "failed" ? (
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#fff7f8] dark:bg-red-950/30">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-[#c84b5e] dark:text-red-200">
3D 世界生成失败
</span>
<span className="text-xs text-[#b77480] dark:text-red-300/80">
可以重新选择模型后再试一次
</span>
</div>
</div>
) : (
<>
<div
className="absolute inset-0"
data-canvas-no-drag="true"
onWheel={(event) => event.stopPropagation()}
>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={renderableMeshUrl}
minimalFrame
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={renderableSplatUrl}
worldUrl={data.worldUrl}
onCaptureReady={handleViewerCaptureReady}
/>
</div>
</>
)}
{showTopLeftControls ? (
<div
className="absolute left-3 top-3 z-20 flex items-center gap-1.5"
style={topLeftControlsStyle}
>
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => {
const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey);
return (
<Button
className="h-5 min-w-5 rounded-full px-1.5 text-[9px]"
key={view.id}
onClick={() => handleSetView(view.id)}
size="sm"
title={viewItem.label}
type="button"
variant={activeView === view.id ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={activeView !== view.id}
className="h-2.5 w-2.5"
fallback={view.icon}
item={viewItem}
/>
</Button>
);
})}
</div>
{activeView === "splat" ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleFirstPerson}
size="sm"
title={world3dFirstPersonItem.label}
type="button"
variant={data.firstPerson ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={!data.firstPerson}
className="h-2.5 w-2.5"
fallback={Waypoints}
item={world3dFirstPersonItem}
/>
</Button>
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleHotspotMode}
size="sm"
title={world3dHotspotItem.label}
type="button"
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={interactionMode !== "hotspot"}
className="h-2.5 w-2.5"
fallback={Crosshair}
item={world3dHotspotItem}
/>
</Button>
</div>
) : null}
{captureEnabled ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
disabled={!viewerCapture || isCapturingSnapshot}
onClick={() => void handleCaptureSnapshot()}
size="sm"
title="拍照"
type="button"
variant="ghost"
>
{isCapturingSnapshot ? (
<Loader2 className="h-2.5 w-2.5 animate-spin text-white" />
) : (
<Camera className="h-2.5 w-2.5 text-white" />
)}
</Button>
</div>
) : null}
</div>
) : null}
{isHovered && data.status === "completed" && (
<div
className="absolute right-3 top-3 z-20 flex items-center gap-1"
style={topRightControlsStyle}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 w-7 rounded-full"
size="icon"
title={world3dMoreItem.label}
variant="secondary"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={world3dMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{data.meshUrl ? (
<DropdownMenuItem onClick={() => window.open(data.meshUrl, "_blank", "noopener,noreferrer")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={world3dOpenMeshItem}
/>
{world3dOpenMeshItem.label}
</DropdownMenuItem>
) : null}
{data.imageUrl ? (
<DropdownMenuItem onClick={() => window.open(data.imageUrl, "_blank", "noopener,noreferrer")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={ImageIcon}
item={world3dOpenImageItem}
/>
{world3dOpenImageItem.label}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleDownload}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={world3dDownloadItem}
/>
{world3dDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Copy}
item={world3dCopyItem}
/>
{world3dCopyItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={world3dDeleteItem}
/>
{world3dDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
}