UNPKG

unicornstudio-react

Version:

React component for embedding Unicorn.Studio interactive scenes with TypeScript support. Compatible with React (Vite) and Next.js.

405 lines (396 loc) 12.2 kB
"use client"; // src/next/index.tsx import { useRef as useRef2, useState as useState3, useEffect as useEffect3 } from "react"; import Script from "next/script"; import Image from "next/image"; // src/next/hooks.ts import { useState as useState2, useCallback as useCallback2, useEffect as useEffect2 } from "react"; // src/shared/hooks.ts import { useEffect, useRef, useState, useCallback, useMemo } from "react"; // src/shared/constants.ts var UNICORN_STUDIO_VERSION = "1.4.29"; var UNICORN_STUDIO_CDN_URL = `https://cdn.jsdelivr.net/gh/hiunicornstudio/unicornstudio.js@v${UNICORN_STUDIO_VERSION}/dist/unicornStudio.umd.js`; var DEFAULT_VALUES = { width: "100%", height: "100%", scale: 1, // 0.25 to 1.0 production: true, dpi: 1.5, fps: 60, // 15, 24, 30, 60, or 120 altText: "Scene", className: "", lazyLoad: true, showPlaceholderOnError: true, showPlaceholderWhileLoading: true }; var VALID_FPS = [15, 24, 30, 60, 120]; // src/shared/utils.ts function isWebGLSupported() { if (typeof window === "undefined") return true; try { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); return !!gl; } catch (e) { return false; } } function validateFPS(fps) { return VALID_FPS.includes(fps); } function validateScale(scale) { return scale >= 0.25 && scale <= 1; } function validateParameters(scale, fps) { if (!validateScale(scale)) { return `Invalid scale: ${scale}. Scale must be between 0.25 and 1.0`; } if (!validateFPS(fps)) { return `Invalid fps: ${fps}. FPS must be one of: 15, 24, 30, 60, 120`; } return null; } // src/shared/hooks.ts function useUnicornScene({ elementRef, projectId, jsonFilePath, production, scale, dpi, fps, lazyLoad, altText, ariaLabel, isScriptLoaded, onLoad, onError }) { const sceneRef = useRef(null); const [initError, setInitError] = useState(null); const hasAttemptedRef = useRef(false); const initializationKeyRef = useRef(""); const isInitializingRef = useRef(false); const validationError = useMemo(() => { return validateParameters(scale, fps); }, [scale, fps]); const prevValidationError = useRef(null); useEffect(() => { if (validationError !== prevValidationError.current) { prevValidationError.current = validationError; if (validationError) { const error = new Error(validationError); setInitError(error); onError == null ? void 0 : onError(error); } else { setInitError(null); } } }, [validationError, onError]); const destroyScene = useCallback(() => { var _a; if ((_a = sceneRef.current) == null ? void 0 : _a.destroy) { sceneRef.current.destroy(); sceneRef.current = null; } isInitializingRef.current = false; }, []); const initializeScene = useCallback(async () => { var _a; if (!elementRef.current || !isScriptLoaded || validationError) return; if (isInitializingRef.current) { console.log("Already initializing, skipping..."); return; } const currentKey = `${projectId || ""}-${jsonFilePath || ""}-${scale}-${dpi}-${fps}-${production ? "prod" : "dev"}`; if (initializationKeyRef.current === currentKey && sceneRef.current) { console.log("Scene already initialized with this configuration, skipping..."); return; } initializationKeyRef.current = currentKey; hasAttemptedRef.current = true; isInitializingRef.current = true; try { destroyScene(); if (!((_a = window.UnicornStudio) == null ? void 0 : _a.addScene)) { throw new Error("UnicornStudio.addScene not found"); } const sceneConfig = { elementId: elementRef.current.id || `unicorn-${Math.random().toString(36).slice(2, 11)}`, scale, dpi, fps, lazyLoad, altText, ariaLabel, production }; if (!elementRef.current.id) { elementRef.current.id = sceneConfig.elementId; } if (jsonFilePath) { sceneConfig.filePath = jsonFilePath; } else if (projectId) { sceneConfig.projectId = projectId; } else { throw new Error("No project ID or JSON file path provided"); } let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error("Scene initialization timeout")), 15e3 ); }); const cleanup = () => { if (timeoutId) { clearTimeout(timeoutId); } }; try { const scene = await Promise.race([ window.UnicornStudio.addScene(sceneConfig), timeoutPromise ]); cleanup(); if (scene) { sceneRef.current = scene; hasAttemptedRef.current = false; setInitError(null); isInitializingRef.current = false; onLoad == null ? void 0 : onLoad(); } else { isInitializingRef.current = false; throw new Error("Failed to initialize scene"); } } catch (error) { cleanup(); throw error; } } catch (error) { const err = error instanceof Error ? error : new Error("Unknown error"); let sanitizedMessage = err.message; if (sanitizedMessage.includes("404") || sanitizedMessage.includes("Failed to fetch")) { sanitizedMessage = "Resource not found"; } else if (sanitizedMessage.includes("Network") || sanitizedMessage.includes("network")) { sanitizedMessage = "Network error occurred"; } else if (sanitizedMessage.includes("timeout")) { sanitizedMessage = "Loading timeout"; } const sanitizedError = new Error(sanitizedMessage); setInitError(sanitizedError); isInitializingRef.current = false; onError == null ? void 0 : onError(sanitizedError); } }, [ elementRef, isScriptLoaded, jsonFilePath, projectId, production, scale, dpi, fps, lazyLoad, altText, ariaLabel, destroyScene, onLoad, onError, validationError ]); useEffect(() => { if (isScriptLoaded) { void initializeScene(); } }, [isScriptLoaded, initializeScene]); useEffect(() => { return destroyScene; }, [destroyScene]); useEffect(() => { const newKey = `${projectId || ""}-${jsonFilePath || ""}-${scale}-${dpi}-${fps}-${production ? "prod" : "dev"}`; if (initializationKeyRef.current !== newKey) { hasAttemptedRef.current = false; setInitError(null); isInitializingRef.current = false; initializationKeyRef.current = ""; } }, [projectId, jsonFilePath, scale, dpi, fps, production]); return { error: initError }; } // src/next/hooks.ts function useUnicornStudioScript() { const [isLoaded, setIsLoaded] = useState2(false); const [error, setError] = useState2(null); const handleScriptLoad = useCallback2(() => { setIsLoaded((prev) => { if (!prev && typeof window !== "undefined" && window.UnicornStudio) { setError(null); return true; } return prev; }); }, []); const handleScriptError = useCallback2(() => { setError(new Error("Failed to load UnicornStudio script")); setIsLoaded(false); }, []); useEffect2(() => { if (typeof window !== "undefined" && window.UnicornStudio && !isLoaded) { setIsLoaded(true); setError(null); } }, [isLoaded]); return { isLoaded, error, handleScriptLoad, handleScriptError }; } // src/shared/styles.ts var unicornStyles = { container: { position: "relative", width: "var(--unicorn-width)", height: "var(--unicorn-height)" }, errorWrapper: { display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }, errorBox: { textAlign: "center", padding: "1rem", borderRadius: "0.5rem", backgroundColor: "rgb(254 242 242)", color: "rgb(239 68 68)" }, errorTitle: { fontWeight: "600", marginBottom: "0.25rem" }, errorMessage: { fontSize: "0.875rem", marginTop: "0.25rem" } }; // src/next/index.tsx import { Fragment, jsx, jsxs } from "react/jsx-runtime"; function UnicornScene({ projectId, jsonFilePath, width = DEFAULT_VALUES.width, height = DEFAULT_VALUES.height, scale = DEFAULT_VALUES.scale, dpi = DEFAULT_VALUES.dpi, fps = DEFAULT_VALUES.fps, altText = DEFAULT_VALUES.altText, ariaLabel, className = DEFAULT_VALUES.className, lazyLoad = DEFAULT_VALUES.lazyLoad, production = DEFAULT_VALUES.production, placeholder, placeholderClassName, showPlaceholderOnError = DEFAULT_VALUES.showPlaceholderOnError, showPlaceholderWhileLoading = DEFAULT_VALUES.showPlaceholderWhileLoading, onLoad, onError }) { const elementRef = useRef2(null); const [isSceneLoaded, setIsSceneLoaded] = useState3(false); const [webGLSupported, setWebGLSupported] = useState3(true); const { isLoaded, error: scriptError, handleScriptLoad, handleScriptError } = useUnicornStudioScript(); const { error: sceneError } = useUnicornScene({ elementRef, projectId, jsonFilePath, production, scale, dpi, fps, lazyLoad, altText, ariaLabel: ariaLabel || altText, isScriptLoaded: isLoaded, onLoad: () => { setIsSceneLoaded(true); onLoad == null ? void 0 : onLoad(); }, onError }); const error = scriptError || sceneError; useEffect3(() => { setWebGLSupported(isWebGLSupported()); }, []); const showPlaceholder = (placeholder || placeholderClassName) && (!webGLSupported || showPlaceholderWhileLoading && !isSceneLoaded || showPlaceholderOnError && error); const numericWidth = typeof width === "number" ? width : 0; const numericHeight = typeof height === "number" ? height : 0; const useNumericDimensions = typeof width === "number" && typeof height === "number"; const customProperties = { "--unicorn-width": typeof width === "number" ? `${width}px` : width, "--unicorn-height": typeof height === "number" ? `${height}px` : height }; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( Script, { src: UNICORN_STUDIO_CDN_URL, strategy: lazyLoad ? "lazyOnload" : "afterInteractive", onLoad: handleScriptLoad, onError: handleScriptError } ), /* @__PURE__ */ jsxs( "div", { ref: elementRef, style: { ...unicornStyles.container, ...customProperties }, className, children: [ showPlaceholder && (placeholder || placeholderClassName) && /* @__PURE__ */ jsx("div", { style: { position: "absolute", inset: 0 }, children: typeof placeholder === "string" ? useNumericDimensions ? /* @__PURE__ */ jsx( Image, { src: placeholder, alt: altText, width: numericWidth, height: numericHeight, style: { objectFit: "cover" }, priority: true } ) : /* @__PURE__ */ jsx( Image, { src: placeholder, alt: altText, fill: true, style: { objectFit: "cover" }, priority: true } ) : placeholder ? placeholder : placeholderClassName ? /* @__PURE__ */ jsx( "div", { className: placeholderClassName, style: { width: "100%", height: "100%" }, "aria-label": altText } ) : null }), error && !showPlaceholder && /* @__PURE__ */ jsx("div", { style: unicornStyles.errorWrapper, children: /* @__PURE__ */ jsxs("div", { style: unicornStyles.errorBox, children: [ /* @__PURE__ */ jsx("p", { style: unicornStyles.errorTitle, children: "Error loading scene" }), /* @__PURE__ */ jsx("p", { style: unicornStyles.errorMessage, children: error.message }) ] }) }) ] } ) ] }); } var next_default = UnicornScene; export { UnicornScene, next_default as default }; //# sourceMappingURL=next.mjs.map