import { throttle } from 'lodash-es'; import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { IReduxState } from '../../../app/types'; import { isTouchDevice, shouldEnableResize } from '../../../base/environment/utils'; import { IconCloseLarge } from '../../../base/icons/svg'; import ClickableIcon from '../../../base/ui/components/web/ClickableIcon'; import { close, setCustomPanelIsResizing, setUserCustomPanelWidth } from '../../actions.web'; import { CUSTOM_PANEL_DRAG_HANDLE_HEIGHT, CUSTOM_PANEL_DRAG_HANDLE_OFFSET, CUSTOM_PANEL_DRAG_HANDLE_WIDTH, CUSTOM_PANEL_TOUCH_HANDLE_SIZE, DEFAULT_CUSTOM_PANEL_WIDTH } from '../../constants'; import { getCustomPanelMaxSize, getCustomPanelOpen, isCustomPanelEnabled } from '../../functions'; import CustomPanelContent from './CustomPanelContent'; interface IStylesProps { /** * Whether the panel is currently being resized. */ isResizing: boolean; /** * Whether the device supports touch. */ isTouch: boolean; /** * Whether resize is enabled. */ resizeEnabled: boolean; /** * The current width of the panel. */ width: number; } const useStyles = makeStyles()((theme, { isResizing, isTouch, resizeEnabled, width }) => { return { container: { backgroundColor: theme.palette.ui01, flexShrink: 0, overflow: 'hidden', position: 'relative', transition: isResizing ? undefined : 'width .16s ease-in-out', width: `${width}px`, zIndex: 0, display: 'flex', flexDirection: 'column', height: '100%', // On non-touch devices (desktop), show handle on hover. // On touch devices, handle is always visible if resize is enabled. ...(!isTouch && { '&:hover, &:focus-within': { '& .customPanelDragHandleContainer': { visibility: 'visible' } } }), '@media (max-width: 580px)': { height: '100dvh', position: 'fixed', left: 0, right: 0, top: 0, width: '100%', zIndex: 301 } }, header: { alignItems: 'center', boxSizing: 'border-box', display: 'flex', height: '48px', padding: '12px', justifyContent: 'flex-end', position: 'absolute', top: 0, right: 0, zIndex: 1, '& button': { backgroundColor: 'rgba(0, 0, 0, 0.85)', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.2)', boxShadow: '0 2px 4px rgba(0, 0, 0, 0.3)', '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.95)' } } }, contentContainer: { flex: 1, overflow: 'hidden', position: 'relative', width: '100%', height: '100%' }, dragHandleContainer: { height: '100%', // Touch devices need larger hit target but positioned to not take extra space. width: isTouch ? `${CUSTOM_PANEL_TOUCH_HANDLE_SIZE}px` : `${CUSTOM_PANEL_DRAG_HANDLE_WIDTH}px`, backgroundColor: 'transparent', position: 'absolute', cursor: 'col-resize', display: resizeEnabled ? 'flex' : 'none', alignItems: 'center', justifyContent: 'center', // On touch devices, always visible if resize enabled. On desktop, hidden by default. visibility: (isTouch && resizeEnabled) ? 'visible' : 'hidden', // Position on LEFT edge of panel (custom panel is rightmost in layout). left: isTouch ? `${CUSTOM_PANEL_DRAG_HANDLE_OFFSET - Math.floor((CUSTOM_PANEL_TOUCH_HANDLE_SIZE - CUSTOM_PANEL_DRAG_HANDLE_WIDTH) / 2)}px` : `${CUSTOM_PANEL_DRAG_HANDLE_OFFSET}px`, top: 0, zIndex: 2, // Prevent touch scrolling while dragging. touchAction: 'none', '&:hover': { '& .customPanelDragHandle': { backgroundColor: theme.palette.icon01 } }, '&.visible': { visibility: 'visible', '& .customPanelDragHandle': { backgroundColor: theme.palette.icon01 } } }, dragHandle: { // Keep the same visual appearance on all devices. backgroundColor: theme.palette.icon02, height: `${CUSTOM_PANEL_DRAG_HANDLE_HEIGHT}px`, width: `${CUSTOM_PANEL_DRAG_HANDLE_WIDTH / 3}px`, borderRadius: '1px', // Make more visible when actively shown on touch. ...(isTouch && resizeEnabled && { backgroundColor: theme.palette.icon01 }) } }; }); /** * Custom panel container component that handles resize, close button, * and renders CustomPanelContent inside it. * * @returns {JSX.Element | null} The custom panel or null if not open. */ export default function CustomPanel(): JSX.Element | null { const dispatch = useDispatch(); const enabled = useSelector(isCustomPanelEnabled); const paneOpen = useSelector(getCustomPanelOpen); const panelWidth = useSelector((state: IReduxState) => state['features/custom-panel']?.width?.current ?? DEFAULT_CUSTOM_PANEL_WIDTH); const isResizing = useSelector((state: IReduxState) => state['features/custom-panel']?.isResizing ?? false); const maxPanelWidth = useSelector(getCustomPanelMaxSize); const isTouch = isTouchDevice(); const resizeEnabled = shouldEnableResize(); const { classes, cx } = useStyles({ isResizing, width: panelWidth, isTouch, resizeEnabled }); const [ isMouseDown, setIsMouseDown ] = useState(false); const [ mousePosition, setMousePosition ] = useState(null); const [ dragPanelWidth, setDragPanelWidth ] = useState(null); /** * Handles pointer down on the drag handle. * Supports both mouse and touch events via Pointer Events API. * * @param {React.PointerEvent} e - The pointer down event. * @returns {void} */ const onDragHandlePointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); // 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); setIsMouseDown(true); setMousePosition(e.clientX); setDragPanelWidth(panelWidth); dispatch(setCustomPanelIsResizing(true)); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, [ panelWidth, dispatch ]); /** * Handles pointer up to end drag resize. * * @returns {void} */ const onDragPointerUp = useCallback(() => { if (isMouseDown) { setIsMouseDown(false); dispatch(setCustomPanelIsResizing(false)); document.body.style.cursor = ''; document.body.style.userSelect = ''; } }, [ isMouseDown, dispatch ]); /** * Handles pointer move during drag resize. * Handle is on the LEFT edge, so dragging left (negative diff) widens the panel. * * @param {PointerEvent} e - The pointermove event. * @returns {void} */ const onPanelResize = useCallback(throttle((e: PointerEvent) => { if (isMouseDown && mousePosition !== null && dragPanelWidth !== null) { const diff = e.clientX - mousePosition; // Handle is on LEFT edge: dragging left (negative diff) increases width. const newWidth = Math.max( Math.min(dragPanelWidth - diff, maxPanelWidth), DEFAULT_CUSTOM_PANEL_WIDTH ); if (newWidth !== panelWidth) { dispatch(setUserCustomPanelWidth(newWidth)); } } }, 50, { leading: true, trailing: false }), [ isMouseDown, mousePosition, dragPanelWidth, panelWidth, maxPanelWidth, dispatch ]); // Set up global event listeners for drag tracking. useEffect(() => { document.addEventListener('pointerup', onDragPointerUp); document.addEventListener('pointermove', onPanelResize); return () => { document.removeEventListener('pointerup', onDragPointerUp); document.removeEventListener('pointermove', onPanelResize); }; }, [ onDragPointerUp, onPanelResize ]); /** * Handles closing the custom panel. * * @returns {void} */ const onClosePane = useCallback(() => { dispatch(close()); }, [ dispatch ]); if (!enabled || !paneOpen) { return null; } return (
); }