Files

120 lines
5.8 KiB
TypeScript

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<SwipeFeedHandle, Props>(function SwipeFeed({ children, onChange, disabled }, ref) {
const viewport = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | null>(null);
const wheelCooldown = useRef(0);
const finishTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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 <div ref={viewport} className="feed-viewport" aria-label="上下滑动视频流"
onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerUp}
onPointerCancel={() => { 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(); } }}>
<div ref={track} className="feed-track">{Children.toArray(children)}</div>
</div>;
});