UNPKG

reactbits-mcp-server

Version:

MCP Server for React Bits - Access 99+ React components with animations, backgrounds, and UI elements

528 lines (487 loc) 14.8 kB
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react/no-unknown-property */ import { Suspense, useRef, useLayoutEffect, useEffect, useMemo, } from "react"; import { Canvas, useFrame, useLoader, useThree, invalidate, } from "@react-three/fiber"; import { OrbitControls, useGLTF, useFBX, useProgress, Html, Environment, ContactShadows, } from "@react-three/drei"; import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader"; import * as THREE from "three"; const isTouch = typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0); const deg2rad = (d) => (d * Math.PI) / 180; const DECIDE = 8; const ROTATE_SPEED = 0.005; const INERTIA = 0.925; const PARALLAX_MAG = 0.05; const PARALLAX_EASE = 0.12; const HOVER_MAG = deg2rad(6); const HOVER_EASE = 0.15; const Loader = ({ placeholderSrc }) => { const { progress, active } = useProgress(); if (!active && placeholderSrc) return null; return ( <Html center> {placeholderSrc ? ( <img src={placeholderSrc} width={128} height={128} className="blur-lg rounded-lg" /> ) : ( `${Math.round(progress)} %` )} </Html> ); }; const DesktopControls = ({ pivot, min, max, zoomEnabled }) => { const ref = useRef(null); useFrame(() => ref.current?.target.copy(pivot)); return ( <OrbitControls ref={ref} makeDefault enablePan={false} enableRotate={false} enableZoom={zoomEnabled} minDistance={min} maxDistance={max} /> ); }; const ModelInner = ({ url, xOff, yOff, pivot, initYaw, initPitch, minZoom, maxZoom, enableMouseParallax, enableManualRotation, enableHoverRotation, enableManualZoom, autoFrame, fadeIn, autoRotate, autoRotateSpeed, onLoaded, }) => { const outer = useRef(null); const inner = useRef(null); const { camera, gl } = useThree(); const vel = useRef({ x: 0, y: 0 }); const tPar = useRef({ x: 0, y: 0 }); const cPar = useRef({ x: 0, y: 0 }); const tHov = useRef({ x: 0, y: 0 }); const cHov = useRef({ x: 0, y: 0 }); const ext = useMemo(() => url.split(".").pop().toLowerCase(), [url]); const content = useMemo(() => { if (ext === "glb" || ext === "gltf") return useGLTF(url).scene.clone(); if (ext === "fbx") return useFBX(url).clone(); if (ext === "obj") return useLoader(OBJLoader, url).clone(); console.error("Unsupported format:", ext); return null; }, [url, ext]); const pivotW = useRef(new THREE.Vector3()); useLayoutEffect(() => { if (!content) return; const g = inner.current; g.updateWorldMatrix(true, true); const sphere = new THREE.Box3() .setFromObject(g) .getBoundingSphere(new THREE.Sphere()); const s = 1 / (sphere.radius * 2); g.position.set(-sphere.center.x, -sphere.center.y, -sphere.center.z); g.scale.setScalar(s); g.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; if (fadeIn) { o.material.transparent = true; o.material.opacity = 0; } } }); g.getWorldPosition(pivotW.current); pivot.copy(pivotW.current); outer.current.rotation.set(initPitch, initYaw, 0); if (autoFrame && camera.isPerspectiveCamera) { const persp = camera; const fitR = sphere.radius * s; const d = (fitR * 1.2) / Math.sin((persp.fov * Math.PI) / 180 / 2); persp.position.set( pivotW.current.x, pivotW.current.y, pivotW.current.z + d ); persp.near = d / 10; persp.far = d * 10; persp.updateProjectionMatrix(); } if (fadeIn) { let t = 0; const id = setInterval(() => { t += 0.05; const v = Math.min(t, 1); g.traverse((o) => { if (o.isMesh) o.material.opacity = v; }); invalidate(); if (v === 1) { clearInterval(id); onLoaded?.(); } }, 16); return () => clearInterval(id); } else onLoaded?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [content]); useEffect(() => { if (!enableManualRotation || isTouch) return; const el = gl.domElement; let drag = false; let lx = 0, ly = 0; const down = (e) => { if (e.pointerType !== "mouse" && e.pointerType !== "pen") return; drag = true; lx = e.clientX; ly = e.clientY; window.addEventListener("pointerup", up); }; const move = (e) => { if (!drag) return; const dx = e.clientX - lx; const dy = e.clientY - ly; lx = e.clientX; ly = e.clientY; outer.current.rotation.y += dx * ROTATE_SPEED; outer.current.rotation.x += dy * ROTATE_SPEED; vel.current = { x: dx * ROTATE_SPEED, y: dy * ROTATE_SPEED }; invalidate(); }; const up = () => (drag = false); el.addEventListener("pointerdown", down); el.addEventListener("pointermove", move); return () => { el.removeEventListener("pointerdown", down); el.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [gl, enableManualRotation]); useEffect(() => { if (!isTouch) return; const el = gl.domElement; const pts = new Map(); let mode = "idle"; let sx = 0, sy = 0, lx = 0, ly = 0, startDist = 0, startZ = 0; const down = (e) => { if (e.pointerType !== "touch") return; pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pts.size === 1) { mode = "decide"; sx = lx = e.clientX; sy = ly = e.clientY; } else if (pts.size === 2 && enableManualZoom) { mode = "pinch"; const [p1, p2] = [...pts.values()]; startDist = Math.hypot(p1.x - p2.x, p1.y - p2.y); startZ = camera.position.z; e.preventDefault(); } invalidate(); }; const move = (e) => { const p = pts.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY; if (mode === "decide") { const dx = e.clientX - sx; const dy = e.clientY - sy; if (Math.abs(dx) > DECIDE || Math.abs(dy) > DECIDE) { if (enableManualRotation && Math.abs(dx) > Math.abs(dy)) { mode = "rotate"; el.setPointerCapture(e.pointerId); } else { mode = "idle"; pts.clear(); } } } if (mode === "rotate") { e.preventDefault(); const dx = e.clientX - lx; const dy = e.clientY - ly; lx = e.clientX; ly = e.clientY; outer.current.rotation.y += dx * ROTATE_SPEED; outer.current.rotation.x += dy * ROTATE_SPEED; vel.current = { x: dx * ROTATE_SPEED, y: dy * ROTATE_SPEED }; invalidate(); } else if (mode === "pinch" && pts.size === 2) { e.preventDefault(); const [p1, p2] = [...pts.values()]; const d = Math.hypot(p1.x - p2.x, p1.y - p2.y); const ratio = startDist / d; camera.position.z = THREE.MathUtils.clamp( startZ * ratio, minZoom, maxZoom ); invalidate(); } }; const up = (e) => { pts.delete(e.pointerId); if (mode === "rotate" && pts.size === 0) mode = "idle"; if (mode === "pinch" && pts.size < 2) mode = "idle"; }; el.addEventListener("pointerdown", down, { passive: true }); window.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", up, { passive: true }); window.addEventListener("pointercancel", up, { passive: true }); return () => { el.removeEventListener("pointerdown", down); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [gl, enableManualRotation, enableManualZoom, minZoom, maxZoom]); useEffect(() => { if (isTouch) return; const mm = (e) => { if (e.pointerType !== "mouse") return; const nx = (e.clientX / window.innerWidth) * 2 - 1; const ny = (e.clientY / window.innerHeight) * 2 - 1; if (enableMouseParallax) tPar.current = { x: -nx * PARALLAX_MAG, y: -ny * PARALLAX_MAG }; if (enableHoverRotation) tHov.current = { x: ny * HOVER_MAG, y: nx * HOVER_MAG }; invalidate(); }; window.addEventListener("pointermove", mm); return () => window.removeEventListener("pointermove", mm); }, [enableMouseParallax, enableHoverRotation]); useFrame((_, dt) => { let need = false; cPar.current.x += (tPar.current.x - cPar.current.x) * PARALLAX_EASE; cPar.current.y += (tPar.current.y - cPar.current.y) * PARALLAX_EASE; const phx = cHov.current.x, phy = cHov.current.y; cHov.current.x += (tHov.current.x - cHov.current.x) * HOVER_EASE; cHov.current.y += (tHov.current.y - cHov.current.y) * HOVER_EASE; const ndc = pivotW.current.clone().project(camera); ndc.x += xOff + cPar.current.x; ndc.y += yOff + cPar.current.y; outer.current.position.copy(ndc.unproject(camera)); outer.current.rotation.x += cHov.current.x - phx; outer.current.rotation.y += cHov.current.y - phy; if (autoRotate) { outer.current.rotation.y += autoRotateSpeed * dt; need = true; } outer.current.rotation.y += vel.current.x; outer.current.rotation.x += vel.current.y; vel.current.x *= INERTIA; vel.current.y *= INERTIA; if (Math.abs(vel.current.x) > 1e-4 || Math.abs(vel.current.y) > 1e-4) need = true; if ( Math.abs(cPar.current.x - tPar.current.x) > 1e-4 || Math.abs(cPar.current.y - tPar.current.y) > 1e-4 || Math.abs(cHov.current.x - tHov.current.x) > 1e-4 || Math.abs(cHov.current.y - tHov.current.y) > 1e-4 ) need = true; if (need) invalidate(); }); if (!content) return null; return ( <group ref={outer}> <group ref={inner}> <primitive object={content} /> </group> </group> ); }; const ModelViewer = ({ url, width = 400, height = 400, modelXOffset = 0, modelYOffset = 0, defaultRotationX = -50, defaultRotationY = 20, defaultZoom = 0.5, minZoomDistance = 0.5, maxZoomDistance = 10, enableMouseParallax = true, enableManualRotation = true, enableHoverRotation = true, enableManualZoom = true, ambientIntensity = 0.3, keyLightIntensity = 1, fillLightIntensity = 0.5, rimLightIntensity = 0.8, environmentPreset = "forest", autoFrame = false, placeholderSrc, showScreenshotButton = true, fadeIn = false, autoRotate = false, autoRotateSpeed = 0.35, onModelLoaded, }) => { useEffect(() => void useGLTF.preload(url), [url]); const pivot = useRef(new THREE.Vector3()).current; const contactRef = useRef(null); const rendererRef = useRef(null); const sceneRef = useRef(null); const cameraRef = useRef(null); const initYaw = deg2rad(defaultRotationX); const initPitch = deg2rad(defaultRotationY); const camZ = Math.min( Math.max(defaultZoom, minZoomDistance), maxZoomDistance ); const capture = () => { const g = rendererRef.current, s = sceneRef.current, c = cameraRef.current; if (!g || !s || !c) return; g.shadowMap.enabled = false; const tmp = []; s.traverse((o) => { if (o.isLight && "castShadow" in o) { tmp.push({ l: o, cast: o.castShadow }); o.castShadow = false; } }); if (contactRef.current) contactRef.current.visible = false; g.render(s, c); const urlPNG = g.domElement.toDataURL("image/png"); const a = document.createElement("a"); a.download = "model.png"; a.href = urlPNG; a.click(); g.shadowMap.enabled = true; tmp.forEach(({ l, cast }) => (l.castShadow = cast)); if (contactRef.current) contactRef.current.visible = true; invalidate(); }; return ( <div style={{ width, height, touchAction: "pan-y pinch-zoom", }} className="relative" > {showScreenshotButton && ( <button onClick={capture} className="absolute top-4 right-4 z-10 cursor-pointer px-4 py-2 border border-white rounded-xl bg-transparent text-white hover:bg-white hover:text-black transition-colors" > Take Screenshot </button> )} <Canvas shadows frameloop="demand" gl={{ preserveDrawingBuffer: true }} onCreated={({ gl, scene, camera }) => { rendererRef.current = gl; sceneRef.current = scene; cameraRef.current = camera; gl.toneMapping = THREE.ACESFilmicToneMapping; gl.outputColorSpace = THREE.SRGBColorSpace; }} camera={{ fov: 50, position: [0, 0, camZ], near: 0.01, far: 100 }} style={{ touchAction: "pan-y pinch-zoom" }} > {environmentPreset !== "none" && ( <Environment preset={environmentPreset} background={false} /> )} <ambientLight intensity={ambientIntensity} /> <directionalLight position={[5, 5, 5]} intensity={keyLightIntensity} castShadow /> <directionalLight position={[-5, 2, 5]} intensity={fillLightIntensity} /> <directionalLight position={[0, 4, -5]} intensity={rimLightIntensity} /> <ContactShadows ref={contactRef} position={[0, -0.5, 0]} opacity={0.35} scale={10} blur={2} /> <Suspense fallback={<Loader placeholderSrc={placeholderSrc} />}> <ModelInner url={url} xOff={modelXOffset} yOff={modelYOffset} pivot={pivot} initYaw={initYaw} initPitch={initPitch} minZoom={minZoomDistance} maxZoom={maxZoomDistance} enableMouseParallax={enableMouseParallax} enableManualRotation={enableManualRotation} enableHoverRotation={enableHoverRotation} enableManualZoom={enableManualZoom} autoFrame={autoFrame} fadeIn={fadeIn} autoRotate={autoRotate} autoRotateSpeed={autoRotateSpeed} onLoaded={onModelLoaded} /> </Suspense> {!isTouch && ( <DesktopControls pivot={pivot} min={minZoomDistance} max={maxZoomDistance} zoomEnabled={enableManualZoom} /> )} </Canvas> </div> ); }; export default ModelViewer;