From c4621a273e0930a218affdb534d68481c718d986 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 00:38:04 +0800 Subject: [PATCH] Add web bookshelf context menu --- .../app/library/components/BookshelfItem.tsx | 98 +++++++++++++++---- apps/readest-app/src/hooks/useLongPress.ts | 4 +- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 1c7ea0a6..1b3c79fa 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -1,16 +1,19 @@ import clsx from 'clsx'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useLongPress } from '@/hooks/useLongPress'; -import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu'; +import { Menu as TauriMenu } from '@tauri-apps/api/menu'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { eventDispatcher } from '@/utils/event'; import { openExternalUrl } from '@/utils/open'; import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads'; import { getOSPlatform } from '@/utils/misc'; import { throttle } from '@/utils/throttle'; +import { Overlay } from '@/components/Overlay'; +import Menu from '@/components/Menu'; +import MenuItem from '@/components/MenuItem'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; @@ -24,6 +27,17 @@ import BookItem from './BookItem'; import GroupItem from './GroupItem'; import { useOpenBook } from '../hooks/useOpenBook'; +type WebContextMenuItem = { + text: string; + action: () => void | Promise; +}; + +type WebContextMenuState = { + x: number; + y: number; + items: WebContextMenuItem[]; +} | null; + export const generateBookshelfItems = ( books: Book[], parentGroupName: string, @@ -128,6 +142,7 @@ const BookshelfItem: React.FC = ({ const { appService } = useEnv(); const { settings } = useSettingsStore(); const { openBook } = useOpenBook({ setLoading, handleBookDownload }); + const [webContextMenu, setWebContextMenu] = useState(null); const showBookDetailsModal = useCallback(async (book: Book) => { handleShowDetailsBook(book); @@ -157,8 +172,7 @@ const BookshelfItem: React.FC = ({ [isSelectMode, handleLibraryNavigation], ); - const bookContextMenuHandler = async (book: Book) => { - if (!appService?.hasContextMenu) return; + const bookContextMenuHandler = async (book: Book, event?: React.MouseEvent) => { const osPlatform = getOSPlatform(); const fileRevealLabel = FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default; @@ -166,7 +180,7 @@ const BookshelfItem: React.FC = ({ // in a single Menu.new({ items }) call. Appending items one-by-one with // un-awaited Menu.append() promises races on the Tauri IPC boundary and // shuffles the order on every open (issue #4389). - const itemOptions: Record = { + const itemOptions: Record = { select: { text: itemSelected ? _('Deselect Book') : _('Select Book'), action: async () => { @@ -254,16 +268,22 @@ const BookshelfItem: React.FC = ({ }, }, }; - const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]); - const menu = await Menu.new({ items }); - await menu.popup(); + const itemIds = getBookContextMenuItemIds(book).filter( + (id) => appService?.hasContextMenu || id !== 'showInFinder', + ); + const items = itemIds.map((id) => itemOptions[id]); + if (appService?.hasContextMenu) { + const menu = await TauriMenu.new({ items }); + await menu.popup(); + return; + } + if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items }); }; - const groupContextMenuHandler = async (group: BooksGroup) => { - if (!appService?.hasContextMenu) return; + const groupContextMenuHandler = async (group: BooksGroup, event?: React.MouseEvent) => { // Single Menu.new({ items }) call keeps the order deterministic — see the // note in bookContextMenuHandler about the Menu.append() IPC race (#4389). - const items: MenuItemOptions[] = [ + const items: WebContextMenuItem[] = [ { text: itemSelected ? _('Deselect Group') : _('Select Group'), action: async () => { @@ -293,8 +313,12 @@ const BookshelfItem: React.FC = ({ }, }, ]; - const menu = await Menu.new({ items }); - await menu.popup(); + if (appService?.hasContextMenu) { + const menu = await TauriMenu.new({ items }); + await menu.popup(); + return; + } + if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items }); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -330,14 +354,27 @@ const BookshelfItem: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps const handleContextMenu = useCallback( - throttle(() => { + throttle((event?: React.MouseEvent) => { if ('format' in item) { - bookContextMenuHandler(item as Book); + bookContextMenuHandler(item as Book, event); } else { - groupContextMenuHandler(item as BooksGroup); + groupContextMenuHandler(item as BooksGroup, event); } }, 100), - [itemSelected, settings.localBooksDir], + [ + appService?.hasContextMenu, + appService?.isMobileApp, + handleBookDownload, + handleBookUpload, + handleGroupBooks, + handleSetSelectMode, + handleUpdateReadingStatus, + item, + itemSelected, + settings.localBooksDir, + showBookDetailsModal, + toggleSelection, + ], ); const { pressing, handlers } = useLongPress( @@ -348,9 +385,11 @@ const BookshelfItem: React.FC = ({ onTap: () => { handleOpenItem(); }, - onContextMenu: () => { + onContextMenu: (event) => { if (appService?.hasContextMenu) { handleContextMenu(); + } else if (!appService?.isMobileApp) { + handleContextMenu(event); } else if (appService?.isAndroidApp) { handleSelectItem(); } @@ -380,6 +419,29 @@ const BookshelfItem: React.FC = ({ return (
+ {webContextMenu && ( + <> + setWebContextMenu(null)} className='z-40' /> + setWebContextMenu(null)} + > + {webContextMenu.items.map((menuItem) => ( + { + setWebContextMenu(null); + void menuItem.action(); + }} + /> + ))} + + + )}
void; onLongPress?: () => void; - onContextMenu?: () => void; + onContextMenu?: (event: React.MouseEvent) => void; onCancel?: () => void; threshold?: number; moveThreshold?: number; @@ -149,7 +149,7 @@ export const useLongPress = ( e.preventDefault(); e.stopPropagation(); setTimeout(() => { - onContextMenu(); + onContextMenu(e); }, 100); } reset();