Sfoglia il codice sorgente

Merge branch 'main' of https://github.com/HapeLee/legado-with-MD3

HapeLee 4 mesi fa
parent
commit
24b2c5a2ef
35 ha cambiato i file con 1322 aggiunte e 600 eliminazioni
  1. 1 0
      app/build.gradle.kts
  2. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 81 50
      app/src/main/java/io/legado/app/model/ReadBook.kt
  4. 19 0
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  5. 2 2
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  6. 4 1
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  7. 55 5
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  8. 41 9
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
  9. 192 30
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  10. 29 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  11. 7 5
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 0 305
      app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt
  13. 312 0
      app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt
  14. 2 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt
  15. 2 49
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt
  16. 2 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt
  17. 176 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/TextSelectMenuConfigSheet.kt
  18. 2 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt
  19. 3 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  20. 5 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadMenuConfig.kt
  21. 7 1
      app/src/main/java/io/legado/app/ui/dict/DictContract.kt
  22. 132 17
      app/src/main/java/io/legado/app/ui/dict/DictSheet.kt
  23. 65 18
      app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt
  24. 45 0
      app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
  25. 9 2
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt
  26. 77 0
      app/src/main/java/io/legado/app/ui/widget/components/pager/PagerHeight.kt
  27. 0 55
      app/src/main/res/layout/popup_action_menu.xml
  28. 0 51
      app/src/main/res/menu/content_select_action.xml
  29. 2 0
      app/src/main/res/values-zh-rCN/strings.xml
  30. 2 0
      app/src/main/res/values-zh-rHK/strings.xml
  31. 2 0
      app/src/main/res/values-zh-rTW/strings.xml
  32. 11 0
      app/src/main/res/values/ids.xml
  33. 2 0
      app/src/main/res/values/strings.xml
  34. 30 0
      app/src/test/java/io/legado/app/ui/book/manage/BookshelfManageScreenExportConfigTest.kt
  35. 2 0
      gradle/libs.versions.toml

+ 1 - 0
app/build.gradle.kts

@@ -173,6 +173,7 @@ dependencies {
     "baselineProfile"(project(":baselineprofile"))
     "baselineProfile"(project(":baselineprofile"))
     coreLibraryDesugaring(libs.desugar)
     coreLibraryDesugaring(libs.desugar)
     testImplementation(libs.junit)
     testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
     androidTestImplementation(libs.bundles.androidTest)
     androidTestImplementation(libs.bundles.androidTest)
     implementation(libs.kotlin.stdlib)
     implementation(libs.kotlin.stdlib)
     implementation(libs.kotlinx.collections.immutable)
     implementation(libs.kotlinx.collections.immutable)

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

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

+ 81 - 50
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -94,6 +94,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private val curChapterLoadingLock = Mutex()
     private val curChapterLoadingLock = Mutex()
     private val nextChapterLoadingLock = Mutex()
     private val nextChapterLoadingLock = Mutex()
     var readStartTime: Long = System.currentTimeMillis()
     var readStartTime: Long = System.currentTimeMillis()
+    var isUiActive = false
+    val isAutoSaveSessionRunning: Boolean
+        get() = autoSaveJob != null
+
 
 
     /* 跳转进度前进度记录 */
     /* 跳转进度前进度记录 */
     var lastBookProgress: BookProgress? = null
     var lastBookProgress: BookProgress? = null
@@ -316,69 +320,88 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
         }
     }
     }
 
 
-    fun initReadTime() {
-        val currentBookName = book?.name ?: return
-        val currentBookAuthor = book?.author ?: ""
-        if (currentActiveSession != null &&
-            (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
-        ) {
-            commitReadSession()
+    fun startReadSession() {
+        synchronized(this) {
+            readStartTime = System.currentTimeMillis()
+            initReadTime()
+            startAutoSaveSession()
         }
         }
+    }
 
 
-        if (currentActiveSession == null) {
-            lastReadLength = currentReadLength
-            currentActiveSession = ReadRecordSession(
-                deviceId = "",
-                bookName = currentBookName,
-                bookAuthor = currentBookAuthor,
-                startTime = readStartTime,
-                endTime = readStartTime,
-                words = durChapterIndex.toLong()
-            )
+    fun initReadTime() {
+        synchronized(this) {
+            val currentBookName = book?.name ?: return
+            val currentBookAuthor = book?.author ?: ""
+            if (currentActiveSession != null &&
+                (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
+            ) {
+                commitReadSession()
+            }
+
+            if (currentActiveSession == null) {
+                lastReadLength = currentReadLength
+                currentActiveSession = ReadRecordSession(
+                    deviceId = "",
+                    bookName = currentBookName,
+                    bookAuthor = currentBookAuthor,
+                    startTime = readStartTime,
+                    endTime = readStartTime,
+                    words = durChapterIndex.toLong()
+                )
+            }
         }
         }
     }
     }
 
 
     fun upReadTime() {
     fun upReadTime() {
-        val currentLength = currentReadLength
-        val currentBookName = book?.name ?: return
-        val currentBookAuthor = book?.author ?: ""
-        val endTime = System.currentTimeMillis()
-
-        if (currentActiveSession == null ||
-            currentActiveSession!!.bookName != currentBookName ||
-            currentActiveSession!!.bookAuthor != currentBookAuthor
-        ) {
-            initReadTime()
-            return
-        }
+        synchronized(this) {
+            val currentLength = currentReadLength
+            val currentBookName = book?.name ?: return
+            val currentBookAuthor = book?.author ?: ""
+            val endTime = System.currentTimeMillis()
+
+            if (currentActiveSession == null ||
+                currentActiveSession!!.bookName != currentBookName ||
+                currentActiveSession!!.bookAuthor != currentBookAuthor
+            ) {
+                initReadTime()
+                return
+            }
 
 
-        currentActiveSession = currentActiveSession!!.copy(
-            endTime = endTime,
-            words = durChapterIndex.toLong()
-        )
+            currentActiveSession = currentActiveSession!!.copy(
+                endTime = endTime,
+                words = durChapterIndex.toLong()
+            )
 
 
-        readStartTime = endTime
-        lastReadLength = currentLength
+            readStartTime = endTime
+            lastReadLength = currentLength
+        }
     }
     }
 
 
     fun startAutoSaveSession() {
     fun startAutoSaveSession() {
-        autoSaveJob?.cancel()
-        autoSaveJob = ioScope.launch {
-            while (isActive) {
-                delay(AUTO_SAVE_INTERVAL)
-                commitSessionInternal()
+        synchronized(this) {
+            autoSaveJob?.cancel()
+            autoSaveJob = ioScope.launch {
+                while (isActive) {
+                    delay(AUTO_SAVE_INTERVAL)
+                    commitSessionInternal()
+                }
             }
             }
         }
         }
     }
     }
 
 
     fun stopAutoSaveSession() {
     fun stopAutoSaveSession() {
-        autoSaveJob?.cancel()
-        autoSaveJob = null
+        synchronized(this) {
+            autoSaveJob?.cancel()
+            autoSaveJob = null
+        }
     }
     }
 
 
     fun commitReadSession() {
     fun commitReadSession() {
-        val sessionToCommit = currentActiveSession ?: return
-        currentActiveSession = null
+        val sessionToCommit = synchronized(this) {
+            val session = currentActiveSession
+            currentActiveSession = null
+            session
+        } ?: return
         ioScope.launch {
         ioScope.launch {
             saveSessionToDb(sessionToCommit)
             saveSessionToDb(sessionToCommit)
         }
         }
@@ -388,7 +411,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      * 内部提交逻辑(auto-save 专用):保存后立即创建新 session 保证连续记录
      * 内部提交逻辑(auto-save 专用):保存后立即创建新 session 保证连续记录
      */
      */
     private suspend fun commitSessionInternal() {
     private suspend fun commitSessionInternal() {
-        val sessionToSave = currentActiveSession ?: return
+        val sessionToSave = synchronized(this) { currentActiveSession } ?: return
         val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
         val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
         if (sessionDuration < MIN_READ_DURATION) {
         if (sessionDuration < MIN_READ_DURATION) {
             return
             return
@@ -400,11 +423,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             return
             return
         }
         }
         // 保存成功后立即创建新 session,避免 auto-save 空窗期
         // 保存成功后立即创建新 session,避免 auto-save 空窗期
-        currentActiveSession = sessionToSave.copy(
-            startTime = sessionToSave.endTime,
-            endTime = sessionToSave.endTime,
-            words = durChapterIndex.toLong()
-        )
+        synchronized(this) {
+            val current = currentActiveSession
+            if (current != null &&
+                current.bookName == sessionToSave.bookName &&
+                current.bookAuthor == sessionToSave.bookAuthor
+            ) {
+                currentActiveSession = current.copy(
+                    startTime = sessionToSave.endTime,
+                    endTime = sessionToSave.endTime,
+                    words = durChapterIndex.toLong()
+                )
+            }
+        }
     }
     }
 
 
     /**
     /**

+ 19 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -188,6 +188,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
     }
 
 
     override fun onDestroy() {
     override fun onDestroy() {
+        ReadBook.upReadTime()
         super.onDestroy()
         super.onDestroy()
         if (useWakeLock) {
         if (useWakeLock) {
             wakeLock.release()
             wakeLock.release()
@@ -203,6 +204,10 @@ abstract class BaseReadAloudService : BaseService(),
         mediaSessionCompat.release()
         mediaSessionCompat.release()
         ReadBook.uploadProgress()
         ReadBook.uploadProgress()
         unregisterPhoneStateListener(phoneStateListener)
         unregisterPhoneStateListener(phoneStateListener)
+        if (!ReadBook.isUiActive) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
         upNotificationJob?.invokeOnCompletion {
         upNotificationJob?.invokeOnCompletion {
             notificationManager.cancel(NotificationId.ReadAloudService)
             notificationManager.cancel(NotificationId.ReadAloudService)
         }
         }
@@ -288,12 +293,16 @@ abstract class BaseReadAloudService : BaseService(),
         needResumeOnCallStateIdle = false
         needResumeOnCallStateIdle = false
         upReadAloudNotification()
         upReadAloudNotification()
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
+        if (!ReadBook.isAutoSaveSessionRunning) {
+            ReadBook.startReadSession()
+        }
     }
     }
 
 
     abstract fun playStop()
     abstract fun playStop()
 
 
     @CallSuper
     @CallSuper
     open fun pauseReadAloud(abandonFocus: Boolean = true) {
     open fun pauseReadAloud(abandonFocus: Boolean = true) {
+        ReadBook.upReadTime()
         if (useWakeLock) {
         if (useWakeLock) {
             wakeLock.release()
             wakeLock.release()
             wifiLock?.release()
             wifiLock?.release()
@@ -307,6 +316,10 @@ abstract class BaseReadAloudService : BaseService(),
         postEvent(EventBus.ALOUD_STATE, Status.PAUSE)
         postEvent(EventBus.ALOUD_STATE, Status.PAUSE)
         ReadBook.uploadProgress()
         ReadBook.uploadProgress()
         doDs()
         doDs()
+        if (!ReadBook.isUiActive) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
     }
     }
 
 
     @SuppressLint("WakelockTimeout")
     @SuppressLint("WakelockTimeout")
@@ -322,15 +335,20 @@ abstract class BaseReadAloudService : BaseService(),
         upReadAloudNotification()
         upReadAloudNotification()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
+        if (!ReadBook.isAutoSaveSessionRunning) {
+            ReadBook.startReadSession()
+        }
     }
     }
 
 
     abstract fun upSpeechRate(reset: Boolean = false)
     abstract fun upSpeechRate(reset: Boolean = false)
 
 
     fun upTtsProgress(progress: Int) {
     fun upTtsProgress(progress: Int) {
+        ReadBook.upReadTime()
         postEvent(EventBus.TTS_PROGRESS, progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
     }
     }
 
 
     private fun prevP() {
     private fun prevP() {
+        ReadBook.upReadTime()
         if (nowSpeak > 0) {
         if (nowSpeak > 0) {
             playStop()
             playStop()
             do {
             do {
@@ -706,6 +724,7 @@ abstract class BaseReadAloudService : BaseService(),
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
 
 
     open fun prevChapter() {
     open fun prevChapter() {
+        ReadBook.upReadTime()
         toLast = false
         toLast = false
         resumeReadAloudInternal()
         resumeReadAloudInternal()
         ReadBook.moveToPrevChapter(true, toLast = false)
         ReadBook.moveToPrevChapter(true, toLast = false)

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -295,7 +295,7 @@ private fun BookshelfManageScreen(
         }
         }
         val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
         val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
         val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
         val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
-        if (state.exportConfig.enableCustomExport) {
+        if (state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = dirPath
             customExportPath = dirPath
             customExportBook = book
             customExportBook = book
             customExportAllChapter = false
             customExportAllChapter = false
@@ -336,7 +336,7 @@ private fun BookshelfManageScreen(
         val path = ACache.get().getAsString(exportBookPathKey)
         val path = ACache.get().getAsString(exportBookPathKey)
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
             selectExportFolder(book.bookUrl)
             selectExportFolder(book.bookUrl)
-        } else if (state.exportConfig.enableCustomExport) {
+        } else if (state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = path
             customExportPath = path
             customExportBook = book
             customExportBook = book
             customExportAllChapter = false
             customExportAllChapter = false

+ 4 - 1
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -60,7 +60,10 @@ data class BookshelfManageScreenExportConfig(
     val exportCharset: String = "UTF-8",
     val exportCharset: String = "UTF-8",
     val bookExportFileName: String? = null,
     val bookExportFileName: String? = null,
     val episodeExportFileName: String = ""
     val episodeExportFileName: String = ""
-)
+) {
+    val isCustomEpubExportEnabled: Boolean
+        get() = enableCustomExport && exportType == 1
+}
 
 
 data class BookshelfManageScreenUiState(
 data class BookshelfManageScreenUiState(
     val groupId: Long = -1,
     val groupId: Long = -1,

+ 55 - 5
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -76,6 +76,7 @@ import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
+import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.widget.number.NumberPickerDialog
 import io.legado.app.ui.widget.number.NumberPickerDialog
@@ -140,6 +141,8 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     }
     }
 
 
     private var justInitData: Boolean = false
     private var justInitData: Boolean = false
+    private var isExitActionRunning = false
+    private var isRestoredFromSavedState = false
     private var syncDialog: AlertDialog? = null
     private var syncDialog: AlertDialog? = null
     private val handler by lazy { buildMainHandler() }
     private val handler by lazy { buildMainHandler() }
     private val eyeProtectionScheduler by lazy {
     private val eyeProtectionScheduler by lazy {
@@ -199,6 +202,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     }
     }
 
 
     override fun onCreate(savedInstanceState: Bundle?) {
     override fun onCreate(savedInstanceState: Bundle?) {
+        isRestoredFromSavedState = savedInstanceState != null
         setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         val transform = MaterialContainerTransform().apply {
         val transform = MaterialContainerTransform().apply {
@@ -235,15 +239,61 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             GSON.fromJsonObject<MangaFooterConfig>(ReadMangaConfig.mangaFooterConfig).getOrNull()
             GSON.fromJsonObject<MangaFooterConfig>(ReadMangaConfig.mangaFooterConfig).getOrNull()
                 ?: MangaFooterConfig()
                 ?: MangaFooterConfig()
 
 
-        onBackPressedDispatcher.addCallback(this){
-            if (savedInstanceState != null || !ReadManga.inBookshelf) {
-                finish()
-            } else {
-                supportFinishAfterTransition()
+        onBackPressedDispatcher.addCallback(this) {
+            requestReaderExit()
+        }
+    }
+
+    private fun requestReaderExit() {
+        if (isExitActionRunning) return
+        val book = ReadManga.book
+        if (book == null || ReadManga.inBookshelf) {
+            finishReader()
+            return
+        }
+        if (!OtherConfig.showAddToShelfAlert) {
+            discardCurrentNotShelfBook()
+            return
+        }
+        alert(title = getString(R.string.add_to_bookshelf)) {
+            setCancelable(false)
+            setMessage(getString(R.string.check_add_bookshelf, book.name))
+            okButton {
+                addCurrentBookToBookshelf()
+            }
+            noButton {
+                discardCurrentNotShelfBook()
             }
             }
         }
         }
     }
     }
 
 
+    private fun addCurrentBookToBookshelf() {
+        isExitActionRunning = true
+        viewModel.addCurrentBookToBookshelf(
+            success = {
+                setResult(RESULT_OK)
+                finishReader()
+            },
+            onFailure = { isExitActionRunning = false }
+        )
+    }
+
+    private fun discardCurrentNotShelfBook() {
+        isExitActionRunning = true
+        viewModel.removeCurrentNotShelfBook(
+            success = ::finishReader,
+            onFailure = { isExitActionRunning = false }
+        )
+    }
+
+    private fun finishReader() {
+        if (ReadManga.inBookshelf && !isRestoredFromSavedState) {
+            supportFinishAfterTransition()
+        } else {
+            finish()
+        }
+    }
+
     override fun observeLiveBus() {
     override fun observeLiveBus() {
         observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
         observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
             mMangaFooterConfig = it
             mMangaFooterConfig = it

+ 41 - 9
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt

@@ -313,12 +313,23 @@ class ReadMangaViewModel(
         }
         }
     }
     }
 
 
-    fun removeFromBookshelf(success: (() -> Unit)?) {
+    fun removeCurrentNotShelfBook(
+        success: () -> Unit,
+        onFailure: () -> Unit = {},
+    ) {
         val book = ReadManga.book
         val book = ReadManga.book
+        val bookUrl = book?.bookUrl
         Coroutine.async {
         Coroutine.async {
             book?.delete()
             book?.delete()
         }.onSuccess {
         }.onSuccess {
-            success?.invoke()
+            if (ReadManga.book?.bookUrl == bookUrl) {
+                ReadManga.book = null
+            }
+            success()
+        }.onError {
+            AppLog.put("移除临时漫画书籍失败", it)
+            context.toastOnUi("移除临时漫画书籍失败")
+            onFailure()
         }
         }
     }
     }
 
 
@@ -393,15 +404,27 @@ class ReadMangaViewModel(
         }
         }
     }
     }
 
 
-    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
+    fun addCurrentBookToBookshelf(
+        success: () -> Unit,
+        onFailure: () -> Unit = {},
+    ) {
+        val book = ReadManga.book ?: return onFailure()
         execute {
         execute {
-            book.removeType(BookType.notShelf)
-            if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
-            }
+            val toc = appDb.bookChapterDao.getChapterList(book.bookUrl)
+            persistOnBookshelf(book, toc)
+            ReadManga.inBookshelf = true
+        }.onSuccess {
+            success()
+        }.onError {
+            AppLog.put("添加书籍到书架失败", it)
+            context.toastOnUi("添加书籍失败")
+            onFailure()
+        }
+    }
 
 
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
+        execute {
+            persistOnBookshelf(book, toc)
         }.onSuccess {
         }.onSuccess {
             success?.invoke()
             success?.invoke()
         }.onError {
         }.onError {
@@ -409,4 +432,13 @@ class ReadMangaViewModel(
             context.toastOnUi("添加书籍失败")
             context.toastOnUi("添加书籍失败")
         }
         }
     }
     }
+
+    private suspend fun persistOnBookshelf(book: Book, toc: List<BookChapter>) {
+        book.removeType(BookType.notShelf)
+        if (book.order == 0) {
+            book.order = appDb.bookDao.minOrder - 1
+        }
+        appDb.bookDao.insert(book)
+        appDb.bookChapterDao.insert(*toc.toTypedArray())
+    }
 }
 }

+ 192 - 30
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -60,10 +60,21 @@ import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.throttle
 import io.legado.app.utils.throttle
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.visible
 import io.legado.app.utils.visible
+import io.legado.app.utils.sendToClip
+import io.legado.app.utils.share
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.printOnDebug
+import android.app.SearchManager
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 
 
+
 /**
 /**
  * Encapsulates all the reader logic that used to be in ReadBookActivity.
  * Encapsulates all the reader logic that used to be in ReadBookActivity.
  * This allows ReadBookRouteScreen to be hosted in any Activity (ReadBookActivity or MainActivity).
  * This allows ReadBookRouteScreen to be hosted in any Activity (ReadBookActivity or MainActivity).
@@ -74,8 +85,7 @@ class ReadBookController(
 ) : ReadBookRouteHost,
 ) : ReadBookRouteHost,
     ReadBookInputHandler,
     ReadBookInputHandler,
     ReadView.CallBack,
     ReadView.CallBack,
-    ContentTextView.CallBack,
-    TextActionMenu.CallBack {
+    ContentTextView.CallBack {
 
 
     var refs: ReadBookViewRefs? = null
     var refs: ReadBookViewRefs? = null
 
 
@@ -106,12 +116,11 @@ class ReadBookController(
     private val networkChangedListener by lazy { NetworkChangedListener(activity) }
     private val networkChangedListener by lazy { NetworkChangedListener(activity) }
     private val handler by lazy { buildMainHandler() }
     private val handler by lazy { buildMainHandler() }
     private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
     private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
-    private val textActionMenu by lazy {
-        TextActionMenu(
-            context = activity,
-            callBack = this,
-            expandTextMenu = { viewModel.readPreferences.value.expandTextMenu }
-        )
+    private val _textMenuState = MutableStateFlow<TextMenuState?>(null)
+    val textMenuState = _textMenuState.asStateFlow()
+
+    fun dismissTextActionMenu() {
+        _textMenuState.value = null
     }
     }
     private val popupAction by lazy { PopupAction(activity) }
     private val popupAction by lazy { PopupAction(activity) }
     private var screenTimeOut: Long = 0
     private var screenTimeOut: Long = 0
@@ -134,7 +143,7 @@ class ReadBookController(
     fun clearTts() {
     fun clearTts() {
         tts?.clearTts()
         tts?.clearTts()
         tts = null
         tts = null
-        textActionMenu.dismiss()
+        dismissTextActionMenu()
         popupAction.dismiss()
         popupAction.dismiss()
         refs?.readView?.onDestroy()
         refs?.readView?.onDestroy()
         networkChangedListener.unRegister()
         networkChangedListener.unRegister()
@@ -285,21 +294,14 @@ class ReadBookController(
 
 
     override fun showTextActionMenu() {
     override fun showTextActionMenu() {
         val r = refs ?: return
         val r = refs ?: return
-        val navigationBarHeight =
-            if (!ReadBookConfig.hideNavigationBar && activity.navigationBarGravity == Gravity.BOTTOM) {
-                r.navigationBar.height
-            } else {
-                0
-            }
-        textActionMenu.upMenu()
-        textActionMenu.show(
-            r.textMenuPosition,
-            r.root.height + navigationBarHeight,
-            r.textMenuPosition.x.toInt(),
-            r.textMenuPosition.y.toInt(),
-            r.cursorLeft.y.toInt() + r.cursorLeft.height,
-            r.cursorRight.x.toInt(),
-            r.cursorRight.y.toInt() + r.cursorRight.height
+        _textMenuState.value = TextMenuState(
+            selectedText = selectedText,
+            startX = r.textMenuPosition.x.toInt(),
+            startTopY = r.textMenuPosition.y.toInt(),
+            startBottomY = r.cursorLeft.y.toInt() + r.cursorLeft.height,
+            endX = r.cursorRight.x.toInt(),
+            endBottomY = r.cursorRight.y.toInt() + r.cursorRight.height,
+            items = getActionMenuItems()
         )
         )
     }
     }
 
 
@@ -415,7 +417,7 @@ class ReadBookController(
     override fun onCancelSelect() {
     override fun onCancelSelect() {
         refs?.cursorLeft?.invisible()
         refs?.cursorLeft?.invisible()
         refs?.cursorRight?.invisible()
         refs?.cursorRight?.invisible()
-        textActionMenu.dismiss()
+        dismissTextActionMenu()
     }
     }
 
 
     override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean =
     override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean =
@@ -505,7 +507,7 @@ class ReadBookController(
             return false
             return false
         }
         }
         when (event.action) {
         when (event.action) {
-            MotionEvent.ACTION_DOWN -> textActionMenu.dismiss()
+            MotionEvent.ACTION_DOWN -> dismissTextActionMenu()
             MotionEvent.ACTION_MOVE -> {
             MotionEvent.ACTION_MOVE -> {
                 when (v.id) {
                 when (v.id) {
                     R.id.cursor_left -> if (!r.readView.curPage.getReverseStartCursor()) {
                     R.id.cursor_left -> if (!r.readView.curPage.getReverseStartCursor()) {
@@ -542,9 +544,9 @@ class ReadBookController(
         return true
         return true
     }
     }
 
 
-    override val selectedText: String get() = refs?.readView?.getSelectText().orEmpty()
+    val selectedText: String get() = refs?.readView?.getSelectText().orEmpty()
 
 
-    override fun onMenuItemSelected(itemId: Int): Boolean {
+    fun onMenuItemSelected(itemId: Int): Boolean {
         when (itemId) {
         when (itemId) {
             R.id.menu_aloud -> {
             R.id.menu_aloud -> {
                 viewModel.onIntent(
                 viewModel.onIntent(
@@ -612,11 +614,127 @@ class ReadBookController(
         return false
         return false
     }
     }
 
 
-    override fun onMenuActionFinally() {
-        textActionMenu.dismiss()
+    fun onMenuActionFinally() {
+        dismissTextActionMenu()
         refs?.readView?.cancelSelect()
         refs?.readView?.cancelSelect()
     }
     }
 
 
+    fun getActionMenuItems(): List<ActionMenuItem> {
+        val items = mutableListOf<ActionMenuItem>()
+        items.add(ActionMenuItem(R.id.menu_copy, activity.getString(android.R.string.copy)))
+        items.add(ActionMenuItem(R.id.menu_share_str, activity.getString(R.string.share)))
+        items.add(ActionMenuItem(R.id.menu_browser, activity.getString(R.string.browser)))
+        items.add(ActionMenuItem(R.id.menu_aloud, activity.getString(R.string.read_aloud)))
+        items.add(ActionMenuItem(R.id.menu_bookmark, activity.getString(R.string.bookmark)))
+        items.add(ActionMenuItem(R.id.menu_dict, activity.getString(R.string.dict)))
+        items.add(ActionMenuItem(R.id.menu_replace, activity.getString(R.string.replace)))
+        items.add(ActionMenuItem(R.id.menu_edit, activity.getString(R.string.edit)))
+        items.add(ActionMenuItem(R.id.menu_ai_clean, activity.getString(R.string.ai_text_clean)))
+        items.add(ActionMenuItem(R.id.menu_ai_rewrite, activity.getString(R.string.ai_text_rewrite)))
+        items.add(ActionMenuItem(R.id.menu_search_content, activity.getString(R.string.search_content)))
+
+        val thirdPartyItems = mutableListOf<ActionMenuItem>()
+        kotlin.runCatching {
+            val pm = activity.packageManager
+            val filterSet = ReadConfig.textSelectMenuFilter.split(",").filter { it.isNotEmpty() }.toSet()
+            val intent = Intent().setAction(Intent.ACTION_PROCESS_TEXT).setType("text/plain")
+            val resolveInfos = pm.queryIntentActivities(intent, 0)
+            for (resolveInfo in resolveInfos) {
+                val componentName = "${resolveInfo.activityInfo.packageName}/${resolveInfo.activityInfo.name}"
+                if (filterSet.contains(componentName)) {
+                    continue
+                }
+                val processIntent = Intent()
+                    .setAction(Intent.ACTION_PROCESS_TEXT)
+                    .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false)
+                    .setClassName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name)
+                
+                val title = resolveInfo.loadLabel(pm).toString()
+                val icon = if (ReadConfig.showSelectMenuIcon) {
+                    kotlin.runCatching { resolveInfo.loadIcon(pm) }.getOrNull()
+                } else null
+
+                thirdPartyItems.add(ActionMenuItem(id = -1, title = title, iconDrawable = icon, intent = processIntent))
+            }
+        }
+
+        val allItems = items + thirdPartyItems
+        val configStr = ReadConfig.textSelectMenuConfig
+        if (configStr.isEmpty()) {
+            return allItems
+        }
+
+        return kotlin.runCatching {
+            val savedConfigs = GSON.fromJsonObject<List<SelectionMenuConfigItem>>(configStr).getOrNull() ?: emptyList()
+            val savedMap = savedConfigs.associateBy { it.id }
+
+            val sortedItems = mutableListOf<ActionMenuItem>()
+            for (saved in savedConfigs) {
+                val found = allItems.find { it.uniqueId == saved.id }
+                if (found != null) {
+                    sortedItems.add(found.copy(enabled = saved.enabled))
+                }
+            }
+            for (item in allItems) {
+                val uniqueId = item.uniqueId
+                if (!savedMap.containsKey(uniqueId)) {
+                    sortedItems.add(item)
+                }
+            }
+            sortedItems
+        }.getOrDefault(allItems)
+    }
+
+    fun saveMenuConfig(items: List<ActionMenuItem>) {
+        val configs = items.map { item ->
+            SelectionMenuConfigItem(
+                id = item.uniqueId,
+                enabled = item.enabled
+            )
+        }
+        ReadConfig.textSelectMenuConfig = GSON.toJson(configs)
+        _textMenuState.value?.let { currentState ->
+            _textMenuState.value = currentState.copy(items = getActionMenuItems())
+        }
+    }
+
+    fun onTextMenuItemClick(item: ActionMenuItem) {
+        if (item.intent != null) {
+            kotlin.runCatching {
+                item.intent.putExtra(Intent.EXTRA_PROCESS_TEXT, selectedText)
+                activity.startActivity(item.intent)
+            }.onFailure { e ->
+                AppLog.put("执行文本菜单操作出错\n$e", e, true)
+            }
+        } else {
+            when (item.id) {
+                R.id.menu_copy -> activity.sendToClip(selectedText)
+                R.id.menu_share_str -> activity.share(selectedText)
+                R.id.menu_browser -> {
+                    kotlin.runCatching {
+                        val intent = if (selectedText.isAbsUrl()) {
+                            Intent(Intent.ACTION_VIEW).apply {
+                                data = selectedText.toUri()
+                            }
+                        } else {
+                            Intent(Intent.ACTION_WEB_SEARCH).apply {
+                                putExtra(SearchManager.QUERY, selectedText)
+                            }
+                        }
+                        activity.startActivity(intent)
+                    }.onFailure { e ->
+                        e.printOnDebug()
+                        activity.toastOnUi(e.localizedMessage ?: "ERROR")
+                    }
+                }
+                else -> {
+                    onMenuItemSelected(item.id)
+                }
+            }
+        }
+        onMenuActionFinally()
+    }
+
     // ── Effect handling ───────────────────────────────────────────────
     // ── Effect handling ───────────────────────────────────────────────
 
 
     /**
     /**
@@ -1246,3 +1364,47 @@ class ReadBookController(
         }
         }
     }
     }
 }
 }
+
+data class TextMenuState(
+    val selectedText: String,
+    val startX: Int,
+    val startTopY: Int,
+    val startBottomY: Int,
+    val endX: Int,
+    val endBottomY: Int,
+    val items: List<ActionMenuItem>
+)
+
+data class ActionMenuItem(
+    val id: Int,
+    val title: String,
+    val iconDrawable: android.graphics.drawable.Drawable? = null,
+    val intent: Intent? = null,
+    val enabled: Boolean = true
+) {
+    val uniqueId: String
+        get() = if (intent != null) {
+            val comp = intent.component
+            if (comp != null) "${comp.packageName}/${comp.className}" else title
+        } else {
+            when (id) {
+                R.id.menu_copy -> "menu_copy"
+                R.id.menu_share_str -> "menu_share_str"
+                R.id.menu_browser -> "menu_browser"
+                R.id.menu_aloud -> "menu_aloud"
+                R.id.menu_bookmark -> "menu_bookmark"
+                R.id.menu_dict -> "menu_dict"
+                R.id.menu_replace -> "menu_replace"
+                R.id.menu_edit -> "menu_edit"
+                R.id.menu_ai_clean -> "menu_ai_clean"
+                R.id.menu_ai_rewrite -> "menu_ai_rewrite"
+                R.id.menu_search_content -> "menu_search_content"
+                else -> id.toString()
+            }
+        }
+}
+
+data class SelectionMenuConfigItem(
+    val id: String,
+    val enabled: Boolean
+)

+ 29 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -49,6 +49,7 @@ import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.replace.ReplaceRuleActivity
+import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
@@ -113,6 +114,7 @@ fun ReadBookRouteScreen(
 ) {
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
+    val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
@@ -453,6 +455,8 @@ fun ReadBookRouteScreen(
 
 
     // ── View layer + Compose UI ───────────────────────────────────────
     // ── View layer + Compose UI ───────────────────────────────────────
 
 
+    var showSelectMenuConfigSheet by remember { mutableStateOf(false) }
+
     Box(Modifier.fillMaxSize()) {
     Box(Modifier.fillMaxSize()) {
         key(controller) {
         key(controller) {
             ReadBookViewLayer(
             ReadBookViewLayer(
@@ -482,6 +486,31 @@ fun ReadBookRouteScreen(
                 onIntent = viewModel::onIntent,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },
                 onBack = { controller.closeReadBook() },
             )
             )
+            textMenuState?.let { menuState ->
+                TextActionSelectionMenu(
+                    menuState = menuState,
+                    onDismiss = { controller.dismissTextActionMenu() },
+                    onItemClick = { item -> controller.onTextMenuItemClick(item) },
+                    onOpenManage = {
+                        controller.dismissTextActionMenu()
+                        controller.refs?.readView?.cancelSelect()
+                        showSelectMenuConfigSheet = true
+                    }
+                )
+            }
+            val configItems = remember(showSelectMenuConfigSheet) {
+                if (showSelectMenuConfigSheet) {
+                    controller.getActionMenuItems()
+                } else {
+                    emptyList()
+                }
+            }
+            TextSelectMenuConfigSheet(
+                show = showSelectMenuConfigSheet,
+                items = configItems,
+                onDismissRequest = { showSelectMenuConfigSheet = false },
+                onSaved = { items -> controller.saveMenuConfig(items) }
+            )
         }
         }
     }
     }
 }
 }

+ 7 - 5
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1345,9 +1345,8 @@ class ReadBookViewModel(
 
 
     private fun handleOnResume() {
     private fun handleOnResume() {
         // Read time tracking
         // Read time tracking
-        ReadBook.readStartTime = System.currentTimeMillis()
-        ReadBook.initReadTime()
-        ReadBook.startAutoSaveSession()
+        ReadBook.isUiActive = true
+        ReadBook.startReadSession()
 
 
         // Web book progress sync
         // Web book progress sync
         ReadBook.webBookProgress?.let {
         ReadBook.webBookProgress?.let {
@@ -1372,9 +1371,12 @@ class ReadBookViewModel(
         _effects.tryEmit(ReadBookEffect.StopAutoPage)
         _effects.tryEmit(ReadBookEffect.StopAutoPage)
 
 
         // Read time tracking
         // Read time tracking
+        ReadBook.isUiActive = false
         ReadBook.saveRead()
         ReadBook.saveRead()
-        ReadBook.stopAutoSaveSession()
-        ReadBook.commitReadSession()
+        if (!BaseReadAloudService.isPlay()) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
         ReadBook.cancelPreDownloadTask()
         ReadBook.cancelPreDownloadTask()
 
 
         // View-layer
         // View-layer

+ 0 - 305
app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt

@@ -1,305 +0,0 @@
-package io.legado.app.ui.book.read
-
-import android.annotation.SuppressLint
-import android.app.SearchManager
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ResolveInfo
-import android.view.Gravity
-import android.view.LayoutInflater
-import android.view.Menu
-import android.view.View
-import android.view.ViewGroup
-import android.widget.PopupWindow
-import androidx.appcompat.view.SupportMenuInflater
-import androidx.appcompat.view.menu.MenuBuilder
-import androidx.appcompat.view.menu.MenuItemImpl
-import androidx.core.net.toUri
-import androidx.core.view.isVisible
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.AppLog
-import io.legado.app.databinding.ItemTextBinding
-import io.legado.app.databinding.PopupActionMenuBinding
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.gone
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.share
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.visible
-
-@SuppressLint("RestrictedApi")
-class TextActionMenu(
-    private val context: Context,
-    private val callBack: CallBack,
-    private val expandTextMenu: () -> Boolean
-) :
-    PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
-
-    private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context))
-    private val adapter = Adapter(context).apply {
-        setHasStableIds(true)
-    }
-
-    private var menuItems: List<MenuItemImpl> = emptyList()
-    private val visibleMenuItems = arrayListOf<MenuItemImpl>()
-    private val moreMenuItems = arrayListOf<MenuItemImpl>()
-    private val myMenu = MenuBuilder(context)
-
-    init {
-        @SuppressLint("InflateParams")
-        contentView = binding.root
-
-        isTouchable = true
-        isOutsideTouchable = false
-        isFocusable = false
-        animationStyle = R.style.TextActionMenuAnimation
-
-        SupportMenuInflater(context).inflate(R.menu.content_select_action, myMenu)
-        binding.recyclerView.adapter = adapter
-        binding.recyclerViewMore.adapter = adapter
-        setOnDismissListener {
-            if (!expandTextMenu()) {
-                binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
-                binding.recyclerViewMore.gone()
-                adapter.setItems(visibleMenuItems)
-                binding.recyclerView.visible()
-            }
-        }
-        binding.ivMenuMore.setOnClickListener {
-            if (binding.recyclerView.isVisible) {
-                binding.ivMenuMore.setImageResource(R.drawable.ic_arrow_back)
-                adapter.setItems(moreMenuItems)
-                binding.recyclerView.gone()
-                binding.recyclerViewMore.visible()
-            } else {
-                binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
-                binding.recyclerViewMore.gone()
-                adapter.setItems(visibleMenuItems)
-                binding.recyclerView.visible()
-            }
-        }
-        upMenu()
-    }
-
-    fun upMenu() {
-        val otherMenu = MenuBuilder(context)
-        onInitializeMenu(otherMenu)
-        menuItems = myMenu.visibleItems + otherMenu.visibleItems
-        visibleMenuItems.clear()
-        moreMenuItems.clear()
-        if (menuItems.size > 6) {
-            visibleMenuItems.addAll(menuItems.subList(0, 6))
-            moreMenuItems.addAll(menuItems.subList(6, menuItems.size))
-        } else {
-            visibleMenuItems.addAll(menuItems)
-        }
-
-        if (expandTextMenu()) {
-            adapter.setItems(menuItems)
-            binding.ivMenuMore.gone()
-        } else {
-            adapter.setItems(visibleMenuItems)
-            binding.ivMenuMore.visible()
-        }
-    }
-
-    fun show(
-        view: View,
-        windowHeight: Int,
-        startX: Int,
-        startTopY: Int,
-        startBottomY: Int,
-        endX: Int,
-        endBottomY: Int
-    ) {
-        if (expandTextMenu()) {
-            when {
-                startTopY > 500 -> {
-                    showAtLocation(
-                        view, Gravity.BOTTOM or Gravity.START, startX, windowHeight - startTopY
-                    )
-                }
-
-                endBottomY - startBottomY > 500 -> {
-                    showAtLocation(view, Gravity.TOP or Gravity.START, startX, startBottomY)
-                }
-
-                else -> {
-                    showAtLocation(view, Gravity.TOP or Gravity.START, endX, endBottomY)
-                }
-            }
-        } else {
-            contentView.measure(
-                View.MeasureSpec.UNSPECIFIED,
-                View.MeasureSpec.UNSPECIFIED,
-            )
-            val popupHeight = contentView.measuredHeight
-            when {
-                startBottomY > 500 -> {
-                    showAtLocation(
-                        view, Gravity.TOP or Gravity.START, startX, startTopY - popupHeight
-                    )
-                }
-
-                endBottomY - startBottomY > 500 -> {
-                    showAtLocation(
-                        view, Gravity.TOP or Gravity.START, startX, startBottomY
-                    )
-                }
-
-                else -> {
-                    showAtLocation(
-                        view, Gravity.TOP or Gravity.START, endX, endBottomY
-                    )
-                }
-            }
-        }
-    }
-
-    inner class Adapter(context: Context) :
-        RecyclerAdapter<MenuItemImpl, ItemTextBinding>(context) {
-
-        override fun getItemId(position: Int): Long {
-            return position.toLong()
-        }
-
-        override fun getViewBinding(parent: ViewGroup): ItemTextBinding {
-            return ItemTextBinding.inflate(inflater, parent, false)
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: ItemTextBinding,
-            item: MenuItemImpl,
-            payloads: MutableList<Any>
-        ) {
-            with(binding) {
-                textView.text = item.title
-                val icon = item.icon
-                if (icon != null && ReadConfig.showSelectMenuIcon) {
-                    val size = 18.dpToPx()
-                    icon.setBounds(0, 0, size, size)
-                    textView.setCompoundDrawables(icon, null, null, null)
-                    textView.compoundDrawablePadding = 6.dpToPx()
-                } else {
-                    textView.setCompoundDrawables(null, null, null, null)
-                }
-            }
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) {
-            holder.itemView.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    if (!callBack.onMenuItemSelected(it.itemId)) {
-                        onMenuItemSelected(it)
-                    }
-                }
-                callBack.onMenuActionFinally()
-            }
-            holder.itemView.setOnLongClickListener {
-                if (ReadConfig.contentSelectSpeakMod == 0) {
-                    ReadConfig.contentSelectSpeakMod = 1
-                    context.toastOnUi("切换为从选择的地方开始一直朗读")
-                } else {
-                    ReadConfig.contentSelectSpeakMod = 0
-                    context.toastOnUi("切换为朗读选择内容")
-                }
-                true
-            }
-        }
-    }
-
-    private fun onMenuItemSelected(item: MenuItemImpl) {
-        when (item.itemId) {
-            R.id.menu_copy -> context.sendToClip(callBack.selectedText)
-            R.id.menu_share_str -> context.share(callBack.selectedText)
-            R.id.menu_browser -> {
-                kotlin.runCatching {
-                    val intent = if (callBack.selectedText.isAbsUrl()) {
-                        Intent(Intent.ACTION_VIEW).apply {
-                            data = callBack.selectedText.toUri()
-                        }
-                    } else {
-                        Intent(Intent.ACTION_WEB_SEARCH).apply {
-                            putExtra(SearchManager.QUERY, callBack.selectedText)
-                        }
-                    }
-                    context.startActivity(intent)
-                }.onFailure {
-                    it.printOnDebug()
-                    context.toastOnUi(it.localizedMessage ?: "ERROR")
-                }
-            }
-
-            else -> item.intent?.let {
-                kotlin.runCatching {
-                    it.putExtra(Intent.EXTRA_PROCESS_TEXT, callBack.selectedText)
-                    context.startActivity(it)
-                }.onFailure { e ->
-                    AppLog.put("执行文本菜单操作出错\n$e", e, true)
-                }
-            }
-        }
-    }
-
-    private fun createProcessTextIntent(): Intent {
-        return Intent()
-            .setAction(Intent.ACTION_PROCESS_TEXT)
-            .setType("text/plain")
-    }
-
-    private fun getSupportedActivities(): List<ResolveInfo> {
-        return context.packageManager
-            .queryIntentActivities(createProcessTextIntent(), 0)
-    }
-
-    private fun createProcessTextIntentForResolveInfo(info: ResolveInfo): Intent {
-        return createProcessTextIntent()
-            .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false)
-            .setClassName(info.activityInfo.packageName, info.activityInfo.name)
-    }
-
-    /**
-     * Start with a menu Item order value that is high enough
-     * so that your "PROCESS_TEXT" menu items appear after the
-     * standard selection menu items like Cut, Copy, Paste.
-     */
-    private fun onInitializeMenu(menu: Menu) {
-        kotlin.runCatching {
-            var menuItemOrder = 100
-            val pm = context.packageManager
-            val filterSet = ReadConfig.textSelectMenuFilter.split(",").filter { it.isNotEmpty() }.toSet()
-            for (resolveInfo in getSupportedActivities()) {
-                val componentName = "${resolveInfo.activityInfo.packageName}/${resolveInfo.activityInfo.name}"
-                if (filterSet.contains(componentName)) {
-                    continue
-                }
-                val menuItem = menu.add(
-                    Menu.NONE, Menu.NONE,
-                    menuItemOrder++, resolveInfo.loadLabel(pm)
-                )
-                menuItem.intent = createProcessTextIntentForResolveInfo(resolveInfo)
-                if (ReadConfig.showSelectMenuIcon) {
-                    menuItem.icon = kotlin.runCatching {
-                        resolveInfo.loadIcon(pm)
-                    }.getOrNull()
-                }
-            }
-        }.onFailure {
-            context.toastOnUi("获取文字操作菜单出错:${it.localizedMessage}")
-        }
-    }
-
-    interface CallBack {
-        val selectedText: String
-
-        fun onMenuItemSelected(itemId: Int): Boolean
-
-        fun onMenuActionFinally()
-    }
-}

+ 312 - 0
app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt

@@ -0,0 +1,312 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.R
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+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.filled.MoreHoriz
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+import coil.compose.AsyncImage
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.text.AppText
+
+enum class MenuMode {
+    Quick,
+    More
+}
+
+@Composable
+fun TextActionSelectionMenu(
+    menuState: TextMenuState,
+    onDismiss: () -> Unit,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onOpenManage: () -> Unit
+) {
+    var mode by remember { mutableStateOf(MenuMode.Quick) }
+    val draftItems = menuState.items
+
+    val density = LocalDensity.current.density
+    val positionProvider = remember(menuState, density) {
+        TextMenuPositionProvider(
+            density = density,
+            startX = menuState.startX,
+            startTopY = menuState.startTopY,
+            startBottomY = menuState.startBottomY,
+            endX = menuState.endX,
+            endBottomY = menuState.endBottomY
+        )
+    }
+
+    Popup(
+        popupPositionProvider = positionProvider,
+        onDismissRequest = onDismiss,
+        properties = PopupProperties(
+            focusable = false,
+            dismissOnBackPress = true,
+            dismissOnClickOutside = true
+        )
+    ) {
+        val widthModifier = Modifier.widthIn(max = 300.dp)
+
+        NormalCard(
+            modifier = widthModifier.wrapContentHeight(),
+            containerColor = LegadoTheme.colorScheme.surface,
+            elevation = 6.dp,
+            cornerRadius = 12.dp
+        ) {
+            if (mode == MenuMode.Quick) {
+                QuickMenuView(
+                    items = draftItems,
+                    onItemClick = onItemClick,
+                    onMoreClick = { mode = MenuMode.More }
+                )
+            } else {
+                MoreMenuView(
+                    items = draftItems,
+                    onItemClick = onItemClick,
+                    onBack = { mode = MenuMode.Quick },
+                    onManageClick = {
+                        onDismiss()
+                        onOpenManage()
+                    }
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun QuickMenuView(
+    items: List<ActionMenuItem>,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onMoreClick: () -> Unit
+) {
+    val enabledItems = remember(items) { items.filter { it.enabled } }
+
+    Row(
+        modifier = Modifier
+            .wrapContentWidth()
+            .padding(horizontal = 4.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        LazyRow(
+            modifier = Modifier
+                .weight(1f, fill = false)
+                .padding(vertical = 6.dp),
+            horizontalArrangement = Arrangement.spacedBy(4.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            items(enabledItems, key = { it.uniqueId }) { item ->
+                QuickMenuItem(item = item, onClick = { onItemClick(item) })
+            }
+        }
+        VerticalDivider(
+            color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+            modifier = Modifier
+                .height(20.dp)
+                .width(1.dp)
+        )
+        Box(
+            modifier = Modifier
+                .clickable(onClick = onMoreClick)
+                .padding(horizontal = 12.dp, vertical = 8.dp),
+            contentAlignment = Alignment.Center
+        ) {
+            Icon(
+                imageVector = Icons.Default.MoreHoriz,
+                contentDescription = stringResource(R.string.more_menu),
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(18.dp)
+            )
+        }
+    }
+}
+
+@Composable
+private fun QuickMenuItem(
+    item: ActionMenuItem,
+    onClick: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .clickable(onClick = onClick)
+            .padding(horizontal = 12.dp, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (item.iconDrawable != null) {
+            AsyncImage(
+                model = item.iconDrawable,
+                contentDescription = item.title,
+                modifier = Modifier
+                    .size(16.dp)
+                    .padding(end = 4.dp)
+            )
+        }
+        AppText(
+            text = item.title,
+            fontSize = 13.sp,
+            color = LegadoTheme.colorScheme.onSurface,
+            maxLines = 1
+        )
+    }
+}
+
+@Composable
+private fun MoreMenuView(
+    items: List<ActionMenuItem>,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onBack: () -> Unit,
+    onManageClick: () -> Unit
+) {
+    val disabledItems = remember(items) { items.filter { !it.enabled } }
+    val scrollState = rememberScrollState()
+
+    Column(
+        modifier = Modifier
+            .width(IntrinsicSize.Max)
+            .padding(vertical = 4.dp)
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clickable(onClick = onBack)
+                .padding(horizontal = 16.dp, vertical = 8.dp),
+            contentAlignment = Alignment.CenterStart
+        ) {
+            Icon(
+                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                contentDescription = stringResource(R.string.back),
+                tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier.size(18.dp)
+            )
+        }
+        
+        Column(
+            modifier = Modifier
+                .heightIn(max = 240.dp)
+                .verticalScroll(scrollState)
+                .fillMaxWidth()
+        ) {
+            disabledItems.forEach { item ->
+                MoreMenuItem(item = item, onClick = { onItemClick(item) })
+            }
+        }
+
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clickable(onClick = onManageClick)
+                .padding(horizontal = 16.dp, vertical = 10.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Icon(
+                imageVector = Icons.Default.Settings,
+                contentDescription = stringResource(R.string.edit_menu_items),
+                tint = LegadoTheme.colorScheme.primary,
+                modifier = Modifier
+                    .size(18.dp)
+                    .padding(end = 8.dp)
+            )
+            AppText(
+                text = stringResource(R.string.edit_menu_items),
+                fontSize = 13.sp,
+                color = LegadoTheme.colorScheme.primary,
+                fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
+            )
+        }
+    }
+}
+
+@Composable
+private fun MoreMenuItem(
+    item: ActionMenuItem,
+    onClick: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(horizontal = 16.dp, vertical = 10.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (item.iconDrawable != null) {
+            AsyncImage(
+                model = item.iconDrawable,
+                contentDescription = item.title,
+                modifier = Modifier
+                    .size(18.dp)
+                    .padding(end = 8.dp)
+            )
+        }
+        AppText(
+            text = item.title,
+            fontSize = 13.sp,
+            color = LegadoTheme.colorScheme.onSurface,
+            maxLines = 1
+        )
+    }
+}
+
+
+private class TextMenuPositionProvider(
+    private val density: Float,
+    private val startX: Int,
+    private val startTopY: Int,
+    private val startBottomY: Int,
+    private val endX: Int,
+    private val endBottomY: Int
+) : PopupPositionProvider {
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize
+    ): IntOffset {
+        val x: Int
+        val y: Int
+
+        val marginHorizontal = (16 * density).toInt()
+        val marginVertical = (12 * density).toInt()
+        val textMargin = (4 * density).toInt()
+
+        val isSpaceEnoughAtTop = startTopY > popupContentSize.height + textMargin + marginVertical
+        
+        if (isSpaceEnoughAtTop) {
+            x = startX
+            y = startTopY - popupContentSize.height - textMargin
+        } else if (windowSize.height - startBottomY > popupContentSize.height + textMargin + marginVertical) {
+            x = startX
+            y = startBottomY + textMargin
+        } else {
+            x = endX
+            y = endBottomY + textMargin
+        }
+
+        val finalX = x.coerceIn(marginHorizontal, (windowSize.width - popupContentSize.width - marginHorizontal).coerceAtLeast(marginHorizontal))
+        val finalY = y.coerceIn(marginVertical, (windowSize.height - popupContentSize.height - marginVertical).coerceAtLeast(marginVertical))
+
+        return IntOffset(finalX, finalY)
+    }
+}

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

@@ -54,6 +54,8 @@ import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem

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

@@ -11,7 +11,6 @@ import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateMapOf
@@ -22,11 +21,8 @@ import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.layout.layout
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadPreferences
@@ -34,6 +30,8 @@ import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookButtonConfigItem
 import io.legado.app.ui.book.read.ReadBookButtonConfigItem
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookStyleConfig
 import io.legado.app.ui.book.read.ReadBookStyleConfig
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.tabRow.CardTabRow
 import io.legado.app.ui.widget.components.tabRow.CardTabRow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
@@ -147,48 +145,3 @@ fun ReadStyleContent(
         )
         )
     }
     }
 }
 }
-
-internal fun Modifier.pagerHeight(height: Dp) = this.layout { measurable, constraints ->
-    val placeable = measurable.measure(constraints)
-    val layoutHeight = if (height != Dp.Unspecified) {
-        height.roundToPx().coerceIn(constraints.minHeight, constraints.maxHeight)
-    } else {
-        placeable.height
-    }
-    layout(placeable.width, layoutHeight) {
-        placeable.placeRelative(0, 0)
-    }
-}
-
-@Composable
-internal fun rememberPagerAnimatedHeight(
-    pagerState: androidx.compose.foundation.pager.PagerState,
-    pageHeights: Map<Int, Int>
-): androidx.compose.runtime.State<Dp> {
-    val density = LocalDensity.current
-    return remember(pagerState, density) {
-        derivedStateOf {
-            val pageCount = pagerState.pageCount
-            val position = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
-                .coerceIn(0f, (pageCount - 1).coerceAtLeast(0).toFloat())
-            val floorPage = position.toInt().coerceIn(0, pageCount - 1)
-            val ceilPage = (floorPage + 1).coerceIn(0, pageCount - 1)
-
-            val floorHeight = pageHeights[floorPage] ?: 0
-            val ceilHeight = pageHeights[ceilPage] ?: floorHeight
-
-            val fraction = position - floorPage
-
-            val startHeight = if (floorHeight > 0) floorHeight else (pageHeights.values.firstOrNull() ?: 0)
-            val endHeight = if (ceilHeight > 0) ceilHeight else startHeight
-
-            val interpolated = startHeight + (endHeight - startHeight) * fraction
-
-            if (interpolated > 0) {
-                with(density) { interpolated.toDp() }
-            } else {
-                Dp.Unspecified
-            }
-        }
-    }
-}

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

@@ -65,6 +65,8 @@ import io.legado.app.ui.book.read.ReadBookSheet
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.settingItem.TinyClearColorModeSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClearColorModeSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem

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

@@ -0,0 +1,176 @@
+package io.legado.app.ui.book.read.sheet
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.ui.book.read.ActionMenuItem
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TextSelectMenuConfigSheet(
+    show: Boolean,
+    items: List<ActionMenuItem>,
+    onDismissRequest: () -> Unit,
+    onSaved: (List<ActionMenuItem>) -> Unit
+) {
+    var draftItems by remember(show, items) {
+        mutableStateOf(items)
+    }
+
+    val lazyListState = rememberLazyListState()
+    val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
+        draftItems = draftItems.toMutableList().apply {
+            add(to.index, removeAt(from.index))
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.edit_select_menu),
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .fillMaxHeight(0.8f)
+                .padding(bottom = 16.dp),
+        ) {
+            LazyColumn(
+                state = lazyListState,
+                verticalArrangement = Arrangement.spacedBy(4.dp),
+                modifier = Modifier
+                    .weight(1f)
+                    .padding(horizontal = 16.dp),
+            ) {
+                items(draftItems, key = { it.uniqueId }) { item ->
+                    ReorderableItem(reorderableState, key = item.uniqueId) { isDragging ->
+                        val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
+                        NormalCard(
+                            elevation = elevation,
+                            cornerRadius = 12.dp,
+                            containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+                        ) {
+                            TextSelectMenuConfigItemRow(
+                                item = item,
+                                onToggleEnabled = {
+                                    draftItems = draftItems.map {
+                                        if (it.uniqueId == item.uniqueId) {
+                                            it.copy(enabled = !it.enabled)
+                                        } else it
+                                    }
+                                },
+                                dragHandleModifier = Modifier.draggableHandle()
+                            )
+                        }
+                    }
+                }
+            }
+
+            ConfirmDismissButtonsRow(
+                onDismiss = onDismissRequest,
+                onConfirm = {
+                    onSaved(draftItems)
+                    onDismissRequest()
+                },
+                dismissText = stringResource(R.string.cancel),
+                confirmText = stringResource(R.string.action_save),
+                modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp),
+            )
+        }
+    }
+}
+
+@Composable
+private fun TextSelectMenuConfigItemRow(
+    item: ActionMenuItem,
+    onToggleEnabled: () -> Unit,
+    dragHandleModifier: Modifier = Modifier
+) {
+    val alpha = if (item.enabled) 1f else 0.38f
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(all = 12.dp),
+    ) {
+        if (item.iconDrawable != null) {
+            Box(
+                contentAlignment = Alignment.Center,
+                modifier = Modifier.size(36.dp),
+            ) {
+                AsyncImage(
+                    model = item.iconDrawable,
+                    contentDescription = item.title,
+                    alpha = alpha,
+                    modifier = Modifier.size(24.dp)
+                )
+            }
+        }
+
+        AppText(
+            text = item.title,
+            fontSize = 14.sp,
+            color = LegadoTheme.colorScheme.onSurface.copy(alpha = alpha),
+            modifier = Modifier
+                .weight(1f)
+                .padding(horizontal = 12.dp),
+            maxLines = 1
+        )
+
+        IconButton(
+            onClick = onToggleEnabled,
+            modifier = Modifier.size(36.dp),
+        ) {
+            Icon(
+                imageVector = if (item.enabled) {
+                    Icons.Default.Visibility
+                } else {
+                    Icons.Default.VisibilityOff
+                },
+                contentDescription = null,
+                tint = if (item.enabled) {
+                    LegadoTheme.colorScheme.onSurface
+                } else {
+                    LegadoTheme.colorScheme.onSurfaceVariant
+                },
+                modifier = Modifier.size(20.dp),
+            )
+        }
+
+        Box(
+            modifier = dragHandleModifier.size(36.dp),
+            contentAlignment = Alignment.Center
+        ) {
+            Icon(
+                imageVector = Icons.Default.Menu,
+                contentDescription = null,
+                tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier.size(20.dp),
+            )
+        }
+    }
+}

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

@@ -56,6 +56,8 @@ import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem

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

@@ -90,6 +90,9 @@ object ReadConfig {
     var textSelectMenuFilter
     var textSelectMenuFilter
         get() = ReadMenuConfig.textSelectMenuFilter
         get() = ReadMenuConfig.textSelectMenuFilter
         set(value) { ReadMenuConfig.textSelectMenuFilter = value }
         set(value) { ReadMenuConfig.textSelectMenuFilter = value }
+    var textSelectMenuConfig
+        get() = ReadMenuConfig.textSelectMenuConfig
+        set(value) { ReadMenuConfig.textSelectMenuConfig = value }
     var showReadTitleAddition
     var showReadTitleAddition
         get() = ReadMenuConfig.showReadTitleAddition
         get() = ReadMenuConfig.showReadTitleAddition
         set(value) { ReadMenuConfig.showReadTitleAddition = value }
         set(value) { ReadMenuConfig.showReadTitleAddition = value }

+ 5 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadMenuConfig.kt

@@ -50,6 +50,11 @@ object ReadMenuConfig {
         ""
         ""
     )
     )
 
 
+    var textSelectMenuConfig by prefDelegate(
+        PreferKey.textSelectMenuConfig,
+        ""
+    )
+
     var showReadTitleAddition by prefDelegate(
     var showReadTitleAddition by prefDelegate(
         PreferKey.showReadTitleAddition,
         PreferKey.showReadTitleAddition,
         true
         true

+ 7 - 1
app/src/main/java/io/legado/app/ui/dict/DictContract.kt

@@ -7,9 +7,15 @@ import kotlinx.collections.immutable.persistentListOf
 @Stable
 @Stable
 data class DictUiState(
 data class DictUiState(
     val word: String = "",
     val word: String = "",
-    val isLoading: Boolean = false,
     val rules: ImmutableList<DictRuleUi> = persistentListOf(),
     val rules: ImmutableList<DictRuleUi> = persistentListOf(),
     val selectedIndex: Int = 0,
     val selectedIndex: Int = 0,
+    val pages: ImmutableList<DictPageUiState> = persistentListOf(),
+    val emptyReason: DictEmptyReason? = null,
+)
+
+@Stable
+data class DictPageUiState(
+    val isLoading: Boolean = false,
     val htmlContent: String = "",
     val htmlContent: String = "",
     val emptyReason: DictEmptyReason? = null,
     val emptyReason: DictEmptyReason? = null,
 )
 )

+ 132 - 17
app/src/main/java/io/legado/app/ui/dict/DictSheet.kt

@@ -1,5 +1,10 @@
 package io.legado.app.ui.dict
 package io.legado.app.ui.dict
 
 
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
@@ -10,10 +15,16 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.ExperimentalTextApi
@@ -23,11 +34,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import coil.compose.AsyncImage
 import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.EmptyMessage
-import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 
 
 @Composable
 @Composable
@@ -49,6 +65,8 @@ fun DictSheet(
         show = show,
         show = show,
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
         title = word,
         title = word,
+        //内部pager管理高度,避免二次动画
+        animateContentSize = false,
     ) {
     ) {
         DictSheetContent(
         DictSheetContent(
             state = state,
             state = state,
@@ -93,28 +111,125 @@ private fun DictContent(
     val emptyText = stringResource(R.string.empty)
     val emptyText = stringResource(R.string.empty)
     val searchEmptyText = stringResource(R.string.search_empty)
     val searchEmptyText = stringResource(R.string.search_empty)
     val tabTitles = remember(state.rules) { state.rules.map { it.name } }
     val tabTitles = remember(state.rules) { state.rules.map { it.name } }
-    val emptyMessage = when (state.emptyReason) {
-        DictEmptyReason.BlankWord -> blankWordText
-        DictEmptyReason.NoRules -> emptyText
-        DictEmptyReason.NoResult -> searchEmptyText
-        null -> null
+    val emptyMessageFor: (DictEmptyReason?) -> String? = { reason ->
+        when (reason) {
+            DictEmptyReason.BlankWord -> blankWordText
+            DictEmptyReason.NoRules -> emptyText
+            DictEmptyReason.NoResult -> searchEmptyText
+            null -> null
+        }
+    }
+    val selectedIndex = state.selectedIndex.coerceIn(0, state.rules.lastIndex.coerceAtLeast(0))
+
+    if (state.rules.size > 1) {
+        DictPagerContent(
+            state = state,
+            tabTitles = tabTitles,
+            selectedIndex = selectedIndex,
+            emptyMessageFor = emptyMessageFor,
+            onIntent = onIntent,
+            modifier = modifier,
+        )
+    } else {
+        val pageState = state.pages.getOrNull(selectedIndex)
+            ?: DictPageUiState(emptyReason = state.emptyReason)
+        DictPageContent(
+            state = pageState,
+            emptyMessage = emptyMessageFor(pageState.emptyReason),
+            modifier = modifier,
+        )
     }
     }
+}
+
+@Composable
+private fun DictPagerContent(
+    state: DictUiState,
+    tabTitles: List<String>,
+    selectedIndex: Int,
+    emptyMessageFor: (DictEmptyReason?) -> String?,
+    onIntent: (DictIntent) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val scope = rememberCoroutineScope()
+    val currentOnIntent by rememberUpdatedState(onIntent)
+    val pagerState = rememberPagerState(initialPage = selectedIndex) { state.rules.size }
+    val pageHeights = remember(state.word, state.rules) { mutableStateMapOf<Int, Int>() }
+    val animatedHeight by rememberPagerAnimatedHeight(
+        pagerState = pagerState,
+        pageHeights = pageHeights,
+        fallbackHeight = 200.dp,
+        heightAnimationSpec = spring(),
+    )
 
 
     Column(
     Column(
-        modifier = modifier.verticalScroll(rememberScrollState()),
+        modifier = modifier,
     ) {
     ) {
-        if (state.rules.size > 1) {
-            AppTabRow(
-                tabTitles = tabTitles,
-                selectedTabIndex = state.selectedIndex.coerceIn(0, state.rules.lastIndex),
-                onTabSelected = { index ->
-                    onIntent(DictIntent.SelectRule(index))
-                },
-                modifier = Modifier.fillMaxWidth(),
-                isScrollable = true,
-            )
+        AppTabRow(
+            tabTitles = tabTitles,
+            selectedTabIndex = pagerState.currentPage.coerceIn(0, state.rules.lastIndex),
+            onTabSelected = { index ->
+                scope.launch {
+                    pagerState.animateScrollToPage(
+                        page = index,
+                        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
+                    )
+                }
+            },
+            modifier = Modifier.fillMaxWidth(),
+            isScrollable = true,
+        )
+
+        HorizontalPager(
+            state = pagerState,
+            verticalAlignment = Alignment.Top,
+            overscrollEffect = null,
+            modifier = Modifier
+                .weight(1f, fill = false)
+                .clipToBounds()
+                .pagerHeight(animatedHeight),
+        ) { page ->
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .onSizeChanged { size ->
+                        pageHeights[page] = size.height
+                    }
+            ) {
+                val pageState = state.pages.getOrNull(page) ?: DictPageUiState()
+                DictPageContent(
+                    state = pageState,
+                    emptyMessage = emptyMessageFor(pageState.emptyReason),
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
         }
         }
+    }
+
+    LaunchedEffect(selectedIndex, state.rules.size) {
+        if (pagerState.currentPage != selectedIndex) {
+            pagerState.scrollToPage(selectedIndex)
+        }
+    }
 
 
+    LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.settledPage }
+            .distinctUntilChanged()
+            .drop(1)
+            .collect { page ->
+                currentOnIntent(DictIntent.SelectRule(page))
+            }
+    }
+}
+
+@Composable
+private fun DictPageContent(
+    state: DictPageUiState,
+    emptyMessage: String?,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.verticalScroll(rememberScrollState()),
+    ) {
         when {
         when {
             state.isLoading -> {
             state.isLoading -> {
                 Box(
                 Box(

+ 65 - 18
app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.data.repository.DictRuleRepository
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -40,7 +41,6 @@ class DictViewModel(
         val query = word.trim()
         val query = word.trim()
         _uiState.value = DictUiState(
         _uiState.value = DictUiState(
             word = query,
             word = query,
-            isLoading = query.isNotBlank(),
             emptyReason = if (query.isBlank()) DictEmptyReason.BlankWord else null,
             emptyReason = if (query.isBlank()) DictEmptyReason.BlankWord else null,
         )
         )
         if (query.isBlank()) return
         if (query.isBlank()) return
@@ -52,7 +52,7 @@ class DictViewModel(
             _uiState.update { state ->
             _uiState.update { state ->
                 state.copy(
                 state.copy(
                     rules = dictRules.map { DictRuleUi(it.name) }.toImmutableList(),
                     rules = dictRules.map { DictRuleUi(it.name) }.toImmutableList(),
-                    isLoading = dictRules.isNotEmpty(),
+                    pages = dictRules.map { DictPageUiState() }.toImmutableList(),
                     emptyReason = if (dictRules.isEmpty()) DictEmptyReason.NoRules else null,
                     emptyReason = if (dictRules.isEmpty()) DictEmptyReason.NoRules else null,
                 )
                 )
             }
             }
@@ -64,7 +64,16 @@ class DictViewModel(
 
 
     private fun selectRule(index: Int) {
     private fun selectRule(index: Int) {
         val state = _uiState.value
         val state = _uiState.value
-        if (index !in dictRules.indices || state.selectedIndex == index && state.isLoading) return
+        if (index !in dictRules.indices || index !in state.pages.indices) return
+        val page = state.pages[index]
+        if (page.isLoading) {
+            _uiState.update { it.copy(selectedIndex = index) }
+            return
+        }
+        if (page.htmlContent.isNotBlank() || page.emptyReason == DictEmptyReason.NoResult) {
+            _uiState.update { it.copy(selectedIndex = index) }
+            return
+        }
         searchRule(index = index, word = state.word)
         searchRule(index = index, word = state.word)
     }
     }
 
 
@@ -74,32 +83,58 @@ class DictViewModel(
         _uiState.update {
         _uiState.update {
             it.copy(
             it.copy(
                 selectedIndex = index,
                 selectedIndex = index,
-                isLoading = true,
-                htmlContent = "",
                 emptyReason = null,
                 emptyReason = null,
-            )
+            ).updatePage(index) { page ->
+                page.copy(
+                    isLoading = true,
+                    htmlContent = "",
+                    emptyReason = null,
+                )
+            }
         }
         }
         dictJob = viewModelScope.launch {
         dictJob = viewModelScope.launch {
-            val result = runCatching {
+            val result = try {
                 withContext(Dispatchers.IO) {
                 withContext(Dispatchers.IO) {
                     rule.search(word)
                     rule.search(word)
                 }
                 }
-            }.getOrElse { error ->
+            } catch (error: CancellationException) {
+                _uiState.update { state ->
+                    if (state.word == word && dictRules.getOrNull(index) === rule) {
+                        state.updatePage(index) { page ->
+                            if (page.isLoading) {
+                                page.copy(isLoading = false)
+                            } else {
+                                page
+                            }
+                        }
+                    } else {
+                        state
+                    }
+                }
+                throw error
+            } catch (error: Throwable) {
                 error.localizedMessage ?: "ERROR"
                 error.localizedMessage ?: "ERROR"
             }
             }
             _uiState.update {
             _uiState.update {
+                if (it.word != word || dictRules.getOrNull(index) !== rule) {
+                    return@update it
+                }
                 if (result.isBlank()) {
                 if (result.isBlank()) {
-                    it.copy(
-                        isLoading = false,
-                        htmlContent = "",
-                        emptyReason = DictEmptyReason.NoResult,
-                    )
+                    it.updatePage(index) { page ->
+                        page.copy(
+                            isLoading = false,
+                            htmlContent = "",
+                            emptyReason = DictEmptyReason.NoResult,
+                        )
+                    }
                 } else {
                 } else {
-                    it.copy(
-                        isLoading = false,
-                        htmlContent = result,
-                        emptyReason = null,
-                    )
+                    it.updatePage(index) { page ->
+                        page.copy(
+                            isLoading = false,
+                            htmlContent = result,
+                            emptyReason = null,
+                        )
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -110,3 +145,15 @@ class DictViewModel(
         super.onCleared()
         super.onCleared()
     }
     }
 }
 }
+
+private fun DictUiState.updatePage(
+    index: Int,
+    transform: (DictPageUiState) -> DictPageUiState,
+): DictUiState {
+    if (index !in pages.indices) return this
+    return copy(
+        pages = pages.mapIndexed { pageIndex, page ->
+            if (pageIndex == index) transform(page) else page
+        }.toImmutableList()
+    )
+}

+ 45 - 0
app/src/main/java/io/legado/app/ui/theme/AppTheme.kt

@@ -2,18 +2,63 @@ package io.legado.app.ui.theme
 
 
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
+import top.yukonga.miuix.kmp.theme.ColorSchemeMode
+import com.materialkolor.PaletteStyle
 
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 @Composable
 fun AppTheme(
 fun AppTheme(
     darkTheme: Boolean = isSystemInDarkTheme(),
     darkTheme: Boolean = isSystemInDarkTheme(),
     content: @Composable () -> Unit
     content: @Composable () -> Unit
+) {
+    if (LocalInspectionMode.current) {
+        AppThemePreview(darkTheme, content)
+    } else {
+        AppThemeActual(darkTheme, content)
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun AppThemePreview(
+    darkTheme: Boolean,
+    content: @Composable () -> Unit
+) {
+    val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme()
+    val themeColors = LegadoThemeMode(
+        colorScheme = colorScheme,
+        isDark = darkTheme,
+        seedColor = Color.Unspecified,
+        paletteStyle = PaletteStyle.TonalSpot,
+        themeMode = if (darkTheme) ColorSchemeMode.Dark else ColorSchemeMode.Light,
+        useDynamicColor = false,
+        composeEngine = "material"
+    )
+    CompositionLocalProvider(
+        LocalLegadoThemeColors provides themeColors
+    ) {
+        MaterialThemeWrapper(
+            themeColors = themeColors,
+            customFontFamily = null,
+            content = content
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun AppThemeActual(
+    darkTheme: Boolean,
+    content: @Composable () -> Unit
 ) {
 ) {
     val context = LocalContext.current
     val context = LocalContext.current
     
     

+ 9 - 2
app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt

@@ -48,6 +48,7 @@ fun AppModalBottomSheet(
     title: String? = null,
     title: String? = null,
     startAction: @Composable (() -> Unit)? = null,
     startAction: @Composable (() -> Unit)? = null,
     endAction: @Composable (() -> Unit)? = null,
     endAction: @Composable (() -> Unit)? = null,
+    animateContentSize: Boolean = true,
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     content: @Composable ColumnScope.() -> Unit
     content: @Composable ColumnScope.() -> Unit
 ) {
 ) {
@@ -92,7 +93,9 @@ fun AppModalBottomSheet(
                         modifier = Modifier
                         modifier = Modifier
                             .fillMaxWidth()
                             .fillMaxWidth()
                             .padding(bottom = 24.dp)
                             .padding(bottom = 24.dp)
-                            .animateContentSize(),
+                            .let { contentModifier ->
+                                if (animateContentSize) contentModifier.animateContentSize() else contentModifier
+                            },
                         content = content
                         content = content
                     )
                     )
                 }
                 }
@@ -125,7 +128,9 @@ fun AppModalBottomSheet(
                             .fillMaxWidth()
                             .fillMaxWidth()
                             .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
                             .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
                             .heightIn(max = maxHeight)
                             .heightIn(max = maxHeight)
-                            .animateContentSize()
+                            .let { contentModifier ->
+                                if (animateContentSize) contentModifier.animateContentSize() else contentModifier
+                            }
                             .then(modifier)
                             .then(modifier)
                     ) {
                     ) {
                         val hasHeader =
                         val hasHeader =
@@ -185,6 +190,7 @@ fun <T> AppModalBottomSheet(
     title: String? = null,
     title: String? = null,
     startAction: @Composable (() -> Unit)? = null,
     startAction: @Composable (() -> Unit)? = null,
     endAction: @Composable (() -> Unit)? = null,
     endAction: @Composable (() -> Unit)? = null,
+    animateContentSize: Boolean = true,
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     content: @Composable ColumnScope.(T) -> Unit
     content: @Composable ColumnScope.(T) -> Unit
 ) {
 ) {
@@ -202,6 +208,7 @@ fun <T> AppModalBottomSheet(
         title = title,
         title = title,
         startAction = startAction,
         startAction = startAction,
         endAction = endAction,
         endAction = endAction,
+        animateContentSize = animateContentSize,
         contentWindowInsets = contentWindowInsets,
         contentWindowInsets = contentWindowInsets,
         content = {
         content = {
             if (currentData != null) {
             if (currentData != null) {

+ 77 - 0
app/src/main/java/io/legado/app/ui/widget/components/pager/PagerHeight.kt

@@ -0,0 +1,77 @@
+package io.legado.app.ui.widget.components.pager
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+
+fun Modifier.pagerHeight(height: Dp) = this.layout { measurable, constraints ->
+    val placeable = measurable.measure(constraints)
+    val layoutHeight = if (height != Dp.Unspecified) {
+        height.roundToPx().coerceIn(constraints.minHeight, constraints.maxHeight)
+    } else {
+        placeable.height
+    }
+    layout(placeable.width, layoutHeight) {
+        placeable.placeRelative(0, 0)
+    }
+}
+
+@Composable
+fun rememberPagerAnimatedHeight(
+    pagerState: PagerState,
+    pageHeights: Map<Int, Int>,
+    fallbackHeight: Dp = Dp.Unspecified,
+    heightAnimationSpec: FiniteAnimationSpec<Dp>? = null,
+): State<Dp> {
+    val density = LocalDensity.current
+    val fallbackHeightPx = with(density) {
+        fallbackHeight.takeIf { it != Dp.Unspecified }?.roundToPx() ?: 0
+    }
+    val targetHeight = remember(pagerState, pageHeights, density, fallbackHeightPx) {
+        derivedStateOf {
+            val pageCount = pagerState.pageCount
+            if (pageCount <= 0) {
+                return@derivedStateOf Dp.Unspecified
+            }
+            val position = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
+                .coerceIn(0f, (pageCount - 1).toFloat())
+            val floorPage = position.toInt().coerceIn(0, pageCount - 1)
+            val ceilPage = (floorPage + 1).coerceIn(0, pageCount - 1)
+
+            val floorHeight = pageHeights[floorPage] ?: 0
+            val ceilHeight = pageHeights[ceilPage] ?: floorHeight
+            val fraction = position - floorPage
+
+            val knownHeight = pageHeights.values.firstOrNull { it > 0 } ?: fallbackHeightPx
+            val unknownPageHeight = fallbackHeightPx.takeIf { it > 0 } ?: knownHeight
+            val startHeight = if (floorHeight > 0) floorHeight else unknownPageHeight
+            val endHeight = if (ceilHeight > 0) ceilHeight else unknownPageHeight
+            val interpolated = startHeight + (endHeight - startHeight) * fraction
+
+            if (interpolated > 0) {
+                with(density) { interpolated.toDp() }
+            } else {
+                Dp.Unspecified
+            }
+        }
+    }
+    if (heightAnimationSpec == null) {
+        return targetHeight
+    }
+    return if (targetHeight.value == Dp.Unspecified) {
+        targetHeight
+    } else {
+        animateDpAsState(
+            targetValue = targetHeight.value,
+            animationSpec = heightAnimationSpec,
+        )
+    }
+}

+ 0 - 55
app/src/main/res/layout/popup_action_menu.xml

@@ -1,55 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="wrap_content"
-    android:layout_height="wrap_content"
-    android:orientation="vertical"
-    app:cardBackgroundColor="?attr/colorSurfaceContainer"
-    app:strokeWidth="0dp">
-
-    <LinearLayout
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:animateLayoutChanges="true"
-        android:padding="8dp"
-        android:orientation="vertical">
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical"
-            android:orientation="horizontal">
-
-            <androidx.recyclerview.widget.RecyclerView
-                android:id="@+id/recycler_view"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_gravity="center"
-                android:orientation="horizontal"
-                app:flexDirection="row"
-                app:flexWrap="wrap"
-                app:layoutManager="com.google.android.flexbox.FlexboxLayoutManager" />
-
-            <androidx.appcompat.widget.AppCompatImageView
-                android:id="@+id/iv_menu_more"
-                android:layout_width="24dp"
-                android:layout_height="24dp"
-                android:background="?attr/selectableItemBackgroundBorderless"
-                android:contentDescription="@string/more_menu"
-                android:src="@drawable/ic_more_vert"
-                android:visibility="gone" />
-
-        </LinearLayout>
-
-        <androidx.recyclerview.widget.RecyclerView
-            android:id="@+id/recycler_view_more"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"
-            android:orientation="vertical"
-            android:visibility="gone"
-            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
-    </LinearLayout>
-
-
-</com.google.android.material.card.MaterialCardView>
-

+ 0 - 51
app/src/main/res/menu/content_select_action.xml

@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/menu_replace"
-        android:title="@string/replace" />
-
-    <item
-        android:id="@+id/menu_ai_clean"
-        android:title="@string/ai_text_clean" />
-
-    <item
-        android:id="@+id/menu_ai_rewrite"
-        android:title="@string/ai_text_rewrite" />
-
-    <item
-        android:id="@+id/menu_copy"
-        android:title="@android:string/copy" />
-
-    <item
-        android:id="@+id/menu_bookmark"
-        android:title="@string/bookmark" />
-
-    <item
-        android:id="@+id/menu_aloud"
-        android:title="@string/read_aloud" />
-
-    <item
-        android:id="@+id/menu_dict"
-        android:title="@string/dict" />
-
-    <item
-        android:id="@+id/menu_edit"
-        android:title="@string/edit" />
-
-    <item
-        android:id="@+id/menu_search_content"
-        android:title="@string/search_content"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_browser"
-        android:title="@string/browser"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_share_str"
-        android:title="@string/share"
-        app:showAsAction="never" />
-</menu>

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

@@ -2455,4 +2455,6 @@
     <string name="delete_chapter_cache_message">删除章节缓存:%1$s?</string>
     <string name="delete_chapter_cache_message">删除章节缓存:%1$s?</string>
     <string name="about_open_project_homepage">打开项目主页</string>
     <string name="about_open_project_homepage">打开项目主页</string>
     <string name="about_open_github">打开 GitHub</string>
     <string name="about_open_github">打开 GitHub</string>
+    <string name="edit_menu_items">编辑菜单项</string>
+    <string name="edit_select_menu">编辑选择菜单</string>
 </resources>
 </resources>

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

@@ -2367,4 +2367,6 @@
     <string name="delete_chapter_cache_message">刪除章節緩存:%1$s?</string>
     <string name="delete_chapter_cache_message">刪除章節緩存:%1$s?</string>
     <string name="about_open_project_homepage">開啟項目首頁</string>
     <string name="about_open_project_homepage">開啟項目首頁</string>
     <string name="about_open_github">開啟 GitHub</string>
     <string name="about_open_github">開啟 GitHub</string>
+    <string name="edit_menu_items">編輯選單項</string>
+    <string name="edit_select_menu">編輯選擇選單</string>
 </resources>
 </resources>

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

@@ -2372,4 +2372,6 @@
     <string name="delete_chapter_cache_message">刪除章節快取:%1$s?</string>
     <string name="delete_chapter_cache_message">刪除章節快取:%1$s?</string>
     <string name="about_open_project_homepage">開啟專案首頁</string>
     <string name="about_open_project_homepage">開啟專案首頁</string>
     <string name="about_open_github">開啟 GitHub</string>
     <string name="about_open_github">開啟 GitHub</string>
+    <string name="edit_menu_items">編輯選單項</string>
+    <string name="edit_select_menu">編輯選擇選單</string>
 </resources>
 </resources>

+ 11 - 0
app/src/main/res/values/ids.xml

@@ -19,4 +19,15 @@
     <item name="cursor_right" type="id" />
     <item name="cursor_right" type="id" />
     <item name="navigation_bar" type="id" />
     <item name="navigation_bar" type="id" />
 
 
+    <item name="menu_replace" type="id" />
+    <item name="menu_ai_clean" type="id" />
+    <item name="menu_ai_rewrite" type="id" />
+    <item name="menu_copy" type="id" />
+    <item name="menu_bookmark" type="id" />
+    <item name="menu_aloud" type="id" />
+    <item name="menu_dict" type="id" />
+    <item name="menu_edit" type="id" />
+    <item name="menu_search_content" type="id" />
+    <item name="menu_browser" type="id" />
+    <item name="menu_share_str" type="id" />
 </resources>
 </resources>

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

@@ -2460,4 +2460,6 @@
     <string name="delete_chapter_cache_message">Delete chapter cache: %1$s?</string>
     <string name="delete_chapter_cache_message">Delete chapter cache: %1$s?</string>
     <string name="about_open_project_homepage">Open project homepage</string>
     <string name="about_open_project_homepage">Open project homepage</string>
     <string name="about_open_github">Open GitHub</string>
     <string name="about_open_github">Open GitHub</string>
+    <string name="edit_menu_items">Edit Menu Items</string>
+    <string name="edit_select_menu">Edit Selection Menu</string>
 </resources>
 </resources>

+ 30 - 0
app/src/test/java/io/legado/app/ui/book/manage/BookshelfManageScreenExportConfigTest.kt

@@ -0,0 +1,30 @@
+package io.legado.app.ui.book.manage
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BookshelfManageScreenExportConfigTest {
+
+    @Test
+    fun customExport_requiresEpubFormat() {
+        assertFalse(
+            BookshelfManageScreenExportConfig(
+                enableCustomExport = true,
+                exportType = 0
+            ).isCustomEpubExportEnabled
+        )
+        assertTrue(
+            BookshelfManageScreenExportConfig(
+                enableCustomExport = true,
+                exportType = 1
+            ).isCustomEpubExportEnabled
+        )
+        assertFalse(
+            BookshelfManageScreenExportConfig(
+                enableCustomExport = false,
+                exportType = 1
+            ).isCustomEpubExportEnabled
+        )
+    }
+}

+ 2 - 0
gradle/libs.versions.toml

@@ -61,6 +61,7 @@ preference = "1.2.1"
 protobufJavalite = "4.26.1"
 protobufJavalite = "4.26.1"
 quickChineseTransfer = "0.2.17"
 quickChineseTransfer = "0.2.17"
 room = "2.8.4"
 room = "2.8.4"
+robolectric = "4.16.1"
 splitties = "3.0.0"
 splitties = "3.0.0"
 rhino = "1.8.1"
 rhino = "1.8.1"
 desugar = "2.1.5"
 desugar = "2.1.5"
@@ -187,6 +188,7 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
 room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
 room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
 room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
 room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
 room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
 room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
 
 
 glide-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
 glide-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
 glide-okhttp= { module = "com.github.bumptech.glide:okhttp3-integration", version.ref ="glide" }
 glide-okhttp= { module = "com.github.bumptech.glide:okhttp3-integration", version.ref ="glide" }