fix(annotator): enhance PDF context menu for translation and improve touch handling (#2430)

* fix(annotator): enhance PDF context menu for translation and improve touch handling

* feat(annotator, shortcuts): add keyboard shortcuts for selection actions

* feat(annotator): reposition popups on scroll to enhance user experience

* feat(annotator): disable currently unsupported annotator functions for PDFs

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
ByteFlow
2025-11-11 22:10:44 +08:00
committed by GitHub
parent df6027cf9f
commit c4d9652335
5 changed files with 196 additions and 28 deletions
@@ -10,7 +10,12 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationPopupProps {
dir: 'ltr' | 'rtl';
isVertical: boolean;
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
buttons: Array<{
tooltipText: string;
Icon: React.ElementType;
onClick: () => void;
disabled?: boolean;
}>;
position: Position;
trianglePosition: Position;
highlightOptionsVisible: boolean;
@@ -65,6 +70,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
))}
</div>
@@ -32,6 +32,7 @@ import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -79,6 +80,57 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 0;
// Reposition popups on scroll without dismissing them
const repositionPopups = useCallback(() => {
if (!selection || !selection.text) return;
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
const triangPos = getPosition(selection.range, rect, popupPadding, viewSettings.vertical);
const annotPopupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? annotPopupHeight : annotPopupWidth,
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
popupPadding,
);
if (annotPopupPos.dir === 'down' && osPlatform === 'android') {
triangPos.point.y += androidSelectionHandlerHeight;
annotPopupPos.point.y += androidSelectionHandlerHeight;
}
const dictPopupPos = getPopupPosition(
triangPos,
rect,
dictPopupWidth,
dictPopupHeight,
popupPadding,
);
const transPopupPos = getPopupPosition(
triangPos,
rect,
transPopupWidth,
transPopupHeight,
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setTrianglePosition(triangPos);
}, [
selection,
bookKey,
osPlatform,
popupPadding,
viewSettings.vertical,
annotPopupHeight,
annotPopupWidth,
dictPopupWidth,
dictPopupHeight,
transPopupWidth,
transPopupHeight,
]);
useEffect(() => {
setSelectedStyle(settings.globalReadSettings.highlightStyle);
}, [settings.globalReadSettings.highlightStyle]);
@@ -120,28 +172,59 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleTouchmove = () => {
// Available on iOS, on Android not fired
// To make the popup not to follow the selection
// To make the popup not follow the selection while dragging
setShowAnnotPopup(false);
};
if (bookData.book?.format !== 'PDF') {
view?.renderer?.addEventListener('scroll', handleScroll);
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
// Disable the default context menu on mobile devices,
// although it should but doesn't work on iOS
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
return false;
});
}
// 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);
// Reposition popups on scroll to keep them in view
view?.renderer?.addEventListener('scroll', () => {
repositionPopups();
});
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
// For PDF selections, enable right-click context menu to directly open translator popup.
if (bookData.book?.format === 'PDF') {
detail.doc?.addEventListener('contextmenu', (e: Event) => {
try {
const sel = doc.getSelection?.();
if (sel && !sel.isCollapsed) {
const range = sel.getRangeAt(0);
const text = sel.toString();
if (text.trim()) {
setSelection({ key: bookKey, text, range, index });
// Show translation popup preferentially for PDF right-click
setShowAnnotPopup(false);
setShowDeepLPopup(true);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
}
}
} catch (err) {
console.warn('PDF context menu translation failed:', err);
}
// Prevent native menu to keep experience consistent
e.preventDefault();
e.stopPropagation();
return false;
});
}
// Disable the default context menu on mobile devices (selection handles suffice)
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
return false;
});
}
};
@@ -194,6 +277,29 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]);
// When popups are visible, update their positions on scroll events
useEffect(() => {
const view = getView(bookKey);
if (!view?.renderer) return;
const onScroll = () => {
if (showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) {
repositionPopups();
}
};
view.renderer.addEventListener('scroll', onScroll);
return () => {
view.renderer.removeEventListener('scroll', onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
bookKey,
showAnnotPopup,
showWiktionaryPopup,
showWikipediaPopup,
showDeepLPopup,
repositionPopups,
]);
useEffect(() => {
eventDispatcher.on('export-annotations', handleExportMarkdown);
return () => {
@@ -401,6 +507,28 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
useShortcuts(
{
onTranslateSelection: () => {
if (selection?.text) {
handleTranslation();
}
},
onDictionarySelection: () => {
if (selection?.text) {
handleDictionary();
}
},
onWikipediaSelection: () => {
if (selection?.text) {
handleWikipedia();
}
},
},
[selection?.text],
);
const handleExportMarkdown = (event: CustomEvent) => {
const { bookKey: exportBookKey } = event.detail;
if (bookKey !== exportBookKey) return;
@@ -493,13 +621,29 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
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: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch },
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
{
tooltipText: _('Speak'),
Icon: FaHeadphones,
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
},
];
return (
@@ -1,13 +1,21 @@
import clsx from 'clsx';
import React, { useState } from 'react';
interface PopupButtonProps {
showTooltip: boolean;
tooltipText: string;
disabled?: boolean;
Icon: React.ElementType;
onClick: () => void;
}
const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Icon, onClick }) => {
const PopupButton: React.FC<PopupButtonProps> = ({
showTooltip,
tooltipText,
disabled,
Icon,
onClick,
}) => {
const [buttonClicked, setButtonClicked] = useState(false);
const handleClick = () => {
setButtonClicked(true);
@@ -20,7 +28,11 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
>
<button
onClick={handleClick}
className='flex h-8 min-h-8 w-8 items-center justify-center p-0'
className={clsx(
'flex h-8 min-h-8 w-8 items-center justify-center p-0',
disabled ? 'cursor-not-allowed opacity-50' : 'rounded-md hover:bg-gray-500',
)}
disabled={disabled}
>
<Icon />
</button>
@@ -128,9 +128,11 @@ export const useTextSelector = (
isTouchStarted.current = false;
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// FIXME: this is a workaround for issue #873
// TODO: support text selection across pages
// If a popup (annotation/dictionary/translation) is open, don't dismiss it while scrolling.
if (isPopuped.current) {
return;
}
// Otherwise, behave as before: dismiss selection UI and keep container position if needed.
handleDismissPopup();
const viewSettings = getViewSettings(bookKey);
if (
@@ -7,6 +7,10 @@ const DEFAULT_SHORTCUTS = {
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'],
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
onOpenBooks: ['ctrl+o', 'cmd+o'],
onReloadPage: ['shift+r'],