Add web bookshelf context menu

This commit is contained in:
Codex
2026-07-09 00:38:04 +08:00
parent c410b405b9
commit c4621a273e
2 changed files with 82 additions and 20 deletions
@@ -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<void>;
};
type WebContextMenuState = {
x: number;
y: number;
items: WebContextMenuItem[];
} | null;
export const generateBookshelfItems = (
books: Book[],
parentGroupName: string,
@@ -128,6 +142,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { openBook } = useOpenBook({ setLoading, handleBookDownload });
const [webContextMenu, setWebContextMenu] = useState<WebContextMenuState>(null);
const showBookDetailsModal = useCallback(async (book: Book) => {
handleShowDetailsBook(book);
@@ -157,8 +172,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
[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<BookshelfItemProps> = ({
// 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<BookContextMenuItemId, MenuItemOptions> = {
const itemOptions: Record<BookContextMenuItemId, WebContextMenuItem> = {
select: {
text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => {
@@ -254,16 +268,22 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
},
},
};
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<BookshelfItemProps> = ({
},
},
];
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<BookshelfItemProps> = ({
// 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<BookshelfItemProps> = ({
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<BookshelfItemProps> = ({
return (
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
{webContextMenu && (
<>
<Overlay onDismiss={() => setWebContextMenu(null)} className='z-40' />
<Menu
className='bg-base-100 border-base-300 fixed z-50 min-w-56 rounded-lg border p-1 shadow-xl'
style={{ left: webContextMenu.x, top: webContextMenu.y }}
onCancel={() => setWebContextMenu(null)}
>
{webContextMenu.items.map((menuItem) => (
<MenuItem
key={menuItem.text}
label={menuItem.text}
noIcon
transient
onClick={() => {
setWebContextMenu(null);
void menuItem.action();
}}
/>
))}
</Menu>
</>
)}
<div
className={clsx(
'visible-focus-inset-2 group',
+2 -2
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
interface UseLongPressOptions {
onTap?: () => 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();