Просмотр исходного кода

recover(reader): 重建滚动阅读修复分支

对象库事故导致原分支中间 tree 丢失。本提交以完整 main 为父节点,保留原 tip 076a3e6902 的完整最终文件树。

合并保留的原提交:
- 31036b9c fix: 阅读标题和文字计算
- 17f7f20a feat: 酒公阁
- 9cd4e254 feat: 优化书签和备注界面
- 27c2565c test: 补齐 SaveMarkingUseCaseTest 的 flowAll 覆写
- 0f47a052 fix(reader): 滚动模式章末页连续衔接
- 076a3e690 fix(reader): 单图样式对齐旧 View,正文页保持一屏高
HapeLee 1 месяц назад
Родитель
Сommit
9e49940d19
47 измененных файлов с 2275 добавлено и 653 удалено
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 8 0
      app/src/main/java/io/legado/app/data/dao/BookMarkingDao.kt
  3. 2 0
      app/src/main/java/io/legado/app/data/entities/HighlightRule.kt
  4. 2 0
      app/src/main/java/io/legado/app/data/repository/BookMarkingRepository.kt
  5. 1 1
      app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt
  6. 3 0
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  7. 3 0
      app/src/main/java/io/legado/app/domain/gateway/BookMarkingGateway.kt
  8. 11 3
      app/src/main/java/io/legado/app/domain/model/BookContentProcessModels.kt
  9. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  10. 209 15
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  11. 145 41
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt
  12. 4 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayout.kt
  13. 1 0
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt
  14. 30 1
      app/src/main/java/io/legado/app/feature/reader/platform/ReaderPageDecorationDrawCache.kt
  15. 5 2
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  16. 350 306
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
  17. 218 142
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt
  18. 7 0
      app/src/main/java/io/legado/app/ui/book/read/MarkingContract.kt
  19. 86 41
      app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt
  20. 490 0
      app/src/main/java/io/legado/app/ui/book/read/MarkingSelectionMenu.kt
  21. 9 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  22. 12 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  23. 8 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarTitle.kt
  24. 38 10
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  25. 26 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  26. 11 0
      app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt
  27. 55 0
      app/src/main/java/io/legado/app/ui/book/read/SelectionMenuMotion.kt
  28. 125 52
      app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt
  29. 23 1
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt
  30. 2 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/MarkingSheet.kt
  31. 5 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderMoreActionsSheet.kt
  32. 1 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  33. 14 0
      app/src/main/java/io/legado/app/utils/HtmlFormatter.kt
  34. 11 1
      app/src/main/res/values-zh-rCN/strings.xml
  35. 11 1
      app/src/main/res/values-zh-rHK/strings.xml
  36. 11 1
      app/src/main/res/values-zh-rTW/strings.xml
  37. 11 1
      app/src/main/res/values/strings.xml
  38. 2 0
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  39. 19 0
      app/src/test/java/io/legado/app/domain/model/MarkingEffectTest.kt
  40. 2 0
      app/src/test/java/io/legado/app/domain/usecase/SaveMarkingUseCaseTest.kt
  41. 5 2
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderLongImageScrollTest.kt
  42. 197 13
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderPaginatorTest.kt
  43. 40 0
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderSingleImageTest.kt
  44. 9 7
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderVolumeTitlePageTest.kt
  45. 17 7
      app/src/test/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayoutTest.kt
  46. 25 0
      app/src/test/java/io/legado/app/feature/reader/platform/ReaderPageDecorationDrawCacheTest.kt
  47. 9 0
      app/src/test/java/io/legado/app/utils/HtmlFormatterTest.kt

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -145,6 +145,7 @@ object PreferKey {
     const val expandTextMenu = "expandTextMenu"
     const val showSelectMenuIcon = "showSelectMenuIcon"
     const val textSelectMenuConfig = "textSelectMenuConfig"
+    const val lastMarkingStyle = "lastMarkingStyle"
     const val doublePageHorizontal = "doubleHorizontalPage"
     const val readUrlOpenInBrowser = "readUrlInBrowser"
     const val defaultBookTreeUri = "defaultBookTreeUri"

+ 8 - 0
app/src/main/java/io/legado/app/data/dao/BookMarkingDao.kt

@@ -18,6 +18,14 @@ interface BookMarkingDao {
     )
     val all: List<BookMarking>
 
+    @Query(
+        """
+        select * from book_marks
+        order by bookName collate localized, bookAuthor collate localized, chapterIndex, createdAt
+        """
+    )
+    fun flowAll(): Flow<List<BookMarking>>
+
     /** 恢复备份用:按主键整批写入(同 id 覆盖,重复导入幂等)。 */
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     suspend fun insert(vararg bookMarkings: BookMarking)

+ 2 - 0
app/src/main/java/io/legado/app/data/entities/HighlightRule.kt

@@ -55,6 +55,8 @@ data class HighlightRule(
                     3 -> "波浪下划线"
                     4 -> "双下划线"
                     5 -> "自定义SVG"
+                    6 -> "删除线"
+                    7 -> "荧光"
                     else -> "下划线"
                 } + underlineColor?.let { " ${it.toHexColor()}" }.orEmpty()
             )

+ 2 - 0
app/src/main/java/io/legado/app/data/repository/BookMarkingRepository.kt

@@ -12,6 +12,8 @@ class BookMarkingRepository(
     private val dao: BookMarkingDao,
 ) : BookMarkingGateway {
 
+    override fun flowAll(): Flow<List<BookMarking>> = dao.flowAll().flowOn(Dispatchers.IO)
+
     override suspend fun getByBook(
         bookName: String,
         bookAuthor: String,

+ 1 - 1
app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt

@@ -162,7 +162,7 @@ class HighlightRuleRepository(
             position = runCatching { rule.position }.getOrDefault(0),
             textColor = runCatching { rule.textColor }.getOrNull(),
             bgColor = runCatching { rule.bgColor }.getOrNull(),
-            underlineMode = runCatching { rule.underlineMode }.getOrDefault(0).coerceIn(0, 5),
+            underlineMode = runCatching { rule.underlineMode }.getOrDefault(0).coerceIn(0, 7),
             underlineColor = runCatching { rule.underlineColor }.getOrNull(),
             underlineWidth = runCatching { rule.underlineWidth }.getOrDefault(1f)
                 .coerceIn(0.1f, 10f),

+ 3 - 0
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -391,6 +391,7 @@ class ReadSettingsRepository(
             expandTextMenu = compatDsValue(Keys.ExpandTextMenu, false),
             showSelectMenuIcon = compatDsValue(Keys.ShowSelectMenuIcon, true),
             textSelectMenuConfig = compatDsValue(Keys.TextSelectMenuConfig, ""),
+            lastMarkingStyle = compatDsValue(Keys.LastMarkingStyle, ""),
             showReadTitleAddition = compatDsValue(Keys.ShowReadTitleAddition, true),
             autoReadSpeed = compatDsValue(Keys.AutoReadSpeed, 10),
             systemTypefaces = compatDsValue(Keys.SystemTypefaces, 0),
@@ -512,6 +513,7 @@ class ReadSettingsRepository(
         val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu)
         val ShowSelectMenuIcon = booleanPreferencesKey(PreferKey.showSelectMenuIcon)
         val TextSelectMenuConfig = stringPreferencesKey(PreferKey.textSelectMenuConfig)
+        val LastMarkingStyle = stringPreferencesKey(PreferKey.lastMarkingStyle)
         val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition)
         val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed)
         val SystemTypefaces = intPreferencesKey(PreferKey.systemTypefaces)
@@ -643,6 +645,7 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.expandTextMenu to expandTextMenu,
     PreferKey.showSelectMenuIcon to showSelectMenuIcon,
     PreferKey.textSelectMenuConfig to textSelectMenuConfig,
+    PreferKey.lastMarkingStyle to lastMarkingStyle,
     PreferKey.showReadTitleAddition to showReadTitleAddition,
     PreferKey.autoReadSpeed to autoReadSpeed,
     PreferKey.systemTypefaces to systemTypefaces,

+ 3 - 0
app/src/main/java/io/legado/app/domain/gateway/BookMarkingGateway.kt

@@ -4,6 +4,9 @@ import io.legado.app.data.entities.BookMarking
 import kotlinx.coroutines.flow.Flow
 
 interface BookMarkingGateway {
+    /** 全部划线与笔记,供书签管理页按书聚合和跨书搜索。 */
+    fun flowAll(): Flow<List<BookMarking>>
+
     /** 按「书名+作者」查,供保存去重/定位;chapterIndex 可空。 */
     suspend fun getByBook(
         bookName: String,

+ 11 - 3
app/src/main/java/io/legado/app/domain/model/BookContentProcessModels.kt

@@ -49,18 +49,19 @@ data class TextProcessStyle(
 )
 
 /**
- * 用户划线/高亮笔记的 5 种效果(5x1 互斥):单实线/波浪线/虚线/背景色/字体色。
+ * 用户划线/高亮笔记的互斥效果。
  *
  * 样式即类型:book_marks 不再存 kind 列,效果由 [TextProcessStyle] 推导/生成,
  * 渲染引擎按 styleJson 画线,效果本身与「划线 vs 高亮」的老二元 kind 等价。
  */
 @Keep
 enum class MarkingEffect {
-    SOLID, WAVE, DASHED, BG, TEXT;
+    SOLID, WAVE, DASHED, STRIKE, HIGHLIGHT, BG, TEXT;
 
     /** 是否属于下划线类效果(对应 underlineMode != 0)。 */
     val isUnderline: Boolean
-        get() = this == SOLID || this == WAVE || this == DASHED
+        get() = this == SOLID || this == WAVE || this == DASHED ||
+                this == STRIKE || this == HIGHLIGHT
 
     /**
      * 由效果 + 选中颜色生成样式。背景色自动半透明(约 20% alpha),
@@ -70,6 +71,11 @@ enum class MarkingEffect {
         SOLID -> TextProcessStyle(underlineMode = 1, underlineColor = color)
         WAVE -> TextProcessStyle(underlineMode = 3, underlineColor = color)
         DASHED -> TextProcessStyle(underlineMode = 2, underlineColor = color)
+        STRIKE -> TextProcessStyle(underlineMode = 6, underlineColor = color)
+        HIGHLIGHT -> TextProcessStyle(
+            underlineMode = 7,
+            underlineColor = (color and 0x00FFFFFF) or 0x66000000,
+        )
         BG -> TextProcessStyle(bgColor = (color and 0x00FFFFFF) or 0x33000000)
         TEXT -> TextProcessStyle(textColor = color)
     }
@@ -83,6 +89,8 @@ enum class MarkingEffect {
             style?.underlineMode == 1 -> SOLID
             style?.underlineMode == 3 -> WAVE
             style?.underlineMode == 2 -> DASHED
+            style?.underlineMode == 6 -> STRIKE
+            style?.underlineMode == 7 -> HIGHLIGHT
             style?.bgColor != null -> BG
             style?.textColor != null -> TEXT
             else -> SOLID

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt

@@ -49,6 +49,7 @@ data class ReadSettings(
     val expandTextMenu: Boolean = false,
     val showSelectMenuIcon: Boolean = true,
     val textSelectMenuConfig: String = "",
+    val lastMarkingStyle: String = "",
     val showReadTitleAddition: Boolean = true,
     val autoReadSpeed: Int = 10,
     val systemTypefaces: Int = 0,

+ 209 - 15
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -87,6 +87,7 @@ import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import io.legado.app.R
+import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.feature.reader.core.accessibility.ReaderAccessibilityPolicy
 import io.legado.app.feature.reader.core.gesture.PullBookmarkDefaults
 import io.legado.app.feature.reader.core.gesture.PullBookmarkGesture
@@ -182,6 +183,7 @@ fun ReaderCanvasSurface(
     backgroundRevision: Long,
     backgroundImageAlpha: Float,
     selectionColor: Color,
+    selectionPreviewStyle: TextProcessStyle? = null,
     textAccentColor: Color,
     autoPageIndicatorColor: Color,
     modifier: Modifier = Modifier,
@@ -1368,9 +1370,33 @@ fun ReaderCanvasSurface(
                 } else if (released && !suppressTap && total.getDistance() < pageTouchSlop) {
                     // 元素命中复用 DOWN 时刻的布局:与长按同一坐标系,且不被
                     // 松手前可能发生的窗口替换干扰。
-                    val elementHandled = downPlacement?.page
-                        ?.elementAt(down.position.x, downPageY)
-                        ?.let(onElementClick) == true
+                    val hitPage = downPlacement?.page
+                    val hitElement = hitPage?.elementAt(down.position.x, downPageY)
+                    val elementHandled = if (
+                        hitPage != null && hitElement is ReaderElement.Text &&
+                        hitElement.markingId != null
+                    ) {
+                        val markingElements = hitPage.elements
+                            .filterIsInstance<ReaderElement.Text>()
+                            .filter { it.markingId == hitElement.markingId }
+                            .sortedBy { it.chapterPosition }
+                        val first = markingElements.firstOrNull()
+                        val last = markingElements.lastOrNull()
+                        if (first != null && last != null && onElementClick(hitElement)) {
+                            val markingSelection = ReaderSelection(
+                                chapterIndex = hitPage.id.chapterIndex,
+                                anchor = first.chapterPosition,
+                                focus = last.chapterPosition,
+                                anchorIsTitle = first.emphasized,
+                                focusIsTitle = last.emphasized,
+                            )
+                            textSelection = markingSelection
+                            showSelectionMenu(markingSelection, downWindow)
+                            true
+                        } else false
+                    } else {
+                        hitElement?.let(onElementClick) == true
+                    }
                     if (elementHandled) {
                         // Element actions take precedence over reader tap zones.
                     } else dispatchTapAction(
@@ -1410,6 +1436,7 @@ fun ReaderCanvasSurface(
                     selection = selectionColor,
                     readAloud = textAccentColor,
                     selectionProvider = { textSelection },
+                    selectionPreviewStyle = selectionPreviewStyle,
                     cachedImage = cachedImage,
                     loadImage = loadImage,
                 )
@@ -1433,6 +1460,7 @@ fun ReaderCanvasSurface(
                 selectionColor,
                 textAccentColor,
                 textSelection,
+                selectionPreviewStyle,
                 cachedImage,
                 loadImage
             )
@@ -1449,6 +1477,7 @@ fun ReaderCanvasSurface(
                             alpha = transform.alpha
                         },
                     textSelection,
+                    selectionPreviewStyle,
                     cachedImage,
                     loadImage,
                 )
@@ -1486,7 +1515,19 @@ fun ReaderCanvasSurface(
                     .drawWithContent {
                         clipRect(bottom = autoRevealPx.coerceAtMost(size.height)) { this@drawWithContent.drawContent() }
                     }) {
-                    ReaderPageCanvas(page, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, Modifier.fillMaxSize(), textSelection, cachedImage, loadImage)
+                    ReaderPageCanvas(
+                        page,
+                        backgroundColor,
+                        pageBackgroundImage,
+                        backgroundImageAlpha,
+                        selectionColor,
+                        textAccentColor,
+                        Modifier.fillMaxSize(),
+                        textSelection,
+                        selectionPreviewStyle,
+                        cachedImage,
+                        loadImage
+                    )
                 }
             }
             Canvas(Modifier.fillMaxSize()) {
@@ -1667,6 +1708,7 @@ private fun ScrollPageStack(
     selection: Color,
     readAloud: Color,
     selectionProvider: () -> ReaderSelection?,
+    selectionPreviewStyle: TextProcessStyle?,
     cachedImage: (ReaderElement.Image) -> Bitmap?,
     loadImage: suspend (ReaderElement.Image) -> Bitmap?,
 ) {
@@ -1735,6 +1777,7 @@ private fun ScrollPageStack(
                     readAloud,
                     activeSelection,
                     selectedBounds,
+                    selectionPreviewStyle,
                     cachedImage
                 )
             }
@@ -1757,16 +1800,30 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
     readAloud: Color,
     activeSelection: ReaderSelection?,
     selectedBounds: List<ReaderRect>,
+    selectionPreviewStyle: TextProcessStyle?,
     cachedImage: (ReaderElement.Image) -> Bitmap?,
 ) {
     val native = drawContext.canvas.nativeCanvas
+    val visibleDecorationCache = if (selectionPreviewStyle != null && activeSelection != null) {
+        ReaderPageDecorationDrawCache.create(page.withoutSelectionDecorations(activeSelection))
+    } else data.decorationDrawCache
     data.textBackgroundRevision.value
     data.textBackgrounds.forEach { run ->
         ReaderTextBackgroundLoader.cached(run.image.source)?.let { bitmap ->
             drawTextBackground(native, bitmap, run, data.textBackgroundPaint)
         }
     }
-    data.textBackgroundBands.forEach { band ->
+    val previewing = selectionPreviewStyle != null && activeSelection != null
+    val previewBounds = if (previewing) {
+        data.textElements.filter { activeSelection.contains(it, page.id.chapterIndex) }
+            .map(ReaderElement.Text::bounds)
+            .mergeSelectionBounds()
+    } else emptyList()
+    val visibleTextBackgroundBands = if (previewing) {
+        data.textElements.filterNot { activeSelection.contains(it, page.id.chapterIndex) }
+            .mergeBackgroundBounds()
+    } else data.textBackgroundBands
+    visibleTextBackgroundBands.forEach { band ->
         drawRect(
             Color(band.colorArgb),
             Offset(band.bounds.left, band.bounds.top),
@@ -1776,10 +1833,14 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
     selectedBounds.forEach { rect ->
         drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
     }
+    drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = true)
+    visibleDecorationCache.halfHighlights.forEach { it.draw(native) }
     page.elements.forEach { e -> when (e) {
         is ReaderElement.Text -> {
             val paint = data.paints.getValue(e.style)
-            paint.color = page.resolvedColorArgb(e, readAloud.toArgb())
+            paint.color = if (previewing && activeSelection.contains(e, page.id.chapterIndex)) {
+                selectionPreviewStyle.textColor ?: page.previewBaseTextColor(e)
+            } else page.resolvedColorArgb(e, readAloud.toArgb())
             paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
             native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
         }
@@ -1821,9 +1882,10 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
             strokeWidth = run.style.widthPx,
         )
     }
-    data.decorationDrawCache.contentRules.forEach { it.draw(native) }
-    data.decorationDrawCache.styledUnderlines.forEach { it.draw(native) }
-    data.decorationDrawCache.overlayRules.forEach { it.draw(native) }
+    visibleDecorationCache.contentRules.forEach { it.draw(native) }
+    visibleDecorationCache.styledUnderlines.forEach { it.draw(native) }
+    drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = false)
+    visibleDecorationCache.overlayRules.forEach { it.draw(native) }
 }
 
 @Composable
@@ -1840,6 +1902,7 @@ private fun SimulationPageStack(
     selection: Color,
     readAloud: Color,
     activeSelection: ReaderSelection?,
+    selectionPreviewStyle: TextProcessStyle?,
     cachedImage: (ReaderElement.Image) -> Bitmap?,
     loadImage: suspend (ReaderElement.Image) -> Bitmap?,
 ) {
@@ -1872,6 +1935,7 @@ private fun SimulationPageStack(
                 readAloud,
                 Modifier.fillMaxSize(),
                 activeSelection,
+                selectionPreviewStyle,
                 cachedImage,
                 loadImage
             )
@@ -1890,6 +1954,7 @@ private fun SimulationPageStack(
                     .fillMaxSize()
                     .graphicsLayer { translationX = baseTranslation },
                 activeSelection,
+                selectionPreviewStyle,
                 cachedImage,
                 loadImage,
             )
@@ -1906,7 +1971,19 @@ private fun SimulationPageStack(
                 baseLayer.record { this@drawWithContent.drawContent() }
                 clipPath(path0, ClipOp.Difference) { drawLayer(baseLayer) }
             }) {
-            ReaderPageCanvas(basePage, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.fillMaxSize(), activeSelection, cachedImage, loadImage)
+            ReaderPageCanvas(
+                basePage,
+                background,
+                backgroundImage,
+                backgroundImageAlpha,
+                selection,
+                readAloud,
+                Modifier.fillMaxSize(),
+                activeSelection,
+                selectionPreviewStyle,
+                cachedImage,
+                loadImage
+            )
         }
         Box(Modifier
             .fillMaxSize()
@@ -1918,7 +1995,19 @@ private fun SimulationPageStack(
                     }
                 }
             }) {
-            ReaderPageCanvas(revealPage, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.fillMaxSize(), activeSelection, cachedImage, loadImage)
+            ReaderPageCanvas(
+                revealPage,
+                background,
+                backgroundImage,
+                backgroundImageAlpha,
+                selection,
+                readAloud,
+                Modifier.fillMaxSize(),
+                activeSelection,
+                selectionPreviewStyle,
+                cachedImage,
+                loadImage
+            )
         }
         Canvas(Modifier.fillMaxSize()) {
             clipPath(path0) { clipPath(pathBack) {
@@ -2052,6 +2141,7 @@ private fun ReaderPageCanvas(
     readAloud: Color,
     modifier: Modifier,
     activeSelection: ReaderSelection?,
+    selectionPreviewStyle: TextProcessStyle?,
     cachedImage: (ReaderElement.Image) -> Bitmap?,
     loadImage: suspend (ReaderElement.Image) -> Bitmap?,
     drawBackground: Boolean = true,
@@ -2091,10 +2181,18 @@ private fun ReaderPageCanvas(
     val textBackgroundPaint = remember {
         Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
     }
-    val decorationDrawCache = remember(page.elements) {
-        ReaderPageDecorationDrawCache.create(page)
+    val previewing = selectionPreviewStyle != null && activeSelection != null
+    val decorationDrawCache = remember(page.elements, activeSelection, previewing) {
+        ReaderPageDecorationDrawCache.create(
+            if (previewing) page.withoutSelectionDecorations(activeSelection) else page
+        )
+    }
+    val textBackgroundBands = remember(textElements, activeSelection, previewing) {
+        if (previewing) {
+            textElements.filterNot { activeSelection.contains(it, page.id.chapterIndex) }
+                .mergeBackgroundBounds()
+        } else textElements.mergeBackgroundBounds()
     }
-    val textBackgroundBands = remember(textElements) { textElements.mergeBackgroundBounds() }
     val selectedTextBounds = remember(
         textElements,
         activeSelection,
@@ -2109,6 +2207,13 @@ private fun ReaderPageCanvas(
             .map(ReaderElement.Text::bounds)
             .mergeSelectionBounds()
     }
+    val previewBounds = remember(textElements, activeSelection, previewing) {
+        if (previewing) {
+            textElements.filter { activeSelection.contains(it, page.id.chapterIndex) }
+                .map(ReaderElement.Text::bounds)
+                .mergeSelectionBounds()
+        } else emptyList()
+    }
     val textBackgroundSources = remember(textBackgrounds) {
         textBackgrounds.map { it.image.source }.distinct()
     }
@@ -2157,10 +2262,14 @@ private fun ReaderPageCanvas(
         selectedTextBounds.forEach { rect ->
             drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
         }
+        drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = true)
+        decorationDrawCache.halfHighlights.forEach { it.draw(native) }
         page.elements.forEach { e -> when (e) {
             is ReaderElement.Text -> {
                 val paint = paints.getValue(e.style)
-                paint.color = page.resolvedColorArgb(e, readAloud.toArgb())
+                paint.color = if (previewing && activeSelection.contains(e, page.id.chapterIndex)) {
+                    selectionPreviewStyle.textColor ?: page.previewBaseTextColor(e)
+                } else page.resolvedColorArgb(e, readAloud.toArgb())
                 paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
                 native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
             }
@@ -2203,11 +2312,96 @@ private fun ReaderPageCanvas(
         }
         decorationDrawCache.contentRules.forEach { it.draw(native) }
         decorationDrawCache.styledUnderlines.forEach { it.draw(native) }
+        drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = false)
         decorationDrawCache.overlayRules.forEach { it.draw(native) }
         if (drawDecoration) drawPageDecoration(native, page, tipPaints, badgeImage)
     }
 }
 
+private fun ReaderPage.withoutSelectionDecorations(selection: ReaderSelection): ReaderPage = copy(
+    elements = elements.map { element ->
+        if (element is ReaderElement.Text && selection.contains(element, id.chapterIndex)) {
+            element.copy(style = element.style.copy(backgroundArgb = null, underline = null))
+        } else element
+    },
+)
+
+private fun ReaderPage.previewBaseTextColor(selected: ReaderElement.Text): Int =
+    elements.asSequence()
+        .filterIsInstance<ReaderElement.Text>()
+        .filter { it.markingId == null && it.emphasized == selected.emphasized }
+        .minByOrNull { kotlin.math.abs(it.chapterPosition - selected.chapterPosition) }
+        ?.style?.colorArgb
+        ?: selected.style.colorArgb
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawSelectionStylePreview(
+    style: TextProcessStyle?,
+    bounds: List<ReaderRect>,
+    beforeText: Boolean,
+) {
+    style ?: return
+    if (beforeText) {
+        style.bgColor?.let { color ->
+            bounds.forEach { rect ->
+                drawRect(Color(color), Offset(rect.left, rect.top), Size(rect.width, rect.height))
+            }
+        }
+        if (style.underlineMode == 7) {
+            val color = style.underlineColor ?: return
+            bounds.forEach { rect ->
+                drawRect(
+                    Color(color),
+                    Offset(rect.left, rect.top + rect.height * 0.5f),
+                    Size(rect.width, rect.height * 0.5f),
+                )
+            }
+        }
+        return
+    }
+    val color = style.underlineColor ?: return
+    val stroke = style.underlineWidth.dp.toPx().coerceAtLeast(1f)
+    bounds.forEach { rect ->
+        val y = when (style.underlineMode) {
+            6 -> rect.top + rect.height * 0.52f
+            else -> rect.bottom + style.underlineOffset.dp.toPx()
+        }
+        when (style.underlineMode) {
+            1, 6 -> drawLine(Color(color), Offset(rect.left, y), Offset(rect.right, y), stroke)
+            2 -> {
+                val on = 8.dp.toPx()
+                val off = 5.dp.toPx()
+                var x = rect.left
+                while (x < rect.right) {
+                    drawLine(
+                        Color(color),
+                        Offset(x, y),
+                        Offset((x + on).coerceAtMost(rect.right), y),
+                        stroke
+                    )
+                    x += on + off
+                }
+            }
+
+            3 -> {
+                val amplitude = 3.dp.toPx()
+                val length = 12.dp.toPx()
+                val path = Path().apply {
+                    moveTo(rect.left, y)
+                    var x = rect.left
+                    var up = true
+                    while (x < rect.right) {
+                        val next = (x + length).coerceAtMost(rect.right)
+                        quadraticTo((x + next) / 2f, y + if (up) -amplitude else amplitude, next, y)
+                        up = !up
+                        x = next
+                    }
+                }
+                drawPath(path, Color(color), style = Stroke(stroke))
+            }
+        }
+    }
+}
+
 @Composable
 fun ReaderBackgroundSurface(
     backgroundImage: Drawable?,

+ 145 - 41
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt

@@ -10,15 +10,33 @@ import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import kotlin.math.max
 
-private fun ReaderTextBackgroundImage.fitIntoLineGap(
-    lineHeightPx: Float,
-    lineSpacingMultiplier: Float,
+/**
+ * 按这一行上下真正能借到的留白,把九宫格**四边等比**收紧。
+ *
+ * [topBudgetPx] / [bottomBudgetPx] 由调用方按邻行情况给出:邻行没有高亮框时是**整个行距**
+ * (那段留白反正空着),邻行也有框时才各让**半个行距**——两个框正好相接、不重叠。
+ *
+ * 四边共用一个因子是这里的要点:旧 View `TextLine.drawNineSliceFrames` 只把
+ * `overflowScale = halfGap / max(上厚, 下厚)` 用在上下边,左右边永远按原图厚度画,于是行距
+ * 一紧就变成「左右两条宽竖边 + 上下两条发丝横线、四角被纵向抹平」的各向异性;而只做各向
+ * 同性的等比例收紧时,框又会整体被钉死在半个行距上(默认行距 1.2、行高 60px 只有 6px)。
+ * 放宽纵向预算 + 等比收紧,两个毛病一起解掉。
+ *
+ * 上下边为 0 的色带型图片纵向没有约束,左右按 图片 × scale 原样画;行距为 0 时上下边归零,
+ * [ReaderNineSliceLayout] 会跳过高度为 0 的上下两行,自然回落成旧 View 的「中心 + 左右两条边」。
+ */
+private fun ReaderTextBackgroundImage.fitIntoLineBudget(
+    topBudgetPx: Float,
+    bottomBudgetPx: Float,
 ): ReaderTextBackgroundImage {
-    val largestFixedRow = maxOf(contentInsetTopPx, contentInsetBottomPx)
-    if (largestFixedRow <= 0f) return this
-    val halfGap = ((lineSpacingMultiplier - 1f).coerceAtLeast(0f) * lineHeightPx) / 2f
-    val frameScale = (halfGap / largestFixedRow).coerceIn(0f, 1f)
+    if (maxOf(contentInsetTopPx, contentInsetBottomPx) <= 0f) return this
+    val frameScale = minOf(
+        1f,
+        if (contentInsetTopPx > 0f) topBudgetPx / contentInsetTopPx else 1f,
+        if (contentInsetBottomPx > 0f) bottomBudgetPx / contentInsetBottomPx else 1f,
+    ).coerceIn(0f, 1f)
     if (frameScale >= 1f) return this
+    if (frameScale <= 0f) return copy(contentInsetTopPx = 0f, contentInsetBottomPx = 0f)
     return copy(
         contentInsetLeftPx = contentInsetLeftPx * frameScale,
         contentInsetRightPx = contentInsetRightPx * frameScale,
@@ -64,6 +82,13 @@ data class ReaderPaginationConfig(
     val columnCount: Int = 1,
     val lineSpacingMultiplier: Float = 1f,
     val continuousScroll: Boolean = false,
+    /**
+     * 单图样式(旧 `Book.imgStyleSingle`,`TextChapterLayout.isSingleImageStyle`)。旧实现
+     * `setTypeText` 对**每一行文本**都做 `if (textPage.height < visibleHeight) textPage.height =
+     * visibleHeight`,因此该样式下含文本的页在滚动堆叠时恒为一屏;纯图片页不受影响
+     * (`setTypeImage` 只把 `durY` 移到竖直居中位置)。见 [ReaderPaginator.paginateBlocks]。
+     */
+    val singleImageStyle: Boolean = false,
     /**
      * 章末页在堆叠高度上额外留出的空档。旧 `TextChapterLayout.setTypeText` 收尾时统一
      * `height = max(height, durY + 20.dpToPx())`,让下一章正文与本章末尾之间不会贴在一起。
@@ -213,6 +238,13 @@ object ReaderPaginator {
         var columnElementStart = 0
         var columnRows = mutableListOf<ReaderLayoutRow>()
 
+        /**
+         * 上一个视觉行是否画了九宫格框。九宫格上下边要借用邻行的留白:邻行没有框时可以
+         * 用满整个行距,邻行也有框时只能各让半个。空行 / 图片 / 分隔线 / 分页本身就带空隙,
+         * 在这些位置复位。
+         */
+        var previousLineHadFrame = false
+
         fun columnLeft() = config.paddingLeftPx + columnIndex * config.columnStridePx
         fun columnHasContent() = elements.size > columnElementStart
 
@@ -301,6 +333,8 @@ object ReaderPaginator {
 
         fun advanceColumn() {
             if (!columnHasContent()) return
+            // 换栏/换页后上一行已经不在同一屏,页边距本身就是空隙。
+            previousLineHadFrame = false
             if (columnIndex + 1 < config.columnCount) {
                 justifyColumnBottom()
                 columnIndex++
@@ -378,6 +412,8 @@ object ReaderPaginator {
                 columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + lineHeight)
                 y += lineHeight * (paragraph.lineSpacingMultiplier ?: config.lineSpacingMultiplier)
             }
+            // 这条路径(整段共用一个 style)不参与九宫格预算,但下一行仍要知道本段带框。
+            previousLineHadFrame = paragraph.style.backgroundImage?.fit == 3
             if (appendSeparator) {
                 pageText.append('\n')
                 y += if (paragraph.isTitle) config.titleParagraphSpacingPx ?: config.paragraphSpacingPx
@@ -386,6 +422,7 @@ object ReaderPaginator {
         }
 
         fun addImage(image: ReaderMeasuredBlock.Image) {
+            previousLineHadFrame = false
             if (image.pageBreakBefore) advanceColumn()
             val sourceWidth = image.intrinsicWidthPx.coerceAtLeast(1f)
             val sourceHeight = image.intrinsicHeightPx.coerceAtLeast(1f)
@@ -447,26 +484,44 @@ object ReaderPaginator {
                     is ReaderMeasuredInlineItem.Image -> "\uFFFC"
                 }
             }
-            fun backgroundImage(index: Int) =
+            val lineGapPx =
+                (paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * paragraph.lineHeightPx
+            val halfLineGapPx = lineGapPx / 2f
+
+            fun itemFrame(index: Int) =
                 (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
                     ?.style?.backgroundImage?.takeIf { it.fit == 3 }
-                    ?.fitIntoLineGap(
-                        lineHeightPx = paragraph.lineHeightPx,
-                        lineSpacingMultiplier = paragraph.lineSpacingMultiplier,
-                    )
 
-            fun backgroundInsetBefore(index: Int, lineStart: Int): Float {
-                val image = backgroundImage(index) ?: return 0f
-                return if (index == lineStart || backgroundImage(index - 1) != image) {
-                    image.contentInsetLeftPx
-                } else 0f
+            fun lineHasFrame(from: Int, until: Int): Boolean =
+                (from until until.coerceAtMost(paragraph.items.size)).any { itemFrame(it) != null }
+
+            fun frameOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
+                itemFrame(index)?.fitIntoLineBudget(topBudgetPx, bottomBudgetPx)
+
+            fun backgroundInsetBefore(
+                index: Int,
+                lineStart: Int,
+                topBudgetPx: Float,
+                bottomBudgetPx: Float,
+            ): Float {
+                val image = frameOf(index, topBudgetPx, bottomBudgetPx) ?: return 0f
+                return if (
+                    index == lineStart ||
+                    frameOf(index - 1, topBudgetPx, bottomBudgetPx) != image
+                ) image.contentInsetLeftPx else 0f
             }
 
-            fun backgroundInsetAfter(index: Int, lineEnd: Int): Float {
-                val image = backgroundImage(index) ?: return 0f
-                return if (index + 1 == lineEnd || backgroundImage(index + 1) != image) {
-                    image.contentInsetRightPx
-                } else 0f
+            fun backgroundInsetAfter(
+                index: Int,
+                lineEnd: Int,
+                topBudgetPx: Float,
+                bottomBudgetPx: Float,
+            ): Float {
+                val image = frameOf(index, topBudgetPx, bottomBudgetPx) ?: return 0f
+                return if (
+                    index + 1 == lineEnd ||
+                    frameOf(index + 1, topBudgetPx, bottomBudgetPx) != image
+                ) image.contentInsetRightPx else 0f
             }
             val breaker = ChineseLineBreaker(
                 clusters = clusters,
@@ -481,6 +536,10 @@ object ReaderPaginator {
             )
             val originalEnds = breaker.lineClusterStarts.drop(1)
             val starts = mutableListOf(0)
+            // 每行的纵向预算在断行阶段就定死,绘制阶段直接复用——排版预留与绘制的外框必须
+            // 用同一份 inset,否则框会压到相邻文字上或者留出多余的空档。
+            val lineTopBudgets = mutableListOf<Float>()
+            val lineBottomBudgets = mutableListOf<Float>()
             // The View reader reserves the left/right pieces around every visual-line run.
             // Refine the shaped line ends so those pieces cannot overlap adjacent text or
             // escape the column. Keep a forbidden Chinese break intact even if its frame has
@@ -490,17 +549,30 @@ object ReaderPaginator {
                 val lineIndent =
                     if (starts.size == 1) indentWidth else paragraph.restLineIndentWidthPx
                 val available = config.contentWidthPx - lineIndent
+                val topBudgetPx = if (previousLineHadFrame) halfLineGapPx else lineGapPx
+                // 下一行的范围在断行阶段还没最终定下来(本行可能被九宫格宽度挤短,把尾巴
+                // 让给下一行),用原始断行结果近似探测那一行有没有框。
+                val breakerEnd = originalEnds.getOrElse(starts.lastIndex) { paragraph.items.size }
+                    .coerceAtLeast(from + 1)
+                val nextBreakerEnd =
+                    originalEnds.getOrElse(starts.lastIndex + 1) { paragraph.items.size }
+                val bottomBudgetPx =
+                    if (lineHasFrame(breakerEnd, nextBreakerEnd)) halfLineGapPx else lineGapPx
                 // Advance the original visual-line cursor even when a frame forces the
                 // preceding row shorter. Reusing the first end after `from` would strand the
                 // remainder of that row as an unnecessary one-character line.
-                var until = originalEnds.getOrElse(starts.lastIndex) { paragraph.items.size }
-                    .coerceAtLeast(from + 1)
+                var until = breakerEnd
 
                 fun occupiedWidth(endExclusive: Int): Float =
                     (from until endExclusive).sumOf { index ->
                         (paragraph.items[index].widthPx +
-                                backgroundInsetBefore(index, from) +
-                                backgroundInsetAfter(index, endExclusive)).toDouble()
+                                backgroundInsetBefore(index, from, topBudgetPx, bottomBudgetPx) +
+                                backgroundInsetAfter(
+                                    index,
+                                    endExclusive,
+                                    topBudgetPx,
+                                    bottomBudgetPx
+                                )).toDouble()
                     }.toFloat() + letterSpacing * (endExclusive - from - 1).coerceAtLeast(0)
                 while (until - from > 1 && occupiedWidth(until) > available) {
                     val candidate = until - 1
@@ -512,6 +584,9 @@ object ReaderPaginator {
                     until = candidate
                 }
                 starts += until
+                lineTopBudgets += topBudgetPx
+                lineBottomBudgets += bottomBudgetPx
+                previousLineHadFrame = lineHasFrame(from, until)
             }
             for (lineIndex in 0 until starts.lastIndex) {
                 val from = starts[lineIndex]
@@ -549,8 +624,13 @@ object ReaderPaginator {
                 if (y + actualLineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
                 val indent = if (lineIndex == 0) indentWidth else paragraph.restLineIndentWidthPx
                 val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
-                fun backgroundInsetBefore(index: Int) = backgroundInsetBefore(from + index, from)
-                fun backgroundInsetAfter(index: Int) = backgroundInsetAfter(from + index, until)
+                val topBudgetPx = lineTopBudgets[lineIndex]
+                val bottomBudgetPx = lineBottomBudgets[lineIndex]
+                fun backgroundInsetBefore(index: Int) =
+                    backgroundInsetBefore(from + index, from, topBudgetPx, bottomBudgetPx)
+
+                fun backgroundInsetAfter(index: Int) =
+                    backgroundInsetAfter(from + index, until, topBudgetPx, bottomBudgetPx)
                 val naturalWidth = lineItems.sumOf { it.widthPx.toDouble() }.toFloat() +
                         letterSpacing * (lineItems.size - 1).coerceAtLeast(0) +
                         lineItems.indices.sumOf {
@@ -612,7 +692,7 @@ object ReaderPaginator {
                 val underlineElementStart = elements.size + indentItems
                 lineItems.forEachIndexed { itemIndex, item ->
                     x += backgroundInsetBefore(itemIndex)
-                    val itemBackground = backgroundImage(from + itemIndex)
+                    val itemBackground = frameOf(from + itemIndex, topBudgetPx, bottomBudgetPx)
                     when (item) {
                         is ReaderMeasuredInlineItem.Text -> {
                             val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
@@ -635,7 +715,12 @@ object ReaderPaginator {
                                 // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续
                                 continuesBackgroundRun = itemBackground != null &&
                                         itemIndex > 0 &&
-                                        backgroundImage(from + itemIndex - 1) == itemBackground,
+                                        frameOf(
+                                            from + itemIndex - 1,
+                                            topBudgetPx,
+                                            bottomBudgetPx
+                                        ) ==
+                                        itemBackground,
                                 backgroundFrameTopPx = itemBackground?.contentInsetTopPx ?: 0f,
                                 backgroundFrameBottomPx = itemBackground?.contentInsetBottomPx
                                     ?: 0f,
@@ -658,6 +743,8 @@ object ReaderPaginator {
                 }
                 addPageUnderline(underlineElementStart, y + actualLineHeight)
                 columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + actualLineHeight)
+                // 按实际落笔顺序(含换页)记录,供下一段第一行判断上方有没有框。
+                previousLineHadFrame = lineHasFrame(from, until)
                 y += actualLineHeight * paragraph.lineSpacingMultiplier
             }
             if (appendSeparator) {
@@ -668,6 +755,7 @@ object ReaderPaginator {
         }
 
         fun addRule(rule: ReaderMeasuredBlock.Rule) {
+            previousLineHadFrame = false
             val requiredHeight = rule.verticalPaddingPx * 2f + rule.widthPx
             if (y + requiredHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
             val lineY = y + rule.verticalPaddingPx
@@ -688,6 +776,7 @@ object ReaderPaginator {
             appendSeparator: Boolean,
         ) {
             val height = blank.lineHeightPx
+            previousLineHadFrame = false
             if (y + height > config.contentBottomPx && columnHasContent()) advanceColumn()
             val rowElementStart = elements.size
             elements += ReaderElement.Spacer(
@@ -717,7 +806,10 @@ object ReaderPaginator {
                 is ReaderMeasuredBlock.InlineParagraph -> addInlineParagraph(block, index, index < blocks.lastIndex)
                 is ReaderMeasuredBlock.BlankLine -> addBlankLine(block, index, index < blocks.lastIndex)
                 is ReaderMeasuredBlock.Rule -> addRule(block)
-                ReaderMeasuredBlock.PageBreak -> advanceColumn()
+                ReaderMeasuredBlock.PageBreak -> {
+                    previousLineHadFrame = false
+                    advanceColumn()
+                }
             }
             if (isTitle) {
                 y += if (blocks.getOrNull(index + 1)?.isTitle() == true) {
@@ -745,17 +837,29 @@ object ReaderPaginator {
             }
         }
         return pages.mapIndexed { pageIndex, pageElements ->
-            // 连续滚动模式按 scrollExtentPx 堆叠相邻页。中间页沿用排版游标(对照旧 View 逐页
-            // 堆叠 durY,ReaderLineSpacingTest 明确要求「不是一整屏」);章末页取「内容覆盖
-            // 高度」与「内容区高度下限」的较大者——旧 View 的章末/提示页同样按可见高度收口
-            // (`TextPage.kt` 的 `height = ChapterProvider.visibleHeight`),否则残页与卷名页
-            // 会让下一章正文压在本页内容上,滚动也会立刻跨页。
-            // 章末页再留出 [chapterEndPaddingPx],对照旧 View 的 `height = durY + 20dp`。
+            // 连续滚动模式按 scrollExtentPx 堆叠相邻页,页高一律是排版游标:对照旧
+            // `ContentTextView.drawPage`(下一页画在 `相对偏移 + textPage.height`)与
+            // `TextChapterLayout`(正文页 `textPage.height = durY`)。章末页只额外加
+            // [chapterEndPaddingPx](旧 `height = durY + 20dp`),**不**向内容区高度收口——
+            // 收口会让残页后面的空白顶满一屏,必须滚过整屏空白才接上下一章正文。
+            // 只有提示页才按可见高度收口(旧 `TextPage.format` 的 `isMsgPage`),那条路径
+            // 在 `ReadBookController` 的占位页上,不经过这里。
+            // 卷名/空正文章整页标题的垂直居中位移必须计入覆盖高度([centeredPageShiftPx]),
+            // 否则下一章正文会压在本页卷名上。
             val contentCoveredExtentPx = pageExtents[pageIndex] + centeredPageShiftPx
-            val stackedExtentPx = if (config.continuousScroll && pageIndex == pages.lastIndex) {
-                maxOf(contentCoveredExtentPx, config.contentBottomPx - config.paddingTopPx) +
-                        config.chapterEndPaddingPx
-            } else pageExtents[pageIndex]
+            // 单图样式的正文页恒为一屏:旧 `setTypeText` 逐行收口到 `visibleHeight`,
+            // 保证一屏一页;纯图片页沿用排版游标(`setTypeImage` 只把 durY 移到竖直居中位置,
+            // 不做收口),所以按「本页是否含文本/分隔等内容」区分,而不是整章一律收口。
+            val isFullViewportPage = config.singleImageStyle &&
+                    pageElements.any { it !is ReaderElement.Image }
+            val pageExtentPx = if (isFullViewportPage) {
+                maxOf(contentCoveredExtentPx, config.contentBottomPx - config.paddingTopPx)
+            } else contentCoveredExtentPx
+            val stackedExtentPx = when {
+                !config.continuousScroll -> pageExtents[pageIndex]
+                pageIndex == pages.lastIndex -> pageExtentPx + config.chapterEndPaddingPx
+                else -> pageExtentPx
+            }
             ReaderPage(
                 id = ReaderPageId(config.chapterIndex, pageIndex),
                 chapterTitle = config.chapterTitle,

+ 4 - 0
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayout.kt

@@ -38,6 +38,10 @@ object ReaderNineSliceLayout {
             sourceBottom,
         )
         if (sx[1] > sx[2] || sy[1] > sy[2]) return emptyList()
+        // 中心格落在文字框上,八个边框格落在外扩出来的 `frame` 上(对照旧 View
+        // `drawNineSliceCenter` 的中心 + `drawNineSliceFrames` 画在行框外的上下边/行框两侧的
+        // 左右边)。退化情形不需要特判:上下边被「半行距」压成 0 时,上下两行的目标高度为 0,
+        // 下面的循环直接跳过,只剩「中心 + 左右两条边」——正是旧 View 在无行距时的画法。
         val dx = floatArrayOf(frame.left, content.left, content.right, frame.right)
         val dy = floatArrayOf(frame.top, content.top, content.bottom, frame.bottom)
         return buildList(9) {

+ 1 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -220,6 +220,7 @@ object LegacyReaderChapterPaginator {
                 baselineOffsetPx = paginationStyle.bodyBaselineOffsetPx,
                 lineSpacingMultiplier = paginationStyle.lineSpacingExtra,
                 continuousScroll = paginationStyle.isScroll,
+                singleImageStyle = singleImage,
                 chapterEndPaddingPx = CHAPTER_END_PADDING_DP.dpToPx(),
                 inlineImagesPreserveScrollLine = imageLayoutMode == ReaderImageLayoutMode.INLINE,
                 textBottomJustify = paginationStyle.textBottomJustify,

+ 30 - 1
app/src/main/java/io/legado/app/feature/reader/platform/ReaderPageDecorationDrawCache.kt

@@ -15,6 +15,7 @@ import io.legado.app.feature.reader.core.model.underlineRuns
 /** Immutable Android draw data prepared once for a page snapshot revision. */
 internal data class ReaderPageDecorationDrawCache(
     val contentRules: List<ReaderRuleDrawCommand>,
+    val halfHighlights: List<ReaderHalfHighlightDrawCommand>,
     val styledUnderlines: List<ReaderUnderlineDrawCommand>,
     val overlayRules: List<ReaderRuleDrawCommand>,
 ) {
@@ -23,7 +24,11 @@ internal data class ReaderPageDecorationDrawCache(
             contentRules = page.elements.filterIsInstance<ReaderElement.Rule>()
                 .filterNot(ReaderElement.Rule::overlayStyledUnderline)
                 .map(::ReaderRuleDrawCommand),
-            styledUnderlines = page.underlineRuns().map { run ->
+            halfHighlights = page.underlineRuns().filter { it.underline.mode == 7 }.map { run ->
+                ReaderHalfHighlightDrawCommand(run.bounds, run.underline.colorArgb)
+            },
+            styledUnderlines = page.underlineRuns().filterNot { it.underline.mode == 7 }
+                .map { run ->
                 ReaderUnderlineDrawCommand(run.bounds, run.underline)
             },
             overlayRules = page.elements.filterIsInstance<ReaderElement.Rule>()
@@ -33,6 +38,23 @@ internal data class ReaderPageDecorationDrawCache(
     }
 }
 
+internal class ReaderHalfHighlightDrawCommand(
+    private val bounds: ReaderRect,
+    colorArgb: Int,
+) {
+    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = colorArgb }
+
+    fun draw(canvas: Canvas) {
+        canvas.drawRect(
+            bounds.left,
+            bounds.top + bounds.height * 0.5f,
+            bounds.right,
+            bounds.bottom,
+            paint,
+        )
+    }
+}
+
 internal class ReaderRuleDrawCommand(private val rule: ReaderElement.Rule) {
     private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
         color = rule.colorArgb
@@ -83,6 +105,13 @@ internal class ReaderUnderlineDrawCommand(
                 canvas.drawPath(path, paint)
                 canvas.restore()
             }
+            6 -> canvas.drawLine(
+                start,
+                bounds.top + bounds.height * 0.52f,
+                end,
+                bounds.top + bounds.height * 0.52f,
+                paint
+            )
         }
     }
 

+ 5 - 2
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -19,6 +19,7 @@ import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.FileUtils
+import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.NetworkUtils
@@ -208,8 +209,10 @@ object BookHelp {
             book.getFolderName(),
             bookChapter.getFileName(),
         ).writeText(content)
-        if (book.isOnLineTxt && readGateway.currentSettings.tocCountWords) {
-            val wordCount = StringUtils.wordCountFormat(content.length)
+        if (book.isOnLineTxt) {
+            val wordCount = StringUtils.wordCountFormat(
+                HtmlFormatter.textForWordCount(content).length
+            )
             bookChapter.wordCount = wordCount
             appDb.bookChapterDao.update(bookChapter)
         }

+ 350 - 306
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -2,166 +2,178 @@ package io.legado.app.ui.book.bookmark
 
 import android.net.Uri
 import android.widget.Toast
+import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.tween
 import androidx.compose.animation.expandVertically
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.filled.UnfoldLess
-import androidx.compose.material.icons.filled.UnfoldMore
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.ListItemDefaults
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.onClick
-import androidx.compose.ui.semantics.role
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
-import io.legado.app.ui.widget.components.bookmark.BookmarkItem
 import io.legado.app.ui.widget.components.card.GlassCard
-import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcons
-import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
-import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
-import top.yukonga.miuix.kmp.theme.MiuixTheme
 
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
-    ExperimentalMaterial3ExpressiveApi::class
-)
+private const val BOOKMARK_TAB = 0
+private const val NOTE_TAB = 1
+private const val DETAIL_PAGE_COUNT = 2
+private const val DETAIL_TEXT_MAX_LINES = 3
+
+/**
+ * 书签页主体所处的状态,用于在「总览 / 详情」等状态切换时驱动过渡动画。
+ */
+private enum class BookmarkBodyState { Loading, EmptyLibrary, Overview, Detail }
+
 @Composable
 fun AllBookmarkRouteScreen(
     viewModel: AllBookmarkViewModel = koinViewModel(),
     onBack: () -> Unit,
 ) {
     val context = LocalContext.current
-    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    var pendingExportIsMd by remember { mutableStateOf(false) }
-    val exportLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocumentTree()
-    ) { uri: Uri? ->
-        uri?.let {
-            viewModel.onIntent(AllBookmarkIntent.Export(it, pendingExportIsMd))
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    var exportMarkdown by remember { mutableStateOf(false) }
+    var exportBook by remember { mutableStateOf<BookmarkBookKey?>(null) }
+    val exportLauncher =
+        rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
+            val book = exportBook
+            if (uri != null && book != null) {
+                viewModel.onIntent(AllBookmarkIntent.Export(uri, exportMarkdown, book))
             Toast.makeText(context, context.getString(R.string.export_started), Toast.LENGTH_SHORT).show()
         }
     }
-
     LaunchedEffect(viewModel) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
-                is AllBookmarkEffect.ShowMessage ->
-                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+                is AllBookmarkEffect.ShowMessage -> Toast.makeText(
+                    context,
+                    effect.message,
+                    Toast.LENGTH_SHORT
+                ).show()
             }
         }
     }
-
     AllBookmarkScreen(
-        state = uiState,
+        state = state,
         onIntent = viewModel::onIntent,
-        onRequestExport = { isMarkdown ->
-            pendingExportIsMd = isMarkdown
+        onRequestExport = { book, markdown ->
+            exportBook = book
+            exportMarkdown = markdown
             exportLauncher.launch(null)
         },
         onBack = onBack,
     )
 }
 
+@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun AllBookmarkScreen(
     state: BookmarkUiState,
     onIntent: (AllBookmarkIntent) -> Unit,
-    onRequestExport: (Boolean) -> Unit,
+    onRequestExport: (BookmarkBookKey, Boolean) -> Unit,
     onBack: () -> Unit,
 ) {
-
-    val contentState = when {
-        state.isLoading -> "LOADING"
-        state.bookmarks.isEmpty() -> "EMPTY"
-        else -> "CONTENT"
-    }
-    val searchText = state.searchQuery
-    val collapsedGroups = state.collapsedGroups
-    val bookmarksGrouped = state.bookmarks
-    val bookmarkGroups = remember(bookmarksGrouped) { bookmarksGrouped.entries.toList() }
-    val allKeys = bookmarksGrouped.keys
-    val isAllCollapsed =
-        allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
-    val listState = rememberLazyListState()
-    val scope = rememberCoroutineScope()
+    val selectedBook = state.selectedBook
+    var showSearch by remember(selectedBook?.key) { mutableStateOf(false) }
     var showMenu by remember { mutableStateOf(false) }
-    var showSearch by remember { mutableStateOf(false) }
-    var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
-    var showBottomSheet by remember { mutableStateOf(false) }
+    val overviewListState = rememberLazyListState()
+    val bookmarkListState = rememberLazyListState()
+    val noteListState = rememberLazyListState()
+    val scope = rememberCoroutineScope()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-    val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
-    val stickyGroup by remember(bookmarkGroups, collapsedGroups, listState) {
-        derivedStateOf {
-            val firstVisibleIndex = listState.firstVisibleItemIndex
-            val firstVisibleGroup = bookmarkGroups.getOrNull(firstVisibleIndex)
-                ?: return@derivedStateOf null
-            val isCollapsed = collapsedGroups.contains(firstVisibleGroup.key.toString())
-            val shouldStick = firstVisibleIndex > 0 || listState.firstVisibleItemScrollOffset > 24
-            if (!isCollapsed && shouldStick) {
-                firstVisibleGroup.key
-            } else {
-                null
-            }
-        }
+    val pagerState = rememberPagerState(pageCount = { DETAIL_PAGE_COUNT })
+    val selectedTab = pagerState.currentPage
+    val bookmarkItems = remember(state.detailItems) {
+        state.detailItems.filter { it is BookmarkDetailItemUi.SavedBookmark }
+    }
+    val noteItems = remember(state.detailItems) {
+        state.detailItems.filterIsInstance<BookmarkDetailItemUi.Marking>()
+    }
+    val activeListState = when {
+        selectedBook == null -> overviewListState
+        selectedTab == NOTE_TAB -> noteListState
+        else -> bookmarkListState
+    }
+    val bodyState = when {
+        state.isLoading -> BookmarkBodyState.Loading
+        selectedBook == null && state.books.isEmpty() -> BookmarkBodyState.EmptyLibrary
+        selectedBook == null -> BookmarkBodyState.Overview
+        else -> BookmarkBodyState.Detail
+    }
+
+    BackHandler(enabled = selectedBook != null) { onIntent(AllBookmarkIntent.CloseBook) }
+    // 换书时回到该书的初始页:带定位目标直接落在备注页,否则落在书签页
+    LaunchedEffect(selectedBook?.key) {
+        if (selectedBook == null) return@LaunchedEffect
+        val initialPage = if (state.targetMarkingId != null) NOTE_TAB else BOOKMARK_TAB
+        if (pagerState.currentPage != initialPage) pagerState.scrollToPage(initialPage)
+    }
+    // 横滑落位后清掉上一个 tab 留下的搜索词
+    LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.settledPage }
+            .drop(1)
+            .collect { onIntent(AllBookmarkIntent.SetSearchQuery("")) }
+    }
+    LaunchedEffect(state.targetMarkingId, state.detailItems) {
+        val target = state.targetMarkingId ?: return@LaunchedEffect
+        if (pagerState.currentPage != NOTE_TAB) pagerState.scrollToPage(NOTE_TAB)
+        val index = noteItems.indexOfFirst { it.marking.id == target }
+        if (index >= 0) noteListState.scrollToItem(index)
+        onIntent(AllBookmarkIntent.TargetConsumed)
     }
 
     AppScaffold(
@@ -169,279 +181,311 @@ fun AllBookmarkScreen(
         topBar = {
             Column {
                 GlassMediumFlexibleTopAppBar(
-                    title = stringResource(R.string.all_bookmark),
+                    title = selectedBook?.key?.name ?: stringResource(R.string.all_bookmark),
                     scrollBehavior = scrollBehavior,
                     navigationIcon = {
-                        TopBarNavigationButton(onClick = onBack)
+                        TopBarNavigationButton(
+                            onClick = {
+                                if (selectedBook == null) onBack() else onIntent(AllBookmarkIntent.CloseBook)
+                            }
+                        )
                     },
                     actions = {
-                        if (bookmarksGrouped.isNotEmpty()) {
-                            TopBarActionButton(
-                                onClick = { onIntent(AllBookmarkIntent.ToggleAllCollapse(allKeys)) },
-                                imageVector = if (isAllCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
-                                contentDescription = stringResource(
-                                    if (isAllCollapsed) {
-                                        R.string.a11y_expand_all_bookmark_groups
-                                    } else {
-                                        R.string.a11y_collapse_all_bookmark_groups
-                                    }
-                                )
-                            )
-                        }
                         TopBarActionButton(
                             onClick = {
                                 showSearch = !showSearch
-                                if (!showSearch) {
-                                    onIntent(AllBookmarkIntent.SetSearchQuery(""))
-                                }
+                                if (!showSearch) onIntent(AllBookmarkIntent.SetSearchQuery(""))
                             },
                             imageVector = Icons.Default.Search,
-                            contentDescription = stringResource(R.string.search)
-                        )
-                        TopBarActionButton(
-                            onClick = { showMenu = true },
-                            imageVector = AppIcons.MoreVert,
-                            contentDescription = stringResource(R.string.more_menu)
+                            contentDescription = stringResource(R.string.search),
                         )
-                        RoundDropdownMenu(
-                            expanded = showMenu,
-                            onDismissRequest = { showMenu = false }
-                        ) {
-                            RoundDropdownMenuItem(
-                                text = stringResource(R.string.export_bookmarks_json),
-                                onClick = {
-                                    showMenu = false
-                                    onRequestExport(false)
-                                }
-                            )
-                            RoundDropdownMenuItem(
-                                text = stringResource(R.string.export_bookmarks_markdown),
-                                onClick = {
-                                    showMenu = false
-                                    onRequestExport(true)
-                                }
+                        if (selectedBook != null) {
+                            TopBarActionButton(
+                                onClick = { showMenu = true },
+                                imageVector = AppIcons.MoreVert,
+                                contentDescription = stringResource(R.string.more_menu),
                             )
+                            RoundDropdownMenu(
+                                expanded = showMenu,
+                                onDismissRequest = { showMenu = false }) {
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.export_bookmarks_json),
+                                    onClick = {
+                                        showMenu = false
+                                        onRequestExport(selectedBook.key, false)
+                                    },
+                                )
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.export_bookmarks_markdown),
+                                    onClick = {
+                                        showMenu = false
+                                        onRequestExport(selectedBook.key, true)
+                                    },
+                                )
+                            }
                         }
-                    }
+                    },
                 )
-
                 AnimatedVisibility(
                     modifier = Modifier.adaptiveHorizontalPadding(),
                     visible = showSearch,
                     enter = expandVertically() + fadeIn(),
-                    exit = shrinkVertically() + fadeOut()
+                    exit = shrinkVertically() + fadeOut(),
                 ) {
                     SearchBar(
-                        query = searchText,
+                        query = state.searchQuery,
                         onQueryChange = { onIntent(AllBookmarkIntent.SetSearchQuery(it)) },
-                        placeholder = stringResource(R.string.search),
-                        scrollState = listState,
-                        scope = scope
+                        placeholder = if (selectedBook == null) {
+                            stringResource(R.string.feature_bookmarks_search_notes)
+                        } else {
+                            stringResource(
+                                R.string.feature_bookmarks_search_current,
+                                stringResource(if (selectedTab == BOOKMARK_TAB) R.string.bookmark else R.string.bookmark_mark_note),
+                            )
+                        },
+                        scrollState = activeListState,
+                        scope = scope,
+                    )
+                }
+                if (selectedBook != null) {
+                    AppTabRow(
+                        tabTitles = listOf(
+                            "${stringResource(R.string.bookmark)} ${selectedBook.bookmarkCount}",
+                            "${stringResource(R.string.bookmark_mark_note)} ${selectedBook.markingCount}",
+                        ),
+                        selectedTabIndex = selectedTab,
+                        onTabSelected = { index ->
+                            scope.launch { pagerState.animateScrollToPage(index) }
+                            onIntent(AllBookmarkIntent.SetSearchQuery(""))
+                        },
+                        modifier = Modifier.fillMaxWidth(),
+                        isScrollable = false,
                     )
                 }
             }
-        }
+        },
     ) { paddingValues ->
-        Column(
-            modifier = Modifier
-                .fillMaxSize()
-        ) {
-            AnimatedContent(
-                targetState = contentState,
-                label = "bookmarkTransition"
-            ) { state ->
-                when (state) {
-                    "LOADING" -> {
-                        EmptyMessage(
-                            message = stringResource(R.string.loading),
-                            isLoading = true,
-                            modifier = Modifier
-                                .fillMaxSize()
-                                .padding(
-                                    top = paddingValues.calculateTopPadding(),
-                                    bottom = 120.dp
-                                )
+        val topPadding = paddingValues.calculateTopPadding()
+        AnimatedContent(
+            targetState = bodyState,
+            transitionSpec = { fadeIn() togetherWith fadeOut() },
+            label = "BookmarkBodyTransition",
+            modifier = Modifier.fillMaxSize(),
+        ) { body ->
+            when (body) {
+                BookmarkBodyState.Loading -> EmptyMessage(
+                    message = stringResource(R.string.loading),
+                    isLoading = true,
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .padding(top = topPadding),
+                )
+
+                BookmarkBodyState.EmptyLibrary -> EmptyMessage(
+                    message = if (state.searchQuery.isBlank()) stringResource(R.string.no_bookmark)
+                    else stringResource(R.string.feature_bookmarks_no_matching_notes),
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .padding(top = topPadding),
+                )
+
+                BookmarkBodyState.Overview -> BookOverview(
+                    books = state.books,
+                    listState = overviewListState,
+                    topPadding = topPadding,
+                    onBookClick = {
+                        onIntent(
+                            AllBookmarkIntent.OpenBook(
+                                it.key,
+                                it.matchedMarkingId
+                            )
                         )
-                    }
+                    },
+                )
 
-                    "EMPTY" -> {
+                BookmarkBodyState.Detail -> HorizontalPager(
+                    state = pagerState,
+                    modifier = Modifier.fillMaxSize(),
+                ) { tab ->
+                    val pageItems = if (tab == BOOKMARK_TAB) bookmarkItems else noteItems
+                    if (pageItems.isEmpty()) {
                         EmptyMessage(
-                            message = stringResource(R.string.no_bookmark),
+                            message = if (tab == BOOKMARK_TAB) stringResource(R.string.no_bookmark)
+                            else stringResource(R.string.feature_bookmarks_no_notes),
                             modifier = Modifier
                                 .fillMaxSize()
-                                .padding(
-                                    top = paddingValues.calculateTopPadding(),
-                                    bottom = 120.dp
-                                )
+                                .padding(top = topPadding),
+                        )
+                    } else {
+                        DetailList(
+                            items = pageItems,
+                            listState = if (tab == BOOKMARK_TAB) bookmarkListState else noteListState,
+                            topPadding = topPadding,
+                            onIntent = onIntent,
                         )
-                    }
-
-                    "CONTENT" -> {
-                        Box(
-                            modifier = Modifier.fillMaxSize()
-                        ) {
-                            FastScrollLazyColumn(
-                                state = listState,
-                                modifier = Modifier.fillMaxSize(),
-                                contentPadding = adaptiveContentPadding(
-                                    top = paddingValues.calculateTopPadding(),
-                                    bottom = 120.dp
-                                )
-                            ) {
-                                items(
-                                    items = bookmarkGroups,
-                                    key = { it.key.toString() }
-                                ) { (headerKey, bookmarks) ->
-                                    val isCollapsed = collapsedGroups.contains(headerKey.toString())
-
-                                    GlassCard(
-                                        modifier = Modifier
-                                            .animateItem()
-                                            .fillMaxWidth()
-                                            .padding(vertical = 4.dp),
-                                        cornerRadius = 12.dp,
-                                        containerColor = LegadoTheme.colorScheme.surfaceContainer
-                                    ) {
-                                        BookmarkGroupHeaderContent(
-                                            title = headerKey.bookName,
-                                            subtitle = headerKey.bookAuthor,
-                                            isCollapsed = isCollapsed,
-                                            onToggle = {
-                                                onIntent(AllBookmarkIntent.ToggleGroupCollapse(headerKey))
-                                            },
-                                            isMiuix = isMiuix
-                                        )
-
-                                        AnimatedVisibility(
-                                            visible = !isCollapsed && bookmarks.isNotEmpty()
-                                        ) {
-                                            Column() {
-                                                HorizontalDivider(
-                                                    color = LegadoTheme.colorScheme.surface
-                                                )
-                                                bookmarks.forEach { bookmarkUi ->
-                                                    BookmarkItem(
-                                                        bookmark = bookmarkUi.rawBookmark,
-                                                        modifier = Modifier.fillMaxWidth(),
-                                                        isDur = false,
-                                                        onClick = {
-                                                            editingBookmark = bookmarkUi.rawBookmark
-                                                            showBottomSheet = true
-                                                        },
-                                                        onLongClick = {
-                                                            editingBookmark = bookmarkUi.rawBookmark
-                                                            showBottomSheet = true
-                                                        }
-                                                    )
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            }
-
-                            TopFloatingStickyItem(
-                                item = stickyGroup,
-                                modifier = Modifier
-                                    .padding(
-                                        top = paddingValues.calculateTopPadding() + 4.dp,
-                                        start = 8.dp
-                                    )
-                            ) { group ->
-                                TextCard(
-                                    text = group.bookName,
-                                    textStyle = LegadoTheme.typography.labelLarge,
-                                    cornerRadius = 8.dp,
-                                    horizontalPadding = 8.dp,
-                                    verticalPadding = 6.dp,
-                                    onClick = {
-                                        scope.launch {
-                                            val index =
-                                                bookmarkGroups.indexOfFirst { it.key == group }
-                                            if (index >= 0) {
-                                                listState.animateScrollToItem(index)
-                                            }
-                                        }
-                                    }
-                                )
-                            }
-                        }
                     }
                 }
             }
         }
+    }
+}
 
-        BookmarkEditSheet(
-            show = showBottomSheet && editingBookmark != null,
-            bookmark = editingBookmark ?: Bookmark(),
-            onDismiss = {
-                showBottomSheet = false
-                editingBookmark = null
-            },
-            onSave = { updatedBookmark ->
-                onIntent(AllBookmarkIntent.UpdateBookmark(updatedBookmark))
-                showBottomSheet = false
-            },
-            onDelete = { bookmarkToDelete ->
-                onIntent(AllBookmarkIntent.DeleteBookmark(bookmarkToDelete))
-                showBottomSheet = false
+@Composable
+private fun BookOverview(
+    books: List<BookmarkBookUi>,
+    listState: androidx.compose.foundation.lazy.LazyListState,
+    topPadding: androidx.compose.ui.unit.Dp,
+    onBookClick: (BookmarkBookUi) -> Unit,
+) {
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = adaptiveContentPadding(top = topPadding, bottom = 120.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        items(books, key = { "${it.key.name}|${it.key.author}" }) { book ->
+            GlassCard(
+                modifier = Modifier.fillMaxWidth(),
+                cornerRadius = 16.dp,
+                containerColor = LegadoTheme.colorScheme.surfaceContainer,
+                onClick = { onBookClick(book) },
+            ) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(12.dp),
+                    horizontalArrangement = Arrangement.spacedBy(16.dp),
+                    verticalAlignment = Alignment.CenterVertically,
+                ) {
+                    Column(modifier = Modifier.weight(1f)) {
+                        AppText(
+                            text = book.key.name,
+                            style = LegadoTheme.typography.titleMediumEmphasized,
+                            maxLines = 2,
+                            overflow = TextOverflow.Ellipsis,
+                        )
+                        if (book.key.author.isNotBlank()) AppText(
+                            text = book.key.author,
+                            style = LegadoTheme.typography.labelMediumEmphasized,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                        AppText(
+                            text = stringResource(
+                                R.string.feature_bookmarks_counts,
+                                book.bookmarkCount,
+                                book.markingCount,
+                            ),
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                        if (book.matchedMarkingId != null) AppText(
+                            text = stringResource(R.string.feature_bookmarks_match_hint),
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.primary,
+                        )
+                    }
+                    CoilBookCover(
+                        name = book.key.name,
+                        author = book.key.author,
+                        path = book.coverPath,
+                        sourceOrigin = book.sourceOrigin,
+                        modifier = Modifier.width(52.dp),
+                    )
+                }
             }
-        )
+        }
     }
 }
 
+@OptIn(ExperimentalFoundationApi::class)
 @Composable
-private fun BookmarkGroupHeaderContent(
-    modifier: Modifier = Modifier,
-    title: String,
-    subtitle: String?,
-    isCollapsed: Boolean,
-    onToggle: () -> Unit,
-    isMiuix: Boolean
+private fun DetailList(
+    items: List<BookmarkDetailItemUi>,
+    listState: androidx.compose.foundation.lazy.LazyListState,
+    topPadding: androidx.compose.ui.unit.Dp,
+    onIntent: (AllBookmarkIntent) -> Unit,
 ) {
+    var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = adaptiveContentPadding(top = topPadding, bottom = 120.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        items(items, key = { it.stableId }, contentType = { it::class }) { item ->
+            GlassCard(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .animateItem(),
+                cornerRadius = 14.dp,
+                containerColor = LegadoTheme.colorScheme.surfaceContainer,
+                onClick = {
+                    if (item is BookmarkDetailItemUi.SavedBookmark) editingBookmark = item.bookmark
+                },
+                onLongClick = {
+                    if (item is BookmarkDetailItemUi.SavedBookmark) editingBookmark = item.bookmark
+                }
+            ) {
+                Column(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(16.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    AppText(
+                        text = item.chapterName.ifBlank {
+                            stringResource(
+                                R.string.feature_bookmarks_chapter_fallback,
+                                item.chapterIndex + 1
+                            )
+                        },
+                        style = LegadoTheme.typography.labelLarge,
+                        color = LegadoTheme.colorScheme.primary,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                    when (item) {
+                        is BookmarkDetailItemUi.SavedBookmark -> {
+                            AppText(
+                                text = item.bookmark.bookText,
+                                style = LegadoTheme.typography.labelMedium,
+                                maxLines = DETAIL_TEXT_MAX_LINES,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                            if (item.bookmark.content.isNotBlank()) AppText(
+                                text = item.bookmark.content,
+                                style = LegadoTheme.typography.labelMedium,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                maxLines = DETAIL_TEXT_MAX_LINES,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                        }
 
-    val contentColor by animateColorAsState(
-        if (isMiuix) MiuixTheme.colorScheme.primary else MaterialTheme.colorScheme.primary,
-        animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
-        label = "CardColor"
-    )
-    val headerDescription = listOfNotNull(title, subtitle).joinToString()
-    val headerStateDescription = stringResource(
-        if (isCollapsed) R.string.a11y_collapsed else R.string.a11y_expanded
-    )
-    val clickLabel = stringResource(
-        if (isCollapsed) R.string.expand else R.string.collapse
-    )
-
-    ListItem(
-        modifier = modifier
-            .fillMaxWidth()
-            .semantics {
-                role = Role.Button
-                contentDescription = headerDescription
-                stateDescription = headerStateDescription
-                onClick(label = clickLabel, action = null)
-            }
-            .combinedClickable(onClick = onToggle),
-        colors = ListItemDefaults.colors(
-            containerColor = Color.Transparent,
-            supportingColor = LegadoTheme.colorScheme.onSurfaceVariant,
-            trailingIconColor = LegadoTheme.colorScheme.onSurfaceVariant
-        ),
-        supportingContent = {
-            subtitle?.let {
-                AppText(
-                    text = it,
-                    style = LegadoTheme.typography.labelMedium,
-                    color = LegadoTheme.colorScheme.onSurfaceVariant
-                )
+                        is BookmarkDetailItemUi.Marking -> {
+                            AppText(
+                                text = item.selectedText,
+                                style = LegadoTheme.typography.labelMedium,
+                                maxLines = DETAIL_TEXT_MAX_LINES,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                            if (item.marking.note.isNotBlank()) AppText(
+                                text = item.marking.note,
+                                style = LegadoTheme.typography.labelMedium,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                maxLines = DETAIL_TEXT_MAX_LINES,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                        }
+                    }
+                }
             }
         }
-    ) {
-        AppText(
-            text = title,
-            style = LegadoTheme.typography.titleMedium,
-            color = contentColor
-        )
     }
+    BookmarkEditSheet(
+        show = editingBookmark != null,
+        bookmark = editingBookmark ?: Bookmark(),
+        onDismiss = { editingBookmark = null },
+        onSave = { onIntent(AllBookmarkIntent.UpdateBookmark(it)); editingBookmark = null },
+        onDelete = { onIntent(AllBookmarkIntent.DeleteBookmark(it)); editingBookmark = null },
+    )
 }

+ 218 - 142
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -5,220 +5,296 @@ import android.net.Uri
 import androidx.compose.runtime.Stable
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
+import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookmarkRepository
+import io.legado.app.domain.gateway.BookMarkingGateway
+import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.GSON
 import io.legado.app.utils.createFileIfNotExist
+import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.openOutputStream
 import io.legado.app.utils.writeToOutputStream
 import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.ImmutableMap
-import kotlinx.collections.immutable.ImmutableSet
-import kotlinx.collections.immutable.persistentMapOf
-import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
-import kotlinx.collections.immutable.toImmutableMap
-import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.mapLatest
 import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import java.io.OutputStream
 import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Locale
 
-data class BookmarkGroupHeader(
-    val bookName: String,
-    val bookAuthor: String
-) {
-    override fun toString(): String = "$bookName|$bookAuthor"
-}
+@Stable
+data class BookmarkBookKey(val name: String, val author: String)
 
 @Stable
-data class BookmarkItemUi(
-    val id: Long,
-    val content: String,
-    val chapterName: String,
-    val bookText: String,
-    val bookName: String,
-    val bookAuthor: String,
-    val rawBookmark: Bookmark
+data class BookmarkBookUi(
+    val key: BookmarkBookKey,
+    val coverPath: String? = null,
+    val sourceOrigin: String? = null,
+    val bookUrl: String? = null,
+    val bookmarkCount: Int,
+    val markingCount: Int,
+    val matchedMarkingId: String? = null,
 )
 
+sealed interface BookmarkDetailItemUi {
+    val stableId: String
+    val chapterIndex: Int
+    val chapterPosition: Int
+    val chapterName: String
+
+    @Stable
+    data class SavedBookmark(val bookmark: Bookmark) : BookmarkDetailItemUi {
+        override val stableId = "bookmark:${bookmark.time}"
+        override val chapterIndex = bookmark.chapterIndex
+        override val chapterPosition = bookmark.chapterPos
+        override val chapterName = bookmark.chapterName
+    }
+
+    @Stable
+    data class Marking(
+        val marking: BookMarking,
+        val selectedText: String,
+        override val chapterPosition: Int,
+    ) : BookmarkDetailItemUi {
+        override val stableId = "marking:${marking.id}"
+        override val chapterIndex = marking.chapterIndex ?: 0
+        override val chapterName = marking.chapterName
+    }
+}
+
 @Stable
 data class BookmarkUiState(
-    val isLoading: Boolean = false,
-    val bookmarks: ImmutableMap<BookmarkGroupHeader, ImmutableList<BookmarkItemUi>> = persistentMapOf(),
-    val error: Throwable? = null,
+    val isLoading: Boolean = true,
+    val books: ImmutableList<BookmarkBookUi> = persistentListOf(),
+    val selectedBook: BookmarkBookUi? = null,
+    val detailItems: ImmutableList<BookmarkDetailItemUi> = persistentListOf(),
     val searchQuery: String = "",
-    val collapsedGroups: ImmutableSet<String> = persistentSetOf()
+    val targetMarkingId: String? = null,
+    val error: Throwable? = null,
 )
 
 sealed interface AllBookmarkIntent {
     data class SetSearchQuery(val query: String) : AllBookmarkIntent
-    data class ToggleGroupCollapse(val group: BookmarkGroupHeader) : AllBookmarkIntent
-    data class ToggleAllCollapse(val groups: Set<BookmarkGroupHeader>) : AllBookmarkIntent
+    data class OpenBook(val key: BookmarkBookKey, val targetMarkingId: String? = null) :
+        AllBookmarkIntent
+
+    data object CloseBook : AllBookmarkIntent
+    data object TargetConsumed : AllBookmarkIntent
     data class UpdateBookmark(val bookmark: Bookmark) : AllBookmarkIntent
     data class DeleteBookmark(val bookmark: Bookmark) : AllBookmarkIntent
-    data class Export(val treeUri: Uri, val isMarkdown: Boolean) : AllBookmarkIntent
+    data class Export(val treeUri: Uri, val isMarkdown: Boolean, val book: BookmarkBookKey) :
+        AllBookmarkIntent
 }
 
 sealed interface AllBookmarkEffect {
     data class ShowMessage(val message: String) : AllBookmarkEffect
 }
 
-
 class AllBookmarkViewModel(
     application: Application,
     private val bookmarkRepository: BookmarkRepository,
+    private val markingGateway: BookMarkingGateway,
+    private val bookRepository: BookRepository,
 ) : AndroidViewModel(application) {
 
-    private val _searchQuery = MutableStateFlow("")
-    private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
-    private val _effects = MutableSharedFlow<AllBookmarkEffect>(extraBufferCapacity = 16)
-    val effects = _effects.asSharedFlow()
+    private val searchQuery = MutableStateFlow("")
+    private val selectedBook = MutableStateFlow<BookmarkBookKey?>(null)
+    private val targetMarkingId = MutableStateFlow<String?>(null)
+    private val effectsFlow = MutableSharedFlow<AllBookmarkEffect>(extraBufferCapacity = 16)
+    val effects = effectsFlow.asSharedFlow()
+
+    private val library =
+        combine(bookmarkRepository.flowAll(), markingGateway.flowAll()) { bookmarks, markings ->
+            bookmarks to markings
+        }
 
     @OptIn(ExperimentalCoroutinesApi::class)
     val uiState: StateFlow<BookmarkUiState> = combine(
-        _searchQuery,
-        _collapsedGroups,
-        bookmarkRepository.flowAll()
-    ) { query, collapsed, allBookmarks ->
-
-        val filteredList = if (query.isBlank()) {
-            allBookmarks
-        } else {
-            allBookmarks.filter {
-                it.bookName.contains(query, ignoreCase = true) ||
-                        it.content.contains(query, ignoreCase = true) ||
-                        it.bookAuthor.contains(query, ignoreCase = true)
+        library, searchQuery, selectedBook, targetMarkingId,
+    ) { (bookmarks, markings), query, selectedKey, targetId ->
+        RawState(bookmarks, markings, query.trim(), selectedKey, targetId)
+    }.mapLatest(::buildUiState)
+        .catch { emit(BookmarkUiState(isLoading = false, error = it)) }
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), BookmarkUiState())
+
+    fun onIntent(intent: AllBookmarkIntent) {
+        when (intent) {
+            is AllBookmarkIntent.SetSearchQuery -> searchQuery.value = intent.query
+            is AllBookmarkIntent.OpenBook -> {
+                selectedBook.value = intent.key
+                targetMarkingId.value = intent.targetMarkingId
+                searchQuery.value = ""
             }
-        }
 
-        val grouped = filteredList.asSequence()
-            .map { bookmark ->
-                BookmarkItemUi(
-                    id = bookmark.time,
-                    content = bookmark.content,
-                    chapterName = bookmark.chapterName,
-                    bookText = bookmark.bookText,
-                    bookName = bookmark.bookName,
-                    bookAuthor = bookmark.bookAuthor,
-                    rawBookmark = bookmark
-                )
+            AllBookmarkIntent.CloseBook -> {
+                selectedBook.value = null
+                targetMarkingId.value = null
+                searchQuery.value = ""
             }
-            .groupBy { item ->
-                BookmarkGroupHeader(item.bookName, item.bookAuthor)
+
+            AllBookmarkIntent.TargetConsumed -> targetMarkingId.value = null
+            is AllBookmarkIntent.UpdateBookmark -> viewModelScope.launch(Dispatchers.IO) {
+                bookmarkRepository.save(intent.bookmark)
             }
-            .mapValues { (_, items) -> items.toImmutableList() }
-            .toImmutableMap()
 
-        BookmarkUiState(
-            isLoading = false,
-            bookmarks = grouped,
-            searchQuery = query,
-            collapsedGroups = collapsed.toImmutableSet()
-        )
-    }.catch { e ->
-        emit(BookmarkUiState(isLoading = false, error = e))
-    }.stateIn(
-        scope = viewModelScope,
-        started = SharingStarted.WhileSubscribed(5000),
-        initialValue = BookmarkUiState(isLoading = true)
-    )
+            is AllBookmarkIntent.DeleteBookmark -> viewModelScope.launch(Dispatchers.IO) {
+                bookmarkRepository.delete(intent.bookmark)
+            }
 
-    fun onIntent(intent: AllBookmarkIntent) {
-        when (intent) {
-            is AllBookmarkIntent.SetSearchQuery -> _searchQuery.value = intent.query
-            is AllBookmarkIntent.ToggleGroupCollapse -> toggleGroupCollapse(intent.group)
-            is AllBookmarkIntent.ToggleAllCollapse -> toggleAllCollapse(intent.groups)
-            is AllBookmarkIntent.UpdateBookmark -> updateBookmark(intent.bookmark)
-            is AllBookmarkIntent.DeleteBookmark -> deleteBookmark(intent.bookmark)
-            is AllBookmarkIntent.Export -> exportBookmark(intent.treeUri, intent.isMarkdown)
+            is AllBookmarkIntent.Export -> exportBook(
+                intent.treeUri,
+                intent.isMarkdown,
+                intent.book
+            )
         }
     }
 
-    fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
-        val stringKey = groupKey.toString()
-        _collapsedGroups.update { current ->
-            if (current.contains(stringKey)) current - stringKey else current + stringKey
-        }
-    }
+    private suspend fun buildUiState(raw: RawState): BookmarkUiState {
+        val keys = (raw.bookmarks.map { BookmarkBookKey(it.bookName, it.bookAuthor) } +
+                raw.markings.map { BookmarkBookKey(it.bookName, it.bookAuthor) }).distinct()
+        val books = keys.map { key ->
+            val bookMarkings = raw.markings.filter { it.matches(key) }
+            val matchedMarking = if (raw.selectedBook == null && raw.query.isNotBlank()) {
+                bookMarkings.firstOrNull { it.matchesNoteQuery(raw.query) }
+            } else null
+            val book = bookRepository.getBook(key.name, key.author)
+            BookmarkBookUi(
+                key = key,
+                coverPath = book?.getDisplayCover(),
+                sourceOrigin = book?.origin,
+                bookUrl = book?.bookUrl,
+                bookmarkCount = raw.bookmarks.count { it.matches(key) },
+                markingCount = bookMarkings.size,
+                matchedMarkingId = matchedMarking?.id,
+            )
+        }.filter { raw.selectedBook != null || raw.query.isBlank() || it.matchedMarkingId != null }
+            .sortedBy { it.key.name }
 
-    fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
-        val stringKeys = currentKeys.map { it.toString() }.toSet()
-        _collapsedGroups.update { current ->
-            if (current.containsAll(stringKeys) && stringKeys.isNotEmpty()) {
-                emptySet()
-            } else {
-                stringKeys
+        val selected = raw.selectedBook?.let { key ->
+            books.firstOrNull { it.key == key } ?: run {
+                val book = bookRepository.getBook(key.name, key.author)
+                BookmarkBookUi(
+                    key = key,
+                    coverPath = book?.getDisplayCover(),
+                    sourceOrigin = book?.origin,
+                    bookUrl = book?.bookUrl,
+                    bookmarkCount = raw.bookmarks.count { it.matches(key) },
+                    markingCount = raw.markings.count { it.matches(key) },
+                )
             }
         }
+        val details = selected?.key?.let { key ->
+            val allItems = raw.bookmarks.filter { it.matches(key) }
+                .map { BookmarkDetailItemUi.SavedBookmark(it) } +
+                    raw.markings.filter { it.matches(key) }.map { marking ->
+                        val anchor =
+                            GSON.fromJsonObject<TextProcessAnchor>(marking.anchorJson).getOrNull()
+                        BookmarkDetailItemUi.Marking(
+                            marking,
+                            anchor?.selectedText.orEmpty(),
+                            anchor?.chapterPosition ?: 0
+                        )
+                    }
+            allItems.filter { raw.query.isBlank() || it.matchesDetailQuery(raw.query) }
+                .sortedWith(compareBy({ it.chapterIndex }, { it.chapterPosition }))
+                .toImmutableList()
+        } ?: persistentListOf()
+        return BookmarkUiState(
+            isLoading = false,
+            books = books.toImmutableList(),
+            selectedBook = selected,
+            detailItems = details,
+            searchQuery = raw.query,
+            targetMarkingId = raw.targetMarkingId,
+        )
     }
 
-    fun updateBookmark(bookmark: Bookmark) {
+    private fun exportBook(treeUri: Uri, markdown: Boolean, key: BookmarkBookKey) {
         viewModelScope.launch(Dispatchers.IO) {
-            bookmarkRepository.save(bookmark)
+            runCatching {
+                val suffix = if (markdown) "md" else "json"
+                val stamp = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault()).format(Date())
+                val fileName = "${key.name}-notes-$stamp.$suffix"
+                val bookmarks = bookmarkRepository.getByBook(key.name, key.author)
+                val markings = markingGateway.getByBook(key.name, key.author, null)
+                FileDoc.fromUri(treeUri, true).createFileIfNotExist(fileName)
+                    .openOutputStream().getOrThrow().use { output ->
+                        if (markdown) writeMarkdown(output, key, bookmarks, markings)
+                        else GSON.writeToOutputStream(output, ExportData(bookmarks, markings))
+                    }
+                fileName
+            }.onSuccess { effectsFlow.emit(AllBookmarkEffect.ShowMessage("导出成功: $it")) }
+                .onFailure { effectsFlow.emit(AllBookmarkEffect.ShowMessage("导出失败: ${it.message}")) }
         }
     }
 
-    fun deleteBookmark(bookmark: Bookmark) {
-        viewModelScope.launch(Dispatchers.IO) {
-            bookmarkRepository.delete(bookmark)
+    private fun writeMarkdown(
+        output: OutputStream,
+        key: BookmarkBookKey,
+        bookmarks: List<Bookmark>,
+        markings: List<BookMarking>
+    ) {
+        val text = buildString {
+            append("# ${key.name}\n\n")
+            if (key.author.isNotBlank()) append("作者:${key.author}\n\n")
+            append("## 书签(${bookmarks.size})\n\n")
+            bookmarks.forEach {
+                append("### ${it.chapterName}\n\n> ${it.bookText}\n\n")
+                if (it.content.isNotBlank()) append("${it.content}\n\n")
+            }
+            append("## 笔记(${markings.size})\n\n")
+            markings.forEach {
+                val selectedText = GSON.fromJsonObject<TextProcessAnchor>(it.anchorJson)
+                    .getOrNull()?.selectedText.orEmpty()
+                append("### ${it.chapterName}\n\n> $selectedText\n\n")
+                if (it.note.isNotBlank()) append("${it.note}\n\n")
+            }
         }
+        output.write(text.toByteArray())
     }
 
-    fun exportBookmark(treeUri: Uri, isMarkdown: Boolean) {
-        viewModelScope.launch(Dispatchers.IO) {
-            try {
-                val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
-                val suffix = if (isMarkdown) "md" else "json"
-                val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
-
-                val dirDoc = FileDoc.fromUri(treeUri, true)
-                val fileDoc = dirDoc.createFileIfNotExist(fileName)
-
-                fileDoc.openOutputStream().getOrThrow().use { outputStream ->
-                    val allData = bookmarkRepository.getAll()
-                    if (isMarkdown) {
-                        writeMarkdown(outputStream, allData)
-                    } else {
-                        GSON.writeToOutputStream(outputStream, allData)
-                    }
-                }
+    private data class RawState(
+        val bookmarks: List<Bookmark>, val markings: List<BookMarking>, val query: String,
+        val selectedBook: BookmarkBookKey?, val targetMarkingId: String?,
+    )
 
-                _effects.emit(AllBookmarkEffect.ShowMessage("导出成功: $fileName"))
-            } catch (e: Exception) {
-                e.printStackTrace()
-                _effects.emit(AllBookmarkEffect.ShowMessage("导出失败: ${e.message}"))
-            }
-        }
-    }
+    private data class ExportData(val bookmarks: List<Bookmark>, val notes: List<BookMarking>)
+}
 
-    private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) {
-        val sb = StringBuilder()
-        var lastHeader = ""
+private fun Bookmark.matches(key: BookmarkBookKey) =
+    bookName == key.name && bookAuthor == key.author
 
-        bookmarks.forEach {
-            val currentHeader = "${it.bookName}|${it.bookAuthor}"
-            if (currentHeader != lastHeader) {
-                lastHeader = currentHeader
-                sb.append("\n## ${it.bookName} - ${it.bookAuthor}\n\n")
-            }
-            sb.append("#### ${it.chapterName}\n")
-            sb.append("> **原文:** ${it.bookText}\n\n")
-            sb.append("${it.content}\n\n")
-            sb.append("---\n")
-        }
-        outputStream.write(sb.toString().toByteArray())
-    }
+private fun BookMarking.matches(key: BookmarkBookKey) =
+    bookName == key.name && bookAuthor == key.author
+
+private fun BookMarking.matchesNoteQuery(query: String): Boolean {
+    val selectedText =
+        GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()?.selectedText.orEmpty()
+    return note.contains(query, true) || selectedText.contains(query, true) || chapterName.contains(
+        query,
+        true
+    )
+}
+
+private fun BookmarkDetailItemUi.matchesDetailQuery(query: String): Boolean = when (this) {
+    is BookmarkDetailItemUi.SavedBookmark -> bookmark.chapterName.contains(query, true) ||
+            bookmark.bookText.contains(query, true) || bookmark.content.contains(query, true)
+
+    is BookmarkDetailItemUi.Marking -> marking.chapterName.contains(query, true) ||
+            selectedText.contains(query, true) || marking.note.contains(query, true)
 }

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/read/MarkingContract.kt

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
+import io.legado.app.domain.model.TextProcessStyle
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
@@ -23,4 +24,10 @@ data class MarkingUiState(
     val highlightRules: ImmutableList<HighlightRule> = persistentListOf(),
     /** 编辑模式异步加载标记期间的占位,避免 Sheet 先空再弹内容。 */
     val loading: Boolean = false,
+    /** True while the selection popup owns this editing session. */
+    val inlineMode: Boolean = false,
+    /** Inline menu preview; it is painted over the selection without repaginating the chapter. */
+    val previewStyle: TextProcessStyle? = null,
+    /** An inline save happened and the persisted page snapshot must be refreshed once on close. */
+    val inlineDirty: Boolean = false,
 )

+ 86 - 41
app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt

@@ -2,6 +2,7 @@ package io.legado.app.ui.book.read
 
 import android.content.Context
 import io.legado.app.R
+import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookMarking
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.repository.HighlightRuleRepository
@@ -20,6 +21,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import kotlin.math.max
 import kotlin.math.min
 
@@ -36,6 +39,8 @@ class MarkingDelegate(
     private val saveMarkingUseCase: SaveMarkingUseCase,
     private val host: Host,
 ) {
+    private val inlineSaveMutex = Mutex()
+    private var inlineSaveVersion = 0L
 
     interface Host {
         fun reloadCurrentChapter()
@@ -46,7 +51,7 @@ class MarkingDelegate(
     private val _uiState = MutableStateFlow(MarkingUiState())
     val uiState = _uiState.asStateFlow()
 
-    fun open(selection: Bookmark) {
+    fun open(selection: Bookmark, inlineMode: Boolean = false) {
         val book = ReadBook.book
         _uiState.update {
             it.copy(
@@ -54,6 +59,7 @@ class MarkingDelegate(
                 editing = null,
                 highlightRules = persistentListOf(),
                 loading = true,
+                inlineMode = inlineMode,
             )
         }
         scope.launch(IO) {
@@ -84,7 +90,7 @@ class MarkingDelegate(
     }
 
     /** 从目录 Sheet 点标记项进入编辑模式:按 id 取完整标记预填。 */
-    fun openForEdit(markingId: String) {
+    fun openForEdit(markingId: String, inlineMode: Boolean = false) {
         val book = ReadBook.book
         _uiState.update {
             it.copy(
@@ -92,6 +98,7 @@ class MarkingDelegate(
                 editing = null,
                 highlightRules = persistentListOf(),
                 loading = true,
+                inlineMode = inlineMode,
             )
         }
         scope.launch(IO) {
@@ -116,47 +123,30 @@ class MarkingDelegate(
     fun save(style: TextProcessStyle, note: String) {
         val current = _uiState.value
         val book = ReadBook.book ?: return
+        val saveVersion = ++inlineSaveVersion
+        if (current.inlineMode) {
+            _uiState.update { it.copy(previewStyle = style, inlineDirty = true) }
+        }
         scope.launch(IO) {
-            runCatching {
-                if (current.editing != null) {
-                    // 编辑模式:锚点与源指纹沿用已有标记(保留原源),只改 style/note
-                    val mark = current.editing
-                    val anchor = mark.anchor() ?: error("marking anchor missing")
-                    saveMarkingUseCase.save(
-                        bookName = book.name,
-                        bookAuthor = book.author,
-                        bookUrl = mark.bookUrl,
-                        chapterIndex = mark.chapterIndex ?: anchor.chapterIndex,
-                        chapterPosition = anchor.chapterPosition ?: 0,
-                        selectedText = anchor.selectedText,
-                        style = style,
-                        contextBefore = anchor.contextBefore,
-                        contextAfter = anchor.contextAfter,
-                        chapterName = mark.chapterName,
-                        note = note,
-                    )
-                } else {
-                    val selection = current.selection ?: return@runCatching
-                    val (contextBefore, contextAfter) = selectionContext(selection)
-                    saveMarkingUseCase.save(
-                        bookName = book.name,
-                        bookAuthor = book.author,
-                        bookUrl = book.bookUrl,
-                        chapterIndex = selection.chapterIndex,
-                        chapterPosition = selection.chapterPos,
-                        selectedText = selection.bookText,
-                        style = style,
-                        contextBefore = contextBefore,
-                        contextAfter = contextAfter,
-                        chapterName = selection.chapterName,
-                        note = note,
-                    )
+            inlineSaveMutex.withLock {
+                if (current.inlineMode && saveVersion != inlineSaveVersion) return@withLock
+                runCatching {
+                    persistMarking(current, book, style, note)
+                }.onSuccess { saved ->
+                    if (current.inlineMode) {
+                        _uiState.update { state ->
+                            if (state.inlineMode && saveVersion == inlineSaveVersion) {
+                                state.copy(editing = saved, loading = false)
+                            } else state
+                        }
+                    }
+                    if (!current.inlineMode) {
+                        host.reloadCurrentChapter()
+                        host.dismissMarkingSheet()
+                    }
+                }.onFailure { error ->
+                    host.showToast(error.localizedMessage ?: context.getString(R.string.error))
                 }
-            }.onSuccess {
-                host.reloadCurrentChapter()
-                host.dismissMarkingSheet()
-            }.onFailure { error ->
-                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
             }
         }
     }
@@ -180,6 +170,61 @@ class MarkingDelegate(
         _uiState.value = MarkingUiState()
     }
 
+    private suspend fun persistMarking(
+        current: MarkingUiState,
+        book: Book,
+        style: TextProcessStyle,
+        note: String,
+    ): BookMarking {
+        val marking = current.editing
+        if (marking != null) {
+            // 编辑模式沿用已有锚点与源指纹,只更新样式和备注。
+            val anchor = marking.anchor() ?: error("marking anchor missing")
+            return saveMarkingUseCase.save(
+                bookName = book.name,
+                bookAuthor = book.author,
+                bookUrl = marking.bookUrl,
+                chapterIndex = marking.chapterIndex ?: anchor.chapterIndex,
+                chapterPosition = anchor.chapterPosition ?: 0,
+                selectedText = anchor.selectedText,
+                style = style,
+                contextBefore = anchor.contextBefore,
+                contextAfter = anchor.contextAfter,
+                chapterName = marking.chapterName,
+                note = note,
+            )
+        }
+
+        val selection = current.selection ?: error("marking selection missing")
+        val (contextBefore, contextAfter) = selectionContext(selection)
+        return saveMarkingUseCase.save(
+            bookName = book.name,
+            bookAuthor = book.author,
+            bookUrl = book.bookUrl,
+            chapterIndex = selection.chapterIndex,
+            chapterPosition = selection.chapterPos,
+            selectedText = selection.bookText,
+            style = style,
+            contextBefore = contextBefore,
+            contextAfter = contextAfter,
+            chapterName = selection.chapterName,
+            note = note,
+        )
+    }
+
+    fun closeInlineSession() {
+        val current = _uiState.value
+        if (current.inlineMode) {
+            _uiState.value = MarkingUiState()
+            if (current.inlineDirty) {
+                scope.launch(IO) {
+                    inlineSaveMutex.withLock { Unit }
+                    host.reloadCurrentChapter()
+                }
+            }
+        }
+    }
+
     private fun BookMarking.anchor(): TextProcessAnchor? =
         GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
 

+ 490 - 0
app/src/main/java/io/legado/app/ui/book/read/MarkingSelectionMenu.kt

@@ -0,0 +1,490 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.Colorize
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.FormatColorFill
+import androidx.compose.material.icons.outlined.FormatColorText
+import androidx.compose.material.icons.outlined.FormatUnderlined
+import androidx.compose.material.icons.outlined.Highlight
+import androidx.compose.material.icons.outlined.MoreHoriz
+import androidx.compose.material.icons.outlined.StrikethroughS
+import androidx.compose.material.icons.outlined.Style
+import androidx.compose.material.icons.outlined.Waves
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+import io.legado.app.R
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.domain.model.MarkingEffect
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.ui.book.read.sheet.labelRes
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ProvideAppDensity
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.button.series.SmallOutlinedButton
+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.text.AppText
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.delay
+
+@Composable
+fun MarkingSelectionMenu(
+    menuState: TextMenuState,
+    state: MarkingUiState,
+    lastMarkingStyle: String,
+    onDismiss: () -> Unit,
+    onApply: (TextProcessStyle, String) -> Unit,
+    onDelete: () -> Unit,
+) {
+    val density = LocalDensity.current
+    val windowSize = LocalWindowInfo.current.containerSize
+    val width = with(density) { windowSize.width.toDp() } - 32.dp
+    val maxHeight = with(density) { windowSize.height.toDp() } - 32.dp
+    val opensBelow = (menuState.startTopY + menuState.endBottomY) / 2f < windowSize.height / 2f
+    val transformOrigin = TransformOrigin(0.5f, if (opensBelow) 0f else 1f)
+    val shadowPadding = 12.dp
+    val positionProvider = remember(menuState, density.density) {
+        TextMenuPositionProvider(
+            density = density.density,
+            startX = menuState.startX,
+            startTopY = menuState.startTopY,
+            startBottomY = menuState.startBottomY,
+            endX = menuState.endX,
+            endBottomY = menuState.endBottomY,
+            shadowPadding = with(density) { shadowPadding.roundToPx() },
+            placeOppositeHalf = true,
+        )
+    }
+
+    val storedStyle = remember(state.editing?.styleJson, lastMarkingStyle) {
+        GSON.fromJsonObject<TextProcessStyle>(state.editing?.styleJson ?: lastMarkingStyle)
+            .getOrNull() ?: MarkingEffect.BG.toStyle(MarkingEffect.DEFAULT_COLOR)
+    }
+    var style by remember(state.selection, state.editing?.id, storedStyle) {
+        mutableStateOf(
+            storedStyle
+        )
+    }
+    var selectedEffect by remember(state.selection, state.editing?.id, storedStyle) {
+        mutableStateOf(MarkingEffect.fromStyle(storedStyle))
+    }
+    var selectedColor by remember(state.selection, state.editing?.id, storedStyle) {
+        mutableStateOf(MarkingEffect.colorOf(storedStyle))
+    }
+    var useRules by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
+    var selectedRuleId by remember(state.selection, state.editing?.id) {
+        mutableStateOf<String?>(
+            null
+        )
+    }
+    var note by remember(
+        state.selection,
+        state.editing?.id
+    ) { mutableStateOf(state.editing?.note.orEmpty()) }
+    var noteInitialized by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
+    var noteDirty by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
+    var showColorPicker by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
+    var menuVisible by remember(menuState) { mutableStateOf(false) }
+    var closing by remember(menuState) { mutableStateOf(false) }
+
+    fun requestDismiss() {
+        if (!closing) {
+            closing = true
+            menuVisible = false
+        }
+    }
+
+    LaunchedEffect(menuState) { menuVisible = true }
+    LaunchedEffect(closing) {
+        if (closing) {
+            delay(SelectionMenuMotion.EXIT_DURATION_MILLIS.toLong())
+            onDismiss()
+        }
+    }
+
+    LaunchedEffect(state.loading, state.editing?.id) {
+        if (!state.loading) {
+            note = state.editing?.note.orEmpty()
+            noteInitialized = true
+            if (state.editing == null && state.selection != null) onApply(style, note)
+        }
+    }
+    LaunchedEffect(note, noteInitialized, noteDirty, style) {
+        if (noteInitialized && noteDirty && !state.loading) {
+            delay(450)
+            onApply(style, note)
+            noteDirty = false
+        }
+    }
+    LaunchedEffect(storedStyle, state.highlightRules) {
+        state.highlightRules.firstOrNull { it.toProcessStyle() == storedStyle }?.let {
+            useRules = true
+            selectedRuleId = it.id
+        }
+    }
+
+    Popup(
+        popupPositionProvider = positionProvider,
+        onDismissRequest = {
+            if (!showColorPicker) requestDismiss()
+        },
+        properties = PopupProperties(
+            focusable = true,
+            dismissOnBackPress = true,
+            dismissOnClickOutside = true,
+        ),
+    ) {
+        ProvideAppDensity {
+            Box(Modifier.padding(shadowPadding)) {
+                AnimatedVisibility(
+                    visible = menuVisible,
+                    enter = SelectionMenuMotion.enter(transformOrigin),
+                    exit = SelectionMenuMotion.exit(transformOrigin),
+                ) {
+                    NormalCard(
+                        modifier = Modifier.width(width),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 10.dp,
+                        cornerRadius = 16.dp,
+                    ) {
+                        Column(
+                            modifier = Modifier
+                                .heightIn(max = maxHeight)
+                                .verticalScroll(rememberScrollState())
+                                .padding(horizontal = 10.dp, vertical = 12.dp),
+                            verticalArrangement = Arrangement.spacedBy(8.dp),
+                        ) {
+                            MarkingColorRow(
+                                selectedColor = selectedColor,
+                                useRules = useRules,
+                                enabled = !state.loading,
+                                onToggleRules = { useRules = !useRules },
+                                onColorSelected = { color ->
+                                    selectedColor = color
+                                    useRules = false
+                                    style = selectedEffect.toStyle(color)
+                                    onApply(style, note)
+                                },
+                                onCustomColor = { showColorPicker = true },
+                            )
+
+                            if (useRules) {
+                                LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                                    items(state.highlightRules, key = { it.id }) { rule ->
+                                        MarkingRuleTile(
+                                            rule = rule,
+                                            selected = selectedRuleId == rule.id,
+                                            onClick = {
+                                                selectedRuleId = rule.id
+                                                style = rule.toProcessStyle()
+                                                onApply(style, note)
+                                            },
+                                        )
+                                    }
+                                }
+                            } else {
+                                LazyRow(
+                                    modifier = Modifier.fillMaxWidth(),
+                                    contentPadding = PaddingValues(horizontal = 2.dp),
+                                    horizontalArrangement = Arrangement.spacedBy(5.dp),
+                                ) {
+                                    items(MarkingEffect.entries, key = { it.name }) { effect ->
+                                        MarkingEffectTile(
+                                            effect = effect,
+                                            selected = selectedEffect == effect,
+                                            onClick = {
+                                                selectedEffect = effect
+                                                style = effect.toStyle(selectedColor)
+                                                onApply(style, note)
+                                            },
+                                        )
+                                    }
+                                }
+                            }
+
+                            AppTextField(
+                                value = note,
+                                onValueChange = {
+                                    note = it
+                                    noteDirty = true
+                                },
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .heightIn(min = 88.dp),
+                                placeholder = { AppText(stringResource(R.string.bookmark_mark_note_hint)) },
+                            )
+
+                            if (state.editing != null) {
+                                SmallOutlinedButton(
+                                    modifier = Modifier
+                                        .align(Alignment.End),
+                                    icon = Icons.Outlined.Delete,
+                                    text = stringResource(R.string.bookmark_mark_delete_note),
+                                    contentDescription = null,
+                                    onClick = onDelete
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    ColorPickerSheet(
+        show = showColorPicker,
+        initialColor = selectedColor,
+        onDismissRequest = { showColorPicker = false },
+        onColorSelected = { color ->
+            selectedColor = color
+            useRules = false
+            selectedRuleId = null
+            style = selectedEffect.toStyle(color)
+            onApply(style, note)
+        },
+    )
+}
+
+@Composable
+private fun MarkingColorRow(
+    selectedColor: Int,
+    useRules: Boolean,
+    enabled: Boolean,
+    onToggleRules: () -> Unit,
+    onColorSelected: (Int) -> Unit,
+    onCustomColor: () -> Unit,
+) {
+    LazyRow(
+        contentPadding = PaddingValues(horizontal = 2.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        item(key = "source") {
+            RoundIconButton(
+                selected = useRules,
+                enabled = true,
+                icon = Icons.Outlined.Style,
+                contentDescription = stringResource(R.string.bookmark_mark_reuse_rule),
+                onClick = onToggleRules,
+            )
+        }
+        items(MarkingMenuColors, key = { it }) { color ->
+            Box(
+                modifier = Modifier
+                    .size(26.dp)
+                    .clip(CircleShape)
+                    .background(Color(color))
+                    .clickable(enabled = enabled) { onColorSelected(color) },
+                contentAlignment = Alignment.Center,
+            ) {
+                if (color == selectedColor && !useRules) {
+                    Icon(
+                        imageVector = Icons.Outlined.Check,
+                        contentDescription = null,
+                        tint = if (Color(color).luminance() > 0.5f) Color.Black else Color.White,
+                        modifier = Modifier.size(16.dp),
+                    )
+                }
+            }
+        }
+        item(key = "custom") {
+            RoundIconButton(
+                selected = false,
+                enabled = enabled,
+                icon = Icons.Outlined.Colorize,
+                contentDescription = stringResource(R.string.bookmark_mark_custom_color),
+                onClick = onCustomColor,
+            )
+        }
+    }
+}
+
+@Composable
+private fun RoundIconButton(
+    selected: Boolean,
+    enabled: Boolean,
+    icon: ImageVector,
+    contentDescription: String,
+    onClick: () -> Unit,
+) {
+    Box(
+        modifier = Modifier
+            .size(26.dp)
+            .clip(CircleShape)
+            .background(
+                if (selected) LegadoTheme.colorScheme.secondaryContainer else Color.Transparent
+            )
+            .border(1.dp, LegadoTheme.colorScheme.outlineVariant, CircleShape)
+            .clickable(enabled = enabled, onClick = onClick),
+        contentAlignment = Alignment.Center,
+    ) {
+        Icon(
+            imageVector = icon,
+            contentDescription = contentDescription,
+            modifier = Modifier.size(15.dp),
+        )
+    }
+}
+
+@Composable
+private fun MarkingEffectTile(
+    effect: MarkingEffect,
+    selected: Boolean,
+    onClick: () -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .clip(RoundedCornerShape(12.dp))
+            .background(if (selected) LegadoTheme.colorScheme.secondaryContainer else Color.Transparent)
+            .clickable(onClick = onClick)
+            .padding(horizontal = 7.dp, vertical = 8.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.spacedBy(2.dp),
+    ) {
+        MarkingEffectIcon(effect = effect)
+        AppText(
+            text = stringResource(effect.labelRes()),
+            style = LegadoTheme.typography.labelSmall,
+            maxLines = 1,
+        )
+    }
+}
+
+@Composable
+private fun MarkingEffectIcon(effect: MarkingEffect) {
+    val foreground = LegadoTheme.colorScheme.onSurface
+    when (effect) {
+        MarkingEffect.SOLID -> Icon(
+            imageVector = Icons.Outlined.FormatUnderlined,
+            contentDescription = null,
+            modifier = Modifier.size(23.dp),
+        )
+
+        MarkingEffect.BG -> Icon(
+            imageVector = Icons.Outlined.FormatColorFill,
+            contentDescription = null,
+            tint = foreground,
+            modifier = Modifier.size(23.dp),
+        )
+
+        MarkingEffect.TEXT -> Icon(
+            imageVector = Icons.Outlined.FormatColorText,
+            contentDescription = null,
+            tint = foreground,
+            modifier = Modifier.size(23.dp),
+        )
+
+        MarkingEffect.WAVE -> Icon(
+            imageVector = Icons.Outlined.Waves,
+            contentDescription = null,
+            tint = foreground,
+            modifier = Modifier.size(23.dp),
+        )
+
+        MarkingEffect.DASHED -> Icon(
+            imageVector = Icons.Outlined.MoreHoriz,
+            contentDescription = null,
+            tint = foreground,
+            modifier = Modifier.size(23.dp),
+        )
+
+        MarkingEffect.STRIKE -> Icon(
+            imageVector = Icons.Outlined.StrikethroughS,
+            contentDescription = null,
+            modifier = Modifier.size(23.dp),
+        )
+
+        MarkingEffect.HIGHLIGHT -> Icon(
+            imageVector = Icons.Outlined.Highlight,
+            contentDescription = null,
+            modifier = Modifier.size(23.dp),
+        )
+    }
+}
+
+@Composable
+private fun MarkingRuleTile(rule: HighlightRule, selected: Boolean, onClick: () -> Unit) {
+    Column(
+        modifier = Modifier
+            .clip(RoundedCornerShape(16.dp))
+            .background(if (selected) LegadoTheme.colorScheme.secondaryContainer else Color.Transparent)
+            .clickable(onClick = onClick)
+            .padding(horizontal = 10.dp, vertical = 7.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        Box(
+            Modifier
+                .size(24.dp)
+                .clip(CircleShape)
+                .background(
+                    Color(
+                        rule.underlineColor ?: rule.textColor ?: rule.bgColor
+                        ?: MarkingEffect.DEFAULT_COLOR
+                    )
+                )
+        )
+        Spacer(Modifier.height(4.dp))
+        AppText(
+            rule.name.ifBlank { rule.displayPattern() },
+            style = LegadoTheme.typography.labelSmall
+        )
+    }
+}
+
+private fun HighlightRule.toProcessStyle() = TextProcessStyle(
+    textColor = textColor,
+    bgColor = bgColor,
+    underlineMode = underlineMode,
+    underlineColor = underlineColor,
+    underlineWidth = underlineWidth,
+    underlineOffset = underlineOffset,
+    underlineSvgPath = underlineSvgPath,
+)
+
+private val MarkingMenuColors = listOf(
+    0xFFF44848.toInt(), 0xFF22C55E.toInt(), 0xFF3B82F6.toInt(),
+    0xFFA855F7.toInt(), 0xFFFF7417.toInt(), 0xFFEC4899.toInt(),
+    0xFF18B5A4.toInt(), 0xFF9A4D0F.toInt(), 0xFF111111.toInt(),
+)

+ 9 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -425,7 +425,7 @@ internal val MoreActionIds = listOf(
     "source_custom_button", "change_source", "refresh", "download", "edit_content", "add_bookmark",
     "text_processing", "reverse_content", "re_segment",
     "del_ruby", "del_h", "toc_rule", "charset", "image_style", "page_anim",
-    "simulated_reading", "get_progress", "cover_progress", "highlight_rule",
+    "simulated_reading", "get_progress", "cover_progress", "highlight_rule", "read_style",
     "bottom_button_config", "log",
 )
 
@@ -692,6 +692,14 @@ sealed interface ReadBookIntent {
     data class TextActionBookmark(val bookmark: Bookmark) : ReadBookIntent
     data class OpenMarking(val selection: Bookmark) : ReadBookIntent
 
+    /** Prepare the inline selection-menu marking controls without opening the editor sheet. */
+    data class OpenQuickMarking(val selection: Bookmark) : ReadBookIntent
+    data class OpenQuickMarkingEdit(val id: String) : ReadBookIntent
+    data class ApplyQuickMarking(val style: TextProcessStyle, val note: String? = null) :
+        ReadBookIntent
+
+    data object DismissQuickMarking : ReadBookIntent
+
     /** 从正文处理 Sheet 点标记项进入编辑模式。 */
     data class EditMarking(val id: String) : ReadBookIntent
     data object DismissMarking : ReadBookIntent

+ 12 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -384,6 +384,7 @@ class ReadBookController(
     fun dismissTextActionMenu() {
         textMenuRequestVersion++
         _textMenuState.value = null
+        viewModel.onIntent(ReadBookIntent.DismissQuickMarking)
     }
     private val popupAction by lazy { PopupAction(activity) }
     private var screenTimeOut: Long = 0
@@ -1387,7 +1388,7 @@ class ReadBookController(
     }
 
     fun onMarkingClick(markingId: String) {
-        viewModel.onIntent(ReadBookIntent.EditMarking(markingId))
+        viewModel.onIntent(ReadBookIntent.OpenQuickMarkingEdit(markingId))
     }
 
     fun oldClickImg(src: String): Boolean {
@@ -1481,6 +1482,16 @@ class ReadBookController(
             }
         }
 
+    fun openQuickMarking(): Boolean {
+        val selection = composeSelectionBookmark(bodyOnly = true)
+        if (selection == null) {
+            activity.toastOnUi(R.string.create_bookmark_error)
+            return false
+        }
+        viewModel.onIntent(ReadBookIntent.OpenQuickMarking(selection))
+        return true
+    }
+
     fun onMenuItemSelected(itemId: Int): Boolean {
         when (itemId) {
             R.id.menu_aloud -> {

+ 8 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarTitle.kt

@@ -209,7 +209,10 @@ internal fun MenuTitleBar(
                         text = state.bookName,
                         modifier = Modifier
                             .weight(1f)
-                            .clickable { onIntent(ReadBookIntent.OpenBookInfo) }
+                            .combinedClickable(
+                                onClick = { onIntent(ReadBookIntent.OpenBookInfo) },
+                                onLongClick = { onIntent(ReadBookIntent.OpenBookInfoDirect) },
+                            )
                             .padding(horizontal = 8.dp, vertical = 4.dp),
                         style = LegadoTheme.typography.titleMedium.copy(
                             shadow = menuTextShadow
@@ -305,7 +308,10 @@ internal fun MenuTitleBar(
                 text = state.bookName,
                 modifier = Modifier
                     .fillMaxWidth()
-                    .clickable { onIntent(ReadBookIntent.OpenBookInfo) }
+                    .combinedClickable(
+                        onClick = { onIntent(ReadBookIntent.OpenBookInfo) },
+                        onLongClick = { onIntent(ReadBookIntent.OpenBookInfoDirect) },
+                    )
                     .padding(horizontal = 16.dp, vertical = 4.dp),
                 style = LegadoTheme.typography.titleMedium.copy(
                     shadow = menuTextShadow

+ 38 - 10
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -156,6 +156,7 @@ fun ReadBookRouteScreen(
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
+    val markingState by viewModel.markingState.collectAsStateWithLifecycle()
     val readerRenderState by readerSessionViewModel.uiState.collectAsStateWithLifecycle()
     val readerPageWindow = readerRenderState.pageWindow
     val readerPaginationError = readerRenderState.paginationError
@@ -704,6 +705,7 @@ fun ReadBookRouteScreen(
                 backgroundRevision = readerBackground.revision,
                 backgroundImageAlpha = readerBackgroundAlpha(state.styleConfig.bgAlpha),
                 selectionColor = LegadoTheme.colorScheme.primary.copy(alpha = 0.28f),
+                    selectionPreviewStyle = markingState.previewStyle,
                 textAccentColor = Color(state.sheetConfig.textAccentColor),
                 autoPageIndicatorColor = LegadoTheme.colorScheme.primary,
                 modifier = Modifier
@@ -913,7 +915,10 @@ fun ReadBookRouteScreen(
             )
             ReaderTextSelectionOverlay(
                 controller = controller,
+                viewModel = viewModel,
                 expandTextMenu = readPreferences.expandTextMenu,
+                showSelectMenuIcon = readPreferences.showSelectMenuIcon,
+                lastMarkingStyle = readPreferences.lastMarkingStyle,
                 onOpenManage = { showSelectMenuConfigSheet = true },
             )
             var configItems by remember { mutableStateOf<List<ActionMenuItem>>(emptyList()) }
@@ -1010,20 +1015,43 @@ private fun rememberReaderSharedClipRadiusDp(
 @Composable
 private fun ReaderTextSelectionOverlay(
     controller: ReadBookController,
+    viewModel: ReadBookViewModel,
     expandTextMenu: Boolean,
+    showSelectMenuIcon: Boolean,
+    lastMarkingStyle: String,
     onOpenManage: () -> Unit,
 ) {
     val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
-    TextActionSelectionMenu(
-        menuState = textMenuState,
-        expandTextMenu = expandTextMenu,
-        onDismiss = controller::dismissTextActionMenu,
-        onItemClick = controller::onTextMenuItemClick,
-        onOpenManage = {
-            controller.dismissTextActionMenu()
-            onOpenManage()
-        },
-    )
+    val markingState by viewModel.markingState.collectAsStateWithLifecycle()
+    val currentMenuState = textMenuState
+    if (markingState.inlineMode && currentMenuState != null) {
+        MarkingSelectionMenu(
+            menuState = currentMenuState,
+            state = markingState,
+            lastMarkingStyle = lastMarkingStyle,
+            onDismiss = controller::onMenuActionFinally,
+            onApply = { style, note ->
+                viewModel.onIntent(ReadBookIntent.ApplyQuickMarking(style, note))
+            },
+            onDelete = {
+                viewModel.onIntent(ReadBookIntent.DeleteMarking)
+                controller.onMenuActionFinally()
+            },
+        )
+    } else {
+        TextActionSelectionMenu(
+            menuState = textMenuState,
+            expandTextMenu = expandTextMenu,
+            showSelectMenuIcon = showSelectMenuIcon,
+            onDismiss = controller::dismissTextActionMenu,
+            onItemClick = controller::onTextMenuItemClick,
+            onOpenQuickMarking = controller::openQuickMarking,
+            onOpenManage = {
+                controller.dismissTextActionMenu()
+                onOpenManage()
+            },
+        )
+    }
 }
 
 

+ 26 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -80,6 +80,7 @@ import io.legado.app.model.translation.TranslationManager
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
+import io.legado.app.utils.GSON
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.isAbsUrl
@@ -225,7 +226,7 @@ class ReadBookViewModel(
         saveMarkingUseCase = saveMarkingUseCase,
         host = object : MarkingDelegate.Host {
             override fun reloadCurrentChapter() {
-                contentProcessDelegate.reloadCurrentChapter()
+                contentProcessDelegate.reloadCurrentChapterPreservingSnapshot()
             }
 
             override fun dismissMarkingSheet() {
@@ -1446,6 +1447,30 @@ class ReadBookViewModel(
                 _uiState.update { it.copy(activeSheet = ReadBookSheet.Marking) }
             }
 
+            is ReadBookIntent.OpenQuickMarking -> {
+                markingReturnSheet = null
+                markingDelegate.open(intent.selection, inlineMode = true)
+            }
+
+            is ReadBookIntent.OpenQuickMarkingEdit -> {
+                markingReturnSheet = null
+                markingDelegate.openForEdit(intent.id, inlineMode = true)
+            }
+
+            is ReadBookIntent.ApplyQuickMarking -> {
+                viewModelScope.launch {
+                    readSettingsRepository.update {
+                        it.copy(lastMarkingStyle = GSON.toJson(intent.style))
+                    }
+                }
+                markingDelegate.save(
+                    style = intent.style,
+                    note = intent.note ?: markingDelegate.uiState.value.editing?.note.orEmpty(),
+                )
+            }
+
+            ReadBookIntent.DismissQuickMarking -> markingDelegate.closeInlineSession()
+
             is ReadBookIntent.EditMarking -> {
                 // 从目录 Sheet 进入:记住原 sheet,保存/删除/取消后返回
                 markingReturnSheet = _uiState.value.activeSheet

+ 11 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt

@@ -124,6 +124,17 @@ class ReadContentProcessDelegate(
         ReadBook.loadContent(resetPageOffset = false)
     }
 
+    /**
+     * Reprocesses only the current chapter while retaining its last readable input/page snapshot.
+     * Marking style changes are paint-only, so clearing the entire three-chapter window would
+     * expose a loading/empty frame for no benefit.
+     */
+    fun reloadCurrentChapterPreservingSnapshot() {
+        val chapterIndex = ReadBook.durChapterIndex
+        ReadBook.removeLoading(chapterIndex)
+        ReadBook.loadContent(chapterIndex, resetPageOffset = false)
+    }
+
     private fun BookContentProcess.toContentProcessItemUi(): ContentProcessItemUi? {
         val anchor = GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
             ?: return null

+ 55 - 0
app/src/main/java/io/legado/app/ui/book/read/SelectionMenuMotion.kt

@@ -0,0 +1,55 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.ui.graphics.TransformOrigin
+
+/**
+ * 文本选择浮层共用的进出动画规格。
+ *
+ * [TextActionSelectionMenu] 与 [MarkingSelectionMenu] 会在同一次选区里互相切换(快捷菜单点“标记”
+ * 后换成标记卡片),两边必须共用同一套时长和缩放比例,否则切换时会出现两套节奏叠加的割裂感。
+ * 各菜单只传自己的缩放原点:居中的小菜单用 [TransformOrigin.Center],贴着选区展开的标记卡片
+ * 用朝向选区的那条边。
+ */
+internal object SelectionMenuMotion {
+
+    /** 淡入时长。 */
+    private const val FADE_IN_DURATION_MILLIS = 360
+
+    /** 缩放入场时长。 */
+    private const val SCALE_IN_DURATION_MILLIS = 400
+
+    /**
+     * 出场总时长,取淡出与缩放中较长的一个。
+     *
+     * 主动关闭浮层时(例如 [MarkingSelectionMenu] 的 `requestDismiss`)要按它来决定延迟多久才真正
+     * 摘掉浮层:早于该时长会把出场动画截断。
+     */
+    const val EXIT_DURATION_MILLIS = 320
+
+    private const val SCALE_OUT_DURATION_MILLIS = 280
+    private const val ENTER_SCALE = 0.94f
+    private const val EXIT_SCALE = 0.96f
+
+    fun enter(transformOrigin: TransformOrigin = TransformOrigin.Center): EnterTransition =
+        fadeIn(animationSpec = tween(FADE_IN_DURATION_MILLIS)) +
+                scaleIn(
+                    animationSpec = tween(SCALE_IN_DURATION_MILLIS),
+                    initialScale = ENTER_SCALE,
+                    transformOrigin = transformOrigin,
+                )
+
+    fun exit(transformOrigin: TransformOrigin = TransformOrigin.Center): ExitTransition =
+        fadeOut(animationSpec = tween(EXIT_DURATION_MILLIS)) +
+                scaleOut(
+                    animationSpec = tween(SCALE_OUT_DURATION_MILLIS),
+                    targetScale = EXIT_SCALE,
+                    transformOrigin = transformOrigin,
+                )
+}

+ 125 - 52
app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt

@@ -30,8 +30,19 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.outlined.MenuBook
+import androidx.compose.material.icons.automirrored.outlined.VolumeUp
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Bookmark
+import androidx.compose.material.icons.outlined.BorderColor
+import androidx.compose.material.icons.outlined.ContentCopy
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.FindReplace
+import androidx.compose.material.icons.outlined.Language
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material.icons.outlined.SmartToy
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.VerticalDivider
@@ -46,6 +57,7 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.TransformOrigin
 import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.layout.positionOnScreen
 import androidx.compose.ui.platform.LocalDensity
@@ -72,8 +84,10 @@ import kotlin.math.roundToInt
 fun TextActionSelectionMenu(
     menuState: TextMenuState?,
     expandTextMenu: Boolean,
+    showSelectMenuIcon: Boolean,
     onDismiss: () -> Unit,
     onItemClick: (ActionMenuItem) -> Unit,
+    onOpenQuickMarking: () -> Boolean,
     onOpenManage: () -> Unit
 ) {
 
@@ -88,9 +102,8 @@ fun TextActionSelectionMenu(
 
     val localDensity = LocalDensity.current
     val containerSize = LocalWindowInfo.current.containerSize
-    val maxMenuWidth = with(localDensity){ containerSize.width.toDp() } - 32.dp
+    val menuCardWidth = with(localDensity) { containerSize.width.toDp() } - 32.dp
     val menuShadowPadding = 12.dp
-    val menuCardMaxWidth = maxMenuWidth - menuShadowPadding * 2
     var showMoreMenu by remember { mutableStateOf(false) }
     var moreMenuAnchor by remember { mutableStateOf<IntRect?>(null) }
     val view = LocalView.current
@@ -98,13 +111,22 @@ fun TextActionSelectionMenu(
     val moreVisibilityState = remember { MutableTransitionState(false) }
     moreVisibilityState.targetState = showMoreMenu
     LaunchedEffect(menuState) {
-        if (menuState == null) showMoreMenu = false
+        if (menuState == null) {
+            showMoreMenu = false
+        }
     }
     val draftItems = displayedMenuState.items
 
     val primaryItems = remember(draftItems) { draftItems.filter { it.showState == 0 } }
     val collapsedItems = remember(draftItems) { draftItems.filter { it.showState == 1 } }
     val activeMultiItems = remember(draftItems) { draftItems.filter { it.showState == 0 || it.showState == 1 } }
+    val handleItemClick: (ActionMenuItem) -> Unit = handleItemClick@{ item ->
+        if (item.id == R.id.menu_mark && onOpenQuickMarking()) {
+            showMoreMenu = false
+        } else if (item.id != R.id.menu_mark) {
+            onItemClick(item)
+        }
+    }
 
     val density = localDensity.density
     val shadowPaddingPx = with(localDensity) { menuShadowPadding.roundToPx() }
@@ -136,31 +158,28 @@ fun TextActionSelectionMenu(
 
             AnimatedVisibility(
                 visibleState = visibilityState,
-                enter = fadeIn(animationSpec = tween(360)) + scaleIn(
-                    initialScale = 0.94f,
-                    animationSpec = tween(400),
-                ),
-                exit = fadeOut(animationSpec = tween(320)) + scaleOut(
-                    targetScale = 0.96f,
-                    animationSpec = tween(280),
-                ),
+                enter = SelectionMenuMotion.enter(),
+                exit = SelectionMenuMotion.exit(),
             ) {
                 if (expandTextMenu) {
                     Box(modifier = Modifier.padding(menuShadowPadding)) {
-                        NormalCard(
-                            modifier = Modifier.widthIn(max = menuCardMaxWidth),
-                            containerColor = LegadoTheme.colorScheme.surfaceBright,
-                            elevation = 12.dp,
-                            cornerRadius = 12.dp,
-                        ) {
-                            MultiLineMenuView(
-                                items = activeMultiItems,
-                                onItemClick = onItemClick,
-                                onManageClick = {
-                                    onDismiss()
-                                    onOpenManage()
-                                }
-                            )
+                        Column(modifier = Modifier.width(menuCardWidth)) {
+                            NormalCard(
+                                modifier = Modifier.fillMaxWidth(),
+                                containerColor = LegadoTheme.colorScheme.surfaceBright,
+                                elevation = 12.dp,
+                                cornerRadius = 12.dp,
+                            ) {
+                                MultiLineMenuView(
+                                    items = activeMultiItems,
+                                    showIcons = showSelectMenuIcon,
+                                    onItemClick = handleItemClick,
+                                    onManageClick = {
+                                        onDismiss()
+                                        onOpenManage()
+                                    }
+                                )
+                            }
                         }
                     }
                 } else {
@@ -185,23 +204,26 @@ fun TextActionSelectionMenu(
                             }
                             .padding(menuShadowPadding),
                     ) {
-                        NormalCard(
-                            modifier = Modifier.widthIn(max = menuCardMaxWidth),
-                            containerColor = LegadoTheme.colorScheme.surfaceBright,
-                            elevation = 6.dp,
-                            cornerRadius = 12.dp,
-                        ) {
-                            QuickMenuView(
-                                items = primaryItems,
-                                hasMore = collapsedItems.isNotEmpty(),
-                                onItemClick = onItemClick,
-                                onMoreClick = { showMoreMenu = true },
-                                onMoreAnchorChanged = { moreMenuAnchor = it },
-                                onSettingsClick = {
-                                    onDismiss()
-                                    onOpenManage()
-                                }
-                            )
+                        Column(modifier = Modifier.width(menuCardWidth)) {
+                            NormalCard(
+                                modifier = Modifier.fillMaxWidth(),
+                                containerColor = LegadoTheme.colorScheme.surfaceBright,
+                                elevation = 6.dp,
+                                cornerRadius = 12.dp,
+                            ) {
+                                QuickMenuView(
+                                    items = primaryItems,
+                                    hasMore = collapsedItems.isNotEmpty(),
+                                    showIcons = showSelectMenuIcon,
+                                    onItemClick = handleItemClick,
+                                    onMoreClick = { showMoreMenu = true },
+                                    onMoreAnchorChanged = { moreMenuAnchor = it },
+                                    onSettingsClick = {
+                                        onDismiss()
+                                        onOpenManage()
+                                    }
+                                )
+                            }
                         }
                     }
                 }
@@ -252,14 +274,15 @@ fun TextActionSelectionMenu(
                         modifier = Modifier.padding(menuShadowPadding),
                     ) {
                         NormalCard(
-                            modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                            modifier = Modifier.widthIn(max = menuCardWidth),
                             containerColor = LegadoTheme.colorScheme.surfaceBright,
                             elevation = 6.dp,
                             cornerRadius = 12.dp,
                         ) {
                             MoreMenuView(
                                 items = collapsedItems,
-                                onItemClick = onItemClick,
+                                showIcons = showSelectMenuIcon,
+                                onItemClick = handleItemClick,
                                 onBack = { showMoreMenu = false },
                                 onManageClick = {
                                     onDismiss()
@@ -277,6 +300,7 @@ fun TextActionSelectionMenu(
 @Composable
 private fun MultiLineMenuView(
     items: List<ActionMenuItem>,
+    showIcons: Boolean,
     onItemClick: (ActionMenuItem) -> Unit,
     onManageClick: () -> Unit
 ) {
@@ -290,6 +314,7 @@ private fun MultiLineMenuView(
         items.forEach { item ->
             QuickMenuItem(
                 item = item,
+                showIcon = showIcons,
                 onClick = { onItemClick(item) },
                 verticalPadding = 12.dp,
             )
@@ -320,6 +345,7 @@ private fun MultiLineMenuView(
 private fun QuickMenuView(
     items: List<ActionMenuItem>,
     hasMore: Boolean,
+    showIcons: Boolean,
     onItemClick: (ActionMenuItem) -> Unit,
     onMoreClick: () -> Unit,
     onMoreAnchorChanged: (IntRect) -> Unit,
@@ -339,6 +365,7 @@ private fun QuickMenuView(
             ) { index, item ->
                 QuickMenuItem(
                     item = item,
+                    showIcon = showIcons,
                     onClick = { onItemClick(item) },
                     startPadding = if (index == 0) 16.dp else 10.dp,
                     endPadding = 8.dp,
@@ -383,12 +410,13 @@ private fun QuickMenuView(
 @Composable
 private fun QuickMenuItem(
     item: ActionMenuItem,
+    showIcon: Boolean,
     onClick: () -> Unit,
     startPadding: androidx.compose.ui.unit.Dp = 16.dp,
     endPadding: androidx.compose.ui.unit.Dp = 16.dp,
     verticalPadding: androidx.compose.ui.unit.Dp = 12.dp,
 ) {
-    Row(
+    Column(
         modifier = Modifier
             .clickable(onClick = onClick)
             .padding(
@@ -397,9 +425,19 @@ private fun QuickMenuItem(
                 top = verticalPadding,
                 bottom = verticalPadding,
             ),
-        verticalAlignment = Alignment.CenterVertically
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
     ) {
-        if (item.iconDrawable != null) {
+        val builtInIcon = if (showIcon) item.builtInIcon() else null
+        if (builtInIcon != null) {
+            Icon(
+                imageVector = builtInIcon,
+                contentDescription = item.title,
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(16.dp),
+            )
+            Spacer(modifier = Modifier.height(4.dp))
+        } else if (showIcon && item.iconDrawable != null) {
             AsyncImage(
                 model = item.iconDrawable,
                 contentDescription = item.title,
@@ -407,7 +445,7 @@ private fun QuickMenuItem(
                     .size(16.dp)
             )
 
-            Spacer(modifier = Modifier.width(12.dp))
+            Spacer(modifier = Modifier.height(3.dp))
         }
         AppText(
             text = item.title,
@@ -420,6 +458,7 @@ private fun QuickMenuItem(
 @Composable
 private fun MoreMenuView(
     items: List<ActionMenuItem>,
+    showIcons: Boolean,
     onItemClick: (ActionMenuItem) -> Unit,
     onBack: () -> Unit,
     onManageClick: () -> Unit
@@ -460,7 +499,7 @@ private fun MoreMenuView(
                 .fillMaxWidth()
         ) {
             items.forEach { item ->
-                MoreMenuItem(item = item, onClick = { onItemClick(item) })
+                MoreMenuItem(item = item, showIcon = showIcons, onClick = { onItemClick(item) })
             }
         }
 
@@ -498,6 +537,7 @@ private fun MoreMenuView(
 @Composable
 private fun MoreMenuItem(
     item: ActionMenuItem,
+    showIcon: Boolean,
     onClick: () -> Unit
 ) {
     Row(
@@ -507,7 +547,16 @@ private fun MoreMenuItem(
             .padding(horizontal = 16.dp, vertical = 12.dp),
         verticalAlignment = Alignment.CenterVertically
     ) {
-        if (item.iconDrawable != null) {
+        val builtInIcon = if (showIcon) item.builtInIcon() else null
+        if (builtInIcon != null) {
+            Icon(
+                imageVector = builtInIcon,
+                contentDescription = item.title,
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(20.dp),
+            )
+            Spacer(modifier = Modifier.width(12.dp))
+        } else if (showIcon && item.iconDrawable != null) {
             AsyncImage(
                 model = item.iconDrawable,
                 contentDescription = item.title,
@@ -523,6 +572,21 @@ private fun MoreMenuItem(
     }
 }
 
+private fun ActionMenuItem.builtInIcon(): ImageVector? = when (id) {
+    R.id.menu_copy -> Icons.Outlined.ContentCopy
+    R.id.menu_share_str -> Icons.Outlined.Share
+    R.id.menu_browser -> Icons.Outlined.Language
+    R.id.menu_aloud -> Icons.AutoMirrored.Outlined.VolumeUp
+    R.id.menu_bookmark -> Icons.Outlined.Bookmark
+    R.id.menu_mark -> Icons.Outlined.BorderColor
+    R.id.menu_dict -> Icons.AutoMirrored.Outlined.MenuBook
+    R.id.menu_replace -> Icons.Outlined.FindReplace
+    R.id.menu_edit -> Icons.Outlined.Edit
+    R.id.menu_ai_clean, R.id.menu_ai_rewrite -> Icons.Outlined.SmartToy
+    R.id.menu_search_content -> Icons.Outlined.Search
+    else -> null
+}
+
 
 private class MoreMenuPositionProvider(
     private val density: Float,
@@ -569,7 +633,7 @@ private class MoreMenuPositionProvider(
     }
 }
 
-private class TextMenuPositionProvider(
+internal class TextMenuPositionProvider(
     private val density: Float,
     private val startX: Int,
     private val startTopY: Int,
@@ -577,6 +641,7 @@ private class TextMenuPositionProvider(
     private val endX: Int,
     private val endBottomY: Int,
     private val shadowPadding: Int,
+    private val placeOppositeHalf: Boolean = false,
 ) : PopupPositionProvider {
     override fun calculatePosition(
         anchorBounds: IntRect,
@@ -601,7 +666,15 @@ private class TextMenuPositionProvider(
         val isSpaceEnoughBelowSelection = windowSize.height - endBottomY >
                 cardHeight + textMargin + cursorHandleClearance + marginVertical
 
-        if (!preferBelowForTopSelection && isSpaceEnoughAtTop) {
+        if (placeOppositeHalf) {
+            x = startX - shadowPadding
+            val selectionCenterY = (startTopY + endBottomY) / 2
+            y = if (selectionCenterY < windowSize.height / 2) {
+                endBottomY + cursorHandleClearance + textMargin - shadowPadding
+            } else {
+                startTopY - popupContentSize.height + shadowPadding - textMargin
+            }
+        } else if (!preferBelowForTopSelection && isSpaceEnoughAtTop) {
             x = startX - shadowPadding
             y = startTopY - popupContentSize.height + shadowPadding - textMargin
         } else if (isSpaceEnoughBelowSelection) {

+ 23 - 1
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt

@@ -37,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.StrokeCap
 import androidx.compose.ui.graphics.asImageBitmap
@@ -389,8 +390,10 @@ fun HighlightRuleEditSheet(
                         stringResource(R.string.underline_wave),
                         stringResource(R.string.underline_title_bar),
                         stringResource(R.string.underline_svg),
+                        stringResource(R.string.bookmark_mark_effect_strike),
+                        stringResource(R.string.bookmark_mark_effect_highlight),
                     )
-                    val underlineValues = arrayOf("1", "2", "3", "4", "5")
+                    val underlineValues = arrayOf("1", "2", "3", "4", "5", "6", "7")
                     TinyDropdownSettingItem(
                         title = stringResource(R.string.underline_style),
                         selectedValue = underlineMode.toString(),
@@ -737,6 +740,16 @@ private fun HighlightRulePreview(
                 size = size.copy(height = textResult.size.height.toFloat()),
             )
         }
+        if (underlineMode == 7) {
+            drawRect(
+                color = resolvedUnderlineColor.copy(alpha = 0.4f),
+                topLeft = Offset(0f, textResult.size.height * 0.5f),
+                size = Size(
+                    textResult.size.width.toFloat(),
+                    textResult.size.height * 0.5f,
+                ),
+            )
+        }
         drawText(textResult)
 
         if (underlineMode > 0) {
@@ -800,6 +813,15 @@ private fun HighlightRulePreview(
                         cap = StrokeCap.Round,
                     )
                 }
+                6 -> {
+                    val y = textResult.size.height * 0.52f
+                    drawLine(
+                        color = resolvedUnderlineColor,
+                        start = Offset(0f, y),
+                        end = Offset(textResult.size.width.toFloat(), y),
+                        strokeWidth = strokeWidth,
+                    )
+                }
             }
         }
     }

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/MarkingSheet.kt

@@ -378,6 +378,8 @@ internal fun MarkingEffect.labelRes(): Int = when (this) {
     MarkingEffect.SOLID -> R.string.bookmark_mark_effect_solid
     MarkingEffect.WAVE -> R.string.bookmark_mark_effect_wave
     MarkingEffect.DASHED -> R.string.bookmark_mark_effect_dash
+    MarkingEffect.STRIKE -> R.string.bookmark_mark_effect_strike
+    MarkingEffect.HIGHLIGHT -> R.string.bookmark_mark_effect_highlight
     MarkingEffect.BG -> R.string.bookmark_mark_effect_bg
     MarkingEffect.TEXT -> R.string.bookmark_mark_effect_text
 }

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderMoreActionsSheet.kt

@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Bookmark
 import androidx.compose.material.icons.filled.BugReport
 import androidx.compose.material.icons.filled.CleanHands
 import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.DisplaySettings
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Extension
 import androidx.compose.material.icons.filled.FindReplace
@@ -46,6 +47,7 @@ import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.MoreActionIds
 import io.legado.app.ui.book.read.ReadBookButtonConfigItem
 import io.legado.app.ui.book.read.ReadBookIntent
+import io.legado.app.ui.book.read.ReadBookMenuRoute
 import io.legado.app.ui.book.read.ReadBookSheet
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.widget.components.ConfigListEntry
@@ -324,6 +326,9 @@ private fun moreActionSpecs(
     MoreActionSpec(
         "highlight_rule", stringResource(R.string.highlight_rule_config), Icons.Default.Tune,
         onClick = { onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig)) }),
+    MoreActionSpec(
+        "read_style", stringResource(R.string.read_config), Icons.Default.DisplaySettings,
+        onClick = { dispatch(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadStyle)) }),
     MoreActionSpec(
         "reverse_content", stringResource(R.string.reverse_content), Icons.Default.SwapVert,
         onClick = { dispatch(ReadBookIntent.MenuReverseContent) }),

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt

@@ -58,6 +58,7 @@ object ReadConfig {
     val progressBarBehavior get() = read.progressBarBehavior
     val showSelectMenuIcon get() = read.showSelectMenuIcon
     val textSelectMenuConfig get() = read.textSelectMenuConfig
+    val lastMarkingStyle get() = read.lastMarkingStyle
     val showReadTitleAddition get() = read.showReadTitleAddition
     val clickActionTL get() = read.clickActionTL
     val clickActionTC get() = read.clickActionTC

+ 14 - 0
app/src/main/java/io/legado/app/utils/HtmlFormatter.kt

@@ -83,6 +83,20 @@ object HtmlFormatter {
             .joinToString("\n") { PARAGRAPH_INDENT + it }
     }
 
+    /**
+     * Returns reader content as text for character counting.
+     *
+     * Cached chapters can retain image tags for rendering; their tag names, attributes, and
+     * URLs are markup rather than readable content and must not contribute to the count.
+     */
+    fun textForWordCount(html: String): String {
+        if (html.isBlank()) return ""
+        val document = Jsoup.parseBodyFragment(html)
+        document.outputSettings().prettyPrint(false)
+        document.body().select("script, style, noscript").remove()
+        return document.text()
+    }
+
     /**
      * 书架/列表用的简介: 在 [formatDisplayText] 之上再丢掉书源排版进简介的状态面板,
      * 即"📡 当前服务:xxx"这类图标开头的整行, 以及纯符号的分隔行。

+ 11 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -377,10 +377,13 @@
     <string name="bookmark_mark_effect_solid">单实线</string>
     <string name="bookmark_mark_effect_wave">波浪线</string>
     <string name="bookmark_mark_effect_dash">虚线</string>
+    <string name="bookmark_mark_effect_strike">删除线</string>
+    <string name="bookmark_mark_effect_highlight">荧光</string>
     <string name="bookmark_mark_effect_bg">背景色</string>
     <string name="bookmark_mark_effect_text">字体色</string>
     <string name="bookmark_mark_custom_color">自定义颜色</string>
     <string name="bookmark_mark_note">备注</string>
+    <string name="bookmark_mark_delete_note">删除笔记</string>
     <string name="bookmark_mark_note_hint">输入备注…</string>
     <string name="bookmark_mark_editing">已有标记,保存将更新</string>
     <string name="content_process_ai_section">AI 改写</string>
@@ -871,7 +874,7 @@
     <string name="nine_patch_split_bottom">下切分线</string>
     <string name="manual_nine_slice">手动九切片</string>
     <string name="edit_nine_slice">编辑拉伸区域</string>
-    <string name="nine_slice_drag_hint">拖拽绿色选区选择允许拉伸的原图区域</string>
+    <string name="nine_slice_drag_hint">绿色选区会被横向和纵向双向拉伸;上下两条边只沿长度横向拉伸,左右两条边只沿长度纵向拉伸,四个角只等比缩放、不变形。要让圆角完整保留,切分线必须拖到圆弧外侧(切片宽度 ≥ 圆角半径);想整体调大调小框请用「缩放」,不要靠切分。</string>
     <string name="nine_slice_mode_all">全部</string>
     <string name="nine_slice_mode_horizontal">水平</string>
     <string name="nine_slice_mode_vertical">垂直</string>
@@ -3276,4 +3279,11 @@
     <string name="manga_reader_cache_following">缓存后续章节</string>
     <string name="manga_reader_cache_all">缓存整部漫画</string>
     <string name="manga_reader_cache_enqueued">已加入离线缓存队列</string>
+    <string name="feature_bookmarks_search_notes">搜索笔记内容</string>
+    <string name="feature_bookmarks_search_current">搜索当前%1$s</string>
+    <string name="feature_bookmarks_counts">%1$d 个书签 · %2$d 个笔记</string>
+    <string name="feature_bookmarks_match_hint">包含匹配笔记,点击查看</string>
+    <string name="feature_bookmarks_no_matching_notes">没有找到相关笔记</string>
+    <string name="feature_bookmarks_no_notes">暂无笔记</string>
+    <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
 </resources>

+ 11 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -320,10 +320,13 @@
     <string name="bookmark_mark_effect_solid">單實線</string>
     <string name="bookmark_mark_effect_wave">波浪線</string>
     <string name="bookmark_mark_effect_dash">虛線</string>
+    <string name="bookmark_mark_effect_strike">刪除線</string>
+    <string name="bookmark_mark_effect_highlight">螢光</string>
     <string name="bookmark_mark_effect_bg">背景色</string>
     <string name="bookmark_mark_effect_text">字體色</string>
     <string name="bookmark_mark_custom_color">自訂顏色</string>
     <string name="bookmark_mark_note">備註</string>
+    <string name="bookmark_mark_delete_note">刪除筆記</string>
     <string name="bookmark_mark_note_hint">輸入備註…</string>
     <string name="bookmark_mark_editing">已有標記,儲存將更新</string>
     <string name="content_process_ai_section">AI 改寫</string>
@@ -1705,7 +1708,7 @@
     <string name="nine_patch_split_bottom">下切分線</string>
     <string name="manual_nine_slice">手動九宮格切圖</string>
     <string name="edit_nine_slice">編輯拉伸區域</string>
-    <string name="nine_slice_drag_hint">拖曳綠色選區,選擇允許拉伸的原圖區域</string>
+    <string name="nine_slice_drag_hint">綠色選區會被橫向和縱向雙向拉伸;上下兩條邊只沿長度橫向拉伸,左右兩條邊只沿長度縱向拉伸,四個角只等比縮放、不變形。要讓圓角完整保留,切分線必須拖到圓弧外側(切片寬度 ≥ 圓角半徑);想整體調大調小框請用「縮放」,不要靠切分。</string>
     <string name="nine_slice_mode_all">全部</string>
     <string name="nine_slice_mode_horizontal">水平</string>
     <string name="nine_slice_mode_vertical">垂直</string>
@@ -2904,4 +2907,11 @@
     <string name="ai_auto_group_error_invalid_response">AI 未回傳可解析的分組方案。</string>
     <string name="ai_auto_group_error_unexpected">AI 自動分組失敗。</string>
     <string name="ai_auto_group_result">已建立 %1$d 個分組,沿用 %2$d 個分組,更新 %3$d 本書,略過 %4$d 本書。</string>
+    <string name="feature_bookmarks_search_notes">搜尋筆記內容</string>
+    <string name="feature_bookmarks_search_current">搜尋目前%1$s</string>
+    <string name="feature_bookmarks_counts">%1$d 個書籤 · %2$d 個筆記</string>
+    <string name="feature_bookmarks_match_hint">包含相符筆記,點擊查看</string>
+    <string name="feature_bookmarks_no_matching_notes">找不到相關筆記</string>
+    <string name="feature_bookmarks_no_notes">暫無筆記</string>
+    <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
 </resources>

+ 11 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -322,10 +322,13 @@
     <string name="bookmark_mark_effect_solid">單實線</string>
     <string name="bookmark_mark_effect_wave">波浪線</string>
     <string name="bookmark_mark_effect_dash">虛線</string>
+    <string name="bookmark_mark_effect_strike">刪除線</string>
+    <string name="bookmark_mark_effect_highlight">螢光</string>
     <string name="bookmark_mark_effect_bg">背景色</string>
     <string name="bookmark_mark_effect_text">字體色</string>
     <string name="bookmark_mark_custom_color">自訂顏色</string>
     <string name="bookmark_mark_note">備註</string>
+    <string name="bookmark_mark_delete_note">刪除筆記</string>
     <string name="bookmark_mark_note_hint">輸入備註…</string>
     <string name="bookmark_mark_editing">已有標記,儲存將更新</string>
     <string name="content_process_ai_section">AI 改寫</string>
@@ -1638,7 +1641,7 @@
     <string name="nine_patch_split_bottom">下切分線</string>
     <string name="manual_nine_slice">手動九宮格切圖</string>
     <string name="edit_nine_slice">編輯拉伸區域</string>
-    <string name="nine_slice_drag_hint">拖曳綠色選區,選擇允許拉伸的原圖區域</string>
+    <string name="nine_slice_drag_hint">綠色選區會被橫向和縱向雙向拉伸;上下兩條邊只沿長度橫向拉伸,左右兩條邊只沿長度縱向拉伸,四個角只等比縮放、不變形。要讓圓角完整保留,切分線必須拖到圓弧外側(切片寬度 ≥ 圓角半徑);想整體調大調小框請用「縮放」,不要靠切分。</string>
     <string name="nine_slice_mode_all">全部</string>
     <string name="nine_slice_mode_horizontal">水平</string>
     <string name="nine_slice_mode_vertical">垂直</string>
@@ -2909,4 +2912,11 @@
     <string name="ai_auto_group_error_invalid_response">AI 未回傳可解析的分組方案。</string>
     <string name="ai_auto_group_error_unexpected">AI 自動分組失敗。</string>
     <string name="ai_auto_group_result">已建立 %1$d 個分組,沿用 %2$d 個分組,更新 %3$d 本書,略過 %4$d 本書。</string>
+    <string name="feature_bookmarks_search_notes">搜尋筆記內容</string>
+    <string name="feature_bookmarks_search_current">搜尋目前%1$s</string>
+    <string name="feature_bookmarks_counts">%1$d 個書籤 · %2$d 個筆記</string>
+    <string name="feature_bookmarks_match_hint">包含相符筆記,點擊查看</string>
+    <string name="feature_bookmarks_no_matching_notes">找不到相關筆記</string>
+    <string name="feature_bookmarks_no_notes">暫無筆記</string>
+    <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
 </resources>

+ 11 - 1
app/src/main/res/values/strings.xml

@@ -407,10 +407,13 @@
     <string name="bookmark_mark_effect_solid">Solid</string>
     <string name="bookmark_mark_effect_wave">Wave</string>
     <string name="bookmark_mark_effect_dash">Dash</string>
+    <string name="bookmark_mark_effect_strike">Strike</string>
+    <string name="bookmark_mark_effect_highlight">Highlight</string>
     <string name="bookmark_mark_effect_bg">Bg</string>
     <string name="bookmark_mark_effect_text">Text</string>
     <string name="bookmark_mark_custom_color">Custom color</string>
     <string name="bookmark_mark_note">Note</string>
+    <string name="bookmark_mark_delete_note">Delete note</string>
     <string name="bookmark_mark_note_hint">Write a note…</string>
     <string name="bookmark_mark_editing">Existing mark — saving will update it</string>
     <string name="content_process_ai_section">AI edits</string>
@@ -902,7 +905,7 @@
     <string name="nine_patch_split_bottom">Bottom Split</string>
     <string name="manual_nine_slice">Manual 9-slice</string>
     <string name="edit_nine_slice">Edit stretch region</string>
-    <string name="nine_slice_drag_hint">Drag the green handles to select the source region that may stretch</string>
+    <string name="nine_slice_drag_hint">The green region stretches in both directions; the four edge slices stretch along their length only, and the corners are never deformed. To keep rounded corners intact, drag the split lines outside the arcs (slice ≥ corner radius). Use the image scale, not the split lines, to make the whole frame bigger or smaller.</string>
     <string name="nine_slice_mode_all">All</string>
     <string name="nine_slice_mode_horizontal">Horizontal</string>
     <string name="nine_slice_mode_vertical">Vertical</string>
@@ -3281,4 +3284,11 @@
     <string name="manga_reader_cache_following">Cache following</string>
     <string name="manga_reader_cache_all">Cache entire comic</string>
     <string name="manga_reader_cache_enqueued">Added to offline cache queue</string>
+    <string name="feature_bookmarks_search_notes">Search note content</string>
+    <string name="feature_bookmarks_search_current">Search current %1$s</string>
+    <string name="feature_bookmarks_counts">%1$d bookmarks · %2$d notes</string>
+    <string name="feature_bookmarks_match_hint">Matching note found — tap to view</string>
+    <string name="feature_bookmarks_no_matching_notes">No matching notes</string>
+    <string name="feature_bookmarks_no_notes">No notes</string>
+    <string name="feature_bookmarks_chapter_fallback">Chapter %1$d</string>
 </resources>

+ 2 - 0
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -121,6 +121,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         pageTouchSlop = 19,
         clickImgWay = "click-way",
         textSelectMenuConfig = "select-menu",
+        lastMarkingStyle = "marking-style",
         prevKeys = "previous-keys",
         nextKeys = "next-keys",
         fontFolder = "font-folder",
@@ -269,6 +270,7 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.expandTextMenu to expandTextMenu,
     PreferKey.showSelectMenuIcon to showSelectMenuIcon,
     PreferKey.textSelectMenuConfig to textSelectMenuConfig,
+    PreferKey.lastMarkingStyle to lastMarkingStyle,
     PreferKey.showReadTitleAddition to showReadTitleAddition,
     PreferKey.autoReadSpeed to autoReadSpeed,
     PreferKey.systemTypefaces to systemTypefaces,

+ 19 - 0
app/src/test/java/io/legado/app/domain/model/MarkingEffectTest.kt

@@ -23,6 +23,17 @@ class MarkingEffectTest {
         assertEquals(2, MarkingEffect.DASHED.toStyle(0xFFFF0000.toInt()).underlineMode)
     }
 
+    @Test
+    fun `效果到样式 - 删除线与半高荧光使用独立绘制模式`() {
+        val color = 0xFFFFD54F.toInt()
+        assertEquals(6, MarkingEffect.STRIKE.toStyle(color).underlineMode)
+        val highlight = MarkingEffect.HIGHLIGHT.toStyle(color)
+        assertEquals(7, highlight.underlineMode)
+        assertEquals(0x66FFD54F, highlight.underlineColor)
+        assertTrue(MarkingEffect.STRIKE.isUnderline)
+        assertTrue(MarkingEffect.HIGHLIGHT.isUnderline)
+    }
+
     @Test
     fun `效果到样式 - 背景色自动加 ~20% 透明度`() {
         val style = MarkingEffect.BG.toStyle(0xFFFFD54F.toInt())
@@ -54,6 +65,14 @@ class MarkingEffectTest {
             MarkingEffect.WAVE,
             MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 3))
         )
+        assertEquals(
+            MarkingEffect.STRIKE,
+            MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 6))
+        )
+        assertEquals(
+            MarkingEffect.HIGHLIGHT,
+            MarkingEffect.fromStyle(TextProcessStyle(underlineMode = 7))
+        )
     }
 
     @Test

+ 2 - 0
app/src/test/java/io/legado/app/domain/usecase/SaveMarkingUseCaseTest.kt

@@ -192,6 +192,8 @@ class SaveMarkingUseCaseTest {
     private class FakeBookMarkingGateway : BookMarkingGateway {
         private val marks = mutableListOf<BookMarking>()
 
+        override fun flowAll(): Flow<List<BookMarking>> = flowOf(marks.toList())
+
         override suspend fun getByBook(
             bookName: String,
             bookAuthor: String,

+ 5 - 2
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderLongImageScrollTest.kt

@@ -50,7 +50,9 @@ class ReaderLongImageScrollTest {
 
         assertEquals(2, pages.size)
         assertEquals(400f, pages.first().scrollExtentPx, 0f)
-        assertEquals(100f, pages.last().scrollExtentPx, 0f)
+        // 章末页的页高是排版游标(一行 10f),不向内容区高度 100f 收口:下一章正文紧接
+        // 图片后的一行出现(对照旧 TextChapterLayout 的 `height = durY + 20dp`)。
+        assertEquals(10f, pages.last().scrollExtentPx, 0f)
         assertTrue(pages.last().elements.single() is ReaderElement.Text)
     }
 
@@ -67,7 +69,8 @@ class ReaderLongImageScrollTest {
             ),
         ).single()
 
-        assertEquals(70f, page.scrollExtentPx, 0f)
+        // 页高为排版游标:只含正文行高 10f,上下留白都不计入堆叠高度。
+        assertEquals(10f, page.scrollExtentPx, 0f)
         assertEquals(10f, page.contentTopPx, 0f)
         assertEquals(80f, page.contentBottomPx, 0f)
     }

+ 197 - 13
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderPaginatorTest.kt

@@ -24,6 +24,11 @@ class ReaderPaginatorTest {
         baselineOffsetPx = 15f,
     )
 
+    /** 单行段落:内容区宽 40f、每字 20f,必定排成一行、占 20f 行高。 */
+    private fun paragraph(text: String, position: Int = 0) = ReaderMeasuredParagraph(
+        text, text.map(Char::toString), List(text.length) { 20f }, style, position,
+    )
+
     @Test fun emphasisUnderlineStyleIsCarriedByEveryPublishedPage() {
         val emphasis = ReaderEmphasisUnderline(0xff123456.toInt(), 2f, 1f)
         val pages = ReaderPaginator.paginate(
@@ -549,8 +554,57 @@ class ReaderPaginatorTest {
         assertEquals(glyphs[0].bounds.top, glyphs[1].bounds.top, 0f)
     }
 
+    /**
+     * 孤立的一行高亮:上下邻行都没有框,整段行距都空着,框可以按原图尺寸画——不再像
+     * 旧 View 那样被钉死在一半行距上(`TextLine.drawNineSliceFrames` 的 overflowScale)。
+     */
+    @Test
+    fun lonelyNineSliceLineMayUseTheWholeLineGap() {
+        val framedStyle = style.copy(
+            backgroundImage = ReaderTextBackgroundImage(
+                "frame.png", 3, 1f,
+                contentInsetLeftPx = 3f,
+                contentInsetRightPx = 4f,
+                contentInsetTopPx = 8f,
+                contentInsetBottomPx = 8f,
+            )
+        )
+        val page = ReaderPaginator.paginateBlocks(
+            listOf(
+                ReaderMeasuredBlock.InlineParagraph(
+                    items = listOf(ReaderMeasuredInlineItem.Text("字", 10f, framedStyle, 0)),
+                    indentCharacters = 0,
+                    alignment = ReaderTextAlignment.START,
+                    lineHeightPx = 20f,
+                    baselineOffsetPx = 15f,
+                    baseTextSizePx = 10f,
+                    lineSpacingMultiplier = 1.5f,
+                )
+            ),
+            config.copy(viewportHeightPx = 100),
+        ).single()
+
+        val glyph = page.elements.single() as ReaderElement.Text
+        // 行距 1.5 ⇒ 整段留白 10px,一半只有 5px。
+        assertTrue(glyph.backgroundFrameTopPx > 5f)
+        assertEquals(8f, glyph.backgroundFrameTopPx, 0.001f)
+        assertEquals(8f, glyph.backgroundFrameBottomPx, 0.001f)
+        val fittedImage = glyph.style.backgroundImage!!
+        assertEquals(3f, fittedImage.contentInsetLeftPx, 0.001f)
+        assertEquals(4f, fittedImage.contentInsetRightPx, 0.001f)
+        val run = page.textBackgroundRuns().single()
+        assertEquals(glyph.bounds.left - 3f, run.bounds.left, 0.001f)
+        assertEquals(glyph.bounds.right + 4f, run.bounds.right, 0.001f)
+        assertEquals(glyph.bounds.top - 8f, run.bounds.top, 0.001f)
+        assertEquals(glyph.bounds.bottom + 8f, run.bounds.bottom, 0.001f)
+    }
+
+    /**
+     * 行距不够时四边**等比**收紧:旧 View 只钳上下,左右保持原图厚度,于是「左右两条宽竖边
+     * + 上下两条发丝横线、四角被纵向抹平」;这里要求四条边共用同一个因子。
+     */
     @Test
-    fun nineSliceFrameUniformlyScalesIntoAvailableLineGap() {
+    fun nineSliceShrinksAllFourEdgesByTheSameFactorWhenTheGapIsTight() {
         val framedStyle = style.copy(backgroundImage = ReaderTextBackgroundImage(
             "frame.png", 3, 1f,
             contentInsetLeftPx = 3f,
@@ -566,22 +620,122 @@ class ReaderPaginatorTest {
                 lineHeightPx = 20f,
                 baselineOffsetPx = 15f,
                 baseTextSizePx = 10f,
+                lineSpacingMultiplier = 1.2f,
+            )
+            ),
+            config.copy(viewportHeightPx = 100),
+        ).single()
+
+        val glyph = page.elements.single() as ReaderElement.Text
+        val fitted = glyph.style.backgroundImage!!
+        // 行距留白 4px:因子取 min(1, 4/4, 4/6) = 2/3。
+        val factor = 2f / 3f
+        assertEquals(3f * factor, fitted.contentInsetLeftPx, 0.001f)
+        assertEquals(4f * factor, fitted.contentInsetRightPx, 0.001f)
+        assertEquals(4f * factor, fitted.contentInsetTopPx, 0.001f)
+        assertEquals(6f * factor, fitted.contentInsetBottomPx, 0.001f)
+        assertEquals(fitted.contentInsetTopPx, glyph.backgroundFrameTopPx, 0.001f)
+        assertEquals(fitted.contentInsetBottomPx, glyph.backgroundFrameBottomPx, 0.001f)
+        // 左右边不再独立于上下边:缩放比例一致,四角不会被纵向抹平。
+        assertEquals(
+            fitted.contentInsetLeftPx / 3f,
+            fitted.contentInsetTopPx / 4f,
+            0.001f,
+        )
+    }
+
+    /**
+     * 连续多行都带框时,相邻两行各让一半行距:`上一行的下边 + 下一行的上边 ≤ 行距`,
+     * 两个框正好相接不重叠,也不会出现两条平行描边。
+     */
+    @Test
+    fun adjacentNineSliceLinesShareTheLineGapInsteadOfOverlapping() {
+        val framedStyle = style.copy(
+            backgroundImage = ReaderTextBackgroundImage(
+                "frame.png", 3, 1f,
+                contentInsetLeftPx = 3f,
+                contentInsetRightPx = 4f,
+                contentInsetTopPx = 8f,
+                contentInsetBottomPx = 8f,
+            )
+        )
+        val page = ReaderPaginator.paginateBlocks(
+            listOf(
+                ReaderMeasuredBlock.InlineParagraph(
+                    items = (0 until 4).map { index ->
+                        ReaderMeasuredInlineItem.Text("字", 10f, framedStyle, index)
+                    },
+                indentCharacters = 0,
+                alignment = ReaderTextAlignment.START,
+                lineHeightPx = 20f,
+                baselineOffsetPx = 15f,
+                baseTextSizePx = 10f,
                 lineSpacingMultiplier = 1.5f,
             )),
+            config.copy(viewportWidthPx = 25, viewportHeightPx = 100),
+        ).single()
+
+        val glyphs = page.elements.filterIsInstance<ReaderElement.Text>()
+        val lineGapPx = 10f
+        val lines = glyphs.groupBy { it.bounds.top }.values.toList()
+        assertEquals(2, lines.size)
+        // 同一行共用一份 inset(同一个因子),左右与上下等比。
+        lines.forEach { line ->
+            val image = line.first().style.backgroundImage!!
+            line.forEach { assertEquals(image, it.style.backgroundImage) }
+            assertEquals(3f * 5f / 8f, image.contentInsetLeftPx, 0.001f)
+            assertEquals(5f, image.contentInsetTopPx, 0.001f)
+            assertEquals(5f, image.contentInsetBottomPx, 0.001f)
+        }
+        // 相邻两边各让半个行距:正好相接,不会叠出两条平行描边。
+        val upper = lines[0].first().style.backgroundImage!!
+        val lower = lines[1].first().style.backgroundImage!!
+        assertTrue(upper.contentInsetBottomPx + lower.contentInsetTopPx <= lineGapPx + 0.001f)
+        assertEquals(lineGapPx / 2f, upper.contentInsetBottomPx, 0.001f)
+        assertEquals(lineGapPx / 2f, lower.contentInsetTopPx, 0.001f)
+        assertEquals(upper.contentInsetBottomPx, lines[0].first().backgroundFrameBottomPx, 0.001f)
+        assertEquals(lower.contentInsetTopPx, lines[1].first().backgroundFrameTopPx, 0.001f)
+    }
+
+    /**
+     * 行距 1.0 ⇒ 上下边归零。旧 View 此时画「中心 + 左右两条边」:左右边仍是原图厚度,
+     * 且落在文字框外侧;文字照常内缩,不再出现把九格塞进文字框、压住首末字的情形。
+     */
+    @Test
+    fun nineSliceWithoutALineGapKeepsTheSideEdgesOutsideTheTextRect() {
+        val framedStyle = style.copy(
+            backgroundImage = ReaderTextBackgroundImage(
+                "frame.png", 3, 1f,
+                contentInsetLeftPx = 3f,
+                contentInsetRightPx = 4f,
+                contentInsetTopPx = 4f,
+                contentInsetBottomPx = 6f,
+            )
+        )
+        val page = ReaderPaginator.paginateBlocks(
+            listOf(
+                ReaderMeasuredBlock.InlineParagraph(
+                    items = listOf(ReaderMeasuredInlineItem.Text("字", 10f, framedStyle, 0)),
+                    indentCharacters = 0,
+                    alignment = ReaderTextAlignment.START,
+                    lineHeightPx = 20f,
+                    baselineOffsetPx = 15f,
+                    baseTextSizePx = 10f,
+                    lineSpacingMultiplier = 1f,
+                )
+            ),
             config.copy(viewportHeightPx = 100),
         ).single()
 
         val glyph = page.elements.single() as ReaderElement.Text
-        assertEquals(10f / 3f, glyph.backgroundFrameTopPx, 0.001f)
-        assertEquals(5f, glyph.backgroundFrameBottomPx, 0.001f)
-        val fittedImage = glyph.style.backgroundImage!!
-        assertEquals(2.5f, fittedImage.contentInsetLeftPx, 0.001f)
-        assertEquals(10f / 3f, fittedImage.contentInsetRightPx, 0.001f)
+        assertEquals(0f, glyph.backgroundFrameTopPx, 0f)
+        assertEquals(0f, glyph.backgroundFrameBottomPx, 0f)
+        assertEquals(3f, glyph.bounds.left, 0.001f)
         val run = page.textBackgroundRuns().single()
-        assertEquals(glyph.bounds.top, run.contentBounds.top, 0f)
-        assertEquals(glyph.bounds.bottom, run.contentBounds.bottom, 0f)
-        assertEquals(glyph.bounds.top - 10f / 3f, run.bounds.top, 0.001f)
-        assertEquals(glyph.bounds.bottom + 5f, run.bounds.bottom, 0.001f)
+        assertEquals(glyph.bounds.top, run.bounds.top, 0f)
+        assertEquals(glyph.bounds.bottom, run.bounds.bottom, 0f)
+        assertEquals(glyph.bounds.left - 3f, run.bounds.left, 0.001f)
+        assertEquals(glyph.bounds.right + 4f, run.bounds.right, 0.001f)
     }
 
     /**
@@ -603,10 +757,40 @@ class ReaderPaginatorTest {
         }
         val pages = ReaderPaginator.paginate(lines, scrollConfig)
         assertEquals(2, pages.size)
-        // 内容区高度 40f(45 − 5):中间页就是排版游标,章末页取「覆盖高度 / 内容区」的
-        // 较大者后再加 20f 留白(对照旧 TextChapterLayout 的 `height = durY + 20dp`)。
+        // 内容区高度 40f(45 − 5):中间页就是排版游标;章末页同样是游标(20f)加 20f 留白,
+        // 不向内容区高度收口(对照旧 TextChapterLayout 的 `height = durY + 20dp`)。
         assertEquals(40f, pages[0].scrollExtentPx, 0.01f)
-        assertEquals(60f, pages[1].scrollExtentPx, 0.01f)
+        assertEquals(40f, pages[1].scrollExtentPx, 0.01f)
+    }
+
+    /**
+     * 滚动模式章末残页只占自身内容高度 + [ReaderPaginationConfig.chapterEndPaddingPx]:下一章
+     * 正文紧接本章末尾出现,中间不会先顶满一屏空白。
+     *
+     * 旧 `TextChapterLayout.setTypeText` 收尾时 `textPage.height = durY + 20dp`(`durY` 是排版
+     * 游标),`ContentTextView.drawPage` 把下一页画在 `相对偏移 + textPage.height` 处;把章末页
+     * 收口到「内容区高度」会让残页后的空白撑满一屏,必须滚过整屏才接上下一章。
+     */
+    @Test
+    fun scrollModeChapterEndIsFollowedImmediatelyByTheNextChapterContent() {
+        val scrollConfig = config.copy(continuousScroll = true, chapterEndPaddingPx = 20f)
+        val chapterEnd = ReaderPaginator.paginate(listOf(paragraph("甲")), scrollConfig).single()
+        val nextChapter = ReaderPaginator.paginate(
+            listOf(paragraph("乙")),
+            scrollConfig.copy(chapterIndex = config.chapterIndex + 1),
+        ).single()
+
+        // 内容区高 40f(45 − 5),本章只有一行 20f:章末页页高 = 20f 内容 + 20f 留白。
+        assertEquals(40f, chapterEnd.scrollExtentPx, 0.01f)
+        val stackedGap = chapterEnd.scrollExtentPx +
+                nextChapter.elements.minOf { it.bounds.top } -
+                chapterEnd.elements.maxOf { it.bounds.bottom }
+        assertEquals(
+            "下一章首行与本章末行之间只应留 chapterEndPaddingPx",
+            scrollConfig.chapterEndPaddingPx,
+            stackedGap,
+            0.01f,
+        )
     }
 
     @Test

+ 40 - 0
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderSingleImageTest.kt

@@ -132,6 +132,46 @@ class ReaderSingleImageTest {
         )
     }
 
+    /**
+     * 单图样式的**正文页**恒为一屏:旧 `TextChapterLayout.setTypeText` 对每一行文本都做
+     * `if (textPage.height < visibleHeight) textPage.height = visibleHeight`
+     * (`isSingleImageStyle`),滚动模式下由此维持一屏一页。非单图样式仍是排版游标。
+     */
+    @Test fun scrollModeSingleImageStyleKeepsTextPagesOneScreenTall() = runBlocking {
+        val measured = measure("甲")
+
+        val singleImagePage = ReaderPaginator.paginateBlocks(
+            measured.blocks,
+            config.copy(continuousScroll = true, singleImageStyle = true),
+        ).single()
+        // 内容区高 200f(240 − 20 − 20):一行 10f 的正文页同样取一屏。
+        assertEquals(200f, singleImagePage.scrollExtentPx, 0f)
+
+        val plainPage = ReaderPaginator.paginateBlocks(
+            measured.blocks,
+            config.copy(continuousScroll = true),
+        ).single()
+        assertEquals(10f, plainPage.scrollExtentPx, 0f)
+    }
+
+    /**
+     * 单图样式的**图片页**不受该收口影响:旧 `setTypeImage` 的 `imgStyleSingle` 分支只把
+     * `durY` 移到竖直居中位置(`durY = (visibleHeight - height) / 2`),页高仍是排版游标
+     * `(visibleHeight + height) / 2`,不补满一屏。
+     */
+    @Test fun scrollModeSingleImageStyleKeepsImagePagesAtTheirContentCursor() = runBlocking {
+        val measured = measure("  <img src=\"a\">")
+
+        val page = ReaderPaginator.paginateBlocks(
+            measured.blocks,
+            config.copy(continuousScroll = true, singleImageStyle = true),
+        ).single()
+        val image = page.elements.single() as ReaderElement.Image
+        // 100×50 的图铺满 100f 内容宽、在 200f 高内容区竖直居中:页高 = (200 + 50) / 2 = 125f。
+        assertEquals(95f, image.bounds.top, 0f)
+        assertEquals(125f, page.scrollExtentPx, 0f)
+    }
+
     @Test fun inlineIconKeepsItsIndentPrefix() = runBlocking {
         val source = ReaderChapterSourceParser.parse(0, "", listOf("  <img src=\"icon\">甲"), false, false)
         val measured = ReaderChapterBlockMeasurer(shaper, shaper, { ReaderImageDimensions(10f, 10f) })

+ 9 - 7
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderVolumeTitlePageTest.kt

@@ -62,10 +62,11 @@ class ReaderVolumeTitlePageTest {
     }
 
     /**
-     * 连续滚动模式按 scrollExtentPx 堆叠相邻页(ScrollPageStack / ReaderPageViewportLayout),
-     * 短页的页高下限是「内容区高度」,因此卷名页无论是否居中都会撑满一屏;否则下一章正文
-     * 会压在本页卷名上。同见 `ReaderLongImageScrollTest
-     * .continuousPagesExcludeFixedViewportChromeFromTheirStackingExtent`。
+     * 连续滚动模式按 scrollExtentPx 堆叠相邻页(ScrollPageStack / ReaderPageViewportLayout)。
+     * 旧 `TextChapterLayout` 的卷名/空正文章把标题居中后按排版游标 `durY` 发布页高
+     * (`emptyContent && textPages.isEmpty()` 分支 + 收尾的 `height = durY + 20dp`),
+     * 所以页高只覆盖居中后的卷名本身:下一章正文紧接卷名下方排下去,不先顶满一屏空白。
+     * 居中位移必须计入页高,否则下一章正文会压在本页卷名上。
      */
     @Test
     fun scrollModeVerticallyCenteredTitleStaysInsidePageExtent() {
@@ -85,8 +86,9 @@ class ReaderVolumeTitlePageTest {
                     "${centered.contentTopPx + centered.scrollExtentPx}",
             centeredBottom <= centered.contentTopPx + centered.scrollExtentPx,
         )
-        // 内容区高度 = 200 − 10 − 20 = 170:短页一律取该下限,居中与否都不改变页高。
-        assertEquals(170f, centered.scrollExtentPx, 0.01f)
-        assertEquals(170f, topAnchored.scrollExtentPx, 0.01f)
+        // 居中后标题字形落在 [85, 105],页高 = 标题底边 − 内容区顶 = 105 − 10 = 95;
+        // 未居中时标题仍在 [15, 35],页高 = 排版游标 25f。两者都不是内容区高度 170f。
+        assertEquals(95f, centered.scrollExtentPx, 0.01f)
+        assertEquals(25f, topAnchored.scrollExtentPx, 0.01f)
     }
 }

+ 17 - 7
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayoutTest.kt

@@ -1,7 +1,6 @@
 package io.legado.app.feature.reader.core.model
 
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class ReaderNineSliceLayoutTest {
@@ -28,14 +27,25 @@ class ReaderNineSliceLayoutTest {
     }
 
     @Test
-    fun collapsedFrameRowsAreOmittedInsteadOfDrawingInvertedRects() {
-        val image = ReaderTextBackgroundImage("frame.png", 3, 1f)
-        val content = ReaderRect(0f, 0f, 20f, 10f)
+    fun withoutALineGapOnlyTheCenterAndTheSideCellsSurvive() {
+        // 无行距时上下边厚度为 0(对照旧 View TextLine.drawNineSliceFrames 里
+        // overflowScale = 0),上下两行连同四角一起消失,只剩中心 + 左右两条边。
+        val image = ReaderTextBackgroundImage(
+            "frame.png", 3, 1f,
+            contentInsetLeftPx = 7f,
+            contentInsetRightPx = 5f,
+        )
+        val content = ReaderRect(10f, 20f, 40f, 50f)
+        val frame = ReaderRect(3f, 20f, 45f, 50f)
 
-        val cells = ReaderNineSliceLayout.cells(10, 10, content, content, image)
+        val cells = ReaderNineSliceLayout.cells(10, 10, content, frame, image)
 
-        assertTrue(cells.all { it.destination.width > 0f && it.destination.height > 0f })
-        assertEquals(1, cells.size)
+        assertEquals(3, cells.size)
+        // 左右边保持原图厚度,且落在文字框外侧(不压在字上)。
+        assertEquals(ReaderRect(3f, 20f, 10f, 50f), cells.first().destination)
+        assertEquals(ReaderRect(40f, 20f, 45f, 50f), cells.last().destination)
+        assertEquals(content, cells[1].destination)
+        assertEquals(ReaderIntRect(1, 1, 9, 9), cells[1].source)
     }
 
     @Test

+ 25 - 0
app/src/test/java/io/legado/app/feature/reader/platform/ReaderPageDecorationDrawCacheTest.kt

@@ -61,6 +61,31 @@ class ReaderPageDecorationDrawCacheTest {
         assertNotSame(first, ReaderSvgPathCache.parse(data))
     }
 
+    @Test
+    fun halfHighlightIsSeparatedSoItCanBeDrawnBehindText() {
+        val style = ReaderTextStyle(
+            colorArgb = 0xff000000.toInt(),
+            fontSizePx = 16f,
+            underline = ReaderUnderline(7, 0x66ffd54f, 1f, 0f),
+        )
+        val cache = ReaderPageDecorationDrawCache.create(
+            page(
+                ReaderElement.Text(
+                    ReaderRect(0f, 0f, 10f, 10f),
+                    8f,
+                    "甲",
+                    style,
+                    false,
+                    false,
+                    chapterPosition = 0
+                )
+            )
+        )
+
+        assertEquals(1, cache.halfHighlights.size)
+        assertEquals(0, cache.styledUnderlines.size)
+    }
+
     @Test(timeout = 1_000) fun zeroDashAndWaveLengthsCannotStallDrawing() {
         val canvas = Canvas(Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888))
         val bounds = ReaderRect(0f, 0f, 10f, 10f)

+ 9 - 0
app/src/test/java/io/legado/app/utils/HtmlFormatterTest.kt

@@ -55,6 +55,15 @@ class HtmlFormatterTest {
         )
     }
 
+    @Test
+    fun textForWordCount_excludesImageMarkupAndNonReadableElements() {
+        val result = HtmlFormatter.textForWordCount(
+            "<p>正文</p><img src=\"https://example.com/cover.jpg\" alt=\"封面\"><script>ignored()</script><style>.hidden {}</style>"
+        )
+
+        assertEquals("正文", result)
+    }
+
     @Test
     fun formatSummaryText_dropsIndentAndLineBreaks() {
         assertEquals(