UX enhancements of grid view in mobile platforms (#379)

* ux: context menu on both group and book items, also closes #376

* ui: more compact grid view of bookshelf on mobile platforms
This commit is contained in:
Huang Xin
2025-02-16 01:02:41 +01:00
committed by GitHub
parent 3078b09ba4
commit c46cf5b0bf
11 changed files with 133 additions and 57 deletions
@@ -17,7 +17,6 @@ interface BookItemProps {
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
showBookDetailsModal: (book: Book) => void;
bookContextMenuHandler: (book: Book, e: React.MouseEvent<HTMLDivElement>) => void;
}
const BookItem: React.FC<BookItemProps> = ({
@@ -28,7 +27,6 @@ const BookItem: React.FC<BookItemProps> = ({
handleBookUpload,
handleBookDownload,
showBookDetailsModal,
bookContextMenuHandler,
}) => {
const iconSize15 = useResponsiveSize(15);
const { appService } = useEnv();
@@ -44,9 +42,8 @@ const BookItem: React.FC<BookItemProps> = ({
'book-item flex h-full flex-col',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
onContextMenu={bookContextMenuHandler.bind(null, book)}
>
<div className='bg-base-100 relative aspect-[28/41] shadow-md'>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md'>
<BookCover book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
@@ -89,10 +86,11 @@ const BookItem: React.FC<BookItemProps> = ({
) : (
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onTouchStart={(e) => stopEvent(e)}
onMouseDown={(e) => stopEvent(e)}
onTouchEnd={(e) => stopEvent(e)}
onMouseUp={(e) => stopEvent(e)}
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
@@ -109,10 +107,11 @@ const BookItem: React.FC<BookItemProps> = ({
)}
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onTouchStart={(e) => stopEvent(e)}
onMouseDown={(e) => stopEvent(e)}
onTouchEnd={(e) => stopEvent(e)}
onMouseUp={(e) => stopEvent(e)}
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => showBookDetailsModal(book)}
>
<CiCircleMore size={iconSize15} />
@@ -100,7 +100,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
navigateToLibrary(router);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, showGroupingModal]);
}, [searchParams, libraryBooks, showGroupingModal]);
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
@@ -138,7 +138,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
return (
<div className='bookshelf'>
<div className='transform-wrapper grid flex-1 grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
<div
className={clsx(
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8',
)}
>
{currentBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
@@ -159,7 +164,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
))}
{!navBooksGroup && allBookshelfItems.length > 0 && (
<div
className='border-1 bg-base-100 hover:bg-base-300/50 m-4 flex aspect-[28/41] items-center justify-center'
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
'mx-0 my-4 aspect-[28/41] sm:mx-4',
)}
role='button'
onClick={handleImportBooks}
>
@@ -107,11 +107,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
return available;
}
}
@@ -119,12 +122,10 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
};
const handleBookClick = async (book: Book) => {
if (!(await makeBookAvailable(book))) return;
if (isSelectMode) {
toggleSelection(book.hash);
} else {
setTimeout(() => setLoading(true), 200);
if (!(await makeBookAvailable(book))) return;
navigateToReader(router, [book.hash]);
}
};
@@ -137,24 +138,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}
};
const handleItemContextMenu = (item: BookshelfItem, event: React.MouseEvent) => {
event.preventDefault();
if ('format' in item) {
bookContextMenuHandler(item as Book, event);
}
};
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
const bookContextMenuHandler = async (book: Book) => {
if (!appService?.hasContextMenu) return;
e.preventDefault();
e.stopPropagation();
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const openBookMenuItem = await MenuItem.new({
text: isSelectMode ? _('Select Book') : _('Open Book'),
const selectBookMenuItem = await MenuItem.new({
text: selectedBooks.includes(book.hash) ? _('Deselect Book') : _('Select Book'),
action: async () => {
handleBookClick(book);
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(book.hash);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
@@ -170,6 +163,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
showBookDetailsModal(book);
},
});
const downloadBookMenuItem = await MenuItem.new({
text: _('Download Book'),
action: async () => {
handleBookDownload(book);
},
});
const uploadBookMenuItem = await MenuItem.new({
text: _('Upload Book'),
action: async () => {
@@ -183,14 +182,42 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
},
});
const menu = await Menu.new();
menu.append(openBookMenuItem);
menu.append(selectBookMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
menu.append(uploadBookMenuItem);
if (book.uploadedAt && !book.downloadedAt) {
menu.append(downloadBookMenuItem);
}
if (!book.uploadedAt && book.downloadedAt) {
menu.append(uploadBookMenuItem);
}
menu.append(deleteBookMenuItem);
menu.popup();
};
const groupContextMenuHandler = async (group: BooksGroup) => {
if (!appService?.hasContextMenu) return;
const selectGroupMenuItem = await MenuItem.new({
text: selectedBooks.includes(group.id) ? _('Deselect Group') : _('Select Group'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(group.id);
},
});
const deleteGroupMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
for (const book of group.books) {
await handleBookDelete(book);
}
},
});
const menu = await Menu.new();
menu.append(selectGroupMenuItem);
menu.append(deleteGroupMenuItem);
menu.popup();
};
const { pressing, handlers } = useLongPress({
onLongPress: () => {
if (!isSelectMode) {
@@ -209,19 +236,25 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleGroupClick(item as BooksGroup);
}
},
onContextMenu: () => {
if ('format' in item) {
bookContextMenuHandler(item as Book);
} else {
groupContextMenuHandler(item as BooksGroup);
}
},
});
return (
<div
className={clsx(
'hover:bg-base-300/50 group flex h-full flex-col p-4',
'sm:hover:bg-base-300/50 group flex h-full flex-col px-0 py-4 sm:px-4',
pressing ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
onContextMenu={(event) => handleItemContextMenu(item, event)}
>
<div className='flex-grow'>
{'format' in item ? (
@@ -229,11 +262,10 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={transferProgress}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
@@ -1,4 +1,6 @@
import clsx from 'clsx';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { BooksGroup } from '@/types/book';
import BookCover from './BookCover';
@@ -9,9 +11,15 @@ interface GroupItemProps {
}
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
const { appService } = useEnv();
return (
<div className='group-item flex h-full flex-col'>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center p-2 shadow-md'>
<div
className={clsx(
'group-item flex h-full flex-col',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden p-2 shadow-md'>
<div className='grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'>
{group.books.slice(0, 4).map((book) => (
<div key={book.hash} className='relative aspect-[28/41] h-full w-full'>
@@ -56,9 +56,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-11 w-full items-center py-2 pr-6',
'titlebar z-10 flex h-11 w-full items-center py-2 pr-4 sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-2',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
+1 -1
View File
@@ -384,7 +384,7 @@ const LibraryPage = () => {
<div
ref={containerRef}
className={clsx(
'mt-12 flex-grow overflow-auto px-2',
'mt-12 flex-grow overflow-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
)}
>
@@ -4,6 +4,7 @@ import { useReaderStore } from '@/store/readerStore';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
import { getOSPlatform } from '@/utils/misc';
export const useClickEvent = (
bookKey: string,
@@ -24,10 +25,14 @@ export const useClickEvent = (
const { screenX } = msg.data;
const viewRect = viewElement.getBoundingClientRect();
let windowStartX;
// Currently for tauri APP the window.screenX is always 0
if (isTauriAppPlatform()) {
// Currently for tauri APP the window.screenX is always 0
const windowPosition = await tauriGetWindowLogicalPosition();
windowStartX = windowPosition.x;
if (['android', 'ios'].includes(getOSPlatform())) {
windowStartX = 0;
} else {
const windowPosition = await tauriGetWindowLogicalPosition();
windowStartX = windowPosition.x;
}
} else {
windowStartX = window.screenX;
}
@@ -39,10 +39,13 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const fetchBookDetails = async () => {
const appService = await envConfig.getAppService();
const details = await appService.fetchBookDetails(book, settings);
setBookMeta(details);
setLoading(false);
if (loadingTimeout) clearTimeout(loadingTimeout);
try {
const details = await appService.fetchBookDetails(book, settings);
setBookMeta(details);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
}
};
fetchBookDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
+22 -7
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseLongPressOptions {
onLongPress?: () => void;
onTap?: () => void;
onLongPress?: () => void;
onContextMenu?: () => void;
onCancel?: () => void;
threshold?: number;
moveThreshold?: number;
@@ -21,8 +22,9 @@ interface UseLongPressResult {
}
export const useLongPress = ({
onLongPress,
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
@@ -80,6 +82,14 @@ export const useLongPress = ({
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
// Special case: if we don't have a pointerId or startPos,
// this might be a post-context-menu tap
if (pointerId.current === null && startPosRef.current === null) {
onTap?.();
reset();
return;
}
if (e.pointerId !== pointerId.current) return;
if (!isLongPressTriggered.current && startPosRef.current) {
@@ -105,11 +115,16 @@ export const useLongPress = ({
[onCancel, reset],
);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (isLongPressTriggered.current) {
e.preventDefault();
}
}, []);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
onContextMenu();
}
},
[onContextMenu],
);
useEffect(() => {
return () => {
+6 -1
View File
@@ -36,6 +36,7 @@ import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ProgressHandler } from '@/utils/transfer';
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
export abstract class BaseAppService implements AppService {
osPlatform: string = getOSPlatform();
@@ -338,7 +339,7 @@ export abstract class BaseAppService implements AppService {
} else if (book.url) {
file = await new RemoteFile(book.url).open();
} else {
throw new Error('Book file not found');
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
return { book, file, config: await this.loadBookConfig(book, settings) };
}
@@ -357,6 +358,10 @@ export abstract class BaseAppService implements AppService {
}
async fetchBookDetails(book: Book, settings: SystemSettings) {
const fp = getFilename(book);
if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) {
await this.downloadBook(book);
}
const { file } = (await this.loadBookContent(book, settings)) as BookContent;
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
return bookDoc.metadata;
+1
View File
@@ -0,0 +1 @@
export const BOOK_FILE_NOT_FOUND_ERROR = 'Book file not found';