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
@@ -49,7 +49,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-11 w-full items-center py-2 pr-4 sm:pr-6',
'titlebar h-13 z-10 flex w-full items-center py-2 pr-4 sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -68,7 +68,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
</button>
)}
<div className='relative flex h-7 w-full items-center'>
<div className='relative flex h-9 w-full items-center sm:h-7'>
<span className='absolute left-3 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
@@ -77,7 +77,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
+4 -3
View File
@@ -346,14 +346,14 @@ const LibraryPage = () => {
};
const handleToggleSelectMode = () => {
if (!isSelectMode && appService?.isMobile) {
if (!isSelectMode && appService?.hasHaptics) {
impactFeedback('medium');
}
setIsSelectMode((pre) => !pre);
};
const handleSetSelectMode = (selectMode: boolean) => {
if (selectMode && appService?.isMobile) {
if (selectMode && appService?.hasHaptics) {
impactFeedback('medium');
}
setIsSelectMode(selectMode);
@@ -402,8 +402,9 @@ const LibraryPage = () => {
<div
ref={containerRef}
className={clsx(
'mt-12 flex-grow overflow-auto px-4 sm:px-2',
'scroll-container mt-12 flex-grow overflow-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
)}
>
<Bookshelf
@@ -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;
@@ -1,28 +0,0 @@
import { useCallback } from 'react';
const useDragBar = (handleDragMove: (e: MouseEvent) => void) => {
const handleMouseMove = useCallback(
(e: MouseEvent) => {
handleDragMove(e);
},
[handleDragMove],
);
const handleMouseUp = useCallback(() => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseMove]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[handleMouseMove, handleMouseUp],
);
return { handleMouseDown };
};
export default useDragBar;
+58 -6
View File
@@ -1,8 +1,13 @@
import clsx from 'clsx';
import React, { ReactNode, useEffect } from 'react';
import React, { ReactNode, useEffect, useState } from 'react';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
const DISMISS_THRESHOLD = 100;
const VELOCITY_THRESHOLD = 0.5;
interface DialogProps {
id?: string;
@@ -28,7 +33,10 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const [translateY, setTranslateY] = useState(0);
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
@@ -43,23 +51,67 @@ const Dialog: React.FC<DialogProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
if (!isMobile) return;
const modal = document.querySelector('.modal-box') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
if (modal && overlay) {
modal.style.transition = '';
overlay.style.opacity = `${1 - data.clientY / window.innerHeight}`;
}
setTranslateY((prev) => Math.max(prev + data.deltaY, 0));
};
const handleDragEnd = (velocity: number) => {
const modal = document.querySelector('.modal-box') as HTMLElement;
if (!modal) return;
if (translateY > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
modal.style.transition = `transform ${0.15 / velocity}s ease-out`;
onClose();
setTimeout(() => setTranslateY(0), 300);
if (appService?.hasHaptics) {
impactFeedback('medium');
}
} else {
modal.style.transition = `transform 0.3s ease-out`;
setTranslateY(0);
if (appService?.hasHaptics) {
impactFeedback('medium');
}
}
};
const { handleDragStart } = useDrag(handleDragMove, handleDragEnd);
return (
<dialog
id={id ?? 'dialog'}
open={isOpen}
className={clsx(
'modal sm:min-w-90 z-50 h-full w-full !bg-[rgba(0,0,0,0.2)] sm:w-full',
className,
)}
className={clsx('modal sm:min-w-90 z-50 h-full w-full sm:w-full', className)}
>
<div className='overlay fixed inset-0 z-10 bg-black/30 sm:bg-black/20' />
<div
className={clsx(
'modal-box settings-content flex flex-col rounded-none p-0 sm:rounded-2xl',
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
'h-full max-h-full w-full max-w-full sm:w-[65%] sm:max-w-[600px]',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
boxClassName,
)}
style={{
transform: `translateY(${translateY}px)`,
}}
>
{window.innerWidth < 640 && (
<div
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
)}
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-4'>
{header ? (
header
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useRef } from 'react';
export const useDrag = (
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: number }) => void,
onDragEnd?: (velocity: number) => void,
) => {
const isDragging = useRef(false);
const startX = useRef(0);
const startY = useRef(0);
const lastX = useRef(0);
const lastY = useRef(0);
const startTime = useRef(0);
const handleDragStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
isDragging.current = true;
if ('touches' in e) {
startY.current = e.touches[0]!.clientY;
startX.current = e.touches[0]!.clientX;
} else {
startY.current = e.clientY;
startX.current = e.clientX;
}
startTime.current = performance.now();
const handleMove = (event: MouseEvent | TouchEvent) => {
if (isDragging.current) {
let deltaX = 0;
let deltaY = 0;
let clientX = 0;
let clientY = 0;
if ('touches' in event && event.touches.length > 0) {
const currentTouch = event.touches[0]!;
clientX = currentTouch.clientX;
clientY = currentTouch.clientY;
} else {
const evt = event as MouseEvent;
clientX = evt.clientX;
clientY = evt.clientY;
}
deltaX = clientX - lastX.current;
deltaY = clientY - lastY.current;
lastX.current = clientX;
lastY.current = clientY;
onDragMove({ clientX, clientY, deltaX, deltaY });
}
};
const handleEnd = (event: MouseEvent | TouchEvent) => {
isDragging.current = false;
const endTime = performance.now();
const deltaT = endTime - startTime.current;
const distanceY =
'touches' in event
? event.changedTouches[0]!.clientY - startY.current
: event.clientY - startY.current;
const velocity = distanceY / deltaT;
if (onDragEnd) {
onDragEnd(velocity);
}
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleEnd);
};
window.addEventListener('mousemove', handleMove, { passive: true });
window.addEventListener('mouseup', handleEnd);
window.addEventListener('touchmove', handleMove, { passive: true });
window.addEventListener('touchend', handleEnd);
},
[onDragMove, onDragEnd],
);
return { handleDragStart };
};
@@ -25,7 +25,7 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
const initialX = startEvent.touches[0]!.clientX;
const initialY = startEvent.touches[0]!.clientY;
el.addEventListener('touchmove', handleTouchMove, { passive: false });
el.addEventListener('touchmove', handleTouchMove, { passive: true });
el.addEventListener('touchend', handleTouchEnd);
function handleTouchMove(moveEvent: TouchEvent) {
@@ -16,7 +16,7 @@ export const useScreenWakeLock = (lock: boolean) => {
console.log('Wake lock acquired');
}
} catch (err) {
console.error('Failed to acquire wake lock:', err);
console.info('Failed to acquire wake lock:', err);
}
};
@@ -52,6 +52,7 @@ export abstract class BaseAppService implements AppService {
abstract hasContextMenu: boolean;
abstract hasRoundedWindow: boolean;
abstract hasSafeAreaInset: boolean;
abstract hasHaptics: boolean;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
@@ -126,6 +126,7 @@ export class NativeAppService extends BaseAppService {
hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
@@ -188,6 +188,7 @@ export class WebAppService extends BaseAppService {
hasContextMenu = false;
hasRoundedWindow = false;
hasSafeAreaInset = isPWA();
hasHaptics = false;
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
+1
View File
@@ -204,6 +204,7 @@ foliate-view {
.scroll-container {
overflow-y: scroll;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.scroll-container.hidden-scrollbar {
+1
View File
@@ -29,6 +29,7 @@ export interface AppService {
hasContextMenu: boolean;
hasRoundedWindow: boolean;
hasSafeAreaInset: boolean;
hasHaptics: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isAndroidApp: boolean;