Add web bookshelf context menu
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user