fix(android): support back key in menu, dialog and alert widgets, closes #2454 (#2539)

This commit is contained in:
Huang Xin
2025-11-25 15:01:17 +08:00
committed by GitHub
parent 5a54c0fb60
commit 37d56b3205
13 changed files with 211 additions and 77 deletions
@@ -2,8 +2,6 @@ import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
@@ -17,12 +15,12 @@ import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/n
import { createBookFilter, createBookSorter } from '../utils/libraryUtils';
import { formatTitle } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import SelectModeActions from './SelectModeActions';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
@@ -372,72 +370,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<Spinner loading />
</div>
)}
<div
className='fixed bottom-0 left-0 right-0 z-40'
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
{isSelectMode && showSelectModeActions && (
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'bg-base-300 text-base-content text-xs',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
<button
onClick={openSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center gap-1',
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
>
<MdOpenInNew />
<div>{_('Open')}</div>
</button>
<button
onClick={groupSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center gap-1',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<LuFolderPlus />
<div>{_('Group')}</div>
</button>
<button
onClick={openBookDetails}
className={clsx(
'flex flex-col items-center justify-center gap-1',
(selectedBooks.length !== 1 || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
>
<MdInfoOutline />
<div>{_('Details')}</div>
</button>
<button
onClick={deleteSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center gap-1',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<MdDelete className='text-red-500' />
<div className='text-red-500'>{_('Delete')}</div>
</button>
<button
onClick={() => handleSetSelectMode(false)}
className={clsx('flex flex-col items-center justify-center gap-1')}
>
<MdOutlineCancel />
<div>{_('Cancel')}</div>
</button>
</div>
)}
</div>
{isSelectMode && showSelectModeActions && (
<SelectModeActions
selectedBooks={selectedBooks}
safeAreaBottom={safeAreaInsets?.bottom || 0}
onOpen={openSelectedBooks}
onGroup={groupSelectedBooks}
onDetails={openBookDetails}
onDelete={deleteSelectedBooks}
onCancel={() => handleSetSelectMode(false)}
/>
)}
{showGroupingModal && (
<ModalPortal>
<GroupingModal
@@ -24,6 +24,7 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
'dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow',
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
</Menu>
@@ -0,0 +1,98 @@
import clsx from 'clsx';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation';
import { isMd5 } from '@/utils/md5';
interface SelectModeActionsProps {
selectedBooks: string[];
safeAreaBottom: number;
onOpen: () => void;
onGroup: () => void;
onDetails: () => void;
onDelete: () => void;
onCancel: () => void;
}
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
selectedBooks,
safeAreaBottom,
onOpen,
onGroup,
onDetails,
onDelete,
onCancel,
}) => {
const _ = useTranslation();
const hasSelection = selectedBooks.length > 0;
const hasValidBooks = selectedBooks.every((id) => isMd5(id));
const hasSingleSelection = selectedBooks.length === 1;
const divRef = useKeyDownActions({ onCancel });
return (
<div
ref={divRef}
className='fixed bottom-0 left-0 right-0 z-40'
style={{
paddingBottom: `${safeAreaBottom + 16}px`,
}}
>
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'bg-base-300 text-base-content text-xs',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
<button
onClick={onOpen}
className={clsx(
'flex flex-col items-center justify-center gap-1',
(!hasSelection || !hasValidBooks) && 'btn-disabled opacity-50',
)}
>
<MdOpenInNew />
<div>{_('Open')}</div>
</button>
<button
onClick={onGroup}
className={clsx(
'flex flex-col items-center justify-center gap-1',
!hasSelection && 'btn-disabled opacity-50',
)}
>
<LuFolderPlus />
<div>{_('Group')}</div>
</button>
<button
onClick={onDetails}
className={clsx(
'flex flex-col items-center justify-center gap-1',
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
)}
>
<MdInfoOutline />
<div>{_('Details')}</div>
</button>
<button
onClick={onDelete}
className={clsx(
'flex flex-col items-center justify-center gap-1',
!hasSelection && 'btn-disabled opacity-50',
)}
>
<MdDelete className='text-red-500' />
<div className='text-red-500'>{_('Delete')}</div>
</button>
<button onClick={onCancel} className='flex flex-col items-center justify-center gap-1'>
<MdOutlineCancel />
<div>{_('Cancel')}</div>
</button>
</div>
</div>
);
};
export default SelectModeActions;
@@ -231,6 +231,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
'settings-menu dropdown-content no-triangle border-base-100',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
{user ? (
<MenuItem
@@ -85,7 +85,10 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
};
return (
<Menu className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'>
<Menu
className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
onCancel={() => setIsDropdownOpen?.(false)}
>
{viewOptions.map((option) => (
<MenuItem
key={option.value}
@@ -53,8 +53,9 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { setLibrary } = useLibraryStore();
const { hoveredBookKey } = useReaderStore();
const { hoveredBookKey, getView } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
@@ -76,11 +77,14 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, []);
const handleKeyDown = (event: CustomEvent) => {
const view = getView(sideBarBookKey!);
if (event.detail.keyName === 'Back') {
if (getIsSideBarVisible()) {
setSideBarVisible(false);
} else if (getIsNotebookVisible()) {
setNotebookVisible(false);
} else if (view?.history.canGoBack) {
view.history.back();
} else {
eventDispatcher.dispatch('close-reader');
router.back();
@@ -100,15 +104,16 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, [appService?.isAndroidApp]);
useEffect(() => {
if (!appService?.isAndroidApp) return;
eventDispatcher.onSync('native-key-down', handleKeyDown);
if (appService?.isAndroidApp) {
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
return () => {
if (appService?.isAndroidApp) {
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.isAndroidApp, isSideBarVisible, isNotebookVisible]);
}, [appService?.isAndroidApp, sideBarBookKey, isSideBarVisible, isNotebookVisible]);
useEffect(() => {
if (isInitiating.current) return;
@@ -153,6 +153,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
maxWidth: `${window.innerWidth - 40}px`,
marginRight: window.innerWidth < 640 ? '-36px' : '0px',
}}
onCancel={() => setIsDropdownOpen?.(false)}
>
{bookData.bookDoc?.rendition?.layout === 'pre-paginated' && (
<>
@@ -25,6 +25,7 @@ const MobileFooterBar: React.FC<FooterBarChildProps> = ({
<>
<ColorPanel actionTab={actionTab} bottomOffset={bottomOffset} />
<NavigationPanel
bookKey={bookKey}
actionTab={actionTab}
progressFraction={progressFraction}
progressValid={progressValid}
@@ -4,6 +4,7 @@ import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { getNavigationIcon, getNavigationLabel, getNavigationHandler } from './utils';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ViewSettings } from '@/types/book';
import { NavigationHandlers } from './types';
@@ -11,6 +12,7 @@ import Button from '@/components/Button';
import Slider from '@/components/Slider';
interface NavigationPanelProps {
bookKey: string;
actionTab: string;
progressFraction: number;
progressValid: boolean;
@@ -21,6 +23,7 @@ interface NavigationPanelProps {
}
export const NavigationPanel: React.FC<NavigationPanelProps> = ({
bookKey,
actionTab,
progressFraction,
progressValid,
@@ -30,6 +33,8 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
sliderHeight,
}) => {
const _ = useTranslation();
const { getView } = useReaderStore();
const view = getView(bookKey);
const [progressValue, setProgressValue] = React.useState(
progressValid ? progressFraction * 100 : 0,
@@ -99,7 +104,7 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
)}
onClick={navigationHandlers.onGoBack}
label={_('Go Back')}
disabled={!navigationHandlers.onGoBack}
disabled={!view?.history.canGoBack}
/>
<Button
icon={getNavigationIcon(
@@ -109,7 +114,7 @@ export const NavigationPanel: React.FC<NavigationPanelProps> = ({
)}
onClick={navigationHandlers.onGoForward}
label={_('Go Forward')}
disabled={!navigationHandlers.onGoForward}
disabled={!view?.history.canGoForward}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowRightSLine />, <RiArrowLeftSLine />)}
@@ -90,6 +90,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
return (
<Menu
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
label={_('Parallel Read')}
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
const Alert: React.FC<{
title: string;
@@ -9,9 +10,12 @@ const Alert: React.FC<{
onConfirm: () => void;
}> = ({ title, message, onCancel, onConfirm }) => {
const _ = useTranslation();
const divRef = useKeyDownActions({ onCancel, onConfirm });
return (
<div className={clsx('z-[100] flex justify-center px-4')}>
<div
ref={divRef}
role='alert'
className={clsx(
'alert flex items-center justify-between',
+5 -1
View File
@@ -1,15 +1,19 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
interface MenuProps {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
onCancel?: () => void;
}
const Menu: React.FC<MenuProps> = ({ children, className, style }) => {
const Menu: React.FC<MenuProps> = ({ children, className, style, onCancel }) => {
const menuRef = useRef<HTMLDivElement>(null);
useKeyDownActions({ onCancel, elementRef: menuRef });
useEffect(() => {
setTimeout(() => {
if (menuRef.current) {
@@ -0,0 +1,67 @@
import { RefObject, useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useDeviceControlStore } from '@/store/deviceStore';
import { eventDispatcher } from '@/utils/event';
interface UseKeyDownOptions {
onCancel?: () => void;
onConfirm?: () => void;
enabled?: boolean;
elementRef?: RefObject<HTMLElement>;
}
export const useKeyDownActions = ({
onCancel,
onConfirm,
enabled = true,
elementRef: providedRef,
}: UseKeyDownOptions) => {
const { appService } = useEnv();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const internalRef = useRef<HTMLDivElement>(null);
const elementRef = providedRef || internalRef;
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent | CustomEvent) => {
if (event instanceof CustomEvent) {
if (event.detail.keyName === 'Back') {
onCancel?.();
return true;
}
} else {
if (event.key === 'Escape') {
onCancel?.();
} else if (event.key === 'Enter') {
onConfirm?.();
}
event.stopPropagation();
}
return false;
};
window.addEventListener('keydown', handleKeyDown);
if (elementRef.current) {
elementRef.current.addEventListener('keydown', handleKeyDown);
}
if (appService?.isAndroidApp) {
acquireBackKeyInterception?.();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
releaseBackKeyInterception?.();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, appService?.isAndroidApp]);
return internalRef;
};