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 ) => { const containerRef = useRef(null) const wrapperRef = useRef(null) const dragInstance = useRef(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 (
setIsFocused(true)} onBlur={() => setIsFocused(false)} >
{React.Children.map(children, (child, index) => ( { 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} ))}
) } ) export default HorizontalScroller