diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css
index 3f9f83ce..b599d4a5 100644
--- a/android/app/src/main/assets/css/index.css
+++ b/android/app/src/main/assets/css/index.css
@@ -163,6 +163,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 82992b32..207fcf6d 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 00000000..896964db
--- /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 ef464a9d..ef9527b7 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);
@@ -71,14 +72,69 @@ export const ChapterContent = ({
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(
@@ -98,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 (
) : (
-
+
)}
{!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 597f8a04..9ef4f576 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 00000000..f8acc072
--- /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 ba0bc9f8..50bf091e 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,
@@ -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(() => {
@@ -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') {
@@ -543,6 +586,7 @@ const WebViewReader: React.FC = ({ onPress }) => {
+