diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt index e3b933939..eed33a662 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt @@ -80,7 +80,6 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -113,7 +112,6 @@ import com.hippo.ehviewer.ui.main.GalleryCommentCard import com.hippo.ehviewer.ui.openBrowser import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs import com.hippo.ehviewer.ui.tools.LocalDialogState -import com.hippo.ehviewer.ui.tools.NoopClipboardManager import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState import com.hippo.ehviewer.ui.tools.normalizeSpan import com.hippo.ehviewer.ui.tools.rememberBBCodeTextToolbar @@ -462,10 +460,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: NavController ) { val color = MaterialTheme.colorScheme.onPrimaryContainer val toolbar = rememberBBCodeTextToolbar(userCommentBackField) - CompositionLocalProvider( - LocalTextToolbar provides toolbar, - LocalClipboardManager provides NoopClipboardManager, - ) { + CompositionLocalProvider(LocalTextToolbar provides toolbar) { BasicTextField( value = userComment, onValueChange = { textFieldValue -> diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/AndroidBBCodeTextToolbar.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/BBCodeTextToolbar.kt similarity index 64% rename from app/src/main/kotlin/com/hippo/ehviewer/ui/tools/AndroidBBCodeTextToolbar.kt rename to app/src/main/kotlin/com/hippo/ehviewer/ui/tools/BBCodeTextToolbar.kt index be34052e4..dbb603133 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/AndroidBBCodeTextToolbar.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/BBCodeTextToolbar.kt @@ -8,7 +8,6 @@ import android.text.style.UnderlineSpan import android.view.ActionMode import android.view.Menu import android.view.MenuItem -import android.view.View import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -17,7 +16,6 @@ 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.ClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.TextToolbar @@ -48,11 +46,6 @@ import moe.tarsin.kt.unreachable typealias PlatformRect = android.graphics.Rect -object NoopClipboardManager : ClipboardManager { - override fun getText() = null - override fun setText(annotatedString: AnnotatedString) = Unit -} - fun TextFieldValue.updateSpan(origin: TextFieldValue): TextFieldValue { val oriSpan = origin.annotatedString.spanStyles val annoStr = origin.annotatedString @@ -182,6 +175,82 @@ fun rememberBBCodeTextToolbar(textFieldValue: MutableState): Tex 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 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 + } + withStyle(SpanStyle(textDecoration = decoration)) { + append(AnnotatedString(ans.text, spans)) + } + 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() { @@ -197,77 +266,14 @@ fun rememberBBCodeTextToolbar(textFieldValue: MutableState): Tex 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( - object : ActionMode.Callback2() { - override fun onCreateActionMode(p0: ActionMode, p1: Menu): Boolean { - activity.menuInflater.inflate(R.menu.context_comment, p1) - return true - } - - override fun onPrepareActionMode(p0: ActionMode, p1: Menu): Boolean { - return true - } - - override fun onActionItemClicked(p0: ActionMode, p1: MenuItem): Boolean { - val capturedTfv = tfv - coroutineScope.launch { - // Hacky: Let TextField recompose first - delay(100) - // Reversed range is not supported by AnnotatedString - val start = capturedTfv.selection.start.coerceAtMost(capturedTfv.selection.end) - val end = capturedTfv.selection.start.coerceAtLeast(capturedTfv.selection.end) - tfv = TextFieldValue( - 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 - } - withStyle(SpanStyle(textDecoration = decoration)) { - append(AnnotatedString(ans.text, spans)) - } - append(capturedTfv.getTextAfterSelection(len)) - } - when (p1.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)) - } - } - }, - selection = TextRange(end), - ) - } - - // Hacky: Notify BasicTextField clear state - onCopyRequested?.invoke() - return true - } - - override fun onDestroyActionMode(p0: ActionMode) { - actionMode = null - } - - override fun onGetContentRect(mode: ActionMode, view: View, outRect: PlatformRect) { - outRect.set(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt()) - } - }, - ActionMode.TYPE_FLOATING, - ) + actionMode = view.startActionMode(callback, ActionMode.TYPE_FLOATING) } else { actionMode?.invalidate() } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/TextActionModeCallback.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/TextActionModeCallback.kt new file mode 100644 index 000000000..ee2a20842 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/TextActionModeCallback.kt @@ -0,0 +1,116 @@ +/* + * 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) {} + + 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 +} diff --git a/app/src/main/res/menu/context_comment.xml b/app/src/main/res/menu/context_comment.xml index ce338f319..9453f6a5a 100644 --- a/app/src/main/res/menu/context_comment.xml +++ b/app/src/main/res/menu/context_comment.xml @@ -1,29 +1,32 @@ - - - - - - - + + + + + + + +