679 lines
23 KiB
TypeScript
679 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
AlertCircle,
|
|
Box,
|
|
Copy,
|
|
Download,
|
|
ExternalLink,
|
|
Globe,
|
|
ImageIcon,
|
|
Loader2,
|
|
MoreVertical,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import useSWR from "swr";
|
|
import { toast } from "sonner";
|
|
import { getNodeContentScale } from "@/lib/canvas/node-content-scale";
|
|
import {
|
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
|
resolveThreeDModelDisplayName,
|
|
type PublicThreeDModelAliasConfig,
|
|
} from "@/lib/model3d/model-alias-config";
|
|
import {
|
|
extractModelAssetFormat,
|
|
resolvePreferredModelAssetUrl,
|
|
} from "@/lib/model3d/model-url";
|
|
import {
|
|
Model3DViewer,
|
|
type Model3DViewerSettings,
|
|
} from "@/components/canvas/model3d/Model3DViewer";
|
|
import {
|
|
getCanvasToolbarItem,
|
|
type CanvasToolbarConfig,
|
|
} from "@/lib/canvas-toolbar-config";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import { canvasService, model3DGenerationService } from "@/services/canvas";
|
|
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
|
import { cn, fetcher } from "@/lib/utils";
|
|
|
|
interface Model3DNodeProps {
|
|
node: CanvasNode;
|
|
isSelected: boolean;
|
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
|
}
|
|
|
|
const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
|
|
cameraPreset: "iso",
|
|
envPreset: "studio",
|
|
lightIntensity: 1,
|
|
autoRotate: false,
|
|
showGrid: true,
|
|
wireframe: false,
|
|
};
|
|
|
|
const MODEL_3D_RECOVERY_INTERVAL_MS = 5000;
|
|
const MODEL_3D_RECOVERY_MAX_ATTEMPTS = 360;
|
|
|
|
function isTerminalModelFailureStatus(status: string) {
|
|
return ["failed", "error", "banned", "expired", "cancelled", "canceled"].includes(
|
|
status.trim().toLowerCase()
|
|
);
|
|
}
|
|
|
|
function inferModelFileName(modelUrl: string | null, nodeId: string) {
|
|
const raw = String(modelUrl || "").trim();
|
|
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
|
try {
|
|
const url = new URL(raw);
|
|
const filename = url.pathname.split("/").filter(Boolean).pop();
|
|
if (filename) return decodeURIComponent(filename);
|
|
} catch {}
|
|
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
|
|
}
|
|
|
|
function inferModelFormat(modelUrl: string | null, explicitFormat?: string) {
|
|
if (explicitFormat) return explicitFormat.toUpperCase();
|
|
return extractModelAssetFormat(modelUrl).toUpperCase();
|
|
}
|
|
|
|
function readWorldAssetPayload(data: Model3DNodeData) {
|
|
const raw = data.metadata?.assetPayload;
|
|
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
}
|
|
|
|
function readOptionalUrl(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
export function Model3DNode({
|
|
node,
|
|
isSelected,
|
|
canvasToolbarConfig,
|
|
}: Model3DNodeProps) {
|
|
const data = node.data as Model3DNodeData;
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const recoveryAttemptRef = useRef(0);
|
|
const recoveryTaskRef = useRef<string | null>(null);
|
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
|
const projectId = useCanvasStore((state) => state.projectId);
|
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
|
const model3dOpen3dItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dOpen3d"
|
|
);
|
|
const model3dOpenWorldItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dOpenWorld"
|
|
);
|
|
const model3dMoreItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dMore"
|
|
);
|
|
const model3dOpenMeshItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dOpenMesh"
|
|
);
|
|
const model3dOpenImageItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dOpenImage"
|
|
);
|
|
const model3dDownloadItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dDownload"
|
|
);
|
|
const model3dCopyItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dCopy"
|
|
);
|
|
const model3dDeleteItem = getCanvasToolbarItem(
|
|
canvasToolbarConfig,
|
|
"model3dDelete"
|
|
);
|
|
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
|
|
const taskId = String(data.metadata?.taskId || "").trim();
|
|
const assetPayload = readWorldAssetPayload(data);
|
|
const providerId = String(
|
|
data.metadata?.providerId || assetPayload?.providerId || ""
|
|
).trim();
|
|
const providerName = String(
|
|
data.metadata?.providerName || assetPayload?.providerName || ""
|
|
).trim();
|
|
const sourceImageUrl = String(
|
|
data.metadata?.sourceImageUrl || data.previewImage || ""
|
|
).trim();
|
|
const { data: modelAliasConfigResponse } = useSWR<{
|
|
success?: boolean;
|
|
data?: PublicThreeDModelAliasConfig;
|
|
}>("/api/canvas/model-aliases", fetcher);
|
|
const providerLabel =
|
|
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
|
|
const publicModelAliasConfig =
|
|
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
|
|
const modelVersionLabel = useMemo(
|
|
() =>
|
|
resolveThreeDModelDisplayName(
|
|
data.modelVersion,
|
|
publicModelAliasConfig.tripoModels,
|
|
providerLabel,
|
|
),
|
|
[data.modelVersion, providerLabel, publicModelAliasConfig.tripoModels],
|
|
);
|
|
const worldAssetPayload = readWorldAssetPayload(data);
|
|
const sourceModelUrl = readOptionalUrl(worldAssetPayload?.sourceModelUrl);
|
|
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
|
|
const worldImageUrl =
|
|
readOptionalUrl(worldAssetPayload?.panoUrl) ||
|
|
readOptionalUrl(worldAssetPayload?.thumbnailUrl) ||
|
|
readOptionalUrl(data.previewImage);
|
|
const resolvedModelUrl = useMemo(
|
|
() =>
|
|
resolvePreferredModelAssetUrl({
|
|
primaryUrl: data.src,
|
|
sourceUrl: sourceModelUrl,
|
|
}),
|
|
[data.src, sourceModelUrl]
|
|
);
|
|
const modelFileName = useMemo(
|
|
() => inferModelFileName(resolvedModelUrl || data.src || null, node.id),
|
|
[data.src, node.id, resolvedModelUrl]
|
|
);
|
|
const modelFormatLabel = useMemo(
|
|
() => inferModelFormat(resolvedModelUrl || data.src || null, data.format),
|
|
[data.format, data.src, resolvedModelUrl]
|
|
);
|
|
const primaryOpenUrl = worldUrl || resolvedModelUrl || null;
|
|
const primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem;
|
|
const primaryOpenLabel = primaryOpenItem.label;
|
|
const shouldRenderLiveViewer =
|
|
isSelected && data.status === "completed" && Boolean(resolvedModelUrl);
|
|
const placeholderSizeScale = useMemo(
|
|
() => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76),
|
|
[node.size.height, node.size.width]
|
|
);
|
|
const placeholderViewportScale = useMemo(
|
|
() =>
|
|
Math.min(
|
|
1.55,
|
|
Math.max(0.72, placeholderSizeScale / Math.max(viewportZoom, 0.42))
|
|
),
|
|
[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 persistCanvasStateSnapshot = useCallback(() => {
|
|
if (!projectId || typeof window === "undefined") return;
|
|
window.setTimeout(() => {
|
|
void canvasService.saveCanvasState(projectId, getCanvasState());
|
|
}, 0);
|
|
}, [getCanvasState, projectId]);
|
|
|
|
const persistCanvas3DAsset = useCallback(
|
|
async (asset: Record<string, unknown>) => {
|
|
if (!projectId) return;
|
|
try {
|
|
await fetch(`/api/canvas/${projectId}/model3d-assets`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ asset }),
|
|
});
|
|
} catch (error) {
|
|
console.error("[Model3DNode] 保存 3D 资产失败:", error);
|
|
}
|
|
},
|
|
[projectId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (data.status !== "generating" || !taskId) return;
|
|
const provider = String(data.metadata?.provider || "tripo-3d").trim();
|
|
if (provider !== "tripo-3d") return;
|
|
|
|
if (recoveryTaskRef.current !== taskId) {
|
|
recoveryTaskRef.current = taskId;
|
|
recoveryAttemptRef.current = 0;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
const prompt = String(data.prompt || "图生 3D 模型").trim();
|
|
const modelVersion = String(data.modelVersion || "tripo-standard").trim();
|
|
const previewImage = String(data.previewImage || sourceImageUrl).trim();
|
|
|
|
const currentData = () => {
|
|
const currentNode = getCanvasState().nodes.find((item) => item.id === node.id);
|
|
return (currentNode?.data || data) as Model3DNodeData;
|
|
};
|
|
|
|
const persistTerminalAsset = async (params: {
|
|
error: string | null;
|
|
modelUrl: string | null;
|
|
previewImageUrl: string;
|
|
progress: number | null;
|
|
status: "failed" | "success";
|
|
taskAssetPayload?: Record<string, unknown> | null;
|
|
}) => {
|
|
await persistCanvas3DAsset({
|
|
taskId,
|
|
provider: "tripo-3d",
|
|
status: params.status,
|
|
mode: "image",
|
|
prompt,
|
|
modelVersion,
|
|
progress: params.progress,
|
|
modelUrl: params.modelUrl,
|
|
previewImageUrl: params.previewImageUrl,
|
|
error: params.error,
|
|
assetPayload: {
|
|
...(params.taskAssetPayload || {}),
|
|
providerId: providerId || null,
|
|
providerName: providerName || null,
|
|
sourceImageUrl,
|
|
},
|
|
createdAt: Date.now(),
|
|
});
|
|
persistCanvasStateSnapshot();
|
|
};
|
|
|
|
const markFailed = async (message: string, progress: number | null) => {
|
|
const latest = currentData();
|
|
updateNode(node.id, {
|
|
data: {
|
|
...latest,
|
|
src: "",
|
|
previewImage: previewImage || sourceImageUrl,
|
|
status: "failed",
|
|
metadata: {
|
|
...(latest.metadata || {}),
|
|
taskId,
|
|
provider: "tripo-3d",
|
|
mode: "image",
|
|
sourceImageUrl,
|
|
progress,
|
|
error: message,
|
|
},
|
|
} as Model3DNodeData,
|
|
});
|
|
await persistTerminalAsset({
|
|
error: message,
|
|
modelUrl: null,
|
|
previewImageUrl: previewImage || sourceImageUrl,
|
|
progress,
|
|
status: "failed",
|
|
});
|
|
toast.error(message);
|
|
};
|
|
|
|
const scheduleNextPoll = () => {
|
|
if (cancelled) return;
|
|
if (recoveryAttemptRef.current >= MODEL_3D_RECOVERY_MAX_ATTEMPTS) {
|
|
void markFailed("3D 模型任务同步超时,请稍后重试", null);
|
|
return;
|
|
}
|
|
timer = setTimeout(() => {
|
|
void pollTask();
|
|
}, MODEL_3D_RECOVERY_INTERVAL_MS);
|
|
};
|
|
|
|
async function pollTask() {
|
|
if (cancelled) return;
|
|
recoveryAttemptRef.current += 1;
|
|
const result = await model3DGenerationService.getTask(taskId, {
|
|
providerId: providerId || null,
|
|
});
|
|
if (cancelled) return;
|
|
if (!result.success || !result.data) {
|
|
scheduleNextPoll();
|
|
return;
|
|
}
|
|
|
|
const status = String(result.data.status || "unknown").toLowerCase();
|
|
const progress = result.data.progress ?? null;
|
|
if (status === "success") {
|
|
const modelUrl = String(result.data.modelUrl || "").trim();
|
|
const nextPreviewImage = String(
|
|
result.data.previewImageUrl || previewImage || sourceImageUrl
|
|
).trim();
|
|
if (!modelUrl) {
|
|
scheduleNextPoll();
|
|
return;
|
|
}
|
|
const taskAssetPayload =
|
|
result.data.assetPayload && typeof result.data.assetPayload === "object"
|
|
? (result.data.assetPayload as Record<string, unknown>)
|
|
: {};
|
|
const latest = currentData();
|
|
updateNode(node.id, {
|
|
data: {
|
|
...latest,
|
|
src: modelUrl,
|
|
previewImage: nextPreviewImage,
|
|
format: inferModelFormat(modelUrl),
|
|
status: "completed",
|
|
metadata: {
|
|
...(latest.metadata || {}),
|
|
taskId,
|
|
provider: "tripo-3d",
|
|
mode: "image",
|
|
sourceImageUrl,
|
|
progress: 100,
|
|
output: result.data.output || null,
|
|
assetPayload: {
|
|
...taskAssetPayload,
|
|
providerId: providerId || taskAssetPayload.providerId || null,
|
|
providerName: providerName || taskAssetPayload.providerName || null,
|
|
sourceImageUrl,
|
|
},
|
|
recoveredAt: new Date().toISOString(),
|
|
},
|
|
} as Model3DNodeData,
|
|
});
|
|
await persistTerminalAsset({
|
|
error: null,
|
|
modelUrl,
|
|
previewImageUrl: nextPreviewImage,
|
|
progress: 100,
|
|
status: "success",
|
|
taskAssetPayload,
|
|
});
|
|
toast.success("3D 模型已生成到画布");
|
|
return;
|
|
}
|
|
|
|
if (isTerminalModelFailureStatus(status)) {
|
|
await markFailed(result.data.errorMessage || "3D 模型生成失败", progress);
|
|
return;
|
|
}
|
|
|
|
const latest = currentData();
|
|
updateNode(node.id, {
|
|
data: {
|
|
...latest,
|
|
metadata: {
|
|
...(latest.metadata || {}),
|
|
progress,
|
|
},
|
|
} as Model3DNodeData,
|
|
});
|
|
scheduleNextPoll();
|
|
}
|
|
|
|
void pollTask();
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
}, [
|
|
data.modelVersion,
|
|
data.previewImage,
|
|
data.prompt,
|
|
data.status,
|
|
getCanvasState,
|
|
node.id,
|
|
persistCanvas3DAsset,
|
|
persistCanvasStateSnapshot,
|
|
providerId,
|
|
providerName,
|
|
sourceImageUrl,
|
|
taskId,
|
|
updateNode,
|
|
]);
|
|
|
|
const handleOpen = () => {
|
|
if (!primaryOpenUrl) {
|
|
toast.error("暂无可打开的地址");
|
|
return;
|
|
}
|
|
window.open(primaryOpenUrl, "_blank", "noopener,noreferrer");
|
|
};
|
|
|
|
const handleOpenMesh = () => {
|
|
if (!resolvedModelUrl) {
|
|
toast.error("暂无可打开的 Mesh 地址");
|
|
return;
|
|
}
|
|
window.open(resolvedModelUrl, "_blank", "noopener,noreferrer");
|
|
};
|
|
|
|
const handleOpenImage = () => {
|
|
if (!worldImageUrl) {
|
|
toast.error("暂无可打开的图像预览");
|
|
return;
|
|
}
|
|
window.open(worldImageUrl, "_blank", "noopener,noreferrer");
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
if (!resolvedModelUrl) {
|
|
toast.error("暂无可下载的 3D 文件");
|
|
return;
|
|
}
|
|
const link = document.createElement("a");
|
|
link.href = resolvedModelUrl;
|
|
link.download = modelFileName;
|
|
link.click();
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (!primaryOpenUrl) {
|
|
toast.error("暂无可复制的地址");
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(primaryOpenUrl);
|
|
toast.success(worldUrl ? "已复制 World 链接" : "已复制 3D 链接");
|
|
} catch {
|
|
toast.error("复制失败");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative h-full w-full overflow-hidden rounded-lg 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-11 w-11 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">
|
|
<Box className="h-5 w-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">
|
|
{data.metadata?.provider === "worldlabs-3d"
|
|
? "3D 世界生成中..."
|
|
: "3D 模型生成中..."}
|
|
</span>
|
|
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
|
|
{data.modelVersion ? `模型:${modelVersionLabel}` : providerLabel}
|
|
</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-11 w-11 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-5 w-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">
|
|
{data.metadata?.provider === "worldlabs-3d"
|
|
? "3D 世界生成失败"
|
|
: "3D 模型生成失败"}
|
|
</span>
|
|
<span className="text-xs text-[#b77480] dark:text-red-300/80">
|
|
可以重新选择模型后再试一次
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col">
|
|
<div className="relative min-h-0 flex-1 overflow-hidden bg-muted/30">
|
|
{shouldRenderLiveViewer ? (
|
|
<div
|
|
className="h-full w-full"
|
|
data-canvas-no-drag="true"
|
|
onWheel={(event) => event.stopPropagation()}
|
|
>
|
|
<Model3DViewer
|
|
emptyStateTitle="准备查看 3D 模型"
|
|
modelUrl={resolvedModelUrl || data.src}
|
|
previewImageUrl={data.previewImage}
|
|
settings={CANVAS_MODEL_VIEWER_SETTINGS}
|
|
/>
|
|
</div>
|
|
) : data.previewImage ? (
|
|
<img
|
|
src={data.previewImage}
|
|
alt={data.prompt || "3D 预览"}
|
|
className="h-full w-full object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<Box className="h-12 w-12 text-muted-foreground/60" />
|
|
</div>
|
|
)}
|
|
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
|
|
{modelFormatLabel}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1 border-t px-3 py-2">
|
|
<p className="truncate text-xs font-medium">
|
|
{data.prompt || modelFileName}
|
|
</p>
|
|
<p className="truncate text-[11px] text-muted-foreground">
|
|
{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isHovered && data.status === "completed" && (
|
|
<div className="absolute right-2 top-2 flex items-center gap-1">
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleOpen}
|
|
title={primaryOpenLabel}
|
|
>
|
|
<CanvasToolbarIcon
|
|
className="h-4 w-4"
|
|
fallback={worldUrl ? Globe : ExternalLink}
|
|
item={primaryOpenItem}
|
|
/>
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
title={model3dMoreItem.label}
|
|
>
|
|
<CanvasToolbarIcon
|
|
className="h-4 w-4"
|
|
fallback={MoreVertical}
|
|
item={model3dMoreItem}
|
|
/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={handleOpen}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={worldUrl ? Globe : ExternalLink}
|
|
item={primaryOpenItem}
|
|
/>
|
|
{primaryOpenLabel}
|
|
</DropdownMenuItem>
|
|
{worldUrl && resolvedModelUrl && resolvedModelUrl !== worldUrl && (
|
|
<DropdownMenuItem onClick={handleOpenMesh}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Box}
|
|
item={model3dOpenMeshItem}
|
|
/>
|
|
{model3dOpenMeshItem.label}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{worldImageUrl && (
|
|
<DropdownMenuItem onClick={handleOpenImage}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={ImageIcon}
|
|
item={model3dOpenImageItem}
|
|
/>
|
|
{model3dOpenImageItem.label}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem onClick={handleDownload}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Download}
|
|
item={model3dDownloadItem}
|
|
/>
|
|
{model3dDownloadItem.label}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleCopy}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Copy}
|
|
item={model3dCopyItem}
|
|
/>
|
|
{model3dCopyItem.label}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
|
|
<CanvasToolbarIcon
|
|
className="mr-2 h-4 w-4"
|
|
fallback={Trash2}
|
|
item={model3dDeleteItem}
|
|
/>
|
|
{model3dDeleteItem.label}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|