import { Children, forwardRef, useEffect, useImperativeHandle, useRef, type ReactNode, type PointerEvent, type WheelEvent } from 'react'; import { flushSync } from 'react-dom'; import { swipeDirection } from '../lib/gestures'; export type SwipeFeedHandle = { next: (direction: number) => void }; type Props = { children: ReactNode; onChange: (direction: number) => void; disabled: boolean }; /** Three live pages share one moving track, so adjacent videos stay visible during a gesture. */ export const SwipeFeed = forwardRef(function SwipeFeed({ children, onChange, disabled }, ref) { const viewport = useRef(null); const track = useRef(null); const gesture = useRef({ active: false, startY: 0, lastY: 0, lastTime: 0, velocity: 0, moved: false }); const offset = useRef(0); const settling = useRef(false); const suppressClickUntil = useRef(0); const wheelTimer = useRef | null>(null); const wheelCooldown = useRef(0); const finishTimer = useRef | null>(null); useEffect(() => () => { if (wheelTimer.current) clearTimeout(wheelTimer.current); if (finishTimer.current) clearTimeout(finishTimer.current); }, []); function move(y: number) { const height = viewport.current?.clientHeight || 1; offset.current = Math.max(-height, Math.min(height, y)); if (track.current) track.current.style.transform = `translate3d(0, ${offset.current}px, 0)`; } function settle(direction: number) { const node = track.current; if (!node || settling.current) return; settling.current = true; gesture.current.active = false; const height = viewport.current?.clientHeight || 1; const target = -direction * height; const distance = Math.abs(target - offset.current); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const duration = reduced ? 0 : Math.min(460, Math.max(240, distance / height * 420)); node.style.transition = `transform ${duration}ms cubic-bezier(.22,.72,.18,1)`; // Flush the drag position before applying the landing position. node.getBoundingClientRect(); move(target); viewport.current?.setAttribute('data-moving', 'true'); const finish = () => { node.removeEventListener('transitionend', ended); if (finishTimer.current) clearTimeout(finishTimer.current); node.style.transition = 'none'; // Update slide positions and reset the shared track in the same paint. if (direction) flushSync(() => onChange(direction)); move(0); settling.current = false; wheelCooldown.current = performance.now() + 160; viewport.current?.removeAttribute('data-moving'); }; const ended = (event: TransitionEvent) => { if (event.target === node) finish(); }; node.addEventListener('transitionend', ended); finishTimer.current = setTimeout(finish, duration + 50); } useImperativeHandle(ref, () => ({ next(direction) { if (!disabled) settle(Math.sign(direction)); } })); function pointerDown(event: PointerEvent) { if (disabled || settling.current || event.button !== 0 || (event.target as HTMLElement).closest('button:not(.video-hit),input,a')) return; if (wheelTimer.current) clearTimeout(wheelTimer.current); gesture.current = { active: true, startY: event.clientY - offset.current, lastY: event.clientY, lastTime: performance.now(), velocity: 0, moved: false }; (event.target as HTMLElement).setPointerCapture(event.pointerId); } function pointerMove(event: PointerEvent) { const state = gesture.current; if (!state.active) return; const now = performance.now(); const elapsed = now - state.lastTime; if (elapsed > 0) state.velocity = state.velocity * .35 + (event.clientY - state.lastY) / elapsed * .65; state.lastY = event.clientY; state.lastTime = now; const dy = event.clientY - state.startY; if (Math.abs(dy) > 5) state.moved = true; if (state.moved) { event.preventDefault(); suppressClickUntil.current = performance.now() + 350; viewport.current?.setAttribute('data-moving', 'true'); move(dy); } } function pointerUp(event: PointerEvent) { const state = gesture.current; if (!state.active) return; state.active = false; if (!state.moved) return; event.preventDefault(); suppressClickUntil.current = performance.now() + 400; const height = viewport.current?.clientHeight || 1; const velocity = performance.now() - state.lastTime < 100 ? state.velocity : 0; settle(swipeDirection(offset.current, velocity, height)); } function wheel(event: WheelEvent) { if (disabled || event.ctrlKey || Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; if (settling.current || performance.now() < wheelCooldown.current) return; const unit = event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? (viewport.current?.clientHeight || 800) : 1; viewport.current?.setAttribute('data-moving', 'true'); move(offset.current - event.deltaY * unit); if (wheelTimer.current) clearTimeout(wheelTimer.current); wheelTimer.current = setTimeout(() => settle(Math.abs(offset.current) > 35 ? (offset.current < 0 ? 1 : -1) : 0), 90); } return
{ if (gesture.current.active) { gesture.current.active = false; settle(0); } }} onWheel={wheel} onClickCapture={event => { if (performance.now() < suppressClickUntil.current || settling.current) { event.preventDefault(); event.stopPropagation(); } }}>
{Children.toArray(children)}
; });