diff --git a/packages/react-sdk/src/core/components/CallLayout/SpeakerLayout.tsx b/packages/react-sdk/src/core/components/CallLayout/SpeakerLayout.tsx index 860c4352a8..c12425b7cf 100644 --- a/packages/react-sdk/src/core/components/CallLayout/SpeakerLayout.tsx +++ b/packages/react-sdk/src/core/components/CallLayout/SpeakerLayout.tsx @@ -10,6 +10,7 @@ import { } from '../ParticipantView'; import { IconButton } from '../../../components'; import { + useDragToScroll, useHorizontalScrollPosition, useVerticalScrollPosition, } from '../../../hooks'; @@ -88,6 +89,12 @@ export type SpeakerLayoutProps = { * Whether the layout is muted. Defaults to `false`. */ muted?: boolean; + + /** + * Whether to enable drag-to-scroll functionality on the participants bar. + * @default false + */ + enableDragToScroll?: boolean; } & Pick< ParticipantViewProps, 'VideoPlaceholder' | 'PictureInPicturePlaceholder' @@ -109,6 +116,7 @@ export const SpeakerLayout = ({ filterParticipants, pageArrowsVisible = true, muted, + enableDragToScroll = false, }: SpeakerLayoutProps) => { const call = useCall(); const { useParticipants } = useCallStateHooks(); @@ -146,6 +154,9 @@ export const SpeakerLayout = ({ const isOneOnOneCall = allParticipants.length === 2; useSpeakerLayoutSortPreset(call, isOneOnOneCall); + useDragToScroll(participantsBarWrapperElement, { + enabled: enableDragToScroll, + }); let participantsWithAppliedLimit = otherParticipants; diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index 05ab0457a3..8a503dd6c9 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -4,3 +4,4 @@ export * from './useScrollPosition'; export * from './useRequestPermission'; export * from './useDeviceList'; export * from './useModeration'; +export * from './useDragToScroll'; diff --git a/packages/react-sdk/src/hooks/useDragToScroll.ts b/packages/react-sdk/src/hooks/useDragToScroll.ts new file mode 100644 index 0000000000..1988518089 --- /dev/null +++ b/packages/react-sdk/src/hooks/useDragToScroll.ts @@ -0,0 +1,162 @@ +import { useEffect, useRef } from 'react'; + +interface DragScrollState { + isDragging: boolean; + isPointerActive: boolean; + prevX: number; + prevY: number; + velocityX: number; + velocityY: number; + rafId: number; + startX: number; + startY: number; +} + +interface DragToScrollOptions { + decay?: number; + minVelocity?: number; + dragThreshold?: number; + enabled?: boolean; +} + +/** + * Enables drag-to-scroll functionality with momentum scrolling on a scrollable element. + * + * This hook allows users to click and drag to scroll an element, with momentum scrolling + * that continues after the drag ends. The drag only activates after moving beyond a threshold + * distance, which prevents accidental drags from clicks. + * + * @param element - The HTML element to enable drag to scroll on. + * @param options - Options for customizing the drag-to-scroll behavior. + */ +export function useDragToScroll( + element: HTMLElement | null, + options: DragToScrollOptions = {}, +) { + const stateRef = useRef({ + isDragging: false, + isPointerActive: false, + prevX: 0, + prevY: 0, + velocityX: 0, + velocityY: 0, + rafId: 0, + startX: 0, + startY: 0, + }); + + useEffect(() => { + if (!element || !options.enabled) return; + + const { decay = 0.95, minVelocity = 0.5, dragThreshold = 5 } = options; + + const state = stateRef.current; + + const stopMomentum = () => { + if (state.rafId) { + cancelAnimationFrame(state.rafId); + state.rafId = 0; + } + state.velocityX = 0; + state.velocityY = 0; + }; + + const momentumStep = () => { + state.velocityX *= decay; + state.velocityY *= decay; + + element.scrollLeft -= state.velocityX; + element.scrollTop -= state.velocityY; + + if ( + Math.abs(state.velocityX) < minVelocity && + Math.abs(state.velocityY) < minVelocity + ) { + state.rafId = 0; + return; + } + + state.rafId = requestAnimationFrame(momentumStep); + }; + + const onPointerDown = (e: PointerEvent) => { + if (e.pointerType !== 'mouse') return; + + stopMomentum(); + + state.isDragging = false; + state.isPointerActive = true; + + state.prevX = e.clientX; + state.prevY = e.clientY; + state.startX = e.clientX; + state.startY = e.clientY; + }; + + const onPointerMove = (e: PointerEvent) => { + if (e.pointerType !== 'mouse') return; + + if (!state.isPointerActive) return; + + const dx = e.clientX - state.startX; + const dy = e.clientY - state.startY; + + if (!state.isDragging && Math.hypot(dx, dy) > dragThreshold) { + state.isDragging = true; + e.preventDefault(); + } + + if (!state.isDragging) return; + + const moveDx = e.clientX - state.prevX; + const moveDy = e.clientY - state.prevY; + + element.scrollLeft -= moveDx; + element.scrollTop -= moveDy; + + state.velocityX = moveDx; + state.velocityY = moveDy; + + state.prevX = e.clientX; + state.prevY = e.clientY; + }; + + const onPointerUpOrCancel = () => { + const wasDragging = state.isDragging; + + state.isDragging = false; + state.isPointerActive = false; + + state.prevX = 0; + state.prevY = 0; + state.startX = 0; + state.startY = 0; + + if (!wasDragging) { + stopMomentum(); + return; + } + + if (Math.hypot(state.velocityX, state.velocityY) < minVelocity) { + stopMomentum(); + return; + } + + state.rafId = requestAnimationFrame(momentumStep); + }; + + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUpOrCancel); + window.addEventListener('pointercancel', onPointerUpOrCancel); + + return () => { + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUpOrCancel); + window.removeEventListener('pointercancel', onPointerUpOrCancel); + + stopMomentum(); + }; + }, [element, options]); +} diff --git a/sample-apps/react/react-dogfood/hooks/useLayoutSwitcher.ts b/sample-apps/react/react-dogfood/hooks/useLayoutSwitcher.ts index 30ff32fbc5..059153030e 100644 --- a/sample-apps/react/react-dogfood/hooks/useLayoutSwitcher.ts +++ b/sample-apps/react/react-dogfood/hooks/useLayoutSwitcher.ts @@ -36,6 +36,7 @@ export const LayoutMap = { title: 'Speaker [top]', icon: 'layout-speaker-top', props: { + enableDragToScroll: true, participantsBarPosition: 'bottom', ParticipantViewUIBar: DebugParticipantViewUI, ParticipantViewUISpotlight: DebugParticipantViewUI, @@ -46,6 +47,7 @@ export const LayoutMap = { title: 'Speaker [bottom]', icon: 'layout-speaker-bottom', props: { + enableDragToScroll: true, ParticipantViewUIBar: DebugParticipantViewUI, ParticipantViewUISpotlight: DebugParticipantViewUI, participantsBarPosition: 'top', @@ -56,6 +58,7 @@ export const LayoutMap = { title: 'Speaker [left]', icon: 'layout-speaker-left', props: { + enableDragToScroll: true, ParticipantViewUIBar: DebugParticipantViewUI, ParticipantViewUISpotlight: DebugParticipantViewUI, participantsBarPosition: 'right', @@ -66,6 +69,7 @@ export const LayoutMap = { title: 'Speaker [right]', icon: 'layout-speaker-right', props: { + enableDragToScroll: true, participantsBarPosition: 'left', ParticipantViewUIBar: DebugParticipantViewUI, ParticipantViewUISpotlight: DebugParticipantViewUI,