UNPKG

kinetic-slider

Version:

A WebGL-powered kinetic slider component using PIXI.js

536 lines (533 loc) 23.8 kB
import { useRef, useCallback, useEffect } from 'react'; import { Assets, Sprite, DisplacementFilter } from 'pixi.js'; import { gsap } from 'gsap'; import { RenderScheduler } from '../managers/RenderScheduler.js'; import { UpdateType } from '../managers/UpdateTypes.js'; // Development environment check const isDevelopment = "production" === 'development'; // Default filter scales const DEFAULT_BG_FILTER_SCALE = 20; const DEFAULT_CURSOR_FILTER_SCALE = 10; /** * Custom hook that manages displacement effects with consistent behavior * for both atlas textures and individual images. * * @param {UseDisplacementEffectsProps} props - Hook properties * @returns {Object} Functions to control displacement effects * @property {Function} showDisplacementEffects - Animates in displacement effects * @property {Function} hideDisplacementEffects - Animates out displacement effects * @property {number} DEFAULT_BG_FILTER_SCALE - Default background filter scale * @property {number} DEFAULT_CURSOR_FILTER_SCALE - Default cursor filter scale */ const useDisplacementEffects = ({ sliderRef, bgDispFilterRef, cursorDispFilterRef, backgroundDisplacementSpriteRef, cursorDisplacementSpriteRef, appRef, backgroundDisplacementSpriteLocation, cursorDisplacementSpriteLocation, cursorImgEffect, cursorScaleIntensity = 0.65, cursorDisplacementSizing = 'natural', cursorDisplacementWidth, cursorDisplacementHeight, resourceManager, atlasManager, effectsAtlas, useEffectsAtlas }) => { /** * Tracks the initialization state of displacement effects. * @type {React.MutableRefObject<{isInitializing: boolean, isInitialized: boolean}>} */ const initializationStateRef = useRef({ isInitializing: false, isInitialized: false }); /** * Validates and sanitizes dimensions for displacement textures. * Handles negative or unusually large values, returning appropriate fallbacks. * * @param {number | undefined} width - Requested width or undefined * @param {number | undefined} height - Requested height or undefined * @param {number} textureWidth - Original texture width as fallback * @param {number} textureHeight - Original texture height as fallback * @returns {{width: number, height: number, isValid: boolean}} Validated dimensions and validity flag */ const validateDimensions = useCallback((width, height, textureWidth, textureHeight) => { let result = { width: width || textureWidth, height: height || textureHeight, isValid: true }; // Check for negative or zero values if ((width !== undefined && width <= 0) || (height !== undefined && height <= 0)) { result = { width: textureWidth, height: textureHeight, isValid: false }; } // Check for unusually large values (more than 10x the canvas) const app = appRef.current; if (app && ((width && width > app.screen.width * 10) || (height && height > app.screen.height * 10))) ; return result; }, [appRef]); /** * Loads a texture from either atlas or individual file with consistent handling. * Attempts multiple loading strategies with fallbacks. * * @param {string} imagePath - Path to the image to load * @returns {Promise<Texture>} The loaded texture * @throws {Error} If texture loading fails */ const loadTexture = useCallback(async (imagePath) => { if (!imagePath || typeof imagePath !== 'string' || imagePath.trim() === '') { throw new Error('Invalid image path'); } try { let texture = null; let loadingMethod = ''; // Try from cache first if (Assets.cache.has(imagePath)) { texture = Assets.cache.get(imagePath); loadingMethod = 'cache'; } // Then try from atlas if enabled else if (atlasManager && effectsAtlas && useEffectsAtlas) { const frameName = imagePath.split('/').pop() || ''; if (atlasManager.hasFrame(frameName)) { const atlasTexture = atlasManager.getFrameTexture(frameName, effectsAtlas); if (atlasTexture) { texture = atlasTexture; loadingMethod = 'atlas'; } } } // Fallback to direct loading if not found if (!texture) { try { texture = await Assets.load(imagePath); loadingMethod = 'direct-load'; } catch (loadError) { if (isDevelopment) ; // Try one last fallback with a stripped path const fallbackPath = imagePath.split('/').pop(); if (fallbackPath && fallbackPath !== imagePath) { try { texture = await Assets.load(fallbackPath); loadingMethod = 'fallback-path'; } catch (fallbackError) { throw loadError; } } else { throw loadError; } } } if (!texture) { throw new Error(`Failed to load texture: ${imagePath}`); } if (isDevelopment) ; return texture; } catch (error) { // Enhanced error with more context const enhancedError = new Error(`Failed to load texture: ${imagePath}. ${error}`); throw enhancedError; } }, [atlasManager, effectsAtlas, useEffectsAtlas]); /** * Sets up displacement effects with consistent sizing regardless of texture source. * This multi-step process loads textures, creates sprites and filters, and configures * them based on the chosen sizing mode. * * @returns {Promise<void>} */ const setupDisplacementEffects = useCallback(async () => { // Prevent multiple initializations if (initializationStateRef.current.isInitializing || initializationStateRef.current.isInitialized) { return; } // Mark as initializing initializationStateRef.current.isInitializing = true; try { // Get stage reference const stage = appRef.current?.stage; if (!stage) { throw new Error('Stage not available'); } // Get canvas dimensions const canvasWidth = appRef.current?.screen.width ?? 0; const canvasHeight = appRef.current?.screen.height ?? 0; if (canvasWidth === 0 || canvasHeight === 0) { throw new Error('Invalid canvas dimensions'); } // 1. Load background displacement sprite const bgTexture = await loadTexture(backgroundDisplacementSpriteLocation); // 2. Create background displacement sprite const bgSprite = new Sprite(bgTexture); // CRITICAL: Force sprite to cover the full canvas const bgScaleX = canvasWidth / bgTexture.width; const bgScaleY = canvasHeight / bgTexture.height; bgSprite.scale.set(bgScaleX, bgScaleY); // Center the sprite on the canvas bgSprite.anchor.set(0.5); bgSprite.position.set(canvasWidth / 2, canvasHeight / 2); // Sprite should not be rendered directly bgSprite.renderable = false; bgSprite.visible = true; bgSprite.alpha = 0; // Start invisible // 3. Create background displacement filter const bgFilter = new DisplacementFilter(bgSprite); bgFilter.scale.set(0); // Start with zero effect bgFilter.padding = 0; // 4. Store references to sprite and filter backgroundDisplacementSpriteRef.current = bgSprite; bgDispFilterRef.current = bgFilter; // 5. Add to stage stage.addChild(bgSprite); // IMPORTANT: Attach the background displacement filter to the stage if (!stage.filters) { stage.filters = [bgFilter]; } else if (!Array.isArray(stage.filters)) { stage.filters = [stage.filters, bgFilter]; } else { stage.filters = [...stage.filters, bgFilter]; } if (isDevelopment) ; // 6. Track resources if (resourceManager) { resourceManager.trackDisplayObject(bgSprite); resourceManager.trackFilter(bgFilter); } // Only set up cursor displacement if enabled if (cursorImgEffect) { // 7. Load cursor displacement sprite const cursorTexture = await loadTexture(cursorDisplacementSpriteLocation); // 8. Create cursor displacement sprite const cursorSprite = new Sprite(cursorTexture); // 9. Set cursor sprite scale based on sizing mode let cursorScaleX = 1; let cursorScaleY = 1; if (cursorDisplacementSizing) { // Validate dimensions if custom sizing is used const validatedDimensions = validateDimensions(cursorDisplacementWidth, cursorDisplacementHeight, cursorTexture.width, cursorTexture.height); if (cursorDisplacementSizing === 'fullscreen') { // Scale to viewport dimensions cursorScaleX = canvasWidth / cursorTexture.width; cursorScaleY = canvasHeight / cursorTexture.height; // Center the sprite on the canvas cursorSprite.anchor.set(0.5); cursorSprite.position.set(canvasWidth / 2, canvasHeight / 2); if (isDevelopment) ; } else if (validatedDimensions.width && validatedDimensions.height) { // Both dimensions specified cursorScaleX = validatedDimensions.width / cursorTexture.width; cursorScaleY = validatedDimensions.height / cursorTexture.height; // Center the sprite cursorSprite.anchor.set(0.5); cursorSprite.position.set(validatedDimensions.width / 2, validatedDimensions.height / 2); if (isDevelopment) ; } else { // Fallback to natural size (should not reach here with validation) cursorScaleX = 1; cursorScaleY = 1; if (isDevelopment) ; } } else { // Natural dimensions (just apply intensity) cursorScaleX = 1; cursorScaleY = 1; if (isDevelopment) ; } // Apply scale intensity cursorSprite.scale.set(cursorScaleX * cursorScaleIntensity, cursorScaleY * cursorScaleIntensity); // 10. Set sprite properties cursorSprite.renderable = false; cursorSprite.visible = true; cursorSprite.alpha = 0; // Start invisible // 11. Create cursor displacement filter const cursorFilter = new DisplacementFilter(cursorSprite); cursorFilter.scale.set(0); // Start with zero effect cursorFilter.padding = 0; // 12. Store references cursorDisplacementSpriteRef.current = cursorSprite; cursorDispFilterRef.current = cursorFilter; // 13. Add to stage stage.addChild(cursorSprite); // IMPORTANT: Attach the cursor displacement filter to the stage if (!stage.filters) { stage.filters = [cursorFilter]; } else if (!Array.isArray(stage.filters)) { stage.filters = [stage.filters, cursorFilter]; } else { stage.filters = [...stage.filters, cursorFilter]; } if (isDevelopment) ; // 14. Track resources if (resourceManager) { resourceManager.trackDisplayObject(cursorSprite); resourceManager.trackFilter(cursorFilter); } } // Mark as initialized initializationStateRef.current = { isInitializing: false, isInitialized: true }; if (isDevelopment) ; } catch (error) { // Reset initialization state on error initializationStateRef.current = { isInitializing: false, isInitialized: false }; throw error; // Re-throw to allow caller to handle } }, [ appRef, backgroundDisplacementSpriteLocation, cursorDisplacementSpriteLocation, cursorImgEffect, cursorScaleIntensity, cursorDisplacementSizing, cursorDisplacementWidth, cursorDisplacementHeight, resourceManager, atlasManager, effectsAtlas, useEffectsAtlas, validateDimensions, loadTexture ]); /** * Handles window resize events to keep displacement effects properly sized and positioned. * Always updates background sprite, and updates cursor sprite if using fullscreen mode. */ useEffect(() => { if (typeof window === 'undefined') return; /** * Resize handler function to update sprite positions and scales. */ const handleResize = () => { const app = appRef.current; if (!app) return; const canvasWidth = app.screen.width; const canvasHeight = app.screen.height; // Update background sprite position and scale const bgSprite = backgroundDisplacementSpriteRef.current; if (bgSprite && bgSprite.texture) { // Update position to new center bgSprite.position.set(canvasWidth / 2, canvasHeight / 2); // Always scale background to fill canvas const bgScaleX = canvasWidth / bgSprite.texture.width; const bgScaleY = canvasHeight / bgSprite.texture.height; bgSprite.scale.set(bgScaleX, bgScaleY); } // Update cursor sprite if using fullscreen mode if (cursorImgEffect && cursorDisplacementSizing === 'fullscreen') { const cursorSprite = cursorDisplacementSpriteRef.current; if (cursorSprite && cursorSprite.texture) { // Update position to new center cursorSprite.position.set(canvasWidth / 2, canvasHeight / 2); // Update scale to maintain fullscreen coverage const scaleX = canvasWidth / cursorSprite.texture.width; const scaleY = canvasHeight / cursorSprite.texture.height; cursorSprite.scale.set(scaleX * cursorScaleIntensity, scaleY * cursorScaleIntensity); } } }; // Always listen for resize to update background sprite window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [ appRef, backgroundDisplacementSpriteRef, cursorDisplacementSpriteRef, cursorDisplacementSizing, cursorImgEffect, cursorScaleIntensity ]); /** * Shows displacement effects by animating sprite alpha and filter scale. * Refactored to support scheduled updates. * * @returns {gsap.core.Tween[]} Array of GSAP animations */ const showDisplacementEffects = useCallback(() => { if (!initializationStateRef.current.isInitialized) return []; // Get the scheduler instance const scheduler = RenderScheduler.getInstance(); // Create a function that performs the actual animation const animate = () => { const animations = []; // Background effect const bgSprite = backgroundDisplacementSpriteRef.current; const bgFilter = bgDispFilterRef.current; if (bgSprite && bgFilter) { // Ensure sprite is properly set up bgSprite.visible = true; bgSprite.renderable = false; // Important: keep this false // Apply immediate scale to ensure filter is visible right away bgFilter.scale.x = DEFAULT_BG_FILTER_SCALE; bgFilter.scale.y = DEFAULT_BG_FILTER_SCALE; // Animate sprite alpha const bgAlphaAnim = gsap.to(bgSprite, { alpha: 1, duration: 0.5 }); // Animate filter scale (still needed for smooth transitions) const bgFilterAnim = gsap.to(bgFilter.scale, { x: DEFAULT_BG_FILTER_SCALE, y: DEFAULT_BG_FILTER_SCALE, duration: 0.5 }); animations.push(bgAlphaAnim, bgFilterAnim); } // Cursor effect if enabled if (cursorImgEffect) { const cursorSprite = cursorDisplacementSpriteRef.current; const cursorFilter = cursorDispFilterRef.current; if (cursorSprite && cursorFilter) { // Ensure sprite is properly set up cursorSprite.visible = true; cursorSprite.renderable = false; // Important: keep this false // Apply immediate scale to ensure filter is visible right away cursorFilter.scale.x = DEFAULT_CURSOR_FILTER_SCALE; cursorFilter.scale.y = DEFAULT_CURSOR_FILTER_SCALE; // Animate sprite alpha const cursorAlphaAnim = gsap.to(cursorSprite, { alpha: 1, duration: 0.5 }); // Animate filter scale (still needed for smooth transitions) const cursorFilterAnim = gsap.to(cursorFilter.scale, { x: DEFAULT_CURSOR_FILTER_SCALE, y: DEFAULT_CURSOR_FILTER_SCALE, duration: 0.5 }); animations.push(cursorAlphaAnim, cursorFilterAnim); } } // Schedule an immediate render update to ensure changes are visible scheduler.scheduleTypedUpdate('displacementEffects', UpdateType.DISPLACEMENT_EFFECT, () => { }, 'critical'); // Track animations if (resourceManager && animations.length) { resourceManager.trackAnimationBatch(animations); } return animations; }; // We can either schedule the effect or run it immediately depending on the context // If called directly from an event handler, it might already be part of a scheduled update return animate(); }, [ backgroundDisplacementSpriteRef, bgDispFilterRef, cursorDisplacementSpriteRef, cursorDispFilterRef, cursorImgEffect, resourceManager ]); /** * Hides displacement effects by animating sprite alpha and filter scale to zero. * Refactored to support scheduled updates. * * @returns {gsap.core.Tween[]} Array of GSAP animations */ const hideDisplacementEffects = useCallback(() => { if (!initializationStateRef.current.isInitialized) return []; // Get the scheduler instance RenderScheduler.getInstance(); // Create a function that performs the actual animation const animate = () => { const animations = []; // Background effect const bgSprite = backgroundDisplacementSpriteRef.current; const bgFilter = bgDispFilterRef.current; if (bgSprite && bgFilter) { const bgAlphaAnim = gsap.to(bgSprite, { alpha: 0, duration: 0.5 }); const bgFilterAnim = gsap.to(bgFilter.scale, { x: 0, y: 0, duration: 0.5 }); animations.push(bgAlphaAnim, bgFilterAnim); } // Cursor effect if enabled if (cursorImgEffect) { const cursorSprite = cursorDisplacementSpriteRef.current; const cursorFilter = cursorDispFilterRef.current; if (cursorSprite && cursorFilter) { const cursorAlphaAnim = gsap.to(cursorSprite, { alpha: 0, duration: 0.5 }); const cursorFilterAnim = gsap.to(cursorFilter.scale, { x: 0, y: 0, duration: 0.5 }); animations.push(cursorAlphaAnim, cursorFilterAnim); } } // Track animations if (resourceManager && animations.length) { resourceManager.trackAnimationBatch(animations); } return animations; }; // We can either schedule the effect or run it immediately depending on the context // If called directly from an event handler, it might already be part of a scheduled update return animate(); }, [ backgroundDisplacementSpriteRef, bgDispFilterRef, cursorDisplacementSpriteRef, cursorDispFilterRef, cursorImgEffect, resourceManager ]); /** * Initializes the displacement effects when the app is ready. * Handles errors and provides cleanup. */ useEffect(() => { if (typeof window === 'undefined') return; // Check if app is ready if (appRef.current?.stage) { try { setupDisplacementEffects().catch(error => { // Handle initialization errors if (isDevelopment) ; // Reset initialization state to allow retry initializationStateRef.current = { isInitializing: false, isInitialized: false }; }); } catch (error) { } } // Cleanup on unmount return () => { initializationStateRef.current = { isInitializing: false, isInitialized: false }; }; }, [appRef.current?.stage, setupDisplacementEffects]); // Return public methods and constants return { showDisplacementEffects, hideDisplacementEffects, DEFAULT_BG_FILTER_SCALE, DEFAULT_CURSOR_FILTER_SCALE }; }; export { useDisplacementEffects }; //# sourceMappingURL=useDisplacementEffects.js.map