From b840f52bb68c7434984d8b1c52312d72f730cf80 Mon Sep 17 00:00:00 2001 From: Shisi Date: Thu, 2 Jul 2026 18:18:58 +0200 Subject: [PATCH 01/13] feat(reader): add search button and search input for word search --- .../reader/components/ReaderAppbar.tsx | 21 ++++- .../reader/components/ReaderSearchbar.tsx | 82 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/screens/reader/components/ReaderSearchbar.tsx diff --git a/src/screens/reader/components/ReaderAppbar.tsx b/src/screens/reader/components/ReaderAppbar.tsx index 597f8a04e8..e68cff3661 100644 --- a/src/screens/reader/components/ReaderAppbar.tsx +++ b/src/screens/reader/components/ReaderAppbar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import color from 'color'; @@ -13,6 +13,7 @@ import { ThemeColors } from '@theme/types'; import { bookmarkChapter } from '@database/queries/ChapterQueries'; import { useChapterContext } from '../ChapterContext'; import { useNovelLayout } from '@screens/novel/NovelContext'; +import ReaderSearchbar from './ReaderSearchbar'; interface ReaderAppbarProps { theme: ThemeColors; @@ -31,6 +32,11 @@ const ReaderAppbar = ({ }: ReaderAppbarProps) => { const { chapter, novel } = useChapterContext(); const { statusBarHeight } = useNovelLayout(); + const [searchVisible, setSearchVisible] = useState(false); + + useEffect(() => { + setSearchVisible(false); + }, [chapter.id]); const entering = () => { 'worklet'; @@ -105,6 +111,13 @@ const ReaderAppbar = ({ {chapter.name} + setSearchVisible(current => !current)} + color={theme.onSurface} + theme={theme} + /> + {searchVisible ? ( + setSearchVisible(false)} + /> + ) : null} ); }; diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx new file mode 100644 index 0000000000..88e209a9f2 --- /dev/null +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Keyboard, StyleSheet, TextInput, View } from 'react-native'; + +import { IconButtonV2 } from '@components'; +import { ThemeColors } from '@theme/types'; + +interface ReaderSearchbarProps { + theme: ThemeColors; + onClose: () => void; +} + +const ReaderSearchbar = ({ theme, onClose }: ReaderSearchbarProps) => { + const inputRef = useRef(null); + const [searchText, setSearchText] = useState(''); + + useEffect(() => { + const frame = requestAnimationFrame(() => inputRef.current?.focus()); + + return () => { + cancelAnimationFrame(frame); + Keyboard.dismiss(); + }; + }, []); + + return ( + + + inputRef.current?.focus()} + padding={6} + theme={theme} + style={styles.searchIcon} + /> + + + + ); +}; + +export default ReaderSearchbar; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 12, + paddingTop: 8, + }, + input: { + flex: 1, + fontSize: 16, + minHeight: 44, + paddingVertical: 0, + }, + searchIcon: { + marginLeft: 8, + }, + searchbar: { + alignItems: 'center', + borderRadius: 24, + flexDirection: 'row', + minHeight: 48, + overflow: 'hidden', + }, +}); From 73b1d75a213c97135d64401c93eeba5a1626b2c1 Mon Sep 17 00:00:00 2001 From: Shisi Date: Thu, 2 Jul 2026 23:11:28 +0200 Subject: [PATCH 02/13] fix(reader): make keyboard close on hideHeader --- src/screens/reader/ReaderScreen.tsx | 34 +++++++++++++++++-- .../reader/components/ReaderAppbar.tsx | 18 ++++------ .../reader/components/ReaderSearchbar.tsx | 3 +- .../reader/components/WebViewReader.tsx | 7 +++- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 30de4f444c..493f8458a6 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -16,7 +16,7 @@ import { ChapterContextProvider, useChapterContext } from './ChapterContext'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; import { useBackHandler } from '@hooks/index'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { StyleSheet, View } from 'react-native'; +import { Keyboard, StyleSheet, View } from 'react-native'; import { Drawer } from 'react-native-drawer-layout'; const Chapter = ({ route, navigation }: ChapterScreenProps) => { @@ -69,14 +69,25 @@ export const ChapterContent = ({ const theme = useTheme(); const { pageReader = false, keepScreenOn } = useChapterGeneralSettings(); const [bookmarked, setBookmarked] = useState(chapter.bookmark ?? false); + const [searchVisible, setSearchVisible] = useState(false); useEffect(() => { setBookmarked(chapter.bookmark ?? false); }, [chapter]); + useEffect(() => { + setSearchVisible(false); + }, [chapter.id]); + const { hidden, loading, error, webViewRef, hideHeader, refetch } = useChapterContext(); + useEffect(() => { + if (hidden) { + setSearchVisible(false); + } + }, [hidden]); + const scrollToStart = () => requestAnimationFrame(() => { webViewRef?.current?.injectJavaScript( @@ -96,6 +107,20 @@ export const ChapterContent = ({ hideHeader(); }, [hideHeader, openDrawer]); + const handleReaderTouchStart = useCallback(() => { + if (searchVisible) { + Keyboard.dismiss(); + } + }, [searchVisible]); + + const handleReaderPress = useCallback(() => { + if (searchVisible) { + setSearchVisible(false); + return; + } + hideHeader(); + }, [hideHeader, searchVisible]); + if (error) { return ( ) : ( - + )} {!hidden ? ( @@ -138,6 +166,8 @@ export const ChapterContent = ({ theme={theme} bookmarked={bookmarked} setBookmarked={setBookmarked} + searchVisible={searchVisible} + setSearchVisible={setSearchVisible} /> void; bookmarked: boolean; setBookmarked: React.Dispatch>; + searchVisible: boolean; + setSearchVisible: React.Dispatch>; } const fastOutSlowIn = Easing.bezier(0.4, 0.0, 0.2, 1.0); @@ -29,14 +31,11 @@ const ReaderAppbar = ({ theme, bookmarked, setBookmarked, + searchVisible, + setSearchVisible, }: ReaderAppbarProps) => { const { chapter, novel } = useChapterContext(); const { statusBarHeight } = useNovelLayout(); - const [searchVisible, setSearchVisible] = useState(false); - - useEffect(() => { - setSearchVisible(false); - }, [chapter.id]); const entering = () => { 'worklet'; @@ -129,12 +128,7 @@ const ReaderAppbar = ({ style={styles.bookmark} /> - {searchVisible ? ( - setSearchVisible(false)} - /> - ) : null} + {searchVisible ? : null} ); }; diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 88e209a9f2..1c83aa15bc 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -6,10 +6,9 @@ import { ThemeColors } from '@theme/types'; interface ReaderSearchbarProps { theme: ThemeColors; - onClose: () => void; } -const ReaderSearchbar = ({ theme, onClose }: ReaderSearchbarProps) => { +const ReaderSearchbar = ({ theme }: ReaderSearchbarProps) => { const inputRef = useRef(null); const [searchText, setSearchText] = useState(''); diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 99befe1cc7..3e5e1cff66 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -44,6 +44,7 @@ type WebViewPostEvent = { type WebViewReaderProps = { onPress(): void; + onTouchStart?(): void; }; const onLogMessage = (payload: { nativeEvent: { data: string } }) => { @@ -63,7 +64,10 @@ const assetsUriPrefix = __DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'; -const WebViewReader: React.FC = ({ onPress }) => { +const WebViewReader: React.FC = ({ + onPress, + onTouchStart, +}) => { const { novel, chapter, @@ -310,6 +314,7 @@ const WebViewReader: React.FC = ({ onPress }) => { return ( Date: Thu, 2 Jul 2026 23:53:59 +0200 Subject: [PATCH 03/13] chore(reader): format reader files --- src/screens/reader/ReaderScreen.tsx | 8 +-- .../reader/components/WebViewReader.tsx | 72 ++++++++++--------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 493f8458a6..6733a87f3f 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -68,7 +68,9 @@ export const ChapterContent = ({ const readerSheetRef = useRef(null); const theme = useTheme(); const { pageReader = false, keepScreenOn } = useChapterGeneralSettings(); - const [bookmarked, setBookmarked] = useState(chapter.bookmark ?? false); + const [bookmarked, setBookmarked] = useState( + chapter.bookmark ?? false, + ); const [searchVisible, setSearchVisible] = useState(false); useEffect(() => { @@ -146,9 +148,7 @@ export const ChapterContent = ({ ); } return ( - + {keepScreenOn ? : null} {loading ? ( diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 3e5e1cff66..ebcbdb17f4 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -98,7 +98,7 @@ const WebViewReader: React.FC = ({ useEffect(() => { setReaderSettings( getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, + initialChapterReaderSettings, ); }, [chapter.id]); @@ -360,9 +360,9 @@ const WebViewReader: React.FC = ({ | undefined; const queue = Array.isArray(payload?.queue) ? payload?.queue.filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ) + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) : []; ttsQueueRef.current = queue; if (typeof payload?.startIndex === 'number') { @@ -480,8 +480,8 @@ const WebViewReader: React.FC = ({ --theme-onSecondary: ${theme.onSecondary}; --theme-surface: ${theme.surface}; --theme-surface-0-9: ${color(theme.surface) - .alpha(0.9) - .toString()}; + .alpha(0.9) + .toString()}; --theme-onSurface: ${theme.onSurface}; --theme-surfaceVariant: ${theme.surfaceVariant}; --theme-onSurfaceVariant: ${theme.onSurfaceVariant}; @@ -491,20 +491,23 @@ const WebViewReader: React.FC = ({ @font-face { font-family: ${readerSettings.fontFamily}; - src: url("file:///android_asset/fonts/${readerSettings.fontFamily - }.ttf"); + src: url("file:///android_asset/fonts/${ + readerSettings.fontFamily + }.ttf"); } - -
+
${chapter.name}
@@ -514,28 +517,31 @@ const WebViewReader: React.FC = ({ From 19cc593441a89b11da684ecf136db97db511acdb Mon Sep 17 00:00:00 2001 From: Shisi Date: Fri, 3 Jul 2026 00:07:37 +0200 Subject: [PATCH 04/13] chore(strings): regenerate string types --- strings/types/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/strings/types/index.ts b/strings/types/index.ts index 1301e6f2ea..fdc5dc591f 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -242,6 +242,10 @@ export interface StringMap { 'generalSettingsScreen.disableLoadingAnimationsDesc': 'string'; 'generalSettingsScreen.disableHapticFeedback': 'string'; 'generalSettingsScreen.disableHapticFeedbackDescription': 'string'; + 'generalSettingsScreen.chapterDownloadCooldown': 'string'; + 'generalSettingsScreen.chapterDownloadCooldownDesc': 'string'; + 'generalSettingsScreen.chapterDownloadCooldownPlaceholder': 'string'; + 'generalSettingsScreen.chapterDownloadCooldownWarning': 'string'; 'generalSettingsScreen.displayMode': 'string'; 'generalSettingsScreen.downloadNewChapters': 'string'; 'generalSettingsScreen.epub': 'string'; From 0f52a096557c2e23aa4142f8568776e4cba14596 Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 15:14:00 +0200 Subject: [PATCH 05/13] feat(reader): add chapter word search --- android/app/src/main/assets/css/index.css | 16 ++ android/app/src/main/assets/js/core.js | 211 ++++++++++++++++++ src/screens/reader/ReaderScreen.tsx | 30 ++- .../reader/components/ReaderAppbar.tsx | 22 +- .../reader/components/ReaderSearchbar.tsx | 133 ++++++++++- .../reader/components/WebViewReader.tsx | 26 ++- src/screens/reader/hooks/useChapter.ts | 29 +++ src/screens/reader/types.ts | 9 + 8 files changed, 467 insertions(+), 9 deletions(-) create mode 100644 src/screens/reader/types.ts diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 50a31c03a8..6ba0065b94 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -160,6 +160,22 @@ td { background-color: transparent; } +mark.lnreader-search-match { + border-radius: 2px; + padding: 0 1px; + color: inherit; + background-color: color-mix( + in srgb, + var(--theme-tertiary) 55%, + transparent + ); +} + +mark.lnreader-search-match-active { + color: var(--theme-onPrimary); + background-color: var(--theme-primary); +} + #Image-Modal { position: fixed; left: 0; diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 82992b326b..8685cbc125 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -135,6 +135,7 @@ window.tts = new (function () { 'BR', 'STRONG', 'A', + 'MARK', ]; this.prevElement = null; this.currentElement = reader.chapterElement; @@ -763,6 +764,211 @@ window.addEventListener('load', () => { }); })(); +window.readerSearch = new (function () { + this.query = ''; + this.index = -1; + this.matches = []; + + this.emit = () => { + reader.post({ + type: 'search-result', + data: { + current: this.index >= 0 ? this.index + 1 : 0, + total: this.matches.length, + }, + }); + }; + + this.clear = (emit = true, resetQuery = true) => { + if (resetQuery) { + this.query = ''; + } + + document.querySelectorAll('mark.lnreader-search-match').forEach(mark => { + const parent = mark.parentNode; + if (!parent) { + return; + } + + parent.replaceChild( + document.createTextNode(mark.textContent || ''), + mark, + ); + parent.normalize(); + }); + + this.matches = []; + this.index = -1; + reader.refresh(); + + if (emit) { + this.emit(); + } + }; + + this.getTextNodes = () => { + const textNodes = []; + const walker = document.createTreeWalker( + reader.chapterElement, + NodeFilter.SHOW_TEXT, + { + acceptNode: node => { + if (!node.nodeValue?.trim()) { + return NodeFilter.FILTER_REJECT; + } + if (node.parentElement?.closest('script, style')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }, + ); + let node = walker.nextNode(); + + while (node) { + textNodes.push(node); + node = walker.nextNode(); + } + + return textNodes; + }; + + this.wrapMatches = (node, term, normalizedTerm) => { + const text = node.nodeValue || ''; + const normalizedText = text.toLocaleLowerCase(); + let start = 0; + let matchIndex = normalizedText.indexOf(normalizedTerm); + + if (matchIndex === -1) { + return; + } + + const fragment = document.createDocumentFragment(); + + while (matchIndex !== -1) { + if (matchIndex > start) { + fragment.appendChild( + document.createTextNode(text.slice(start, matchIndex)), + ); + } + + const mark = document.createElement('mark'); + mark.className = 'lnreader-search-match'; + mark.textContent = text.slice(matchIndex, matchIndex + term.length); + fragment.appendChild(mark); + + start = matchIndex + term.length; + matchIndex = normalizedText.indexOf(normalizedTerm, start); + } + + if (start < text.length) { + fragment.appendChild(document.createTextNode(text.slice(start))); + } + + node.parentNode?.replaceChild(fragment, node); + }; + + this.hasLiveMatches = () => { + return ( + this.matches.length > 0 && + this.matches.every(match => reader.chapterElement.contains(match)) + ); + }; + + this.ensureSearch = query => { + const term = String(query ?? this.query ?? '').trim(); + if (!term) { + this.clear(); + return false; + } + + if (term !== this.query || !this.hasLiveMatches()) { + this.search(term, Math.max(0, this.index)); + } + + return this.matches.length > 0; + }; + + this.scrollToMatch = match => { + if (reader.generalSettings.val.pageReader && window.pageReader) { + const rect = match.getBoundingClientRect(); + const relativePage = Math.floor( + (rect.left + rect.width / 2) / reader.layoutWidth, + ); + const page = Math.max( + 0, + Math.min( + pageReader.totalPages.val - 1, + pageReader.page.val + relativePage, + ), + ); + pageReader.movePage(page); + return; + } + + match.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + + this.focus = index => { + if (!this.matches.length) { + this.index = -1; + this.emit(); + return; + } + + this.matches[this.index]?.classList.remove( + 'lnreader-search-match-active', + ); + this.index = + ((index % this.matches.length) + this.matches.length) % + this.matches.length; + + const match = this.matches[this.index]; + match.classList.add('lnreader-search-match-active'); + this.scrollToMatch(match); + this.emit(); + }; + + this.search = (query, preferredIndex = 0) => { + const term = String(query ?? '').trim(); + this.clear(false, false); + this.query = term; + + if (!term) { + this.emit(); + return; + } + + const normalizedTerm = term.toLocaleLowerCase(); + this.getTextNodes().forEach(node => { + this.wrapMatches(node, term, normalizedTerm); + }); + this.matches = Array.from( + reader.chapterElement.querySelectorAll('mark.lnreader-search-match'), + ); + reader.refresh(); + + if (!this.matches.length) { + this.emit(); + return; + } + + this.focus(Math.max(0, Math.min(preferredIndex, this.matches.length - 1))); + }; + + this.next = query => { + if (this.ensureSearch(query)) { + this.focus(this.index + 1); + } + }; + + this.previous = query => { + if (this.ensureSearch(query)) { + this.focus(this.index - 1); + } + }; +})(); + // text options (function () { van.derive(() => { @@ -790,5 +996,10 @@ window.addEventListener('load', () => { .replace(/
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g, ''); } reader.chapterElement.innerHTML = html; + const searchQuery = window.readerSearch?.query; + const searchIndex = window.readerSearch?.index; + if (searchQuery) { + window.readerSearch.search(searchQuery, searchIndex); + } }); })(); diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 6733a87f3f..bea325eae9 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -18,6 +18,7 @@ import { useBackHandler } from '@hooks/index'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Keyboard, StyleSheet, View } from 'react-native'; import { Drawer } from 'react-native-drawer-layout'; +import { EMPTY_READER_SEARCH_RESULT, ReaderSearchResult } from './types'; const Chapter = ({ route, navigation }: ChapterScreenProps) => { const [open, setOpen] = useState(false); @@ -72,6 +73,25 @@ export const ChapterContent = ({ chapter.bookmark ?? false, ); const [searchVisible, setSearchVisible] = useState(false); + const [searchResult, setSearchResult] = useState( + EMPTY_READER_SEARCH_RESULT, + ); + const [searchText, setSearchTextState] = useState(''); + const searchTextRef = useRef(''); + + const setSearchText = useCallback((text: string) => { + searchTextRef.current = text; + setSearchTextState(text); + }, []); + + const resetSearchResult = useCallback(() => { + setSearchResult(EMPTY_READER_SEARCH_RESULT); + }, []); + + const resetSearch = useCallback(() => { + setSearchText(''); + resetSearchResult(); + }, [resetSearchResult, setSearchText]); useEffect(() => { setBookmarked(chapter.bookmark ?? false); @@ -79,7 +99,8 @@ export const ChapterContent = ({ useEffect(() => { setSearchVisible(false); - }, [chapter.id]); + resetSearch(); + }, [chapter.id, resetSearch]); const { hidden, loading, error, webViewRef, hideHeader, refetch } = useChapterContext(); @@ -156,6 +177,8 @@ export const ChapterContent = ({ )} @@ -168,6 +191,11 @@ export const ChapterContent = ({ setBookmarked={setBookmarked} searchVisible={searchVisible} setSearchVisible={setSearchVisible} + searchText={searchText} + setSearchText={setSearchText} + searchResult={searchResult} + resetSearchResult={resetSearchResult} + resetSearch={resetSearch} /> >; searchVisible: boolean; setSearchVisible: React.Dispatch>; + searchText: string; + setSearchText: (text: string) => void; + searchResult: ReaderSearchResult; + resetSearchResult: () => void; + resetSearch: () => void; } const fastOutSlowIn = Easing.bezier(0.4, 0.0, 0.2, 1.0); @@ -33,6 +39,11 @@ const ReaderAppbar = ({ setBookmarked, searchVisible, setSearchVisible, + searchText, + setSearchText, + searchResult, + resetSearchResult, + resetSearch, }: ReaderAppbarProps) => { const { chapter, novel } = useChapterContext(); const { statusBarHeight } = useNovelLayout(); @@ -128,7 +139,16 @@ const ReaderAppbar = ({ style={styles.bookmark} /> - {searchVisible ? : null} + {searchVisible ? ( + + ) : null} ); }; diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 1c83aa15bc..5f0cbe1157 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -1,25 +1,105 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Keyboard, StyleSheet, TextInput, View } from 'react-native'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { Keyboard, StyleSheet, Text, TextInput, View } from 'react-native'; import { IconButtonV2 } from '@components'; import { ThemeColors } from '@theme/types'; +import { useChapterContext } from '../ChapterContext'; +import { ReaderSearchResult } from '../types'; interface ReaderSearchbarProps { theme: ThemeColors; + searchText: string; + setSearchText: (text: string) => void; + searchResult: ReaderSearchResult; + resetSearchResult: () => void; + resetSearch: () => void; } -const ReaderSearchbar = ({ theme }: ReaderSearchbarProps) => { +const SEARCH_DEBOUNCE_MS = 300; + +const ReaderSearchbar = ({ + theme, + searchText, + setSearchText, + searchResult, + resetSearchResult, + resetSearch, +}: ReaderSearchbarProps) => { const inputRef = useRef(null); - const [searchText, setSearchText] = useState(''); + const searchTimeoutRef = useRef | null>(null); + const { searchChapterText, clearChapterSearch, navigateChapterSearch } = + useChapterContext(); + const hasSearchText = searchText.trim().length > 0; + const hasMatches = hasSearchText && searchResult.total > 0; + + const clearPendingSearch = useCallback(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = null; + } + }, []); useEffect(() => { const frame = requestAnimationFrame(() => inputRef.current?.focus()); return () => { cancelAnimationFrame(frame); + clearPendingSearch(); Keyboard.dismiss(); + resetSearch(); + clearChapterSearch(); }; - }, []); + }, [clearChapterSearch, clearPendingSearch, resetSearch]); + + const handleSearchTextChange = useCallback( + (text: string) => { + setSearchText(text); + resetSearchResult(); + clearPendingSearch(); + + if (!text.trim()) { + clearChapterSearch(); + return; + } + + searchTimeoutRef.current = setTimeout(() => { + searchChapterText(text); + searchTimeoutRef.current = null; + }, SEARCH_DEBOUNCE_MS); + }, + [ + clearChapterSearch, + clearPendingSearch, + resetSearchResult, + searchChapterText, + setSearchText, + ], + ); + + const handleClearSearch = useCallback(() => { + clearPendingSearch(); + resetSearch(); + clearChapterSearch(); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [clearChapterSearch, clearPendingSearch, resetSearch]); + + const handleSubmitEditing = useCallback(() => { + clearPendingSearch(); + if (hasMatches) { + navigateChapterSearch('NEXT', searchText); + return; + } + if (hasSearchText) { + searchChapterText(searchText); + } + }, [ + clearPendingSearch, + hasMatches, + hasSearchText, + navigateChapterSearch, + searchChapterText, + searchText, + ]); return ( @@ -41,7 +121,8 @@ const ReaderSearchbar = ({ theme }: ReaderSearchbarProps) => { ref={inputRef} autoCapitalize="none" autoCorrect={false} - onChangeText={setSearchText} + onChangeText={handleSearchTextChange} + onSubmitEditing={handleSubmitEditing} placeholder="Search chapter" placeholderTextColor={theme.onSurfaceVariant} returnKeyType="search" @@ -50,6 +131,38 @@ const ReaderSearchbar = ({ theme }: ReaderSearchbarProps) => { submitBehavior="submit" value={searchText} /> + {hasSearchText ? ( + + {searchResult.current}/{searchResult.total} + + ) : null} + navigateChapterSearch('PREV', searchText)} + padding={6} + theme={theme} + /> + navigateChapterSearch('NEXT', searchText)} + padding={6} + style={styles.trailingButton} + theme={theme} + /> + {hasSearchText ? ( + + ) : null} ); @@ -58,6 +171,9 @@ const ReaderSearchbar = ({ theme }: ReaderSearchbarProps) => { export default ReaderSearchbar; const styles = StyleSheet.create({ + trailingButton: { + marginRight: 4, + }, container: { paddingHorizontal: 12, paddingTop: 8, @@ -68,6 +184,11 @@ const styles = StyleSheet.create({ minHeight: 44, paddingVertical: 0, }, + resultText: { + fontSize: 13, + minWidth: 40, + textAlign: 'center', + }, searchIcon: { marginLeft: 8, }, diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index ebcbdb17f4..b70669c78b 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -33,10 +33,11 @@ import { dismissTTSNotification, ttsMediaEmitter, } from '@utils/ttsNotification'; +import { ReaderSearchResult } from '../types'; type WebViewPostEvent = { type: string; - data?: { [key: string]: unknown }; + data?: unknown; autoStartTTS?: boolean; index?: number; total?: number; @@ -45,6 +46,8 @@ type WebViewPostEvent = { type WebViewReaderProps = { onPress(): void; onTouchStart?(): void; + onSearchResult(result: ReaderSearchResult): void; + searchTextRef: React.MutableRefObject; }; const onLogMessage = (payload: { nativeEvent: { data: string } }) => { @@ -67,6 +70,8 @@ const assetsUriPrefix = __DEV__ const WebViewReader: React.FC = ({ onPress, onTouchStart, + onSearchResult, + searchTextRef, }) => { const { novel, @@ -331,6 +336,13 @@ const WebViewReader: React.FC = ({ }`, ); + const searchText = searchTextRef.current.trim(); + if (searchText) { + webViewRef.current?.injectJavaScript( + `window.readerSearch?.search(${JSON.stringify(searchText)}); true;`, + ); + } + if (autoStartTTSRef.current) { autoStartTTSRef.current = false; setTimeout(() => { @@ -393,6 +405,18 @@ const WebViewReader: React.FC = ({ saveProgress(event.data); } break; + case 'search-result': + if (event.data && typeof event.data === 'object') { + const data = event.data as { + current?: unknown; + total?: unknown; + }; + onSearchResult({ + current: typeof data.current === 'number' ? data.current : 0, + total: typeof data.total === 'number' ? data.total : 0, + }); + } + break; case 'speak': if (event.data && typeof event.data === 'string') { if (typeof event.index === 'number') { diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index a825ade483..64ecdd98a1 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -229,6 +229,29 @@ export default function useChapter( ], ); + const searchChapterText = useCallback( + (text: string) => { + webViewRef.current?.injectJavaScript( + `window.readerSearch?.search(${JSON.stringify(text)}); true;`, + ); + }, + [webViewRef], + ); + + const clearChapterSearch = useCallback(() => { + webViewRef.current?.injectJavaScript('window.readerSearch?.clear(); true;'); + }, [webViewRef]); + + const navigateChapterSearch = useCallback( + (direction: 'NEXT' | 'PREV', text: string) => { + const method = direction === 'NEXT' ? 'next' : 'previous'; + webViewRef.current?.injectJavaScript( + `window.readerSearch?.${method}(${JSON.stringify(text)}); true;`, + ); + }, + [webViewRef], + ); + const scrollInterval = useRef(null); useEffect(() => { if (autoScroll) { @@ -355,6 +378,9 @@ export default function useChapter( saveProgress, hideHeader, navigateChapter, + navigateChapterSearch, + searchChapterText, + clearChapterSearch, refetch, setChapter, setLoading, @@ -372,6 +398,9 @@ export default function useChapter( saveProgress, hideHeader, navigateChapter, + navigateChapterSearch, + searchChapterText, + clearChapterSearch, refetch, setChapter, setLoading, diff --git a/src/screens/reader/types.ts b/src/screens/reader/types.ts new file mode 100644 index 0000000000..e125bffcb6 --- /dev/null +++ b/src/screens/reader/types.ts @@ -0,0 +1,9 @@ +export type ReaderSearchResult = { + current: number; + total: number; +}; + +export const EMPTY_READER_SEARCH_RESULT: ReaderSearchResult = { + current: 0, + total: 0, +}; From ef2e3d5e04b9c19976fc3f74416ac5a542181ce5 Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 16:33:21 +0200 Subject: [PATCH 06/13] fix(reader): improve chapter search reliability --- android/app/src/main/assets/css/index.css | 1 - android/app/src/main/assets/js/core.js | 217 +++++++++++++++--- .../reader/components/ReaderSearchbar.tsx | 11 +- .../reader/components/WebViewReader.tsx | 16 +- src/screens/reader/types.ts | 6 + 5 files changed, 208 insertions(+), 43 deletions(-) diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 6ba0065b94..c9a9b1c027 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -162,7 +162,6 @@ td { mark.lnreader-search-match { border-radius: 2px; - padding: 0 1px; color: inherit; background-color: color-mix( in srgb, diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 8685cbc125..79d4d7a6e3 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -765,25 +765,67 @@ window.addEventListener('load', () => { })(); window.readerSearch = new (function () { + const MIN_QUERY_LENGTH = 1; + const NODE_BATCH_SIZE = 80; + const MAX_RENDERED_MATCHES = 1500; + this.query = ''; this.index = -1; this.matches = []; + this.total = 0; + this.isTruncated = false; + this.searchToken = 0; + this.pendingSearchTimer = null; - this.emit = () => { + this.emit = (query = this.query) => { reader.post({ type: 'search-result', data: { + query, current: this.index >= 0 ? this.index + 1 : 0, - total: this.matches.length, + total: this.total, + renderedTotal: this.matches.length, + isTruncated: this.isTruncated, }, }); }; - this.clear = (emit = true, resetQuery = true) => { - if (resetQuery) { - this.query = ''; + this.cancelPendingSearch = () => { + this.searchToken += 1; + + if (this.pendingSearchTimer !== null) { + clearTimeout(this.pendingSearchTimer); + this.pendingSearchTimer = null; + } + }; + + this.refreshLayout = () => { + reader.refresh(); + + if (!reader.generalSettings.val.pageReader || !window.pageReader) { + return; } + const totalPages = parseInt( + (reader.chapterWidth + reader.readerSettings.val.padding * 2) / + reader.layoutWidth, + 10, + ); + + if (!Number.isFinite(totalPages) || totalPages <= 0) { + return; + } + + pageReader.totalPages.val = totalPages; + + if (pageReader.page.val >= totalPages) { + pageReader.movePage(totalPages - 1); + } + }; + + this.resetMatches = () => { + const touchedParents = new Set(); + document.querySelectorAll('mark.lnreader-search-match').forEach(mark => { const parent = mark.parentNode; if (!parent) { @@ -794,12 +836,28 @@ window.readerSearch = new (function () { document.createTextNode(mark.textContent || ''), mark, ); + touchedParents.add(parent); + }); + + touchedParents.forEach(parent => { parent.normalize(); }); this.matches = []; this.index = -1; - reader.refresh(); + this.total = 0; + this.isTruncated = false; + this.refreshLayout(); + }; + + this.clear = (emit = true, resetQuery = true) => { + this.cancelPendingSearch(); + + if (resetQuery) { + this.query = ''; + } + + this.resetMatches(); if (emit) { this.emit(); @@ -833,39 +891,70 @@ window.readerSearch = new (function () { return textNodes; }; - this.wrapMatches = (node, term, normalizedTerm) => { + this.countMatches = (text, normalizedTerm) => { + const normalizedText = text.toLowerCase(); + let matchCount = 0; + let matchIndex = normalizedText.indexOf(normalizedTerm); + + while (matchIndex !== -1) { + matchCount += 1; + matchIndex = normalizedText.indexOf( + normalizedTerm, + matchIndex + normalizedTerm.length, + ); + } + + return matchCount; + }; + + this.wrapMatches = (node, term, normalizedTerm, maxMatches) => { const text = node.nodeValue || ''; - const normalizedText = text.toLocaleLowerCase(); + const normalizedText = text.toLowerCase(); + let matchesAdded = 0; + let totalMatches = 0; let start = 0; let matchIndex = normalizedText.indexOf(normalizedTerm); if (matchIndex === -1) { - return; + return { rendered: matchesAdded, total: totalMatches }; } const fragment = document.createDocumentFragment(); while (matchIndex !== -1) { - if (matchIndex > start) { - fragment.appendChild( - document.createTextNode(text.slice(start, matchIndex)), - ); - } + totalMatches += 1; + + if (matchesAdded < maxMatches) { + if (matchIndex > start) { + fragment.appendChild( + document.createTextNode(text.slice(start, matchIndex)), + ); + } - const mark = document.createElement('mark'); - mark.className = 'lnreader-search-match'; - mark.textContent = text.slice(matchIndex, matchIndex + term.length); - fragment.appendChild(mark); + const mark = document.createElement('mark'); + mark.className = 'lnreader-search-match'; + mark.textContent = text.slice(matchIndex, matchIndex + term.length); + fragment.appendChild(mark); - start = matchIndex + term.length; - matchIndex = normalizedText.indexOf(normalizedTerm, start); + matchesAdded += 1; + start = matchIndex + term.length; + } + + matchIndex = normalizedText.indexOf( + normalizedTerm, + matchIndex + term.length, + ); } - if (start < text.length) { + if (matchesAdded > 0 && start < text.length) { fragment.appendChild(document.createTextNode(text.slice(start))); } - node.parentNode?.replaceChild(fragment, node); + if (matchesAdded > 0) { + node.parentNode?.replaceChild(fragment, node); + } + + return { rendered: matchesAdded, total: totalMatches }; }; this.hasLiveMatches = () => { @@ -929,33 +1018,85 @@ window.readerSearch = new (function () { this.emit(); }; - this.search = (query, preferredIndex = 0) => { - const term = String(query ?? '').trim(); - this.clear(false, false); - this.query = term; - - if (!term) { - this.emit(); - return; - } - - const normalizedTerm = term.toLocaleLowerCase(); - this.getTextNodes().forEach(node => { - this.wrapMatches(node, term, normalizedTerm); - }); + this.finishSearch = (query, preferredIndex, total) => { + this.pendingSearchTimer = null; this.matches = Array.from( reader.chapterElement.querySelectorAll('mark.lnreader-search-match'), ); - reader.refresh(); + this.total = total; + this.isTruncated = this.matches.length < this.total; + this.refreshLayout(); if (!this.matches.length) { - this.emit(); + this.emit(query); return; } this.focus(Math.max(0, Math.min(preferredIndex, this.matches.length - 1))); }; + this.search = (query, preferredIndex = 0) => { + const term = String(query ?? '').trim(); + this.cancelPendingSearch(); + this.resetMatches(); + this.query = term; + + if (!term || term.length < MIN_QUERY_LENGTH) { + this.emit(term); + return; + } + + const searchToken = this.searchToken; + const normalizedTerm = term.toLowerCase(); + const textNodes = this.getTextNodes(); + let textNodeIndex = 0; + let totalMatchCount = 0; + let renderedMatchCount = 0; + + const processBatch = () => { + if (searchToken !== this.searchToken || term !== this.query) { + this.pendingSearchTimer = null; + return; + } + + const batchEnd = Math.min( + textNodeIndex + NODE_BATCH_SIZE, + textNodes.length, + ); + + while (textNodeIndex < batchEnd) { + const node = textNodes[textNodeIndex]; + + if (renderedMatchCount < MAX_RENDERED_MATCHES) { + const matchCounts = this.wrapMatches( + node, + term, + normalizedTerm, + MAX_RENDERED_MATCHES - renderedMatchCount, + ); + renderedMatchCount += matchCounts.rendered; + totalMatchCount += matchCounts.total; + } else { + totalMatchCount += this.countMatches( + node.nodeValue || '', + normalizedTerm, + ); + } + + textNodeIndex += 1; + } + + if (textNodeIndex < textNodes.length) { + this.pendingSearchTimer = setTimeout(processBatch, 0); + return; + } + + this.finishSearch(term, preferredIndex, totalMatchCount); + }; + + processBatch(); + }; + this.next = query => { if (this.ensureSearch(query)) { this.focus(this.index + 1); diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 5f0cbe1157..eba818a7d9 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -29,8 +29,13 @@ const ReaderSearchbar = ({ const searchTimeoutRef = useRef | null>(null); const { searchChapterText, clearChapterSearch, navigateChapterSearch } = useChapterContext(); - const hasSearchText = searchText.trim().length > 0; - const hasMatches = hasSearchText && searchResult.total > 0; + const normalizedSearchText = searchText.trim(); + const hasSearchText = normalizedSearchText.length > 0; + const hasCurrentSearchResult = searchResult.query === normalizedSearchText; + const hasMatches = hasCurrentSearchResult && searchResult.renderedTotal > 0; + const resultTotalText = searchResult.isTruncated + ? `${searchResult.renderedTotal}+` + : String(searchResult.total); const clearPendingSearch = useCallback(() => { if (searchTimeoutRef.current) { @@ -133,7 +138,7 @@ const ReaderSearchbar = ({ /> {hasSearchText ? ( - {searchResult.current}/{searchResult.total} + {searchResult.current}/{resultTotalText} ) : null} = ({ case 'search-result': if (event.data && typeof event.data === 'object') { const data = event.data as { + query?: unknown; current?: unknown; total?: unknown; + renderedTotal?: unknown; + isTruncated?: unknown; }; + const query = typeof data.query === 'string' ? data.query : ''; + if (query !== searchTextRef.current.trim()) { + break; + } + const total = typeof data.total === 'number' ? data.total : 0; onSearchResult({ + query, current: typeof data.current === 'number' ? data.current : 0, - total: typeof data.total === 'number' ? data.total : 0, + total, + renderedTotal: + typeof data.renderedTotal === 'number' + ? data.renderedTotal + : total, + isTruncated: data.isTruncated === true, }); } break; diff --git a/src/screens/reader/types.ts b/src/screens/reader/types.ts index e125bffcb6..f4a6726024 100644 --- a/src/screens/reader/types.ts +++ b/src/screens/reader/types.ts @@ -1,9 +1,15 @@ export type ReaderSearchResult = { + query: string; current: number; total: number; + renderedTotal: number; + isTruncated: boolean; }; export const EMPTY_READER_SEARCH_RESULT: ReaderSearchResult = { + query: '', current: 0, total: 0, + renderedTotal: 0, + isTruncated: false, }; From 13eb387fac2a3192eb0e41edc5606170c9ca8e6c Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 20:22:22 +0200 Subject: [PATCH 07/13] fix(reader): hide ReaderFooter when search is open --- src/screens/reader/ReaderScreen.tsx | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index bea325eae9..74dd477b23 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -111,6 +111,19 @@ export const ChapterContent = ({ } }, [hidden]); + useEffect(() => { + if (hidden) { + return; + } + + webViewRef.current?.injectJavaScript(` + if (window.reader?.hidden) { + window.reader.hidden.val = ${searchVisible ? 'true' : 'false'}; + } + true; + `); + }, [hidden, searchVisible, webViewRef]); + const scrollToStart = () => requestAnimationFrame(() => { webViewRef?.current?.injectJavaScript( @@ -197,12 +210,14 @@ export const ChapterContent = ({ resetSearchResult={resetSearchResult} resetSearch={resetSearch} /> - + {!searchVisible ? ( + + ) : null} ) : null} From 84475b72b1f62f2734a401aba284c0804fd01edc Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 20:54:00 +0200 Subject: [PATCH 08/13] fix(reader): add support for search in bionic reading mode --- android/app/src/main/assets/js/core.js | 251 +++++++++++++++++-------- 1 file changed, 173 insertions(+), 78 deletions(-) diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 79d4d7a6e3..3855f3e36b 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -766,8 +766,38 @@ window.addEventListener('load', () => { window.readerSearch = new (function () { const MIN_QUERY_LENGTH = 1; - const NODE_BATCH_SIZE = 80; + const SEGMENT_BATCH_SIZE = 80; const MAX_RENDERED_MATCHES = 1500; + const INLINE_TEXT_ELEMENTS = new Set([ + 'A', + 'ABBR', + 'B', + 'BDI', + 'BDO', + 'CITE', + 'CODE', + 'DATA', + 'DFN', + 'EM', + 'I', + 'KBD', + 'MARK', + 'Q', + 'RP', + 'RT', + 'RUBY', + 'S', + 'SAMP', + 'SMALL', + 'SPAN', + 'STRONG', + 'SUB', + 'SUP', + 'TIME', + 'U', + 'VAR', + 'WBR', + ]); this.query = ''; this.index = -1; @@ -832,10 +862,10 @@ window.readerSearch = new (function () { return; } - parent.replaceChild( - document.createTextNode(mark.textContent || ''), - mark, - ); + while (mark.firstChild) { + parent.insertBefore(mark.firstChild, mark); + } + parent.removeChild(mark); touchedParents.add(parent); }); @@ -864,14 +894,43 @@ window.readerSearch = new (function () { } }; - this.getTextNodes = () => { + this.getTextBlock = node => { + let element = node.parentElement; + + while ( + element && + element !== reader.chapterElement && + INLINE_TEXT_ELEMENTS.has(element.nodeName) + ) { + element = element.parentElement; + } + + return element || reader.chapterElement; + }; + + this.hasElementBetween = (previousNode, nextNode, selector) => { + const range = document.createRange(); + + try { + range.setStartAfter(previousNode); + range.setEndBefore(nextNode); + return !!range.cloneContents().querySelector(selector); + } catch { + return false; + } finally { + range.detach?.(); + } + }; + + this.getTextSegments = () => { + const segments = []; const textNodes = []; const walker = document.createTreeWalker( reader.chapterElement, NodeFilter.SHOW_TEXT, { acceptNode: node => { - if (!node.nodeValue?.trim()) { + if (!node.nodeValue) { return NodeFilter.FILTER_REJECT; } if (node.parentElement?.closest('script, style')) { @@ -888,73 +947,114 @@ window.readerSearch = new (function () { node = walker.nextNode(); } - return textNodes; + textNodes.forEach(textNode => { + const block = this.getTextBlock(textNode); + const previousSegment = segments[segments.length - 1]; + const previousEntry = + previousSegment?.entries[previousSegment.entries.length - 1]; + const startsNewSegment = + !previousSegment || + previousSegment.block !== block || + this.hasElementBetween( + previousEntry.node, + textNode, + 'br, hr, img, table, ul, ol', + ); + + if (startsNewSegment) { + segments.push({ + block, + entries: [], + text: '', + }); + } + + const segment = segments[segments.length - 1]; + const start = segment.text.length; + const text = textNode.nodeValue || ''; + + segment.entries.push({ + end: start + text.length, + node: textNode, + start, + }); + segment.text += text; + }); + + return segments.filter(segment => segment.text.trim()); }; - this.countMatches = (text, normalizedTerm) => { - const normalizedText = text.toLowerCase(); - let matchCount = 0; + this.findSegmentMatches = (segment, normalizedTerm) => { + const matches = []; + const normalizedText = segment.text.toLowerCase(); let matchIndex = normalizedText.indexOf(normalizedTerm); while (matchIndex !== -1) { - matchCount += 1; + matches.push(matchIndex); matchIndex = normalizedText.indexOf( normalizedTerm, matchIndex + normalizedTerm.length, ); } - return matchCount; + return matches; }; - this.wrapMatches = (node, term, normalizedTerm, maxMatches) => { - const text = node.nodeValue || ''; - const normalizedText = text.toLowerCase(); - let matchesAdded = 0; - let totalMatches = 0; - let start = 0; - let matchIndex = normalizedText.indexOf(normalizedTerm); - - if (matchIndex === -1) { - return { rendered: matchesAdded, total: totalMatches }; - } - - const fragment = document.createDocumentFragment(); - - while (matchIndex !== -1) { - totalMatches += 1; - - if (matchesAdded < maxMatches) { - if (matchIndex > start) { - fragment.appendChild( - document.createTextNode(text.slice(start, matchIndex)), - ); - } - - const mark = document.createElement('mark'); - mark.className = 'lnreader-search-match'; - mark.textContent = text.slice(matchIndex, matchIndex + term.length); - fragment.appendChild(mark); - - matchesAdded += 1; - start = matchIndex + term.length; + this.getTextPosition = (segment, offset, preferPrevious = false) => { + for (const entry of segment.entries) { + if (offset >= entry.start && offset < entry.end) { + return { + node: entry.node, + offset: offset - entry.start, + }; } - matchIndex = normalizedText.indexOf( - normalizedTerm, - matchIndex + term.length, - ); + if (preferPrevious && offset === entry.end) { + return { + node: entry.node, + offset: entry.node.nodeValue?.length || 0, + }; + } } - if (matchesAdded > 0 && start < text.length) { - fragment.appendChild(document.createTextNode(text.slice(start))); - } + const entry = segment.entries[segment.entries.length - 1]; + return { + node: entry.node, + offset: entry.node.nodeValue?.length || 0, + }; + }; - if (matchesAdded > 0) { - node.parentNode?.replaceChild(fragment, node); + this.removeEmptyInlineTextElement = node => { + if ( + !node || + node.nodeType !== Node.ELEMENT_NODE || + !INLINE_TEXT_ELEMENTS.has(node.nodeName) || + node.textContent || + node.querySelector('img, svg, canvas, video, audio, iframe') + ) { + return; } - return { rendered: matchesAdded, total: totalMatches }; + const parent = node.parentNode; + parent?.removeChild(node); + this.removeEmptyInlineTextElement(parent); + }; + + this.wrapSegmentMatch = (segment, start, length) => { + const end = start + length; + const range = document.createRange(); + const mark = document.createElement('mark'); + const startPosition = this.getTextPosition(segment, start); + const endPosition = this.getTextPosition(segment, end, true); + + mark.className = 'lnreader-search-match'; + range.setStart(startPosition.node, startPosition.offset); + range.setEnd(endPosition.node, endPosition.offset); + mark.appendChild(range.extractContents()); + range.insertNode(mark); + this.removeEmptyInlineTextElement(mark.previousSibling); + this.removeEmptyInlineTextElement(mark.nextSibling); + range.detach?.(); }; this.hasLiveMatches = () => { @@ -1048,8 +1148,8 @@ window.readerSearch = new (function () { const searchToken = this.searchToken; const normalizedTerm = term.toLowerCase(); - const textNodes = this.getTextNodes(); - let textNodeIndex = 0; + const textSegments = this.getTextSegments(); + let textSegmentIndex = 0; let totalMatchCount = 0; let renderedMatchCount = 0; @@ -1060,33 +1160,28 @@ window.readerSearch = new (function () { } const batchEnd = Math.min( - textNodeIndex + NODE_BATCH_SIZE, - textNodes.length, + textSegmentIndex + SEGMENT_BATCH_SIZE, + textSegments.length, ); - while (textNodeIndex < batchEnd) { - const node = textNodes[textNodeIndex]; - - if (renderedMatchCount < MAX_RENDERED_MATCHES) { - const matchCounts = this.wrapMatches( - node, - term, - normalizedTerm, - MAX_RENDERED_MATCHES - renderedMatchCount, - ); - renderedMatchCount += matchCounts.rendered; - totalMatchCount += matchCounts.total; - } else { - totalMatchCount += this.countMatches( - node.nodeValue || '', - normalizedTerm, - ); - } + while (textSegmentIndex < batchEnd) { + const segment = textSegments[textSegmentIndex]; + const matches = this.findSegmentMatches(segment, normalizedTerm); + const renderableMatches = matches.slice( + 0, + Math.max(0, MAX_RENDERED_MATCHES - renderedMatchCount), + ); + + renderableMatches.reverse().forEach(matchIndex => { + this.wrapSegmentMatch(segment, matchIndex, normalizedTerm.length); + }); - textNodeIndex += 1; + renderedMatchCount += renderableMatches.length; + totalMatchCount += matches.length; + textSegmentIndex += 1; } - if (textNodeIndex < textNodes.length) { + if (textSegmentIndex < textSegments.length) { this.pendingSearchTimer = setTimeout(processBatch, 0); return; } From 4096738ac93c73dd26fb7a01958b32110a15471f Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 21:02:36 +0200 Subject: [PATCH 09/13] fix(reader): localize chapter search placeholder --- src/screens/reader/components/ReaderSearchbar.tsx | 3 ++- strings/languages/en/strings.json | 3 ++- strings/types/index.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index eba818a7d9..08ea6d0aec 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -5,6 +5,7 @@ import { IconButtonV2 } from '@components'; import { ThemeColors } from '@theme/types'; import { useChapterContext } from '../ChapterContext'; import { ReaderSearchResult } from '../types'; +import { getString } from '@strings/translations'; interface ReaderSearchbarProps { theme: ThemeColors; @@ -128,7 +129,7 @@ const ReaderSearchbar = ({ autoCorrect={false} onChangeText={handleSearchTextChange} onSubmitEditing={handleSubmitEditing} - placeholder="Search chapter" + placeholder={getString('readerScreen.searchPlaceholder')} placeholderTextColor={theme.onSurfaceVariant} returnKeyType="search" selectionColor={theme.primary} diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index 3293b344ee..0bcb5d7ba1 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -495,7 +495,8 @@ "finished": "Finished", "nextChapter": "Next: %{name}", "noNextChapter": "There's no next chapter", - "noPreviousChapter": "There's no previous chapter" + "noPreviousChapter": "There's no previous chapter", + "searchPlaceholder": "Search chapter" }, "readerSettings": { "autoScrollInterval": "Scroll interval (seconds)", diff --git a/strings/types/index.ts b/strings/types/index.ts index fdc5dc591f..49dd56535f 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -411,6 +411,7 @@ export interface StringMap { 'readerScreen.nextChapter': 'string'; 'readerScreen.noNextChapter': 'string'; 'readerScreen.noPreviousChapter': 'string'; + 'readerScreen.searchPlaceholder': 'string'; 'readerSettings.autoScrollInterval': 'string'; 'readerSettings.autoScrollOffset': 'string'; 'readerSettings.backgroundColor': 'string'; From 86e35ca1f550b6bfcffbc3adcd173a635c792b8a Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 22:02:48 +0200 Subject: [PATCH 10/13] feat(reader): block short word search queries --- android/app/src/main/assets/js/core.js | 2 +- .../reader/components/ReaderSearchbar.tsx | 31 +++++++++++++++++-- strings/languages/en/strings.json | 1 + strings/types/index.ts | 1 + 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 3855f3e36b..dbb5b2db48 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -765,7 +765,7 @@ window.addEventListener('load', () => { })(); window.readerSearch = new (function () { - const MIN_QUERY_LENGTH = 1; + const MIN_QUERY_LENGTH = 3; const SEGMENT_BATCH_SIZE = 80; const MAX_RENDERED_MATCHES = 1500; const INLINE_TEXT_ELEMENTS = new Set([ diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 08ea6d0aec..3ec380edc6 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -17,6 +17,7 @@ interface ReaderSearchbarProps { } const SEARCH_DEBOUNCE_MS = 300; +const MIN_SEARCH_LENGTH = 3; const ReaderSearchbar = ({ theme, @@ -32,6 +33,8 @@ const ReaderSearchbar = ({ useChapterContext(); const normalizedSearchText = searchText.trim(); const hasSearchText = normalizedSearchText.length > 0; + const isSearchBlocked = + hasSearchText && normalizedSearchText.length < MIN_SEARCH_LENGTH; const hasCurrentSearchResult = searchResult.query === normalizedSearchText; const hasMatches = hasCurrentSearchResult && searchResult.renderedTotal > 0; const resultTotalText = searchResult.isTruncated @@ -63,7 +66,9 @@ const ReaderSearchbar = ({ resetSearchResult(); clearPendingSearch(); - if (!text.trim()) { + const normalizedText = text.trim(); + + if (!normalizedText || normalizedText.length < MIN_SEARCH_LENGTH) { clearChapterSearch(); return; } @@ -91,6 +96,11 @@ const ReaderSearchbar = ({ const handleSubmitEditing = useCallback(() => { clearPendingSearch(); + if (isSearchBlocked) { + clearChapterSearch(); + return; + } + if (hasMatches) { navigateChapterSearch('NEXT', searchText); return; @@ -102,6 +112,8 @@ const ReaderSearchbar = ({ clearPendingSearch, hasMatches, hasSearchText, + isSearchBlocked, + clearChapterSearch, navigateChapterSearch, searchChapterText, searchText, @@ -137,7 +149,7 @@ const ReaderSearchbar = ({ submitBehavior="submit" value={searchText} /> - {hasSearchText ? ( + {hasSearchText && !isSearchBlocked ? ( {searchResult.current}/{resultTotalText} @@ -170,6 +182,16 @@ const ReaderSearchbar = ({ /> ) : null} + {isSearchBlocked ? ( + + {getString('readerScreen.searchMinLength', { + count: MIN_SEARCH_LENGTH, + })} + + ) : null} ); }; @@ -195,6 +217,11 @@ const styles = StyleSheet.create({ minWidth: 40, textAlign: 'center', }, + helperText: { + fontSize: 12, + marginTop: 4, + paddingHorizontal: 12, + }, searchIcon: { marginLeft: 8, }, diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index 0bcb5d7ba1..368bf738d2 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -496,6 +496,7 @@ "nextChapter": "Next: %{name}", "noNextChapter": "There's no next chapter", "noPreviousChapter": "There's no previous chapter", + "searchMinLength": "Enter at least %{count} characters to search", "searchPlaceholder": "Search chapter" }, "readerSettings": { diff --git a/strings/types/index.ts b/strings/types/index.ts index 49dd56535f..39c8c286d9 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -411,6 +411,7 @@ export interface StringMap { 'readerScreen.nextChapter': 'string'; 'readerScreen.noNextChapter': 'string'; 'readerScreen.noPreviousChapter': 'string'; + 'readerScreen.searchMinLength': 'string'; 'readerScreen.searchPlaceholder': 'string'; 'readerSettings.autoScrollInterval': 'string'; 'readerSettings.autoScrollOffset': 'string'; From 2338f30b5abbc55612580163305ff49fe37f625e Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 22:24:31 +0200 Subject: [PATCH 11/13] feat(reader): make android back button exit search --- src/screens/reader/ReaderScreen.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 74dd477b23..ef9527b7d6 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -93,6 +93,17 @@ export const ChapterContent = ({ resetSearchResult(); }, [resetSearchResult, setSearchText]); + useBackHandler( + useCallback(() => { + if (searchVisible) { + setSearchVisible(false); + return true; + } + + return false; + }, [searchVisible]), + ); + useEffect(() => { setBookmarked(chapter.bookmark ?? false); }, [chapter]); From c65f6a77a099084837131ea2938a50c4aaa96171 Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 23:24:28 +0200 Subject: [PATCH 12/13] feat(reader): add exception for special character search --- android/app/src/main/assets/js/core.js | 6 +++++- src/screens/reader/components/ReaderSearchbar.tsx | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index dbb5b2db48..1a435324d1 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -768,6 +768,7 @@ window.readerSearch = new (function () { const MIN_QUERY_LENGTH = 3; const SEGMENT_BATCH_SIZE = 80; const MAX_RENDERED_MATCHES = 1500; + const SPECIAL_CHARACTER_REGEX = /[^\p{L}\p{N}\s]/u; const INLINE_TEXT_ELEMENTS = new Set([ 'A', 'ABBR', @@ -1141,7 +1142,10 @@ window.readerSearch = new (function () { this.resetMatches(); this.query = term; - if (!term || term.length < MIN_QUERY_LENGTH) { + if ( + !term || + (term.length < MIN_QUERY_LENGTH && !SPECIAL_CHARACTER_REGEX.test(term)) + ) { this.emit(term); return; } diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 3ec380edc6..f8acc0723f 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -18,6 +18,7 @@ interface ReaderSearchbarProps { const SEARCH_DEBOUNCE_MS = 300; const MIN_SEARCH_LENGTH = 3; +const SPECIAL_CHARACTER_REGEX = /[^\p{L}\p{N}\s]/u; const ReaderSearchbar = ({ theme, @@ -33,8 +34,12 @@ const ReaderSearchbar = ({ useChapterContext(); const normalizedSearchText = searchText.trim(); const hasSearchText = normalizedSearchText.length > 0; + const hasSpecialCharacter = + SPECIAL_CHARACTER_REGEX.test(normalizedSearchText); const isSearchBlocked = - hasSearchText && normalizedSearchText.length < MIN_SEARCH_LENGTH; + hasSearchText && + normalizedSearchText.length < MIN_SEARCH_LENGTH && + !hasSpecialCharacter; const hasCurrentSearchResult = searchResult.query === normalizedSearchText; const hasMatches = hasCurrentSearchResult && searchResult.renderedTotal > 0; const resultTotalText = searchResult.isTruncated @@ -68,7 +73,13 @@ const ReaderSearchbar = ({ const normalizedText = text.trim(); - if (!normalizedText || normalizedText.length < MIN_SEARCH_LENGTH) { + const containsSpecialCharacter = + SPECIAL_CHARACTER_REGEX.test(normalizedText); + + if ( + !normalizedText || + (normalizedText.length < MIN_SEARCH_LENGTH && !containsSpecialCharacter) + ) { clearChapterSearch(); return; } From 55a381bbc657f8a6d41965f5f92085c0d729db93 Mon Sep 17 00:00:00 2001 From: Shisi Date: Mon, 6 Jul 2026 23:32:28 +0200 Subject: [PATCH 13/13] refactor(reader): split chapter search script from core --- android/app/src/main/assets/js/core.js | 445 ------------------ android/app/src/main/assets/js/search.js | 442 +++++++++++++++++ .../reader/components/WebViewReader.tsx | 1 + 3 files changed, 443 insertions(+), 445 deletions(-) create mode 100644 android/app/src/main/assets/js/search.js diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 1a435324d1..207fcf6d03 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -764,451 +764,6 @@ window.addEventListener('load', () => { }); })(); -window.readerSearch = new (function () { - const MIN_QUERY_LENGTH = 3; - const SEGMENT_BATCH_SIZE = 80; - const MAX_RENDERED_MATCHES = 1500; - const SPECIAL_CHARACTER_REGEX = /[^\p{L}\p{N}\s]/u; - const INLINE_TEXT_ELEMENTS = new Set([ - 'A', - 'ABBR', - 'B', - 'BDI', - 'BDO', - 'CITE', - 'CODE', - 'DATA', - 'DFN', - 'EM', - 'I', - 'KBD', - 'MARK', - 'Q', - 'RP', - 'RT', - 'RUBY', - 'S', - 'SAMP', - 'SMALL', - 'SPAN', - 'STRONG', - 'SUB', - 'SUP', - 'TIME', - 'U', - 'VAR', - 'WBR', - ]); - - this.query = ''; - this.index = -1; - this.matches = []; - this.total = 0; - this.isTruncated = false; - this.searchToken = 0; - this.pendingSearchTimer = null; - - this.emit = (query = this.query) => { - reader.post({ - type: 'search-result', - data: { - query, - current: this.index >= 0 ? this.index + 1 : 0, - total: this.total, - renderedTotal: this.matches.length, - isTruncated: this.isTruncated, - }, - }); - }; - - this.cancelPendingSearch = () => { - this.searchToken += 1; - - if (this.pendingSearchTimer !== null) { - clearTimeout(this.pendingSearchTimer); - this.pendingSearchTimer = null; - } - }; - - this.refreshLayout = () => { - reader.refresh(); - - if (!reader.generalSettings.val.pageReader || !window.pageReader) { - return; - } - - const totalPages = parseInt( - (reader.chapterWidth + reader.readerSettings.val.padding * 2) / - reader.layoutWidth, - 10, - ); - - if (!Number.isFinite(totalPages) || totalPages <= 0) { - return; - } - - pageReader.totalPages.val = totalPages; - - if (pageReader.page.val >= totalPages) { - pageReader.movePage(totalPages - 1); - } - }; - - this.resetMatches = () => { - const touchedParents = new Set(); - - document.querySelectorAll('mark.lnreader-search-match').forEach(mark => { - const parent = mark.parentNode; - if (!parent) { - return; - } - - while (mark.firstChild) { - parent.insertBefore(mark.firstChild, mark); - } - parent.removeChild(mark); - touchedParents.add(parent); - }); - - touchedParents.forEach(parent => { - parent.normalize(); - }); - - this.matches = []; - this.index = -1; - this.total = 0; - this.isTruncated = false; - this.refreshLayout(); - }; - - this.clear = (emit = true, resetQuery = true) => { - this.cancelPendingSearch(); - - if (resetQuery) { - this.query = ''; - } - - this.resetMatches(); - - if (emit) { - this.emit(); - } - }; - - this.getTextBlock = node => { - let element = node.parentElement; - - while ( - element && - element !== reader.chapterElement && - INLINE_TEXT_ELEMENTS.has(element.nodeName) - ) { - element = element.parentElement; - } - - return element || reader.chapterElement; - }; - - this.hasElementBetween = (previousNode, nextNode, selector) => { - const range = document.createRange(); - - try { - range.setStartAfter(previousNode); - range.setEndBefore(nextNode); - return !!range.cloneContents().querySelector(selector); - } catch { - return false; - } finally { - range.detach?.(); - } - }; - - this.getTextSegments = () => { - const segments = []; - const textNodes = []; - const walker = document.createTreeWalker( - reader.chapterElement, - NodeFilter.SHOW_TEXT, - { - acceptNode: node => { - if (!node.nodeValue) { - return NodeFilter.FILTER_REJECT; - } - if (node.parentElement?.closest('script, style')) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }, - }, - ); - let node = walker.nextNode(); - - while (node) { - textNodes.push(node); - node = walker.nextNode(); - } - - textNodes.forEach(textNode => { - const block = this.getTextBlock(textNode); - const previousSegment = segments[segments.length - 1]; - const previousEntry = - previousSegment?.entries[previousSegment.entries.length - 1]; - const startsNewSegment = - !previousSegment || - previousSegment.block !== block || - this.hasElementBetween( - previousEntry.node, - textNode, - 'br, hr, img, table, ul, ol', - ); - - if (startsNewSegment) { - segments.push({ - block, - entries: [], - text: '', - }); - } - - const segment = segments[segments.length - 1]; - const start = segment.text.length; - const text = textNode.nodeValue || ''; - - segment.entries.push({ - end: start + text.length, - node: textNode, - start, - }); - segment.text += text; - }); - - return segments.filter(segment => segment.text.trim()); - }; - - this.findSegmentMatches = (segment, normalizedTerm) => { - const matches = []; - const normalizedText = segment.text.toLowerCase(); - let matchIndex = normalizedText.indexOf(normalizedTerm); - - while (matchIndex !== -1) { - matches.push(matchIndex); - matchIndex = normalizedText.indexOf( - normalizedTerm, - matchIndex + normalizedTerm.length, - ); - } - - return matches; - }; - - this.getTextPosition = (segment, offset, preferPrevious = false) => { - for (const entry of segment.entries) { - if (offset >= entry.start && offset < entry.end) { - return { - node: entry.node, - offset: offset - entry.start, - }; - } - - if (preferPrevious && offset === entry.end) { - return { - node: entry.node, - offset: entry.node.nodeValue?.length || 0, - }; - } - } - - const entry = segment.entries[segment.entries.length - 1]; - return { - node: entry.node, - offset: entry.node.nodeValue?.length || 0, - }; - }; - - this.removeEmptyInlineTextElement = node => { - if ( - !node || - node.nodeType !== Node.ELEMENT_NODE || - !INLINE_TEXT_ELEMENTS.has(node.nodeName) || - node.textContent || - node.querySelector('img, svg, canvas, video, audio, iframe') - ) { - return; - } - - const parent = node.parentNode; - parent?.removeChild(node); - this.removeEmptyInlineTextElement(parent); - }; - - this.wrapSegmentMatch = (segment, start, length) => { - const end = start + length; - const range = document.createRange(); - const mark = document.createElement('mark'); - const startPosition = this.getTextPosition(segment, start); - const endPosition = this.getTextPosition(segment, end, true); - - mark.className = 'lnreader-search-match'; - range.setStart(startPosition.node, startPosition.offset); - range.setEnd(endPosition.node, endPosition.offset); - mark.appendChild(range.extractContents()); - range.insertNode(mark); - this.removeEmptyInlineTextElement(mark.previousSibling); - this.removeEmptyInlineTextElement(mark.nextSibling); - range.detach?.(); - }; - - this.hasLiveMatches = () => { - return ( - this.matches.length > 0 && - this.matches.every(match => reader.chapterElement.contains(match)) - ); - }; - - this.ensureSearch = query => { - const term = String(query ?? this.query ?? '').trim(); - if (!term) { - this.clear(); - return false; - } - - if (term !== this.query || !this.hasLiveMatches()) { - this.search(term, Math.max(0, this.index)); - } - - return this.matches.length > 0; - }; - - this.scrollToMatch = match => { - if (reader.generalSettings.val.pageReader && window.pageReader) { - const rect = match.getBoundingClientRect(); - const relativePage = Math.floor( - (rect.left + rect.width / 2) / reader.layoutWidth, - ); - const page = Math.max( - 0, - Math.min( - pageReader.totalPages.val - 1, - pageReader.page.val + relativePage, - ), - ); - pageReader.movePage(page); - return; - } - - match.scrollIntoView({ block: 'center', behavior: 'smooth' }); - }; - - this.focus = index => { - if (!this.matches.length) { - this.index = -1; - this.emit(); - return; - } - - this.matches[this.index]?.classList.remove( - 'lnreader-search-match-active', - ); - this.index = - ((index % this.matches.length) + this.matches.length) % - this.matches.length; - - const match = this.matches[this.index]; - match.classList.add('lnreader-search-match-active'); - this.scrollToMatch(match); - this.emit(); - }; - - this.finishSearch = (query, preferredIndex, total) => { - this.pendingSearchTimer = null; - this.matches = Array.from( - reader.chapterElement.querySelectorAll('mark.lnreader-search-match'), - ); - this.total = total; - this.isTruncated = this.matches.length < this.total; - this.refreshLayout(); - - if (!this.matches.length) { - this.emit(query); - return; - } - - this.focus(Math.max(0, Math.min(preferredIndex, this.matches.length - 1))); - }; - - this.search = (query, preferredIndex = 0) => { - const term = String(query ?? '').trim(); - this.cancelPendingSearch(); - this.resetMatches(); - this.query = term; - - if ( - !term || - (term.length < MIN_QUERY_LENGTH && !SPECIAL_CHARACTER_REGEX.test(term)) - ) { - this.emit(term); - return; - } - - const searchToken = this.searchToken; - const normalizedTerm = term.toLowerCase(); - const textSegments = this.getTextSegments(); - let textSegmentIndex = 0; - let totalMatchCount = 0; - let renderedMatchCount = 0; - - const processBatch = () => { - if (searchToken !== this.searchToken || term !== this.query) { - this.pendingSearchTimer = null; - return; - } - - const batchEnd = Math.min( - textSegmentIndex + SEGMENT_BATCH_SIZE, - textSegments.length, - ); - - while (textSegmentIndex < batchEnd) { - const segment = textSegments[textSegmentIndex]; - const matches = this.findSegmentMatches(segment, normalizedTerm); - const renderableMatches = matches.slice( - 0, - Math.max(0, MAX_RENDERED_MATCHES - renderedMatchCount), - ); - - renderableMatches.reverse().forEach(matchIndex => { - this.wrapSegmentMatch(segment, matchIndex, normalizedTerm.length); - }); - - renderedMatchCount += renderableMatches.length; - totalMatchCount += matches.length; - textSegmentIndex += 1; - } - - if (textSegmentIndex < textSegments.length) { - this.pendingSearchTimer = setTimeout(processBatch, 0); - return; - } - - this.finishSearch(term, preferredIndex, totalMatchCount); - }; - - processBatch(); - }; - - this.next = query => { - if (this.ensureSearch(query)) { - this.focus(this.index + 1); - } - }; - - this.previous = query => { - if (this.ensureSearch(query)) { - this.focus(this.index - 1); - } - }; -})(); - // text options (function () { van.derive(() => { diff --git a/android/app/src/main/assets/js/search.js b/android/app/src/main/assets/js/search.js new file mode 100644 index 0000000000..896964dbaf --- /dev/null +++ b/android/app/src/main/assets/js/search.js @@ -0,0 +1,442 @@ +window.readerSearch = new (function () { + const MIN_QUERY_LENGTH = 3; + const SEGMENT_BATCH_SIZE = 80; + const MAX_RENDERED_MATCHES = 1500; + const SPECIAL_CHARACTER_REGEX = /[^\p{L}\p{N}\s]/u; + const INLINE_TEXT_ELEMENTS = new Set([ + 'A', + 'ABBR', + 'B', + 'BDI', + 'BDO', + 'CITE', + 'CODE', + 'DATA', + 'DFN', + 'EM', + 'I', + 'KBD', + 'MARK', + 'Q', + 'RP', + 'RT', + 'RUBY', + 'S', + 'SAMP', + 'SMALL', + 'SPAN', + 'STRONG', + 'SUB', + 'SUP', + 'TIME', + 'U', + 'VAR', + 'WBR', + ]); + + this.query = ''; + this.index = -1; + this.matches = []; + this.total = 0; + this.isTruncated = false; + this.searchToken = 0; + this.pendingSearchTimer = null; + + this.emit = (query = this.query) => { + reader.post({ + type: 'search-result', + data: { + query, + current: this.index >= 0 ? this.index + 1 : 0, + total: this.total, + renderedTotal: this.matches.length, + isTruncated: this.isTruncated, + }, + }); + }; + + this.cancelPendingSearch = () => { + this.searchToken += 1; + + if (this.pendingSearchTimer !== null) { + clearTimeout(this.pendingSearchTimer); + this.pendingSearchTimer = null; + } + }; + + this.refreshLayout = () => { + reader.refresh(); + + if (!reader.generalSettings.val.pageReader || !window.pageReader) { + return; + } + + const totalPages = parseInt( + (reader.chapterWidth + reader.readerSettings.val.padding * 2) / + reader.layoutWidth, + 10, + ); + + if (!Number.isFinite(totalPages) || totalPages <= 0) { + return; + } + + pageReader.totalPages.val = totalPages; + + if (pageReader.page.val >= totalPages) { + pageReader.movePage(totalPages - 1); + } + }; + + this.resetMatches = () => { + const touchedParents = new Set(); + + document.querySelectorAll('mark.lnreader-search-match').forEach(mark => { + const parent = mark.parentNode; + if (!parent) { + return; + } + + while (mark.firstChild) { + parent.insertBefore(mark.firstChild, mark); + } + parent.removeChild(mark); + touchedParents.add(parent); + }); + + touchedParents.forEach(parent => { + parent.normalize(); + }); + + this.matches = []; + this.index = -1; + this.total = 0; + this.isTruncated = false; + this.refreshLayout(); + }; + + this.clear = (emit = true, resetQuery = true) => { + this.cancelPendingSearch(); + + if (resetQuery) { + this.query = ''; + } + + this.resetMatches(); + + if (emit) { + this.emit(); + } + }; + + this.getTextBlock = node => { + let element = node.parentElement; + + while ( + element && + element !== reader.chapterElement && + INLINE_TEXT_ELEMENTS.has(element.nodeName) + ) { + element = element.parentElement; + } + + return element || reader.chapterElement; + }; + + this.hasElementBetween = (previousNode, nextNode, selector) => { + const range = document.createRange(); + + try { + range.setStartAfter(previousNode); + range.setEndBefore(nextNode); + return !!range.cloneContents().querySelector(selector); + } catch { + return false; + } finally { + range.detach?.(); + } + }; + + this.getTextSegments = () => { + const segments = []; + const textNodes = []; + const walker = document.createTreeWalker( + reader.chapterElement, + NodeFilter.SHOW_TEXT, + { + acceptNode: node => { + if (!node.nodeValue) { + return NodeFilter.FILTER_REJECT; + } + if (node.parentElement?.closest('script, style')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }, + ); + let node = walker.nextNode(); + + while (node) { + textNodes.push(node); + node = walker.nextNode(); + } + + textNodes.forEach(textNode => { + const block = this.getTextBlock(textNode); + const previousSegment = segments[segments.length - 1]; + const previousEntry = + previousSegment?.entries[previousSegment.entries.length - 1]; + const startsNewSegment = + !previousSegment || + previousSegment.block !== block || + this.hasElementBetween( + previousEntry.node, + textNode, + 'br, hr, img, table, ul, ol', + ); + + if (startsNewSegment) { + segments.push({ + block, + entries: [], + text: '', + }); + } + + const segment = segments[segments.length - 1]; + const start = segment.text.length; + const text = textNode.nodeValue || ''; + + segment.entries.push({ + end: start + text.length, + node: textNode, + start, + }); + segment.text += text; + }); + + return segments.filter(segment => segment.text.trim()); + }; + + this.findSegmentMatches = (segment, normalizedTerm) => { + const matches = []; + const normalizedText = segment.text.toLowerCase(); + let matchIndex = normalizedText.indexOf(normalizedTerm); + + while (matchIndex !== -1) { + matches.push(matchIndex); + matchIndex = normalizedText.indexOf( + normalizedTerm, + matchIndex + normalizedTerm.length, + ); + } + + return matches; + }; + + this.getTextPosition = (segment, offset, preferPrevious = false) => { + for (const entry of segment.entries) { + if (offset >= entry.start && offset < entry.end) { + return { + node: entry.node, + offset: offset - entry.start, + }; + } + + if (preferPrevious && offset === entry.end) { + return { + node: entry.node, + offset: entry.node.nodeValue?.length || 0, + }; + } + } + + const entry = segment.entries[segment.entries.length - 1]; + return { + node: entry.node, + offset: entry.node.nodeValue?.length || 0, + }; + }; + + this.removeEmptyInlineTextElement = node => { + if ( + !node || + node.nodeType !== Node.ELEMENT_NODE || + !INLINE_TEXT_ELEMENTS.has(node.nodeName) || + node.textContent || + node.querySelector('img, svg, canvas, video, audio, iframe') + ) { + return; + } + + const parent = node.parentNode; + parent?.removeChild(node); + this.removeEmptyInlineTextElement(parent); + }; + + this.wrapSegmentMatch = (segment, start, length) => { + const end = start + length; + const range = document.createRange(); + const mark = document.createElement('mark'); + const startPosition = this.getTextPosition(segment, start); + const endPosition = this.getTextPosition(segment, end, true); + + mark.className = 'lnreader-search-match'; + range.setStart(startPosition.node, startPosition.offset); + range.setEnd(endPosition.node, endPosition.offset); + mark.appendChild(range.extractContents()); + range.insertNode(mark); + this.removeEmptyInlineTextElement(mark.previousSibling); + this.removeEmptyInlineTextElement(mark.nextSibling); + range.detach?.(); + }; + + this.hasLiveMatches = () => { + return ( + this.matches.length > 0 && + this.matches.every(match => reader.chapterElement.contains(match)) + ); + }; + + this.ensureSearch = query => { + const term = String(query ?? this.query ?? '').trim(); + if (!term) { + this.clear(); + return false; + } + + if (term !== this.query || !this.hasLiveMatches()) { + this.search(term, Math.max(0, this.index)); + } + + return this.matches.length > 0; + }; + + this.scrollToMatch = match => { + if (reader.generalSettings.val.pageReader && window.pageReader) { + const rect = match.getBoundingClientRect(); + const relativePage = Math.floor( + (rect.left + rect.width / 2) / reader.layoutWidth, + ); + const page = Math.max( + 0, + Math.min( + pageReader.totalPages.val - 1, + pageReader.page.val + relativePage, + ), + ); + pageReader.movePage(page); + return; + } + + match.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + + this.focus = index => { + if (!this.matches.length) { + this.index = -1; + this.emit(); + return; + } + + this.matches[this.index]?.classList.remove('lnreader-search-match-active'); + this.index = + ((index % this.matches.length) + this.matches.length) % + this.matches.length; + + const match = this.matches[this.index]; + match.classList.add('lnreader-search-match-active'); + this.scrollToMatch(match); + this.emit(); + }; + + this.finishSearch = (query, preferredIndex, total) => { + this.pendingSearchTimer = null; + this.matches = Array.from( + reader.chapterElement.querySelectorAll('mark.lnreader-search-match'), + ); + this.total = total; + this.isTruncated = this.matches.length < this.total; + this.refreshLayout(); + + if (!this.matches.length) { + this.emit(query); + return; + } + + this.focus(Math.max(0, Math.min(preferredIndex, this.matches.length - 1))); + }; + + this.search = (query, preferredIndex = 0) => { + const term = String(query ?? '').trim(); + this.cancelPendingSearch(); + this.resetMatches(); + this.query = term; + + if ( + !term || + (term.length < MIN_QUERY_LENGTH && !SPECIAL_CHARACTER_REGEX.test(term)) + ) { + this.emit(term); + return; + } + + const searchToken = this.searchToken; + const normalizedTerm = term.toLowerCase(); + const textSegments = this.getTextSegments(); + let textSegmentIndex = 0; + let totalMatchCount = 0; + let renderedMatchCount = 0; + + const processBatch = () => { + if (searchToken !== this.searchToken || term !== this.query) { + this.pendingSearchTimer = null; + return; + } + + const batchEnd = Math.min( + textSegmentIndex + SEGMENT_BATCH_SIZE, + textSegments.length, + ); + + while (textSegmentIndex < batchEnd) { + const segment = textSegments[textSegmentIndex]; + const matches = this.findSegmentMatches(segment, normalizedTerm); + const renderableMatches = matches.slice( + 0, + Math.max(0, MAX_RENDERED_MATCHES - renderedMatchCount), + ); + + renderableMatches.reverse().forEach(matchIndex => { + this.wrapSegmentMatch(segment, matchIndex, normalizedTerm.length); + }); + + renderedMatchCount += renderableMatches.length; + totalMatchCount += matches.length; + textSegmentIndex += 1; + } + + if (textSegmentIndex < textSegments.length) { + this.pendingSearchTimer = setTimeout(processBatch, 0); + return; + } + + this.finishSearch(term, preferredIndex, totalMatchCount); + }; + + processBatch(); + }; + + this.next = query => { + if (this.ensureSearch(query)) { + this.focus(this.index + 1); + } + }; + + this.previous = query => { + if (this.ensureSearch(query)) { + this.focus(this.index - 1); + } + }; +})(); diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index cc6644fd89..50bf091ef9 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -586,6 +586,7 @@ const WebViewReader: React.FC = ({ +