diff --git a/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt b/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt index 2053654b0..68d41da46 100644 --- a/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt +++ b/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt @@ -10,6 +10,7 @@ import android.widget.ArrayAdapter import android.widget.LinearLayout import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener import io.element.android.wysiwyg.EditorEditText import io.element.android.wysiwyg.poc.databinding.ViewRichTextEditorBinding import io.element.android.wysiwyg.poc.matrix.MatrixMentionMentionDisplayHandler @@ -42,6 +43,17 @@ class RichTextEditor : LinearLayout { super.onAttachedToWindow() with(binding) { + richTextEditText.addTextChangedListener( + beforeTextChanged = { text, start, count, after -> + Timber.d("Before text changed: '$text', start: $start, count: $count, after: $after") + }, + onTextChanged = { text, start, before, count -> + Timber.d("Text changed: '$text', start: $start, before: $before, count: $count") + }, + afterTextChanged = { text -> + Timber.d("After text changed: '$text'") + } + ) formattingSwitch.apply { isChecked = true setOnCheckedChangeListener { _, isChecked -> @@ -194,10 +206,9 @@ class RichTextEditor : LinearLayout { override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean { return binding.richTextEditText.requestFocus(direction, previouslyFocusedRect) } - } interface OnSetLinkListener { fun openSetLinkDialog(currentLink: String?, callback: (url: String?) -> Unit) fun openInsertLinkDialog(callback: (text: String, url: String) -> Unit) -} +} \ No newline at end of file diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/EditorEditTextInputTests.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/EditorEditTextInputTests.kt index 3a173a71a..a2c1b038e 100644 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/EditorEditTextInputTests.kt +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/EditorEditTextInputTests.kt @@ -31,7 +31,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.test.R import io.element.android.wysiwyg.test.rules.createFlakyEmulatorRule -import io.element.android.wysiwyg.test.utils.* +import io.element.android.wysiwyg.test.utils.AnyViewAction +import io.element.android.wysiwyg.test.utils.EditorActions +import io.element.android.wysiwyg.test.utils.ImeActions +import io.element.android.wysiwyg.test.utils.ScreenshotFailureHandler +import io.element.android.wysiwyg.test.utils.TestActivity +import io.element.android.wysiwyg.test.utils.TestMentionDisplayHandler +import io.element.android.wysiwyg.test.utils.selectionIsAt import io.element.android.wysiwyg.utils.NBSP import io.element.android.wysiwyg.utils.RustErrorCollector import io.element.android.wysiwyg.view.models.InlineFormat @@ -45,9 +51,12 @@ import io.mockk.verify import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers.containsString import org.hamcrest.Description -import org.hamcrest.MatcherAssert -import org.hamcrest.Matchers -import org.junit.* +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction @@ -145,7 +154,6 @@ class EditorEditTextInputTests { val emoji = "\uD83E\uDD17" onView(withId(R.id.rich_text_edit_text)) // pressKey doesn't seem to work if no `typeText` is used before - .perform(pressKey(KeyEvent.KEYCODE_A)) .perform(replaceText(emoji)) .perform(pressKey(KeyEvent.KEYCODE_ENTER)) .check(matches(withText(containsString(emoji + "\n")))) @@ -569,15 +577,15 @@ class EditorEditTextInputTests { verify(exactly = 2) { // In future when we support parsing/loading of pasted html into the model - // we can make more assertions on that the corrrect formating is applied - textWatcher.invoke(match { it.toString() == ipsum + ipsum }) + // we can make more assertions on that the correct formatting is applied + textWatcher(match { it.toString() == ipsum + ipsum }) } confirmVerified(contentWatcher) } - private fun pasteFromClipboard(clipData: ClipData, pasteAsPlainText: Boolean){ + private fun pasteFromClipboard(clipData: ClipData, pasteAsPlainText: Boolean) { scenarioRule.scenario.onActivity { activity -> val editor = activity.findViewById(R.id.rich_text_edit_text) val clipboardManager = diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt index 8f0719328..59a3b65e5 100644 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt @@ -5,9 +5,11 @@ import android.view.KeyEvent import android.view.inputmethod.EditorInfo import android.widget.EditText import androidx.test.core.app.ApplicationProvider +import io.element.android.wysiwyg.EditorTextWatcher import io.element.android.wysiwyg.fakes.createFakeStyleConfig import io.element.android.wysiwyg.internal.viewmodel.EditorInputAction import io.element.android.wysiwyg.internal.viewmodel.EditorViewModel +import io.element.android.wysiwyg.test.utils.Editor import io.element.android.wysiwyg.test.utils.dumpSpans import io.element.android.wysiwyg.utils.HtmlConverter import io.element.android.wysiwyg.utils.NBSP @@ -34,6 +36,7 @@ class InterceptInputConnectionIntegrationTest { viewModel = viewModel, editorEditText = textView, baseInputConnection = textView.onCreateInputConnection(EditorInfo()), + textWatcher = EditorTextWatcher(), ) private val baseEditedSpans = listOf( diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorEditText.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorEditText.kt index 286b3cc0e..db1e0e127 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorEditText.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorEditText.kt @@ -10,6 +10,7 @@ import android.os.Build import android.os.Parcelable import android.text.Selection import android.text.Spanned +import android.text.TextWatcher import android.util.AttributeSet import android.view.KeyEvent import android.view.MotionEvent @@ -20,7 +21,6 @@ import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.AppCompatEditText import androidx.core.graphics.withTranslation import androidx.core.view.ViewCompat -import androidx.lifecycle.* import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.inputhandlers.InterceptInputConnection import io.element.android.wysiwyg.internal.display.MemoizingMentionDisplayHandler @@ -29,21 +29,27 @@ import io.element.android.wysiwyg.internal.view.EditorEditTextAttributeReader import io.element.android.wysiwyg.internal.view.viewModel import io.element.android.wysiwyg.internal.viewmodel.EditorInputAction import io.element.android.wysiwyg.internal.viewmodel.EditorViewModel -import io.element.android.wysiwyg.utils.* +import io.element.android.wysiwyg.utils.EditorIndexMapper +import io.element.android.wysiwyg.utils.HtmlConverter import io.element.android.wysiwyg.utils.HtmlToSpansParser.FormattingSpans.removeFormattingSpans +import io.element.android.wysiwyg.utils.RustErrorCollector import io.element.android.wysiwyg.view.StyleConfig import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelper import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelperFactory import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction import io.element.android.wysiwyg.view.spans.ReuseSourceSpannableFactory -import uniffi.wysiwyg_composer.* +import uniffi.wysiwyg_composer.ActionState +import uniffi.wysiwyg_composer.ComposerAction +import uniffi.wysiwyg_composer.MentionsState +import uniffi.wysiwyg_composer.MenuAction +import uniffi.wysiwyg_composer.newComposerModel /** * An [EditText] that handles rich text editing. */ class EditorEditText : AppCompatEditText { - + private lateinit var textWatcher: EditorTextWatcher private var inputConnection: InterceptInputConnection? = null /** @@ -75,7 +81,7 @@ class EditorEditText : AppCompatEditText { rerender() } - private fun createHtmlConverter(styleConfig: StyleConfig, mentionDisplayHandler: MentionDisplayHandler?): HtmlConverter? { + private fun createHtmlConverter(styleConfig: StyleConfig, mentionDisplayHandler: MentionDisplayHandler?): HtmlConverter { return HtmlConverter.Factory.create( context = context.applicationContext, styleConfig = styleConfig, @@ -209,7 +215,7 @@ class EditorEditText : AppCompatEditText { override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { val baseInputConnection = requireNotNull(super.onCreateInputConnection(outAttrs)) val inputConnection = - InterceptInputConnection(baseInputConnection, this, viewModel) + InterceptInputConnection(baseInputConnection, this, viewModel, textWatcher) this.inputConnection = inputConnection return inputConnection } @@ -521,9 +527,6 @@ class EditorEditText : AppCompatEditText { /** * Set a mention link that applies to the current suggestion range - * - * @param url The url of the new link - * @param text The text to insert into the current suggestion range */ fun insertAtRoomMentionAtSuggestion() { val result = viewModel.processInput( @@ -578,6 +581,18 @@ class EditorEditText : AppCompatEditText { ) } + override fun addTextChangedListener(watcher: TextWatcher) { + if (!this::textWatcher.isInitialized) { + this.textWatcher = EditorTextWatcher() + super.addTextChangedListener(this.textWatcher) + } + textWatcher.addChild(watcher) + } + + override fun removeTextChangedListener(watcher: TextWatcher) { + textWatcher.removeChild(watcher) + } + /** * Force redisplay the current editor model. * @@ -591,8 +606,14 @@ class EditorEditText : AppCompatEditText { private fun setTextFromComposerUpdate(text: CharSequence) { beginBatchEdit() - editableText.removeFormattingSpans() - editableText.replace(0, editableText.length, text) + textWatcher.runInEditor { + val beforeLength = editableText.length + notifyBeforeTextChanged(editableText, 0, beforeLength, text.length) + editableText.removeFormattingSpans() + editableText.replace(0, editableText.length, text) + notifyOnTextChanged(editableText, 0, beforeLength, text.length) + notifyAfterTextChanged(editableText) + } endBatchEdit() } diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt new file mode 100644 index 000000000..dc2b10378 --- /dev/null +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt @@ -0,0 +1,110 @@ +package io.element.android.wysiwyg + +import android.text.Editable +import android.text.TextWatcher +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A [TextWatcher] that intercepts changes in the underlying text and can have child watchers. + */ +internal class EditorTextWatcher: TextWatcher { + private val nestedWatchers: MutableList = mutableListOf() + private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(false) + + var enableDebugLogs = false + + private var beforeText: CharSequence? = null + + /** + * The callback to be called when the text changes unexpectedly. + * These changes don't come from the editor, they might come from the OS or other sources. + */ + var updateCallback: (CharSequence, Int, Int, CharSequence?) -> Unit = { _, _, _, _ -> } + + /** + * Add a child watcher to be notified of text changes. + */ + fun addChild(watcher: TextWatcher) { + nestedWatchers.add(watcher) + } + + /** + * Remove a child watcher from being notified of text changes. + */ + fun removeChild(watcher: TextWatcher) { + nestedWatchers.remove(watcher) + } + + /** + * Run a block of code that comes from the editor. + * + * This is used to prevent the editor from updating itself when it's already updating itself and + * entering an endless loop. + * + * @param block The block of code to run. + */ + fun runInEditor(block: EditorTextWatcher.() -> Unit) { + updateIsFromEditor.set(true) + this.block() + updateIsFromEditor.set(false) + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + if (enableDebugLogs) { + Timber.v("beforeTextChanged | text: \"$s\", start: $start, count: $count, after: $after") + } + if (!updateIsFromEditor.get()) { + beforeText = s?.subSequence(start, start + count) + } + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (enableDebugLogs) { + Timber.v("onTextChanged | text: \"$s\", start: $start, before: $before, count: $count") + } + if (!updateIsFromEditor.get()) { + val newText = s?.subSequence(start, start + count) ?: "" + updateCallback(newText, start, start + before, beforeText) + } + } + + override fun afterTextChanged(s: Editable?) { + if (enableDebugLogs) { + Timber.v("afterTextChanged") + } + if (!updateIsFromEditor.get()) { + beforeText = null + } + } + + /** + * Notify the nested watchers that the text is about to change. + * @param text The text that will be changed. + * @param start The start index of the change. + * @param count The number of characters that will be removed. + * @param after The number of characters that will be added. + */ + fun notifyBeforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) { + nestedWatchers.forEach { it.beforeTextChanged(text, start, count, after) } + } + + /** + * Notify the nested watchers that the text is changing. + * @param text The new text. + * @param start The start index of the change. + * @param before The number of characters that were removed. + * @param count The number of characters that were added. + */ + fun notifyOnTextChanged(text: CharSequence, start: Int, before: Int, count: Int) { + nestedWatchers.forEach { it.onTextChanged(text, start, before, count) } + } + + /** + * Notify the nested watchers that the text changed. + * @param editable The updated text. + */ + fun notifyAfterTextChanged(editable: Editable) { + nestedWatchers.forEach { it.afterTextChanged(editable) } + } +} \ No newline at end of file diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnection.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnection.kt index 23cf79df8..7b0f744bd 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnection.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnection.kt @@ -10,6 +10,7 @@ import android.widget.TextView import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting import androidx.core.text.isDigitsOnly +import io.element.android.wysiwyg.EditorTextWatcher import io.element.android.wysiwyg.internal.utils.TextRangeHelper import io.element.android.wysiwyg.internal.viewmodel.EditorInputAction import io.element.android.wysiwyg.internal.viewmodel.ReplaceTextResult @@ -24,7 +25,13 @@ internal class InterceptInputConnection( private val baseInputConnection: InputConnection, private val editorEditText: TextView, private val viewModel: EditorViewModel, + private val textWatcher: EditorTextWatcher, ) : BaseInputConnection(editorEditText, true) { + init { + textWatcher.updateCallback = { updatedText, start, end, previousText -> + replaceTextInternal(start, end, updatedText, previousText) + } + } override fun getEditable(): Editable { return editorEditText.editableText @@ -119,14 +126,14 @@ internal class InterceptInputConnection( finishComposingText() return false } - val result = processTextEntry(text, start, end) + val result = processTextEntry(text, start, end, null) return if (result != null) { beginBatchEdit() val newStart = start.coerceIn(0, result.text.length) val newEnd = (newStart + text.length).coerceIn(newStart, result.text.length) - replaceAll(result.text) + replaceAll(charSequence = result.text, start = start, end = end, newEnd = newEnd) val editorSelectionIndex = editorIndex(result.selection.last, editable) setSelection(editorSelectionIndex, editorSelectionIndex) setComposingRegion(newStart, newEnd) @@ -138,9 +145,9 @@ internal class InterceptInputConnection( } // Called for suggestion from IME selected - override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean { val (start, end) = getCurrentCompositionOrSelection() - return replaceTextInternal(start, end, text) + return replaceTextInternal(start, end, text, null) } // In Android 13+, this is called instead of [commitText] when selecting suggestions from IME @@ -151,19 +158,20 @@ internal class InterceptInputConnection( newCursorPosition: Int, textAttribute: TextAttribute? ): Boolean { - return replaceTextInternal(start, end, text) + return replaceTextInternal(start, end, text, null) } private fun replaceTextInternal( start: Int, end: Int, - text: CharSequence?, + text: CharSequence, + oldText: CharSequence?, ): Boolean { - val result = processTextEntry(text, start, end) + val result = processTextEntry(text, start, end, oldText?.toString()) return if (result != null) { beginBatchEdit() - replaceAll(result.text) + replaceAll(charSequence = result.text, start = start, end = end, newEnd = start + text.length) val editorSelectionIndex = editorIndex(result.selection.last, editable) setSelection(editorSelectionIndex, editorSelectionIndex) setComposingRegion(editorSelectionIndex, editorSelectionIndex) @@ -174,8 +182,8 @@ internal class InterceptInputConnection( } } - private fun processTextEntry(newText: CharSequence?, start: Int, end: Int): ReplaceTextResult? { - val previousText = editable.substring(start until end) + private fun processTextEntry(newText: CharSequence?, start: Int, end: Int, previousText: String?): ReplaceTextResult? { + val actualPreviousText = previousText ?: editable.substring(start until end) return withProcessor { when { // Special case for whitespace, to keep the formatting status we need to add it first @@ -186,7 +194,7 @@ internal class InterceptInputConnection( // First add whitespace var result = processInput(EditorInputAction.ReplaceTextIn(cEnd, cEnd, " ")) // Then replace text if needed - if (toAppend != previousText) { + if (toAppend != actualPreviousText) { result = processInput(EditorInputAction.ReplaceTextIn(cStart, cEnd, toAppend))?.let { // Fix selection to include whitespace at the end val prevSelection = it.selection @@ -199,18 +207,18 @@ internal class InterceptInputConnection( newText?.lastOrNull() == '\n' -> { processInput(EditorInputAction.InsertParagraph) } - previousText.isNotEmpty() && newText?.startsWith(previousText) == true -> { + actualPreviousText.isNotEmpty() && newText?.startsWith(actualPreviousText) == true -> { // Appending new text at the end val pos = end - start - val diff = newText.length - previousText.length + val diff = newText.length - actualPreviousText.length val toAppend = newText.substring(pos until pos + diff) val (_, cEnd) = EditorIndexMapper.fromEditorToComposer(start, end, editable) ?: error("Invalid indexes in composer $start, $end") processInput(EditorInputAction.ReplaceTextIn(cEnd, cEnd, toAppend)) } - newText != null && previousText.startsWith(newText) -> { + newText != null && actualPreviousText.startsWith(newText) -> { // Removing text from the end - val diff = previousText.length - newText.length + val diff = actualPreviousText.length - newText.length val pos = end - diff val (cStart, cEnd) = EditorIndexMapper.fromEditorToComposer(pos, end, editable) ?: error("Invalid indexes in composer $pos, $end") @@ -307,7 +315,7 @@ internal class InterceptInputConnection( processInput(action) } if (result != null) { - replaceAll(result.text) + replaceAll(result.text, start = end, end = end + afterLength, newEnd = end) val editorSelectionIndex = editorIndex(result.selection.first, editable) setSelection(editorSelectionIndex, editorSelectionIndex) setComposingRegion(editorSelectionIndex, editorSelectionIndex) @@ -329,7 +337,7 @@ internal class InterceptInputConnection( processInput(EditorInputAction.BackPress) } if (result != null) { - replaceAll(result.text) + replaceAll(result.text, start = start - beforeLength, end = start, newEnd = start - beforeLength) val editorSelectionIndex = editorIndex(result.selection.first, editable) setSelection(editorSelectionIndex, editorSelectionIndex) setComposingRegion(editorSelectionIndex, editorSelectionIndex) @@ -364,10 +372,21 @@ internal class InterceptInputConnection( return start to end } - private fun replaceAll(charSequence: CharSequence) { - editable.removeFormattingSpans() - editable.clear() - editable.append(charSequence) + private fun replaceAll( + charSequence: CharSequence, + start: Int = 0, + end: Int = editable.length, + newEnd: Int = charSequence.length + ) { + val clampedAfterCount = (newEnd.coerceAtMost(charSequence.length) - start).coerceAtLeast(0) + textWatcher.runInEditor { + notifyBeforeTextChanged(editable, start, end - start, clampedAfterCount) + editable.removeFormattingSpans() + editable.clear() + editable.append(charSequence) + notifyOnTextChanged(editable, start, end - start, clampedAfterCount) + notifyAfterTextChanged(editable) + } } private fun editorIndex(composerIndex: Int, editable: Editable): Int { diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt index 9f4d05709..7e82469e4 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt @@ -74,8 +74,8 @@ internal class EditorViewModel( // This conversion to a plain String might be too simple composer?.replaceTextIn( action.value.toString(), - action.start.toUInt(), - action.end.toUInt() + action.start, + action.end ) }