Fix editor crash

This commit is contained in:
世界
2026-07-06 21:15:24 +08:00
parent 06dd8ce686
commit 90c0e2a0fb
2 changed files with 104 additions and 98 deletions
@@ -51,6 +51,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -449,20 +450,23 @@ fun EditProfileContentScreen(
setBackgroundColor( setBackgroundColor(
androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent), androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent),
) )
// Set up the editor with read-only state - this handles all configuration viewModel.attachEditor(this)
viewModel.setEditor(this, uiState.isReadOnly)
} }
}, },
update = { textProcessor ->
// Re-apply configuration when read-only state changes
viewModel.setEditor(textProcessor, uiState.isReadOnly)
},
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) )
LaunchedEffect(uiState.isReadOnly) {
viewModel.setReadOnly(uiState.isReadOnly)
}
DisposableEffect(Unit) {
onDispose { viewModel.detachEditor() }
}
// Simple loading indicator at the top // Simple loading indicator at the top
if (uiState.isLoading) { if (uiState.isLoading) {
LinearProgressIndicator( LinearProgressIndicator(
@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.compose.screen.profile package io.nekohasekai.sfa.compose.screen.profile
import android.text.TextWatcher
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -21,7 +22,6 @@ import java.io.File
data class EditProfileContentUiState( data class EditProfileContentUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val content: String = "",
val originalContent: String = "", val originalContent: String = "",
val hasUnsavedChanges: Boolean = false, val hasUnsavedChanges: Boolean = false,
val canUndo: Boolean = false, val canUndo: Boolean = false,
@@ -49,107 +49,103 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
private var profile: Profile? = null private var profile: Profile? = null
private var editor: ManualScrollTextProcessor? = null private var editor: ManualScrollTextProcessor? = null
private var textWatcher: TextWatcher? = null
private var configCheckJob: Job? = null private var configCheckJob: Job? = null
fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) { private val readOnlyKeyListener = android.view.View.OnKeyListener { _, _, _ -> true }
val isNewEditor = editor != textProcessor
private val readOnlySelectionCallback =
object : android.view.ActionMode.Callback {
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean = true
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
menu?.let { m ->
m.removeItem(android.R.id.cut)
m.removeItem(android.R.id.paste)
m.removeItem(android.R.id.pasteAsPlainText)
m.removeItem(android.R.id.replaceText)
m.removeItem(android.R.id.undo)
m.removeItem(android.R.id.redo)
m.removeItem(android.R.id.autofill)
m.removeItem(android.R.id.textAssist)
}
return true
}
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean = false
override fun onDestroyActionMode(mode: android.view.ActionMode?) {}
}
fun attachEditor(textProcessor: ManualScrollTextProcessor) {
editor = textProcessor editor = textProcessor
textProcessor.resumeAutoScroll() textProcessor.resumeAutoScroll()
// Always keep these for scrolling, focus, and selection
textProcessor.isEnabled = true textProcessor.isEnabled = true
textProcessor.isFocusable = true textProcessor.isFocusable = true
textProcessor.isFocusableInTouchMode = true textProcessor.isFocusableInTouchMode = true
// Allow text selection for copying
textProcessor.setTextIsSelectable(true) textProcessor.setTextIsSelectable(true)
// Multi-line configuration
textProcessor.setSingleLine(false) textProcessor.setSingleLine(false)
textProcessor.maxLines = Integer.MAX_VALUE textProcessor.maxLines = Integer.MAX_VALUE
textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
textProcessor.isCursorVisible = true textProcessor.isCursorVisible = true
textProcessor.isLongClickable = true
if (isReadOnly) { textWatcher = textProcessor.addTextChangedListener { editable ->
// Use a custom OnKeyListener that blocks all key input val length = editable?.length ?: 0
textProcessor.setOnKeyListener { _, _, _ -> true } // Return true to consume all key events val original = _uiState.value.originalContent
// Enable long click for selection val hasUnsavedChanges = when {
textProcessor.isLongClickable = true length != original.length -> true
editable == null -> original.isNotEmpty()
// Customize text selection to remove Cut and Paste options else -> editable.toString() != original
textProcessor.customSelectionActionModeCallback =
object : android.view.ActionMode.Callback {
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
// Allow the action mode to be created
return true
}
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean {
// Remove editing-related menu items, keep only Copy and Select All
menu?.let { m ->
// Remove all editing-related items
m.removeItem(android.R.id.cut)
m.removeItem(android.R.id.paste)
m.removeItem(android.R.id.pasteAsPlainText)
m.removeItem(android.R.id.replaceText)
m.removeItem(android.R.id.undo)
m.removeItem(android.R.id.redo)
m.removeItem(android.R.id.autofill)
m.removeItem(android.R.id.textAssist)
}
return true
}
override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean {
// Let the default implementation handle allowed actions (copy, select all)
return false
}
override fun onDestroyActionMode(mode: android.view.ActionMode?) {
// No special cleanup needed
}
}
} else {
// For editable mode, remove the blocking listener
textProcessor.setOnKeyListener(null)
// Remove the custom selection callback to allow all text operations
textProcessor.customSelectionActionModeCallback = null
// Only add text change listener for new editors in editable mode
if (isNewEditor) {
textProcessor.addTextChangedListener { editable ->
val currentText = editable?.toString() ?: ""
_uiState.update { state ->
state.copy(
content = currentText,
canUndo = textProcessor.canUndo(),
canRedo = textProcessor.canRedo(),
hasUnsavedChanges = currentText != state.originalContent,
)
}
// Schedule background configuration check
scheduleConfigurationCheck(currentText)
}
} }
_uiState.update { state ->
state.copy(
canUndo = textProcessor.canUndo(),
canRedo = textProcessor.canRedo(),
hasUnsavedChanges = hasUnsavedChanges,
)
}
scheduleConfigurationCheck()
} }
} }
private fun scheduleConfigurationCheck(content: String) { fun setReadOnly(isReadOnly: Boolean) {
// Cancel previous check val textProcessor = editor ?: return
if (isReadOnly) {
textProcessor.setOnKeyListener(readOnlyKeyListener)
textProcessor.customSelectionActionModeCallback = readOnlySelectionCallback
} else {
textProcessor.setOnKeyListener(null)
textProcessor.customSelectionActionModeCallback = null
}
}
fun detachEditor() {
val textProcessor = editor ?: return
textWatcher?.let { textProcessor.removeTextChangedListener(it) }
textWatcher = null
editor = null
configCheckJob?.cancel()
configCheckJob = null
}
private fun scheduleConfigurationCheck() {
configCheckJob?.cancel() configCheckJob?.cancel()
// Clear error immediately when user is typing if (_uiState.value.configurationError != null) {
_uiState.update { it.copy(configurationError = null) } _uiState.update { it.copy(configurationError = null) }
}
// Schedule new check after 2 seconds of inactivity
configCheckJob = configCheckJob =
viewModelScope.launch { viewModelScope.launch {
delay(2000) // Wait 2 seconds delay(2000)
val content =
// Check configuration in background withContext(Dispatchers.Main) {
editor?.text?.toString().orEmpty()
}
checkConfigurationInBackground(content) checkConfigurationInBackground(content)
} }
} }
@@ -206,7 +202,6 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
} }
_uiState.update { _uiState.update {
it.copy( it.copy(
content = content,
originalContent = content, originalContent = content,
hasUnsavedChanges = false, hasUnsavedChanges = false,
isLoading = false, isLoading = false,
@@ -480,21 +475,28 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly
fun insertSymbol(symbol: String) { fun insertSymbol(symbol: String) {
editor?.let { textProcessor -> editor?.let { textProcessor ->
val start = textProcessor.selectionStart val text = textProcessor.text ?: return@let
val end = textProcessor.selectionEnd val rawStart = textProcessor.selectionStart
val text = textProcessor.text val rawEnd = textProcessor.selectionEnd
// selectionStart/End can be reversed (backward drag-selection) or -1 (no cursor)
if (text != null) { val start: Int
val newText = val end: Int
StringBuilder(text) if (rawStart < 0 || rawEnd < 0) {
.replace(start, end, symbol) start = text.length
.toString() end = text.length
} else {
textProcessor.resumeAutoScroll() start = minOf(rawStart, rawEnd).coerceIn(0, text.length)
textProcessor.setTextContent(newText) end = maxOf(rawStart, rawEnd).coerceIn(0, text.length)
// Place cursor after the inserted symbol
textProcessor.setSelection(start + symbol.length)
} }
val newText =
StringBuilder(text)
.replace(start, end, symbol)
.toString()
textProcessor.resumeAutoScroll()
textProcessor.setTextContent(newText)
textProcessor.setSelection(start + symbol.length)
} }
} }