import { useEffect, useRef, useState } from "react"; import { Globe, X } from "lucide-react"; import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context"; import { getNodeResourceKind } from "@/lib/canvas/node-registry"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { CanvasNodeData } from "@/types/canvas"; type PanoramaNodeContentProps = { node: CanvasNodeData; theme: CanvasTheme; reduceMediaEffects?: boolean; }; /** * 360° 全景查看器节点:取上游图片,拖拽环视。 * * 三个刻意的取舍: * 1. **点击才激活 WebGL**。浏览器对同时存在的 WebGL 上下文有硬上限(十几个), * 画布上可以有任意多个全景节点,自动激活必然在某个数量后整片黑。默认只显示平面预览, * 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。 * 2. **用原生 three 而不是 @react-three/fiber**。这里需要精确控制 dispose—— * 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。 * 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染, * reduceMediaEffects 时只给静态预览。 */ export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) { const upstream = useUpstreamNodes(node.id); const inherited = upstream.find((item) => getNodeResourceKind(item) === "image"); const url = node.metadata?.content || inherited?.metadata?.content || ""; const [active, setActive] = useState(false); const [failed, setFailed] = useState(false); // 上游换图后退出环视,避免旧上下文继续持有已被替换的纹理。 useEffect(() => { setActive(false); setFailed(false); }, [url]); if (!url) { return (
连接一张 360° 等距柱状全景图
); } if (!active) { return (
{node.title
{failed ? "全景加载失败(图片可能不允许跨域读取)" : reduceMediaEffects ? "性能模式下仅显示预览" : "360° 全景"} {reduceMediaEffects ? null : ( )}
); } return (
{ setFailed(true); setActive(false); }} />
); } function PanoramaViewport({ url, onError }: { url: string; onError: () => void }) { const hostRef = useRef(null); // onError 是内联箭头函数,每次渲染都是新引用;直接放进 effect 依赖会让 // 父级任何重渲染(悬停/选中/画布状态)都销毁重建 WebGL 上下文 —— // 表现就是环视画面闪动、视角回到初始位置。用 ref 稳定它。 const onErrorRef = useRef(onError); onErrorRef.current = onError; useEffect(() => { const host = hostRef.current; if (!host) return; let disposed = false; let frame = 0; // three 体积大,按需加载,不进主包。 const teardown: Array<() => void> = []; void (async () => { const THREE = await import("three"); if (disposed) return; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100); const loader = new THREE.TextureLoader(); loader.setCrossOrigin("anonymous"); const texture = await new Promise | null>((resolve) => { loader.load(url, resolve, undefined, () => resolve(null)); }); if (disposed) { renderer.dispose(); return; } if (!texture) { renderer.dispose(); onErrorRef.current(); return; } texture.colorSpace = THREE.SRGBColorSpace; const geometry = new THREE.SphereGeometry(500, 60, 40); const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide }); scene.add(new THREE.Mesh(geometry, material)); host.appendChild(renderer.domElement); renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab"; let lon = 0; let lat = 0; let dragging = false; let lastX = 0; let lastY = 0; const onPointerDown = (event: PointerEvent) => { dragging = true; lastX = event.clientX; lastY = event.clientY; renderer.domElement.setPointerCapture(event.pointerId); event.stopPropagation(); }; const onPointerMove = (event: PointerEvent) => { if (!dragging) return; lon -= (event.clientX - lastX) * 0.18; lat += (event.clientY - lastY) * 0.18; lat = Math.max(-85, Math.min(85, lat)); lastX = event.clientX; lastY = event.clientY; }; const onPointerUp = (event: PointerEvent) => { dragging = false; if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId); }; // 滚轮留给画布缩放,全景不吃 wheel —— 节点里再嵌一层缩放会让手感彻底乱。 renderer.domElement.addEventListener("pointerdown", onPointerDown); renderer.domElement.addEventListener("pointermove", onPointerMove); renderer.domElement.addEventListener("pointerup", onPointerUp); renderer.domElement.addEventListener("pointercancel", onPointerUp); const resize = () => { const { clientWidth, clientHeight } = host; if (!clientWidth || !clientHeight) return; renderer.setSize(clientWidth, clientHeight, false); camera.aspect = clientWidth / clientHeight; camera.updateProjectionMatrix(); }; const observer = new ResizeObserver(resize); observer.observe(host); resize(); const tick = () => { const phi = THREE.MathUtils.degToRad(90 - lat); const theta = THREE.MathUtils.degToRad(lon); camera.lookAt(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta)); renderer.render(scene, camera); frame = requestAnimationFrame(tick); }; tick(); teardown.push(() => { cancelAnimationFrame(frame); observer.disconnect(); renderer.domElement.removeEventListener("pointerdown", onPointerDown); renderer.domElement.removeEventListener("pointermove", onPointerMove); renderer.domElement.removeEventListener("pointerup", onPointerUp); renderer.domElement.removeEventListener("pointercancel", onPointerUp); renderer.domElement.remove(); geometry.dispose(); material.dispose(); texture.dispose(); // forceContextLoss 才真正释放上下文;只 dispose 在部分浏览器上仍占着配额。 renderer.forceContextLoss(); renderer.dispose(); }); })(); return () => { disposed = true; teardown.forEach((fn) => fn()); }; }, [url]); return
event.stopPropagation()} />; }