feat: 合并 sigma 书源编辑与 JS 扩展功能

书源编辑界面:
- 新增事件监听、定制按钮 CheckBox 绑定
- 新增 callBackJs 字段接入正文规则
- 新增 menu_log 菜单项 (AppLogDialog)
- 新增撤销/重做回调 (KeyboardToolPop.CallBack)
- sendText() 智能滚动逻辑
- 保存时清除并发限制缓存

JS 扩展函数 (#1368):
- 新增 refreshBookToc() 方法
- reLoginView() 增加 deltaUp 参数
- upLoginData() 参数类型改为 Map<String, Any?>?
- SourceLoginDialog 适配新回调接口
- ExploreKindUiUseCase 适配新回调接口

BookInfo 事件监听:
- BookInfoViewModel 监听 REFRESH_BOOK_INFO/REFRESH_BOOK_TOC 事件
- 书源 JS 函数可触发书籍信息和目录刷新
This commit is contained in:
HapeLee
2026-06-25 22:30:08 +08:00
parent bea1095b0a
commit adf3cef981
10 changed files with 166 additions and 38 deletions
@@ -35,6 +35,7 @@ object EventBus {
const val UP_MANGA_CONFIG = "upMangaConfig" const val UP_MANGA_CONFIG = "upMangaConfig"
const val PLAY_MODE_CHANGED = "playModeChanged" const val PLAY_MODE_CHANGED = "playModeChanged"
const val REFRESH_BOOK_INFO = "refreshBookInfo" const val REFRESH_BOOK_INFO = "refreshBookInfo"
const val REFRESH_BOOK_TOC = "refreshBookToc"
const val REFRESH_BOOK_CONTENT = "refreshBookContent" const val REFRESH_BOOK_CONTENT = "refreshBookContent"
const val UP_TOC = "upToc" const val UP_TOC = "upToc"
@@ -72,8 +72,8 @@ class ExploreKindUiUseCase(
activity = activity, activity = activity,
source = source, source = source,
callback = object : SourceLoginJsExtensions.Callback { callback = object : SourceLoginJsExtensions.Callback {
override fun upUiData(data: Map<String, String?>?) = Unit override fun upUiData(data: Map<String, Any?>?) = Unit
override fun reUiView() = onRefreshKinds() override fun reUiView(deltaUp: Boolean) = onRefreshKinds()
} }
) )
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -74,6 +74,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -99,6 +102,35 @@ class BookInfoViewModel(
private val _effects = MutableSharedFlow<BookInfoEffect>(extraBufferCapacity = 8) private val _effects = MutableSharedFlow<BookInfoEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
init {
collectEventBus()
}
private fun collectEventBus() {
viewModelScope.launch {
eventFlow<Boolean>(EventBus.REFRESH_BOOK_INFO).collect {
currentBook?.let { book ->
refreshBook(book)
}
}
}
viewModelScope.launch {
eventFlow<Boolean>(EventBus.REFRESH_BOOK_TOC).collect {
currentBook?.let { book ->
loadChapter(book)
}
}
}
}
private inline fun <reified T> eventFlow(tag: String): Flow<T> = callbackFlow {
val obs = androidx.lifecycle.Observer<T> { trySend(it) }
com.jeremyliao.liveeventbus.LiveEventBus.get<T>(tag).observeForever(obs)
awaitClose {
com.jeremyliao.liveeventbus.LiveEventBus.get<T>(tag).removeObserver(obs)
}
}
private var currentBook: Book? = null private var currentBook: Book? = null
set(value) { set(value) {
field = value field = value
@@ -1,11 +1,13 @@
package io.legado.app.ui.book.source.edit package io.legado.app.ui.book.source.edit
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.EditText import android.widget.EditText
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
@@ -24,9 +26,7 @@ import io.legado.app.help.config.LocalConfig
import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector import io.legado.app.lib.dialogs.selector
//import io.legado.app.lib.theme.accentColor import io.legado.app.ui.about.AppLogDialog
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.debug.BookSourceDebugActivity import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
@@ -146,6 +146,7 @@ class BookSourceEditActivity :
ErrorCorrectionLevel.L ErrorCorrectionLevel.L
) )
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_help -> showHelp("ruleHelp") R.id.menu_help -> showHelp("ruleHelp")
R.id.menu_login -> viewModel.save(getSource()) { source -> R.id.menu_login -> viewModel.save(getSource()) { source ->
startActivity<SourceLoginActivity> { startActivity<SourceLoginActivity> {
@@ -261,6 +262,8 @@ class BookSourceEditActivity :
else -> 0 else -> 0
} }
) )
binding.cbIsEventListener.isChecked = it.eventListener
binding.cbIsCustomButton.isChecked = it.customButton
} }
// 基本信息 // 基本信息
sourceEntities.clear() sourceEntities.clear()
@@ -361,6 +364,7 @@ class BookSourceEditActivity :
add(EditEntity("imageStyle", cr.imageStyle, R.string.rule_image_style)) add(EditEntity("imageStyle", cr.imageStyle, R.string.rule_image_style))
add(EditEntity("imageDecode", cr.imageDecode, R.string.rule_image_decode)) add(EditEntity("imageDecode", cr.imageDecode, R.string.rule_image_decode))
add(EditEntity("payAction", cr.payAction, R.string.rule_pay_action)) add(EditEntity("payAction", cr.payAction, R.string.rule_pay_action))
add(EditEntity("callBackJs", cr.callBackJs, R.string.rule_call_back))
} }
// val rr = bs.getReviewRule() // val rr = bs.getReviewRule()
@@ -392,6 +396,8 @@ class BookSourceEditActivity :
1 -> BookSourceType.audio 1 -> BookSourceType.audio
else -> BookSourceType.default else -> BookSourceType.default
} }
source.eventListener = binding.cbIsEventListener.isChecked
source.customButton = binding.cbIsCustomButton.isChecked
val searchRule = SearchRule() val searchRule = SearchRule()
val exploreRule = ExploreRule() val exploreRule = ExploreRule()
val bookInfoRule = BookInfoRule() val bookInfoRule = BookInfoRule()
@@ -559,6 +565,7 @@ class BookSourceEditActivity :
"imageStyle" -> contentRule.imageStyle = it.value "imageStyle" -> contentRule.imageStyle = it.value
"imageDecode" -> contentRule.imageDecode = it.value "imageDecode" -> contentRule.imageDecode = it.value
"payAction" -> contentRule.payAction = it.value "payAction" -> contentRule.payAction = it.value
"callBackJs" -> contentRule.callBackJs = it.value
} }
} }
// reviewEntities.forEach { // reviewEntities.forEach {
@@ -592,6 +599,22 @@ class BookSourceEditActivity :
return source return source
} }
@RequiresApi(Build.VERSION_CODES.M)
override fun onUndoClicked() {
val editText = window.decorView.findFocus()
if (editText is EditText) {
editText.onTextContextMenuItem(android.R.id.undo)
}
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onRedoClicked() {
val editText = window.decorView.findFocus()
if (editText is EditText) {
editText.onTextContextMenuItem(android.R.id.redo)
}
}
private fun alertGroups() { private fun alertGroups() {
lifecycleScope.launch { lifecycleScope.launch {
val groups = withContext(IO) { val groups = withContext(IO) {
@@ -643,20 +666,46 @@ class BookSourceEditActivity :
} }
override fun sendText(text: String) { override fun sendText(text: String) {
if (text.isBlank()) return
val view = window.decorView.findFocus() val view = window.decorView.findFocus()
if (view is EditText) { if (view is EditText) {
val start = view.selectionStart var start = view.selectionStart
val end = view.selectionEnd var end = view.selectionEnd
if (start > end) {
val temp = start
start = end
end = temp
}
if (text.isNotEmpty()) {
val edit = view.editableText//获取EditText的文字 val edit = view.editableText//获取EditText的文字
if (start < 0 || start >= edit.length) { if (start < 0 || start >= edit.length) {
edit.append(text) edit.append(text)
} else if (start > end) {
edit.replace(end, start, text)
} else { } else {
edit.replace(start, end, text)//光标所在位置插入文字 edit.replace(start, end, text)//光标所在位置插入文字
} }
} }
if (adapter.editEntityMaxLine >= 999) {
view.post {
val editTextLocation = IntArray(2)
view.getLocationOnScreen(editTextLocation)
val recyclerViewLocation = IntArray(2)
binding.recyclerView.getLocationOnScreen(recyclerViewLocation)
val layout = view.layout
if (layout != null) {
val line = layout.getLineForOffset(end)
val cursorYInEditText = layout.getLineTop(line)
val cursorYOnScreen = editTextLocation[1] + cursorYInEditText
val cursorYInRecyclerView = cursorYOnScreen - recyclerViewLocation[1]
val recyclerViewBottom = binding.recyclerView.height - 120
if (cursorYInRecyclerView !in 0..recyclerViewBottom) {
val scrollDistance = cursorYInRecyclerView - recyclerViewBottom / 3
if (scrollDistance > 0 && binding.recyclerView.canScrollVertically(1) || scrollDistance < 0 && binding.recyclerView.canScrollVertically(-1)) {
binding.recyclerView.smoothScrollBy(0, scrollDistance)
}
}
}
}
}
}
} }
private fun setSourceVariable() { private fun setSourceVariable() {
@@ -7,6 +7,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.ConcurrentRateLimiter.Companion.concurrentRecordMap
import io.legado.app.help.RuleComplete import io.legado.app.help.RuleComplete
import io.legado.app.help.config.SourceConfig import io.legado.app.help.config.SourceConfig
import io.legado.app.help.http.CookieStore import io.legado.app.help.http.CookieStore
@@ -73,6 +74,7 @@ class BookSourceEditViewModel(application: Application) : BaseViewModel(applicat
} }
appDb.bookSourceDao.insert(source) appDb.bookSourceDao.insert(source)
bookSource = source bookSource = source
concurrentRecordMap.remove(source.bookSourceUrl)
source source
}.onSuccess { }.onSuccess {
success?.invoke(it) success?.invoke(it)
@@ -70,22 +70,22 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
viewModel.source, viewModel.source,
viewModel.bookType, viewModel.bookType,
callback = object : SourceLoginJsExtensions.Callback { callback = object : SourceLoginJsExtensions.Callback {
override fun upUiData(data: Map<String, String?>?) { override fun upUiData(data: Map<String, Any?>?) {
activity?.runOnUiThread { // 在主线程中更新 UI activity?.runOnUiThread { // 在主线程中更新 UI
handleUpUiData(data) handleUpUiData(data)
} }
} }
override fun reUiView() { override fun reUiView(deltaUp: Boolean) {
activity?.runOnUiThread { activity?.runOnUiThread {
handleReUiView() handleReUiView(deltaUp)
} }
} }
} }
) )
} }
private fun handleReUiView() { private fun handleReUiView(deltaUp: Boolean = false) {
val source = viewModel.source ?: return val source = viewModel.source ?: return
val loginUiStr = source.loginUi ?: return val loginUiStr = source.loginUi ?: return
val codeStr = loginUiStr.let { val codeStr = loginUiStr.let {
@@ -100,18 +100,16 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
lifecycleScope.launch(Main) { lifecycleScope.launch(Main) {
val loginUiJson = evalUiJs(codeStr) val loginUiJson = evalUiJs(codeStr)
rowUis = loginUi(loginUiJson) rowUis = loginUi(loginUiJson)
binding.flexbox.removeAllViews() rowUiBuilder(source, rowUis, deltaUp)
rowUiBuilder(source, rowUis)
} }
} else { } else {
rowUis = loginUi(loginUiStr) rowUis = loginUi(loginUiStr)
binding.flexbox.removeAllViews() rowUiBuilder(source, rowUis, deltaUp)
rowUiBuilder(source, rowUis)
} }
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun handleUpUiData(data: Map<String, String?>?) { private fun handleUpUiData(data: Map<String, Any?>?) {
hasChange = true hasChange = true
if (data == null) { if (data == null) {
val newLoginInfo: MutableMap<String, String> = mutableMapOf() val newLoginInfo: MutableMap<String, String> = mutableMapOf()
@@ -163,22 +161,22 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
if (index != -1) { if (index != -1) {
when (val rowView = binding.root.findViewById<View>(index + 1000)) { when (val rowView = binding.root.findViewById<View>(index + 1000)) {
is TextInputLayout -> { is TextInputLayout -> {
val value = value ?: run { val textValue = value?.toString() ?: run {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach val rowUi = rowUis?.getOrNull(index) ?: return@forEach
rowUi.default ?: "" rowUi.default ?: ""
} }
rowView.editText?.setText(value) rowView.editText?.setText(textValue)
} }
is TextView -> { is TextView -> {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach val rowUi = rowUis?.getOrNull(index) ?: return@forEach
when (rowUi.type) { when (rowUi.type) {
Type.button -> { Type.button -> {
rowView.text = value ?: key rowView.text = value?.toString() ?: key
} }
Type.toggle -> { Type.toggle -> {
val char = value ?: run { val char = value?.toString() ?: run {
val chars = val chars =
rowUi.chars?.filterNotNull() ?: listOf("chars is null") rowUi.chars?.filterNotNull() ?: listOf("chars is null")
chars.getOrNull(0) ?: "" chars.getOrNull(0) ?: ""
@@ -194,8 +192,9 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
is LinearLayout -> { is LinearLayout -> {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach val rowUi = rowUis?.getOrNull(index) ?: return@forEach
val items = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null") val items = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
val index = items.indexOf(value) val strValue = value?.toString()
loginInfo[rowUi.name] = value ?: run { val index = items.indexOf(strValue)
loginInfo[rowUi.name] = strValue ?: run {
items.getOrNull(0) ?: "" items.getOrNull(0) ?: ""
} }
rowView.findViewById<AppCompatSpinner>(R.id.sp_type) rowView.findViewById<AppCompatSpinner>(R.id.sp_type)
@@ -203,7 +202,7 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
} }
} }
} else { } else {
loginInfo[key] = value ?: "" loginInfo[key] = value?.toString() ?: ""
} }
} }
} }
@@ -240,9 +239,12 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
} }
@SuppressLint("SetTextI18n", "ClickableViewAccessibility") @SuppressLint("SetTextI18n", "ClickableViewAccessibility")
private fun rowUiBuilder(source: BaseSource, rowUis: List<RowUi>?) { private fun rowUiBuilder(source: BaseSource, rowUis: List<RowUi>?, deltaUp: Boolean = false) {
val loginInfo = viewModel.loginInfo val loginInfo = viewModel.loginInfo
if (!deltaUp) {
binding.flexbox.removeAllViews()
rowUiName.clear() rowUiName.clear()
}
rowUis?.forEachIndexed { index, rowUi -> rowUis?.forEachIndexed { index, rowUi ->
val type = rowUi.type val type = rowUi.type
val name = rowUi.name val name = rowUi.name
@@ -27,16 +27,17 @@ class SourceLoginJsExtensions(
) : RssJsExtensions(activity, source) { ) : RssJsExtensions(activity, source) {
private val callbackRef: WeakReference<Callback> = WeakReference(callback) private val callbackRef: WeakReference<Callback> = WeakReference(callback)
interface Callback { interface Callback {
fun upUiData(data: Map<String, String?>?) fun upUiData(data: Map<String, Any?>?)
fun reUiView() fun reUiView(deltaUp: Boolean = false)
} }
fun upLoginData(data: Map<String, String?>?) { fun upLoginData(data: Map<String, Any?>?) {
callbackRef.get()?.upUiData(data) callbackRef.get()?.upUiData(data)
} }
fun reLoginView() { @JvmOverloads
callbackRef.get()?.reUiView() fun reLoginView(deltaUp: Boolean = false) {
callbackRef.get()?.reUiView(deltaUp)
} }
fun refreshExplore() { fun refreshExplore() {
@@ -47,6 +48,10 @@ class SourceLoginJsExtensions(
postEvent(EventBus.REFRESH_BOOK_INFO, true) postEvent(EventBus.REFRESH_BOOK_INFO, true)
} }
fun refreshBookToc() {
postEvent(EventBus.REFRESH_BOOK_TOC, true)
}
fun refreshContent() { fun refreshContent() {
postEvent(EventBus.REFRESH_BOOK_CONTENT, true) postEvent(EventBus.REFRESH_BOOK_CONTENT, true)
} }
@@ -175,6 +175,8 @@ class KeyboardToolPop(
fun sendText(text: String) fun sendText(text: String)
fun onUndoClicked() {}
fun onRedoClicked() {}
} }
} }
@@ -73,6 +73,38 @@
</HorizontalScrollView> </HorizontalScrollView>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none"
android:paddingHorizontal="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:ignore="VisualLintBounds">
<io.legado.app.lib.theme.view.ThemeCheckBox
android:id="@+id/cb_is_event_listener"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false"
android:text="@string/is_event_listener" />
<io.legado.app.lib.theme.view.ThemeCheckBox
android:id="@+id/cb_is_custom_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false"
android:text="@string/custom_button" />
</LinearLayout>
</HorizontalScrollView>
<com.google.android.material.tabs.TabLayout <com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout" android:id="@+id/tab_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
+3
View File
@@ -1168,6 +1168,9 @@
<string name="enable_record">Enable record</string> <string name="enable_record">Enable record</string>
<string name="copy_all">Copy all</string> <string name="copy_all">Copy all</string>
<string name="auto_complete">Automatic completion</string> <string name="auto_complete">Automatic completion</string>
<string name="is_event_listener">EventListener</string>
<string name="custom_button">Custom Button</string>
<string name="rule_call_back">callBackJs</string>
<string name="sort_by_size">Sort by size</string> <string name="sort_by_size">Sort by size</string>
<string name="welcome_style">Launch interface style</string> <string name="welcome_style">Launch interface style</string>
<string name="welcome_style_summary">Startup screen image and whether to display text, etc.</string> <string name="welcome_style_summary">Startup screen image and whether to display text, etc.</string>