From c4d965233581b774915fb89c9f960a8b905162cb Mon Sep 17 00:00:00 2001 From: ByteFlow Date: Tue, 11 Nov 2025 22:10:44 +0800 Subject: [PATCH] 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 --- .../components/annotator/AnnotationPopup.tsx | 8 +- .../reader/components/annotator/Annotator.tsx | 188 ++++++++++++++++-- .../components/annotator/PopupButton.tsx | 16 +- .../src/app/reader/hooks/useTextSelector.ts | 8 +- apps/readest-app/src/helpers/shortcuts.ts | 4 + 5 files changed, 196 insertions(+), 28 deletions(-) diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx index fc1bf796..c6e3979b 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx @@ -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 = ({ tooltipText={button.tooltipText} Icon={button.Icon} onClick={button.onClick} + disabled={button.disabled} /> ))} diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 14b0dd38..619e582c 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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 ( diff --git a/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx b/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx index 48a006ca..51e52340 100644 --- a/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/PopupButton.tsx @@ -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 = ({ showTooltip, tooltipText, Icon, onClick }) => { +const PopupButton: React.FC = ({ + showTooltip, + tooltipText, + disabled, + Icon, + onClick, +}) => { const [buttonClicked, setButtonClicked] = useState(false); const handleClick = () => { setButtonClicked(true); @@ -20,7 +28,11 @@ const PopupButton: React.FC = ({ showTooltip, tooltipText, Ico > diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index 7e7b6813..15f0efd1 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -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 ( diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 5a369f79..0fc65450 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -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'],