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:
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user