feat(annotator): support quick actions for text selection, closes #2505 (#2813)

This commit is contained in:
Huang Xin
2025-12-30 10:32:01 +01:00
committed by GitHub
parent 58a5c1625c
commit a42897ec5c
47 changed files with 1036 additions and 122 deletions
@@ -3,7 +3,7 @@ import React from 'react';
import { Position } from '@/utils/sel';
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
import Popup from '@/components/Popup';
import PopupButton from './PopupButton';
import AnnotationToolButton from './AnnotationToolButton';
import AnnotationNotes from './AnnotationNotes';
import HighlightOptions from './HighlightOptions';
@@ -69,7 +69,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
<AnnotationToolButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import React, { useState } from 'react';
interface PopupButtonProps {
interface AnnotationToolButtonProps {
showTooltip: boolean;
tooltipText: string;
disabled?: boolean;
@@ -9,7 +9,7 @@ interface PopupButtonProps {
onClick: () => void;
}
const PopupButton: React.FC<PopupButtonProps> = ({
const AnnotationToolButton: React.FC<AnnotationToolButtonProps> = ({
showTooltip,
tooltipText,
disabled,
@@ -40,4 +40,4 @@ const PopupButton: React.FC<PopupButtonProps> = ({
);
};
export default PopupButton;
export default AnnotationToolButton;
@@ -0,0 +1,92 @@
import { IconType } from 'react-icons';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosBuild } from 'react-icons/io';
import { AnnotationToolType } from '@/types/annotator';
import { stubTranslation as _ } from '@/utils/misc';
type AnnotationToolButton = {
type: AnnotationToolType;
label: string;
tooltip: string;
Icon: IconType;
quickAction?: boolean;
};
function createAnnotationToolButtons<T extends AnnotationToolType>(
buttons: AnnotationToolType extends T
? {
[K in T]: {
type: K;
label: string;
tooltip: string;
Icon: IconType;
quickAction?: boolean;
};
}[T][]
: never,
): AnnotationToolButton[] {
return buttons;
}
export const annotationToolButtons = createAnnotationToolButtons([
{ type: 'copy', label: _('Copy'), tooltip: _('Copy text after selection'), Icon: FiCopy },
{
type: 'highlight',
label: _('Highlight'),
tooltip: _('Highlight text after selection'),
Icon: PiHighlighterFill,
quickAction: true,
},
{
type: 'annotate',
label: _('Annotate'),
tooltip: _('Annotate text after selection'),
Icon: BsPencilSquare,
},
{ type: 'search', label: _('Search'), tooltip: _('Search text after selection'), Icon: FiSearch },
{
type: 'dictionary',
label: _('Dictionary'),
tooltip: _('Look up text in dictionary after selection'),
Icon: TbHexagonLetterD,
quickAction: true,
},
{
type: 'wikipedia',
label: _('Wikipedia'),
tooltip: _('Look up text in Wikipedia after selection'),
Icon: FaWikipediaW,
quickAction: true,
},
{
type: 'translate',
label: _('Translate'),
tooltip: _('Translate text after selection'),
Icon: BsTranslate,
quickAction: true,
},
{
type: 'tts',
label: _('Speak'),
tooltip: _('Read text aloud after selection'),
Icon: FaHeadphones,
quickAction: true,
},
{
type: 'proofread',
label: _('Proofread'),
tooltip: _('Proofread text after selection'),
Icon: IoIosBuild,
},
]);
export const annotationToolQuickActions = annotationToolButtons.filter(
(button) => button.quickAction,
);
@@ -1,20 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosBuild } from 'react-icons/io';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
import { useEnv } from '@/context/EnvContext';
import { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book';
import { NOTE_PREFIX } from '@/types/view';
import { NativeTouchEventType } from '@/types/system';
import { getOSPlatform, uniqueId } from '@/utils/misc';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -22,6 +14,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { useTextSelector } from '../../hooks/useTextSelector';
@@ -30,8 +23,9 @@ import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { getWordCount } from '@/utils/word';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
import { annotationToolButtons } from './AnnotationTools';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
@@ -46,6 +40,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
const { listenToNativeTouchEvents } = useDeviceControlStore();
useNotesSync(bookKey);
@@ -163,12 +158,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
[],
);
const handleDismissPopupAndSelection = () => {
handleDismissPopup();
view?.deselect();
};
const {
isTextSelected,
handleScroll,
handleTouchStart,
handleTouchEnd,
@@ -180,6 +171,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleContextmenu,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const handleDismissPopupAndSelection = () => {
handleDismissPopup();
view?.deselect();
isTextSelected.current = false;
};
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
const { doc, index } = detail;
@@ -190,6 +187,21 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowAnnotPopup(false);
};
const handleNativeTouch = (event: CustomEvent) => {
const ev = event.detail as NativeTouchEventType;
if (ev.type === 'touchstart') {
handleTouchStart();
} else if (ev.type === 'touchend') {
handleTouchEnd();
handlePointerup(doc, index);
}
};
if (appService?.isAndroidApp) {
listenToNativeTouchEvents();
eventDispatcher.on('native-touch', handleNativeTouch);
}
// Attach generic selection listeners for all formats, including PDF.
// For PDF we only guarantee Copy & Translate; highlight/annotate may be limited by CFI support.
view?.renderer?.addEventListener('scroll', handleScroll);
@@ -204,7 +216,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc));
// For PDF selections, enable right-click context menu to directly open translator popup.
if (bookData.book?.format === 'PDF') {
@@ -242,9 +254,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const detail = (event as CustomEvent).detail;
const { draw, annotation, doc, range } = detail;
const { style, color } = annotation as BookNote;
const customColors = settings.globalReadSettings.customHighlightColors;
const hexColor =
color && customColors ? customColors[color] : color ? HIGHLIGHT_COLOR_HEX[color] : color;
const hexColor = getHighlightColorHex(settings, color);
if (annotation.note) {
const { defaultView } = doc;
const node = range.startContainer;
@@ -361,6 +371,32 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleQuickAction = () => {
const action = viewSettings.annotationQuickAction;
switch (action) {
case 'copy':
handleCopy(false);
handleDismissPopupAndSelection();
break;
case 'highlight':
handleHighlight();
handleDismissPopupAndSelection();
break;
case 'dictionary':
handleDictionary();
break;
case 'wikipedia':
handleWikipedia();
break;
case 'translate':
handleTranslation();
break;
case 'tts':
handleSpeakText(true);
break;
}
};
useEffect(() => {
setHighlightOptionsVisible(!!(selection && selection.annotated));
if (selection && selection.text.trim().length > 0) {
@@ -406,10 +442,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setTranslatorPopupPosition(transPopupPos);
setProofreadPopupPosition(proofreadPopupPos);
setTrianglePosition(triangPos);
handleShowAnnotPopup();
const { enableAnnotationQuickActions, annotationQuickAction } = viewSettings;
if (enableAnnotationQuickActions && annotationQuickAction && isTextSelected.current) {
handleQuickAction();
} else {
handleShowAnnotPopup();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
}, [selection?.cfi, bookKey]);
useEffect(() => {
if (!progress) return;
@@ -465,7 +507,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWikipediaPopup(false);
};
const handleCopy = (copyToNotebook = true, dismissPopup = true) => {
const handleCopy = (dismissPopup = true) => {
if (!selection || !selection.text) return;
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
@@ -475,7 +517,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleDismissPopupAndSelection();
}
if (!copyToNotebook) return;
if (!viewSettings?.copyToNotebook) return;
eventDispatcher.dispatch('toast', {
type: 'info',
@@ -604,13 +646,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(true);
};
const handleSpeakText = async () => {
const handleSpeakText = async (oneTime = false) => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range, oneTime });
};
const handleShowProofreadOptions = () => {
const handleProofread = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowProofreadPopup(true);
@@ -641,7 +683,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleSearch();
},
onCopySelection: () => {
handleCopy(false, false);
handleCopy(false);
},
onTranslateSelection: () => {
handleTranslation();
@@ -655,6 +697,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onReadAloudSelection: () => {
handleSpeakText();
},
onProofreadSelection: () => {
handleProofread();
},
},
[selection?.text],
);
@@ -745,42 +790,55 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
{
tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'),
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
onClick: handleHighlight,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: _('Annotate'),
Icon: BsPencilSquare,
onClick: handleAnnotate,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: _('Search'),
Icon: FiSearch,
onClick: handleSearch,
disabled: bookData.book?.format === 'PDF',
},
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{
tooltipText: _('Speak'),
Icon: FaHeadphones,
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: _('Proofread'),
Icon: IoIosBuild,
onClick: handleShowProofreadOptions,
disabled: bookData.book?.format !== 'EPUB',
},
];
const toolButtons = annotationToolButtons.map(({ type, label, Icon }) => {
switch (type) {
case 'copy':
return { tooltipText: _(label), Icon, onClick: handleCopy };
case 'highlight':
return {
tooltipText: selectionAnnotated ? _('Delete Highlight') : _(label),
Icon: selectionAnnotated ? RiDeleteBinLine : Icon,
onClick: handleHighlight,
disabled: bookData.book?.format === 'PDF',
};
case 'annotate':
return {
tooltipText: _(label),
Icon,
onClick: handleAnnotate,
disabled: bookData.book?.format === 'PDF',
};
case 'search':
return {
tooltipText: _(label),
Icon,
onClick: handleSearch,
disabled: bookData.book?.format === 'PDF',
};
case 'dictionary':
return { tooltipText: _(label), Icon, onClick: handleDictionary };
case 'wikipedia':
return { tooltipText: _(label), Icon, onClick: handleWikipedia };
case 'translate':
return { tooltipText: _(label), Icon, onClick: handleTranslation };
case 'tts':
return {
tooltipText: _(label),
Icon,
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
};
case 'proofread':
return {
tooltipText: _(label),
Icon,
onClick: handleProofread,
disabled: bookData.book?.format !== 'EPUB',
};
default:
return { tooltipText: '', Icon, onClick: () => {} };
}
});
return (
<div ref={containerRef} role='toolbar' tabIndex={-1}>
@@ -821,7 +879,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
bookKey={bookKey}
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
isVertical={viewSettings.vertical}
buttons={buttons}
buttons={toolButtons}
notes={annotationNotes}
position={annotPopupPosition}
trianglePosition={trianglePosition}
@@ -0,0 +1,52 @@
import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { AnnotationToolType } from '@/types/annotator';
import { useTranslation } from '@/hooks/useTranslation';
import { annotationToolQuickActions } from './AnnotationTools';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface QuickActionMenuProps {
selectedAction?: AnnotationToolType | null;
onActionSelect: (action: AnnotationToolType) => void;
setIsDropdownOpen?: (open: boolean) => void;
}
const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
selectedAction,
onActionSelect,
setIsDropdownOpen,
}) => {
const _ = useTranslation();
const handleActionClick = (action: AnnotationToolType) => {
onActionSelect(action);
};
return (
<Menu
className={clsx(
'annotation-quick-action-menu dropdown-content z-20 mt-1 border',
'bgcolor-base-200 border-base-200 shadow-2xl',
)}
style={{
maxWidth: `${window.innerWidth - 40}px`,
}}
onCancel={() => setIsDropdownOpen?.(false)}
>
{annotationToolQuickActions.map((button) => (
<MenuItem
key={button.type}
label={_(button.label)}
tooltip={_(button.tooltip)}
Icon={selectedAction === button.type ? MdCheck : button.Icon}
onClick={() => handleActionClick(button.type)}
/>
))}
</Menu>
);
};
export default QuickActionMenu;