Просмотр исходного кода

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

HapeLee 4 месяцев назад
Родитель
Сommit
97627be0da

+ 37 - 6
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -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)
 

+ 7 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -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

+ 46 - 27
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -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
         }

+ 15 - 9
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -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()
         }
     }
 

+ 2 - 16
app/src/main/java/io/legado/app/ui/book/read/ReadBookSearchBar.kt

@@ -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,

+ 82 - 81
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -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 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)
+        }
 
-        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)
+        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) {

+ 6 - 1
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt

@@ -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(