feat(ux): improve pull-down interaction for mobile modal (#440)

* Adjusts overlay background opacity dynamically based on drag position
* Ensures smooth transition as the modal is pulled down
* Enhances UX for mobile users by making dismissal more intuitive
This commit is contained in:
Huang Xin
2025-02-24 01:05:13 +01:00
committed by GitHub
parent 9e7e41a623
commit ccc04825b7
16 changed files with 236 additions and 58 deletions
@@ -1,6 +1,7 @@
import React from 'react';
import { TbLayoutSidebar, TbLayoutSidebarFilled } from 'react-icons/tb';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import Button from '@/components/Button';
@@ -12,6 +13,7 @@ interface SidebarTogglerProps {
const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useSidebarStore();
const { setHoveredBookKey } = useReaderStore();
const handleToggleSidebar = () => {
if (sideBarBookKey === bookKey) {
toggleSideBar();
@@ -19,6 +21,7 @@ const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
setSideBarBookKey(bookKey);
if (!isSideBarVisible) toggleSideBar();
}
setHoveredBookKey('');
};
return (
<Button
@@ -9,11 +9,11 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import useDragBar from '../../hooks/useDragBar';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
@@ -81,12 +81,6 @@ const Notebook: React.FC = ({}) => {
setNotebookEditAnnotation(null);
};
const handleDragMove = (e: MouseEvent) => {
const widthFraction = 1 - e.clientX / window.innerWidth;
const newWidth = Math.max(MIN_NOTEBOOK_WIDTH, Math.min(MAX_NOTEBOOK_WIDTH, widthFraction));
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const handleSaveNote = (selection: TextSelection, note: string) => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
@@ -132,7 +126,13 @@ const Notebook: React.FC = ({}) => {
setNotebookEditAnnotation(null);
};
const { handleMouseDown } = useDragBar(handleDragMove);
const onDragMove = (data: { clientX: number }) => {
const widthFraction = 1 - data.clientX / window.innerWidth;
const newWidth = Math.max(MIN_NOTEBOOK_WIDTH, Math.min(MAX_NOTEBOOK_WIDTH, widthFraction));
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleDragStart } = useDrag(onDragMove);
if (!sideBarBookKey) return null;
@@ -175,7 +175,7 @@ const Notebook: React.FC = ({}) => {
`}</style>
<div
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
onMouseDown={handleDragStart}
/>
<NotebookHeader
isPinned={isNotebookPinned}
@@ -64,6 +64,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
/>
}
label={book.title}
labelClass='max-w-36'
onClick={() => handleParallelView(book.hash)}
/>
))}
@@ -9,11 +9,11 @@ import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useDrag } from '@/hooks/useDrag';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
import useSidebar from '../../hooks/useSidebar';
import useDragBar from '../../hooks/useDragBar';
import SearchBar from './SearchBar';
import SearchResults from './SearchResults';
import useShortcuts from '@/hooks/useShortcuts';
@@ -33,6 +33,7 @@ const SideBar: React.FC<{
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const isMobile = window.innerWidth < 640;
const {
sideBarWidth,
isSideBarPinned,
@@ -42,7 +43,7 @@ const SideBar: React.FC<{
handleSideBarTogglePin,
} = useSidebar(
settings.globalReadSettings.sideBarWidth,
window.innerWidth >= 640 ? settings.globalReadSettings.isSideBarPinned : false,
isMobile ? false : settings.globalReadSettings.isSideBarPinned,
);
const onSearchEvent = async (event: CustomEvent) => {
@@ -79,12 +80,52 @@ const SideBar: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDragMove = (e: MouseEvent) => {
const widthFraction = e.clientX / window.innerWidth;
const handleVerticalDragMove = (data: { clientY: number }) => {
if (!isMobile) return;
const heightFraction = data.clientY / window.innerHeight;
const newTop = Math.max(0.0, Math.min(1, heightFraction));
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
if (sidebar && overlay) {
sidebar.style.top = `${newTop * 100}%`;
overlay.style.opacity = `${1 - heightFraction}`;
}
};
const handleVerticalDragEnd = (velocity: number) => {
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
if (!sidebar || !overlay) return;
if (velocity > 0.5) {
sidebar.style.transition = `top ${0.15 / velocity}s ease-out`;
sidebar.style.top = '100%';
overlay.style.transition = `opacity ${0.15 / velocity}s ease-out`;
overlay.style.opacity = '0';
setTimeout(() => setSideBarVisible(false), 300);
} else {
sidebar.style.transition = 'top 0.3s ease-out';
sidebar.style.top = '0%';
overlay.style.transition = 'opacity 0.3s ease-out';
overlay.style.opacity = '0.8';
}
};
const handleHorizontalDragMove = (data: { clientX: number }) => {
const widthFraction = data.clientX / window.innerWidth;
const newWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, widthFraction));
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleMouseDown } = useDragBar(handleDragMove);
const { handleDragStart: handleVerticalDragStart } = useDrag(
handleVerticalDragMove,
handleVerticalDragEnd,
);
const { handleDragStart: handleHorizontalDragStart } = useDrag(handleHorizontalDragMove);
const handleClickOverlay = () => {
setSideBarVisible(false);
@@ -135,10 +176,27 @@ const SideBar: React.FC<{
.sidebar-container {
width: 100%;
min-width: 100%;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.sidebar-container.open {
top: 0%;
}
.overlay {
transition: opacity 0.3s ease-in-out;
}
}
`}</style>
<div className='flex-shrink-0'>
{isMobile && (
<div
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleVerticalDragStart}
onTouchStart={handleVerticalDragStart}
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
)}
<SidebarHeader
isPinned={isSideBarPinned}
isSearchBarVisible={isSearchBarVisible}
@@ -174,11 +232,14 @@ const SideBar: React.FC<{
)}
<div
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
onMouseDown={handleHorizontalDragStart}
></div>
</div>
{!isSideBarPinned && (
<div className='overlay fixed inset-0 z-10 bg-black/20' onClick={handleClickOverlay} />
<div
className='overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
</>
) : null;