import {useEffect} from 'react' import {type FlatList} from 'react-native' import {ITEM_GAP} from '#/components/images/Gallery/const' import {tween} from '#/components/images/Gallery/tween' import {getOffsetForIndex} from '#/components/images/Gallery/utils' import {IS_WEB_SAFARI} from '#/env' const DRAG_THRESHOLD = 3 const FLICK_DECAY = 0.85 const FLICK_MIN_VELOCITY = 0.1 const ADVANCE_THRESHOLD = 0.15 const FRAME_MS = 1000 / 60 const SETTLE_DURATION = 700 const OVERSCROLL_RESISTANCE = 0.4 const BOUNCE_DURATION = 700 function whichByDistance( itemWidths: Map, currentIndex: number, distance: number, direction: -1 | 1, imageCount: number, ): number { let remaining = distance let i = currentIndex while (remaining > 0 && i >= 0 && i < imageCount) { const w = (itemWidths.get(i) ?? 0) + ITEM_GAP if (remaining > w) { remaining -= w i -= direction } else if (remaining > w * ADVANCE_THRESHOLD) { i -= direction break } else { break } } return Math.max(0, Math.min(i, imageCount - 1)) } export function usePointerHandlers({ flatListRef, itemWidthsRef, currentIndexRef, scrollTo, onSettle, imageCount, }: { flatListRef: React.RefObject itemWidthsRef: React.RefObject> currentIndexRef: React.RefObject scrollTo: (offset: number) => void onSettle: (index: number) => void imageCount: number }) { useEffect(() => { if (imageCount <= 1) return const el = flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null if (!el) return let isDragging = false let isMouseDown = false let startX = 0 let dragScrollLeft = 0 let delta = 0 let prevDelta = 0 let velo = 0 let t = 0 let stopTween: (() => void) | null = null let localIndex = currentIndexRef.current let overscrollX = 0 el.style.cursor = 'grab' const clearOverscroll = () => { overscrollX = 0 el.style.transform = '' } const onMouseDown = (e: MouseEvent) => { e.preventDefault() // prevent native image drag // Cancel any in-progress tween if (stopTween) { stopTween() stopTween = null } clearOverscroll() isMouseDown = true isDragging = false localIndex = currentIndexRef.current startX = e.pageX dragScrollLeft = el.scrollLeft delta = 0 prevDelta = 0 velo = 0 t = e.timeStamp } const onMouseMove = (e: MouseEvent) => { if (!isMouseDown) return const x = e.pageX - startX // Require minimum movement before starting drag if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return if (!isDragging) { isDragging = true el.style.cursor = 'grabbing' el.style.userSelect = 'none' // Blur focused element within the gallery if (el.contains(document.activeElement)) { ;(document.activeElement as HTMLElement)?.blur?.() } } e.preventDefault() // Track velocity const elapsed = e.timeStamp - t || 1 prevDelta = delta delta = x velo = (delta - prevDelta) / (elapsed * FRAME_MS) t = e.timeStamp const desiredScroll = dragScrollLeft - delta const maxScroll = el.scrollWidth - el.clientWidth if (desiredScroll < 0) { // Overscroll at start — rubber band scrollTo(0) overscrollX = desiredScroll * OVERSCROLL_RESISTANCE el.style.transform = `translateX(${-overscrollX}px)` } else if (desiredScroll > maxScroll) { // Overscroll at end — rubber band scrollTo(maxScroll) overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE el.style.transform = `translateX(${-overscrollX}px)` } else { // Normal scroll range scrollTo(desiredScroll) if (overscrollX !== 0) clearOverscroll() } // Update local index from scroll position (only in normal range) if (overscrollX === 0) { const offsetX = desiredScroll let accumulated = 0 for (let i = 0; i < imageCount; i++) { const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP if (offsetX < accumulated + w / 2) { localIndex = i break } accumulated += w if (i === imageCount - 1) localIndex = i } } } const onMouseUp = () => { if (!isMouseDown) return const wasDragging = isDragging isMouseDown = false isDragging = false el.style.cursor = 'grab' el.style.userSelect = '' if (wasDragging) { // Suppress the click that follows mouseup after a drag el.addEventListener('click', e => e.stopPropagation(), { once: true, capture: true, }) if (overscrollX !== 0) { // Bounce back from overscroll const targetIndex = overscrollX > 0 ? imageCount - 1 : 0 const fromOverscroll = overscrollX stopTween = tween( fromOverscroll, 0, BOUNCE_DURATION, )( v => { el.style.transform = `translateX(${-v}px)` }, () => { stopTween = null clearOverscroll() onSettle(targetIndex) }, ) } else { // Normal flick settle let v = Math.abs(velo) let restingDistance = 0 while (v > FLICK_MIN_VELOCITY) { v *= FLICK_DECAY restingDistance += v } const direction: -1 | 1 = delta < 0 ? -1 : 1 const totalDistance = Math.abs(delta) + restingDistance const targetIndex = whichByDistance( itemWidthsRef.current, localIndex, totalDistance, direction, imageCount, ) const from = el.scrollLeft const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) if (from === to) { onSettle(targetIndex) return } stopTween = tween( from, to, SETTLE_DURATION, )( v => { scrollTo(v) }, () => { stopTween = null onSettle(targetIndex) }, ) } } } /* * Safari does not support `overscroll-behavior`, so a horizontal trackpad * swipe over the carousel can trigger the browser's back/forward * navigation gesture. We intercept predominantly-horizontal wheel events * and apply the scroll ourselves, calling preventDefault to suppress the * history-nav gesture. Vertical-dominant wheel events are left untouched so * normal page scroll still works. Chrome/Firefox are covered by the * `overscrollBehaviorX: 'contain'` style on the FlatList. * * Listener must be non-passive so preventDefault is honored. */ const onWheel = (e: WheelEvent) => { if (!IS_WEB_SAFARI) return // Only act on predominantly-horizontal scrolls. Vertical-dominant events // are page scroll and must not be swallowed. if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return e.preventDefault() // Cancel any in-progress settle tween so manual scrolling feels direct. if (stopTween) { stopTween() stopTween = null } if (overscrollX !== 0) clearOverscroll() const maxScroll = el.scrollWidth - el.clientWidth const next = Math.max(0, Math.min(el.scrollLeft + e.deltaX, maxScroll)) scrollTo(next) // Keep the active index in sync so keyboard/lightbox stay correct, but // only settle when it actually changes - onSettle moves focus, which we // don't want to thrash on every wheel tick. let accumulated = 0 let index = 0 for (let i = 0; i < imageCount; i++) { const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP if (next < accumulated + w / 2) { index = i break } accumulated += w if (i === imageCount - 1) index = i } if (index !== localIndex) { localIndex = index onSettle(index) } } el.addEventListener('mousedown', onMouseDown) el.addEventListener('wheel', onWheel, {passive: false}) window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) return () => { el.removeEventListener('mousedown', onMouseDown) el.removeEventListener('wheel', onWheel) window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) if (stopTween) stopTween() clearOverscroll() el.style.cursor = '' el.style.userSelect = '' } }, [ flatListRef, itemWidthsRef, currentIndexRef, scrollTo, onSettle, imageCount, ]) }