From 95a69d6bdf49189b7dbf4590c70cbc6ada0ccff6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 13 Feb 2025 22:49:59 +0100 Subject: [PATCH] ux: polish ux of grouping dialog (#370) --- .../src/app/library/components/Bookshelf.tsx | 23 +-- .../app/library/components/BookshelfItem.tsx | 95 +++++-------- .../app/library/components/GroupingModal.tsx | 10 +- apps/readest-app/src/components/Alert.tsx | 14 +- .../src/components/BookDetailModal.tsx | 4 +- apps/readest-app/src/hooks/useLongPress.ts | 134 ++++++++++++++++++ 6 files changed, 196 insertions(+), 84 deletions(-) create mode 100644 apps/readest-app/src/hooks/useLongPress.ts diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 73b89fff..269686fc 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -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 = ({ }; const deleteSelectedBooks = () => { + setShowSelectModeActions(false); setShowDeleteAlert(true); }; @@ -175,8 +176,9 @@ const Bookshelf: React.FC = ({ {isSelectMode && showSelectModeActions && (
)} @@ -246,8 +248,11 @@ const Bookshelf: React.FC = ({ setShowDeleteAlert(false)} - onClickConfirm={confirmDelete} + onCancel={() => { + setShowDeleteAlert(false); + setShowSelectModeActions(true); + }} + onConfirm={confirmDelete} /> )} diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 0cea4926..a33a1a62 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -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 = ({ const { settings } = useSettingsStore(); const { updateBook } = useLibraryStore(); - const longPressTimerRef = useRef | null>(null); - const longPressStartRef = useRef(null); - const longPressThreshold = 500; - const showBookDetailsModal = async (book: Book) => { if (await makeBookAvailable(book)) { handleShowDetailsBook(book); @@ -140,59 +137,11 @@ const BookshelfItem: React.FC = ({ } }; - 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 = ({ 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 (
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)} >
diff --git a/apps/readest-app/src/app/library/components/GroupingModal.tsx b/apps/readest-app/src/app/library/components/GroupingModal.tsx index c4788166..09975f02 100644 --- a/apps/readest-app/src/app/library/components/GroupingModal.tsx +++ b/apps/readest-app/src/app/library/components/GroupingModal.tsx @@ -15,15 +15,15 @@ import { generateGroupsList } from './BookshelfItem'; interface GroupingModalProps { libraryBooks: Book[]; selectedBooks: string[]; - onConfirm: () => void; onCancel: () => void; + onConfirm: () => void; } const GroupingModal: React.FC = ({ libraryBooks, selectedBooks, - onConfirm, onCancel, + onConfirm, }) => { const _ = useTranslation(); const { appService } = useEnv(); @@ -226,6 +226,9 @@ const GroupingModal: React.FC = ({ ))}
+ -
diff --git a/apps/readest-app/src/components/Alert.tsx b/apps/readest-app/src/components/Alert.tsx index a23ae92d..847cdd14 100644 --- a/apps/readest-app/src/components/Alert.tsx +++ b/apps/readest-app/src/components/Alert.tsx @@ -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 (
@@ -34,15 +34,15 @@ const Alert: React.FC<{ >
-

{title}

+

{title}

{message}
-
- -
diff --git a/apps/readest-app/src/components/BookDetailModal.tsx b/apps/readest-app/src/components/BookDetailModal.tsx index a7a12c10..d0407f4d 100644 --- a/apps/readest-app/src/components/BookDetailModal.tsx +++ b/apps/readest-app/src/components/BookDetailModal.tsx @@ -196,10 +196,10 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => { { + onCancel={() => { setShowDeleteAlert(false); }} - onClickConfirm={confirmDelete} + onConfirm={confirmDelete} /> )} diff --git a/apps/readest-app/src/hooks/useLongPress.ts b/apps/readest-app/src/hooks/useLongPress.ts new file mode 100644 index 00000000..cb29dc43 --- /dev/null +++ b/apps/readest-app/src/hooks/useLongPress.ts @@ -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(); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const pointerId = useRef(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, + }, + }; +};