feat(reader): Add Current Chapter Search (#1877)

* feat(reader): add search button and search input for word search

* fix(reader): make keyboard close on hideHeader

* chore(reader): format reader files

* chore(strings): regenerate string types

* feat(reader): add chapter word search

* fix(reader): improve chapter search reliability

* fix(reader): hide ReaderFooter when search is open

* fix(reader): add support for search in bionic reading mode

* fix(reader): localize chapter search placeholder

* feat(reader): block short word search queries

* feat(reader): make android back button exit search

* feat(reader): add exception for special character search

* refactor(reader): split chapter search script from core
This commit is contained in:
Shisii
2026-07-19 15:52:40 +02:00
committed by GitHub
parent 4404494ec9
commit 71da09ed44
11 changed files with 933 additions and 11 deletions
+15
View File
@@ -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;
+6
View File
@@ -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(/<br>(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*<br>))\s*/g, '');
}
reader.chapterElement.innerHTML = html;
const searchQuery = window.readerSearch?.query;
const searchIndex = window.readerSearch?.index;
if (searchQuery) {
window.readerSearch.search(searchQuery, searchIndex);
}
});
})();
+442
View File
@@ -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);
}
};
})();
+92 -8
View File
@@ -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<boolean>(
chapter.bookmark ?? false,
);
const [searchVisible, setSearchVisible] = useState(false);
const [searchResult, setSearchResult] = useState<ReaderSearchResult>(
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 (
<ErrorScreenV2
@@ -128,7 +198,12 @@ export const ChapterContent = ({
{loading ? (
<ChapterLoadingScreen />
) : (
<WebViewReader onPress={hideHeader} />
<WebViewReader
onPress={handleReaderPress}
onTouchStart={handleReaderTouchStart}
onSearchResult={setSearchResult}
searchTextRef={searchTextRef}
/>
)}
<ReaderBottomSheetV2 bottomSheetRef={readerSheetRef} />
{!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}
/>
<ReaderFooter
readerSheetRef={readerSheetRef}
scrollToStart={scrollToStart}
navigation={navigation}
openDrawer={openDrawerI}
/>
{!searchVisible ? (
<ReaderFooter
readerSheetRef={readerSheetRef}
scrollToStart={scrollToStart}
navigation={navigation}
openDrawer={openDrawerI}
/>
) : null}
</>
) : null}
</View>
@@ -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<React.SetStateAction<boolean>>;
searchVisible: boolean;
setSearchVisible: React.Dispatch<React.SetStateAction<boolean>>;
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}
</Text>
</View>
<IconButtonV2
name={searchVisible ? 'magnify-close' : 'magnify'}
size={24}
onPress={() => setSearchVisible(current => !current)}
color={theme.onSurface}
theme={theme}
/>
<IconButtonV2
name={bookmarked ? 'bookmark' : 'bookmark-outline'}
size={24}
@@ -116,6 +139,16 @@ const ReaderAppbar = ({
style={styles.bookmark}
/>
</View>
{searchVisible ? (
<ReaderSearchbar
theme={theme}
searchText={searchText}
setSearchText={setSearchText}
searchResult={searchResult}
resetSearchResult={resetSearchResult}
resetSearch={resetSearch}
/>
) : null}
</Animated.View>
);
};
@@ -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<TextInput>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<View style={styles.container}>
<View
style={[
styles.searchbar,
{ backgroundColor: theme.surface2 || theme.surface },
]}
>
<IconButtonV2
name="magnify"
color={theme.onSurfaceVariant}
onPress={() => inputRef.current?.focus()}
padding={6}
theme={theme}
style={styles.searchIcon}
/>
<TextInput
ref={inputRef}
autoCapitalize="none"
autoCorrect={false}
onChangeText={handleSearchTextChange}
onSubmitEditing={handleSubmitEditing}
placeholder={getString('readerScreen.searchPlaceholder')}
placeholderTextColor={theme.onSurfaceVariant}
returnKeyType="search"
selectionColor={theme.primary}
style={[styles.input, { color: theme.onSurface }]}
submitBehavior="submit"
value={searchText}
/>
{hasSearchText && !isSearchBlocked ? (
<Text style={[styles.resultText, { color: theme.onSurfaceVariant }]}>
{searchResult.current}/{resultTotalText}
</Text>
) : null}
<IconButtonV2
name="chevron-up"
color={theme.onSurface}
disabled={!hasMatches}
onPress={() => navigateChapterSearch('PREV', searchText)}
padding={6}
theme={theme}
/>
<IconButtonV2
name="chevron-down"
color={theme.onSurface}
disabled={!hasMatches}
onPress={() => navigateChapterSearch('NEXT', searchText)}
padding={6}
style={styles.trailingButton}
theme={theme}
/>
{hasSearchText ? (
<IconButtonV2
name="close"
color={theme.onSurface}
onPress={handleClearSearch}
padding={6}
style={styles.trailingButton}
theme={theme}
/>
) : null}
</View>
{isSearchBlocked ? (
<Text
style={[styles.helperText, { color: theme.onSurfaceVariant }]}
numberOfLines={1}
>
{getString('readerScreen.searchMinLength', {
count: MIN_SEARCH_LENGTH,
})}
</Text>
) : null}
</View>
);
};
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',
},
});
@@ -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<string>;
};
const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
@@ -63,7 +67,12 @@ const assetsUriPrefix = __DEV__
? 'http://localhost:8081/assets'
: 'file:///android_asset';
const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
const WebViewReader: React.FC<WebViewReaderProps> = ({
onPress,
onTouchStart,
onSearchResult,
searchTextRef,
}) => {
const {
novel,
chapter,
@@ -310,6 +319,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
return (
<WebView
ref={webViewRef}
onTouchStart={onTouchStart}
style={{ backgroundColor: readerSettings.theme }}
allowFileAccess={true}
originWhitelist={['*']}
@@ -326,6 +336,13 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ 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<WebViewReaderProps> = ({ 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<WebViewReaderProps> = ({ onPress }) => {
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/search.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script src="${pluginCustomJS}"></script>
<script>
+29
View File
@@ -245,6 +245,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<NodeJS.Timeout>(null);
useEffect(() => {
if (autoScroll) {
@@ -371,6 +394,9 @@ export default function useChapter(
saveProgress,
hideHeader,
navigateChapter,
navigateChapterSearch,
searchChapterText,
clearChapterSearch,
refetch,
setChapter,
setLoading,
@@ -388,6 +414,9 @@ export default function useChapter(
saveProgress,
hideHeader,
navigateChapter,
navigateChapterSearch,
searchChapterText,
clearChapterSearch,
refetch,
setChapter,
setLoading,
+15
View File
@@ -0,0 +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,
};
+3 -1
View File
@@ -496,7 +496,9 @@
"finished": "Finished",
"nextChapter": "Next: %{name}",
"noNextChapter": "There's no next chapter",
"noPreviousChapter": "There's no previous chapter"
"noPreviousChapter": "There's no previous chapter",
"searchMinLength": "Enter at least %{count} characters to search",
"searchPlaceholder": "Search chapter"
},
"readerSettings": {
"autoScrollInterval": "Scroll interval (seconds)",
+6
View File
@@ -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';
@@ -408,6 +412,8 @@ export interface StringMap {
'readerScreen.nextChapter': 'string';
'readerScreen.noNextChapter': 'string';
'readerScreen.noPreviousChapter': 'string';
'readerScreen.searchMinLength': 'string';
'readerScreen.searchPlaceholder': 'string';
'readerSettings.autoScrollInterval': 'string';
'readerSettings.autoScrollOffset': 'string';
'readerSettings.backgroundColor': 'string';