TelegramPWA/src/hooks/scroll/useTopOverscroll.tsx

242 lines
7.8 KiB
TypeScript

import { type ElementRef, useEffect, useRef, useSignal } from '@teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import stopEvent from '../../util/stopEvent';
import useEffectWithPrevDeps from '../useEffectWithPrevDeps';
import useLastCallback from '../useLastCallback';
type State = 'overscroll' | 'animating' | 'normal';
type ActiveScrollContext = {
lastDeltas: number[];
lastAverageDelta: number;
isStartedAtTop: boolean;
resetStartTopAt?: number;
timeout: number | undefined;
};
const LAST_DELTA_COUNT = 7;
const ACTIVE_SCROLL_RESET_TIMEOUT = 100;
const NEW_INPUT_DELTA_THRESHOLD = 7;
const OVERSCROLL_CONTAINER_CLASS = 'no-overscroll';
const NO_TOUCH_CONTAINER_CLASS = 'no-touch';
const TRANSITION_DURATION = 350;
const DRAG_TRIGGER_DISTANCE = 75;
const initialActiveScrollContext: ActiveScrollContext = {
lastDeltas: new Array(LAST_DELTA_COUNT).fill(0),
lastAverageDelta: 0,
isStartedAtTop: false,
resetStartTopAt: undefined,
timeout: undefined,
};
export default function useTopOverscroll({
containerRef,
isOverscrolled,
isDisabled,
onOverscroll,
onReset,
}: {
containerRef: ElementRef<HTMLDivElement>;
isOverscrolled?: boolean;
onOverscroll?: AnyToVoidFunction;
onReset?: AnyToVoidFunction;
isDisabled?: boolean;
},
) {
const [getState, setState] = useSignal<State>('normal');
const activeScrollRef = useRef<ActiveScrollContext>({ ...initialActiveScrollContext });
const transitionTimeoutRef = useRef<number | undefined>();
const touchStartYRef = useRef<number | undefined>();
const triggerOverscroll = useLastCallback(() => {
clearTimeout(transitionTimeoutRef.current);
setState('overscroll');
onOverscroll?.();
});
const triggerReset = useLastCallback(() => {
setState('animating');
transitionTimeoutRef.current = window.setTimeout(() => {
setState('normal');
}, TRANSITION_DURATION);
onReset?.();
});
const scheduleResetActiveScroll = useLastCallback((timeout: number) => {
clearTimeout(activeScrollRef.current.timeout);
activeScrollRef.current.timeout = window.setTimeout(() => {
activeScrollRef.current = { ...initialActiveScrollContext };
}, timeout);
});
const handleWheel = useLastCallback((e: WheelEvent) => {
const container = containerRef.current;
if (!container || e.defaultPrevented) {
return;
}
const { deltaY } = e;
const { scrollTop } = container;
const state = getState();
const activeScroll = activeScrollRef.current;
const lastAverageDelta = activeScroll.lastAverageDelta;
const isStarting = activeScroll.lastDeltas.at(-1) === 0
|| (activeScroll.resetStartTopAt && Date.now() >= activeScroll.resetStartTopAt);
if (scrollTop === 0 && isStarting) {
activeScroll.isStartedAtTop = true;
activeScroll.resetStartTopAt = undefined;
}
const lastDeltas = activeScrollRef.current.lastDeltas.slice(); // Copy
lastDeltas.push(deltaY);
if (lastDeltas.length > LAST_DELTA_COUNT) {
lastDeltas.shift();
}
activeScrollRef.current.lastDeltas = lastDeltas;
const currentAverageDelta = lastDeltas.reduce((a, b) => a + b, 0) / lastDeltas.length;
activeScrollRef.current.lastAverageDelta = currentAverageDelta;
const isNewInput = Math.abs(currentAverageDelta) - Math.abs(lastAverageDelta) > NEW_INPUT_DELTA_THRESHOLD;
scheduleResetActiveScroll(ACTIVE_SCROLL_RESET_TIMEOUT);
// If we're at the top and scrolling up
if (scrollTop === 0 && deltaY < 0 && state !== 'overscroll') {
if (!activeScroll.resetStartTopAt) {
// Schedule delta reset, so we would respond to new input with `isStartedAtTop` flag set
activeScroll.resetStartTopAt = Date.now() + ACTIVE_SCROLL_RESET_TIMEOUT;
}
// Only trigger overscroll on new input, ignore momentum events
if (isNewInput && activeScroll.isStartedAtTop) {
triggerOverscroll();
}
return;
}
// Ignore scroll events during collapse animation
if (state === 'animating' && deltaY > 0) {
stopEvent(e);
return;
}
// If we're overscrolled, any down wheel event should reset
if (state === 'overscroll' && deltaY > 0) {
triggerReset();
stopEvent(e);
return;
}
});
const handleTouchStart = useLastCallback((e: TouchEvent) => {
const container = containerRef.current;
if (!container || e.touches.length !== 1) return;
const { scrollTop } = container;
const state = getState();
// Register touch start position when at top or in overscroll state
if (scrollTop === 0 || state === 'overscroll') {
touchStartYRef.current = e.touches[0].clientY;
}
});
const handleTouchMove = useLastCallback((e: TouchEvent) => {
const container = containerRef.current;
const startY = touchStartYRef.current;
if (!container || startY === undefined || e.touches.length !== 1) return;
const { scrollTop } = container;
const state = getState();
const currentY = e.touches[0].clientY;
const deltaY = currentY - startY;
if (state === 'animating') {
return;
}
// If we're at the top and dragging down by more than trigger distance
if (scrollTop === 0 && deltaY > DRAG_TRIGGER_DISTANCE && state !== 'overscroll') {
triggerOverscroll();
touchStartYRef.current = undefined; // Reset to prevent multiple triggers
return;
}
// If we're overscrolled and dragging up by more than trigger distance, reset
if (state === 'overscroll' && deltaY < -DRAG_TRIGGER_DISTANCE) {
triggerReset();
touchStartYRef.current = undefined; // Reset to prevent multiple triggers
return;
}
});
const handleTouchEnd = useLastCallback(() => {
touchStartYRef.current = undefined;
});
useEffect(() => {
const container = containerRef.current;
if (isDisabled || !container) return;
requestMutation(() => {
container.classList.add(OVERSCROLL_CONTAINER_CLASS);
});
return () => {
requestMutation(() => {
container.classList.remove(OVERSCROLL_CONTAINER_CLASS);
});
};
}, [containerRef, isDisabled]);
useEffect(() => {
const container = containerRef.current;
if (isDisabled || !container) return;
requestMutation(() => {
container.classList.toggle(NO_TOUCH_CONTAINER_CLASS, getState() !== 'normal');
});
return () => {
requestMutation(() => {
container.classList.remove(NO_TOUCH_CONTAINER_CLASS);
});
};
}, [containerRef, isDisabled, getState]);
useEffectWithPrevDeps(([prevIsOverscrolled]) => {
if (prevIsOverscrolled === isOverscrolled) return;
if (!isOverscrolled && getState() === 'animating') {
return; // We're animating towards this state
}
setState(isOverscrolled ? 'overscroll' : 'normal');
}, [isOverscrolled, getState, setState]);
useEffect(() => {
const container = containerRef.current;
if (isDisabled || !container) {
return undefined;
}
container.addEventListener('wheel', handleWheel, { passive: getState() === 'normal' });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
return () => {
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
const activeScroll = activeScrollRef.current;
if (activeScroll?.timeout) clearTimeout(activeScroll.timeout);
};
}, [containerRef, handleWheel, handleTouchStart, handleTouchMove, handleTouchEnd, getState, isDisabled]);
}