Migrate to the new context menu APIs

This commit is contained in:
FooIbar committed 2025-08-26 10:02:21 +08:00
1 parent e1252d0554
commit f174ccfc04
3 files changed
+83 -245

No files matched your search

@@ -51,7 +51,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -70,7 +69,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -105,10 +103,10 @@ import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.NavigationIcon
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.addBBCodeTextContextMenuItems
import com.hippo.ehviewer.ui.tools.awaitConfirmationOrCancel
import com.hippo.ehviewer.ui.tools.awaitSelectAction
import com.hippo.ehviewer.ui.tools.normalizeSpan
import com.hippo.ehviewer.ui.tools.rememberBBCodeTextToolbar
import com.hippo.ehviewer.ui.tools.showNoButton
import com.hippo.ehviewer.ui.tools.toBBCode
import com.hippo.ehviewer.ui.tools.updateSpan
@@ -119,7 +117,6 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.collections.forEach
import kotlin.math.roundToInt
import moe.tarsin.coroutines.runSuspendCatching
import moe.tarsin.navigate
@@ -479,18 +476,15 @@ fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: Destinat
},
) {
val color = MaterialTheme.colorScheme.onPrimaryContainer
val toolbar = rememberBBCodeTextToolbar(userCommentBackField)
CompositionLocalProvider(LocalTextToolbar provides toolbar) {
BasicTextField(
value = userComment,
onValueChange = { textFieldValue ->
userComment = textFieldValue.updateSpan(userComment)
},
modifier = Modifier.weight(1f).padding(keylineMargin),
textStyle = MaterialTheme.typography.bodyLarge.merge(color = color),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
BasicTextField(
value = userComment,
onValueChange = { textFieldValue ->
userComment = textFieldValue.updateSpan(userComment)
},
modifier = Modifier.weight(1f).padding(keylineMargin).addBBCodeTextContextMenuItems(userCommentBackField),
textStyle = MaterialTheme.typography.bodyLarge.merge(color = color),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
IconButton(
onClick = { launchIO { sendComment() } },
shapes = IconButtonDefaults.shapes(),
@@ -1,18 +1,17 @@
package com.hippo.ehviewer.ui.tools
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.foundation.text.contextmenu.builder.item
import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys
import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents
import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
@@ -25,8 +24,7 @@ import androidx.compose.ui.text.input.getTextAfterSelection
import androidx.compose.ui.text.input.getTextBeforeSelection
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.MainActivity
import com.ehviewer.core.i18n.R
import com.hippo.ehviewer.util.toRangeSet
import io.github.petertrr.diffutils.diffInline
import io.github.petertrr.diffutils.patch.ChangeDelta
@@ -36,8 +34,7 @@ import io.github.petertrr.diffutils.patch.InsertDelta
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.tarsin.kt.unreachable
typealias PlatformRect = android.graphics.Rect
import moe.tarsin.string
fun TextFieldValue.updateSpan(origin: TextFieldValue): TextFieldValue {
val oriSpan = origin.annotatedString.spanStyles
@@ -141,118 +138,81 @@ fun AnnotatedString.toBBCode() = buildString {
}
@Composable
context(activity: MainActivity)
fun rememberBBCodeTextToolbar(textFieldValue: MutableState<TextFieldValue>): TextToolbar {
context(_: Context)
fun Modifier.addBBCodeTextContextMenuItems(textFieldValue: MutableState<TextFieldValue>): Modifier {
var tfv by textFieldValue
val view = LocalView.current
val coroutineScope = rememberCoroutineScope()
val toolbar = remember {
object : TextToolbar {
private var actionMode: ActionMode? = null
private val callback = object : TextActionModeCallback() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
super.onCreateActionMode(mode, menu)
activity.menuInflater.inflate(R.menu.context_comment, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
super.onPrepareActionMode(mode, menu)
if (tfv.selection.collapsed) {
menu.removeGroup(R.id.bbcode_group)
return appendTextContextMenuComponents {
if (tfv.selection.collapsed) return@appendTextContextMenuComponents
BBCodeFormat.entries.forEach { item ->
item(key = item, label = string(item.id)) {
val capturedTfv = tfv
val start = capturedTfv.selection.min
val end = capturedTfv.selection.max
val annotatedString = buildAnnotatedString {
val len = capturedTfv.text.length
fun addStyle(style: SpanStyle) {
append(capturedTfv.annotatedString)
addStyle(style, start, end)
}
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val handled = super.onActionItemClicked(mode, item)
if (handled) {
return true
}
val capturedTfv = tfv
val start = capturedTfv.selection.min
val end = capturedTfv.selection.max
val annotatedString = buildAnnotatedString {
val len = capturedTfv.text.length
fun addStyle(style: SpanStyle) {
append(capturedTfv.annotatedString)
addStyle(style, start, end)
fun addTextDecoration(decoration: TextDecoration) {
append(capturedTfv.getTextBeforeSelection(len))
val ans = capturedTfv.getSelectedText()
val spans = ans.spanStyles.filter {
it.item.textDecoration == null || it.item.textDecoration == decoration
}
fun addTextDecoration(decoration: TextDecoration) {
withStyle(SpanStyle(textDecoration = decoration)) {
append(AnnotatedString(ans.text, spans))
}
append(capturedTfv.getTextAfterSelection(len))
}
when (item) {
BBCodeFormat.Bold -> addStyle(SpanStyle(fontWeight = FontWeight.Bold))
BBCodeFormat.Italic -> addStyle(SpanStyle(fontStyle = FontStyle.Italic))
BBCodeFormat.Underline -> addTextDecoration(TextDecoration.Underline)
BBCodeFormat.Strikethrough -> addTextDecoration(TextDecoration.LineThrough)
BBCodeFormat.Url -> append(capturedTfv.annotatedString)
BBCodeFormat.Clear -> {
append(capturedTfv.getTextBeforeSelection(len))
val ans = capturedTfv.getSelectedText()
val spans = ans.spanStyles.filter {
it.item.textDecoration == null || it.item.textDecoration == decoration
}
withStyle(SpanStyle(textDecoration = decoration)) {
append(AnnotatedString(ans.text, spans))
}
append(capturedTfv.getSelectedText().text)
append(capturedTfv.getTextAfterSelection(len))
}
when (item.itemId) {
R.id.action_bold -> addStyle(SpanStyle(fontWeight = FontWeight.Bold))
R.id.action_italic -> addStyle(SpanStyle(fontStyle = FontStyle.Italic))
R.id.action_underline -> addTextDecoration(TextDecoration.Underline)
R.id.action_strikethrough -> addTextDecoration(TextDecoration.LineThrough)
R.id.action_url -> append(capturedTfv.annotatedString)
R.id.action_clear -> {
append(capturedTfv.getTextBeforeSelection(len))
append(capturedTfv.getSelectedText().text)
append(capturedTfv.getTextAfterSelection(len))
}
}
}
tfv = TextFieldValue(
annotatedString = buildAnnotatedString {
append(annotatedString)
// Hacky: Trigger recomposition to hide text toolbar
append(Char.MIN_VALUE)
},
selection = TextRange(end),
)
coroutineScope.launch {
// Hacky: Let TextField recompose first
delay(100)
tfv = tfv.copy(annotatedString = annotatedString)
}
mode.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
actionMode = null
}
}
override var status = TextToolbarStatus.Hidden
override fun hide() {
status = TextToolbarStatus.Hidden
actionMode?.finish()
actionMode = null
}
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
) {
callback.rect = rect
callback.onCopyRequested = onCopyRequested
callback.onCutRequested = onCutRequested
callback.onPasteRequested = onPasteRequested
callback.onSelectAllRequested = onSelectAllRequested
if (actionMode == null) {
status = TextToolbarStatus.Shown
actionMode = view.startActionMode(callback, ActionMode.TYPE_FLOATING)
} else {
actionMode?.invalidate()
tfv = TextFieldValue(
annotatedString = buildAnnotatedString {
append(annotatedString)
// Hacky: Trigger recomposition to hide text toolbar
append(Char.MIN_VALUE)
},
selection = TextRange(end),
)
coroutineScope.launch {
// Hacky: Let TextField recompose first
delay(100)
tfv = tfv.copy(annotatedString = annotatedString)
}
close()
}
}
}.filterTextContextMenuComponents {
when (it.key) {
TextContextMenuKeys.CutKey,
TextContextMenuKeys.CopyKey,
TextContextMenuKeys.PasteKey,
TextContextMenuKeys.SelectAllKey,
is BBCodeFormat,
-> true
else -> false
}
}
return toolbar
}
private enum class BBCodeFormat(@StringRes val id: Int) {
Bold(R.string.format_bold),
Italic(R.string.format_italic),
Underline(R.string.format_underline),
Strikethrough(R.string.format_strikethrough),
Url(R.string.format_url),
Clear(R.string.format_plain),
}
@@ -1,116 +0,0 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.ui.tools
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.compose.ui.geometry.Rect
open class TextActionModeCallback(
var rect: Rect = Rect.Zero,
var onCopyRequested: (() -> Unit)? = null,
var onPasteRequested: (() -> Unit)? = null,
var onCutRequested: (() -> Unit)? = null,
var onSelectAllRequested: (() -> Unit)? = null,
) : ActionMode.Callback2() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
onCopyRequested?.let {
addMenuItem(menu, MenuItemOption.Copy)
}
onPasteRequested?.let {
addMenuItem(menu, MenuItemOption.Paste)
}
onCutRequested?.let {
addMenuItem(menu, MenuItemOption.Cut)
}
onSelectAllRequested?.let {
addMenuItem(menu, MenuItemOption.SelectAll)
}
return true
}
// this method is called to populate new menu items when the actionMode was invalidated
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
updateMenuItems(menu)
// should return true so that new menu items are populated
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
MenuItemOption.Copy.id -> onCopyRequested?.invoke()
MenuItemOption.Paste.id -> onPasteRequested?.invoke()
MenuItemOption.Cut.id -> onCutRequested?.invoke()
MenuItemOption.SelectAll.id -> onSelectAllRequested?.invoke()
else -> return false
}
mode.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode) = Unit
override fun onGetContentRect(mode: ActionMode, view: View, outRect: PlatformRect) {
outRect.set(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt())
}
private fun updateMenuItems(menu: Menu) {
addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Paste, onPasteRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Cut, onCutRequested)
addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested)
}
private fun addMenuItem(menu: Menu, item: MenuItemOption) {
menu.add(0, item.id, item.order, item.titleResource)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
private fun addOrRemoveMenuItem(
menu: Menu,
item: MenuItemOption,
callback: (() -> Unit)?,
) {
when {
callback != null && menu.findItem(item.id) == null -> addMenuItem(menu, item)
callback == null && menu.findItem(item.id) != null -> menu.removeItem(item.id)
}
}
}
enum class MenuItemOption(val id: Int) {
Copy(0),
Paste(1),
Cut(2),
SelectAll(3),
;
val titleResource: Int
get() = when (this) {
Copy -> android.R.string.copy
Paste -> android.R.string.paste
Cut -> android.R.string.cut
SelectAll -> android.R.string.selectAll
}
/**
* This item will be shown before all items that have order greater than this value.
*/
val order = id
}