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 PLAY_MODE_CHANGED = "playModeChanged"
const val REFRESH_BOOK_INFO = "refreshBookInfo"
const val REFRESH_BOOK_TOC = "refreshBookToc"
const val REFRESH_BOOK_CONTENT = "refreshBookContent"
const val UP_TOC = "upToc"
@@ -72,8 +72,8 @@ class ExploreKindUiUseCase(
activity = activity,
source = source,
callback = object : SourceLoginJsExtensions.Callback {
override fun upUiData(data: Map<String, String?>?) = Unit
override fun reUiView() = onRefreshKinds()
override fun upUiData(data: Map<String, Any?>?) = Unit
override fun reUiView(deltaUp: Boolean) = onRefreshKinds()
}
)
withContext(Dispatchers.IO) {
@@ -74,6 +74,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
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.combine
import kotlinx.coroutines.flow.update
@@ -99,6 +102,35 @@ class BookInfoViewModel(
private val _effects = MutableSharedFlow<BookInfoEffect>(extraBufferCapacity = 8)
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
set(value) {
field = value
@@ -1,11 +1,13 @@
package io.legado.app.ui.book.source.edit
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.EditText
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.lifecycle.lifecycleScope
import com.google.android.material.tabs.TabLayout
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.alert
import io.legado.app.lib.dialogs.selector
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
@@ -146,6 +146,7 @@ class BookSourceEditActivity :
ErrorCorrectionLevel.L
)
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_help -> showHelp("ruleHelp")
R.id.menu_login -> viewModel.save(getSource()) { source ->
startActivity<SourceLoginActivity> {
@@ -261,6 +262,8 @@ class BookSourceEditActivity :
else -> 0
}
)
binding.cbIsEventListener.isChecked = it.eventListener
binding.cbIsCustomButton.isChecked = it.customButton
}
// 基本信息
sourceEntities.clear()
@@ -361,6 +364,7 @@ class BookSourceEditActivity :
add(EditEntity("imageStyle", cr.imageStyle, R.string.rule_image_style))
add(EditEntity("imageDecode", cr.imageDecode, R.string.rule_image_decode))
add(EditEntity("payAction", cr.payAction, R.string.rule_pay_action))
add(EditEntity("callBackJs", cr.callBackJs, R.string.rule_call_back))
}
// val rr = bs.getReviewRule()
@@ -392,6 +396,8 @@ class BookSourceEditActivity :
1 -> BookSourceType.audio
else -> BookSourceType.default
}
source.eventListener = binding.cbIsEventListener.isChecked
source.customButton = binding.cbIsCustomButton.isChecked
val searchRule = SearchRule()
val exploreRule = ExploreRule()
val bookInfoRule = BookInfoRule()
@@ -559,6 +565,7 @@ class BookSourceEditActivity :
"imageStyle" -> contentRule.imageStyle = it.value
"imageDecode" -> contentRule.imageDecode = it.value
"payAction" -> contentRule.payAction = it.value
"callBackJs" -> contentRule.callBackJs = it.value
}
}
// reviewEntities.forEach {
@@ -592,6 +599,22 @@ class BookSourceEditActivity :
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() {
lifecycleScope.launch {
val groups = withContext(IO) {
@@ -643,18 +666,44 @@ class BookSourceEditActivity :
}
override fun sendText(text: String) {
if (text.isBlank()) return
val view = window.decorView.findFocus()
if (view is EditText) {
val start = view.selectionStart
val end = view.selectionEnd
val edit = view.editableText//获取EditText的文字
if (start < 0 || start >= edit.length) {
edit.append(text)
} else if (start > end) {
edit.replace(end, start, text)
} else {
edit.replace(start, end, text)//光标所在位置插入文字
var start = view.selectionStart
var end = view.selectionEnd
if (start > end) {
val temp = start
start = end
end = temp
}
if (text.isNotEmpty()) {
val edit = view.editableText//获取EditText的文字
if (start < 0 || start >= edit.length) {
edit.append(text)
} else {
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)
}
}
}
}
}
}
}
@@ -7,6 +7,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
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.config.SourceConfig
import io.legado.app.help.http.CookieStore
@@ -73,6 +74,7 @@ class BookSourceEditViewModel(application: Application) : BaseViewModel(applicat
}
appDb.bookSourceDao.insert(source)
bookSource = source
concurrentRecordMap.remove(source.bookSourceUrl)
source
}.onSuccess {
success?.invoke(it)
@@ -70,22 +70,22 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
viewModel.source,
viewModel.bookType,
callback = object : SourceLoginJsExtensions.Callback {
override fun upUiData(data: Map<String, String?>?) {
override fun upUiData(data: Map<String, Any?>?) {
activity?.runOnUiThread { // 在主线程中更新 UI
handleUpUiData(data)
}
}
override fun reUiView() {
override fun reUiView(deltaUp: Boolean) {
activity?.runOnUiThread {
handleReUiView()
handleReUiView(deltaUp)
}
}
}
)
}
private fun handleReUiView() {
private fun handleReUiView(deltaUp: Boolean = false) {
val source = viewModel.source ?: return
val loginUiStr = source.loginUi ?: return
val codeStr = loginUiStr.let {
@@ -100,18 +100,16 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
lifecycleScope.launch(Main) {
val loginUiJson = evalUiJs(codeStr)
rowUis = loginUi(loginUiJson)
binding.flexbox.removeAllViews()
rowUiBuilder(source, rowUis)
rowUiBuilder(source, rowUis, deltaUp)
}
} else {
rowUis = loginUi(loginUiStr)
binding.flexbox.removeAllViews()
rowUiBuilder(source, rowUis)
rowUiBuilder(source, rowUis, deltaUp)
}
}
@SuppressLint("SetTextI18n")
private fun handleUpUiData(data: Map<String, String?>?) {
private fun handleUpUiData(data: Map<String, Any?>?) {
hasChange = true
if (data == null) {
val newLoginInfo: MutableMap<String, String> = mutableMapOf()
@@ -163,22 +161,22 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
if (index != -1) {
when (val rowView = binding.root.findViewById<View>(index + 1000)) {
is TextInputLayout -> {
val value = value ?: run {
val textValue = value?.toString() ?: run {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach
rowUi.default ?: ""
}
rowView.editText?.setText(value)
rowView.editText?.setText(textValue)
}
is TextView -> {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach
when (rowUi.type) {
Type.button -> {
rowView.text = value ?: key
rowView.text = value?.toString() ?: key
}
Type.toggle -> {
val char = value ?: run {
val char = value?.toString() ?: run {
val chars =
rowUi.chars?.filterNotNull() ?: listOf("chars is null")
chars.getOrNull(0) ?: ""
@@ -194,8 +192,9 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
is LinearLayout -> {
val rowUi = rowUis?.getOrNull(index) ?: return@forEach
val items = rowUi.chars?.filterNotNull() ?: listOf("chars", "is null")
val index = items.indexOf(value)
loginInfo[rowUi.name] = value ?: run {
val strValue = value?.toString()
val index = items.indexOf(strValue)
loginInfo[rowUi.name] = strValue ?: run {
items.getOrNull(0) ?: ""
}
rowView.findViewById<AppCompatSpinner>(R.id.sp_type)
@@ -203,7 +202,7 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
}
}
} else {
loginInfo[key] = value ?: ""
loginInfo[key] = value?.toString() ?: ""
}
}
}
@@ -240,9 +239,12 @@ class SourceLoginDialog : BaseBottomSheetDialogFragment(R.layout.dialog_login) {
}
@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
rowUiName.clear()
if (!deltaUp) {
binding.flexbox.removeAllViews()
rowUiName.clear()
}
rowUis?.forEachIndexed { index, rowUi ->
val type = rowUi.type
val name = rowUi.name
@@ -27,16 +27,17 @@ class SourceLoginJsExtensions(
) : RssJsExtensions(activity, source) {
private val callbackRef: WeakReference<Callback> = WeakReference(callback)
interface Callback {
fun upUiData(data: Map<String, String?>?)
fun reUiView()
fun upUiData(data: Map<String, Any?>?)
fun reUiView(deltaUp: Boolean = false)
}
fun upLoginData(data: Map<String, String?>?) {
fun upLoginData(data: Map<String, Any?>?) {
callbackRef.get()?.upUiData(data)
}
fun reLoginView() {
callbackRef.get()?.reUiView()
@JvmOverloads
fun reLoginView(deltaUp: Boolean = false) {
callbackRef.get()?.reUiView(deltaUp)
}
fun refreshExplore() {
@@ -47,6 +48,10 @@ class SourceLoginJsExtensions(
postEvent(EventBus.REFRESH_BOOK_INFO, true)
}
fun refreshBookToc() {
postEvent(EventBus.REFRESH_BOOK_TOC, true)
}
fun refreshContent() {
postEvent(EventBus.REFRESH_BOOK_CONTENT, true)
}
@@ -175,6 +175,8 @@ class KeyboardToolPop(
fun sendText(text: String)
fun onUndoClicked() {}
fun onRedoClicked() {}
}
}
@@ -73,6 +73,38 @@
</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
android:id="@+id/tab_layout"
android:layout_width="match_parent"
+3
View File
@@ -1168,6 +1168,9 @@
<string name="enable_record">Enable record</string>
<string name="copy_all">Copy all</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="welcome_style">Launch interface style</string>
<string name="welcome_style_summary">Startup screen image and whether to display text, etc.</string>