From 82c65be3d3d15b6dd36fd790d4ae5caf329cb3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 18 Mar 2024 08:17:47 +0100 Subject: [PATCH 1/5] Android: reconcile UI text with editor text This should help in cases where the UI text is modified externally by the OS or some app. --- .../android/wysiwyg/poc/RichTextEditor.kt | 4 +- .../wysiwyg/EditorEditTextInputTests.kt | 24 +++-- ...InterceptInputConnectionIntegrationTest.kt | 3 + .../element/android/wysiwyg/EditorEditText.kt | 88 ++++++++++++++++++- .../inputhandlers/InterceptInputConnection.kt | 42 ++++++--- .../internal/viewmodel/EditorViewModel.kt | 4 +- 6 files changed, 134 insertions(+), 31 deletions(-) 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..4491ef006 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 @@ -16,7 +16,6 @@ import io.element.android.wysiwyg.poc.matrix.MatrixMentionMentionDisplayHandler import io.element.android.wysiwyg.poc.matrix.Mention import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction -import timber.log.Timber import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction import uniffi.wysiwyg_composer.MenuAction @@ -194,10 +193,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 1199c84a6..8711075df 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 @@ -4,9 +4,11 @@ import android.app.Application 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 @@ -33,6 +35,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..4ba7cae65 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 @@ -8,8 +8,10 @@ import android.graphics.Canvas import android.net.Uri import android.os.Build import android.os.Parcelable +import android.text.Editable import android.text.Selection import android.text.Spanned +import android.text.TextWatcher import android.util.AttributeSet import android.view.KeyEvent import android.view.MotionEvent @@ -37,13 +39,15 @@ 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 timber.log.Timber import uniffi.wysiwyg_composer.* +import java.util.concurrent.atomic.AtomicBoolean /** * An [EditText] that handles rich text editing. */ class EditorEditText : AppCompatEditText { - + private lateinit var textWatcher: EditorTextWatcher private var inputConnection: InterceptInputConnection? = null /** @@ -209,7 +213,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 } @@ -578,6 +582,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 +607,14 @@ class EditorEditText : AppCompatEditText { private fun setTextFromComposerUpdate(text: CharSequence) { beginBatchEdit() - editableText.removeFormattingSpans() - editableText.replace(0, editableText.length, text) + textWatcher.inEditor { + val beforeLength = editableText.length + textWatcher.notifyBeforeTextChanged(editableText, 0, beforeLength, text.length) + editableText.removeFormattingSpans() + editableText.replace(0, editableText.length, text) + textWatcher.notifyOnTextChanged(editableText, 0, beforeLength, text.length) + textWatcher.notifyAfterTextChanged(editableText) + } endBatchEdit() } @@ -615,3 +637,61 @@ private fun KeyEvent.isMovementKey(): Boolean { private fun KeyEvent.isPrintableCharacter(): Boolean { return isPrintingKey || keyCode == KeyEvent.KEYCODE_SPACE } + +class EditorTextWatcher: TextWatcher { + private val nestedWatchers: MutableList = mutableListOf() + + private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(false) + + var updateCallback: (CharSequence, Int, Int, CharSequence?) -> Unit = { _, _, _, _ -> } + + fun addChild(watcher: TextWatcher) { + nestedWatchers.add(watcher) + } + + fun removeChild(watcher: TextWatcher) { + nestedWatchers.remove(watcher) + } + + fun inEditor(block: () -> Unit) { + updateIsFromEditor.set(true) + block() + updateIsFromEditor.set(false) + } + + private var beforeText: CharSequence? = null + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + 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) { + 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?) { + Timber.v("afterTextChanged") + if (!updateIsFromEditor.get()) { + beforeText = null + } + } + + fun notifyBeforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + nestedWatchers.forEach { it.beforeTextChanged(s, start, count, after) } + } + + fun notifyOnTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + nestedWatchers.forEach { it.onTextChanged(s, start, before, count) } + } + + fun notifyAfterTextChanged(s: Editable?) { + nestedWatchers.forEach { it.afterTextChanged(s) } + } +} \ 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 d3dff91b5..d902aac15 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 @@ -114,7 +121,7 @@ internal class InterceptInputConnection( // Called when started typing override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean { val (start, end) = getCurrentCompositionOrSelection() - val result = processTextEntry(text, start, end) + val result = processTextEntry(text, start, end, null) return if (result != null) { beginBatchEdit() @@ -135,7 +142,7 @@ internal class InterceptInputConnection( // Called for suggestion from IME selected 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 @@ -146,15 +153,16 @@ 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?, + oldText: CharSequence?, ): Boolean { - val result = processTextEntry(text, start, end) + val result = processTextEntry(text, start, end, oldText?.toString()) return if (result != null) { beginBatchEdit() @@ -169,8 +177,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 @@ -181,7 +189,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 @@ -194,18 +202,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") @@ -360,9 +368,15 @@ internal class InterceptInputConnection( } private fun replaceAll(charSequence: CharSequence) { - editable.removeFormattingSpans() - editable.clear() - editable.append(charSequence) + textWatcher.inEditor { + val beforeLength = editable.length + textWatcher.notifyBeforeTextChanged(editable, 0, beforeLength, charSequence.length) + editable.removeFormattingSpans() + editable.clear() + editable.append(charSequence) + textWatcher.notifyOnTextChanged(editable, 0, beforeLength, charSequence.length) + textWatcher.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 ) } From 3baf5362fceb59693ac5fa5c321c4e1801f26bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 20 Mar 2024 11:47:39 +0100 Subject: [PATCH 2/5] Improved string diffing notification with more accurate ranges --- .../inputhandlers/InterceptInputConnection.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 c458516c7..0767a82c8 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 @@ -133,7 +133,7 @@ internal class InterceptInputConnection( 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) @@ -145,7 +145,7 @@ 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, null) } @@ -164,14 +164,14 @@ internal class InterceptInputConnection( private fun replaceTextInternal( start: Int, end: Int, - text: CharSequence?, + text: CharSequence, oldText: CharSequence?, ): Boolean { 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) @@ -315,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) @@ -337,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) @@ -372,14 +372,19 @@ internal class InterceptInputConnection( return start to end } - private fun replaceAll(charSequence: 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.inEditor { - val beforeLength = editable.length - textWatcher.notifyBeforeTextChanged(editable, 0, beforeLength, charSequence.length) + textWatcher.notifyBeforeTextChanged(editable, start, end - start, clampedAfterCount) editable.removeFormattingSpans() editable.clear() editable.append(charSequence) - textWatcher.notifyOnTextChanged(editable, 0, beforeLength, charSequence.length) + textWatcher.notifyOnTextChanged(editable, start, end - start, clampedAfterCount) textWatcher.notifyAfterTextChanged(editable) } } From 7b097632c07dd619646ede7afcbb497f7a963752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 20 Mar 2024 11:57:28 +0100 Subject: [PATCH 3/5] Improve API and docs --- .../element/android/wysiwyg/EditorEditText.kt | 85 +++------------ .../android/wysiwyg/EditorTextWatcher.kt | 102 ++++++++++++++++++ .../inputhandlers/InterceptInputConnection.kt | 8 +- 3 files changed, 119 insertions(+), 76 deletions(-) create mode 100644 platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt 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 4ba7cae65..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 @@ -8,7 +8,6 @@ import android.graphics.Canvas import android.net.Uri import android.os.Build import android.os.Parcelable -import android.text.Editable import android.text.Selection import android.text.Spanned import android.text.TextWatcher @@ -22,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 @@ -31,17 +29,21 @@ 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 timber.log.Timber -import uniffi.wysiwyg_composer.* -import java.util.concurrent.atomic.AtomicBoolean +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. @@ -79,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, @@ -525,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( @@ -607,13 +606,13 @@ class EditorEditText : AppCompatEditText { private fun setTextFromComposerUpdate(text: CharSequence) { beginBatchEdit() - textWatcher.inEditor { + textWatcher.runInEditor { val beforeLength = editableText.length - textWatcher.notifyBeforeTextChanged(editableText, 0, beforeLength, text.length) + notifyBeforeTextChanged(editableText, 0, beforeLength, text.length) editableText.removeFormattingSpans() editableText.replace(0, editableText.length, text) - textWatcher.notifyOnTextChanged(editableText, 0, beforeLength, text.length) - textWatcher.notifyAfterTextChanged(editableText) + notifyOnTextChanged(editableText, 0, beforeLength, text.length) + notifyAfterTextChanged(editableText) } endBatchEdit() } @@ -637,61 +636,3 @@ private fun KeyEvent.isMovementKey(): Boolean { private fun KeyEvent.isPrintableCharacter(): Boolean { return isPrintingKey || keyCode == KeyEvent.KEYCODE_SPACE } - -class EditorTextWatcher: TextWatcher { - private val nestedWatchers: MutableList = mutableListOf() - - private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(false) - - var updateCallback: (CharSequence, Int, Int, CharSequence?) -> Unit = { _, _, _, _ -> } - - fun addChild(watcher: TextWatcher) { - nestedWatchers.add(watcher) - } - - fun removeChild(watcher: TextWatcher) { - nestedWatchers.remove(watcher) - } - - fun inEditor(block: () -> Unit) { - updateIsFromEditor.set(true) - block() - updateIsFromEditor.set(false) - } - - private var beforeText: CharSequence? = null - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - 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) { - 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?) { - Timber.v("afterTextChanged") - if (!updateIsFromEditor.get()) { - beforeText = null - } - } - - fun notifyBeforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - nestedWatchers.forEach { it.beforeTextChanged(s, start, count, after) } - } - - fun notifyOnTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - nestedWatchers.forEach { it.onTextChanged(s, start, before, count) } - } - - fun notifyAfterTextChanged(s: Editable?) { - nestedWatchers.forEach { it.afterTextChanged(s) } - } -} \ No newline at end of file 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..6162ad1bf --- /dev/null +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/EditorTextWatcher.kt @@ -0,0 +1,102 @@ +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. + */ +class EditorTextWatcher: TextWatcher { + private val nestedWatchers: MutableList = mutableListOf() + private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(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) { + 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) { + 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?) { + 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 0767a82c8..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 @@ -379,13 +379,13 @@ internal class InterceptInputConnection( newEnd: Int = charSequence.length ) { val clampedAfterCount = (newEnd.coerceAtMost(charSequence.length) - start).coerceAtLeast(0) - textWatcher.inEditor { - textWatcher.notifyBeforeTextChanged(editable, start, end - start, clampedAfterCount) + textWatcher.runInEditor { + notifyBeforeTextChanged(editable, start, end - start, clampedAfterCount) editable.removeFormattingSpans() editable.clear() editable.append(charSequence) - textWatcher.notifyOnTextChanged(editable, start, end - start, clampedAfterCount) - textWatcher.notifyAfterTextChanged(editable) + notifyOnTextChanged(editable, start, end - start, clampedAfterCount) + notifyAfterTextChanged(editable) } } From 027b0e891eafb462137c0524702f61832666c31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 20 Mar 2024 15:22:07 +0100 Subject: [PATCH 4/5] Disable logs in `EditorTextWatcher` by default, add text listener to `RichTextEditor` for easier debugging --- .../element/android/wysiwyg/poc/RichTextEditor.kt | 13 +++++++++++++ .../element/android/wysiwyg/EditorTextWatcher.kt | 14 +++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) 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 4491ef006..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,12 +10,14 @@ 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 import io.element.android.wysiwyg.poc.matrix.Mention import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction +import timber.log.Timber import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction import uniffi.wysiwyg_composer.MenuAction @@ -41,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 -> 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 index 6162ad1bf..938c6567d 100644 --- 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 @@ -12,6 +12,8 @@ class EditorTextWatcher: TextWatcher { private val nestedWatchers: MutableList = mutableListOf() private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(false) + var enableDebugLogs = false + private var beforeText: CharSequence? = null /** @@ -49,14 +51,18 @@ class EditorTextWatcher: TextWatcher { } override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - Timber.v("beforeTextChanged | text: \"$s\", start: $start, count: $count, after: $after") + 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) { - Timber.v("onTextChanged | text: \"$s\", start: $start, before: $before, count: $count") + 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) @@ -64,7 +70,9 @@ class EditorTextWatcher: TextWatcher { } override fun afterTextChanged(s: Editable?) { - Timber.v("afterTextChanged") + if (enableDebugLogs) { + Timber.v("afterTextChanged") + } if (!updateIsFromEditor.get()) { beforeText = null } From cc9abf6f128c7a03f6153ccf1ec5910a09c26a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 20 Mar 2024 16:41:52 +0100 Subject: [PATCH 5/5] Make `EditorTextWatcher` internal --- .../main/java/io/element/android/wysiwyg/EditorTextWatcher.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 938c6567d..dc2b10378 100644 --- 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 @@ -8,7 +8,7 @@ import java.util.concurrent.atomic.AtomicBoolean /** * A [TextWatcher] that intercepts changes in the underlying text and can have child watchers. */ -class EditorTextWatcher: TextWatcher { +internal class EditorTextWatcher: TextWatcher { private val nestedWatchers: MutableList = mutableListOf() private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(false)