feat: Implement inertial horizontal scrolling for carousel components and enhance text selection handling
This commit is contained in:
397
src/app/hooks/useInertialHorizontalScroll.ts
Normal file
397
src/app/hooks/useInertialHorizontalScroll.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
export type UseInertialHorizontalScrollOptions = {
|
||||
axis?: 'x' | 'y';
|
||||
scrollStepRatio?: number;
|
||||
dragThreshold?: number;
|
||||
friction?: number;
|
||||
minVelocity?: number;
|
||||
};
|
||||
|
||||
export type UseInertialHorizontalScrollResult = {
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
isDragging: boolean;
|
||||
canScrollBack: boolean;
|
||||
canScrollForward: boolean;
|
||||
scrollBack: () => void;
|
||||
scrollForward: () => void;
|
||||
scrollProps: {
|
||||
onPointerDown: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onPointerMove: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onPointerUp: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onPointerCancel: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onLostPointerCapture: (evt: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onDragStartCapture: (evt: React.DragEvent<HTMLDivElement>) => void;
|
||||
onClickCapture: (evt: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const TEXT_SELECTION_TARGET_SELECTOR =
|
||||
'[data-allow-text-selection="true"], input, textarea, [contenteditable="true"]';
|
||||
|
||||
type ScrollState = {
|
||||
activePointerId: number | null;
|
||||
startPrimary: number;
|
||||
startScrollPrimary: number;
|
||||
lastPrimary: number;
|
||||
lastTime: number;
|
||||
velocity: number;
|
||||
dragging: boolean;
|
||||
moved: boolean;
|
||||
suppressClick: boolean;
|
||||
animationFrameId: number | null;
|
||||
};
|
||||
|
||||
type PointerLikeEvent = {
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
timeStamp: number;
|
||||
preventDefault?: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: Required<UseInertialHorizontalScrollOptions> = {
|
||||
axis: 'x',
|
||||
scrollStepRatio: 1.3,
|
||||
dragThreshold: 3,
|
||||
friction: 0.95,
|
||||
minVelocity: 0.02,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared hook for drag-scrolling horizontal overflow areas with inertial momentum.
|
||||
*/
|
||||
export function useInertialHorizontalScroll(
|
||||
options: UseInertialHorizontalScrollOptions = {}
|
||||
): UseInertialHorizontalScrollResult {
|
||||
const resolvedOptions = useMemo(
|
||||
() => ({ ...DEFAULT_OPTIONS, ...options }),
|
||||
[options]
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const stateRef = useRef<ScrollState>({
|
||||
activePointerId: null,
|
||||
startPrimary: 0,
|
||||
startScrollPrimary: 0,
|
||||
lastPrimary: 0,
|
||||
lastTime: 0,
|
||||
velocity: 0,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
suppressClick: false,
|
||||
animationFrameId: null,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [canScrollBack, setCanScrollBack] = useState(false);
|
||||
const [canScrollForward, setCanScrollForward] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
|
||||
const isHorizontal = resolvedOptions.axis === 'x';
|
||||
const scrollPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop;
|
||||
const scrollSize = isHorizontal ? scroll.scrollWidth : scroll.scrollHeight;
|
||||
const clientSize = isHorizontal ? scroll.clientWidth : scroll.clientHeight;
|
||||
const maxScrollPrimary = Math.max(scrollSize - clientSize, 0);
|
||||
setCanScrollBack(scrollPrimary > 1);
|
||||
setCanScrollForward(scrollPrimary < maxScrollPrimary - 1);
|
||||
}, [resolvedOptions.axis]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return undefined;
|
||||
|
||||
const handleScroll = () => {
|
||||
updateScrollState();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
updateScrollState();
|
||||
};
|
||||
|
||||
scroll.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
|
||||
return () => {
|
||||
scroll.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [updateScrollState]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const { animationFrameId } = stateRef.current;
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const scrollByStep = useCallback((direction: 1 | -1) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
|
||||
const isHorizontal = resolvedOptions.axis === 'x';
|
||||
const currentPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop;
|
||||
const offsetSize = isHorizontal ? scroll.offsetWidth : scroll.offsetHeight;
|
||||
scroll.scrollTo({
|
||||
...(isHorizontal
|
||||
? { left: currentPrimary + offsetSize / resolvedOptions.scrollStepRatio * direction }
|
||||
: { top: currentPrimary + offsetSize / resolvedOptions.scrollStepRatio * direction }),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [resolvedOptions.axis, resolvedOptions.scrollStepRatio]);
|
||||
|
||||
const stopInertia = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
if (state.animationFrameId !== null) {
|
||||
cancelAnimationFrame(state.animationFrameId);
|
||||
state.animationFrameId = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startInertia = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const isHorizontal = resolvedOptions.axis === 'x';
|
||||
|
||||
const step = (timestamp: number, previousTimestamp: number) => {
|
||||
const currentState = stateRef.current;
|
||||
const currentScroll = scrollRef.current;
|
||||
if (!currentScroll) return;
|
||||
|
||||
const deltaMs = Math.max(timestamp - previousTimestamp, 1);
|
||||
const deltaScroll = currentState.velocity * deltaMs;
|
||||
if (Math.abs(currentState.velocity) < resolvedOptions.minVelocity) {
|
||||
currentState.animationFrameId = null;
|
||||
updateScrollState();
|
||||
return;
|
||||
}
|
||||
|
||||
const before = isHorizontal ? currentScroll.scrollLeft : currentScroll.scrollTop;
|
||||
if (isHorizontal) {
|
||||
currentScroll.scrollLeft = before - deltaScroll;
|
||||
} else {
|
||||
currentScroll.scrollTop = before - deltaScroll;
|
||||
}
|
||||
updateScrollState();
|
||||
|
||||
const after = isHorizontal ? currentScroll.scrollLeft : currentScroll.scrollTop;
|
||||
if (after === before) {
|
||||
currentState.animationFrameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentState.velocity *= Math.pow(resolvedOptions.friction, deltaMs / 16.6667);
|
||||
currentState.animationFrameId = requestAnimationFrame((nextTimestamp) => step(nextTimestamp, timestamp));
|
||||
};
|
||||
|
||||
stopInertia();
|
||||
state.animationFrameId = requestAnimationFrame((timestamp) => step(timestamp, timestamp));
|
||||
}, [resolvedOptions.axis, resolvedOptions.friction, resolvedOptions.minVelocity, stopInertia, updateScrollState]);
|
||||
|
||||
const endDrag = useCallback((pointerId: number | null) => {
|
||||
const state = stateRef.current;
|
||||
if (!state.dragging) return;
|
||||
|
||||
const scroll = scrollRef.current;
|
||||
if (scroll && pointerId !== null && scroll.hasPointerCapture(pointerId)) {
|
||||
scroll.releasePointerCapture(pointerId);
|
||||
}
|
||||
|
||||
if (state.moved) {
|
||||
state.suppressClick = true;
|
||||
startInertia();
|
||||
}
|
||||
|
||||
state.dragging = false;
|
||||
state.activePointerId = null;
|
||||
state.moved = false;
|
||||
state.velocity = 0;
|
||||
setIsDragging(false);
|
||||
}, [startInertia]);
|
||||
|
||||
const processPointerMove = useCallback((evt: PointerLikeEvent) => {
|
||||
const state = stateRef.current;
|
||||
if (!state.dragging || state.activePointerId !== evt.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const isHorizontal = resolvedOptions.axis === 'x';
|
||||
|
||||
const currentPrimary = isHorizontal ? evt.clientX : evt.clientY;
|
||||
const deltaPrimary = currentPrimary - state.startPrimary;
|
||||
const now = evt.timeStamp;
|
||||
const deltaTime = Math.max(now - state.lastTime, 1);
|
||||
|
||||
if (Math.abs(deltaPrimary) >= resolvedOptions.dragThreshold) {
|
||||
state.moved = true;
|
||||
}
|
||||
|
||||
evt.preventDefault?.();
|
||||
|
||||
if (isHorizontal) {
|
||||
scroll.scrollLeft = state.startScrollPrimary - deltaPrimary;
|
||||
} else {
|
||||
scroll.scrollTop = state.startScrollPrimary - deltaPrimary;
|
||||
}
|
||||
|
||||
const rawVelocity = (currentPrimary - state.lastPrimary) / deltaTime;
|
||||
state.velocity = Math.max(Math.min(rawVelocity, 2), -2);
|
||||
state.lastPrimary = currentPrimary;
|
||||
state.lastTime = now;
|
||||
}, [resolvedOptions.axis, resolvedOptions.dragThreshold]);
|
||||
|
||||
const handlePointerDown = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.button !== 0) return;
|
||||
if (!evt.isPrimary) return;
|
||||
|
||||
const state = stateRef.current;
|
||||
if (state.dragging && state.activePointerId === evt.pointerId) return;
|
||||
|
||||
const target = evt.target;
|
||||
if (target instanceof Element && target.closest(TEXT_SELECTION_TARGET_SELECTOR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const isHorizontal = resolvedOptions.axis === 'x';
|
||||
|
||||
stopInertia();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
state.activePointerId = evt.pointerId;
|
||||
state.startPrimary = isHorizontal ? evt.clientX : evt.clientY;
|
||||
state.startScrollPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop;
|
||||
state.lastPrimary = isHorizontal ? evt.clientX : evt.clientY;
|
||||
state.lastTime = evt.timeStamp;
|
||||
state.velocity = 0;
|
||||
state.dragging = true;
|
||||
state.moved = false;
|
||||
state.suppressClick = false;
|
||||
|
||||
try {
|
||||
scroll.setPointerCapture(evt.pointerId);
|
||||
} catch {
|
||||
// Ignore capture failures when browser has already ended pointer interaction.
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
}, [resolvedOptions.axis, stopInertia]);
|
||||
|
||||
const handlePointerMove = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
processPointerMove(evt);
|
||||
}, [processPointerMove]);
|
||||
|
||||
const handlePointerUp = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const state = stateRef.current;
|
||||
if (state.activePointerId !== evt.pointerId) return;
|
||||
endDrag(evt.pointerId);
|
||||
}, [endDrag]);
|
||||
|
||||
const handlePointerCancel = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const state = stateRef.current;
|
||||
if (state.activePointerId !== evt.pointerId) return;
|
||||
endDrag(evt.pointerId);
|
||||
}, [endDrag]);
|
||||
|
||||
const handleLostPointerCapture = useCallback((evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const state = stateRef.current;
|
||||
if (state.activePointerId !== evt.pointerId) return;
|
||||
endDrag(evt.pointerId);
|
||||
}, [endDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowPointerMove = (evt: PointerEvent) => {
|
||||
processPointerMove(evt);
|
||||
};
|
||||
|
||||
const handleWindowPointerUpOrCancel = (evt: PointerEvent) => {
|
||||
const state = stateRef.current;
|
||||
if (state.activePointerId !== evt.pointerId) return;
|
||||
endDrag(evt.pointerId);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleWindowPointerMove, true);
|
||||
window.addEventListener('pointerup', handleWindowPointerUpOrCancel, true);
|
||||
window.addEventListener('pointercancel', handleWindowPointerUpOrCancel, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleWindowPointerMove, true);
|
||||
window.removeEventListener('pointerup', handleWindowPointerUpOrCancel, true);
|
||||
window.removeEventListener('pointercancel', handleWindowPointerUpOrCancel, true);
|
||||
};
|
||||
}, [endDrag, processPointerMove]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalDragTermination = () => {
|
||||
const state = stateRef.current;
|
||||
if (!state.dragging) return;
|
||||
endDrag(state.activePointerId);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
handleGlobalDragTermination();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mouseup', handleGlobalDragTermination, true);
|
||||
window.addEventListener('pointerup', handleGlobalDragTermination, true);
|
||||
window.addEventListener('blur', handleGlobalDragTermination, true);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleGlobalDragTermination, true);
|
||||
window.removeEventListener('pointerup', handleGlobalDragTermination, true);
|
||||
window.removeEventListener('blur', handleGlobalDragTermination, true);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange, true);
|
||||
};
|
||||
}, [endDrag]);
|
||||
|
||||
const handleClickCapture = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const state = stateRef.current;
|
||||
if (!state.suppressClick) return;
|
||||
|
||||
state.suppressClick = false;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDragStartCapture = useCallback((evt: React.DragEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
isDragging,
|
||||
canScrollBack,
|
||||
canScrollForward,
|
||||
scrollBack: () => scrollByStep(-1),
|
||||
scrollForward: () => scrollByStep(1),
|
||||
scrollProps: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerCancel: handlePointerCancel,
|
||||
onLostPointerCapture: handleLostPointerCapture,
|
||||
onDragStartCapture: handleDragStartCapture,
|
||||
onClickCapture: handleClickCapture,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user