From 9603b61776f59fa26d1047bd6a5a0fdd0b056803 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 5 Jan 2026 08:53:38 +0100 Subject: [PATCH] refactor(annotatot): more cleaner text selector on Android (#2867) --- .../reader/components/annotator/Annotator.tsx | 24 ++++++- .../src/app/reader/hooks/useTextSelector.ts | 66 +++---------------- apps/readest-app/src/utils/sel.ts | 19 ++++++ apps/readest-app/src/utils/style.ts | 2 + 4 files changed, 52 insertions(+), 59 deletions(-) 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 3d6a9c33..10b67379 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -7,7 +7,7 @@ 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 { getLocale, getOSPlatform, uniqueId } from '@/utils/misc'; import { useBookDataStore } from '@/store/bookDataStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useReaderStore } from '@/store/readerStore'; @@ -18,13 +18,16 @@ import { useDeviceControlStore } from '@/store/deviceStore'; import { useFoliateEvents } from '../../hooks/useFoliateEvents'; import { useNotesSync } from '../../hooks/useNotesSync'; import { useTextSelector } from '../../hooks/useTextSelector'; -import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel'; +import { Position, TextSelection } from '@/utils/sel'; +import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; import { findTocItemBS } from '@/utils/toc'; import { throttle } from '@/utils/throttle'; import { runSimpleCC } from '@/utils/simplecc'; import { getWordCount } from '@/utils/word'; import { isCfiInLocation } from '@/utils/cfi'; +import { TransformContext } from '@/services/transformers/types'; +import { transformContent } from '@/services/transformService'; import { getHighlightColorHex } from '../../utils/annotatorUtil'; import { annotationToolButtons } from './AnnotationTools'; import AnnotationPopup from './AnnotationPopup'; @@ -51,6 +54,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const bookData = getBookData(bookKey)!; const view = getView(bookKey); const viewSettings = getViewSettings(bookKey)!; + const primaryLang = bookData.book?.primaryLanguage || 'en'; const containerRef = React.useRef(null); @@ -159,6 +163,20 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { [], ); + const transformCtx: TransformContext = { + bookKey, + viewSettings: getViewSettings(bookKey)!, + userLocale: getLocale(), + content: '', + transformers: ['punctuation'], + reversePunctuationTransform: true, + }; + + const getAnnotationText = async (range: Range) => { + transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []); + return await transformContent(transformCtx); + }; + const { isTextSelected, handleScroll, @@ -170,7 +188,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { handleShowPopup, handleUpToPopup, handleContextmenu, - } = useTextSelector(bookKey, setSelection, handleDismissPopup); + } = useTextSelector(bookKey, setSelection, getAnnotationText, handleDismissPopup); const handleDismissPopupAndSelection = () => { handleDismissPopup(); diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index fd4ffafe..099c77ab 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -1,24 +1,19 @@ import { useEffect, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; -import { useBookDataStore } from '@/store/bookDataStore'; -import { getLocale, getOSPlatform } from '@/utils/misc'; +import { getOSPlatform } from '@/utils/misc'; import { eventDispatcher } from '@/utils/event'; -import { getTextFromRange, TextSelection } from '@/utils/sel'; -import { transformContent } from '@/services/transformService'; -import { TransformContext } from '@/services/transformers/types'; +import { isPointerInsideSelection, TextSelection } from '@/utils/sel'; export const useTextSelector = ( bookKey: string, setSelection: React.Dispatch>, + getAnnotationText: (range: Range) => Promise, handleDismissPopup: () => void, ) => { const { appService } = useEnv(); - const { getBookData } = useBookDataStore(); const { getView, getViewSettings } = useReaderStore(); const view = getView(bookKey); - const bookData = getBookData(bookKey)!; - const primaryLang = bookData.book?.primaryLanguage || 'en'; const osPlatform = getOSPlatform(); const isPopuped = useRef(false); @@ -32,18 +27,6 @@ export const useTextSelector = ( return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0; }; - const transformCtx: TransformContext = { - bookKey, - viewSettings: getViewSettings(bookKey)!, - userLocale: getLocale(), - content: '', - transformers: ['punctuation'], - reversePunctuationTransform: true, - }; - const getAnnotationText = async (range: Range) => { - transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []); - return await transformContent(transformCtx); - }; const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => { isTextSelected.current = true; isUpToPopup.current = true; @@ -80,24 +63,7 @@ export const useTextSelector = ( }, 30); }, 30); }; - const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => { - if (selection.rangeCount === 0) return false; - const range = selection.getRangeAt(0); - const rects = range.getClientRects(); - const padding = 80; - for (let i = 0; i < rects.length; i++) { - const rect = rects[i]!; - if ( - ev.clientX >= rect.left - padding && - ev.clientX <= rect.right + padding && - ev.clientY >= rect.top - padding && - ev.clientY <= rect.bottom + padding - ) { - return true; - } - } - return false; - }; + const handlePointerdown = (e: PointerEvent) => { lastPointerType.current = e.pointerType; }; @@ -129,19 +95,10 @@ export const useTextSelector = ( const sel = doc.getSelection() as Selection; if (isValidSelection(sel)) { - isTextSelected.current = true; - isUpToPopup.current = true; if (!selectionPosition.current) { selectionPosition.current = view?.renderer?.start || null; } } else { - if (!isUpToPopup.current) { - handleDismissPopup(); - isTextSelected.current = false; - } - if (isPopuped.current) { - isUpToPopup.current = false; - } selectionPosition.current = null; } }; @@ -161,15 +118,12 @@ export const useTextSelector = ( }; const handleShowPopup = (showPopup: boolean) => { - setTimeout( - () => { - if (showPopup && !isPopuped.current) { - isUpToPopup.current = false; - } - isPopuped.current = showPopup; - }, - ['android', 'ios'].includes(osPlatform) || appService?.isIOSApp ? 0 : 500, - ); + setTimeout(() => { + if (showPopup && !isPopuped.current) { + isUpToPopup.current = false; + } + isPopuped.current = showPopup; + }, 500); }; const handleUpToPopup = () => { diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index 7b241503..5f7402a8 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -74,6 +74,25 @@ const constrainPointWithinRect = (point: Point, rect: Rect, padding: number) => }; }; +export const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => { + if (selection.rangeCount === 0) return false; + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + const padding = 80; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]!; + if ( + ev.clientX >= rect.left - padding && + ev.clientX <= rect.right + padding && + ev.clientY >= rect.top - padding && + ev.clientY <= rect.bottom + padding + ) { + return true; + } + } + return false; +}; + export const getPosition = ( targetElement: Range | Element | TextSelection, rect: Rect, diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 876caeab..360f4bfc 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -245,6 +245,8 @@ const getLayoutStyles = ( ${vertical ? 'font-feature-settings: "vrt2" 1, "vert" 1; text-orientation: upright;' : ''} text-align: var(--default-text-align); max-height: unset; + -webkit-touch-callout: none; + -webkit-user-select: text; } body { overflow: unset;