forked from akai/readest
ux: polish ux of grouping dialog (#370)
This commit is contained in:
@@ -10,11 +10,11 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
import GroupingModal from './GroupingModal';
|
||||
|
||||
interface BookshelfProps {
|
||||
@@ -125,6 +125,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
|
||||
const deleteSelectedBooks = () => {
|
||||
setShowSelectModeActions(false);
|
||||
setShowDeleteAlert(true);
|
||||
};
|
||||
|
||||
@@ -175,8 +176,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
{isSelectMode && showSelectModeActions && (
|
||||
<div
|
||||
className={clsx(
|
||||
'text-base-content bg-base-300 mx-auto flex w-fit items-center justify-center',
|
||||
'space-x-6 rounded-lg p-4 shadow-lg',
|
||||
'flex items-center justify-center shadow-lg',
|
||||
'text-base-content bg-base-300 text-sm',
|
||||
'mx-auto w-fit space-x-6 rounded-lg p-4',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -225,14 +227,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<GroupingModal
|
||||
libraryBooks={libraryBooks}
|
||||
selectedBooks={selectedBooks}
|
||||
onConfirm={() => {
|
||||
setShowGroupingModal(false);
|
||||
handleSetSelectMode(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowGroupingModal(false);
|
||||
setShowSelectModeActions(true);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setShowGroupingModal(false);
|
||||
handleSetSelectMode(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -246,8 +248,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
onClickCancel={() => setShowDeleteAlert(false)}
|
||||
onClickConfirm={confirmDelete}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
@@ -97,10 +98,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressStartRef = useRef<number | null>(null);
|
||||
const longPressThreshold = 500;
|
||||
|
||||
const showBookDetailsModal = async (book: Book) => {
|
||||
if (await makeBookAvailable(book)) {
|
||||
handleShowDetailsBook(book);
|
||||
@@ -140,59 +137,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemLongPress = (item: BookshelfItem) => {
|
||||
if (!isSelectMode) {
|
||||
handleSetSelectMode(true);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if ('format' in item) {
|
||||
toggleSelection((item as Book).hash);
|
||||
} else {
|
||||
toggleSelection((item as BooksGroup).id);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleItemTouchStart = (
|
||||
_item: BookshelfItem,
|
||||
event: React.TouchEvent | React.MouseEvent,
|
||||
) => {
|
||||
longPressStartRef.current = event.timeStamp;
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
}
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
longPressStartRef.current = null;
|
||||
}, longPressThreshold);
|
||||
};
|
||||
|
||||
const handleItemTouchEnd = (item: BookshelfItem, event: React.TouchEvent | React.MouseEvent) => {
|
||||
if (
|
||||
longPressStartRef.current &&
|
||||
event.timeStamp - longPressStartRef.current < longPressThreshold
|
||||
) {
|
||||
if ('format' in item) {
|
||||
handleBookClick(item as Book);
|
||||
} else {
|
||||
handleGroupClick(item as BooksGroup);
|
||||
}
|
||||
} else {
|
||||
handleItemLongPress(item);
|
||||
}
|
||||
longPressStartRef.current = null;
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemContextMenu = (item: BookshelfItem, event: React.MouseEvent) => {
|
||||
if (longPressStartRef.current) {
|
||||
event.preventDefault();
|
||||
if ('format' in item) {
|
||||
bookContextMenuHandler(item as Book, event);
|
||||
}
|
||||
event.preventDefault();
|
||||
if ('format' in item) {
|
||||
bookContextMenuHandler(item as Book, event);
|
||||
}
|
||||
longPressStartRef.current = null;
|
||||
};
|
||||
|
||||
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
|
||||
@@ -242,13 +191,37 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
const { pressing, handlers } = useLongPress({
|
||||
onLongPress: () => {
|
||||
if (!isSelectMode) {
|
||||
handleSetSelectMode(true);
|
||||
}
|
||||
if ('format' in item) {
|
||||
toggleSelection((item as Book).hash);
|
||||
} else {
|
||||
toggleSelection((item as BooksGroup).id);
|
||||
}
|
||||
},
|
||||
onTap: () => {
|
||||
if ('format' in item) {
|
||||
handleBookClick(item as Book);
|
||||
} else {
|
||||
handleGroupClick(item as BooksGroup);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
|
||||
onTouchStart={(event) => handleItemTouchStart(item, event)}
|
||||
onTouchEnd={(event) => handleItemTouchEnd(item, event)}
|
||||
onMouseDown={(event) => handleItemTouchStart(item, event)}
|
||||
onMouseUp={(event) => handleItemTouchEnd(item, event)}
|
||||
className={clsx(
|
||||
'hover:bg-base-300/50 group flex h-full flex-col p-4',
|
||||
pressing ? 'scale-95' : 'scale-100',
|
||||
)}
|
||||
style={{
|
||||
transition: 'transform 0.2s',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
{...handlers}
|
||||
onContextMenu={(event) => handleItemContextMenu(item, event)}
|
||||
>
|
||||
<div className='flex-grow'>
|
||||
|
||||
@@ -15,15 +15,15 @@ import { generateGroupsList } from './BookshelfItem';
|
||||
interface GroupingModalProps {
|
||||
libraryBooks: Book[];
|
||||
selectedBooks: string[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
libraryBooks,
|
||||
selectedBooks,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
@@ -226,6 +226,9 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
))}
|
||||
</ul>
|
||||
<div className='mt-6 flex justify-end gap-x-8 p-2'>
|
||||
<button onClick={onCancel} className='flex items-center'>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmGrouping}
|
||||
className={clsx(
|
||||
@@ -235,9 +238,6 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
>
|
||||
{_('Confirm')}
|
||||
</button>
|
||||
<button onClick={onCancel} className='flex items-center'>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
const Alert: React.FC<{
|
||||
title: string;
|
||||
message: string;
|
||||
onClickCancel: () => void;
|
||||
onClickConfirm: () => void;
|
||||
}> = ({ title, message, onClickCancel, onClickConfirm }) => {
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}> = ({ title, message, onCancel, onConfirm }) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className={clsx('z-[100] flex justify-center px-4')}>
|
||||
@@ -34,15 +34,15 @@ const Alert: React.FC<{
|
||||
></path>
|
||||
</svg>
|
||||
<div className=''>
|
||||
<h3 className='font-bold'>{title}</h3>
|
||||
<h3 className='font-sm text-base'>{title}</h3>
|
||||
<div className='text-xs'>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<button className='btn btn-sm' onClick={onClickCancel}>
|
||||
<div className='flex flex-wrap items-center justify-center gap-2'>
|
||||
<button className='btn btn-sm' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button className='btn btn-sm btn-warning' onClick={onClickConfirm}>
|
||||
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
|
||||
{_('Confirm')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -196,10 +196,10 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
onClickCancel={() => {
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
}}
|
||||
onClickConfirm={confirmDelete}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseLongPressOptions {
|
||||
onLongPress?: () => void;
|
||||
onTap?: () => void;
|
||||
onCancel?: () => void;
|
||||
threshold?: number;
|
||||
moveThreshold?: number;
|
||||
}
|
||||
|
||||
interface UseLongPressResult {
|
||||
pressing: boolean;
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerCancel: (e: React.PointerEvent) => void;
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const useLongPress = ({
|
||||
onLongPress,
|
||||
onTap,
|
||||
onCancel,
|
||||
threshold = 500,
|
||||
moveThreshold = 10,
|
||||
}: UseLongPressOptions): UseLongPressResult => {
|
||||
const [pressing, setPressing] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout>();
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerId = useRef<number | null>(null);
|
||||
const isLongPressTriggered = useRef(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPressing(false);
|
||||
isLongPressTriggered.current = false;
|
||||
startPosRef.current = null;
|
||||
pointerId.current = null;
|
||||
clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target instanceof Element) {
|
||||
target.setPointerCapture(e.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
pointerId.current = e.pointerId;
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
isLongPressTriggered.current = false;
|
||||
setPressing(true);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPressTriggered.current = true;
|
||||
onLongPress?.();
|
||||
setPressing(false);
|
||||
}, threshold);
|
||||
},
|
||||
[onLongPress, threshold],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
// Only handle moves for the same pointer that started the press
|
||||
if (e.pointerId !== pointerId.current) return;
|
||||
|
||||
if (startPosRef.current) {
|
||||
const deltaX = Math.abs(e.clientX - startPosRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - startPosRef.current.y);
|
||||
|
||||
if (deltaX > moveThreshold || deltaY > moveThreshold) {
|
||||
onCancel?.();
|
||||
reset();
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveThreshold, onCancel, reset],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerId !== pointerId.current) return;
|
||||
|
||||
if (!isLongPressTriggered.current && startPosRef.current) {
|
||||
onTap?.();
|
||||
}
|
||||
|
||||
reset();
|
||||
},
|
||||
[onTap, reset],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerId !== pointerId.current) return;
|
||||
|
||||
onCancel?.();
|
||||
reset();
|
||||
},
|
||||
[onCancel, reset],
|
||||
);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pressing,
|
||||
handlers: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerCancel: handleCancel,
|
||||
onPointerLeave: handleCancel,
|
||||
onContextMenu: handleContextMenu,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user