Przeglądaj źródła

feat: 增强章节编辑功能

HapeLee 1 miesiąc temu
rodzic
commit
4f257e5677

+ 135 - 0
app/src/main/java/io/legado/app/feature/reader/core/source/ReaderBodyOnlyFilter.kt

@@ -0,0 +1,135 @@
+package io.legado.app.feature.reader.core.source
+
+import io.legado.app.constant.AppPattern
+import io.legado.app.feature.reader.core.source.ReaderBodyOnlyFilter.reassemble
+import io.legado.app.feature.reader.core.source.ReaderBodyOnlyFilter.toBodyOnly
+
+/**
+ * 「仅显示正文」折叠器:把阅读器**不以文字呈现**的片段换成占位标记,供正文编辑弹层使用。
+ *
+ * 判据与 [ReaderChapterSourceParser] 完全一致——渲染器只把这三种片段当结构处理,其余源码一律
+ * 按字面文本显示,所以折叠它们不会隐藏用户真正读到的字:
+ *
+ * - 段内 `<img ...>`([AppPattern.imgPattern]):渲染成图片。源码可能是几 KB 的
+ *   `data:image/...;base64`,塞在编辑框里既不可读,光标一划就容易误删。
+ * - `<usehtml>...</usehtml>` 整段:渲染成交互 HTML 块,源码同样不可读。
+ * - `[newpage]` 整段:分页标记。
+ *
+ * 折叠只发生在**显示层**:原始片段按 [HiddenSpan.raw] 记住,[reassemble] 再按标记出现顺序
+ * 原样插回。因此
+ *
+ * - 编辑正文不会丢图片/HTML/分页标记,也不会改写标签内部字符;
+ * - 未被折叠的字符与换行位置就地保留(占位标记原地替换原片段),排版不被重排;
+ * - 用户若删掉或改坏某个标记,[reassemble] 仍按顺序把原始片段插回——最坏情况是位置漂移,
+ *   不会丢内容。
+ */
+object ReaderBodyOnlyFilter {
+
+    private const val USE_HTML_START = "<usehtml>"
+    private const val USE_HTML_END = "</usehtml>"
+    private const val PAGE_BREAK = "[newpage]"
+
+    /** 折叠片段的类型,[label] 直接出现在占位标记里(形如 `〔图片1〕`)。 */
+    enum class Kind(val label: String) {
+        Image("图片"),
+        Html("富文本"),
+        PageBreak("分页"),
+    }
+
+    /**
+     * 一段被折叠的原文。[index] 从 1 开始,标记里带序号,既避免重名又让 [reassemble]
+     * 能逐个定位(用户手打不出同样的字符串)。
+     */
+    data class HiddenSpan(
+        val index: Int,
+        val start: Int,
+        val end: Int,
+        val raw: String,
+        val kind: Kind,
+    ) {
+        val marker: String get() = "〔${kind.label}$index〕"
+    }
+
+    /**
+     * 按出现顺序扫描全部非正文片段([toBodyOnly] / [reassemble] 共用同一份结果)。
+     *
+     * [adaptSpecialStyle] 必须与渲染器同源(`ReadSettings.adaptSpecialStyle`):该开关关闭时
+     * `[newpage]`、`<usehtml>` 会按字面文字显示,此时只折叠 `<img>`,否则会藏掉用户看得见的字。
+     */
+    fun scan(text: String, adaptSpecialStyle: Boolean = true): List<HiddenSpan> {
+        val spans = mutableListOf<HiddenSpan>()
+        var lineStart = 0
+        while (lineStart <= text.length) {
+            val lineEnd = text.indexOf('\n', lineStart).let { if (it < 0) text.length else it }
+            val line = text.substring(lineStart, lineEnd)
+            val trimmed = line.trim()
+            when {
+                adaptSpecialStyle && trimmed == PAGE_BREAK ->
+                    spans += span(spans.size, lineStart, lineEnd, line, Kind.PageBreak)
+
+                adaptSpecialStyle && trimmed.startsWith(USE_HTML_START) && trimmed.endsWith(
+                    USE_HTML_END
+                ) ->
+                    spans += span(spans.size, lineStart, lineEnd, line, Kind.Html)
+
+                else -> AppPattern.imgPattern.findAll(line).forEach { match ->
+                    spans += span(
+                        spans.size,
+                        lineStart + match.range.first,
+                        lineStart + match.range.last + 1,
+                        match.value,
+                        Kind.Image,
+                    )
+                }
+            }
+            if (lineEnd >= text.length) break
+            lineStart = lineEnd + 1
+        }
+        return spans
+    }
+
+    /** 把 [spans] 换成占位标记;[spans] 必须由同一份 [text] 扫描得到。 */
+    fun toBodyOnly(text: String, spans: List<HiddenSpan> = scan(text)): String {
+        if (spans.isEmpty()) return text
+        val sb = StringBuilder(text.length)
+        var cursor = 0
+        for (span in spans) {
+            if (span.start < cursor || span.end > text.length) continue
+            sb.append(text, cursor, span.start)
+            sb.append(span.marker)
+            cursor = span.end
+        }
+        if (cursor < text.length) sb.append(text, cursor, text.length)
+        return sb.toString()
+    }
+
+    /** 按标记出现顺序把原始片段插回 [bodyOnlyText];标记缺失时顺序插回,不丢内容。 */
+    fun reassemble(bodyOnlyText: String, spans: List<HiddenSpan>): String {
+        if (spans.isEmpty()) return bodyOnlyText
+        val sb = StringBuilder(bodyOnlyText.length + spans.sumOf { it.raw.length })
+        var cursor = 0
+        for (span in spans) {
+            val found = bodyOnlyText.indexOf(span.marker, cursor)
+            val at = if (found >= 0) found else bodyOnlyText.length
+            sb.append(bodyOnlyText, cursor, at)
+            sb.append(span.raw)
+            cursor = if (found >= 0) at + span.marker.length else bodyOnlyText.length
+        }
+        if (cursor < bodyOnlyText.length) sb.append(bodyOnlyText, cursor, bodyOnlyText.length)
+        return sb.toString()
+    }
+
+    /** 原文光标位置 → 折叠后文本的光标位置(折叠掉的字符换成标记长度)。 */
+    fun toBodyOnlyOffset(rawOffset: Int, spans: List<HiddenSpan>): Int {
+        var offset = rawOffset
+        for (span in spans) {
+            if (span.start >= rawOffset) break
+            val hidden = (minOf(span.end, rawOffset) - span.start).coerceAtLeast(0)
+            offset += span.marker.length - hidden
+        }
+        return offset.coerceAtLeast(0)
+    }
+
+    private fun span(index: Int, start: Int, end: Int, raw: String, kind: Kind) =
+        HiddenSpan(index + 1, start, end, raw, kind)
+}

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

@@ -12,11 +12,13 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.help.book.BookHelp.saveText
+import io.legado.app.help.book.BookHelp.saveToLocalTxt
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.TextFile
-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
@@ -220,6 +222,16 @@ object BookHelp {
         }
     }
 
+    /**
+     * 保存章名。[BookChapter] 是 Room 实体,就地改名后 update,与 [saveText] 里
+     * `wordCount` 的写法一致。本地 TXT 的源文件标题由随后的 [saveText] →
+     * [saveToLocalTxt] 写入(它用 `bookChapter.title` 当段首标题)。
+     */
+    fun saveChapterTitle(bookChapter: BookChapter, title: String) {
+        bookChapter.title = title
+        appDb.bookChapterDao.update(bookChapter)
+    }
+
     private fun saveToLocalTxt(book: Book, bookChapter: BookChapter, content: String) {
         val start = bookChapter.start ?: return
         val end = bookChapter.end ?: return

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

@@ -568,9 +568,15 @@ sealed interface ReadBookIntent {
     // Content edit
     data object OpenContentEdit : ReadBookIntent
     data object LoadContentEdit : ReadBookIntent
-    data class SaveContentEdit(val content: String, val saveToSource: Boolean) : ReadBookIntent
+    data class SaveContentEdit(
+        val content: String,
+        val saveToSource: Boolean,
+        val chapterTitle: String,
+    ) : ReadBookIntent
     data object ResetContentEdit : ReadBookIntent
     data class SetContentEditText(val text: String) : ReadBookIntent
+    data class SetContentEditTitle(val title: String) : ReadBookIntent
+    data class SetContentEditBodyOnly(val enabled: Boolean) : ReadBookIntent
     data class SetContentEditSaveToSource(val value: Boolean) : ReadBookIntent
 
     // Tools

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

@@ -1055,9 +1055,12 @@ class ReadBookViewModel(
             is ReadBookIntent.OpenContentEdit -> contentEditDelegate.open()
             is ReadBookIntent.LoadContentEdit -> contentEditDelegate.load()
             is ReadBookIntent.SaveContentEdit ->
-                contentEditDelegate.save(intent.content, intent.saveToSource)
+                contentEditDelegate.save(intent.content, intent.saveToSource, intent.chapterTitle)
             is ReadBookIntent.ResetContentEdit -> contentEditDelegate.reset()
             is ReadBookIntent.SetContentEditText -> contentEditDelegate.setText(intent.text)
+            is ReadBookIntent.SetContentEditTitle -> contentEditDelegate.setTitle(intent.title)
+            is ReadBookIntent.SetContentEditBodyOnly ->
+                contentEditDelegate.setBodyOnly(intent.enabled)
             is ReadBookIntent.SetContentEditSaveToSource ->
                 contentEditDelegate.setSaveToSource(intent.value)
             is ReadBookIntent.RefreshImage -> refreshImage(intent.src)

+ 5 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadContentEditContract.kt

@@ -10,8 +10,12 @@ import androidx.compose.runtime.Stable
 @Stable
 data class ContentEditUiState(
     val loading: Boolean = false,
+    /** 编辑器正文;[bodyOnly] 打开时非正文片段已折叠成 `〔图片1〕` 这类占位标记。 */
     val text: String = "",
-    val title: String = "",
+    /** 章名编辑框:`chapters.title` 原值。原样回写,避免把转换后的显示名写进库里。 */
+    val chapterTitle: String = "",
+    /** 仅显示正文:图片/富文本/分页标记以占位符显示,保存时原样写回。 */
+    val bodyOnly: Boolean = false,
     val cursorOffset: Int = 0,
     val isLocalTxt: Boolean = false,
     val saveToSource: Boolean = false,

+ 107 - 20
app/src/main/java/io/legado/app/ui/book/read/ReadContentEditDelegate.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.read
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.feature.reader.core.navigation.ReaderPageContext
+import io.legado.app.feature.reader.core.source.ReaderBodyOnlyFilter
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
@@ -17,13 +18,21 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 
 /**
- * 阅读页正文编辑域:打开编辑弹层、载入当前章正文、保存、还原。
+ * 阅读页正文编辑域:打开编辑弹层、载入当前章正文与章名、保存、还原。
  *
  * 自持 [ContentEditUiState];章节读取走 [Host](理由同 [ReadAiDelegate]——
  * 不让 DAO 直连从 `legacyDaoInjectionBaseline` 洗进宽松的 `legacyUiDaoAccessBaseline`)。
  *
  * `execute {}` 是 `BaseViewModel` 的成员,这里换成它的实现体
  * `Coroutine.async(scope, Dispatchers.IO)`,默认参数一致,语义不变。
+ *
+ * 正文有两条文本线:
+ *
+ * - [rawText]:ContentProcessor 处理后的完整正文(含 `<img>`、`<usehtml>`、`[newpage]`),
+ *   写回缓存的就是这一份。
+ * - [ContentEditUiState.text]:编辑器里显示的那一份。「仅显示正文」打开时由
+ *   [ReaderBodyOnlyFilter] 把非正文片段折叠成占位标记,保存时再按标记原样插回,
+ *   因此折叠与否都不会改动图片标签、富文本源码、分页标记和其它字符。
  */
 class ReadContentEditDelegate(
     private val scope: CoroutineScope,
@@ -45,18 +54,35 @@ class ReadContentEditDelegate(
     private var pendingCursorOffset: Int? = null
     private var pendingAnchor: String? = null
 
+    /** 完整正文(写回缓存的那一份)。 */
+    private var rawText: String = ""
+
+    /** [rawText] 里的非正文片段,按出现顺序;保存时按标记插回。 */
+    private var hiddenSpans: List<ReaderBodyOnlyFilter.HiddenSpan> = emptyList()
+
+    /** [ReadBook.durChapterPos] 一类原文坐标的光标位置;折叠后按标记长度换算。 */
+    private var rawCursorOffset: Int = 0
+
     fun open() {
         pendingCursorOffset = currentOffset()
         pendingAnchor = currentAnchor()
         host.setActiveSheet(ReadBookSheet.ContentEdit)
     }
 
-    /** 关闭弹层时清空正文缓冲,避免下次开弹层闪上一章内容。 */
+    /**
+     * 关闭弹层时清空正文缓冲,避免下次开弹层闪上一章内容。
+     *
+     * 保存路径不受这里影响:[save] 在调用线程就把折叠文本换算成了完整正文,
+     * 协程里不再读这些字段。
+     */
     fun onSheetDismissed() {
+        rawText = ""
+        hiddenSpans = emptyList()
+        rawCursorOffset = 0
         _uiState.update {
             it.copy(
                 text = "",
-                title = "",
+                chapterTitle = "",
                 cursorOffset = 0,
                 loading = false,
                 saveToSource = false,
@@ -64,10 +90,31 @@ class ReadContentEditDelegate(
         }
     }
 
+    /**
+     * 编辑器正文变化;[text] 是当前显示文本(「仅显示正文」打开时已折叠非正文)。
+     */
     fun setText(text: String) {
+        rawText = toRawText(text)
         _uiState.update { it.copy(text = text) }
     }
 
+    fun setTitle(title: String) {
+        _uiState.update { it.copy(chapterTitle = title) }
+    }
+
+    /**
+     * 切换「仅显示正文」。打开时按当前 [rawText] 重新扫描非正文片段
+     * (用户可能在未折叠状态下手写过 `<img>`),保证显示与插回用的是同一套片段。
+     */
+    fun setBodyOnly(enabled: Boolean) {
+        if (enabled == _uiState.value.bodyOnly) return
+        if (enabled) {
+            hiddenSpans = scanNonBody(rawText)
+        }
+        _uiState.update { it.copy(bodyOnly = enabled) }
+        publishRaw()
+    }
+
     fun setSaveToSource(value: Boolean) {
         _uiState.update { it.copy(saveToSource = value) }
     }
@@ -78,33 +125,38 @@ class ReadContentEditDelegate(
             val book = ReadBook.book ?: return@async
             val chapter = host.findChapter(book.bookUrl, ReadBook.durChapterIndex)
                 ?: return@async
-            val title = chapter.getDisplayTitle(
-                chineseConverterType = readSettingsRepository.currentSettings.chineseConverterType
-            )
             val contentProcessor = ContentProcessor.get(book.name, book.origin)
             val rawContent = BookHelp.getContent(book, chapter) ?: return@async
             val text = contentProcessor.getContent(book, chapter, rawContent, includeTitle = false)
                 .toString()
-            val cursorOffset = resolveCursorOffset(text)
             _uiState.update {
                 it.copy(
-                    text = text,
-                    title = title,
-                    cursorOffset = cursorOffset,
+                    chapterTitle = chapter.title,
                     isLocalTxt = book.isLocalTxt,
                 )
             }
+            adopt(text)
         }.onFinally {
             _uiState.update { it.copy(loading = false) }
         }
     }
 
-    fun save(content: String, saveToSource: Boolean) {
+    fun save(content: String, saveToSource: Boolean, chapterTitle: String) {
+        // 「折叠显示 → 完整正文」的换算必须在**调用线程当场**做完:点保存后弹层立刻 dismiss,
+        // [onSheetDismissed] 会同步清空 rawText/hiddenSpans,而 `Coroutine.async` 的 body 是
+        // 派发到 IO 才跑的。放到协程里换算就会读到空片段表,把带 `〔图片1〕` 的显示文本
+        // 直接写回缓存(图片被占位符顶掉)。
+        val text = toRawText(content)
         Coroutine.async(scope, Dispatchers.IO) {
             val book = ReadBook.book ?: return@async
             val chapter = host.findChapter(book.bookUrl, ReadBook.durChapterIndex)
                 ?: return@async
-            BookHelp.saveText(book, chapter, content, saveToSource)
+            val title = chapterTitle.trim()
+            if (title.isNotEmpty() && title != chapter.title) {
+                // 章名先写:本地 TXT 的写回(saveText → saveToLocalTxt)用 chapter.title 当段首标题
+                BookHelp.saveChapterTitle(chapter, title)
+            }
+            BookHelp.saveText(book, chapter, text, saveToSource)
             ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
         }
     }
@@ -129,20 +181,55 @@ class ReadContentEditDelegate(
             } else {
                 ""
             }
-            val cursorOffset = resolveCursorOffset(text)
-            _uiState.update {
-                it.copy(
-                    text = text,
-                    cursorOffset = cursorOffset,
-                    loading = false,
-                )
-            }
+            adopt(text)
+            _uiState.update { it.copy(loading = false) }
             ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
         }.onError {
             _uiState.update { it.copy(loading = false) }
         }
     }
 
+    /**
+     * 采纳一份新正文:重扫非正文片段、定位光标,再按当前开关发布显示文本。
+     * 光标坐标来自 [ReaderPageContext],与 [rawText] 同一坐标系,折叠后再换算。
+     */
+    private fun adopt(text: String) {
+        rawText = text
+        hiddenSpans = scanNonBody(text)
+        rawCursorOffset = resolveCursorOffset(text)
+        publishRaw()
+    }
+
+    /** 非正文判据与渲染器同源:`adaptSpecialStyle` 关闭时 `[newpage]`/`<usehtml>` 是可见文字。 */
+    private fun scanNonBody(text: String): List<ReaderBodyOnlyFilter.HiddenSpan> =
+        ReaderBodyOnlyFilter.scan(text, readSettingsRepository.currentSettings.adaptSpecialStyle)
+
+    private fun publishRaw() {
+        val bodyOnly = _uiState.value.bodyOnly
+        val text = if (bodyOnly) {
+            ReaderBodyOnlyFilter.toBodyOnly(rawText, hiddenSpans)
+        } else {
+            rawText
+        }
+        val cursorOffset = if (bodyOnly) {
+            ReaderBodyOnlyFilter.toBodyOnlyOffset(rawCursorOffset, hiddenSpans)
+        } else {
+            rawCursorOffset
+        }
+        _uiState.update { it.copy(text = text, cursorOffset = cursorOffset) }
+    }
+
+    /**
+     * 编辑器文本 → 完整正文。折叠态下按占位标记把非正文片段原样插回;若文本里还带着某个片段的
+     * 原始源码(切换开关瞬间编辑器回吐旧值),说明它不是折叠文本,按原文收下——否则
+     * [ReaderBodyOnlyFilter.reassemble] 找不到标记会把这些片段再插一遍。
+     */
+    private fun toRawText(text: String): String {
+        if (!_uiState.value.bodyOnly) return text
+        if (hiddenSpans.any { it.raw.isNotEmpty() && text.contains(it.raw) }) return text
+        return ReaderBodyOnlyFilter.reassemble(text, hiddenSpans)
+    }
+
     // --- 光标定位:优先用打开弹层那一刻的可见首行,其次用锚点文本 ---
 
     private fun currentPage(): ReaderPageContext? = host.currentCanvasPage

+ 49 - 11
app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt

@@ -10,6 +10,10 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.text.input.TextFieldState
 import androidx.compose.foundation.text.input.setTextAndSelectAll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Restore
+import androidx.compose.material.icons.filled.Save
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -17,22 +21,22 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Restore
-import androidx.compose.material.icons.filled.Save
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ContentEditUiState
+import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.checkBox.AppCheckbox
+import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
+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.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
@@ -48,6 +52,7 @@ fun ContentEditSheet(
     val editorScrollState = rememberScrollState()
     var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
     var pendingLocateOffset by remember { mutableStateOf<Int?>(null) }
+    var menuExpanded by remember { mutableStateOf(false) }
 
     LaunchedEffect(show) {
         if (!show) return@LaunchedEffect
@@ -97,13 +102,36 @@ fun ContentEditSheet(
     AppModalBottomSheet(
         show = show,
         onDismissRequest = onDismissRequest,
-        title = state.title,
+        title = stringResource(R.string.edit_chapter),
         startAction = {
-            MediumTonalButton(
-                onClick = { onIntent(ReadBookIntent.ResetContentEdit) },
-                icon = Icons.Default.Restore,
-                contentDescription = stringResource(R.string.reset),
-            )
+            Box {
+                MediumTonalButton(
+                    onClick = { menuExpanded = true },
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = stringResource(R.string.more_menu),
+                )
+                RoundDropdownMenu(
+                    expanded = menuExpanded,
+                    onDismissRequest = { menuExpanded = false },
+                ) { dismiss ->
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.content_edit_reset),
+                        leadingIcon = { MenuItemIcon(Icons.Default.Restore) },
+                        onClick = {
+                            dismiss()
+                            onIntent(ReadBookIntent.ResetContentEdit)
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.content_edit_body_only),
+                        isSelected = state.bodyOnly,
+                        onClick = {
+                            dismiss()
+                            onIntent(ReadBookIntent.SetContentEditBodyOnly(!state.bodyOnly))
+                        },
+                    )
+                }
+            }
         },
         endAction = {
             MediumTonalButton(
@@ -112,6 +140,7 @@ fun ContentEditSheet(
                         ReadBookIntent.SaveContentEdit(
                             editorState.text.toString(),
                             state.saveToSource,
+                            state.chapterTitle,
                         )
                     )
                     onDismissRequest()
@@ -126,6 +155,15 @@ fun ContentEditSheet(
                 .fillMaxWidth()
                 .padding(bottom = 16.dp),
         ) {
+            AppTextField(
+                value = state.chapterTitle,
+                onValueChange = { onIntent(ReadBookIntent.SetContentEditTitle(it)) },
+                label = stringResource(R.string.content_edit_title),
+                singleLine = true,
+                enabled = !state.loading,
+                modifier = Modifier.fillMaxWidth(),
+            )
+
             if (state.loading) {
                 Box(
                     modifier = Modifier

+ 4 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -890,6 +890,10 @@
     <string name="sample_text">示例文本</string>
     <string name="sure_delete">确定要删除吗?</string>
     <string name="save_to_source">保存到源文件,保存后重置功能将无效</string>
+    <string name="edit_chapter">编辑章节</string>
+    <string name="content_edit_reset">还原</string>
+    <string name="content_edit_title">章节标题</string>
+    <string name="content_edit_body_only">仅显示正文</string>
     <string name="add_regex_rule">添加正则规则</string>
     <string name="custom_regex_rule">自定义正则规则</string>
     <string name="custom_regex">自定义规则</string>

+ 4 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2192,6 +2192,10 @@
     <string name="save_success">已儲存</string>
     <string name="save_to_local">儲存到本機</string>
     <string name="save_to_source">儲存到源檔案(重置後將不可用)</string>
+    <string name="edit_chapter">編輯章節</string>
+    <string name="content_edit_reset">還原</string>
+    <string name="content_edit_title">章節標題</string>
+    <string name="content_edit_body_only">僅顯示正文</string>
     <string name="saved_to_album">已儲存到相簿</string>
     <string name="scope_hint">指定此規則的作用範圍</string>
     <string name="screen_landscape_reversed">反向橫屏</string>

+ 4 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1657,6 +1657,10 @@
     <string name="sample_text">範例文字</string>
     <string name="sure_delete">確定要刪除嗎?</string>
     <string name="save_to_source">儲存到來源檔案(重設將不可用)</string>
+    <string name="edit_chapter">編輯章節</string>
+    <string name="content_edit_reset">還原</string>
+    <string name="content_edit_title">章節標題</string>
+    <string name="content_edit_body_only">僅顯示正文</string>
     <string name="add_regex_rule">新增正則規則</string>
     <string name="custom_regex_rule">自訂正則規則</string>
     <string name="custom_regex">自訂規則</string>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -921,6 +921,10 @@
     <string name="sample_text">Sample Text</string>
     <string name="sure_delete">Are you sure you want to delete?</string>
     <string name="save_to_source">Save to source file (reset will be unavailable)</string>
+    <string name="edit_chapter">Edit chapter</string>
+    <string name="content_edit_reset">Restore</string>
+    <string name="content_edit_title">Chapter title</string>
+    <string name="content_edit_body_only">Body text only</string>
     <string name="add_regex_rule">Add Regex Rule</string>
     <string name="custom_regex_rule">Custom Regex Rule</string>
     <string name="custom_regex">Custom Rule</string>

+ 82 - 0
app/src/test/java/io/legado/app/feature/reader/core/source/ReaderBodyOnlyFilterTest.kt

@@ -0,0 +1,82 @@
+package io.legado.app.feature.reader.core.source
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderBodyOnlyFilterTest {
+
+    private val imageTag = "<img src=\"data:image/png;base64,AAAA\">"
+
+    @Test
+    fun inlineImageIsFoldedInPlace() {
+        assertEquals(
+            "正文前〔图片1〕正文后",
+            ReaderBodyOnlyFilter.toBodyOnly("正文前${imageTag}正文后"),
+        )
+    }
+
+    @Test
+    fun useHtmlParagraphAndPageBreakBecomeWholeLineMarkers() {
+        val text = "第一段\n<usehtml><div>hello</div></usehtml>\n[newpage]\n第二段"
+        val spans = ReaderBodyOnlyFilter.scan(text)
+        assertEquals(
+            listOf(ReaderBodyOnlyFilter.Kind.Html, ReaderBodyOnlyFilter.Kind.PageBreak),
+            spans.map { it.kind },
+        )
+        assertEquals(
+            "第一段\n〔富文本1〕\n〔分页2〕\n第二段",
+            ReaderBodyOnlyFilter.toBodyOnly(text, spans),
+        )
+    }
+
+    @Test
+    fun withoutAdaptSpecialStyleHtmlStaysVisible() {
+        val text = "第一段\n<usehtml><div>hello</div></usehtml>\n[newpage]"
+        assertTrue(ReaderBodyOnlyFilter.scan(text, adaptSpecialStyle = false).isEmpty())
+        assertEquals(text, ReaderBodyOnlyFilter.toBodyOnly(text, emptyList()))
+    }
+
+    @Test
+    fun plainTextIsUntouched() {
+        val text = "第一段\n第二段"
+        assertTrue(ReaderBodyOnlyFilter.scan(text).isEmpty())
+        assertEquals(text, ReaderBodyOnlyFilter.toBodyOnly(text))
+    }
+
+    @Test
+    fun reassembleWithoutEditsRestoresOriginalText() {
+        val text = "正文前${imageTag}正文后\n<usehtml>x</usehtml>\n[newpage]\n尾"
+        val spans = ReaderBodyOnlyFilter.scan(text)
+        val folded = ReaderBodyOnlyFilter.toBodyOnly(text, spans)
+        assertEquals(text, ReaderBodyOnlyFilter.reassemble(folded, spans))
+    }
+
+    @Test
+    fun reassembleKeepsNonBodySourceAfterBodyEdit() {
+        val text = "第一行\n${imageTag}\n第二行"
+        val spans = ReaderBodyOnlyFilter.scan(text)
+        assertEquals(
+            "改过的第一行\n${imageTag}\n第二行加字",
+            ReaderBodyOnlyFilter.reassemble("改过的第一行\n〔图片1〕\n第二行加字", spans),
+        )
+    }
+
+    @Test
+    fun deletedMarkerStillWritesBackSourceInOrder() {
+        val text = "第一行\n${imageTag}\n第二行"
+        val spans = ReaderBodyOnlyFilter.scan(text)
+        val restored = ReaderBodyOnlyFilter.reassemble("第一行\n第二行", spans)
+        assertTrue(restored.contains(imageTag))
+        assertEquals("第一行\n第二行$imageTag", restored)
+    }
+
+    @Test
+    fun bodyOnlyOffsetFollowsFoldedCharacters() {
+        val text = "abc${imageTag}def"
+        val spans = ReaderBodyOnlyFilter.scan(text)
+        assertEquals(3, ReaderBodyOnlyFilter.toBodyOnlyOffset(3, spans))
+        assertEquals(11, ReaderBodyOnlyFilter.toBodyOnlyOffset(text.length, spans))
+        assertEquals("abc〔图片1〕def", ReaderBodyOnlyFilter.toBodyOnly(text, spans))
+    }
+}