feat: Implement inertial horizontal scrolling for carousel components and enhance text selection handling

This commit is contained in:
2026-05-17 16:22:57 +10:00
parent b9b0dce40b
commit af02b215f4
6 changed files with 424 additions and 11 deletions

View File

@@ -35,6 +35,7 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
size="T400"
priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
data-allow-text-selection="true"
{...props}
ref={ref}
/>

View File

@@ -215,6 +215,8 @@ export const MessageTextBody = recipe({
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
userSelect: 'text',
WebkitUserSelect: 'text',
},
variants: {
preWrap: {

View File

@@ -60,10 +60,12 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
export function PageNavContent({
scrollRef,
scrollProps,
children,
}: {
children: ReactNode;
scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>;
}) {
return (
<Box grow="Yes" direction="Column">
@@ -74,6 +76,7 @@ export function PageNavContent({
size="300"
hideTrack
visibility="Hover"
{...scrollProps}
>
<div className={css.PageNavContent}>{children}</div>
</Scroll>

View File

@@ -52,7 +52,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
import { autoScrollToBottom, editableActiveElement, isScrolledToBottom } from '../../utils/dom';
import {
DefaultPlaceholder,
CompactPlaceholder,
@@ -110,6 +110,7 @@ import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalScroll';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
@@ -651,7 +652,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback(
(mEvt: MatrixEvent) => {
const isOwnMessage = mEvt.getSender() === mx.getUserId();
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
const isBottomPinnedEvent =
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Always update timeline with new message
setTimeline((ct) => ({
@@ -676,10 +679,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setUnreadInfo(getRoomUnreadInfo(room));
}
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = !isOwnMessage;
scrollToBottomRef.current.force = shouldStickToBottom;
// Preserve the pre-update bottom state so newly added chat messages keep the view pinned.
if (shouldStickToBottom) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = !isOwnMessage;
scrollToBottomRef.current.force = true;
}
// Show unread message preview for incoming messages when not at bottom
if (!isOwnMessage && !atBottomRef.current) {
@@ -753,7 +758,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollElement = getScrollElement();
if (!editorBaseEntry || !scrollElement) return;
if (atBottomRef.current) {
if (isScrolledToBottom(scrollElement)) {
autoScrollToBottom(scrollElement, false, true);
}
};
@@ -774,7 +779,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
return;
}
@@ -2023,7 +2028,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
ref={timelineContentRef}
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
style={{
minHeight: '100%',
padding: `${config.space.S600} 0`,
userSelect: 'none',
WebkitUserSelect: 'none',
}}
>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div

View 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,
},
};
}

View File

@@ -57,7 +57,7 @@ import {
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { UnreadBadge } from '../../../components/unread-badge';
type DirectMenuProps = {
requestClose: () => void;
};
@@ -235,7 +235,6 @@ const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
@@ -246,6 +245,7 @@ export function Direct() {
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const scrollRef = useRef<HTMLDivElement>(null);
const reorderTrigger = useRoomListReorder(mx, directs);