forked from 77media/video-flow
181 lines
5.2 KiB
TypeScript
181 lines
5.2 KiB
TypeScript
import React, {
|
|
useRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useCallback,
|
|
forwardRef,
|
|
ReactNode,
|
|
Ref,
|
|
useState,
|
|
} from 'react'
|
|
import gsap from 'gsap'
|
|
import { Draggable } from 'gsap/Draggable'
|
|
import { motion } from 'framer-motion'
|
|
|
|
gsap.registerPlugin(Draggable)
|
|
|
|
export interface HorizontalScrollerProps {
|
|
children: ReactNode[]
|
|
itemWidth?: number | string
|
|
gap?: number
|
|
selectedIndex?: number
|
|
onItemClick?: (index: number) => void
|
|
}
|
|
|
|
export interface HorizontalScrollerRef {
|
|
scrollToIndex: (index: number) => void
|
|
}
|
|
|
|
const HorizontalScroller = forwardRef(
|
|
(
|
|
{
|
|
children,
|
|
itemWidth = 300,
|
|
gap = 16,
|
|
selectedIndex,
|
|
onItemClick,
|
|
}: HorizontalScrollerProps,
|
|
ref: Ref<HorizontalScrollerRef>
|
|
) => {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
const dragInstance = useRef<Draggable | null>(null)
|
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
|
const [currentIndex, setCurrentIndex] = useState(selectedIndex || 0)
|
|
const [isFocused, setIsFocused] = useState(false)
|
|
|
|
const scrollToIndex = useCallback(
|
|
(index: number) => {
|
|
if (!containerRef.current || !wrapperRef.current || index < 0 || index >= children.length) return
|
|
|
|
const containerWidth = containerRef.current.offsetWidth
|
|
let targetX = 0
|
|
|
|
if (itemWidth === 'auto') {
|
|
const itemElement = itemRefs.current[index]
|
|
if (!itemElement) return
|
|
|
|
const itemRect = itemElement.getBoundingClientRect()
|
|
const wrapperRect = wrapperRef.current.getBoundingClientRect()
|
|
targetX = -(itemElement.offsetLeft - (containerWidth - itemRect.width) / 2)
|
|
} else {
|
|
const numericWidth = typeof itemWidth === 'number' ? itemWidth : parseFloat(itemWidth)
|
|
targetX = -(index * (numericWidth + gap) - (containerWidth - numericWidth) / 2)
|
|
}
|
|
|
|
const maxScroll = wrapperRef.current.scrollWidth - containerWidth
|
|
targetX = Math.max(-maxScroll, Math.min(targetX, 0))
|
|
|
|
gsap.to(wrapperRef.current, {
|
|
x: targetX,
|
|
duration: 0.6,
|
|
ease: 'power3.out',
|
|
onUpdate: () => {
|
|
if (dragInstance.current?.update) {
|
|
dragInstance.current.update()
|
|
}
|
|
},
|
|
})
|
|
|
|
setCurrentIndex(index)
|
|
},
|
|
[itemWidth, gap, children.length]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!wrapperRef.current || !containerRef.current) return
|
|
|
|
const maxScroll =
|
|
wrapperRef.current.scrollWidth - containerRef.current.offsetWidth
|
|
|
|
dragInstance.current = Draggable.create(wrapperRef.current, {
|
|
type: 'x',
|
|
edgeResistance: 0.85,
|
|
inertia: true,
|
|
bounds: {
|
|
minX: -maxScroll,
|
|
maxX: 0,
|
|
},
|
|
onDrag: function () {
|
|
gsap.to(wrapperRef.current!, {
|
|
x: this.x,
|
|
duration: 0.3,
|
|
ease: 'power3.out',
|
|
})
|
|
},
|
|
})[0]
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (typeof selectedIndex === 'number') {
|
|
scrollToIndex(selectedIndex)
|
|
}
|
|
}, [selectedIndex, scrollToIndex])
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// 只在容器被聚焦时处理键盘事件
|
|
if (!isFocused) return;
|
|
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault()
|
|
let newIndex = Math.max(0, currentIndex - 1)
|
|
if (newIndex === 0 && currentIndex - 1 < 0) {
|
|
newIndex = children.length - 1
|
|
}
|
|
onItemClick?.(newIndex)
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault()
|
|
let newIndex = Math.min(children.length - 1, currentIndex + 1)
|
|
if (newIndex === children.length - 1 && currentIndex + 1 > children.length - 1) {
|
|
newIndex = 0
|
|
}
|
|
onItemClick?.(newIndex)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [currentIndex, children.length, scrollToIndex, onItemClick, isFocused])
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
scrollToIndex,
|
|
}))
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
tabIndex={0}
|
|
className="relative w-full overflow-auto touch-none cursor-grab active:cursor-grabbing focus:outline-none"
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
>
|
|
<div
|
|
ref={wrapperRef}
|
|
className="flex select-none"
|
|
style={{ gap: `${gap}px` }}
|
|
>
|
|
{React.Children.map(children, (child, index) => (
|
|
<motion.div
|
|
key={index}
|
|
onClick={() => {
|
|
onItemClick?.(index)
|
|
}}
|
|
ref={(el) => (itemRefs.current[index] = el)}
|
|
style={{
|
|
minWidth: itemWidth === 'auto' ? 'auto' : itemWidth,
|
|
flexShrink: 0,
|
|
}}
|
|
className={`p-2 rounded-lg overflow-hidden cursor-pointer hover:scale-[1.03] transition-transform duration-300`}
|
|
>
|
|
{child}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
export default HorizontalScroller
|