fix:修复阅读搜索定位问题

This commit is contained in:
HapeLee
2026-06-13 12:38:38 +08:00
parent 9be789f310
commit 97627be0da
7 changed files with 195 additions and 141 deletions
@@ -7,6 +7,7 @@ import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.utils.ChineseUtils
import kotlinx.coroutines.Dispatchers
@@ -21,8 +22,13 @@ class SearchContentRepository {
private var lastSearchResults: List<SearchResult>? = null
private var lastQueryKey: String? = null
fun getCache(bookUrl: String, query: String): List<SearchResult>? {
val key = "$bookUrl-$query"
fun getCache(
bookUrl: String,
query: String,
replaceEnabled: Boolean,
regexReplace: Boolean
): List<SearchResult>? {
val key = searchKey(bookUrl, query, replaceEnabled, regexReplace)
return if (lastQueryKey == key) lastSearchResults else null
}
@@ -61,10 +67,19 @@ class SearchContentRepository {
}
}
lastSearchResults = allResults
lastQueryKey = "${book.bookUrl}-$query"
lastQueryKey = searchKey(book.bookUrl, query, replaceEnabled, regexReplace)
emit(ArrayList(allResults))
}.flowOn(Dispatchers.Default)
private fun searchKey(
bookUrl: String,
query: String,
replaceEnabled: Boolean,
regexReplace: Boolean
): String {
return "$bookUrl-$query-$replaceEnabled-$regexReplace-${ReadBookConfig.titleMode}"
}
private suspend fun searchChapter(
query: String,
book: Book,
@@ -82,9 +97,25 @@ class SearchContentRepository {
else -> chapter.title
}
val mContent = contentProcessor.getContent(
book, chapter, chapterContent, useReplace = replaceEnabled
).toString()
val bodyContent = contentProcessor.getContent(
book,
chapter,
chapterContent,
includeTitle = false,
useReplace = replaceEnabled
)
val includeTitle = ReadBookConfig.titleMode != 2 ||
chapter.isVolume ||
bodyContent.textList.isEmpty()
val mContent = if (includeTitle) {
val title = chapter.getDisplayTitle(
contentProcessor.getTitleReplaceRules(),
useReplace = replaceEnabled && book.getUseReplaceRule()
)
listOf(title).plus(bodyContent.textList).joinToString("\n")
} else {
bodyContent.toString()
}
val matches = searchPosition(mContent, query, regexReplace)
@@ -258,6 +258,7 @@ sealed interface ReadBookIntent {
data class SetSearchResults(val results: List<SearchResult>, val index: Int, val query: String? = null) : ReadBookIntent
data class SetSearchResultIndex(val index: Int) : ReadBookIntent
data class SetShowingSearchResult(val value: Boolean) : ReadBookIntent
data class NavigateSearchResultByOffset(val offset: Int) : ReadBookIntent
data class NavigateToSearchResult(val result: SearchResult, val index: Int) : ReadBookIntent
data object RestoreLastBookProgress : ReadBookIntent
data object KeepCurrentBookProgress : ReadBookIntent
@@ -515,7 +516,11 @@ sealed interface ReadBookEffect {
// ReadView operations (require Activity/View reference)
data class UpdateReadViewConfig(val actions: Set<ConfigUpdateAction>) : ReadBookEffect
data class UpContent(val relativePosition: Int, val resetPageOffset: Boolean) : ReadBookEffect
data class UpContent(
val relativePosition: Int,
val resetPageOffset: Boolean,
val success: (() -> Unit)? = null,
) : ReadBookEffect
data class UpPageAnim(val upRecorder: Boolean) : ReadBookEffect
data object UpTime : ReadBookEffect
data class UpBattery(val level: Int) : ReadBookEffect
@@ -547,6 +552,7 @@ sealed interface ReadBookEffect {
val pageIndex: Int,
val lineIndex: Int,
val startCharIndex: Int,
val endRelativePage: Int,
val endLineIndex: Int,
val endCharIndex: Int,
) : ReadBookEffect
@@ -116,6 +116,7 @@ class ReadBookController(
}
private val popupAction by lazy { PopupAction(activity) }
private var screenTimeOut: Long = 0
private var pendingSearchResultMark: IntArray? = null
// justInitData moved to ViewModel (set on InitData intent)
val isAutoPage: Boolean get() = refs?.readView?.isAutoPage == true
@@ -599,6 +600,10 @@ class ReadBookController(
is ReadBookEffect.UpContent -> {
refs?.readView?.upContent(effect.relativePosition, effect.resetPageOffset)
effect.success?.invoke()
refs?.readView?.post {
consumePendingSearchResultMark()
}
if (effect.relativePosition == 0) onUnhandledEffect(ReadBookEffect.UpSeekBar)
}
@@ -611,7 +616,10 @@ class ReadBookController(
ReadBook.loadContent(false)
}
is ReadBookEffect.CancelSelect -> refs?.readView?.cancelSelect()
is ReadBookEffect.CancelSelect -> {
pendingSearchResultMark = null
refs?.readView?.cancelSelect()
}
is ReadBookEffect.MenuImageStyleChanged -> refs?.readView?.upPageAnim()
// ── Simple Activity-API effects ──
@@ -707,37 +715,35 @@ class ReadBookController(
is ReadBookEffect.NavigateToSearchResult -> {
if (effect.pageIndex < 0) {
// Chapter not loaded — open it, then mark in the success callback
ReadBook.openChapter(effect.chapterIndex) {
ReadBook.openChapter(
effect.chapterIndex,
effect.result.queryIndexInChapter
) {
val tc = ReadBook.curTextChapter ?: return@openChapter
val query = effect.result.query
val pos = viewModel.searchResultPositions(tc, effect.result, query)
val lineIndex = pos[1]
val charIndex = pos[2]
val endLineIndex = pos[3]
val endCharIndex = pos[4]
if (pos[0] < 0) return@openChapter
activity.lifecycleScope.launch(Main) {
navigatePageByPos(tc, pos[0])
markSearchResultOnPage(
intArrayOf(
pos[0], lineIndex, charIndex, endLineIndex, endCharIndex
)
markSearchResultAfterNavigation(
intArrayOf(pos[0], pos[1], pos[2], pos[3], pos[4], pos[5])
)
}
}
} else {
// Same chapter — navigate to page, then mark
val tc = ReadBook.curTextChapter ?: return
navigatePageByPos(tc, effect.pageIndex)
markSearchResultOnPage(
markSearchResultAfterNavigation(
intArrayOf(
effect.pageIndex, effect.lineIndex,
effect.startCharIndex, effect.endLineIndex, effect.endCharIndex
effect.startCharIndex, effect.endRelativePage,
effect.endLineIndex, effect.endCharIndex
)
)
}
}
is ReadBookEffect.ExitSearch -> {
pendingSearchResultMark = null
ReadBook.clearSearchResult()
refs?.readView?.cancelSelect(clearSearchResult = true)
}
@@ -1105,33 +1111,46 @@ class ReadBookController(
// ── Search result navigation helpers ─────────────────────────────
/**
* Navigate to the page at [pageIndex] within the current chapter.
*/
private fun navigatePageByPos(textChapter: TextChapter, pageIndex: Int) {
val pagePos = textChapter.getReadLength(pageIndex)
if (ReadBook.durChapterPos != pagePos) {
ReadBook.durChapterPos = pagePos
ReadBook.callBack?.upContent()
ReadBook.callBack?.cancelSelect()
private fun markSearchResultAfterNavigation(pos: IntArray) {
if (pos[0] < 0) return
val readView = refs?.readView ?: return
pendingSearchResultMark = pos
ReadBook.skipToPage(pos[0]) {
readView.post {
consumePendingSearchResultMark()
}
}
}
private fun consumePendingSearchResultMark(): Boolean {
val pos = pendingSearchResultMark ?: return false
val readView = refs?.readView ?: return false
if (ReadBook.durPageIndex != pos[0] || readView.curPage.textPage.index != pos[0]) {
return false
}
pendingSearchResultMark = null
markSearchResultOnPage(pos)
return true
}
/**
* Mark search result columns on the current page for highlighting.
* @param pos array of [pageIndex, lineIndex, startCharIndex, endLineIndex, endCharIndex]
* @param pos array of [pageIndex, lineIndex, startCharIndex, endRelativePage, endLineIndex, endCharIndex]
*/
private fun markSearchResultOnPage(pos: IntArray) {
val readView = refs?.readView ?: return
val lineIndex = pos[1]
val startCharIndex = pos[2]
val endLineIndex = pos[3]
val endCharIndex = pos[4]
val endRelativePage = pos[3]
val endLineIndex = pos[4]
val endCharIndex = pos[5]
ReadBook.clearSearchResult()
readView.cancelSelect(clearSearchResult = true)
isSelectingSearchResult = true
try {
readView.curPage.selectStartMoveIndex(0, lineIndex, startCharIndex)
readView.curPage.selectEndMoveIndex(0, endLineIndex, endCharIndex)
readView.curPage.selectEndMoveIndex(endRelativePage, endLineIndex, endCharIndex)
readView.isTextSelected = true
} finally {
isSelectingSearchResult = false
}
@@ -19,7 +19,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
@@ -99,6 +102,7 @@ fun ReadBookRouteScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
val menuBackdrop = rememberLayerBackdrop()
val menuHazeState = remember { HazeState() }
@@ -375,18 +379,20 @@ fun ReadBookRouteScreen(
// ── Search result collection (from Navigation3 search route) ──────
LaunchedEffect(viewModel) {
SearchContentResult.results.collect { result ->
effectsReady.await()
viewModel.onIntent(
ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query)
)
result.searchResults.getOrNull(result.index)?.let { searchResult ->
LaunchedEffect(viewModel, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
SearchContentResult.results.collect { result ->
effectsReady.await()
viewModel.onIntent(
ReadBookIntent.NavigateToSearchResult(searchResult, result.index)
ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query)
)
result.searchResults.getOrNull(result.index)?.let { searchResult ->
viewModel.onIntent(
ReadBookIntent.NavigateToSearchResult(searchResult, result.index)
)
}
SearchContentResult.resetReplayCache()
}
SearchContentResult.resetReplayCache()
}
}
@@ -44,14 +44,7 @@ fun ReadBookSearchBar(
) {
AppFloatingActionButton(
onClick = {
val prevIndex = currentIndex - 1
val result = state.searchResultList.getOrNull(prevIndex)
?: return@AppFloatingActionButton
onIntent(
ReadBookIntent.NavigateToSearchResult(
result, prevIndex
)
)
onIntent(ReadBookIntent.NavigateSearchResultByOffset(-1))
},
tooltipText = "上一个结果",
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
@@ -76,14 +69,7 @@ fun ReadBookSearchBar(
) {
AppFloatingActionButton(
onClick = {
val nextIndex = currentIndex + 1
val result = state.searchResultList.getOrNull(nextIndex)
?: return@AppFloatingActionButton
onIntent(
ReadBookIntent.NavigateToSearchResult(
result, nextIndex
)
)
onIntent(ReadBookIntent.NavigateSearchResultByOffset(1))
},
tooltipText = "下一个结果",
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
@@ -258,6 +258,10 @@ class ReadBookViewModel(
_uiState.update { it.copy(isShowingSearchResult = intent.value) }
}
is ReadBookIntent.NavigateSearchResultByOffset -> {
navigateSearchResultByOffset(intent.offset)
}
is ReadBookIntent.NavigateToSearchResult -> {
ReadBook.saveCurrentBookProgress()
_uiState.update {
@@ -1265,9 +1269,8 @@ class ReadBookViewModel(
) {
_uiState.update { syncFromReadBook(it) }
_effects.tryEmit(
ReadBookEffect.UpContent(relativePosition, resetPageOffset)
ReadBookEffect.UpContent(relativePosition, resetPageOffset, success)
)
success?.invoke()
}
override suspend fun upContentAwait(
@@ -1278,9 +1281,8 @@ class ReadBookViewModel(
withContext(Main.immediate) {
_uiState.update { syncFromReadBook(it) }
_effects.tryEmit(
ReadBookEffect.UpContent(relativePosition, resetPageOffset)
ReadBookEffect.UpContent(relativePosition, resetPageOffset, success)
)
success?.invoke()
}
}
@@ -2216,102 +2218,86 @@ class ReadBookViewModel(
): Array<Int> {
val pages = textChapter.pages
val content = textChapter.getContent()
if (pages.isEmpty()) return arrayOf(0, 0, 0, 0, 0)
if (pages.isEmpty()) return arrayOf(-1, 0, 0, 0, 0, 0)
val contentPosition = searchResult.queryIndexInChapter
.takeIf { it in content.indices && searchResultMatchesAt(content, it, searchResult, query) }
?: findSearchResultPosition(content, searchResult, query)
if (contentPosition < 0) return arrayOf(0, 0, 0, 0, 0)
val match = findSearchResultMatch(content, searchResult, query)
?: return arrayOf(-1, 0, 0, 0, 0, 0)
val contentPosition = match.first
val queryLength = match.second
if (contentPosition < 0 || queryLength <= 0) {
return arrayOf(-1, 0, 0, 0, 0, 0)
}
var pageIndex = 0
var length = pages[pageIndex].text.length
while (length < contentPosition && pageIndex + 1 < pages.size) {
pageIndex += 1
length += pages[pageIndex].text.length
}
if (length < contentPosition) return arrayOf(-1, 0, 0, 0, 0, 0)
val pageIndex = textChapter.getPageIndexByCharIndex(contentPosition)
.coerceIn(0, pages.lastIndex)
val currentPage = pages[pageIndex]
val matchLength = searchResult.matchLength.takeIf { it > 0 } ?: query.length
val endPosition = (contentPosition + matchLength).coerceAtMost(content.length)
val start = linePositionFor(currentPage, contentPosition, preferPreviousAtBoundary = false)
val end = linePositionFor(currentPage, endPosition, preferPreviousAtBoundary = true)
return arrayOf(pageIndex, start.lineIndex, start.charIndex, end.lineIndex, end.charIndex)
val lines = currentPage.lines
if (lines.isEmpty()) return arrayOf(-1, 0, 0, 0, 0, 0)
var lineIndex = 0
var currentLine = lines[lineIndex]
length = length - currentPage.text.length + currentLine.text.length
if (currentLine.isParagraphEnd) length++
while (length <= contentPosition && lineIndex + 1 < lines.size) {
lineIndex += 1
currentLine = lines[lineIndex]
length += currentLine.text.length
if (currentLine.isParagraphEnd) length++
}
var currentLineLength = currentLine.text.length
if (currentLine.isParagraphEnd) currentLineLength++
length -= currentLineLength
val charIndex = contentPosition - length
var addLine = 0
var charIndex2 = 0
if ((charIndex + queryLength) > currentLineLength) {
addLine = 1
charIndex2 = charIndex + queryLength - currentLineLength - 1
}
if ((lineIndex + addLine + 1) > currentPage.lines.size) {
addLine = -1
charIndex2 = charIndex + queryLength - currentLineLength - 1
}
return when (addLine) {
0 -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex, charIndex + queryLength - 1)
1 -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex + 1, charIndex2)
-1 -> arrayOf(pageIndex, lineIndex, charIndex, 1, 0, charIndex2)
else -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex, charIndex)
}
}
private fun findSearchResultPosition(
private fun findSearchResultMatch(
content: String,
searchResult: SearchResult,
query: String,
): Int {
if (query.isEmpty()) return -1
): Pair<Int, Int>? {
if (query.isEmpty()) return null
if (searchResult.isRegex) {
return runCatching {
Regex(query).findAll(content)
.drop(searchResult.resultCountWithinChapter)
.firstOrNull()
?.range
?.first
}.getOrNull() ?: -1
?.let { it.range.first to it.value.length }
}.getOrNull()
}
var count = 0
var index = content.indexOf(query, ignoreCase = true)
var index = content.indexOf(query)
while (count != searchResult.resultCountWithinChapter && index >= 0) {
index = content.indexOf(query, index + query.length, ignoreCase = true)
index = content.indexOf(query, index + query.length)
count += 1
}
return index
return index.takeIf { it >= 0 }?.let { it to query.length }
}
private fun searchResultMatchesAt(
content: String,
position: Int,
searchResult: SearchResult,
query: String,
): Boolean {
if (query.isEmpty()) return false
if (searchResult.isRegex) {
return runCatching {
Regex(query).find(content, position)?.range?.first == position
}.getOrDefault(false)
}
return position + query.length <= content.length &&
content.regionMatches(position, query, 0, query.length, ignoreCase = true)
}
private fun linePositionFor(
page: TextPage,
chapterPosition: Int,
preferPreviousAtBoundary: Boolean,
): SearchLinePosition {
if (page.lines.isEmpty()) return SearchLinePosition(0, 0)
var fallbackIndex = 0
page.lines.forEachIndexed { index, line ->
val lineEnd = line.chapterPosition + line.text.length +
if (line.isParagraphEnd) 1 else 0
if (chapterPosition < line.chapterPosition) {
return@forEachIndexed
}
if (preferPreviousAtBoundary && chapterPosition == line.chapterPosition && index > 0) {
val previousLine = page.lines[index - 1]
return SearchLinePosition(index - 1, previousLine.text.length)
}
if (chapterPosition <= lineEnd) {
return SearchLinePosition(
lineIndex = index,
charIndex = (chapterPosition - line.chapterPosition).coerceIn(0, line.text.length),
)
}
fallbackIndex = index
}
val lineIndex = fallbackIndex
val line = page.lines[lineIndex]
return SearchLinePosition(
lineIndex = lineIndex,
charIndex = (chapterPosition - line.chapterPosition).coerceIn(0, line.text.length),
)
}
private data class SearchLinePosition(
val lineIndex: Int,
val charIndex: Int,
)
/**
* Compute the search result position and emit [ReadBookEffect.NavigateToSearchResult]
* so the Controller can navigate and highlight.
@@ -2325,8 +2311,9 @@ class ReadBookViewModel(
val pos = searchResultPositions(textChapter, result, query)
val lineIndex = pos[1]
val charIndex = pos[2]
val endLineIndex = pos[3]
val endCharIndex = pos[4]
val endRelativePage = pos[3]
val endLineIndex = pos[4]
val endCharIndex = pos[5]
_effects.tryEmit(
ReadBookEffect.NavigateToSearchResult(
result = result,
@@ -2334,6 +2321,7 @@ class ReadBookViewModel(
pageIndex = pos[0],
lineIndex = lineIndex,
startCharIndex = charIndex,
endRelativePage = endRelativePage,
endLineIndex = endLineIndex,
endCharIndex = endCharIndex,
)
@@ -2347,6 +2335,7 @@ class ReadBookViewModel(
pageIndex = -1,
lineIndex = 0,
startCharIndex = 0,
endRelativePage = 0,
endLineIndex = 0,
endCharIndex = 0,
)
@@ -3450,6 +3439,18 @@ class ReadBookViewModel(
_effects.tryEmit(ReadBookEffect.ExitSearch)
}
private fun navigateSearchResultByOffset(offset: Int) {
val state = _uiState.value
val currentIndex = state.searchResultIndex.coerceSearchResultIndex(
state.searchResultList.size
)
val targetIndex = currentIndex + offset
val result = state.searchResultList.getOrNull(targetIndex) ?: return
ReadBook.saveCurrentBookProgress()
_uiState.update { it.copy(searchResultIndex = targetIndex) }
navigateToSearchResult(result)
}
override fun onCleared() {
super.onCleared()
if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
@@ -81,7 +81,12 @@ class SearchContentViewModel(
private fun initBook() {
viewModelScope.launch {
val book = bookRepository.getBook(bookUrl)
val cachedResults = searchContentRepository.getCache(bookUrl, _searchQuery.value)
val cachedResults = searchContentRepository.getCache(
bookUrl,
_searchQuery.value,
_replaceEnabled.value,
_regexReplace.value
)
_uiState.update {
it.copy(