import { RefObject, UIEvent, useCallback, useEffect, useRef, useState } from 'react'; import type { ContextType } from 'react'; import { StoreContext } from '../../../../services/store'; import type { IWidgetEvents } from '../../../../types/events'; type StoreStateUpdater = ContextType['setStoreState']; interface MobileScrollSyncParams { scrollContainerRef: RefObject; itemCount: number; cloneCount: number; visibleContentSize: number; setStoreState: StoreStateUpdater; triggerEvent: IWidgetEvents['triggerEvent']; widgetId: string; } /** * Calculate item width and shoulder visibility for a mobile carousel. * - 1 item is centered and uses 83.33% width so the remaining space acts as shoulders. * - 2+ items use 90% total width for items, with 5% shoulder padding on each side. */ export const useMobileCarouselDimensions = (visibleContentSize: number) => { if (visibleContentSize === 1) { return { itemWidthPercentage: 100 / 1.2, // 1 item = 83.33%, leaves room for shoulders shoulderPercentage: 5, }; } const totalItemSpace = 90; // 2 + items = 90%, leaves 10% for shoulders const itemWidthPercentage = totalItemSpace / visibleContentSize; const shoulderPercentage = 5; return { itemWidthPercentage, shoulderPercentage, }; }; /** * Handles scroll synchronization, position tracking, and infinite loop wrapping */ export const useMobileScrollSync = ({ scrollContainerRef, itemCount, cloneCount, setStoreState, triggerEvent, widgetId, }: MobileScrollSyncParams) => { const [activeIndex, setActiveIndex] = useState(0); const wrapTimeoutRef = useRef(null); const lastScrollLeft = useRef(0); const isScrollingRef = useRef(false); const scrollDirectionRef = useRef<'left' | 'right'>('right'); const lastNativeEventRef = useRef(null); // Track the last real index we dispatched so we only update state when it changes. const lastReportedRealIndex = useRef(-1); // Return the centered item index in the full scroll strip, including clones. const getCurrentIndex = useCallback(() => { if (!scrollContainerRef.current) return 0; const container = scrollContainerRef.current; const { scrollWidth } = container; const { scrollLeft } = container; const containerWidth = container.clientWidth; // Calculate total items including clones (no spacers) const totalItems = cloneCount > 0 ? itemCount + 2 * cloneCount : itemCount; const itemWidth = scrollWidth / totalItems; // Find the centered/first visible item index based on scroll position const centerPosition = scrollLeft + containerWidth / 2; const index = Math.floor(centerPosition / itemWidth); return index; }, [scrollContainerRef, itemCount, cloneCount]); // Handle scroll events const handleScroll = useCallback((event: UIEvent) => { if (!scrollContainerRef.current || cloneCount === 0) { return; } const container = scrollContainerRef.current; scrollDirectionRef.current = container.scrollLeft >= lastScrollLeft.current ? 'right' : 'left'; lastNativeEventRef.current = event.nativeEvent; lastScrollLeft.current = container.scrollLeft; isScrollingRef.current = true; // Update active position as soon as the centered item changes. const currentIdxLive = getCurrentIndex(); const realIndexLive = (((currentIdxLive - cloneCount) % itemCount) + itemCount) % itemCount; if (realIndexLive !== lastReportedRealIndex.current) { lastReportedRealIndex.current = realIndexLive; setActiveIndex(realIndexLive); setStoreState((state) => { if (state.currentPosition === realIndexLive) return state; return { ...state, currentPosition: realIndexLive }; }); } // Clear any pending wrap timeout if (wrapTimeoutRef.current !== null) { window.clearTimeout(wrapTimeoutRef.current); } // Wait until scrolling has settled, then do the clone wrap and // send the native scroll event. The current position is already updated live. wrapTimeoutRef.current = window.setTimeout(() => { if (!scrollContainerRef.current) return; const { scrollWidth } = container; const totalItems = itemCount + 2 * cloneCount; const itemWidth = scrollWidth / totalItems; const currentIdx = getCurrentIndex(); const realIndex = (((currentIdx - cloneCount) % itemCount) + itemCount) % itemCount; // Notify subscribers of the native scroll if (lastNativeEventRef.current) { triggerEvent( 'carouselNativeScroll', { position: realIndex, direction: scrollDirectionRef.current, originalEvent: lastNativeEventRef.current, }, widgetId, ); } // Check if we're in the clone zones and jump back to the real position const inStartClone = currentIdx < cloneCount; const inEndClone = currentIdx >= cloneCount + itemCount; if (inStartClone || inEndClone) { const targetScrollIndex = cloneCount + realIndex; const targetScrollPosition = targetScrollIndex * itemWidth - container.clientWidth / 2 + itemWidth / 2; // Disable smooth scrolling for the wrap container.style.scrollBehavior = 'auto'; container.scrollLeft = targetScrollPosition; // Re-enable smooth scrolling requestAnimationFrame(() => { if (container) { container.style.scrollBehavior = 'smooth'; } }); } isScrollingRef.current = false; wrapTimeoutRef.current = null; }, 250); // 250ms — wait for momentum scroll to settle before wrapping }, [scrollContainerRef, itemCount, cloneCount, getCurrentIndex, isScrollingRef, setStoreState, triggerEvent, widgetId]); // Cleanup timeout on unmount useEffect( () => () => { if (wrapTimeoutRef.current !== null) { window.clearTimeout(wrapTimeoutRef.current); } }, [], ); return { activeIndex, handleScroll, }; };