kinetic-slider
Version:
A WebGL-powered kinetic slider component using PIXI.js
657 lines (654 loc) • 24.7 kB
JavaScript
import { useState, useRef, useCallback, useEffect } from 'react';
import { Container, Sprite, Assets } from 'pixi.js';
import { calculateSpriteScale } from '../utils/calculateSpriteScale.js';
import { gsap } from 'gsap';
import { AnimationCoordinator, AnimationGroupType } from '../managers/AnimationCoordinator.js';
const isDevelopment = false;
const useSlides = ({ sliderRef, pixi, props, resourceManager, atlasManager, onSlideChange, slidingWindowManager }) => {
console.log("useSlides received useSlidesAtlas:", props.useSlidesAtlas);
console.log("useSlides received props:", props);
const [isLoading, setIsLoading] = useState(false);
const [loadingProgress, setLoadingProgress] = useState(0);
const activeTransitionRef = useRef(null);
const animationCoordinator = AnimationCoordinator.getInstance();
props.slidesBasePath || "/images/";
const normalizePath = (imagePath) => {
if (imagePath.startsWith("/")) {
return imagePath.substring(1);
}
return imagePath;
};
const isUseSlidesAtlasEnabled = () => {
if (props.useSlidesAtlas === true) return true;
if (typeof props.useSlidesAtlas === "string" && props.useSlidesAtlas === "true") return true;
if (typeof props.useSlidesAtlas === "number" && props.useSlidesAtlas === 1) return true;
if (typeof props.useSlidesAtlas === "string" && props.useSlidesAtlas === "1") return true;
return false;
};
const areAssetsInAtlas = useCallback(() => {
if (!atlasManager || !props.slidesAtlas) {
return false;
}
const useSlidesAtlasEnabled = isUseSlidesAtlasEnabled();
if (!useSlidesAtlasEnabled) {
return false;
}
const result = props.images.every((imagePath) => {
const normalizedPath = normalizePath(imagePath);
const atlasId = atlasManager.hasFrame(normalizedPath);
return !!atlasId;
});
return result;
}, [atlasManager, props.images, props.slidesAtlas, props.useSlidesAtlas]);
useEffect(() => {
if (!pixi.app.current || !pixi.app.current.stage) {
return;
}
if (!props.images.length) {
return;
}
if (!sliderRef.current) {
return;
}
let slidesContainer;
try {
const app = pixi.app.current;
if (app.stage.children.length > 0 && app.stage.children[0] instanceof Container) {
slidesContainer = app.stage.children[0];
} else {
slidesContainer = new Container();
slidesContainer.label = "slidesContainer";
app.stage.addChild(slidesContainer);
if (resourceManager) {
resourceManager.trackDisplayObject(slidesContainer);
}
}
pixi.slides.current.forEach((sprite) => {
if (sprite && sprite.parent) {
try {
sprite.parent.removeChild(sprite);
} catch (error) {
if (isDevelopment) ;
}
}
});
pixi.slides.current = [];
const useSlidesAtlasEnabled = isUseSlidesAtlasEnabled();
const useAtlas = atlasManager && props.slidesAtlas && areAssetsInAtlas() && useSlidesAtlasEnabled;
if (isDevelopment) ;
if (useAtlas) {
loadSlidesFromAtlas(slidesContainer);
} else {
loadSlidesFromIndividualImages(slidesContainer);
}
} catch (error) {
setIsLoading(false);
}
}, [pixi.app.current, props.images, resourceManager, sliderRef, atlasManager, props.slidesAtlas, props.useSlidesAtlas]);
const loadSlidesFromAtlas = async (slidesContainer) => {
if (!pixi.app.current || !sliderRef.current || !atlasManager) return;
try {
setIsLoading(true);
setLoadingProgress(0);
if (isDevelopment) ;
const app = pixi.app.current;
const sliderWidth = sliderRef.current.clientWidth;
const sliderHeight = sliderRef.current.clientHeight;
const totalImages = props.images.length;
let loadedCount = 0;
const visibilityWindowIndices = slidingWindowManager ? slidingWindowManager.getWindowIndices() : [];
if (isDevelopment && slidingWindowManager) ;
for (const [index, imagePath] of props.images.entries()) {
try {
const isInVisibilityWindow = !slidingWindowManager || visibilityWindowIndices.includes(index);
if (isDevelopment && slidingWindowManager) ;
if (isInVisibilityWindow) {
const normalizedPath = normalizePath(imagePath);
if (isDevelopment) ;
const texture = atlasManager.getFrameTexture(normalizedPath, props.slidesAtlas);
if (!texture) {
throw new Error(`Frame ${normalizedPath} not found in atlas ${props.slidesAtlas}`);
}
if (resourceManager) {
resourceManager.trackTexture(imagePath, texture);
}
const sprite = new Sprite(texture);
sprite.anchor.set(0.5);
sprite.x = app.screen.width / 2;
sprite.y = app.screen.height / 2;
sprite.alpha = index === 0 ? 1 : 0;
sprite.visible = index === 0;
try {
const { scale, baseScale } = calculateSpriteScale(
texture.width,
texture.height,
sliderWidth,
sliderHeight
);
sprite.scale.set(scale);
sprite.baseScale = baseScale;
} catch (scaleError) {
if (isDevelopment) ;
sprite.scale.set(1);
sprite.baseScale = 1;
}
sprite._inVisibilityWindow = true;
if (resourceManager) {
resourceManager.trackDisplayObject(sprite);
}
slidesContainer.addChild(sprite);
pixi.slides.current.push(sprite);
if (isDevelopment) ;
} else {
const placeholderOptions = {
width: sliderWidth,
height: sliderHeight,
color: 3355443,
showIndex: isDevelopment,
// Show index in development mode
index,
trackWithResourceManager: true,
resourceManager,
renderer: app.renderer
};
const { createPlaceholderSprite } = await import('../utils/placeholderUtils.js');
const placeholderSprite = createPlaceholderSprite(placeholderOptions);
placeholderSprite.x = app.screen.width / 2;
placeholderSprite.y = app.screen.height / 2;
placeholderSprite.alpha = index === 0 ? 1 : 0;
placeholderSprite.visible = index === 0;
placeholderSprite.scale.set(1);
placeholderSprite.baseScale = 1;
placeholderSprite._isPlaceholder = true;
placeholderSprite._placeholderIndex = index;
placeholderSprite._inVisibilityWindow = false;
slidesContainer.addChild(placeholderSprite);
pixi.slides.current.push(placeholderSprite);
if (isDevelopment) ;
}
loadedCount++;
const progress = loadedCount / totalImages * 100;
setLoadingProgress(progress);
} catch (error) {
if (isDevelopment) ;
const texture = await Assets.load(imagePath);
createSlideFromTexture(texture, imagePath, index, slidesContainer, app, sliderWidth, sliderHeight);
loadedCount++;
setLoadingProgress(loadedCount / totalImages * 100);
}
}
setIsLoading(false);
setLoadingProgress(100);
if (isDevelopment) ;
} catch (error) {
loadSlidesFromIndividualImages(slidesContainer);
}
};
const loadSlidesFromIndividualImages = async (slidesContainer) => {
if (!pixi.app.current || !sliderRef.current) return;
try {
setIsLoading(true);
setLoadingProgress(0);
if (isDevelopment) ;
const app = pixi.app.current;
const sliderWidth = sliderRef.current.clientWidth;
const sliderHeight = sliderRef.current.clientHeight;
if (isDevelopment) ;
const visibilityWindowIndices = slidingWindowManager ? slidingWindowManager.getWindowIndices() : [];
if (isDevelopment && slidingWindowManager) ;
const imagesToLoad = props.images.filter((_, index) => !slidingWindowManager || visibilityWindowIndices.includes(index)).filter((image) => !Assets.cache.has(image));
if (isDevelopment) ;
if (imagesToLoad.length > 0) {
Assets.addBundle("slider-images", imagesToLoad.reduce((acc, image, index) => {
acc[`slide-${index}`] = image;
return acc;
}, {}));
await Assets.loadBundle("slider-images", (progress) => {
setLoadingProgress(progress * 100);
});
}
for (const [index, image] of props.images.entries()) {
try {
const isInVisibilityWindow = !slidingWindowManager || visibilityWindowIndices.includes(index);
if (isDevelopment && slidingWindowManager) ;
if (isInVisibilityWindow) {
const texture = Assets.get(image);
const sprite = createSlideFromTexture(texture, image, index, slidesContainer, app, sliderWidth, sliderHeight);
if (sprite) {
sprite._inVisibilityWindow = true;
}
if (isDevelopment) ;
} else {
const placeholderOptions = {
width: sliderWidth,
height: sliderHeight,
color: 3355443,
showIndex: isDevelopment,
// Show index in development mode
index,
trackWithResourceManager: true,
resourceManager,
renderer: app.renderer
};
const { createPlaceholderSprite } = await import('../utils/placeholderUtils.js');
const placeholderSprite = createPlaceholderSprite(placeholderOptions);
placeholderSprite.x = app.screen.width / 2;
placeholderSprite.y = app.screen.height / 2;
placeholderSprite.alpha = index === 0 ? 1 : 0;
placeholderSprite.visible = index === 0;
placeholderSprite.scale.set(1);
placeholderSprite.baseScale = 1;
placeholderSprite._isPlaceholder = true;
placeholderSprite._placeholderIndex = index;
placeholderSprite._inVisibilityWindow = false;
slidesContainer.addChild(placeholderSprite);
pixi.slides.current.push(placeholderSprite);
if (isDevelopment) ;
}
} catch (error) {
if (isDevelopment) ;
}
}
setIsLoading(false);
setLoadingProgress(100);
} catch (error) {
setIsLoading(false);
}
};
const createSlideFromTexture = (texture, imagePath, index, slidesContainer, app, sliderWidth, sliderHeight) => {
try {
if (resourceManager) {
resourceManager.trackTexture(imagePath, texture);
}
const sprite = new Sprite(texture);
sprite.anchor.set(0.5);
sprite.x = app.screen.width / 2;
sprite.y = app.screen.height / 2;
sprite.alpha = index === 0 ? 1 : 0;
sprite.visible = index === 0;
try {
const { scale, baseScale } = calculateSpriteScale(
texture.width,
texture.height,
sliderWidth,
sliderHeight
);
sprite.scale.set(scale);
sprite.baseScale = baseScale;
} catch (scaleError) {
if (isDevelopment) ;
sprite.scale.set(1);
sprite.baseScale = 1;
}
if (resourceManager) {
resourceManager.trackDisplayObject(sprite);
}
slidesContainer.addChild(sprite);
pixi.slides.current.push(sprite);
if (isDevelopment) ;
return sprite;
} catch (error) {
return null;
}
};
const loadSlideAtIndex = async (index) => {
try {
if (index < 0 || index >= props.images.length) {
if (isDevelopment) ;
return false;
}
const sprite = pixi.slides.current[index];
if (!sprite || !sprite._isPlaceholder || sprite._loadingState === "loaded") {
return true;
}
if (isDevelopment) ;
sprite._loadingState = "loading";
const imagePath = props.images[index];
const useAtlas = atlasManager && props.slidesAtlas && areAssetsInAtlas() && isUseSlidesAtlasEnabled();
let texture;
if (useAtlas) {
const normalizedPath = normalizePath(imagePath);
const atlasTexture = atlasManager.getFrameTexture(normalizedPath, props.slidesAtlas);
if (!atlasTexture) {
throw new Error(`Frame ${normalizedPath} not found in atlas ${props.slidesAtlas}`);
}
texture = atlasTexture;
} else {
texture = await Assets.load(imagePath);
}
sprite._originalTexture = texture;
sprite.texture = texture;
if (sliderRef.current) {
const sliderWidth = sliderRef.current.clientWidth;
const sliderHeight = sliderRef.current.clientHeight;
try {
const { scale, baseScale } = calculateSpriteScale(
texture.width,
texture.height,
sliderWidth,
sliderHeight
);
sprite.scale.set(scale);
sprite.baseScale = baseScale;
} catch (scaleError) {
if (isDevelopment) ;
sprite.scale.set(1);
sprite.baseScale = 1;
}
}
sprite._isPlaceholder = false;
sprite._inVisibilityWindow = true;
sprite._loadingState = "loaded";
if (resourceManager) {
resourceManager.trackTexture(imagePath, texture);
resourceManager.trackDisplayObject(sprite);
}
if (isDevelopment) ;
return true;
} catch (error) {
const sprite = pixi.slides.current[index];
if (sprite) {
sprite._loadingState = "error";
}
return false;
}
};
const transitionToSlide = useCallback((nextIndex) => {
if (!sliderRef.current) {
return null;
}
if (!pixi.slides.current.length) {
return null;
}
if (nextIndex < 0 || nextIndex >= pixi.slides.current.length) {
return null;
}
try {
if (activeTransitionRef.current) {
activeTransitionRef.current.kill();
activeTransitionRef.current = null;
}
if (isDevelopment) ;
const currentIndex = pixi.currentIndex.current;
const currentSlide = pixi.slides.current[currentIndex];
const nextSlide2 = pixi.slides.current[nextIndex];
if (slidingWindowManager) {
slidingWindowManager.updateCurrentIndex(nextIndex);
const visibilityIndices = slidingWindowManager.getWindowIndices();
if (isDevelopment) ;
Promise.all(
visibilityIndices.map(async (index) => {
const slideSprite = pixi.slides.current[index];
if (slideSprite && slideSprite._isPlaceholder) {
return loadSlideAtIndex(index);
}
return Promise.resolve(true);
})
).then((results) => {
if (isDevelopment) ;
});
}
const isNextSlideAPlaceholder = nextSlide2._isPlaceholder === true;
if (isNextSlideAPlaceholder) {
if (isDevelopment) ;
return new Promise(async (resolve) => {
try {
const loadSuccess = await loadSlideAtIndex(nextIndex);
if (!loadSuccess) {
console.error(`Failed to load next slide at index ${nextIndex}`);
resolve(null);
return;
}
const timeline = performTransition(currentIndex, nextIndex);
resolve(timeline);
} catch (error) {
console.error("Error loading next slide:", error);
resolve(null);
}
});
}
return performTransition(currentIndex, nextIndex);
} catch (error) {
return null;
}
}, [
sliderRef,
pixi.slides,
pixi.textContainers,
pixi.currentIndex,
props.transitionScaleIntensity,
resourceManager,
onSlideChange,
animationCoordinator,
slidingWindowManager
]);
const performTransition = (currentIndex, nextIndex) => {
try {
const currentSlide = pixi.slides.current[currentIndex];
const nextSlide2 = pixi.slides.current[nextIndex];
const textContainersAvailable = pixi.textContainers.current && pixi.textContainers.current.length > currentIndex && pixi.textContainers.current.length > nextIndex;
const currentTextContainer = textContainersAvailable ? pixi.textContainers.current[currentIndex] : null;
const nextTextContainer = textContainersAvailable ? pixi.textContainers.current[nextIndex] : null;
currentSlide.visible = true;
nextSlide2.visible = true;
nextSlide2.alpha = 0;
if (nextTextContainer) {
nextTextContainer.alpha = 0;
nextTextContainer.visible = true;
}
const transitionScaleIntensity = props.transitionScaleIntensity ?? 30;
const scaleMultiplier = 1 + transitionScaleIntensity / 100;
const slideOutAnimations = [];
const slideInAnimations = [];
const textOutAnimations = [];
const textInAnimations = [];
slideOutAnimations.push(
gsap.to(currentSlide.scale, {
x: currentSlide.baseScale * scaleMultiplier,
y: currentSlide.baseScale * scaleMultiplier,
duration: 1,
ease: "power2.out",
onComplete: () => {
if (resourceManager) {
resourceManager.trackDisplayObject(currentSlide);
}
}
}),
gsap.to(currentSlide, {
alpha: 0,
duration: 1,
ease: "power2.out",
onComplete: () => {
currentSlide.visible = false;
if (resourceManager) {
resourceManager.trackDisplayObject(currentSlide);
}
}
})
);
slideInAnimations.push(
gsap.to(nextSlide2.scale, {
x: nextSlide2.baseScale,
y: nextSlide2.baseScale,
duration: 1,
ease: "power2.out",
onComplete: () => {
if (resourceManager) {
resourceManager.trackDisplayObject(nextSlide2);
}
}
}),
gsap.to(nextSlide2, {
alpha: 1,
duration: 1,
ease: "power2.out",
onComplete: () => {
if (resourceManager) {
resourceManager.trackDisplayObject(nextSlide2);
}
}
})
);
nextSlide2.scale.set(
nextSlide2.baseScale * scaleMultiplier,
nextSlide2.baseScale * scaleMultiplier
);
if (currentTextContainer && nextTextContainer) {
textOutAnimations.push(
gsap.to(currentTextContainer, {
alpha: 0,
duration: 1,
ease: "power2.out",
onComplete: () => {
currentTextContainer.visible = false;
if (resourceManager) {
resourceManager.trackDisplayObject(currentTextContainer);
}
}
})
);
textInAnimations.push(
gsap.to(nextTextContainer, {
alpha: 1,
duration: 1,
ease: "power2.out",
onComplete: () => {
if (resourceManager) {
resourceManager.trackDisplayObject(nextTextContainer);
}
}
})
);
}
const slideOutGroup = {
id: `slide_out_${currentIndex}_${Date.now()}`,
type: AnimationGroupType.SLIDE_TRANSITION,
animations: slideOutAnimations
};
const slideInGroup = {
id: `slide_in_${nextIndex}_${Date.now()}`,
type: AnimationGroupType.SLIDE_TRANSITION,
animations: slideInAnimations
};
const masterTimeline = gsap.timeline({
onComplete: () => {
pixi.currentIndex.current = nextIndex;
activeTransitionRef.current = null;
if (onSlideChange) {
onSlideChange(nextIndex);
}
if (slidingWindowManager) {
handleSlidingWindowUnload(nextIndex);
}
}
});
const slideOutTimeline = animationCoordinator.createAnimationGroup(slideOutGroup);
const slideInTimeline = animationCoordinator.createAnimationGroup(slideInGroup);
masterTimeline.add(slideOutTimeline, 0);
masterTimeline.add(slideInTimeline, 0);
if (textOutAnimations.length > 0 && textInAnimations.length > 0) {
const textOutGroup = {
id: `text_out_${currentIndex}_${Date.now()}`,
type: AnimationGroupType.TEXT_ANIMATION,
animations: textOutAnimations
};
const textInGroup = {
id: `text_in_${nextIndex}_${Date.now()}`,
type: AnimationGroupType.TEXT_ANIMATION,
animations: textInAnimations
};
const textOutTimeline = animationCoordinator.createAnimationGroup(textOutGroup);
const textInTimeline = animationCoordinator.createAnimationGroup(textInGroup);
masterTimeline.add(textOutTimeline, 0);
masterTimeline.add(textInTimeline, 0);
}
activeTransitionRef.current = masterTimeline;
if (resourceManager) {
resourceManager.trackAnimation(masterTimeline);
}
return masterTimeline;
} catch (error) {
return null;
}
};
const handleSlidingWindowUnload = (currentIndex) => {
if (!slidingWindowManager) return;
const visibilityIndices = slidingWindowManager.getWindowIndices();
pixi.slides.current.forEach((sprite, index) => {
if (sprite._isPlaceholder || sprite._loadingState === "uninitialized") {
return;
}
if (!visibilityIndices.includes(index) && index !== currentIndex) {
const distanceFromWindow = Math.min(
...visibilityIndices.map((visIndex) => Math.abs(index - visIndex))
);
const unloadThreshold = slidingWindowManager.getWindowSize() + 1;
if (distanceFromWindow > unloadThreshold) {
const distanceFromCurrent = Math.abs(index - currentIndex);
if (distanceFromCurrent <= unloadThreshold) {
return;
}
import('../utils/placeholderUtils.js').then(({ createPlaceholderSprite }) => {
if (sliderRef.current && pixi.app.current) {
const sliderWidth = sliderRef.current.clientWidth;
const sliderHeight = sliderRef.current.clientHeight;
const placeholderOptions = {
width: sliderWidth,
height: sliderHeight,
color: 3355443,
showIndex: isDevelopment,
index,
trackWithResourceManager: true,
resourceManager,
renderer: pixi.app.current.renderer
};
const placeholderSprite = createPlaceholderSprite(placeholderOptions);
placeholderSprite.x = sprite.x;
placeholderSprite.y = sprite.y;
placeholderSprite.alpha = sprite.alpha;
placeholderSprite.visible = sprite.visible;
placeholderSprite.baseScale = sprite.baseScale;
placeholderSprite.scale.set(sprite.scale.x, sprite.scale.y);
placeholderSprite._inVisibilityWindow = false;
placeholderSprite._originalTexture = sprite.texture;
if (sprite.parent) {
const parent = sprite.parent;
const spriteIndex = parent.getChildIndex(sprite);
parent.addChildAt(placeholderSprite, spriteIndex);
parent.removeChild(sprite);
}
pixi.slides.current[index] = placeholderSprite;
if (resourceManager) {
resourceManager.trackDisplayObject(sprite);
sprite.destroy({ children: true, texture: false });
} else {
sprite.destroy({ children: true, texture: false });
}
}
});
}
}
});
};
const nextSlide = useCallback((nextIndex) => {
const tl = transitionToSlide(nextIndex);
if (tl && onSlideChange) {
onSlideChange(nextIndex);
}
}, [transitionToSlide, onSlideChange]);
const prevSlide = useCallback((prevIndex) => {
const tl = transitionToSlide(prevIndex);
if (tl && onSlideChange) {
onSlideChange(prevIndex);
}
}, [transitionToSlide, onSlideChange]);
return {
transitionToSlide,
nextSlide,
prevSlide,
isLoading,
loadingProgress
};
};
export { useSlides as default, useSlides };
//# sourceMappingURL=useSlides.js.map