feat(shortcut): add shortcuts for annotation tools, closes #2270 (#2548)

This commit is contained in:
Huang Xin
2025-11-25 23:10:18 +08:00
committed by GitHub
parent 0c51a625f3
commit 514780a572
13 changed files with 127 additions and 29 deletions
@@ -37,6 +37,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
const containerRef = useRef<HTMLDivElement>(null);
const [gridRect, setGridRect] = useState<DOMRect | null>(null);
const [responsiveWidth, setResponsiveWidth] = useState(popupWidth);
@@ -115,6 +116,12 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
useEffect(() => {
if (showPopup) {
containerRef.current?.focus();
}
}, [showPopup]);
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(popupHeight);
@@ -231,14 +238,16 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}, [footnoteRef]);
return (
<div>
<div ref={containerRef} role='toolbar' tabIndex={-1}>
{showPopup && <Overlay onDismiss={handleDismissPopup} />}
<Popup
isOpen={showPopup}
width={responsiveWidth}
height={responsiveHeight}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
onDismiss={handleDismissPopup}
>
<div
className=''
@@ -24,6 +24,7 @@ interface AnnotationPopupProps {
popupWidth: number;
popupHeight: number;
onHighlight: (update?: boolean) => void;
onDismiss?: () => void;
}
const OPTIONS_HEIGHT_PIX = 28;
@@ -41,6 +42,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
popupWidth,
popupHeight,
onHighlight,
onDismiss,
}) => {
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
@@ -53,6 +55,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 text-white'
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
<div
className={clsx(
@@ -51,6 +51,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const containerRef = React.useRef<HTMLDivElement>(null);
const [selection, setSelection] = useState<TextSelection | null>(null);
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
@@ -373,6 +375,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [progress]);
const handleShowAnnotPopup = () => {
if (!appService?.isMobile) {
containerRef.current?.focus();
}
setShowAnnotPopup(true);
setShowDeepLPopup(false);
setShowWiktionaryPopup(false);
@@ -421,13 +426,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
};
const handleHighlight = (update = false) => {
const handleHighlight = (update = false, highlightStyle?: HighlightStyle) => {
if (!selection || !selection.text) return;
setHighlightOptionsVisible(true);
const { booknotes: annotations = [] } = config;
const cfi = view?.getCFI(selection.index, selection.range);
if (!cfi) return;
const style = settings.globalReadSettings.highlightStyle;
const style = highlightStyle || settings.globalReadSettings.highlightStyle;
const color = settings.globalReadSettings.highlightStyles[style];
const annotation: BookNote = {
id: uniqueId(),
@@ -479,7 +484,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleSearch = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
handleDismissPopupAndSelection();
eventDispatcher.dispatch('search', { term: selection.text });
};
@@ -510,20 +515,32 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
useShortcuts(
{
onHighlightSelection: () => {
handleHighlight(false, 'highlight');
},
onUnderlineSelection: () => {
handleHighlight(false, 'underline');
},
onAnnotateSelection: () => {
handleAnnotate();
},
onSearchSelection: () => {
handleSearch();
},
onCopySelection: () => {
handleCopy();
},
onTranslateSelection: () => {
if (selection?.text) {
handleTranslation();
}
handleTranslation();
},
onDictionarySelection: () => {
if (selection?.text) {
handleDictionary();
}
handleDictionary();
},
onWikipediaSelection: () => {
if (selection?.text) {
handleWikipedia();
}
handleWikipedia();
},
onReadAloudSelection: () => {
handleSpeakText();
},
},
[selection?.text],
@@ -647,7 +664,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
];
return (
<div>
<div ref={containerRef} role='toolbar' tabIndex={-1}>
{showWiktionaryPopup && trianglePosition && dictPopupPosition && (
<WiktionaryPopup
word={selection?.text as string}
@@ -656,6 +673,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showWikipediaPopup && trianglePosition && dictPopupPosition && (
@@ -666,6 +684,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
@@ -675,6 +694,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
trianglePosition={trianglePosition}
popupWidth={transPopupWidth}
popupHeight={transPopupHeight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showAnnotPopup && trianglePosition && annotPopupPosition && (
@@ -690,6 +710,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupWidth={annotPopupWidth}
popupHeight={annotPopupHeight}
onHighlight={handleHighlight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
</div>
@@ -25,6 +25,7 @@ interface TranslatorPopupProps {
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
}
interface TranslatorType {
@@ -38,6 +39,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
trianglePosition,
popupWidth,
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const { token } = useAuth();
@@ -141,6 +143,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
position={position}
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
<div className='overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
@@ -9,6 +9,7 @@ interface WikipediaPopupProps {
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
}
const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
@@ -18,6 +19,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
trianglePosition,
popupWidth,
popupHeight,
onDismiss,
}) => {
const isLoading = useRef(false);
@@ -109,6 +111,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
position={position}
trianglePosition={trianglePosition}
className='select-text'
onDismiss={onDismiss}
>
<div className='text-base-content flex h-full flex-col pt-2'>
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
@@ -20,6 +20,7 @@ interface WiktionaryPopupProps {
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onDismiss?: () => void;
}
const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
@@ -29,6 +30,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
trianglePosition,
popupWidth,
popupHeight,
onDismiss,
}) => {
const [lookupWord, setLookupWord] = useState(word);
const isLookingUp = useRef(false);
@@ -164,6 +166,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
height={popupHeight}
position={position}
className='select-text'
onDismiss={onDismiss}
>
<div className='flex h-full flex-col'>
<main className='flex-grow overflow-y-auto p-4 font-sans' />
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -16,6 +16,7 @@ import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { Overlay } from '@/components/Overlay';
import useShortcuts from '@/hooks/useShortcuts';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
@@ -50,6 +51,15 @@ const Notebook: React.FC = ({}) => {
}
};
const handleHideNotebook = useCallback(() => {
if (!isNotebookPinned) {
setNotebookVisible(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNotebookPinned]);
useShortcuts({ onEscape: handleHideNotebook }, [handleHideNotebook]);
useEffect(() => {
if (isNotebookVisible) {
updateAppTheme('base-200');
@@ -40,6 +40,7 @@ const SideBar: React.FC<{
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const searchTermRef = useRef(searchTerm);
const sidebarHeight = useRef(1.0);
const isMobile = window.innerWidth < 640;
const {
@@ -59,7 +60,9 @@ const SideBar: React.FC<{
const { term } = event.detail;
setSideBarVisible(true);
setIsSearchBarVisible(true);
setSearchTerm(term);
if (term !== undefined && term !== null) {
setSearchTerm(term);
}
};
const onNavigateEvent = async () => {
@@ -79,6 +82,10 @@ const SideBar: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible]);
useEffect(() => {
searchTermRef.current = searchTerm;
}, [searchTerm]);
useEffect(() => {
eventDispatcher.on('search', onSearchEvent);
eventDispatcher.on('navigate', onNavigateEvent);
@@ -176,16 +183,35 @@ const SideBar: React.FC<{
});
};
const handleShowSearchBar = useCallback(() => {
setTimeout(() => {
setSideBarVisible(true);
setIsSearchBarVisible(true);
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleHideSearchBar = useCallback(() => {
setIsSearchBarVisible(false);
setSearchResults(null);
setSearchTerm('');
setTimeout(() => {
setSearchTerm('');
}, 100);
getView(sideBarBookKey)?.clearSearch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
useShortcuts({ onToggleSearchBar: handleToggleSearchBar, onEscape: handleHideSearchBar }, [
sideBarBookKey,
const handleHideSideBar = useCallback(() => {
if (searchTermRef.current) {
handleHideSearchBar();
} else if (!isSideBarPinned) {
setSideBarVisible(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey, isSideBarPinned]);
useShortcuts({ onShowSearchBar: handleShowSearchBar, onEscape: handleHideSideBar }, [
handleHideSideBar,
]);
const handleSearchResultClick = (cfi: string) => {
@@ -665,6 +665,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
position={panelPosition}
trianglePosition={trianglePosition}
className='bg-base-200 flex shadow-lg'
onDismiss={handleDismissPopup}
>
<TTSPanel
bookKey={bookKey}
@@ -156,7 +156,9 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
};
const showSearchBar = () => {
eventDispatcher.dispatch('search', { term: '' });
setTimeout(() => {
eventDispatcher.dispatch('search', { term: null });
}, 100);
};
const applyZoomLevel = (zoomLevel: number) => {
@@ -241,7 +243,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onToggleScrollMode: toggleScrollMode,
onToggleBookmark: toggleBookmark,
onOpenFontLayoutSettings: () => setSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onShowSearchBar: showSearchBar,
onToggleFullscreen: toggleFullscreen,
onToggleTTS: toggleTTS,
onReloadPage: reloadPage,
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import { Position } from '@/utils/sel';
import { useEffect, useRef, useState } from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
const Popup = ({
width,
@@ -14,7 +15,10 @@ const Popup = ({
className = '',
triangleClassName = '',
additionalStyle = {},
isOpen = true,
onDismiss,
}: {
isOpen?: boolean;
width: number;
height?: number;
minHeight?: number;
@@ -25,11 +29,14 @@ const Popup = ({
className?: string;
triangleClassName?: string;
additionalStyle?: React.CSSProperties;
onDismiss?: () => void;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [childrenHeight, setChildrenHeight] = useState(height || minHeight || 0);
useKeyDownActions({ onCancel: onDismiss, elementRef: containerRef, enabled: isOpen });
const popupPadding = useResponsiveSize(10);
let availableHeight = window.innerHeight - 2 * popupPadding;
if (trianglePosition?.dir === 'up') {
+10 -5
View File
@@ -2,15 +2,20 @@ const DEFAULT_SHORTCUTS = {
onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
onToggleSideBar: ['s', 'F9'],
onToggleNotebook: ['n'],
onToggleSearchBar: ['ctrl+f', 'cmd+f'],
onShowSearchBar: ['ctrl+f', 'cmd+f'],
onToggleScrollMode: ['shift+j'],
onToggleSelectMode: ['shift+s'],
onToggleBookmark: ['ctrl+d', 'cmd+d'],
onToggleTTS: ['t'],
// Selection actions (lowercase key without modifiers when selection active)
onTranslateSelection: ['ctrl+z', 'ctrl+shift+t'],
onDictionarySelection: ['ctrl+x', 'ctrl+shift+d'],
onWikipediaSelection: ['ctrl+w', 'ctrl+shift+w'],
onHighlightSelection: ['ctrl+h', 'cmd+h'],
onUnderlineSelection: ['ctrl+u', 'cmd+u'],
onAnnotateSelection: ['ctrl+n', 'cmd+n'],
onSearchSelection: ['ctrl+f', 'cmd+f'],
onCopySelection: ['ctrl+c', 'cmd+c'],
onTranslateSelection: ['ctrl+t', 'cmd+t'],
onDictionarySelection: ['ctrl+d', 'cmd+d'],
onWikipediaSelection: ['ctrl+w', 'cmd+w'],
onReadAloudSelection: ['ctrl+r', 'cmd+r'],
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
onOpenBooks: ['ctrl+o', 'cmd+o'],
onReloadPage: ['shift+r'],
+8 -3
View File
@@ -58,16 +58,20 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
if (key === 'backspace') return true;
for (const [actionName, actionHandler] of Object.entries(actions)) {
const shortcutKey = actionName as keyof ShortcutConfig;
const handler = actionHandler as ((event?: KeyboardEvent | MessageEvent) => void) | undefined;
const handler = actionHandler as
| ((event?: KeyboardEvent | MessageEvent) => void | boolean)
| undefined;
const shortcutList = shortcuts[shortcutKey as keyof ShortcutConfig];
// console.log('Checking action:', shortcutKey);
if (
handler &&
shortcutList?.some((shortcut) =>
isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey),
)
) {
handler(event);
return true;
if (handler(event)) {
return true;
}
}
}
return false;
@@ -96,6 +100,7 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
}
const handled = processKeyEvent(key.toLowerCase(), ctrlKey, altKey, metaKey, shiftKey, event);
// console.log('Key event handled:', key, handled);
if (handled) event.preventDefault();
} else if (
event instanceof MessageEvent &&