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:
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const BOOK_FILE_NOT_FOUND_ERROR = 'Book file not found';
|
||||
Reference in New Issue
Block a user