diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index efd936e5d..31bd94111 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -1,18 +1,17 @@ "use client"; import React, { useRef, useState, useCallback, useMemo } from "react"; +import dynamic from "next/dynamic"; import { Rnd } from "react-rnd"; import { motion } from "framer-motion"; import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas"; import { ImageNode } from "./nodes/ImageNode"; import { AgentSlotNode } from "./nodes/AgentSlotNode"; -import { Model3DNode } from "./nodes/Model3DNode"; import { TextNode } from "./nodes/TextNode"; import { UiScreenNode } from "./nodes/UiScreenNode"; import { VideoNode } from "./nodes/VideoNode"; import { WorkflowNode } from "./nodes/WorkflowNode"; -import { World3DNode } from "./nodes/World3DNode"; import { CanvasToolbarIcon } from "./CanvasToolbarIcon"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -68,6 +67,50 @@ import { } from "@/lib/canvas/agent-pending-requests"; import { inferOriginalImageUrl } from "@/lib/image-url"; +type Model3DNodeModule = typeof import("./nodes/Model3DNode"); +type World3DNodeModule = typeof import("./nodes/World3DNode"); + +let model3DNodeModulePromise: Promise | null = null; +let world3DNodeModulePromise: Promise | null = null; + +function loadModel3DNodeModule() { + model3DNodeModulePromise ??= import("./nodes/Model3DNode"); + return model3DNodeModulePromise; +} + +function loadWorld3DNodeModule() { + world3DNodeModulePromise ??= import("./nodes/World3DNode"); + return world3DNodeModulePromise; +} + +function preloadCanvas3DNodeRenderers() { + void loadModel3DNodeModule(); + void loadWorld3DNodeModule(); +} + +function Canvas3DNodeLoading() { + return ( +
+ 正在加载 3D 预览 +
+ ); +} + +const Model3DNode = dynamic( + () => loadModel3DNodeModule().then((module) => module.Model3DNode), + { + loading: () => , + ssr: false, + } +); +const World3DNode = dynamic( + () => loadWorld3DNodeModule().then((module) => module.World3DNode), + { + loading: () => , + ssr: false, + } +); + const CANVAS_NO_DRAG_SELECTOR = '[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *'; const CANVAS_DRAG_CANCEL_SELECTOR = [ @@ -352,6 +395,10 @@ export function CanvasWorkspace({ const tool = useCanvasStore(canvasSelectors.tool); const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId); const setViewport = useCanvasStore((state) => state.setViewport); + const has3DNodes = useMemo( + () => nodes.some((node) => node.type === "model3d" || node.type === "world3d"), + [nodes] + ); const viewportRef = useRef(viewport); const pendingViewportRef = useRef(null); const pendingViewportRafRef = useRef(null); @@ -370,6 +417,49 @@ export function CanvasWorkspace({ viewportRef.current = viewport; }, [viewport]); + React.useEffect(() => { + if (has3DNodes) { + preloadCanvas3DNodeRenderers(); + return; + } + + if (typeof window === "undefined") return; + const connection = ( + navigator as Navigator & { + connection?: { effectiveType?: string; saveData?: boolean }; + } + ).connection; + if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) { + return; + } + + const idleWindow = window as typeof window & { + cancelIdleCallback?: (handle: number) => void; + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions + ) => number; + }; + let idleId: number | null = null; + const warmupTimer = window.setTimeout(() => { + if (document.visibilityState !== "visible") return; + if (typeof idleWindow.requestIdleCallback === "function") { + idleId = idleWindow.requestIdleCallback(preloadCanvas3DNodeRenderers, { + timeout: 15000, + }); + return; + } + preloadCanvas3DNodeRenderers(); + }, 12000); + + return () => { + window.clearTimeout(warmupTimer); + if (idleId !== null) { + idleWindow.cancelIdleCallback?.(idleId); + } + }; + }, [has3DNodes]); + React.useEffect(() => { return () => { if (pendingViewportRafRef.current !== null) { diff --git a/tests/routes/canvas-entry-performance.test.ts b/tests/routes/canvas-entry-performance.test.ts index 932514425..763703480 100644 --- a/tests/routes/canvas-entry-performance.test.ts +++ b/tests/routes/canvas-entry-performance.test.ts @@ -85,4 +85,25 @@ test.describe("home to canvas entry performance", () => { 'canvasChatPanelModulePromise ??= import("./CanvasChatPanel")' ); }); + + test("keeps heavy 3d renderers out of the normal canvas workspace chunk", () => { + const workspaceSource = readSource("components/canvas/CanvasWorkspace.tsx"); + + expect(workspaceSource).not.toContain( + 'import { Model3DNode } from "./nodes/Model3DNode"' + ); + expect(workspaceSource).not.toContain( + 'import { World3DNode } from "./nodes/World3DNode"' + ); + expect(workspaceSource).toContain( + 'model3DNodeModulePromise ??= import("./nodes/Model3DNode")' + ); + expect(workspaceSource).toContain( + 'world3DNodeModulePromise ??= import("./nodes/World3DNode")' + ); + expect(workspaceSource).toContain("window.setTimeout(() => {"); + expect(workspaceSource).toContain('document.visibilityState !== "visible"'); + expect(workspaceSource).toContain("timeout: 15000"); + expect(workspaceSource).toContain("}, 12000)"); + }); });