Merge remote-tracking branch 'origin/master'

This commit is contained in:
gedoor 2021-12-15 17:27:25 +08:00
commit 87265c7818
18 changed files with 725 additions and 169 deletions

View File

@ -27,4 +27,5 @@ object EventBus {
const val CHECK_SOURCE_DONE = "checkSourceDone"
const val TIP_COLOR = "tipColor"
const val SOURCE_CHANGED = "sourceChanged"
const val SEARCH_RESULT = "searchResult"
}

View File

@ -47,6 +47,7 @@ import io.legado.app.ui.book.read.page.ReadView
import io.legado.app.ui.book.read.page.entities.PageDirection
import io.legado.app.ui.book.read.page.provider.TextPageFactory
import io.legado.app.ui.book.searchContent.SearchContentActivity
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.BookmarkDialog
import io.legado.app.ui.book.toc.TocActivityResult
@ -66,6 +67,7 @@ class ReadBookActivity : BaseReadBookActivity(),
TextActionMenu.CallBack,
ContentTextView.CallBack,
ReadMenu.CallBack,
SearchMenu.CallBack,
ReadAloudDialog.CallBack,
ChangeSourceDialog.CallBack,
ReadBook.CallBack,
@ -94,17 +96,21 @@ class ReadBookActivity : BaseReadBookActivity(),
viewModel.replaceRuleChanged()
}
}
private val searchContentActivity =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
it ?: return@registerForActivityResult
it.data?.let { data ->
data.getIntExtra("index", ReadBook.durChapterIndex).let { index ->
viewModel.searchContentQuery = data.getStringExtra("query") ?: ""
val indexWithinChapter = data.getIntExtra("indexWithinChapter", 0)
skipToSearch(index, indexWithinChapter)
private val searchContentActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
it ?: return@registerForActivityResult
it.data?.let { data ->
data.getIntExtra("chapterIndex", ReadBook.durChapterIndex).let { _ ->
viewModel.searchContentQuery = data.getStringExtra("query") ?: ""
val searchResultIndex = data.getIntExtra("searchResultIndex", 0)
isShowingSearchResult = true
binding.searchMenu.updateSearchResultIndex(searchResultIndex)
binding.searchMenu.selectedSearchResult?.let { currentResult ->
skipToSearch(currentResult)
showActionMenu()
}
}
}
}
private var menu: Menu? = null
val textActionMenu: TextActionMenu by lazy {
TextActionMenu(this, this)
@ -117,6 +123,11 @@ class ReadBookActivity : BaseReadBookActivity(),
private var backupJob: Job? = null
override var autoPageProgress = 0
override var isAutoPage = false
override var isShowingSearchResult = false
override var isSelectingSearchResult = false
set(value) {
field = value && isShowingSearchResult
}
private var screenTimeOut: Long = 0
private var timeBatteryReceiver: TimeBatteryReceiver? = null
private var loadStates: Boolean = false
@ -644,6 +655,7 @@ class ReadBookActivity : BaseReadBookActivity(),
when {
BaseReadAloudService.isRun -> showReadAloudDialog()
isAutoPage -> showDialogFragment<AutoReadDialog>()
isShowingSearchResult -> binding.searchMenu.runMenuIn()
else -> binding.readMenu.runMenuIn()
}
}
@ -775,6 +787,10 @@ class ReadBookActivity : BaseReadBookActivity(),
showDialogFragment<MoreConfigDialog>()
}
override fun showSearchSetting() {
showDialogFragment<MoreConfigDialog>()
}
/**
* 更新状态栏,导航栏
*/
@ -783,6 +799,13 @@ class ReadBookActivity : BaseReadBookActivity(),
upNavigationBarColor()
}
override fun exitSearchMenu() {
if (isShowingSearchResult) {
isShowingSearchResult = false
binding.searchMenu.invalidate()
}
}
override fun showLogin() {
ReadBook.bookSource?.let {
startActivity<SourceLoginActivity> {
@ -879,34 +902,45 @@ class ReadBookActivity : BaseReadBookActivity(),
}
}
private fun skipToSearch(index: Int, indexWithinChapter: Int) {
viewModel.openChapter(index) {
val pages = ReadBook.curTextChapter?.pages ?: return@openChapter
val positions = viewModel.searchResultPositions(pages, indexWithinChapter)
ReadBook.skipToPage(positions[0]) {
launch {
binding.readView.curPage.selectStartMoveIndex(0, positions[1], positions[2])
delay(20L)
when (positions[3]) {
0 -> binding.readView.curPage.selectEndMoveIndex(
0,
positions[1],
positions[2] + viewModel.searchContentQuery.length - 1
)
1 -> binding.readView.curPage.selectEndMoveIndex(
0,
positions[1] + 1,
positions[4]
)
//consider change page, jump to scroll position
-1 -> binding.readView.curPage
.selectEndMoveIndex(1, 0, positions[4])
override fun navigateToSearch(searchResult: SearchResult) {
skipToSearch(searchResult)
}
private fun skipToSearch(searchResult: SearchResult) {
val previousResult = binding.searchMenu.previousSearchResult
fun jumpToPosition(){
ReadBook.curTextChapter?.let {
binding.searchMenu.updateSearchInfo()
val positions = viewModel.searchResultPositions(it, searchResult)
ReadBook.skipToPage(positions[0]) {
launch {
isSelectingSearchResult = true
binding.readView.curPage.selectStartMoveIndex(0, positions[1], positions[2])
when (positions[3]) {
0 -> binding.readView.curPage.selectEndMoveIndex(
0, positions[1], positions[2] + viewModel.searchContentQuery.length - 1
)
1 -> binding.readView.curPage.selectEndMoveIndex(
0, positions[1] + 1, positions[4]
)
//consider change page, jump to scroll position
-1 -> binding.readView.curPage.selectEndMoveIndex(1, 0, positions[4])
}
binding.readView.isTextSelected = true
isSelectingSearchResult = false
}
binding.readView.isTextSelected = true
delay(100L)
}
}
}
if (previousResult.chapterIndex != searchResult.chapterIndex) {
viewModel.openChapter(searchResult.chapterIndex) {
jumpToPosition()
}
} else {
jumpToPosition()
}
}
private fun startBackupJob() {

View File

@ -21,7 +21,8 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.page.entities.TextPage
import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.utils.msg
import io.legado.app.utils.postEvent
import io.legado.app.utils.toastOnUi
@ -262,17 +263,16 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
* 内容搜索跳转
*/
fun searchResultPositions(
pages: List<TextPage>,
indexWithinChapter: Int
textChapter: TextChapter,
searchResult: SearchResult
): Array<Int> {
// calculate search result's pageIndex
var content = ""
pages.map {
content += it.text
}
var count = 1
val pages = textChapter.pages
val content = textChapter.getContent()
var count = 0
var index = content.indexOf(searchContentQuery)
while (count != indexWithinChapter) {
while (count != searchResult.resultCountWithinChapter) {
index = content.indexOf(searchContentQuery, index + 1)
count += 1
}

View File

@ -0,0 +1,235 @@
package io.legado.app.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.animation.Animation
import android.widget.FrameLayout
import androidx.core.view.isVisible
import io.legado.app.R
import io.legado.app.constant.EventBus
import io.legado.app.databinding.ViewSearchMenuBinding
import io.legado.app.help.*
import io.legado.app.lib.theme.*
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.utils.*
import splitties.views.*
/**
* 搜索界面菜单
*/
class SearchMenu @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val callBack: CallBack get() = activity as CallBack
private val binding = ViewSearchMenuBinding.inflate(LayoutInflater.from(context), this, true)
private val menuBottomIn: Animation = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_bottom_in)
private val menuBottomOut: Animation = AnimationUtilsSupport.loadAnimation(context, R.anim.anim_readbook_bottom_out)
private val bgColor: Int = context.bottomBackground
private val textColor: Int = context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
private val bottomBackgroundList: ColorStateList =
Selector.colorBuild().setDefaultColor(bgColor).setPressedColor(ColorUtils.darkenColor(bgColor)).create()
private var onMenuOutEnd: (() -> Unit)? = null
private val searchResultList: MutableList<SearchResult> = mutableListOf()
private var currentSearchResultIndex: Int = 0
private var lastSearchResultIndex: Int = 0
private val hasSearchResult: Boolean
get() = searchResultList.isNotEmpty()
val selectedSearchResult: SearchResult?
get() = if (searchResultList.isNotEmpty()) searchResultList[currentSearchResultIndex] else null
val previousSearchResult: SearchResult
get() = searchResultList[lastSearchResultIndex]
init {
initAnimation()
initView()
bindEvent()
updateSearchInfo()
observeSearchResultList()
}
private fun observeSearchResultList() {
activity?.let { owner ->
eventObservable<List<SearchResult>>(EventBus.SEARCH_RESULT).observe(owner, {
searchResultList.clear()
searchResultList.addAll(it)
updateSearchInfo()
})
}
}
private fun initView() = binding.run {
llSearchBaseInfo.setBackgroundColor(bgColor)
tvCurrentSearchInfo.setTextColor(bottomBackgroundList)
llBottomBg.setBackgroundColor(bgColor)
fabLeft.backgroundTintList = bottomBackgroundList
fabLeft.setColorFilter(textColor)
fabRight.backgroundTintList = bottomBackgroundList
fabRight.setColorFilter(textColor)
tvMainMenu.setTextColor(textColor)
tvSearchResults.setTextColor(textColor)
tvSearchExit.setTextColor(textColor)
//tvSetting.setTextColor(textColor)
ivMainMenu.setColorFilter(textColor)
ivSearchResults.setColorFilter(textColor)
ivSearchExit.setColorFilter(textColor)
//ivSetting.setColorFilter(textColor)
ivSearchContentUp.setColorFilter(textColor)
ivSearchContentDown.setColorFilter(textColor)
tvCurrentSearchInfo.setTextColor(textColor)
}
fun runMenuIn() {
this.visible()
binding.llSearchBaseInfo.visible()
binding.llBottomBg.visible()
binding.vwMenuBg.visible()
binding.llSearchBaseInfo.startAnimation(menuBottomIn)
binding.llBottomBg.startAnimation(menuBottomIn)
}
fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) {
this.onMenuOutEnd = onMenuOutEnd
if (this.isVisible) {
binding.llSearchBaseInfo.startAnimation(menuBottomOut)
binding.llBottomBg.startAnimation(menuBottomOut)
}
}
fun updateSearchInfo() {
ReadBook.curTextChapter?.let {
binding.tvCurrentSearchInfo.text = context.getString(R.string.search_content_size) + ": ${searchResultList.size} / 当前章节: ${it.title}"
}
}
fun updateSearchResultIndex(updateIndex: Int) {
lastSearchResultIndex = currentSearchResultIndex
currentSearchResultIndex = when {
updateIndex < 0 -> 0
updateIndex >= searchResultList.size -> searchResultList.size - 1
else -> updateIndex
}
}
private fun bindEvent() = binding.run {
llSearchResults.setOnClickListener {
runMenuOut {
callBack.openSearchActivity(selectedSearchResult?.query)
}
}
//主菜单
llMainMenu.setOnClickListener {
runMenuOut {
callBack.showMenuBar()
this@SearchMenu.invisible()
}
}
//目录
llSearchExit.setOnClickListener {
runMenuOut {
callBack.exitSearchMenu()
this@SearchMenu.invisible()
}
}
//设置
// llSetting.setOnClickListener {
// runMenuOut {
// callBack.showSearchSetting()
// }
// }
fabLeft.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex - 1)
callBack.navigateToSearch(searchResultList[currentSearchResultIndex])
}
ivSearchContentUp.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex - 1)
callBack.navigateToSearch(searchResultList[currentSearchResultIndex])
}
ivSearchContentDown.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex + 1)
callBack.navigateToSearch(searchResultList[currentSearchResultIndex])
}
fabRight.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex + 1)
callBack.navigateToSearch(searchResultList[currentSearchResultIndex])
}
}
private fun initAnimation() {
//显示菜单
menuBottomIn.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
callBack.upSystemUiVisibility()
binding.fabLeft.visible(hasSearchResult)
binding.fabRight.visible(hasSearchResult)
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
val navigationBarHeight = if (ReadBookConfig.hideNavigationBar) {
activity?.navigationBarHeight ?: 0
} else {
0
}
binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
root.padding = 0
when (activity?.navigationBarGravity) {
Gravity.BOTTOM -> root.bottomPadding = navigationBarHeight
Gravity.LEFT -> root.leftPadding = navigationBarHeight
Gravity.RIGHT -> root.rightPadding = navigationBarHeight
}
}
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
})
//隐藏菜单
menuBottomOut.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
binding.llSearchBaseInfo.invisible()
binding.llBottomBg.invisible()
binding.vwMenuBg.invisible()
binding.vwMenuBg.setOnClickListener { runMenuOut() }
onMenuOutEnd?.invoke()
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
})
}
interface CallBack {
var isShowingSearchResult: Boolean
fun openSearchActivity(searchWord: String?)
fun showSearchSetting()
fun upSystemUiVisibility()
fun exitSearchMenu()
fun showMenuBar()
fun navigateToSearch(searchResult: SearchResult)
}
}

View File

@ -145,7 +145,11 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
if (it.isImage) {
drawImage(canvas, it, lineTop, lineBottom, isImageLine)
} else {
if(it.isSearchResult) {
textPaint.color = context.accentColor
}
canvas.drawText(it.charData, it.start, lineBase, textPaint)
textPaint.color = ReadBookConfig.textColor
}
if (it.selected) {
canvas.drawRect(it.start, lineTop, it.end, lineBottom, selectedPaint)
@ -392,6 +396,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
relativePos in selectStart[0] + 1 until selectEnd[0]
}
}
textChar.isSearchResult = textChar.selected && callBack.isSelectingSearchResult
}
}
}
@ -539,5 +544,6 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
val headerHeight: Int
val pageFactory: TextPageFactory
val isScroll: Boolean
var isSelectingSearchResult: Boolean
}
}

View File

@ -5,7 +5,8 @@ data class TextChar(
var start: Float,
var end: Float,
var selected: Boolean = false,
var isImage: Boolean = false
var isImage: Boolean = false,
var isSearchResult: Boolean = false
) {
fun isTouch(x: Float): Boolean {

View File

@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import com.github.liuyueyi.quick.transfer.ChineseUtils
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.EventBus
@ -13,7 +12,6 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.databinding.ActivitySearchContentBinding
import io.legado.app.help.AppConfig
import io.legado.app.help.BookHelp
import io.legado.app.lib.theme.bottomBackground
import io.legado.app.lib.theme.getPrimaryTextColor
@ -23,6 +21,7 @@ import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.ColorUtils
import io.legado.app.utils.applyTint
import io.legado.app.utils.observeEvent
import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -40,9 +39,7 @@ class SearchContentActivity :
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var searchResultCounts = 0
private var durChapterIndex = 0
private var searchResultList: MutableList<SearchResult> = mutableListOf()
override fun onActivityCreated(savedInstanceState: Bundle?) {
val bbg = bottomBackground
@ -103,7 +100,7 @@ class SearchContentActivity :
@SuppressLint("SetTextI18n")
private fun initBook() {
binding.tvCurrentSearchInfo.text = "搜索结果:$searchResultCounts"
binding.tvCurrentSearchInfo.text = this.getString(R.string.search_content_size) +": ${viewModel.searchResultCounts}"
viewModel.book?.let {
initCacheFileNames(it)
durChapterIndex = it.durChapterIndex
@ -115,7 +112,7 @@ class SearchContentActivity :
private fun initCacheFileNames(book: Book) {
launch(Dispatchers.IO) {
adapter.cacheFileNames.addAll(BookHelp.getChapterFiles(book))
viewModel.cacheChapterNames.addAll(BookHelp.getChapterFiles(book))
withContext(Dispatchers.Main) {
adapter.notifyItemRangeChanged(0, adapter.itemCount, true)
}
@ -126,7 +123,7 @@ class SearchContentActivity :
observeEvent<BookChapter>(EventBus.SAVE_CONTENT) { chapter ->
viewModel.book?.bookUrl?.let { bookUrl ->
if (chapter.bookUrl == bookUrl) {
adapter.cacheFileNames.add(chapter.getFileName())
viewModel.cacheChapterNames.add(chapter.getFileName())
adapter.notifyItemChanged(chapter.index, true)
}
}
@ -134,28 +131,26 @@ class SearchContentActivity :
}
@SuppressLint("SetTextI18n")
fun startContentSearch(newText: String) {
fun startContentSearch(query: String) {
// 按章节搜索内容
if (newText.isNotBlank()) {
if (query.isNotBlank()) {
adapter.clearItems()
searchResultList.clear()
binding.refreshProgressBar.isAutoLoading = true
searchResultCounts = 0
viewModel.lastQuery = newText
viewModel.searchResultList.clear()
viewModel.searchResultCounts = 0
viewModel.lastQuery = query
var searchResults = listOf<SearchResult>()
launch(Dispatchers.Main) {
appDb.bookChapterDao.getChapterList(viewModel.bookUrl).map { chapter ->
appDb.bookChapterDao.getChapterList(viewModel.bookUrl).map { bookChapter ->
binding.refreshProgressBar.isAutoLoading = true
withContext(Dispatchers.IO) {
if (isLocalBook
|| adapter.cacheFileNames.contains(chapter.getFileName())
) {
searchResults = searchChapter(newText, chapter)
if (isLocalBook || viewModel.cacheChapterNames.contains(bookChapter.getFileName())) {
searchResults = viewModel.searchChapter(query, bookChapter)
}
}
if (searchResults.isNotEmpty()) {
searchResultList.addAll(searchResults)
viewModel.searchResultList.addAll(searchResults)
binding.refreshProgressBar.isAutoLoading = false
binding.tvCurrentSearchInfo.text = "搜索结果:$searchResultCounts"
binding.tvCurrentSearchInfo.text = this@SearchContentActivity.getString(R.string.search_content_size) +": ${viewModel.searchResultCounts}"
adapter.addItems(searchResults)
searchResults = listOf()
}
@ -164,94 +159,17 @@ class SearchContentActivity :
}
}
private suspend fun searchChapter(query: String, chapter: BookChapter?): List<SearchResult> {
val searchResults: MutableList<SearchResult> = mutableListOf()
var positions: List<Int>
var replaceContents: List<String>?
var totalContents: String
if (chapter != null) {
viewModel.book?.let { book ->
val bookContent = BookHelp.getContent(book, chapter)
if (bookContent != null) {
//搜索替换后的正文
withContext(Dispatchers.IO) {
chapter.title = when (AppConfig.chineseConverterType) {
1 -> ChineseUtils.t2s(chapter.title)
2 -> ChineseUtils.s2t(chapter.title)
else -> chapter.title
}
replaceContents =
viewModel.contentProcessor!!.getContent(
book,
chapter,
bookContent,
chineseConvert = false,
reSegment = false
)
}
totalContents = replaceContents?.joinToString("") ?: bookContent
positions = searchPosition(totalContents, query)
var count = 1
positions.map {
val construct = constructText(totalContents, it, query)
val result = SearchResult(
index = searchResultCounts,
indexWithinChapter = count,
text = construct[1] as String,
chapterTitle = chapter.title,
query = query,
chapterIndex = chapter.index,
newPosition = construct[0] as Int,
contentPosition = it
)
count += 1
searchResultCounts += 1
searchResults.add(result)
}
}
}
}
return searchResults
}
private fun searchPosition(content: String, pattern: String): List<Int> {
val position: MutableList<Int> = mutableListOf()
var index = content.indexOf(pattern)
while (index >= 0) {
position.add(index)
index = content.indexOf(pattern, index + 1)
}
return position
}
private fun constructText(content: String, position: Int, query: String): Array<Any> {
// 构建关键词周边文字,在搜索结果里显示
// todo: 判断段落,只在关键词所在段落内分割
// todo: 利用标点符号分割完整的句
// todo: length和设置结合自由调整周边文字长度
val length = 20
var po1 = position - length
var po2 = position + query.length + length
if (po1 < 0) {
po1 = 0
}
if (po2 > content.length) {
po2 = content.length
}
val newPosition = position - po1
val newText = content.substring(po1, po2)
return arrayOf(newPosition, newText)
}
val isLocalBook: Boolean
get() = viewModel.book?.isLocalBook() == true
override fun openSearchResult(searchResult: SearchResult) {
postEvent(EventBus.SEARCH_RESULT, viewModel.searchResultList as List<SearchResult>)
val searchData = Intent()
searchData.putExtra("index", searchResult.chapterIndex)
searchData.putExtra("contentPosition", searchResult.contentPosition)
searchData.putExtra("searchResultIndex", viewModel.searchResultList.indexOf(searchResult))
searchData.putExtra("chapterIndex", searchResult.chapterIndex)
searchData.putExtra("contentPosition", searchResult.queryIndexInChapter)
searchData.putExtra("query", searchResult.query)
searchData.putExtra("indexWithinChapter", searchResult.indexWithinChapter)
searchData.putExtra("resultCountWithinChapter", searchResult.resultCountWithinChapter)
setResult(RESULT_OK, searchData)
finish()
}

View File

@ -2,16 +2,26 @@ package io.legado.app.ui.book.searchContent
import android.app.Application
import com.github.liuyueyi.quick.transfer.ChineseUtils
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.help.AppConfig
import io.legado.app.help.BookHelp
import io.legado.app.help.ContentProcessor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SearchContentViewModel(application: Application) : BaseViewModel(application) {
var bookUrl: String = ""
var book: Book? = null
var contentProcessor: ContentProcessor? = null
private var contentProcessor: ContentProcessor? = null
var lastQuery: String = ""
var searchResultCounts = 0
val cacheChapterNames = hashSetOf<String>()
val searchResultList: MutableList<SearchResult> = mutableListOf()
var selectedIndex = 0
fun initBook(bookUrl: String, success: () -> Unit) {
this.bookUrl = bookUrl
@ -25,4 +35,72 @@ class SearchContentViewModel(application: Application) : BaseViewModel(applicati
}
}
suspend fun searchChapter(query: String, chapter: BookChapter?): List<SearchResult> {
val searchResultsWithinChapter: MutableList<SearchResult> = mutableListOf()
if (chapter != null) {
book?.let { book ->
val chapterContent = BookHelp.getContent(book, chapter)
if (chapterContent != null) {
//搜索替换后的正文
val replaceContent: String
withContext(Dispatchers.IO) {
chapter.title = when (AppConfig.chineseConverterType) {
1 -> ChineseUtils.t2s(chapter.title)
2 -> ChineseUtils.s2t(chapter.title)
else -> chapter.title
}
replaceContent = contentProcessor!!.getContent(
book, chapter, chapterContent, chineseConvert = false, reSegment = false
).joinToString("")
}
val positions = searchPosition(replaceContent, query)
positions.forEachIndexed { index, position ->
val construct = getResultAndQueryIndex(replaceContent, position, query)
val result = SearchResult(
resultCountWithinChapter = index,
resultText = construct.second,
chapterTitle = chapter.title,
query = query,
chapterIndex = chapter.index,
queryIndexInResult = construct.first,
queryIndexInChapter = position
)
searchResultsWithinChapter.add(result)
}
searchResultCounts += searchResultsWithinChapter.size
}
}
}
return searchResultsWithinChapter
}
private fun searchPosition(chapterContent: String, pattern: String): List<Int> {
val position: MutableList<Int> = mutableListOf()
var index = chapterContent.indexOf(pattern)
while (index >= 0) {
position.add(index)
index = chapterContent.indexOf(pattern, index + 1)
}
return position
}
private fun getResultAndQueryIndex(content: String, queryIndexInContent: Int, query: String): Pair<Int, String> {
// 左右移动20个字符构建关键词周边文字在搜索结果里显示
// todo: 判断段落,只在关键词所在段落内分割
// todo: 利用标点符号分割完整的句
// todo: length和设置结合自由调整周边文字长度
val length = 20
var po1 = queryIndexInContent - length
var po2 = queryIndexInContent + query.length + length
if (po1 < 0) {
po1 = 0
}
if (po2 > content.length) {
po2 = content.length
}
val queryIndexInResult = queryIndexInContent - po1
val newText = content.substring(po1, po2)
return queryIndexInResult to newText
}
}

View File

@ -4,36 +4,29 @@ import android.text.Spanned
import androidx.core.text.HtmlCompat
data class SearchResult(
var index: Int = 0,
var indexWithinChapter: Int = 0,
var text: String = "",
var chapterTitle: String = "",
val resultCount: Int = 0,
val resultCountWithinChapter: Int = 0,
val resultText: String = "",
val chapterTitle: String = "",
val query: String,
var pageSize: Int = 0,
var chapterIndex: Int = 0,
var pageIndex: Int = 0,
var newPosition: Int = 0,
var contentPosition: Int = 0
val pageSize: Int = 0,
val chapterIndex: Int = 0,
val pageIndex: Int = 0,
val queryIndexInResult: Int = 0,
val queryIndexInChapter: Int = 0
) {
fun getHtmlCompat(textColor: String, accentColor: String): Spanned {
val html = colorPresentText(newPosition, query, text, textColor, accentColor) +
"<font color=#${accentColor}>($chapterTitle)</font>"
val queryIndexInSurrounding = resultText.indexOf(query)
val leftString = resultText.substring(0, queryIndexInSurrounding)
val rightString = resultText.substring(queryIndexInSurrounding + query.length, resultText.length)
val html = leftString.colorTextForHtml(textColor) +
query.colorTextForHtml(accentColor) +
rightString.colorTextForHtml(textColor) +
chapterTitle.colorTextForHtml(accentColor)
return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private fun colorPresentText(
position: Int,
center: String,
targetText: String,
textColor: String,
accentColor: String
): String {
val sub1 = text.substring(0, position)
val sub2 = text.substring(position + center.length, targetText.length)
return "<font color=#${textColor}>$sub1</font>" +
"<font color=#${accentColor}>$center</font>" +
"<font color=#${textColor}>$sub2</font>"
}
private fun String.colorTextForHtml(textColor: String) = "<font color=#${textColor}>$this</font>"
}

View File

@ -37,6 +37,12 @@
android:layout_height="match_parent"
android:visibility="gone" />
<io.legado.app.ui.book.read.SearchMenu
android:id="@+id/search_menu"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<View
android:id="@+id/navigation_bar"
android:layout_width="match_parent"

View File

@ -0,0 +1,276 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:id="@+id/vw_menu_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/content"
tools:layout_editor_absoluteX="0dp"
tools:layout_editor_absoluteY="0dp"
tools:visibility="invisible"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabLeft"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="上个结果"
android:rotation="180"
android:src="@drawable/ic_arrow_right"
android:tint="@color/primaryText"
android:tooltipText="@string/search_content"
app:backgroundTint="@color/background_menu"
app:elevation="2dp"
app:fabSize="mini"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:pressedTranslationZ="2dp"
tools:ignore="UnusedAttribute" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabRight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="下个结果"
android:src="@drawable/ic_arrow_right"
android:tint="@color/primaryText"
android:tooltipText="@string/search_content"
app:backgroundTint="@color/background_menu"
app:elevation="2dp"
app:fabSize="mini"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:pressedTranslationZ="2dp"
tools:ignore="UnusedAttribute" />
<LinearLayout
android:id="@+id/ll_search_base_info"
android:layout_width="match_parent"
android:layout_height="36dp"
android:background="@color/background_menu"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="10dp"
android:paddingRight="10dp"
app:layout_constraintBottom_toTopOf="@id/ll_bottom_bg">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_search_content_up"
android:layout_width="36dp"
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/go_to_top"
android:src="@drawable/ic_arrow_drop_up"
android:tooltipText="@string/go_to_top"
app:tint="@color/primaryText"
tools:ignore="UnusedAttribute" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_search_content_down"
android:layout_width="36dp"
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/go_to_bottom"
android:src="@drawable/ic_arrow_drop_down"
android:tooltipText="@string/go_to_bottom"
app:tint="@color/primaryText"
tools:ignore="UnusedAttribute" />
<TextView
android:id="@+id/tv_current_search_info"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:ellipsize="middle"
android:gravity="center_vertical"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:singleLine="true"
android:textColor="@color/primaryText"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_bottom_bg"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="8dp"
android:baselineAligned="false"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent">
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:importantForAccessibility="no" />
<!--结果按钮-->
<LinearLayout
android:id="@+id/ll_search_results"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="结果"
android:focusable="true"
android:orientation="vertical"
android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_search_results"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="结果"
android:src="@drawable/ic_toc"
app:tint="@color/primaryText"
tools:ignore="NestedWeights" />
<TextView
android:id="@+id/tv_search_results"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="3dp"
android:maxLines="1"
android:text="结果"
android:textColor="@color/primaryText"
android:textSize="12sp" />
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />
<!--调节按钮-->
<LinearLayout
android:id="@+id/ll_main_menu"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/read_aloud"
android:focusable="true"
android:orientation="vertical"
android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_main_menu"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="@string/main_menu"
android:src="@drawable/ic_menu"
app:tint="@color/primaryText"
tools:ignore="NestedWeights" />
<TextView
android:id="@+id/tv_main_menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="3dp"
android:maxLines="1"
android:text="@string/main_menu"
android:textColor="@color/primaryText"
android:textSize="12sp" />
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />
<!--界面按钮-->
<LinearLayout
android:id="@+id/ll_search_exit"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="退出"
android:focusable="true"
android:orientation="vertical"
android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_search_exit"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="退出"
android:src="@drawable/ic_auto_page_stop"
app:tint="@color/primaryText"
tools:ignore="NestedWeights" />
<TextView
android:id="@+id/tv_search_exit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="3dp"
android:maxLines="1"
android:text="退出"
android:textColor="@color/primaryText"
android:textSize="12sp" />
</LinearLayout>
<!-- <View-->
<!-- android:layout_width="0dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:layout_weight="2" />-->
<!-- &lt;!&ndash;设置按钮&ndash;&gt;-->
<!-- <LinearLayout-->
<!-- android:id="@+id/ll_setting"-->
<!-- android:layout_width="50dp"-->
<!-- android:layout_height="50dp"-->
<!-- android:background="?android:attr/selectableItemBackgroundBorderless"-->
<!-- android:clickable="true"-->
<!-- android:contentDescription="@string/setting"-->
<!-- android:focusable="true"-->
<!-- android:orientation="vertical"-->
<!-- android:paddingBottom="7dp">-->
<!-- <androidx.appcompat.widget.AppCompatImageView-->
<!-- android:id="@+id/iv_setting"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="0dp"-->
<!-- android:layout_weight="1"-->
<!-- android:contentDescription="@string/aloud_config"-->
<!-- android:src="@drawable/ic_settings"-->
<!-- app:tint="@color/primaryText"-->
<!-- tools:ignore="NestedWeights" />-->
<!-- <TextView-->
<!-- android:id="@+id/tv_setting"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_gravity="center_horizontal"-->
<!-- android:layout_marginTop="3dp"-->
<!-- android:maxLines="1"-->
<!-- android:text="@string/setting"-->
<!-- android:textColor="@color/primaryText"-->
<!-- android:textSize="12sp" />-->
<!-- </LinearLayout>-->
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:importantForAccessibility="no" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -678,6 +678,7 @@
<string name="alouding_disable">The selected text cannot be spoken in full text speech</string>
<string name="read_body_to_lh">Extend to cutout</string>
<string name="toc_updateing">Updating Chapters</string>
<string name="media_button_on_exit_title">Headset buttons are always available</string>
<string name="media_button_on_exit_summary">Headset buttons are available even exit the app.</string>
<string name="contributors">Contributors</string>
@ -903,6 +904,7 @@
<string name="login_header">登录头</string>
<string name="font_scale">字体大小</string>
<string name="font_scale_summary">当前字体大小:%.1f</string>
<string name="search_content_size">search result</string>
<string name="tts_speech_reduce">语速减</string>
<string name="tts_speech_add">语速加</string>
<string name="open_sys_dir_picker_error">打开系统文件夹选择器出错,自动打开应用文件夹选择器</string>

View File

@ -907,6 +907,7 @@
<string name="login_header">登录头</string>
<string name="font_scale">字体大小</string>
<string name="font_scale_summary">当前字体大小:%.1f</string>
<string name="search_content_size">search result</string>
<string name="tts_speech_reduce">语速减</string>
<string name="tts_speech_add">语速加</string>
<string name="open_sys_dir_picker_error">打开系统文件夹选择器出错,自动打开应用文件夹选择器</string>

View File

@ -907,6 +907,7 @@
<string name="login_header">登录头</string>
<string name="font_scale">字体大小</string>
<string name="font_scale_summary">当前字体大小:%.1f</string>
<string name="search_content_size">search result</string>
<string name="tts_speech_reduce">语速减</string>
<string name="tts_speech_add">语速加</string>
<string name="open_sys_dir_picker_error">打开系统文件夹选择器出错,自动打开应用文件夹选择器</string>

View File

@ -898,6 +898,7 @@
<string name="open_fun">打開方式</string>
<string name="use_browser_open">是否使用外部瀏覽器打開?</string>
<string name="see">查看</string>
<string name="search_content_size">搜索結果</string>
<string name="open">打開</string>
<string name="del_login_header">刪除登錄頭</string>
<string name="show_login_header">查看登錄頭</string>

View File

@ -910,5 +910,6 @@
<string name="tts_speech_add">語速加</string>
<string name="open_sys_dir_picker_error">打开系统文件夹选择器出错,自动打开应用文件夹选择器</string>
<string name="expand_text_menu">展开文本选择菜单</string>
<string name="search_content_size">搜索結果</string>
</resources>

View File

@ -777,6 +777,7 @@
<string name="select_theme">切换默认主题</string>
<string name="sort_by_lastUpdateTime">更新时间排序</string>
<string name="search_content">全文搜索</string>
<string name="search_content_size">搜索结果</string>
<string name="rss_source_empty">关注公众号[开源阅读]获取订阅源!</string>
<string name="explore_empty">当前没有发现源,关注公众号[开源阅读]添加带发现的书源!</string>
<string name="page_key_set_help">将焦点放到输入框按下物理按键会自动录入键值,多个按键会自动用英文逗号隔开.</string>

View File

@ -907,6 +907,7 @@
<string name="login_header">login header</string>
<string name="font_scale">font scale</string>
<string name="font_scale_summary">font scale:%.1f</string>
<string name="search_content_size">search result</string>
<string name="tts_speech_reduce">语速减</string>
<string name="tts_speech_add">语速加</string>
<string name="open_sys_dir_picker_error">打开系统文件夹选择器出错,自动打开应用文件夹选择器</string>