Spaces:
Running
Running
File size: 7,156 Bytes
5c85958 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
import React, { useState, useEffect, useMemo } from 'react';
import { Box } from '@mui/material';
// Load all images from reachies/small-top-sided folder dynamically with Vite
const imageModules = import.meta.glob('../assets/reachies/small-top-sided/*.png', { eager: true });
/**
* Component that loads all PNG images from reachies/small-top-sided folder,
* stores them in memory and displays them in sequence with overlapping fade transition.
*
* Images are loaded dynamically and displayed one after another
* in a fixed frame, with a fade in/out transition between each image.
*/
export default function ReachiesCarousel({
width = 100,
height = 100,
interval = 1000, // Display duration of each image in ms (faster)
transitionDuration = 150, // Fade transition duration in ms (very sharp) - DEPRECATED, use fadeInDuration and fadeOutDuration
fadeInDuration = 350, // Fade-in duration for incoming image (slower, Apple/Google style)
fadeOutDuration = 120, // Fade-out duration for outgoing image (faster, Apple/Google style)
zoom = 1.8, // Zoom factor to enlarge the sticker
verticalAlign = 'center', // Vertical alignment: 'top', 'center', 'bottom', or percentage (e.g.: '60%')
darkMode = false,
sx = {}
}) {
// Extract URLs of loaded images and sort them for consistent order
const imagePaths = useMemo(() => {
const paths = Object.values(imageModules)
.map(module => {
// With eager: true, module is already loaded, access .default
return typeof module === 'object' && module !== null && 'default' in module
? module.default
: module;
})
.filter(Boolean) // Filter null/undefined values
.sort(); // Sort for consistent order
return paths;
}, []);
const [currentIndex, setCurrentIndex] = useState(0);
const [previousIndex, setPreviousIndex] = useState(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const [fadeOutComplete, setFadeOutComplete] = useState(false);
// Preload all images in memory for smooth transitions
useEffect(() => {
imagePaths.forEach(imagePath => {
const img = new Image();
img.src = imagePath;
});
}, [imagePaths]);
// Function to get a random index different from current
const getRandomIndex = (currentIdx, total) => {
if (total <= 1) return 0;
let newIndex;
do {
newIndex = Math.floor(Math.random() * total);
} while (newIndex === currentIdx && total > 1);
return newIndex;
};
// Automatically change image with overlap and random selection
useEffect(() => {
if (imagePaths.length > 0) {
const timer = setInterval(() => {
// Save previous index BEFORE changing to guarantee crossfade
const prevIdx = currentIndex;
setPreviousIndex(prevIdx);
setIsTransitioning(true);
setFadeOutComplete(false); // Reset at start of transition
// Select a random image different from current
const newIndex = getRandomIndex(currentIndex, imagePaths.length);
setCurrentIndex(newIndex);
// Outgoing image starts disappearing after a delay to create more overlap
// Both images remain visible together longer
const overlapDelay = Math.min(fadeInDuration * 0.4, fadeOutDuration * 2); // 40% of fade-in or 2x fade-out
setTimeout(() => {
setFadeOutComplete(true);
}, overlapDelay);
// Reset transition state after longest duration (fade-in)
setTimeout(() => {
setIsTransitioning(false);
setPreviousIndex(null);
setFadeOutComplete(false);
}, Math.max(fadeInDuration, fadeOutDuration));
}, interval);
return () => clearInterval(timer);
}
}, [imagePaths.length, interval, currentIndex, fadeInDuration, fadeOutDuration]);
if (imagePaths.length === 0) {
return (
<Box
sx={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...sx,
}}
/>
);
}
return (
<Box
sx={{
position: 'relative',
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // Prevent zoom overflow
...sx,
}}
>
{imagePaths.map((imageSrc, index) => {
const isActive = index === currentIndex;
const isPrevious = index === previousIndex && isTransitioning;
// Calculate vertical position according to alignment
let topValue, transformY;
if (verticalAlign === 'top') {
topValue = 0;
transformY = '0';
} else if (verticalAlign === 'bottom') {
topValue = '100%';
transformY = '-100%';
} else if (typeof verticalAlign === 'string' && verticalAlign.includes('%')) {
// Custom percentage
topValue = verticalAlign;
transformY = '-50%';
} else {
// Default: center
topValue = '50%';
transformY = '-50%';
}
// Crossfade style Apple/Google: outgoing disappears faster than incoming appears
const baseOpacity = darkMode ? 0.8 : 0.9;
let opacity = 0;
let transitionStyle = 'none';
// Crossfade logic: both images must be visible simultaneously
if (isActive) {
// Incoming image: slow and progressive fade-in (premium style)
opacity = baseOpacity;
transitionStyle = `opacity ${fadeInDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`; // Smooth ease-out
} else if (isPrevious) {
// Outgoing image: fast fade-out (disappears quickly to make room)
// Starts visible, then disappears after fadeOutDuration
opacity = fadeOutComplete ? 0 : baseOpacity;
transitionStyle = `opacity ${fadeOutDuration}ms cubic-bezier(0.4, 0, 1, 1)`; // More aggressive ease-out
}
// Otherwise opacity stays at 0 (invisible)
return (
<Box
key={`${imageSrc}-${index}`} // Unique key to force re-render
component="img"
src={imageSrc}
alt={`Reachy ${index + 1}`}
sx={{
position: 'absolute',
width: width * zoom,
height: height * zoom,
objectFit: 'cover',
objectPosition: 'center top', // Align top of image to top
opacity,
transform: `translate(-50%, ${transformY})`, // No scale
transition: transitionStyle,
pointerEvents: 'none',
// Position zoomed image with custom vertical alignment
left: '50%',
top: topValue,
zIndex: isActive ? 2 : (isPrevious ? 1 : 0), // Active image on top
willChange: 'opacity', // GPU optimization
backfaceVisibility: 'hidden', // Avoid rendering artifacts
WebkitBackfaceVisibility: 'hidden',
}}
/>
);
})}
</Box>
);
}
|