168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
|
|
import { useSpring, animated } from '@react-spring/web';
|
|
import { useDrag } from '@use-gesture/react';
|
|
import clsx from 'clsx';
|
|
|
|
interface InfiniteCarouselProps {
|
|
children: React.ReactNode;
|
|
height: string;
|
|
speed?: number;
|
|
gap?: number;
|
|
overlay?: ReactNode;
|
|
overlayFunc?: (index: number) => void;
|
|
overlayClassName?: string;
|
|
}
|
|
|
|
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
|
|
children,
|
|
height,
|
|
speed = 20000,
|
|
gap = 16,
|
|
overlay = undefined,
|
|
overlayFunc = undefined,
|
|
overlayClassName = ""
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
|
const itemCount = React.Children.count(children);
|
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
const [itemWidth, setItemWidth] = useState<number>(0);
|
|
const [isInfinite, setIsInfinite] = useState<boolean>(true);
|
|
const dragStartX = useRef<number>(0);
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
if (containerRef.current) {
|
|
const containerWidth = containerRef.current.clientWidth;
|
|
setContainerWidth(containerWidth);
|
|
|
|
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
|
if (firstChild) {
|
|
const childWidth = firstChild.offsetWidth;
|
|
setItemWidth(childWidth);
|
|
|
|
const totalContentWidth = (childWidth + gap) * itemCount - gap;
|
|
setIsInfinite(totalContentWidth > containerWidth);
|
|
}
|
|
}
|
|
};
|
|
|
|
handleResize();
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}, [gap, itemCount]);
|
|
|
|
const totalWidth = (itemWidth + gap) * itemCount;
|
|
|
|
const [{ x }, api] = useSpring(() => ({
|
|
from: { x: 0 },
|
|
to: { x: -totalWidth },
|
|
config: { duration: speed },
|
|
loop: true,
|
|
}));
|
|
|
|
const startAnimation = useCallback(() => {
|
|
if (isInfinite) {
|
|
api.start({
|
|
from: { x: x.get() },
|
|
to: { x: x.get() - totalWidth },
|
|
config: { duration: speed },
|
|
loop: true,
|
|
});
|
|
} else {
|
|
api.stop();
|
|
api.start({ x: 0, immediate: true });
|
|
}
|
|
}, [api, x, totalWidth, speed, isInfinite]);
|
|
|
|
useEffect(() => {
|
|
if (containerWidth > 0 && !isDragging) {
|
|
startAnimation();
|
|
}
|
|
}, [containerWidth, isDragging, startAnimation]);
|
|
|
|
const bind = useDrag(({ down, movement: [mx], first }) => {
|
|
if (!isInfinite) return;
|
|
if (first) {
|
|
setIsDragging(true);
|
|
api.stop();
|
|
dragStartX.current = x.get();
|
|
}
|
|
if (down) {
|
|
let newX = dragStartX.current + mx;
|
|
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
|
|
if (newX > 0) newX -= totalWidth;
|
|
api.start({ x: newX, immediate: true });
|
|
} else {
|
|
setIsDragging(false);
|
|
startAnimation();
|
|
}
|
|
}, {
|
|
filterTaps: true,
|
|
from: () => [x.get(), 0],
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className="overflow-hidden relative select-none"
|
|
style={{ height, touchAction: 'pan-y' }}
|
|
ref={containerRef}
|
|
{...(isInfinite ? bind() : {})}
|
|
>
|
|
<animated.div
|
|
className="flex"
|
|
style={{
|
|
display: 'flex',
|
|
willChange: 'transform',
|
|
transform: isInfinite
|
|
? x.to((x) => `translate3d(${x}px, 0, 0)`)
|
|
: 'none',
|
|
gap: `${gap}px`,
|
|
width: 'fit-content',
|
|
}}
|
|
>
|
|
{React.Children.map(children, (child, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-shrink-0 relative"
|
|
>
|
|
{overlay !== undefined && overlayFunc !== undefined && (
|
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
|
{overlay}
|
|
</div>
|
|
)}
|
|
<div
|
|
className="select-none"
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
{child}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isInfinite && React.Children.map(children, (child, i) => (
|
|
<div
|
|
key={`clone-${i}`}
|
|
className="flex-shrink-0 relative"
|
|
>
|
|
{overlay !== undefined && overlayFunc !== undefined && (
|
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
|
{overlay}
|
|
</div>
|
|
)}
|
|
<div
|
|
className="select-none"
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
{child}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</animated.div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InfiniteCarousel; |