fix: 书签问题与高亮规则代码优化

This commit is contained in:
HapeLee
2026-06-10 02:44:07 +08:00
parent 6749faf6eb
commit 62875c54d9
17 changed files with 394 additions and 2682 deletions
+2 -1
View File
@@ -112,7 +112,8 @@
"Bash(Select-Object -Last 10)", "Bash(Select-Object -Last 10)",
"Bash(dir /s /b \"%USERPROFILE%\\\\.gradle\\\\caches\\\\*kyant*\")", "Bash(dir /s /b \"%USERPROFILE%\\\\.gradle\\\\caches\\\\*kyant*\")",
"Bash(Select-String -Pattern \"\\(BUILD|error:|FAILED\\)\")", "Bash(Select-String -Pattern \"\\(BUILD|error:|FAILED\\)\")",
"PowerShell(grep *)" "PowerShell(grep *)",
"Bash(powershell -Command \"./gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 30\")"
] ]
} }
} }
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 91, "version": 91,
"identityHash": "ec7132bb4a28ea41ce9314f820eb6d7b", "identityHash": "3b66f251365b78f6007d87da5bd12b3f",
"entities": [ "entities": [
{ {
"tableName": "books", "tableName": "books",
@@ -2293,7 +2293,7 @@
}, },
{ {
"tableName": "highlightRules", "tableName": "highlightRules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, PRIMARY KEY(`id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, PRIMARY KEY(`id`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -2396,6 +2396,11 @@
"fieldPath": "configName", "fieldPath": "configName",
"columnName": "configName", "columnName": "configName",
"affinity": "TEXT" "affinity": "TEXT"
},
{
"fieldPath": "fontPath",
"columnName": "fontPath",
"affinity": "TEXT"
} }
], ],
"primaryKey": { "primaryKey": {
@@ -2414,7 +2419,7 @@
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ec7132bb4a28ea41ce9314f820eb6d7b')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b66f251365b78f6007d87da5bd12b3f')"
] ]
} }
} }
File diff suppressed because it is too large Load Diff
@@ -41,8 +41,8 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.data.entities.Cache import io.legado.app.data.entities.Cache
import io.legado.app.data.entities.Cookie import io.legado.app.data.entities.Cookie
import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HomepageCustomSet
import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.entities.HomepageCustomSet
import io.legado.app.data.entities.HomepageModule import io.legado.app.data.entities.HomepageModule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist import io.legado.app.data.entities.KeyboardAssist
@@ -75,7 +75,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 92, version = 91,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -134,8 +134,7 @@ val appDb by lazy {
AutoMigration(from = 87, to = 88), AutoMigration(from = 87, to = 88),
AutoMigration(from = 88, to = 89), AutoMigration(from = 88, to = 89),
AutoMigration(from = 89, to = 90), AutoMigration(from = 89, to = 90),
AutoMigration(from = 90, to = 91), AutoMigration(from = 90, to = 91)
AutoMigration(from = 91, to = 92)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -1,9 +1,10 @@
package io.legado.app.ui.book.read.config package io.legado.app.data.repository
import android.content.Context
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.dao.HighlightRuleDao
import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HighlightRule
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
@@ -11,9 +12,14 @@ import io.legado.app.utils.putPrefBoolean
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
object HighlightRuleStore { class HighlightRuleRepository(
private val dao: HighlightRuleDao = appDb.highlightRuleDao,
private val context: Context = appCtx,
) {
const val backupFileName = "highlightRule.json" companion object {
const val backupFileName = "highlightRule.json"
}
data class BackupData( data class BackupData(
val rules: List<HighlightRule> = emptyList(), val rules: List<HighlightRule> = emptyList(),
@@ -22,24 +28,19 @@ object HighlightRuleStore {
val bracketNoteEnabled: Boolean = true, val bracketNoteEnabled: Boolean = true,
) )
private val dao get() = appDb.highlightRuleDao fun load(configName: String): List<HighlightRule> {
fun load(): List<HighlightRule> {
val configName = ReadBookConfig.durConfig.name
return dao.getAll().filter { it.matchesConfig(configName) } return dao.getAll().filter { it.matchesConfig(configName) }
} }
fun loadEnabled(): List<HighlightRule> { fun loadEnabled(configName: String): List<HighlightRule> {
val configName = ReadBookConfig.durConfig.name
return dao.getEnabled().filter { it.matchesConfig(configName) } return dao.getEnabled().filter { it.matchesConfig(configName) }
} }
/** /**
* 保存当前排版的规则 * 保存指定排版的规则
* 仅替换当前排版绑定的规则不影响其他排版的规则 * 仅替换当前排版绑定的规则不影响其他排版的规则
*/ */
fun save(rules: List<HighlightRule>) { fun save(configName: String, rules: List<HighlightRule>) {
val configName = ReadBookConfig.durConfig.name
saveForConfig(rules, configName.ifBlank { null }) saveForConfig(rules, configName.ifBlank { null })
} }
@@ -59,11 +60,16 @@ object HighlightRuleStore {
} }
val newIds = sanitized.map { it.id }.toSet() val newIds = sanitized.map { it.id }.toSet()
// 被移除的旧规则:从 configName 列表中去掉当前排版 // 被移除的旧规则:从 configName 列表中去掉当前排版
// 未绑定任何排版时保留规则(configName="[]"),不删除 // 未绑定任何排版时删除,避免留下无法在管理界面看到的规则
for (old in oldBound) { for (old in oldBound) {
if (old.id !in newIds) { if (old.id !in newIds) {
val remaining = old.configName.orEmpty().configNames().filter { it != configName } val remaining =
dao.update(old.copy(configName = remaining.toJsonArray())) old.configName.orEmpty().configNames().filter { it != configName }
if (remaining.isEmpty()) {
dao.delete(old)
} else {
dao.update(old.copy(configName = remaining.toJsonArray()))
}
} }
} }
// 插入所有规则(全局规则也一起,否则会被 replaceGlobal 删掉) // 插入所有规则(全局规则也一起,否则会被 replaceGlobal 删掉)
@@ -72,31 +78,46 @@ object HighlightRuleStore {
cleanupUnusedBgImages() cleanupUnusedBgImages()
} }
fun update(rule: HighlightRule) {
dao.update(sanitizeRule(rule))
}
fun delete(rule: HighlightRule) { fun delete(rule: HighlightRule) {
dao.delete(rule) dao.delete(rule)
cleanupUnusedBgImages()
} }
fun reset(): List<HighlightRule> { fun removeConfigBinding(configName: String) {
val defaults = createDefaultRules() if (configName.isBlank()) return
val configName = ReadBookConfig.durConfig.name dao.getAll().forEach { rule ->
if (configName.isBlank()) { val names = rule.configName.orEmpty().configNames()
dao.replaceGlobal(defaults) if (configName in names) {
} else { val remaining = names.filter { it != configName }
saveForConfig(defaults, configName) val updatedConfigName = remaining.takeIf { it.isNotEmpty() }?.toJsonArray()
dao.update(rule.copy(configName = updatedConfigName))
}
} }
return defaults
} }
fun createBackupData(): BackupData { fun reset(configName: String): List<HighlightRule> {
val defaults = createDefaultRules()
val rules = if (configName.isBlank()) {
defaults
} else {
defaults.map {
it.copyWithNewId().copy(configName = listOf(configName).toJsonArray())
}
}
if (configName.isBlank()) {
dao.replaceGlobal(rules)
} else {
saveForConfig(rules, configName)
}
return rules
}
fun createBackupData(configName: String): BackupData {
return BackupData( return BackupData(
rules = load(), rules = load(configName),
dialogEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleDialog, true), dialogEnabled = context.getPrefBoolean(PreferKey.highlightRuleDialog, true),
bookTitleEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleBookTitle, true), bookTitleEnabled = context.getPrefBoolean(PreferKey.highlightRuleBookTitle, true),
bracketNoteEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleBracketNote, true), bracketNoteEnabled = context.getPrefBoolean(PreferKey.highlightRuleBracketNote, true),
) )
} }
@@ -109,9 +130,9 @@ object HighlightRuleStore {
// 备份恢复是全量替换 // 备份恢复是全量替换
dao.replaceAll(rules) dao.replaceAll(rules)
cleanupUnusedBgImages() cleanupUnusedBgImages()
appCtx.putPrefBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled) context.putPrefBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled)
appCtx.putPrefBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled) context.putPrefBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled)
appCtx.putPrefBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled) context.putPrefBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled)
} }
fun sanitizeRule(rule: HighlightRule): HighlightRule { fun sanitizeRule(rule: HighlightRule): HighlightRule {
@@ -119,22 +140,30 @@ object HighlightRuleStore {
val pattern = runCatching { rule.pattern }.getOrNull().orEmpty() val pattern = runCatching { rule.pattern }.getOrNull().orEmpty()
val sampleText = runCatching { rule.sampleText }.getOrNull().orEmpty() val sampleText = runCatching { rule.sampleText }.getOrNull().orEmpty()
val id = runCatching { rule.id }.getOrNull().orEmpty().ifBlank { val id = runCatching { rule.id }.getOrNull().orEmpty().ifBlank {
"${System.currentTimeMillis()}_${listOf(name, pattern).joinToString("|").hashCode().toUInt().toString(16)}" "${System.currentTimeMillis()}_${
listOf(name, pattern).joinToString("|").hashCode().toUInt().toString(16)
}"
} }
return HighlightRule( return HighlightRule(
id = id, id = id,
name = name, name = name,
pattern = pattern, pattern = pattern,
sampleText = sampleText, sampleText = sampleText,
targetScope = normalizeTargetScope(runCatching { rule.targetScope }.getOrDefault(HighlightRule.TARGET_ALL)), targetScope = normalizeTargetScope(
runCatching { rule.targetScope }.getOrDefault(
HighlightRule.TARGET_ALL
)
),
enabled = runCatching { rule.enabled }.getOrDefault(true), enabled = runCatching { rule.enabled }.getOrDefault(true),
position = runCatching { rule.position }.getOrDefault(0), position = runCatching { rule.position }.getOrDefault(0),
textColor = runCatching { rule.textColor }.getOrNull(), textColor = runCatching { rule.textColor }.getOrNull(),
bgColor = runCatching { rule.bgColor }.getOrNull(), bgColor = runCatching { rule.bgColor }.getOrNull(),
underlineMode = runCatching { rule.underlineMode }.getOrDefault(0).coerceIn(0, 5), underlineMode = runCatching { rule.underlineMode }.getOrDefault(0).coerceIn(0, 5),
underlineColor = runCatching { rule.underlineColor }.getOrNull(), underlineColor = runCatching { rule.underlineColor }.getOrNull(),
underlineWidth = runCatching { rule.underlineWidth }.getOrDefault(1f).coerceIn(0.1f, 10f), underlineWidth = runCatching { rule.underlineWidth }.getOrDefault(1f)
underlineOffset = runCatching { rule.underlineOffset }.getOrDefault(2f).coerceIn(0f, 20f), .coerceIn(0.1f, 10f),
underlineOffset = runCatching { rule.underlineOffset }.getOrDefault(2f)
.coerceIn(0f, 20f),
underlineSvgPath = runCatching { rule.underlineSvgPath }.getOrNull(), underlineSvgPath = runCatching { rule.underlineSvgPath }.getOrNull(),
bgImage = runCatching { rule.bgImage }.getOrNull()?.takeIf { it.isNotBlank() }, bgImage = runCatching { rule.bgImage }.getOrNull()?.takeIf { it.isNotBlank() },
bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 2), bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 2),
@@ -149,12 +178,13 @@ object HighlightRuleStore {
HighlightRule.TARGET_ALL, HighlightRule.TARGET_ALL,
HighlightRule.TARGET_TITLE, HighlightRule.TARGET_TITLE,
HighlightRule.TARGET_BODY -> value HighlightRule.TARGET_BODY -> value
else -> fallback else -> fallback
} }
} }
fun createDefaultRules(): List<HighlightRule> { fun createDefaultRules(): List<HighlightRule> {
val ctx = appCtx val ctx = context
return listOf( return listOf(
HighlightRule( HighlightRule(
id = "dialog_default", id = "dialog_default",
@@ -288,7 +318,7 @@ object HighlightRuleStore {
val usedPaths = allRules.mapNotNull { it.bgImage } val usedPaths = allRules.mapNotNull { it.bgImage }
.filter { it.isNotBlank() && !it.startsWith("assets://") } .filter { it.isNotBlank() && !it.startsWith("assets://") }
.toSet() .toSet()
val dir = File(appCtx.filesDir, "bg_images") val dir = File(context.filesDir, "bg_images")
if (!dir.exists()) return if (!dir.exists()) return
dir.listFiles()?.forEach { file -> dir.listFiles()?.forEach { file ->
if (file.absolutePath !in usedPaths) { if (file.absolutePath !in usedPaths) {
@@ -304,7 +334,7 @@ object HighlightRuleStore {
val backupFile = File(rootPath, "highlightRuleBg${File.separator}${File(path).name}") val backupFile = File(rootPath, "highlightRuleBg${File.separator}${File(path).name}")
.takeIf { it.exists() && it.isFile } .takeIf { it.exists() && it.isFile }
?: return path ?: return path
val dir = File(appCtx.filesDir, "bg_images") val dir = File(context.filesDir, "bg_images")
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
val targetFile = File(dir, backupFile.name) val targetFile = File(dir, backupFile.name)
if (!targetFile.exists() || targetFile.length() != backupFile.length()) { if (!targetFile.exists() || targetFile.length() != backupFile.length()) {
@@ -329,18 +359,15 @@ object HighlightRuleStore {
/** /**
* 解析 configName JSON 数组为列表 * 解析 configName JSON 数组为列表
*/ */
fun String.configNames(): List<String> {
return runCatching {
GSON.fromJsonArray<String>(this).getOrNull() ?: emptyList()
}.getOrElse { emptyList() }
}
/**
* 将排版名列表转为 JSON 数组字符串
*/
fun List<String>.toJsonArray(): String {
return GSON.toJson(this)
}
// endregion // endregion
} }
fun String.configNames(): List<String> {
return runCatching {
GSON.fromJsonArray<String>(this).getOrNull() ?: emptyList()
}.getOrElse { emptyList() }
}
fun List<String>.toJsonArray(): String {
return GSON.toJson(this)
}
@@ -10,7 +10,10 @@ import java.io.InputStream
* keeps a full layout/background/text configuration. This wrapper centralizes * keeps a full layout/background/text configuration. This wrapper centralizes
* file mutations so UI and ViewModel code do not call persistence helpers directly. * file mutations so UI and ViewModel code do not call persistence helpers directly.
*/ */
class ReadBookStyleConfigRepository { class ReadBookStyleConfigRepository(
private val readStyleRepository: ReadStyleRepository,
private val highlightRuleRepository: HighlightRuleRepository,
) {
fun save() { fun save() {
ReadBookConfig.save() ReadBookConfig.save()
@@ -23,20 +26,25 @@ class ReadBookStyleConfigRepository {
} }
fun deleteCurrentStyle(): Boolean { fun deleteCurrentStyle(): Boolean {
val deletedConfigName = ReadBookConfig.durConfig.name
val deleted = ReadBookConfig.deleteDur() val deleted = ReadBookConfig.deleteDur()
if (deleted) { if (deleted) {
highlightRuleRepository.removeConfigBinding(deletedConfigName)
save() save()
} }
return deleted return deleted
} }
fun importCurrentStyle(bytes: ByteArray) { fun importCurrentStyle(bytes: ByteArray) {
ReadBookConfig.durConfig = ReadBookConfig.import(bytes) ReadBookConfig.durConfig = readStyleRepository.import(bytes)
save() save()
} }
fun exportCurrentStyle(): ByteArray { fun exportCurrentStyle(): ByteArray {
return ReadBookConfig.export() val config = ReadBookConfig.getExportConfig().copy(
highlightRules = ArrayList(highlightRuleRepository.load(ReadBookConfig.durConfig.name))
)
return readStyleRepository.export(config)
} }
fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String { fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String {
@@ -5,7 +5,6 @@ import io.legado.app.constant.AppLog
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadStyleResolver import io.legado.app.help.config.ReadStyleResolver
import io.legado.app.ui.book.read.config.HighlightRuleStore
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.compress.ZipUtils
@@ -20,7 +19,9 @@ import splitties.init.appCtx
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
class ReadStyleRepository { class ReadStyleRepository(
private val highlightRuleRepository: HighlightRuleRepository,
) {
val configFilePath: String = val configFilePath: String =
FileUtils.getPath(appCtx.filesDir, ReadBookConfig.configFileName) FileUtils.getPath(appCtx.filesDir, ReadBookConfig.configFileName)
@@ -199,7 +200,15 @@ class ReadStyleRepository {
config.curTextShadowColor() config.curTextShadowColor()
if (config.highlightRules.isNotEmpty()) { if (config.highlightRules.isNotEmpty()) {
val targetConfigName = config.name.ifBlank { null } val targetConfigName = config.name.ifBlank { null }
HighlightRuleStore.saveForConfig(config.highlightRules, targetConfigName) val highlightRules = config.highlightRules.map { rule ->
if (targetConfigName.isNullOrBlank()) {
rule.copy(configName = null)
} else {
rule.copyWithNewId().copy(configName = listOf(targetConfigName).toJsonArray())
}
}
config.highlightRules = ArrayList(highlightRules)
highlightRuleRepository.saveForConfig(highlightRules, targetConfigName)
} }
return config return config
} }
@@ -22,13 +22,15 @@ import io.legado.app.data.repository.DictionaryRepositoryImpl
import io.legado.app.data.repository.DirectLinkUploadRepository import io.legado.app.data.repository.DirectLinkUploadRepository
import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.data.repository.HomepageModulesRepository import io.legado.app.data.repository.HomepageModulesRepository
import io.legado.app.data.repository.LlmTranslateRepositoryImpl import io.legado.app.data.repository.LlmTranslateRepositoryImpl
import io.legado.app.data.repository.LocalBookRepository import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.ReadBookStyleConfigRepository
import io.legado.app.data.repository.ReadAloudSettingsRepository import io.legado.app.data.repository.ReadAloudSettingsRepository
import io.legado.app.data.repository.ReadBookStyleConfigRepository
import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.ReadSettingsRepository import io.legado.app.data.repository.ReadSettingsRepository
import io.legado.app.data.repository.ReadStyleRepository
import io.legado.app.data.repository.RemoteBookRepository import io.legado.app.data.repository.RemoteBookRepository
import io.legado.app.data.repository.RssRepository import io.legado.app.data.repository.RssRepository
import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.SearchContentRepository
@@ -155,6 +157,8 @@ val appModule = module {
singleOf(::SettingsRepository) singleOf(::SettingsRepository)
singleOf(::ReadSettingsRepository) singleOf(::ReadSettingsRepository)
singleOf(::ReadAloudSettingsRepository) singleOf(::ReadAloudSettingsRepository)
singleOf(::HighlightRuleRepository)
singleOf(::ReadStyleRepository)
singleOf(::ReadBookStyleConfigRepository) singleOf(::ReadBookStyleConfigRepository)
singleOf(::LocalPreferencesRepository) singleOf(::LocalPreferencesRepository)
singleOf(::ExploreBooksUseCase) singleOf(::ExploreBooksUseCase)
@@ -267,7 +271,8 @@ val appModule = module {
readSettingsRepository = get(), readSettingsRepository = get(),
readBookStyleConfigRepository = get(), readBookStyleConfigRepository = get(),
readAloudSettingsRepository = get(), readAloudSettingsRepository = get(),
localPreferencesRepository = get() localPreferencesRepository = get(),
highlightRuleRepository = get()
) )
} }
viewModelOf(::ChangeCoverViewModel) viewModelOf(::ChangeCoverViewModel)
@@ -6,16 +6,15 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.compose.runtime.State
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.PageAnim import io.legado.app.constant.PageAnim
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.constant.ReadMenuBlurMode import io.legado.app.constant.ReadMenuBlurMode
import io.legado.app.constant.ReadMenuBlurStyle import io.legado.app.constant.ReadMenuBlurStyle
import androidx.compose.runtime.State
import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.repository.ReadStyleRepository import io.legado.app.data.repository.ReadStyleRepository
import io.legado.app.ui.book.read.config.HighlightRuleStore
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.ui.config.PrefDelegate import io.legado.app.ui.config.PrefDelegate
@@ -24,6 +23,7 @@ import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getMeanColor import io.legado.app.utils.getMeanColor
import io.legado.app.utils.hexString import io.legado.app.utils.hexString
import org.koin.core.context.GlobalContext
import splitties.init.appCtx import splitties.init.appCtx
import java.io.InputStream import java.io.InputStream
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
@@ -35,7 +35,8 @@ import kotlin.reflect.KProperty
@Suppress("ConstPropertyName") @Suppress("ConstPropertyName")
@Keep @Keep
object ReadBookConfig { object ReadBookConfig {
private val readStyleRepository = ReadStyleRepository() private val readStyleRepository: ReadStyleRepository
get() = GlobalContext.get().get()
// region prefDelegate helpers // region prefDelegate helpers
@@ -773,7 +774,7 @@ object ReadBookConfig {
// endregion // endregion
fun getExportConfig(): Config { fun getExportConfig(): Config {
val exportConfig = durConfig.copy(highlightRules = ArrayList(HighlightRuleStore.load())) val exportConfig = durConfig.copy(highlightRules = arrayListOf())
if (shareLayout) { if (shareLayout) {
exportConfig.textFont = shareConfig.textFont exportConfig.textFont = shareConfig.textFont
exportConfig.titleFont = shareConfig.titleFont exportConfig.titleFont = shareConfig.titleFont
@@ -5,7 +5,6 @@ object ReadBookColorPickerIds {
const val TEXT_COLOR = 121 const val TEXT_COLOR = 121
const val TEXT_ACCENT_COLOR = 125 const val TEXT_ACCENT_COLOR = 125
const val BG_COLOR = 122 const val BG_COLOR = 122
const val HIGHLIGHT_RULE_COLOR = 7900
const val TITLE_COLOR = 7896 const val TITLE_COLOR = 7896
const val TIP_HEADER_COLOR = 7897 const val TIP_HEADER_COLOR = 7897
const val TIP_DIVIDER_COLOR = 7898 const val TIP_DIVIDER_COLOR = 7898
@@ -13,5 +12,4 @@ object ReadBookColorPickerIds {
const val MENU_BG_COLOR = 114 const val MENU_BG_COLOR = 114
const val MENU_ACCENT_COLOR = 514 const val MENU_ACCENT_COLOR = 514
const val UNDERLINE_COLOR = 810 const val UNDERLINE_COLOR = 810
var pendingHighlightRulePosition = -1
} }
@@ -9,6 +9,8 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.Bookmark
import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.read.page.entities.TextPage import io.legado.app.ui.book.read.page.entities.TextPage
@@ -153,11 +155,20 @@ data class ReadBookUiState(
val styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), val styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
// Menu config (from ReadBookConfig via repository) // Menu config (from ReadBookConfig via repository)
val menuConfig: ReadMenuConfig = ReadMenuConfig(), val menuConfig: ReadMenuConfig = ReadMenuConfig(),
val highlightRuleConfig: HighlightRuleConfigUiState = HighlightRuleConfigUiState(),
) { ) {
val menuVisible: Boolean val menuVisible: Boolean
get() = menuState.visible get() = menuState.visible
} }
@Stable
data class HighlightRuleConfigUiState(
val rules: ImmutableList<HighlightRule> = persistentListOf(),
val editingRule: HighlightRule? = null,
val showNewRule: Boolean = false,
val deleteRule: HighlightRule? = null,
)
@Stable @Stable
data class ReadMenuConfig( data class ReadMenuConfig(
val titleBarIconPosition: Int = 0, val titleBarIconPosition: Int = 0,
@@ -381,6 +392,16 @@ sealed interface ReadBookIntent {
// Typed config mutation — single entry point for all ReadBookConfig changes // Typed config mutation — single entry point for all ReadBookConfig changes
data class UpdateConfig(val update: ConfigUpdate) : ReadBookIntent data class UpdateConfig(val update: ConfigUpdate) : ReadBookIntent
// Highlight rules
data object AddHighlightRule : ReadBookIntent
data class EditHighlightRule(val rule: HighlightRule) : ReadBookIntent
data class ToggleHighlightRule(val rule: HighlightRule, val enabled: Boolean) : ReadBookIntent
data class SaveHighlightRule(val rule: HighlightRule) : ReadBookIntent
data object DismissHighlightRuleEdit : ReadBookIntent
data class RequestDeleteHighlightRule(val rule: HighlightRule) : ReadBookIntent
data object ConfirmDeleteHighlightRule : ReadBookIntent
data object DismissDeleteHighlightRule : ReadBookIntent
// Icon picker — file IO handled by ViewModel // Icon picker — file IO handled by ViewModel
data class SaveMenuCustomIcon(val id: String, val uri: Uri) : ReadBookIntent data class SaveMenuCustomIcon(val id: String, val uri: Uri) : ReadBookIntent
data class SaveTitleBarCustomIcon(val id: String, val uri: Uri) : ReadBookIntent data class SaveTitleBarCustomIcon(val id: String, val uri: Uri) : ReadBookIntent
@@ -398,7 +419,7 @@ sealed interface ReadBookIntent {
// Default font picker (needs Activity for AlertDialog) // Default font picker (needs Activity for AlertDialog)
// Text action menu (moved from Activity) // Text action menu (moved from Activity)
data class TextActionAloud(val text: String) : ReadBookIntent data class TextActionAloud(val text: String) : ReadBookIntent
data class TextActionBookmark(val text: String) : ReadBookIntent data class TextActionBookmark(val bookmark: Bookmark) : ReadBookIntent
data class TextActionReplace(val text: String) : ReadBookIntent data class TextActionReplace(val text: String) : ReadBookIntent
data class TextActionSearchContent(val text: String) : ReadBookIntent data class TextActionSearchContent(val text: String) : ReadBookIntent
data class TextActionDict(val text: String) : ReadBookIntent data class TextActionDict(val text: String) : ReadBookIntent
@@ -1032,11 +1053,6 @@ sealed interface ConfigUpdate {
override val actions = emptySet<ConfigUpdateAction>() override val actions = emptySet<ConfigUpdateAction>()
} }
// --- Highlight rules ---
data class HighlightRules(val rules: List<io.legado.app.data.entities.HighlightRule>) : ConfigUpdate {
override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
}
// --- Auto read --- // --- Auto read ---
data class AutoReadSpeed(val value: Int) : ConfigUpdate { data class AutoReadSpeed(val value: Int) : ConfigUpdate {
override val actions = emptySet<ConfigUpdateAction>() override val actions = emptySet<ConfigUpdateAction>()
@@ -1,7 +1,6 @@
package io.legado.app.ui.book.read package io.legado.app.ui.book.read
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.Build import android.os.Build
import android.view.Gravity import android.view.Gravity
@@ -43,7 +42,6 @@ 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.entities.PageDirection
import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.ui.book.read.page.provider.TextPageFactory import io.legado.app.ui.book.read.page.provider.TextPageFactory
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.ui.login.SourceLoginJsExtensions import io.legado.app.ui.login.SourceLoginJsExtensions
import io.legado.app.ui.widget.PopupAction import io.legado.app.ui.widget.PopupAction
import io.legado.app.utils.ColorUtils import io.legado.app.utils.ColorUtils
@@ -529,7 +527,9 @@ class ReadBookController(
} }
R.id.menu_bookmark -> { R.id.menu_bookmark -> {
viewModel.onIntent(ReadBookIntent.TextActionBookmark(selectedText)) refs?.readView?.curPage?.createBookmark()?.let {
viewModel.onIntent(ReadBookIntent.TextActionBookmark(it))
} ?: activity.toastOnUi(R.string.create_bookmark_error)
return true return true
} }
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.ui.widget.components.log.AppLogSheet
import io.legado.app.ui.book.read.sheet.BgTextConfigSheet import io.legado.app.ui.book.read.sheet.BgTextConfigSheet
import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet
import io.legado.app.ui.book.read.sheet.CharsetConfigSheet import io.legado.app.ui.book.read.sheet.CharsetConfigSheet
@@ -20,22 +19,23 @@ import io.legado.app.ui.book.read.sheet.DictSheet
import io.legado.app.ui.book.read.sheet.DownloadSheet import io.legado.app.ui.book.read.sheet.DownloadSheet
import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet
import io.legado.app.ui.book.read.sheet.FontSelectSheet import io.legado.app.ui.book.read.sheet.FontSelectSheet
import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
import io.legado.app.ui.book.read.sheet.HttpTtsEditSheet
import io.legado.app.ui.book.read.sheet.MoreConfigSheet import io.legado.app.ui.book.read.sheet.MoreConfigSheet
import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet
import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet
import io.legado.app.ui.book.read.sheet.PhotoSheet import io.legado.app.ui.book.read.sheet.PhotoSheet
import io.legado.app.ui.book.read.sheet.ReadAloudConfigSheet import io.legado.app.ui.book.read.sheet.ReadAloudConfigSheet
import io.legado.app.ui.book.read.sheet.ReadAloudNumberConfigSheet import io.legado.app.ui.book.read.sheet.ReadAloudNumberConfigSheet
import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
import io.legado.app.ui.book.read.sheet.ShadowSetSheet import io.legado.app.ui.book.read.sheet.ShadowSetSheet
import io.legado.app.ui.book.read.sheet.SimulatedReadingSheet import io.legado.app.ui.book.read.sheet.SimulatedReadingSheet
import io.legado.app.ui.book.read.sheet.HttpTtsEditSheet
import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet
import io.legado.app.ui.book.read.sheet.TitleBarIconSheet import io.legado.app.ui.book.read.sheet.TitleBarIconSheet
import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
import io.legado.app.ui.widget.components.log.AppLogSheet
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -169,6 +169,7 @@ fun ReadBookScreen(
) )
HighlightRuleConfigSheet( HighlightRuleConfigSheet(
show = state.activeSheet is ReadBookSheet.HighlightRuleConfig, show = state.activeSheet is ReadBookSheet.HighlightRuleConfig,
state = state.highlightRuleConfig,
onDismissRequest = dismissSheet, onDismissRequest = dismissSheet,
onIntent = onIntent, onIntent = onIntent,
) )
@@ -17,12 +17,14 @@ import io.legado.app.constant.ReadMenuBlurMode
import io.legado.app.constant.Status import io.legado.app.constant.Status
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.Bookmark
import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.data.repository.ReadAloudSettingsRepository import io.legado.app.data.repository.ReadAloudSettingsRepository
import io.legado.app.data.repository.ReadBookStyleConfigRepository import io.legado.app.data.repository.ReadBookStyleConfigRepository
import io.legado.app.data.repository.ReadPreferences import io.legado.app.data.repository.ReadPreferences
@@ -42,7 +44,6 @@ import io.legado.app.help.book.isMobi
import io.legado.app.help.book.removeType import io.legado.app.help.book.removeType
import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.book.simulatedTotalChapterNum
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.utils.openUrl
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.getSourceType import io.legado.app.help.source.getSourceType
@@ -57,7 +58,6 @@ import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.config.HighlightRuleStore
import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.read.page.entities.TextPage import io.legado.app.ui.book.read.page.entities.TextPage
import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.ChapterProvider
@@ -74,6 +74,7 @@ import io.legado.app.utils.hexString
import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isTrue import io.legado.app.utils.isTrue
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.openUrl
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefInt
import io.legado.app.utils.toStringArray import io.legado.app.utils.toStringArray
@@ -120,7 +121,8 @@ class ReadBookViewModel(
private val readSettingsRepository: ReadSettingsRepository, private val readSettingsRepository: ReadSettingsRepository,
private val readBookStyleConfigRepository: ReadBookStyleConfigRepository, private val readBookStyleConfigRepository: ReadBookStyleConfigRepository,
private val readAloudSettingsRepository: ReadAloudSettingsRepository, private val readAloudSettingsRepository: ReadAloudSettingsRepository,
private val localPreferencesRepository: LocalPreferencesRepository private val localPreferencesRepository: LocalPreferencesRepository,
private val highlightRuleRepository: HighlightRuleRepository,
) : BaseViewModel(application), ReadBook.CallBack { ) : BaseViewModel(application), ReadBook.CallBack {
// --- MVI State --- // --- MVI State ---
@@ -296,6 +298,9 @@ class ReadBookViewModel(
if (intent.sheet is ReadBookSheet.Bookmark) { if (intent.sheet is ReadBookSheet.Bookmark) {
// Bookmark is shown as a menu route, not a sheet // Bookmark is shown as a menu route, not a sheet
openReadMenuRoute(ReadBookMenuRoute.Bookmark(intent.sheet.bookmark)) openReadMenuRoute(ReadBookMenuRoute.Bookmark(intent.sheet.bookmark))
} else if (intent.sheet is ReadBookSheet.HighlightRuleConfig) {
loadHighlightRules()
_uiState.update { it.copy(activeSheet = intent.sheet) }
} else { } else {
_uiState.update { it.copy(activeSheet = intent.sheet) } _uiState.update { it.copy(activeSheet = intent.sheet) }
} }
@@ -309,6 +314,15 @@ class ReadBookViewModel(
contentEditLoading = false, contentEditLoading = false,
contentEditSaveToSource = false, contentEditSaveToSource = false,
) )
} else if (it.activeSheet is ReadBookSheet.HighlightRuleConfig) {
it.copy(
activeSheet = null,
highlightRuleConfig = it.highlightRuleConfig.copy(
editingRule = null,
showNewRule = false,
deleteRule = null,
),
)
} else { } else {
it.copy(activeSheet = null) it.copy(activeSheet = null)
} }
@@ -506,6 +520,48 @@ class ReadBookViewModel(
is ReadBookIntent.UpdateConfig -> { is ReadBookIntent.UpdateConfig -> {
handleConfigUpdate(intent.update) handleConfigUpdate(intent.update)
} }
is ReadBookIntent.AddHighlightRule -> _uiState.update {
it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(showNewRule = true))
}
is ReadBookIntent.EditHighlightRule -> _uiState.update {
it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(editingRule = intent.rule))
}
is ReadBookIntent.ToggleHighlightRule -> {
val rules = _uiState.value.highlightRuleConfig.rules.map {
if (it.id == intent.rule.id) it.copy(enabled = intent.enabled) else it
}
saveHighlightRules(rules)
}
is ReadBookIntent.SaveHighlightRule -> {
val currentRules = _uiState.value.highlightRuleConfig.rules
val updatedRules = if (currentRules.any { it.id == intent.rule.id }) {
currentRules.map { if (it.id == intent.rule.id) intent.rule else it }
} else {
currentRules + intent.rule
}
saveHighlightRules(updatedRules)
}
is ReadBookIntent.DismissHighlightRuleEdit -> _uiState.update {
it.copy(
highlightRuleConfig = it.highlightRuleConfig.copy(
editingRule = null,
showNewRule = false,
),
)
}
is ReadBookIntent.RequestDeleteHighlightRule -> _uiState.update {
it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(deleteRule = intent.rule))
}
is ReadBookIntent.ConfirmDeleteHighlightRule -> deletePendingHighlightRule()
is ReadBookIntent.DismissDeleteHighlightRule -> _uiState.update {
it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(deleteRule = null))
}
is ReadBookIntent.SaveMenuCustomIcon -> saveMenuCustomIcon(intent.id, intent.uri) is ReadBookIntent.SaveMenuCustomIcon -> saveMenuCustomIcon(intent.id, intent.uri)
is ReadBookIntent.SaveTitleBarCustomIcon -> saveTitleBarCustomIcon(intent.id, intent.uri) is ReadBookIntent.SaveTitleBarCustomIcon -> saveTitleBarCustomIcon(intent.id, intent.uri)
is ReadBookIntent.OpenMenuCustomIconPicker -> { is ReadBookIntent.OpenMenuCustomIconPicker -> {
@@ -842,28 +898,16 @@ class ReadBookViewModel(
} }
is ReadBookIntent.TextActionBookmark -> { is ReadBookIntent.TextActionBookmark -> {
val book = ReadBook.book _uiState.update {
val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex) it.copy(
if (book != null && page != null) { menuState = ReadBookMenuState(
val bookmark = book.createBookMark().apply { visible = true,
chapterIndex = ReadBook.durChapterIndex routeStack = kotlinx.collections.immutable.persistentListOf(
chapterPos = ReadBook.durChapterPos ReadBookMenuRoute.Main,
chapterName = page.title ReadBookMenuRoute.Bookmark(intent.bookmark),
bookText = page.text.replace(Regex("[袮꧁]"), "").trim()
}
_uiState.update {
it.copy(
menuState = ReadBookMenuState(
visible = true,
routeStack = kotlinx.collections.immutable.persistentListOf(
ReadBookMenuRoute.Main,
ReadBookMenuRoute.Bookmark(bookmark),
),
), ),
) ),
} )
} else {
context.toastOnUi(R.string.create_bookmark_error)
} }
} }
@@ -2685,12 +2729,6 @@ class ReadBookViewModel(
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
} }
// --- Highlight rules ---
is ConfigUpdate.HighlightRules -> {
HighlightRuleStore.save(update.rules)
TextChapterLayout.invalidateRegexCache()
}
// --- Auto read --- // --- Auto read ---
is ConfigUpdate.AutoReadSpeed -> { is ConfigUpdate.AutoReadSpeed -> {
ReadBookConfig.autoReadSpeed = update.value ReadBookConfig.autoReadSpeed = update.value
@@ -2709,6 +2747,62 @@ class ReadBookViewModel(
} }
} }
private fun loadHighlightRules() {
val configName = ReadBookConfig.durConfig.name
_uiState.update {
it.copy(
highlightRuleConfig = it.highlightRuleConfig.copy(
rules = highlightRuleRepository.load(configName).toImmutableList(),
editingRule = null,
showNewRule = false,
deleteRule = null,
),
)
}
}
private fun saveHighlightRules(rules: List<HighlightRule>) {
val configName = ReadBookConfig.durConfig.name
val sanitizedRules = rules.map(highlightRuleRepository::sanitizeRule)
highlightRuleRepository.save(configName, sanitizedRules)
TextChapterLayout.invalidateRegexCache()
_uiState.update {
it.copy(
highlightRuleConfig = it.highlightRuleConfig.copy(
rules = highlightRuleRepository.load(configName).toImmutableList(),
editingRule = null,
showNewRule = false,
deleteRule = null,
),
)
}
_effects.tryEmit(
ReadBookEffect.UpdateReadViewConfig(
setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
)
)
}
private fun deletePendingHighlightRule() {
val rule = _uiState.value.highlightRuleConfig.deleteRule ?: return
val configName = ReadBookConfig.durConfig.name
highlightRuleRepository.delete(rule)
TextChapterLayout.invalidateRegexCache()
_uiState.update {
it.copy(
highlightRuleConfig = it.highlightRuleConfig.copy(
rules = highlightRuleRepository.load(configName).toImmutableList(),
deleteRule = null,
),
)
}
_effects.tryEmit(
ReadBookEffect.UpdateReadViewConfig(
setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
)
)
}
private fun saveMenuCustomIcon(id: String, uri: Uri) { private fun saveMenuCustomIcon(id: String, uri: Uri) {
execute { execute {
val iconFile = java.io.File(context.filesDir, "read_menu_icons/$id.png") val iconFile = java.io.File(context.filesDir, "read_menu_icons/$id.png")
@@ -2925,18 +3019,6 @@ class ReadBookViewModel(
)) ))
} }
ReadBookColorPickerIds.HIGHLIGHT_RULE_COLOR -> {
val pos = ReadBookColorPickerIds.pendingHighlightRulePosition
val rules = HighlightRuleStore.load()
if (pos in rules.indices) {
HighlightRuleStore.update(rules[pos].copy(textColor = color))
TextChapterLayout.invalidateRegexCache()
_effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(
setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
))
}
}
ReadBookColorPickerIds.MENU_BG_COLOR -> { ReadBookColorPickerIds.MENU_BG_COLOR -> {
ReadBookConfig.readMenuBgColor = color ReadBookConfig.readMenuBgColor = color
viewModelScope.launch { viewModelScope.launch {
@@ -22,13 +22,13 @@ import io.legado.app.constant.PageAnim
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.help.book.BookContent import io.legado.app.help.book.BookContent
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.getBookSource import io.legado.app.help.book.getBookSource
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.data.entities.HighlightRule
import io.legado.app.ui.book.read.config.HighlightRuleStore
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.ImageProvider import io.legado.app.model.ImageProvider
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
@@ -60,6 +60,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import java.util.LinkedList import java.util.LinkedList
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -75,20 +76,36 @@ class TextChapterLayout(
@Volatile @Volatile
private var cachedHighlightRules: List<CompiledHighlightRule>? = null private var cachedHighlightRules: List<CompiledHighlightRule>? = null
@Volatile
private var cachedHighlightRulesConfigName: String? = null
fun invalidateRegexCache() { fun invalidateRegexCache() {
cachedHighlightRules = null cachedHighlightRules = null
cachedHighlightRulesConfigName = null
} }
} }
private val compiledHighlightRules: List<CompiledHighlightRule> private val compiledHighlightRules: List<CompiledHighlightRule>
get() = cachedHighlightRules ?: HighlightRuleStore.loadEnabled().mapNotNull { rule -> get() {
runCatching { val configName = ReadBookConfig.durConfig.name
CompiledHighlightRule( cachedHighlightRules?.takeIf {
rule = rule, cachedHighlightRulesConfigName == configName
regex = Regex(rule.pattern) }?.let { return it }
) return highlightRuleRepository.loadEnabled(configName).mapNotNull { rule ->
}.getOrNull() runCatching {
}.also { cachedHighlightRules = it } CompiledHighlightRule(
rule = rule,
regex = Regex(rule.pattern)
)
}.getOrNull()
}.also {
cachedHighlightRulesConfigName = configName
cachedHighlightRules = it
}
}
private val highlightRuleRepository: HighlightRuleRepository
get() = GlobalContext.get().get()
@Volatile @Volatile
private var listener: LayoutProgressListener? = textChapter private var listener: LayoutProgressListener? = textChapter
@@ -3,37 +3,27 @@ package io.legado.app.ui.book.read.sheet
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.repository.configNames
import io.legado.app.ui.book.read.HighlightRuleConfigUiState
import io.legado.app.ui.book.read.ReadBookIntent import io.legado.app.ui.book.read.ReadBookIntent
import io.legado.app.ui.book.read.config.HighlightRuleStore
import io.legado.app.ui.book.read.config.HighlightRuleStore.configNames
import io.legado.app.ui.widget.components.TinySwitch import io.legado.app.ui.widget.components.TinySwitch
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.series.SmallTonalButton import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.TinySettingItem import io.legado.app.ui.widget.components.settingItem.TinySettingItem
@@ -41,19 +31,20 @@ import io.legado.app.ui.widget.components.settingItem.TinySettingItem
@Composable @Composable
fun HighlightRuleConfigSheet( fun HighlightRuleConfigSheet(
show: Boolean, show: Boolean,
state: HighlightRuleConfigUiState,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onIntent: (ReadBookIntent) -> Unit, onIntent: (ReadBookIntent) -> Unit,
) { ) {
var rules by remember { mutableStateOf(HighlightRuleStore.load()) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var deleteIndex by remember { mutableIntStateOf(-1) }
var editingRule by remember { mutableStateOf<HighlightRule?>(null) }
var showNewRule by remember { mutableStateOf(false) }
AppModalBottomSheet( AppModalBottomSheet(
show = show, show = show,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = stringResource(R.string.highlight_rule_config), title = stringResource(R.string.highlight_rule_config),
endAction = {
SmallTonalButton(
onClick = { onIntent(ReadBookIntent.AddHighlightRule) },
icon = Icons.Default.Add
)
}
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -64,100 +55,63 @@ fun HighlightRuleConfigSheet(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) { ) {
itemsIndexed(rules, key = { _, rule -> rule.id }) { index, rule -> itemsIndexed(state.rules, key = { _, rule -> rule.id }) { _, rule ->
HighlightRuleItem( HighlightRuleItem(
rule = rule, rule = rule,
onToggle = { enabled -> onToggle = { enabled ->
rules = rules.toMutableList().also { onIntent(ReadBookIntent.ToggleHighlightRule(rule, enabled))
it[index] = it[index].copy(enabled = enabled)
}
saveRules(rules, onIntent)
}, },
onEditClick = { editingRule = rule }, onEditClick = { onIntent(ReadBookIntent.EditHighlightRule(rule)) },
onDeleteClick = { onDeleteClick = {
deleteIndex = index onIntent(ReadBookIntent.RequestDeleteHighlightRule(rule))
showDeleteConfirm = true
}, },
) )
} }
} }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(
onClick = { showNewRule = true },
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.add))
}
}
} }
} }
// Edit existing rule // Edit existing rule
val editingRuleValue = editingRule val editingRuleValue = state.editingRule
if (show && editingRuleValue != null) { if (show && editingRuleValue != null) {
HighlightRuleEditSheet( HighlightRuleEditSheet(
show = true, show = true,
rule = editingRuleValue, rule = editingRuleValue,
onDismissRequest = { editingRule = null }, onDismissRequest = { onIntent(ReadBookIntent.DismissHighlightRuleEdit) },
onSave = { updated -> onSave = { updated ->
rules = rules.map { if (it.id == updated.id) updated else it } onIntent(ReadBookIntent.SaveHighlightRule(updated))
saveRules(rules, onIntent)
editingRule = null
}, },
) )
} }
// Add new rule // Add new rule
if (show && showNewRule) { if (show && state.showNewRule) {
HighlightRuleEditSheet( HighlightRuleEditSheet(
show = true, show = true,
rule = null, rule = null,
onDismissRequest = { showNewRule = false }, onDismissRequest = { onIntent(ReadBookIntent.DismissHighlightRuleEdit) },
onSave = { newRule -> onSave = { newRule ->
rules = rules + newRule onIntent(ReadBookIntent.SaveHighlightRule(newRule))
saveRules(rules, onIntent)
showNewRule = false
}, },
) )
} }
// Delete confirmation // Delete confirmation
if (showDeleteConfirm && deleteIndex in rules.indices) { val deletingRule = state.deleteRule
AlertDialog( val sureDeleteText = stringResource(R.string.sure_delete)
onDismissRequest = { AppAlertDialog(
showDeleteConfirm = false show = show && deletingRule != null,
deleteIndex = -1 onDismissRequest = { onIntent(ReadBookIntent.DismissDeleteHighlightRule) },
}, title = stringResource(R.string.delete),
containerColor = io.legado.app.ui.theme.LegadoTheme.colorScheme.surfaceContainer, text = deletingRule?.let { "$sureDeleteText \"${it.name}\"?" },
title = { Text(stringResource(R.string.delete)) }, confirmText = stringResource(android.R.string.ok),
text = { Text(stringResource(R.string.sure_delete) + " \"${rules[deleteIndex].name}\"?") }, onConfirm = { onIntent(ReadBookIntent.ConfirmDeleteHighlightRule) },
confirmButton = { dismissText = stringResource(android.R.string.cancel),
TextButton(onClick = { onDismiss = { onIntent(ReadBookIntent.DismissDeleteHighlightRule) },
rules = rules.toMutableList().also { it.removeAt(deleteIndex) } )
saveRules(rules, onIntent)
showDeleteConfirm = false
deleteIndex = -1
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
showDeleteConfirm = false
deleteIndex = -1
}) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
} }
@Composable @Composable
@@ -191,7 +145,3 @@ private fun HighlightRuleItem(
}, },
) )
} }
private fun saveRules(rules: List<HighlightRule>, onIntent: (ReadBookIntent) -> Unit) {
onIntent(ReadBookIntent.UpdateConfig(io.legado.app.ui.book.read.ConfigUpdate.HighlightRules(rules)))
}
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilterChip
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -39,13 +40,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.repository.configNames
import io.legado.app.data.repository.toJsonArray
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.config.HighlightRuleStore
import io.legado.app.ui.book.read.config.HighlightRuleStore.configNames
import io.legado.app.ui.book.read.config.HighlightRuleStore.toJsonArray
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SectionTitle import io.legado.app.ui.widget.components.SectionTitle
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
@@ -147,7 +148,7 @@ fun HighlightRuleEditSheet(
} }
} }
patternError = null patternError = null
val sanitized = HighlightRuleStore.sanitizeRule( onSave(
HighlightRule( HighlightRule(
id = initial.id, id = initial.id,
name = name, name = name,
@@ -170,7 +171,6 @@ fun HighlightRuleEditSheet(
fontPath = if (hasFont) fontPath.ifBlank { null } else null, fontPath = if (hasFont) fontPath.ifBlank { null } else null,
) )
) )
onSave(sanitized)
}) { }) {
androidx.compose.material3.Icon( androidx.compose.material3.Icon(
Icons.Default.Done, Icons.Default.Done,
@@ -372,33 +372,51 @@ fun HighlightRuleEditSheet(
// === Section 3: Config Binding === // === Section 3: Config Binding ===
if (allConfigNames.isNotEmpty()) { if (allConfigNames.isNotEmpty()) {
SectionTitle("应用排版") SectionTitle("应用排版")
AppText( LazyRow(
text = if (configNames.isEmpty()) "全局(所有排版生效)" modifier = Modifier.padding(vertical = 4.dp),
else "已选: ${configNames.joinToString("、")}", horizontalArrangement = Arrangement.spacedBy(8.dp)
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
androidx.compose.foundation.layout.FlowRow(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
// Global toggle // Global toggle
FilterChip( item {
selected = configNames.isEmpty(), val selected = configNames.isEmpty()
onClick = { configNames = emptySet() }, val bg = if (selected) LegadoTheme.colorScheme.secondaryContainer
label = { AppText("全局") }, else LegadoTheme.colorScheme.surfaceContainerLow
) val fg = if (selected) LegadoTheme.colorScheme.onSecondaryContainer
allConfigNames.forEach { cn -> else LegadoTheme.colorScheme.onSurfaceVariant
FilterChip( NormalCard(
selected = cn in configNames, onClick = { configNames = emptySet() },
containerColor = bg,
cornerRadius = 8.dp,
) {
AppText(
"全局",
style = LegadoTheme.typography.labelMedium,
color = fg,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
itemsIndexed(allConfigNames) { _, cn ->
val selected = cn in configNames
val bg = if (selected) LegadoTheme.colorScheme.secondaryContainer
else LegadoTheme.colorScheme.surfaceContainerLow
val fg = if (selected) LegadoTheme.colorScheme.onSecondaryContainer
else LegadoTheme.colorScheme.onSurfaceVariant
NormalCard(
onClick = { onClick = {
configNames = if (cn in configNames) configNames - cn configNames = if (selected) configNames - cn
else configNames + cn else configNames + cn
}, },
label = { AppText(cn) }, containerColor = bg,
) cornerRadius = 8.dp,
) {
AppText(
cn,
style = LegadoTheme.typography.labelMedium,
color = fg,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
} }
} }
} }