diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css index 50a31c03a8..c9a9b1c027 100644 --- a/android/app/src/main/assets/css/index.css +++ b/android/app/src/main/assets/css/index.css @@ -160,6 +160,21 @@ td { background-color: transparent; } +mark.lnreader-search-match { + border-radius: 2px; + 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..207fcf6d03 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; @@ -790,5 +791,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/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/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 30de4f444c..ef9527b7d6 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -16,8 +16,9 @@ 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'; +import { EMPTY_READER_SEARCH_RESULT, ReaderSearchResult } from './types'; const Chapter = ({ route, navigation }: ChapterScreenProps) => { const [open, setOpen] = useState(false); @@ -68,15 +69,72 @@ 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); + 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]); + + useBackHandler( + useCallback(() => { + if (searchVisible) { + setSearchVisible(false); + return true; + } + + return false; + }, [searchVisible]), + ); useEffect(() => { setBookmarked(chapter.bookmark ?? false); }, [chapter]); + useEffect(() => { + setSearchVisible(false); + resetSearch(); + }, [chapter.id, resetSearch]); + const { hidden, loading, error, webViewRef, hideHeader, refetch } = useChapterContext(); + useEffect(() => { + if (hidden) { + setSearchVisible(false); + } + }, [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( @@ -96,6 +154,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 ( + {keepScreenOn ? : null} {loading ? ( ) : ( - + )} {!hidden ? ( @@ -138,13 +213,22 @@ export const ChapterContent = ({ theme={theme} bookmarked={bookmarked} setBookmarked={setBookmarked} + searchVisible={searchVisible} + setSearchVisible={setSearchVisible} + searchText={searchText} + setSearchText={setSearchText} + searchResult={searchResult} + resetSearchResult={resetSearchResult} + resetSearch={resetSearch} /> - + {!searchVisible ? ( + + ) : null} > ) : null} diff --git a/src/screens/reader/components/ReaderAppbar.tsx b/src/screens/reader/components/ReaderAppbar.tsx index 597f8a04e8..9ef4f57699 100644 --- a/src/screens/reader/components/ReaderAppbar.tsx +++ b/src/screens/reader/components/ReaderAppbar.tsx @@ -13,12 +13,21 @@ 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'; +import { ReaderSearchResult } from '../types'; interface ReaderAppbarProps { theme: ThemeColors; goBack: () => void; bookmarked: boolean; setBookmarked: React.Dispatch>; + 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); @@ -28,6 +37,13 @@ const ReaderAppbar = ({ theme, bookmarked, setBookmarked, + searchVisible, + setSearchVisible, + searchText, + setSearchText, + searchResult, + resetSearchResult, + resetSearch, }: ReaderAppbarProps) => { const { chapter, novel } = useChapterContext(); const { statusBarHeight } = useNovelLayout(); @@ -105,6 +121,13 @@ const ReaderAppbar = ({ {chapter.name} + setSearchVisible(current => !current)} + color={theme.onSurface} + theme={theme} + /> + {searchVisible ? ( + + ) : null} ); }; diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx new file mode 100644 index 0000000000..f8acc0723f --- /dev/null +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -0,0 +1,246 @@ +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'; +import { getString } from '@strings/translations'; + +interface ReaderSearchbarProps { + theme: ThemeColors; + searchText: string; + setSearchText: (text: string) => void; + searchResult: ReaderSearchResult; + resetSearchResult: () => void; + resetSearch: () => void; +} + +const SEARCH_DEBOUNCE_MS = 300; +const MIN_SEARCH_LENGTH = 3; +const SPECIAL_CHARACTER_REGEX = /[^\p{L}\p{N}\s]/u; + +const ReaderSearchbar = ({ + theme, + searchText, + setSearchText, + searchResult, + resetSearchResult, + resetSearch, +}: ReaderSearchbarProps) => { + const inputRef = useRef(null); + const searchTimeoutRef = useRef | null>(null); + const { searchChapterText, clearChapterSearch, navigateChapterSearch } = + 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 && + !hasSpecialCharacter; + 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) { + 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(); + + const normalizedText = text.trim(); + + const containsSpecialCharacter = + SPECIAL_CHARACTER_REGEX.test(normalizedText); + + if ( + !normalizedText || + (normalizedText.length < MIN_SEARCH_LENGTH && !containsSpecialCharacter) + ) { + 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 (isSearchBlocked) { + clearChapterSearch(); + return; + } + + if (hasMatches) { + navigateChapterSearch('NEXT', searchText); + return; + } + if (hasSearchText) { + searchChapterText(searchText); + } + }, [ + clearPendingSearch, + hasMatches, + hasSearchText, + isSearchBlocked, + clearChapterSearch, + navigateChapterSearch, + searchChapterText, + searchText, + ]); + + return ( + + + inputRef.current?.focus()} + padding={6} + theme={theme} + style={styles.searchIcon} + /> + + {hasSearchText && !isSearchBlocked ? ( + + {searchResult.current}/{resultTotalText} + + ) : null} + navigateChapterSearch('PREV', searchText)} + padding={6} + theme={theme} + /> + navigateChapterSearch('NEXT', searchText)} + padding={6} + style={styles.trailingButton} + theme={theme} + /> + {hasSearchText ? ( + + ) : null} + + {isSearchBlocked ? ( + + {getString('readerScreen.searchMinLength', { + count: MIN_SEARCH_LENGTH, + })} + + ) : null} + + ); +}; + +export default ReaderSearchbar; + +const styles = StyleSheet.create({ + trailingButton: { + marginRight: 4, + }, + container: { + paddingHorizontal: 12, + paddingTop: 8, + }, + input: { + flex: 1, + fontSize: 16, + minHeight: 44, + paddingVertical: 0, + }, + resultText: { + fontSize: 13, + minWidth: 40, + textAlign: 'center', + }, + helperText: { + fontSize: 12, + marginTop: 4, + paddingHorizontal: 12, + }, + searchIcon: { + marginLeft: 8, + }, + searchbar: { + alignItems: 'center', + borderRadius: 24, + flexDirection: 'row', + minHeight: 48, + overflow: 'hidden', + }, +}); diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 99befe1cc7..50bf091ef9 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; @@ -44,6 +45,9 @@ type WebViewPostEvent = { type WebViewReaderProps = { onPress(): void; + onTouchStart?(): void; + onSearchResult(result: ReaderSearchResult): void; + searchTextRef: React.MutableRefObject; }; const onLogMessage = (payload: { nativeEvent: { data: string } }) => { @@ -63,7 +67,12 @@ const assetsUriPrefix = __DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'; -const WebViewReader: React.FC = ({ onPress }) => { +const WebViewReader: React.FC = ({ + onPress, + onTouchStart, + onSearchResult, + searchTextRef, +}) => { const { novel, chapter, @@ -94,7 +103,7 @@ const WebViewReader: React.FC = ({ onPress }) => { useEffect(() => { setReaderSettings( getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, + initialChapterReaderSettings, ); }, [chapter.id]); @@ -310,6 +319,7 @@ const WebViewReader: React.FC = ({ onPress }) => { return ( = ({ onPress }) => { }`, ); + const searchText = searchTextRef.current.trim(); + if (searchText) { + webViewRef.current?.injectJavaScript( + `window.readerSearch?.search(${JSON.stringify(searchText)}); true;`, + ); + } + if (autoStartTTSRef.current) { autoStartTTSRef.current = false; setTimeout(() => { @@ -355,9 +372,9 @@ const WebViewReader: React.FC = ({ onPress }) => { | 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') { @@ -388,6 +405,32 @@ const WebViewReader: React.FC = ({ onPress }) => { saveProgress(event.data); } break; + 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, + renderedTotal: + typeof data.renderedTotal === 'number' + ? data.renderedTotal + : total, + isTruncated: data.isTruncated === true, + }); + } + break; case 'speak': if (event.data && typeof event.data === 'string') { if (typeof event.index === 'number') { @@ -475,8 +518,8 @@ const WebViewReader: React.FC = ({ onPress }) => { --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}; @@ -486,20 +529,23 @@ const WebViewReader: React.FC = ({ onPress }) => { @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} @@ -509,34 +555,38 @@ const WebViewReader: React.FC = ({ onPress }) => { +