ux: polish ux of grouping dialog (#370)

This commit is contained in:
Huang Xin
2025-02-13 22:49:59 +01:00
committed by GitHub
parent ed9a0c6f20
commit 95a69d6bdf
6 changed files with 196 additions and 84 deletions
@@ -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>
+7 -7
View File
@@ -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>
+134
View File
@@ -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,
},
};
};