feat(draggable-panels): Enable touch-screen support

Adds touch-screen support for resizing filmstrip and chat panels to enable tablet and touch-laptop users to adjust panel widths. Previously, drag handles only worked with mouse hover, making panels non-resizable on touch devices.

Changes:
- Implement Pointer Events API for unified mouse/touch handling
- Add touch device detection with screen size threshold
- Make drag handles always visible on touch devices with padding for easier tapping
- Maintain identical visual layout between touch and non-touch versions

Touch devices with sufficiently large screens now have fully functional drag handles with appropriate hit targets while smaller devices remain disabled to preserve mobile UX.
This commit is contained in:
Hristo Terezov
2025-11-05 13:27:42 -06:00
parent 9ea9f4c899
commit 2ab86cffb8
8 changed files with 282 additions and 73 deletions

View File

@@ -4,6 +4,7 @@ import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg';
import { getLocalParticipant, getRemoteParticipants, isPrivateChatEnabledSelf } from '../../../base/participants/functions';
@@ -23,7 +24,16 @@ import {
setUserChatWidth,
toggleChat
} from '../../actions.web';
import { CHAT_SIZE, ChatTabs, OPTION_GROUPCHAT, SMALL_WIDTH_THRESHOLD } from '../../constants';
import {
CHAT_DRAG_HANDLE_HEIGHT,
CHAT_DRAG_HANDLE_OFFSET,
CHAT_DRAG_HANDLE_WIDTH,
CHAT_SIZE,
CHAT_TOUCH_HANDLE_SIZE,
ChatTabs,
OPTION_GROUPCHAT,
SMALL_WIDTH_THRESHOLD
} from '../../constants';
import { getChatMaxSize, getFocusedTab, isChatDisabled } from '../../functions';
import { IChatProps as AbstractProps } from '../../types';
@@ -104,7 +114,12 @@ interface IProps extends AbstractProps {
_width: number;
}
const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme, { _isResizing, width }) => {
const useStyles = makeStyles<{
_isResizing: boolean;
isTouch: boolean;
resizeEnabled: boolean;
width: number;
}>()((theme, { _isResizing, isTouch, resizeEnabled, width }) => {
return {
container: {
backgroundColor: theme.palette.chatBackground,
@@ -115,11 +130,15 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
width: `${width}px`,
zIndex: 300,
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
// On non-touch devices (desktop), show handle on hover
// On touch devices, handle is always visible if resize is enabled
...(!isTouch && {
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
}
}
},
}),
'@media (max-width: 580px)': {
height: '100dvh',
@@ -183,16 +202,23 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
dragHandleContainer: {
height: '100%',
width: '9px',
// Touch devices need larger hit target but positioned to not take extra space
width: isTouch ? `${CHAT_TOUCH_HANDLE_SIZE}px` : `${CHAT_DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'absolute',
cursor: 'col-resize',
display: 'flex',
display: resizeEnabled ? 'flex' : 'none', // Hide if resize not enabled
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden',
right: '4px',
// On touch devices, always visible if resize enabled. On desktop, hidden by default
visibility: (isTouch && resizeEnabled) ? 'visible' : 'hidden',
// Position touch handle centered on offset from edge, maintaining same gap as non-touch
right: isTouch
? `${CHAT_DRAG_HANDLE_OFFSET - Math.floor((CHAT_TOUCH_HANDLE_SIZE - CHAT_DRAG_HANDLE_WIDTH) / 2)}px`
: `${CHAT_DRAG_HANDLE_OFFSET}px`,
top: 0,
// Prevent touch scrolling while dragging
touchAction: 'none',
'&:hover': {
'& .dragHandle': {
@@ -210,10 +236,15 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
},
dragHandle: {
// Keep the same visual appearance on all devices
backgroundColor: theme.palette.icon02,
height: '100px',
width: '3px',
borderRadius: '1px'
height: `${CHAT_DRAG_HANDLE_HEIGHT}px`,
width: `${CHAT_DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px',
// Make more visible when actively shown
...(isTouch && resizeEnabled && {
backgroundColor: theme.palette.icon01
})
},
privateMessageRecipientsList: {
@@ -246,7 +277,10 @@ const Chat = ({
return null;
}
const { classes, cx } = useStyles({ _isResizing, width: _width });
// Detect touch capability and screen size for resize functionality
const isTouch = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const { classes, cx } = useStyles({ _isResizing, width: _width, isTouch, resizeEnabled });
const [ isMouseDown, setIsMouseDown ] = useState(false);
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
const [ dragChatWidth, setDragChatWidth ] = useState<number | null>(null);
@@ -282,16 +316,21 @@ const Chat = ({
}, [ participants, defaultRemoteDisplayName, t, notifyTimestamp ]);
/**
* Handles mouse down on the drag handle.
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mouse down event.
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => {
const onDragHandlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Store the initial mouse position and chat width
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Store the initial pointer position and chat width
setIsMouseDown(true);
setMousePosition(e.clientX);
setDragChatWidth(_width);
@@ -299,7 +338,7 @@ const Chat = ({
// Indicate that resizing is in progress
dispatch(setChatIsResizing(true));
// Add visual feedback that we're dragging
// Add visual feedback that we're dragging (cursor for mouse, not visible on touch)
document.body.style.cursor = 'col-resize';
// Disable text selection during resize
@@ -307,11 +346,12 @@ const Chat = ({
}, [ _width, dispatch ]);
/**
* Drag handle mouse up handler.
* Drag handle pointer up handler.
* Supports both mouse and touch events via Pointer Events API.
*
* @returns {void}
*/
const onDragMouseUp = useCallback(() => {
const onDragPointerUp = useCallback(() => {
if (isMouseDown) {
setIsMouseDown(false);
dispatch(setChatIsResizing(false));
@@ -323,12 +363,13 @@ const Chat = ({
}, [ isMouseDown, dispatch ]);
/**
* Handles drag handle mouse move.
* Handles drag handle pointer move.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mousemove event.
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
const onChatResize = useCallback(throttle((e: MouseEvent) => {
const onChatResize = useCallback(throttle((e: PointerEvent) => {
if (isMouseDown && mousePosition !== null && dragChatWidth !== null) {
// For chat panel resizing on the left edge:
// - Dragging left (decreasing X coordinate) should make the panel wider
@@ -352,14 +393,14 @@ const Chat = ({
// Set up event listeners when component mounts
useEffect(() => {
document.addEventListener('mouseup', onDragMouseUp);
document.addEventListener('mousemove', onChatResize);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointermove', onChatResize);
return () => {
document.removeEventListener('mouseup', onDragMouseUp);
document.removeEventListener('mousemove', onChatResize);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointermove', onChatResize);
};
}, [ onDragMouseUp, onChatResize ]);
}, [ onDragPointerUp, onChatResize ]);
/**
* Sends a text message.
@@ -600,7 +641,7 @@ const Chat = ({
(isMouseDown || _isResizing) && 'visible',
'dragHandleContainer'
) }
onMouseDown = { onDragHandleMouseDown }>
onPointerDown = { onDragHandlePointerDown }>
<div className = { cx(classes.dragHandle, 'dragHandle') } />
</div>
</div> : null