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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)",
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user