120 lines
5.8 KiB
TypeScript
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>;
|
|
});
|