fix: 高亮规则增加更多配置

This commit is contained in:
HapeLee
2026-06-10 01:45:32 +08:00
parent b27f068525
commit eef1f58f26
17 changed files with 5089 additions and 116 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -102,8 +102,6 @@ object PreferKey {
const val replaceEnableDefault = "replaceEnableDefault" const val replaceEnableDefault = "replaceEnableDefault"
const val showBrightnessView = "showBrightnessView" const val showBrightnessView = "showBrightnessView"
const val useUnderline = "useUnderline" const val useUnderline = "useUnderline"
const val regexColorRules = "regexColorRules"
const val highlightRuleItems = "highlightRuleItems"
const val highlightRuleDialog = "highlightRuleDialog" const val highlightRuleDialog = "highlightRuleDialog"
const val highlightRuleBookTitle = "highlightRuleBookTitle" const val highlightRuleBookTitle = "highlightRuleBookTitle"
const val highlightRuleBracketNote = "highlightRuleBracketNote" const val highlightRuleBracketNote = "highlightRuleBracketNote"
@@ -75,7 +75,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 90, version = 92,
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,
@@ -133,7 +133,9 @@ val appDb by lazy {
AutoMigration(from = 86, to = 87), AutoMigration(from = 86, to = 87),
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 = 91, to = 92)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -30,6 +30,9 @@ interface HighlightRuleDao {
@Query("DELETE FROM highlightRules") @Query("DELETE FROM highlightRules")
fun deleteAll() fun deleteAll()
@Query("DELETE FROM highlightRules WHERE configName IS NULL")
fun deleteGlobal()
@Query("SELECT COUNT(*) FROM highlightRules") @Query("SELECT COUNT(*) FROM highlightRules")
fun count(): Int fun count(): Int
@@ -38,4 +41,10 @@ interface HighlightRuleDao {
deleteAll() deleteAll()
insertAll(rules) insertAll(rules)
} }
@Transaction
fun replaceGlobal(rules: List<HighlightRule>) {
deleteGlobal()
insertAll(rules)
}
} }
@@ -24,6 +24,8 @@ data class HighlightRule(
var bgImage: String? = null, var bgImage: String? = null,
var bgImageFit: Int = 0, var bgImageFit: Int = 0,
var bgImageScale: Float = 1f, var bgImageScale: Float = 1f,
var configName: String? = null,
var fontPath: String? = null,
) { ) {
fun styleSummary(): String { fun styleSummary(): String {
@@ -56,6 +58,9 @@ data class HighlightRule(
} }
) )
} }
if (!fontPath.isNullOrBlank()) {
parts.add("自定义字体")
}
if (parts.isEmpty()) { if (parts.isEmpty()) {
parts.add("无样式") parts.add("无样式")
} }
@@ -5,6 +5,7 @@ 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
@@ -118,7 +119,7 @@ class ReadStyleRepository {
val exportDir = appCtx.externalCache.getFile("readConfigExport") val exportDir = appCtx.externalCache.getFile("readConfigExport")
exportDir.createFolderReplace() exportDir.createFolderReplace()
val exportConfig = config.copy( val exportConfig = config.copy(
regexColorRules = ArrayList(config.regexColorRules.map { it.copy() }) highlightRules = ArrayList(config.highlightRules.map { it.copy() })
) )
val exportFiles = arrayListOf<File>() val exportFiles = arrayListOf<File>()
@@ -196,6 +197,10 @@ class ReadStyleRepository {
config.curTextColor() config.curTextColor()
config.curTextAccentColor() config.curTextAccentColor()
config.curTextShadowColor() config.curTextShadowColor()
if (config.highlightRules.isNotEmpty()) {
val targetConfigName = config.name.ifBlank { null }
HighlightRuleStore.saveForConfig(config.highlightRules, targetConfigName)
}
return config return config
} }
@@ -13,7 +13,9 @@ 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 androidx.compose.runtime.State
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
@@ -329,11 +331,6 @@ object ReadBookConfig {
val resolvedMenuBorderColor: Int val resolvedMenuBorderColor: Int
get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor
val regexColorRules: ArrayList<RegexColorRule> get() = durConfig.regexColorRules
fun saveRegexColorRules() {
save()
}
val config get() = if (shareLayout) shareConfig else durConfig val config get() = if (shareLayout) shareConfig else durConfig
@@ -776,7 +773,7 @@ object ReadBookConfig {
// endregion // endregion
fun getExportConfig(): Config { fun getExportConfig(): Config {
val exportConfig = durConfig.copy(regexColorRules = ArrayList(durConfig.regexColorRules.map { it.copy() })) val exportConfig = durConfig.copy(highlightRules = ArrayList(HighlightRuleStore.load()))
if (shareLayout) { if (shareLayout) {
exportConfig.textFont = shareConfig.textFont exportConfig.textFont = shareConfig.textFont
exportConfig.titleFont = shareConfig.titleFont exportConfig.titleFont = shareConfig.titleFont
@@ -963,7 +960,7 @@ object ReadBookConfig {
var menuBottomHorizontalMargin: Int = 0, var menuBottomHorizontalMargin: Int = 0,
@Transient @Transient
var menuBottomBottomMargin: Int = 0, var menuBottomBottomMargin: Int = 0,
var regexColorRules: ArrayList<RegexColorRule> = arrayListOf() var highlightRules: ArrayList<HighlightRule> = arrayListOf()
) { ) {
@Transient @Transient
@@ -1082,7 +1079,7 @@ object ReadBookConfig {
"tipDividerColor" to tipDividerColor, "tipDividerColor" to tipDividerColor,
"headerMode" to headerMode, "headerMode" to headerMode,
"footerMode" to footerMode, "footerMode" to footerMode,
"regexColorRules" to regexColorRules.map { mapOf("name" to it.name, "pattern" to it.pattern, "color" to it.color, "fontPath" to it.fontPath) } "highlightRules" to highlightRules.map { mapOf("id" to it.id, "name" to it.name, "pattern" to it.pattern, "sampleText" to it.sampleText, "targetScope" to it.targetScope, "enabled" to it.enabled, "position" to it.position, "textColor" to it.textColor, "bgColor" to it.bgColor, "underlineMode" to it.underlineMode, "underlineColor" to it.underlineColor, "underlineWidth" to it.underlineWidth, "underlineOffset" to it.underlineOffset, "underlineSvgPath" to it.underlineSvgPath, "bgImage" to it.bgImage, "bgImageFit" to it.bgImageFit, "bgImageScale" to it.bgImageScale, "configName" to it.configName, "fontPath" to it.fontPath) }
) )
fun getBgPath(bgIndex: Int): String? { fun getBgPath(bgIndex: Int): String? {
@@ -1,8 +0,0 @@
package io.legado.app.help.config
data class RegexColorRule(
var name: String,
var pattern: String,
var color: Int,
var fontPath: String = ""
)
@@ -7,9 +7,7 @@ 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
import io.legado.app.utils.getPrefString
import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.putPrefString
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
@@ -27,21 +25,51 @@ object HighlightRuleStore {
private val dao get() = appDb.highlightRuleDao private val dao get() = appDb.highlightRuleDao
fun load(): List<HighlightRule> { fun load(): List<HighlightRule> {
migrateFromPrefsIfNeeded() val configName = ReadBookConfig.durConfig.name
return dao.getAll() return dao.getAll().filter { it.matchesConfig(configName) }
} }
fun loadEnabled(): List<HighlightRule> { fun loadEnabled(): List<HighlightRule> {
migrateFromPrefsIfNeeded() val configName = ReadBookConfig.durConfig.name
return dao.getEnabled() return dao.getEnabled().filter { it.matchesConfig(configName) }
} }
/**
* 保存当前排版的规则。
* 仅替换当前排版绑定的规则,不影响其他排版的规则。
*/
fun save(rules: List<HighlightRule>) { fun save(rules: List<HighlightRule>) {
val configName = ReadBookConfig.durConfig.name
saveForConfig(rules, configName.ifBlank { null })
}
fun saveForConfig(rules: List<HighlightRule>, configName: String?) {
val sanitized = rules.mapIndexed { index, rule -> val sanitized = rules.mapIndexed { index, rule ->
sanitizeRule(rule).copy(position = index) sanitizeRule(rule).copy(position = index)
} }
dao.replaceAll(sanitized) if (configName.isNullOrBlank()) {
cleanupUnusedBgImages(sanitized) // 全局规则:只替换 configName 为 null 的规则
dao.replaceGlobal(sanitized)
} else {
// 按排版保存:只处理绑定到当前排版的规则,不动全局规则
val allRules = dao.getAll()
// 旧的绑定到当前排版的规则(不含全局规则)
val oldBound = allRules.filter {
!it.configName.isNullOrBlank() && it.matchesConfig(configName)
}
val newIds = sanitized.map { it.id }.toSet()
// 被移除的旧规则:从 configName 列表中去掉当前排版
// 未绑定任何排版时保留规则(configName="[]"),不删除
for (old in oldBound) {
if (old.id !in newIds) {
val remaining = old.configName.orEmpty().configNames().filter { it != configName }
dao.update(old.copy(configName = remaining.toJsonArray()))
}
}
// 插入所有规则(全局规则也一起,否则会被 replaceGlobal 删掉)
dao.insertAll(sanitized)
}
cleanupUnusedBgImages()
} }
fun update(rule: HighlightRule) { fun update(rule: HighlightRule) {
@@ -54,7 +82,12 @@ object HighlightRuleStore {
fun reset(): List<HighlightRule> { fun reset(): List<HighlightRule> {
val defaults = createDefaultRules() val defaults = createDefaultRules()
dao.replaceAll(defaults) val configName = ReadBookConfig.durConfig.name
if (configName.isBlank()) {
dao.replaceGlobal(defaults)
} else {
saveForConfig(defaults, configName)
}
return defaults return defaults
} }
@@ -73,70 +106,14 @@ object HighlightRuleStore {
val restoredBgImage = restoreRuleBgImage(backupRootPath, safeRule.bgImage) val restoredBgImage = restoreRuleBgImage(backupRootPath, safeRule.bgImage)
safeRule.copy(bgImage = restoredBgImage) safeRule.copy(bgImage = restoredBgImage)
} }
save(rules) // 备份恢复是全量替换
dao.replaceAll(rules)
cleanupUnusedBgImages()
appCtx.putPrefBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled) appCtx.putPrefBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled)
appCtx.putPrefBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled) appCtx.putPrefBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled)
appCtx.putPrefBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled) appCtx.putPrefBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled)
} }
/**
* 从旧版 SharedPreferences 迁移数据(一次性)
*/
private fun migrateFromPrefsIfNeeded() {
if (dao.count() > 0) return
// 尝试从 SharedPreferences 读取旧数据
val stored = appCtx.getPrefString(PreferKey.highlightRuleItems)
if (!stored.isNullOrBlank()) {
val oldRules = GSON.fromJsonArray<LegacyHighlightRule>(stored).getOrNull()
if (!oldRules.isNullOrEmpty()) {
val migrated = oldRules.mapIndexed { index, old ->
sanitizeRule(
HighlightRule(
id = old.id,
name = old.name,
pattern = old.pattern,
sampleText = old.sampleText,
targetScope = old.targetScope,
enabled = old.enabled,
position = index,
textColor = old.textColor,
underlineMode = old.underlineMode,
underlineColor = old.underlineColor,
underlineWidth = old.underlineWidth,
underlineOffset = old.underlineOffset,
underlineSvgPath = old.underlineSvgPath,
bgImage = old.bgImage,
bgImageFit = old.bgImageFit,
bgImageScale = old.bgImageScale,
)
).copy(position = index)
}
dao.insertAll(migrated)
// 清除旧 SharedPreferences 数据
appCtx.putPrefString(PreferKey.highlightRuleItems, null)
return
}
}
// 尝试从旧版 RegexColorRule 迁移
migrateFromRegexColorRules()
}
private fun migrateFromRegexColorRules() {
val oldRules = ReadBookConfig.regexColorRules
if (oldRules.isEmpty()) return
val migrated = oldRules.mapIndexed { index, old ->
HighlightRule(
name = old.name,
pattern = old.pattern,
position = index,
textColor = old.color,
)
}
dao.insertAll(migrated)
oldRules.clear()
ReadBookConfig.save()
}
fun sanitizeRule(rule: HighlightRule): HighlightRule { fun sanitizeRule(rule: HighlightRule): HighlightRule {
val name = runCatching { rule.name }.getOrNull().orEmpty() val name = runCatching { rule.name }.getOrNull().orEmpty()
val pattern = runCatching { rule.pattern }.getOrNull().orEmpty() val pattern = runCatching { rule.pattern }.getOrNull().orEmpty()
@@ -162,6 +139,8 @@ object HighlightRuleStore {
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),
bgImageScale = runCatching { rule.bgImageScale }.getOrDefault(1f).coerceIn(0.1f, 5f), bgImageScale = runCatching { rule.bgImageScale }.getOrDefault(1f).coerceIn(0.1f, 5f),
configName = runCatching { rule.configName }.getOrNull()?.takeIf { it.isNotBlank() },
fontPath = runCatching { rule.fontPath }.getOrNull()?.takeIf { it.isNotBlank() },
) )
} }
@@ -304,8 +283,9 @@ object HighlightRuleStore {
) )
} }
private fun cleanupUnusedBgImages(rules: List<HighlightRule>) { private fun cleanupUnusedBgImages() {
val usedPaths = rules.mapNotNull { it.bgImage } val allRules = dao.getAll()
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(appCtx.filesDir, "bg_images")
@@ -333,24 +313,34 @@ object HighlightRuleStore {
return targetFile.absolutePath return targetFile.absolutePath
} }
// region configName helpers
/** /**
* 旧版 SharedPreferences 数据结构(用于迁移) * 判断规则是否适用于指定排版。
* configName 为 null 表示全局规则(适用于所有排版)。
* configName 为 JSON 数组字符串,如 '["日间","夜间"]'。
*/ */
private data class LegacyHighlightRule( private fun HighlightRule.matchesConfig(configName: String): Boolean {
val id: String = "", val cn = this.configName
val name: String = "", if (cn.isNullOrBlank()) return true // 全局规则
val pattern: String = "", return cn.configNames().contains(configName)
val sampleText: String = "", }
val targetScope: Int = 0,
val enabled: Boolean = true, /**
val textColor: Int? = null, * 解析 configName JSON 数组为列表。
val underlineMode: Int = 0, */
val underlineColor: Int? = null, fun String.configNames(): List<String> {
val underlineWidth: Float = 1f, return runCatching {
val underlineOffset: Float = 2f, GSON.fromJsonArray<String>(this).getOrNull() ?: emptyList()
val underlineSvgPath: String? = null, }.getOrElse { emptyList() }
val bgImage: String? = null, }
val bgImageFit: Int = 0,
val bgImageScale: Float = 1f, /**
) * 将排版名列表转为 JSON 数组字符串。
*/
fun List<String>.toJsonArray(): String {
return GSON.toJson(this)
}
// endregion
} }
@@ -17,6 +17,7 @@ interface TextBaseColumn : BaseColumn {
val bgImage: String val bgImage: String
val bgImageFit: Int val bgImageFit: Int
val bgImageScale: Float val bgImageScale: Float
val fontPath: String
var selected: Boolean var selected: Boolean
var isSearchResult: Boolean var isSearchResult: Boolean
} }
@@ -4,11 +4,15 @@ import android.graphics.Canvas
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Build import android.os.Build
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.core.net.toUri
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.ContentTextView
import io.legado.app.ui.book.read.page.entities.TextLine import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.utils.isContentScheme
import splitties.init.appCtx
import java.io.File
@Keep @Keep
data class TextColumn( data class TextColumn(
@@ -25,6 +29,7 @@ data class TextColumn(
override val bgImage: String = "", override val bgImage: String = "",
override val bgImageFit: Int = 0, override val bgImageFit: Int = 0,
override val bgImageScale: Float = 1f, override val bgImageScale: Float = 1f,
override val fontPath: String = "",
) : TextBaseColumn { ) : TextBaseColumn {
override var textLine: TextLine = emptyTextLine override var textLine: TextLine = emptyTextLine
@@ -105,7 +110,29 @@ data class TextColumn(
} }
private fun getCustomTypeface(): Typeface? { private fun getCustomTypeface(): Typeface? {
// TODO: HighlightRule 不再存储 fontPath,需要重新设计自定义字体方案 if (fontPath.isEmpty()) return null
return null return typefaceCache.getOrPut(fontPath) {
loadTypeface(fontPath)
}
}
companion object {
private val typefaceCache = HashMap<String, Typeface?>()
private fun loadTypeface(fontPath: String): Typeface? {
return runCatching {
when {
fontPath.isContentScheme() -> {
appCtx.contentResolver
.openFileDescriptor(fontPath.toUri(), "r")!!
.use { Typeface.Builder(it.fileDescriptor).build() }
}
fontPath.isNotEmpty() -> {
Typeface.Builder(File(fontPath)).build()
}
else -> null
}
}.getOrNull()
}
} }
} }
@@ -30,6 +30,7 @@ data class TextHtmlColumn(
override val bgImage: String = "", override val bgImage: String = "",
override val bgImageFit: Int = 0, override val bgImageFit: Int = 0,
override val bgImageScale: Float = 1f, override val bgImageScale: Float = 1f,
override val fontPath: String = "",
) : TextBaseColumn { ) : TextBaseColumn {
override val textColor: Int? get() = mTextColor override val textColor: Int? get() = mTextColor
@@ -14,7 +14,8 @@ data class CharStyle(
val bgImage: String = "", val bgImage: String = "",
val bgImageFit: Int = 0, val bgImageFit: Int = 0,
val bgImageScale: Float = 1f, val bgImageScale: Float = 1f,
val fontPath: String = "",
) { ) {
val hasStyle: Boolean val hasStyle: Boolean
get() = textColor != null || bgColor != null || underlineMode != 0 || bgImage.isNotEmpty() get() = textColor != null || bgColor != null || underlineMode != 0 || bgImage.isNotEmpty() || fontPath.isNotEmpty()
} }
@@ -1240,7 +1240,8 @@ class TextChapterLayout(
underlineSvgPath = style?.underlineSvgPath ?: "", underlineSvgPath = style?.underlineSvgPath ?: "",
bgImage = style?.bgImage ?: "", bgImage = style?.bgImage ?: "",
bgImageFit = style?.bgImageFit ?: 0, bgImageFit = style?.bgImageFit ?: 0,
bgImageScale = style?.bgImageScale ?: 1f bgImageScale = style?.bgImageScale ?: 1f,
fontPath = style?.fontPath ?: ""
) )
} }
} }
@@ -1374,7 +1375,8 @@ class TextChapterLayout(
underlineSvgPath = rule.underlineSvgPath.orEmpty(), underlineSvgPath = rule.underlineSvgPath.orEmpty(),
bgImage = rule.bgImage.orEmpty(), bgImage = rule.bgImage.orEmpty(),
bgImageFit = rule.bgImageFit, bgImageFit = rule.bgImageFit,
bgImageScale = rule.bgImageScale bgImageScale = rule.bgImageScale,
fontPath = rule.fontPath.orEmpty()
) )
compiled.regex.findAll(text).forEach { match -> compiled.regex.findAll(text).forEach { match ->
for (i in match.range) { for (i in match.range) {
@@ -32,6 +32,7 @@ import io.legado.app.R
import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HighlightRule
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
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.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
@@ -166,9 +167,10 @@ private fun HighlightRuleItem(
onEditClick: () -> Unit, onEditClick: () -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
) { ) {
val configLabel = rule.configName?.configNames()?.joinToString("") ?: "全局"
TinySettingItem( TinySettingItem(
title = rule.name.ifBlank { rule.displayPattern() }, title = rule.name.ifBlank { rule.displayPattern() },
description = rule.styleSummary(), description = "${rule.styleSummary()} · $configLabel",
onClick = onEditClick, onClick = onEditClick,
trailingContent = { trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1,9 +1,11 @@
package io.legado.app.ui.book.read.sheet package io.legado.app.ui.book.read.sheet
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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
@@ -20,6 +23,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -35,7 +39,10 @@ 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.help.config.ReadBookConfig
import io.legado.app.ui.book.read.config.HighlightRuleStore 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
@@ -47,6 +54,7 @@ import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.launch
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
@@ -87,10 +95,21 @@ fun HighlightRuleEditSheet(
var bgImageScale by remember { mutableFloatStateOf(initial.bgImageScale) } var bgImageScale by remember { mutableFloatStateOf(initial.bgImageScale) }
var hasBgImage by remember { mutableStateOf(initial.bgImage?.isNotBlank() == true) } var hasBgImage by remember { mutableStateOf(initial.bgImage?.isNotBlank() == true) }
// Config binding state — empty set = global (applies to all configs)
val allConfigNames = remember { ReadBookConfig.configList.map { it.name }.filter { it.isNotBlank() } }
var configNames by remember {
mutableStateOf(initial.configName.orEmpty().configNames().toSet())
}
// Font state
var hasFont by remember { mutableStateOf(initial.fontPath?.isNotBlank() == true) }
var fontPath by remember { mutableStateOf(initial.fontPath.orEmpty()) }
// Color picker state // Color picker state
var showTextColorPicker by remember { mutableStateOf(false) } var showTextColorPicker by remember { mutableStateOf(false) }
var showBgColorPicker by remember { mutableStateOf(false) } var showBgColorPicker by remember { mutableStateOf(false) }
var showUnderlineColorPicker by remember { mutableStateOf(false) } var showUnderlineColorPicker by remember { mutableStateOf(false) }
var showFontSelect by remember { mutableStateOf(false) }
// Validation // Validation
var patternError by remember { mutableStateOf<String?>(null) } var patternError by remember { mutableStateOf<String?>(null) }
@@ -147,6 +166,8 @@ fun HighlightRuleEditSheet(
bgImage = if (hasBgImage) bgImage.ifBlank { null } else null, bgImage = if (hasBgImage) bgImage.ifBlank { null } else null,
bgImageFit = bgImageFit, bgImageFit = bgImageFit,
bgImageScale = bgImageScale, bgImageScale = bgImageScale,
configName = if (configNames.isEmpty()) null else configNames.toList().toJsonArray(),
fontPath = if (hasFont) fontPath.ifBlank { null } else null,
) )
) )
onSave(sanitized) onSave(sanitized)
@@ -348,7 +369,56 @@ fun HighlightRuleEditSheet(
) )
} }
// === Section 3: Preview === // === Section 3: Config Binding ===
if (allConfigNames.isNotEmpty()) {
SectionTitle("应用排版")
AppText(
text = if (configNames.isEmpty()) "全局(所有排版生效)"
else "已选: ${configNames.joinToString("、")}",
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
FilterChip(
selected = configNames.isEmpty(),
onClick = { configNames = emptySet() },
label = { AppText("全局") },
)
allConfigNames.forEach { cn ->
FilterChip(
selected = cn in configNames,
onClick = {
configNames = if (cn in configNames) configNames - cn
else configNames + cn
},
label = { AppText(cn) },
)
}
}
}
// === Section 4: Font ===
SectionTitle("字体替换")
TinySwitchSettingItem(
title = "自定义字体",
checked = hasFont,
onCheckedChange = { hasFont = it },
)
if (hasFont) {
TinyClickableSettingItem(
title = stringResource(R.string.select_font),
description = fontPath.ifBlank { null }?.let { File(it).name },
onClick = { showFontSelect = true },
)
}
// === Section 5: Preview ===
SectionTitle(stringResource(R.string.preview_effect)) SectionTitle(stringResource(R.string.preview_effect))
AppTextField( AppTextField(
@@ -408,6 +478,32 @@ fun HighlightRuleEditSheet(
}, },
) )
} }
// Font selector
if (showFontSelect) {
val readSettingsRepository: io.legado.app.data.repository.ReadSettingsRepository =
org.koin.compose.koinInject()
val scope = rememberCoroutineScope()
val fontFolderLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
context.contentResolver.takePersistableUriPermission(
it, Intent.FLAG_GRANT_READ_URI_PERMISSION
)
scope.launch {
readSettingsRepository.setFontFolder(it.toString())
}
}
}
FontSelectSheet(
show = true,
onDismissRequest = { showFontSelect = false },
onSelectFont = { fontPath = it; showFontSelect = false },
onSelectSystemTypeface = { fontPath = ""; showFontSelect = false },
onOpenFolderPicker = { fontFolderLauncher.launch(null) },
)
}
} }
@Composable @Composable