diff --git a/platforms/android/example-compose/build.gradle b/platforms/android/example-compose/build.gradle index bd89a22c4..0b025ef14 100644 --- a/platforms/android/example-compose/build.gradle +++ b/platforms/android/example-compose/build.gradle @@ -5,7 +5,7 @@ plugins { android { namespace 'io.element.wysiwyg.compose' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "io.element.wysiwyg.compose" @@ -34,7 +34,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.5.1' + kotlinCompilerExtensionVersion '1.5.3' } packagingOptions { resources { diff --git a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt index 634845c56..0a19e6c03 100644 --- a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt +++ b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -28,6 +29,7 @@ import io.element.android.wysiwyg.view.models.LinkAction import io.element.wysiwyg.compose.ui.components.FormattingButtons import io.element.wysiwyg.compose.ui.theme.RichTextEditorTheme import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.launch import timber.log.Timber import uniffi.wysiwyg_composer.ComposerAction @@ -39,13 +41,22 @@ class MainActivity : ComponentActivity() { val state = rememberRichTextEditorState() var linkDialogAction by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + linkDialogAction?.let { linkAction -> LinkDialog( linkAction = linkAction, - onRemoveLink = state::removeLink, - onSetLink = state::setLink, - onInsertLink = state::insertLink, + onRemoveLink = { coroutineScope.launch { state.removeLink() } }, + onSetLink = { coroutineScope.launch { state.setLink(it) } }, + onInsertLink = { url, text -> + coroutineScope.launch { + state.insertLink( + url, + text + ) + } + }, onDismissRequest = { linkDialogAction = null } ) } @@ -61,10 +72,12 @@ class MainActivity : ComponentActivity() { modifier = Modifier .padding(8.dp) .border( - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant + ), ) - .padding(8.dp) - , + .padding(8.dp), color = MaterialTheme.colorScheme.surface, ) { RichTextEditor( @@ -76,36 +89,49 @@ class MainActivity : ComponentActivity() { } FormattingButtons( onResetText = { - state.setHtml("") + coroutineScope.launch { + state.setHtml("") + } }, actionStates = state.actions.toPersistentMap(), onActionClick = { - when (it) { - ComposerAction.BOLD -> state.toggleInlineFormat(InlineFormat.Bold) - ComposerAction.ITALIC -> state.toggleInlineFormat(InlineFormat.Italic) - ComposerAction.STRIKE_THROUGH -> state.toggleInlineFormat( - InlineFormat.StrikeThrough - ) + coroutineScope.launch { + when (it) { + ComposerAction.BOLD -> state.toggleInlineFormat( + InlineFormat.Bold + ) + + ComposerAction.ITALIC -> state.toggleInlineFormat( + InlineFormat.Italic + ) + + ComposerAction.STRIKE_THROUGH -> state.toggleInlineFormat( + InlineFormat.StrikeThrough + ) + + ComposerAction.UNDERLINE -> state.toggleInlineFormat( + InlineFormat.Underline + ) - ComposerAction.UNDERLINE -> state.toggleInlineFormat( - InlineFormat.Underline - ) + ComposerAction.INLINE_CODE -> state.toggleInlineFormat( + InlineFormat.InlineCode + ) - ComposerAction.INLINE_CODE -> state.toggleInlineFormat( - InlineFormat.InlineCode - ) + ComposerAction.LINK -> + linkDialogAction = state.linkAction - ComposerAction.LINK -> - linkDialogAction = state.linkAction + ComposerAction.UNDO -> state.undo() + ComposerAction.REDO -> state.redo() + ComposerAction.ORDERED_LIST -> state.toggleList(ordered = true) + ComposerAction.UNORDERED_LIST -> state.toggleList( + ordered = false + ) - ComposerAction.UNDO -> state.undo() - ComposerAction.REDO -> state.redo() - ComposerAction.ORDERED_LIST -> state.toggleList(ordered = true) - ComposerAction.UNORDERED_LIST -> state.toggleList(ordered = false) - ComposerAction.INDENT -> state.indent() - ComposerAction.UNINDENT -> state.unindent() - ComposerAction.CODE_BLOCK -> state.toggleCodeBlock() - ComposerAction.QUOTE -> state.toggleQuote() + ComposerAction.INDENT -> state.indent() + ComposerAction.UNINDENT -> state.unindent() + ComposerAction.CODE_BLOCK -> state.toggleCodeBlock() + ComposerAction.QUOTE -> state.toggleQuote() + } } } ) diff --git a/platforms/android/example-view/build.gradle b/platforms/android/example-view/build.gradle index 03e06d94f..2ce2c0ea6 100644 --- a/platforms/android/example-view/build.gradle +++ b/platforms/android/example-view/build.gradle @@ -6,7 +6,7 @@ plugins { android { namespace = "io.element.android.wysiwyg.poc" - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "io.element.android.wysiwyg.poc" diff --git a/platforms/android/gradle/libs.versions.toml b/platforms/android/gradle/libs.versions.toml index 7b5f526b6..e1e7a6150 100644 --- a/platforms/android/gradle/libs.versions.toml +++ b/platforms/android/gradle/libs.versions.toml @@ -6,7 +6,7 @@ activity-compose = "1.7.2" androidx-junit = "1.1.5" core-ktx = "1.10.1" coroutines = "1.7.2" -compose-bom = "2023.06.01" +compose-bom = "2023.09.00" lifecycle-runtime-ktx = "2.6.1" timber = "5.0.1" tagsoup = "1.2" @@ -22,9 +22,10 @@ hamcrest = "2.2" android-junit = "1.1.3" espresso = "3.5.1" agp = "8.1.0" -kotlin-android = "1.9.0" +kotlin-android = "1.9.10" jacoco = "0.8.8" maven-publish = "0.25.3" +molecule = "1.2.1" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -56,12 +57,15 @@ google-material = { module="com.google.android.material:material", version.ref=" # Misc timber = { module="com.jakewharton.timber:timber", version.ref="timber" } tagsoup = { module="org.ccil.cowan.tagsoup:tagsoup", version.ref="tagsoup" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } # Test test-junit = { module="junit:junit", version.ref="junit" } test-robolectric = { module="org.robolectric:robolectric", version.ref="robolectric" } test-mockk = { module="io.mockk:mockk", version.ref="mockk" } test-hamcrest = { module="org.hamcrest:hamcrest", version.ref="hamcrest" } +test-kotlin-coroutines = { module="org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref="coroutines" } +test-turbine = { module="app.cash.turbine:turbine", version="1.0.0" } # Android Test test-androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } diff --git a/platforms/android/library-compose/build.gradle b/platforms/android/library-compose/build.gradle index 0fcb05341..27c39c8b5 100644 --- a/platforms/android/library-compose/build.gradle +++ b/platforms/android/library-compose/build.gradle @@ -27,7 +27,7 @@ android { namespace 'io.element.android.wysiwyg.compose' testNamespace 'io.element.android.wysiwyg.compose.test' - compileSdk 33 + compileSdk 34 defaultConfig { minSdk 23 @@ -53,11 +53,14 @@ android { testCoverage { jacocoVersion = "0.8.8" } + testOptions { + unitTests.returnDefaultValues = true + } buildFeatures { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.5.1' + kotlinCompilerExtensionVersion '1.5.3' } packagingOptions { resources.excludes += 'META-INF/LICENSE.md' @@ -87,6 +90,9 @@ dependencies { testImplementation libs.test.junit testImplementation libs.test.mockk + testImplementation libs.test.kotlin.coroutines + testImplementation libs.test.turbine + testImplementation libs.molecule.runtime androidTestImplementation libs.test.androidx.junit androidTestImplementation libs.test.androidx.espresso androidTestImplementation libs.test.mockk.android diff --git a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorActionsTest.kt b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorActionsTest.kt index f4f126962..8187cdb2b 100644 --- a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorActionsTest.kt +++ b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorActionsTest.kt @@ -22,9 +22,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleInlineFormat(InlineFormat.Bold) - } + state.toggleInlineFormat(InlineFormat.Bold) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -42,9 +41,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleInlineFormat(InlineFormat.Italic) - } + state.toggleInlineFormat(InlineFormat.Italic) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -62,9 +60,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleInlineFormat(InlineFormat.StrikeThrough) - } + state.toggleInlineFormat(InlineFormat.StrikeThrough) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -82,9 +79,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleInlineFormat(InlineFormat.Underline) - } + state.toggleInlineFormat(InlineFormat.Underline) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -102,9 +98,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleQuote() - } + state.toggleQuote() + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -121,9 +116,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleCodeBlock() - } + state.toggleCodeBlock() + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -146,6 +140,7 @@ class RichTextEditorActionsTest { composeTestRule.showContent(state) state.toggleInlineFormat(InlineFormat.InlineCode) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -168,9 +163,8 @@ class RichTextEditorActionsTest { val state = StateFactory.createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleInlineFormat(InlineFormat.Bold) - } + state.toggleInlineFormat(InlineFormat.Bold) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -183,6 +177,7 @@ class RichTextEditorActionsTest { ) state.undo() + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -194,6 +189,7 @@ class RichTextEditorActionsTest { ) state.redo() + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -212,10 +208,10 @@ class RichTextEditorActionsTest { composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleList(ordered = true) - } - assertEquals( + state.toggleList(ordered = true) + composeTestRule.awaitIdle() + + assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( mapOf( ComposerAction.UNDO to ActionState.ENABLED, @@ -230,9 +226,8 @@ class RichTextEditorActionsTest { composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.toggleList(ordered = false) - } + state.toggleList(ordered = false) + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -250,9 +245,8 @@ class RichTextEditorActionsTest { composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("
  1. Test
  2. Test
") - } + state.setHtml("
  1. Test
  2. Test
") + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -263,9 +257,8 @@ class RichTextEditorActionsTest { ) ), state.actions) - composeTestRule.runOnUiThread { - state.indent() - } + state.indent() + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -284,9 +277,8 @@ class RichTextEditorActionsTest { composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("
  1. Test
    1. Test
") - } + state.setHtml("
  1. Test
    1. Test
") + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( @@ -297,9 +289,8 @@ class RichTextEditorActionsTest { ) ), state.actions) - composeTestRule.runOnUiThread { - state.unindent() - } + state.unindent() + composeTestRule.awaitIdle() assertEquals( ComposerActions.DEFAULT_ACTIONS.copy( diff --git a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStateTest.kt b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStateTest.kt index 8490f22f4..ec904e7bc 100644 --- a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStateTest.kt +++ b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStateTest.kt @@ -1,7 +1,17 @@ package io.element.android.wysiwyg.compose import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -10,14 +20,53 @@ class RichTextEditorStateTest { @get:Rule val composeTestRule = createComposeRule() - @Test(expected = IllegalStateException::class) + @Test fun testSharingState() = runTest { val state = RichTextEditorState() + val showAlternateEditor = MutableStateFlow(false) composeTestRule.setContent { MaterialTheme { - RichTextEditor(state) - RichTextEditor(state) + val showAlt by showAlternateEditor.collectAsState() + if(!showAlt) { + Text("Main editor") + RichTextEditor(state = state) + } else { + Text("Alternative editor") + RichTextEditor(state = state) + } } } + + state.setHtml("Hello, world") + composeTestRule.awaitIdle() + + composeTestRule.onNodeWithText("Main editor").assertIsDisplayed() + onView(withText("Hello, world")).check(matches(isDisplayed())) + + showAlternateEditor.emit(true) + composeTestRule.awaitIdle() + + composeTestRule.onNodeWithText("Alternative editor").assertIsDisplayed() + onView(withText("Hello, world")).check(matches(isDisplayed())) + } + + @Test + fun testStateUpdatesDisabled() = runTest { + val state = RichTextEditorState( + "Original text" + ) + composeTestRule.setContent { + MaterialTheme { + RichTextEditor( + state = state, + registerStateUpdates = false + ) + } + } + + state.setHtml("Updated text") + composeTestRule.awaitIdle() + + onView(withText("Original text")).check(matches(isDisplayed())) } } diff --git a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStyleTest.kt b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStyleTest.kt index 4e262186f..407be4420 100644 --- a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStyleTest.kt +++ b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorStyleTest.kt @@ -1,6 +1,5 @@ package io.element.android.wysiwyg.compose -import android.content.res.Resources.NotFoundException import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.collectAsState @@ -31,9 +30,7 @@ class RichTextEditorStyleTest { fun testContentIsStillDisplayedAfterSetStyle() = runTest { showContent() - composeTestRule.runOnUiThread { - state.setHtml("") - } + state.setHtml("") bulletRadius.emit(20.dp) diff --git a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorTest.kt b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorTest.kt index 4a0816ee9..95cbfea6d 100644 --- a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorTest.kt +++ b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/RichTextEditorTest.kt @@ -51,9 +51,8 @@ class RichTextEditorTest { val state = createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("Hello, world") - } + state.setHtml("Hello, world") + composeTestRule.awaitIdle() onView(withText("Hello, world")).check(matches(isDisplayed())) @@ -69,9 +68,8 @@ class RichTextEditorTest { val state = createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("Hello, world") - } + state.setHtml("Hello, world") + composeTestRule.awaitIdle() onView(withText("Hello, world")).check(matches(isDisplayed())) @@ -94,10 +92,9 @@ class RichTextEditorTest { val state = createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("Hello, ") - state.insertLink("https://element.io", "element") - } + state.setHtml("Hello, ") + state.insertLink("https://element.io", "element") + composeTestRule.awaitIdle() assertEquals("Hello, element", state.messageHtml) assertEquals("Hello, [element]()", state.messageMarkdown) @@ -108,10 +105,9 @@ class RichTextEditorTest { val state = createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("Hello, element") - state.removeLink() - } + state.setHtml("Hello, element") + state.removeLink() + composeTestRule.awaitIdle() assertEquals("Hello, element", state.messageHtml) assertEquals("Hello, element", state.messageMarkdown) @@ -122,10 +118,9 @@ class RichTextEditorTest { val state = createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("Hello, element") - state.setLink("https://matrix.org") - } + state.setHtml("Hello, element") + state.setLink("https://matrix.org") + composeTestRule.awaitIdle() assertEquals("Hello, element", state.messageHtml) assertEquals("Hello, [element]()", state.messageMarkdown) @@ -136,9 +131,8 @@ class RichTextEditorTest { val state = createState() composeTestRule.showContent(state) - composeTestRule.runOnUiThread { - state.setHtml("matrix element plain") - } + state.setHtml("matrix element plain") + composeTestRule.awaitIdle() onView(withText("matrix element plain")).perform(EditorActions.setSelection(0, 0)) assertEquals(LinkAction.SetLink("https://matrix.org"), state.linkAction) diff --git a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/testutils/ComposeTestRuleExt.kt b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/testutils/ComposeTestRuleExt.kt index 7109cd273..40dc9beb2 100644 --- a/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/testutils/ComposeTestRuleExt.kt +++ b/platforms/android/library-compose/src/androidTest/java/io/element/android/wysiwyg/compose/testutils/ComposeTestRuleExt.kt @@ -14,7 +14,7 @@ fun ComposeContentTestRule.showContent( ) = setContent { MaterialTheme { RichTextEditor( - state, modifier = Modifier.fillMaxWidth().background(Color.Cyan) + state = state, modifier = Modifier.fillMaxWidth().background(Color.Cyan) ) } } diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditor.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditor.kt index 174c26417..24fa7de4b 100644 --- a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditor.kt +++ b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditor.kt @@ -5,7 +5,7 @@ import android.util.TypedValue import android.view.View import androidx.appcompat.widget.AppCompatEditText import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext @@ -14,25 +14,31 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.widget.addTextChangedListener import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.compose.internal.ViewConnection +import io.element.android.wysiwyg.compose.internal.ViewAction import io.element.android.wysiwyg.compose.internal.toStyleConfig import io.element.android.wysiwyg.utils.RustErrorCollector -import io.element.android.wysiwyg.view.models.InlineFormat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + /** * A composable rich text editor. * * This composable is a wrapper around the [EditorEditText] view. * - * @param state The state holder for this composable. See [rememberRichTextEditorState]. + * To use within a subcomposition, set the [registerStateUpdates] parameter to false. + * * @param modifier The modifier for the layout + * @param state The state holder for this composable. See [rememberRichTextEditorState]. + * @param registerStateUpdates If true, register the state for updates. * @param style The styles to use for any customisable elements * @param onError Called when an internal error occurs */ @Composable fun RichTextEditor( - state: RichTextEditorState, modifier: Modifier = Modifier, + state: RichTextEditorState = rememberRichTextEditorState(), + registerStateUpdates: Boolean = true, style: RichTextEditorStyle = RichTextEditorDefaults.style(), onError: (Throwable) -> Unit = {}, ) { @@ -41,95 +47,85 @@ fun RichTextEditor( if (isPreview) { PreviewEditor(state, modifier, style) } else { - RealEditor(state, modifier, style, onError) + RealEditor(state, registerStateUpdates, modifier, style, onError) } } @Composable private fun RealEditor( state: RichTextEditorState, + registerStateUpdates: Boolean, modifier: Modifier = Modifier, - style: RichTextEditorStyle = RichTextEditorDefaults.style(), + style: RichTextEditorStyle, onError: (Throwable) -> Unit, ) { val context = LocalContext.current - // Clean up the connection between view and state holder - DisposableEffect(Unit) { - onDispose { - state.viewConnection = null - } - } + val coroutineScope = rememberCoroutineScope() AndroidView( modifier = modifier, factory = { - if (state.viewConnection != null) { - throw IllegalStateException( - "Instance of RichTextEditorState is already set up with another RichTextEditor." - ) - } - val view = EditorEditText(context).apply { - actionStatesChangedListener = - EditorEditText.OnActionStatesChangedListener { actionStates -> - state.actions = actionStates + if (registerStateUpdates) { + state.activeViewKey = hashCode() + actionStatesChangedListener = + EditorEditText.OnActionStatesChangedListener { actionStates -> + state.actions = actionStates + } + + selectionChangeListener = + EditorEditText.OnSelectionChangeListener { start, end -> + state.selection = start to end + } + menuActionListener = EditorEditText.OnMenuActionChangedListener { menuAction -> + state.menuAction = menuAction } - - selectionChangeListener = - EditorEditText.OnSelectionChangeListener { start, end -> - state.selection = start to end + linkActionChangedListener = + EditorEditText.OnLinkActionChangedListener { linkAction -> + state.linkAction = linkAction + } + addTextChangedListener { + state.internalHtml = getInternalHtml() + state.messageHtml = getContentAsMessageHtml() + state.messageMarkdown = getMarkdown() + state.lineCount = lineCount + } + val shouldRestoreFocus = state.hasFocus + if (shouldRestoreFocus) { + requestFocus() + } + onFocusChangeListener = View.OnFocusChangeListener { view, hasFocus -> + state.onFocusChanged(view.hashCode(), hasFocus) } - menuActionListener = EditorEditText.OnMenuActionChangedListener { menuAction -> - state.menuAction = menuAction - } - linkActionChangedListener = EditorEditText.OnLinkActionChangedListener { linkAction -> - state.linkAction = linkAction - } - onFocusChangeListener = - View.OnFocusChangeListener { _, hasFocus -> state.hasFocus = hasFocus } - - - addTextChangedListener { - state.messageHtml = getContentAsMessageHtml() - state.messageMarkdown = getMarkdown() - state.lineCount = lineCount } applyDefaultStyle() // Restore the state of the view with the saved state - setHtml(state.messageHtml) - } - - state.viewConnection = object : ViewConnection { - override fun toggleInlineFormat(inlineFormat: InlineFormat) = - view.toggleInlineFormat(inlineFormat) - - override fun undo() = view.undo() - - override fun redo() = view.redo() - - override fun toggleList(ordered: Boolean) = - view.toggleList(ordered) - - override fun indent() = view.indent() - - override fun unindent() = view.unindent() - - override fun toggleCodeBlock() = view.toggleCodeBlock() - - override fun toggleQuote() = view.toggleQuote() - - override fun setHtml(html: String) = view.setHtml(html) - - override fun requestFocus() = view.requestFocus() - - override fun setLink(url: String?) = view.setLink(url) - - override fun removeLink() = view.removeLink() - - override fun insertLink(url: String, text: String) = - view.insertLink(url, text) + setHtml(state.internalHtml) + + // Only start listening for text changes after the initial state has been restored + if (registerStateUpdates) { + coroutineScope.launch(context = Dispatchers.Main) { + state.viewActions.collect { + when (it) { + is ViewAction.ToggleInlineFormat -> toggleInlineFormat(it.inlineFormat) + is ViewAction.ToggleList -> toggleList(it.ordered) + is ViewAction.ToggleCodeBlock -> toggleCodeBlock() + is ViewAction.ToggleQuote -> toggleQuote() + is ViewAction.Undo -> undo() + is ViewAction.Redo -> redo() + is ViewAction.Indent -> indent() + is ViewAction.Unindent -> unindent() + is ViewAction.SetHtml -> setHtml(it.html) + is ViewAction.RequestFocus -> requestFocus() + is ViewAction.SetLink -> setLink(it.url) + is ViewAction.RemoveLink -> removeLink() + is ViewAction.InsertLink -> insertLink(it.url, it.text) + } + } + } + } } view @@ -146,7 +142,7 @@ private fun RealEditor( private fun PreviewEditor( state: RichTextEditorState, modifier: Modifier = Modifier, - style: RichTextEditorStyle = RichTextEditorDefaults.style(), + style: RichTextEditorStyle, ) { if (!LocalInspectionMode.current) { throw IllegalStateException("PreviewEditor should only be used in preview mode") diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorDefaults.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorDefaults.kt index aef13dadd..19edfcfe3 100644 --- a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorDefaults.kt +++ b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorDefaults.kt @@ -16,6 +16,9 @@ private val defaultCodeBorderWidth = 1.dp * Default config for the [RichTextEditor] composable. */ object RichTextEditorDefaults { + internal const val initialLineCount = 1 + internal const val initialHtml = "" + internal const val initialFocus = false /** * Creates the default set of style customisations for [RichTextEditor]. diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorState.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorState.kt index 4e221c1e3..05fb74533 100644 --- a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorState.kt +++ b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorState.kt @@ -1,17 +1,24 @@ package io.element.android.wysiwyg.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalInspectionMode import io.element.android.wysiwyg.compose.internal.FakeViewConnection -import io.element.android.wysiwyg.compose.internal.ViewConnection +import io.element.android.wysiwyg.compose.internal.ViewAction import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction import uniffi.wysiwyg_composer.MenuAction @@ -20,53 +27,46 @@ import uniffi.wysiwyg_composer.MenuAction * A state holder for the [RichTextEditor] composable. * * Create an instance using [rememberRichTextEditorState]. - * Ensure that [RichTextEditorState] is not shared between multiple [RichTextEditor] composables. - * - * Note that fake mode is only intended for use in preview or test environments and behaviour will - * not mirror that of the real editor. + * Ensure that [RichTextEditorState] is not shared between multiple [RichTextEditor] composables + * that are displayed at the same time. * * @param initialHtml The HTML formatted content to initialise the state with. - * @param fake If true, initialise the state for use in preview or test environment. + * @param initialLineCount The line count to initialise the state with. + * @param initialFocus The focus value to initialise the state with. */ +@Stable class RichTextEditorState( - initialHtml: String = "", - fake: Boolean = false, + initialHtml: String = RichTextEditorDefaults.initialHtml, + initialLineCount: Int = RichTextEditorDefaults.initialLineCount, + initialFocus: Boolean = RichTextEditorDefaults.initialFocus, ) { - internal var viewConnection: ViewConnection? by mutableStateOf(null) + // A unique key for the most recent view to subscribe + internal var activeViewKey: Any? by mutableStateOf(-1) - init { - if (fake) { - viewConnection = FakeViewConnection(this) - } - } - - private val initialLineCount = if (fake) { - initialHtml.count { it == '\n' } + 1 - } else { - 1 - } + private val _viewActions = MutableSharedFlow() + internal val viewActions: SharedFlow = _viewActions.asSharedFlow() /** * Toggle inline formatting on the current selection. * * @param inlineFormat which format to toggle (e.g. [InlineFormat.Bold]) */ - fun toggleInlineFormat(inlineFormat: InlineFormat) { - viewConnection?.toggleInlineFormat(inlineFormat) + suspend fun toggleInlineFormat(inlineFormat: InlineFormat) { + _viewActions.emit(ViewAction.ToggleInlineFormat(inlineFormat)) } /** * Undo the last action. */ - fun undo() { - viewConnection?.undo() + suspend fun undo() { + _viewActions.emit(ViewAction.Undo) } /** * Redo the last undone action. */ - fun redo() { - viewConnection?.redo() + suspend fun redo() { + _viewActions.emit(ViewAction.Redo) } /** @@ -74,43 +74,43 @@ class RichTextEditorState( * * @param ordered Whether the list should be ordered (numbered) or unordered (bulleted). */ - fun toggleList(ordered: Boolean) { - viewConnection?.toggleList(ordered) + suspend fun toggleList(ordered: Boolean) { + _viewActions.emit(ViewAction.ToggleList(ordered)) } /** * Indent the current selection. */ - fun indent() { - viewConnection?.indent() + suspend fun indent() { + _viewActions.emit(ViewAction.Indent) } /** * Unindent the current selection. */ - fun unindent() { - viewConnection?.unindent() + suspend fun unindent() { + _viewActions.emit(ViewAction.Unindent) } /** * Toggle code block formatting on the current selection. */ - fun toggleCodeBlock() { - viewConnection?.toggleCodeBlock() + suspend fun toggleCodeBlock() { + _viewActions.emit(ViewAction.ToggleCodeBlock) } /** * Toggle quote formatting on the current selection. */ - fun toggleQuote() { - viewConnection?.toggleQuote() + suspend fun toggleQuote() { + _viewActions.emit(ViewAction.ToggleQuote) } /** * Set the HTML content of the editor. */ - fun setHtml(html: String) { - viewConnection?.setHtml(html) + suspend fun setHtml(html: String) { + _viewActions.emit(ViewAction.SetHtml(html)) } /** @@ -118,14 +118,18 @@ class RichTextEditorState( * * @param url The link URL to set or null to remove */ - fun setLink(url: String?) = viewConnection?.setLink(url) + suspend fun setLink(url: String?) { + _viewActions.emit(ViewAction.SetLink(url)) + } /** * Remove a link for the current selection. Convenience for setLink(null). * * @see [setLink] */ - fun removeLink() = viewConnection?.removeLink() + suspend fun removeLink() { + _viewActions.emit(ViewAction.RemoveLink) + } /** * Insert new text with a link. @@ -133,7 +137,9 @@ class RichTextEditorState( * @param url The link URL to set * @param text The new text to insert */ - fun insertLink(url: String, text: String) = viewConnection?.insertLink(url, text) + suspend fun insertLink(url: String, text: String) { + _viewActions.emit(ViewAction.InsertLink(url, text)) + } /** * The content of the editor as HTML formatted for sending as a message. @@ -141,6 +147,14 @@ class RichTextEditorState( var messageHtml by mutableStateOf(initialHtml) internal set + /** + * The content of the editor as represented internally. + * + * Can be used to restore the editor state. + */ + internal var internalHtml by mutableStateOf(initialHtml) + internal set + /** * The content of the editor as markdown formatted for sending as a message. */ @@ -168,19 +182,32 @@ class RichTextEditorState( /** * Whether the editor input field currently has focus. */ - var hasFocus: Boolean by mutableStateOf(false) + var hasFocus: Boolean by mutableStateOf(initialFocus) internal set /** * Request focus of the editor input field. */ - fun requestFocus(): Boolean = - viewConnection?.requestFocus() ?: false + suspend fun requestFocus() { + _viewActions.emit(ViewAction.RequestFocus) + } + + /** + * Notify the state that focus has changed. + * + * Ignores the event if the view key does not match the current active view. + */ + internal fun onFocusChanged(viewKey: Any, hasFocus: Boolean) { + if (viewKey != activeViewKey) { + return + } + this.hasFocus = hasFocus + } /** * The number of lines displayed in the editor. */ - var lineCount: Int by mutableStateOf(initialLineCount) + var lineCount: Int by mutableIntStateOf(initialLineCount) internal set var linkAction: LinkAction? by mutableStateOf(null) @@ -191,23 +218,34 @@ class RichTextEditorState( /** * Create an instance of the [RichTextEditorState]. * - * Note that fake mode is only intended for use in preview or test environments and behaviour will - * not mirror that of the real editor. + * Initial values can be provided for preview or test environments. [RichTextEditor] will + * overwrite these values unless configured not to. * * @param initialHtml The HTML formatted content to initialise the state with. - * @param fake If true, initialise the state for use in preview or test environment. + * @param initialLineCount The line count to initialise the state with. + * @param initialFocus The value of hasFocus to initialise the state with. */ @Composable fun rememberRichTextEditorState( - initialHtml: String = "", - fake: Boolean = LocalInspectionMode.current, + initialHtml: String = RichTextEditorDefaults.initialHtml, + initialLineCount: Int = RichTextEditorDefaults.initialLineCount, + initialFocus: Boolean = RichTextEditorDefaults.initialFocus, + fake: Boolean = false, ): RichTextEditorState { - return rememberSaveable(saver = RichTextEditorStateSaver) { + val state = rememberSaveable(saver = RichTextEditorStateSaver) { RichTextEditorState( initialHtml = initialHtml, - fake = fake, + initialLineCount = initialLineCount, + initialFocus = initialFocus, ) + } + + if (fake) { + FakeViewConnection(state) + } + + return state } object RichTextEditorStateSaver : Saver { @@ -216,6 +254,6 @@ object RichTextEditorStateSaver : Saver { } override fun SaverScope.save(value: RichTextEditorState): String { - return value.messageHtml + return value.internalHtml } } diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorStyle.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorStyle.kt index 4b08f5aa6..281f270ef 100644 --- a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorStyle.kt +++ b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/RichTextEditorStyle.kt @@ -2,6 +2,8 @@ package io.element.android.wysiwyg.compose import android.graphics.drawable.GradientDrawable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Density @@ -66,7 +68,7 @@ data class CodeBackgroundStyle( val cornerRadiusBottomRight: Dp, val borderWidth: Dp, ) { - internal val drawable by lazy { + internal val drawable by mutableStateOf( GradientDrawable().apply { shape = GradientDrawable.RECTANGLE setColor(this@CodeBackgroundStyle.color.toArgb()) @@ -83,7 +85,7 @@ data class CodeBackgroundStyle( ) } } - } + ) } data class InlineCodeBackgroundStyle( diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/FakeViewConnection.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/FakeViewConnection.kt index 090b893e6..8db86abe5 100644 --- a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/FakeViewConnection.kt +++ b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/FakeViewConnection.kt @@ -1,27 +1,63 @@ package io.element.android.wysiwyg.compose.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.launch import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction /** - * Fake implementation of [ViewConnection] for use in preview and test environments. + * Fake behaviour for use in preview and test environments. * This implementation does not actually connect to a view, but instead updates the state * in _some_ way. The changes made to the state are not guaranteed to be the same as the * real implementation. */ -internal class FakeViewConnection( - val state: RichTextEditorState -) : ViewConnection { +@Composable +internal fun FakeViewConnection( + state: RichTextEditorState +) { + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + coroutineScope.launch { + state.activeViewKey = "fake" + state.viewActions.collect(FakeViewActionCollector(state)) + } + } + +} - override fun toggleInlineFormat(inlineFormat: InlineFormat): Boolean { +internal class FakeViewActionCollector( + val state: RichTextEditorState +): FlowCollector { + override suspend fun emit(value: ViewAction) { + when(value) { + ViewAction.Indent -> indent() + is ViewAction.InsertLink -> insertLink(value.url) + ViewAction.Redo -> redo() + ViewAction.RemoveLink -> removeLink() + ViewAction.RequestFocus -> requestFocus() + is ViewAction.SetHtml -> setHtml(value.html) + is ViewAction.SetLink -> setLink(value.url) + ViewAction.ToggleCodeBlock -> toggleCodeBlock() + is ViewAction.ToggleInlineFormat -> toggleInlineFormat(value.inlineFormat) + is ViewAction.ToggleList -> toggleList(value.ordered) + ViewAction.ToggleQuote -> toggleQuote() + ViewAction.Undo -> undo() + ViewAction.Unindent -> unindent() + } + } + private fun toggleInlineFormat(inlineFormat: InlineFormat): Boolean { updateActionState(inlineFormat.toComposerAction()) return true } - override fun toggleList(ordered: Boolean) { + private fun toggleList(ordered: Boolean) { updateActionState( if (ordered) { ComposerAction.ORDERED_LIST @@ -31,51 +67,51 @@ internal class FakeViewConnection( ) } - override fun toggleCodeBlock(): Boolean { + private fun toggleCodeBlock(): Boolean { updateActionState(ComposerAction.CODE_BLOCK) return true } - override fun toggleQuote(): Boolean { + private fun toggleQuote(): Boolean { updateActionState(ComposerAction.QUOTE) return true } - override fun undo() { + private fun undo() { updateActionState(ComposerAction.UNDO) } - override fun redo() { + private fun redo() { updateActionState(ComposerAction.REDO) } - override fun indent() { + private fun indent() { updateActionState(ComposerAction.INDENT) } - override fun unindent() { + private fun unindent() { updateActionState(ComposerAction.UNINDENT) } - override fun setHtml(html: String) { + private fun setHtml(html: String) { state.messageHtml = html state.messageMarkdown = html } - override fun requestFocus(): Boolean { + private fun requestFocus(): Boolean { state.hasFocus = true return true } - override fun setLink(url: String?) { + private fun setLink(url: String?) { state.linkAction = url?.let { LinkAction.SetLink(it) } ?: LinkAction.InsertLink } - override fun removeLink() { + private fun removeLink() { state.linkAction = LinkAction.InsertLink } - override fun insertLink(url: String, text: String) { + private fun insertLink(url: String) { state.linkAction = LinkAction.SetLink(url) } diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/ViewAction.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/ViewAction.kt new file mode 100644 index 000000000..08f0b305b --- /dev/null +++ b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/ViewAction.kt @@ -0,0 +1,19 @@ +package io.element.android.wysiwyg.compose.internal + +import io.element.android.wysiwyg.view.models.InlineFormat + +internal sealed class ViewAction { + data class ToggleInlineFormat(val inlineFormat: InlineFormat): ViewAction() + data class ToggleList(val ordered: Boolean): ViewAction() + data object ToggleCodeBlock: ViewAction() + data object ToggleQuote: ViewAction() + data object Undo: ViewAction() + data object Redo: ViewAction() + data object Indent: ViewAction() + data object Unindent: ViewAction() + data class SetHtml(val html: String): ViewAction() + data object RequestFocus: ViewAction() + data class SetLink(val url: String?): ViewAction() + data object RemoveLink: ViewAction() + data class InsertLink(val url: String, val text: String): ViewAction() +} diff --git a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/ViewConnection.kt b/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/ViewConnection.kt deleted file mode 100644 index a1bf0efe7..000000000 --- a/platforms/android/library-compose/src/main/java/io/element/android/wysiwyg/compose/internal/ViewConnection.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.element.android.wysiwyg.compose.internal - -import io.element.android.wysiwyg.view.models.InlineFormat - -internal interface ViewConnection { - fun toggleInlineFormat(inlineFormat: InlineFormat): Boolean - fun toggleList(ordered: Boolean) - fun toggleCodeBlock(): Boolean - fun toggleQuote(): Boolean - fun undo() - fun redo() - fun indent() - fun unindent() - fun setHtml(html: String) - fun requestFocus(): Boolean - fun setLink(url: String?) - fun removeLink() - fun insertLink(url: String, text: String) -} \ No newline at end of file diff --git a/platforms/android/library-compose/src/test/java/io/element/android/wysiwyg/compose/FakeRichTextEditorStateTest.kt b/platforms/android/library-compose/src/test/java/io/element/android/wysiwyg/compose/FakeRichTextEditorStateTest.kt index 3772eeea3..5b00011aa 100644 --- a/platforms/android/library-compose/src/test/java/io/element/android/wysiwyg/compose/FakeRichTextEditorStateTest.kt +++ b/platforms/android/library-compose/src/test/java/io/element/android/wysiwyg/compose/FakeRichTextEditorStateTest.kt @@ -1,7 +1,12 @@ package io.element.android.wysiwyg.compose +import androidx.compose.runtime.remember +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction +import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.junit.Test @@ -10,129 +15,265 @@ import uniffi.wysiwyg_composer.ComposerAction class FakeRichTextEditorStateTest { - private val state = RichTextEditorState(initialHtml = "", fake = true) - @Test - fun `toggleInlineFormat(bold) updates the state`() { - state.toggleInlineFormat(inlineFormat = InlineFormat.Bold) - assertThat(state.actions[ComposerAction.BOLD], equalTo(ActionState.REVERSED)) + fun `toggleInlineFormat(bold) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleInlineFormat(inlineFormat = InlineFormat.Bold) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.BOLD], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleInlineFormat(italic) updates the state`() { - state.toggleInlineFormat(inlineFormat = InlineFormat.Italic) - assertThat(state.actions[ComposerAction.ITALIC], equalTo(ActionState.REVERSED)) + fun `toggleInlineFormat(italic) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleInlineFormat(inlineFormat = InlineFormat.Italic) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.ITALIC], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleInlineFormat(underline) updates the state`() { - state.toggleInlineFormat(inlineFormat = InlineFormat.Underline) - assertThat(state.actions[ComposerAction.UNDERLINE], equalTo(ActionState.REVERSED)) + fun `toggleInlineFormat(underline) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions){ state } + }.test { + val initialState = awaitItem() + initialState.toggleInlineFormat(inlineFormat = InlineFormat.Underline) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.UNDERLINE], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleInlineFormat(strikethrough) updates the state`() { - state.toggleInlineFormat(inlineFormat = InlineFormat.StrikeThrough) - assertThat(state.actions[ComposerAction.STRIKE_THROUGH], equalTo(ActionState.REVERSED)) + fun `toggleInlineFormat(strikethrough) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleInlineFormat(inlineFormat = InlineFormat.StrikeThrough) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.STRIKE_THROUGH], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleInlineFormat(inlinecode) updates the state`() { - state.toggleInlineFormat(inlineFormat = InlineFormat.InlineCode) - assertThat(state.actions[ComposerAction.INLINE_CODE], equalTo(ActionState.REVERSED)) + fun `toggleInlineFormat(inlinecode) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleInlineFormat(inlineFormat = InlineFormat.InlineCode) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.INLINE_CODE], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleList(ordered) updates the state`() { - state.toggleList(ordered = true) - assertThat(state.actions[ComposerAction.ORDERED_LIST], equalTo(ActionState.REVERSED)) + fun `toggleList(ordered) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleList(ordered = true) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.ORDERED_LIST], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleList(unordered) updates the state`() { - state.toggleList(ordered = false) - assertThat(state.actions[ComposerAction.UNORDERED_LIST], equalTo(ActionState.REVERSED)) + fun `toggleList(unordered) updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleList(ordered = false) + val actions = awaitItem().actions + assertThat(actions[ComposerAction.UNORDERED_LIST], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleCodeBlock updates the state`() { - state.toggleCodeBlock() - assertThat(state.actions[ComposerAction.CODE_BLOCK], equalTo(ActionState.REVERSED)) + fun `toggleCodeBlock updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleCodeBlock() + val actions = awaitItem().actions + assertThat(actions[ComposerAction.CODE_BLOCK], equalTo(ActionState.REVERSED)) + } } @Test - fun `toggleQuote updates the state`() { - state.toggleQuote() - assertThat(state.actions[ComposerAction.QUOTE], equalTo(ActionState.REVERSED)) + fun `toggleQuote updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.toggleQuote() + val actions = awaitItem().actions + assertThat(actions[ComposerAction.QUOTE], equalTo(ActionState.REVERSED)) + } } @Test - fun `undo updates the state`() { - state.undo() - assertThat(state.actions[ComposerAction.UNDO], equalTo(ActionState.REVERSED)) + fun `undo updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.undo() + val actions = awaitItem().actions + assertThat(actions[ComposerAction.UNDO], equalTo(ActionState.REVERSED)) + } } @Test - fun `redo updates the state`() { - state.redo() - assertThat(state.actions[ComposerAction.REDO], equalTo(ActionState.REVERSED)) + fun `redo updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.redo() + val actions = awaitItem().actions + assertThat(actions[ComposerAction.REDO], equalTo(ActionState.REVERSED)) + } } @Test - fun `indent updates the state`() { - state.indent() - assertThat(state.actions[ComposerAction.INDENT], equalTo(ActionState.REVERSED)) + fun `indent updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.indent() + val actions = awaitItem().actions + assertThat(actions[ComposerAction.INDENT], equalTo(ActionState.REVERSED)) + } } @Test - fun `unindent updates the state`() { - state.unindent() - assertThat(state.actions[ComposerAction.UNINDENT], equalTo(ActionState.REVERSED)) + fun `unindent updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + initialState.unindent() + val actions = awaitItem().actions + assertThat(actions[ComposerAction.UNINDENT], equalTo(ActionState.REVERSED)) + } } @Test - fun `setLink updates the state`() { - state.setLink("https://element.io") - assertThat(state.linkAction, equalTo(LinkAction.SetLink("https://element.io"))) + fun `setLink updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.linkAction) { state } + }.test { + val initialState = awaitItem() + initialState.setLink("https://element.io") + val linkAction = awaitItem().linkAction + assertThat(linkAction, equalTo(LinkAction.SetLink("https://element.io"))) + } } @Test - fun `removeLink updates the state`() { - state.setLink("https://element.io") - state.removeLink() - assertThat(state.linkAction, equalTo(LinkAction.InsertLink)) + fun `removeLink updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.linkAction) { state } + }.test { + val initialState = awaitItem() + initialState.setLink("https://element.io") + val withLink = awaitItem() + withLink.removeLink() + val linkAction = awaitItem().linkAction + assertThat(linkAction, equalTo(LinkAction.InsertLink)) + } } @Test - fun `insertLink updates the state`() { - state.insertLink("https://element.io", "hello!") - assertThat(state.linkAction, equalTo(LinkAction.SetLink("https://element.io"))) + fun `insertLink updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.linkAction) { state } + }.test { + val initialState = awaitItem() + initialState.insertLink("https://element.io", "hello!") + val linkAction = awaitItem().linkAction + assertThat(linkAction, equalTo(LinkAction.SetLink("https://element.io"))) + } } @Test - fun `toggling multiple times toggles state`() { - assertThat(state.actions[ComposerAction.BOLD], equalTo(null)) - state.toggleInlineFormat(InlineFormat.Bold) - assertThat(state.actions[ComposerAction.BOLD], equalTo(ActionState.REVERSED)) - state.toggleInlineFormat(InlineFormat.Bold) - assertThat(state.actions[ComposerAction.BOLD], equalTo(ActionState.ENABLED)) - state.toggleInlineFormat(InlineFormat.Bold) - assertThat(state.actions[ComposerAction.BOLD], equalTo(ActionState.REVERSED)) - state.toggleInlineFormat(InlineFormat.Bold) - assertThat(state.actions[ComposerAction.BOLD], equalTo(ActionState.ENABLED)) + fun `toggling multiple times toggles state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.actions) { state } + }.test { + val initialState = awaitItem() + assertThat(initialState.actions[ComposerAction.BOLD], equalTo(null)) + + initialState.toggleInlineFormat(InlineFormat.Bold) + val state1 = awaitItem() + assertThat(state1.actions[ComposerAction.BOLD], equalTo(ActionState.REVERSED)) + state1.toggleInlineFormat(InlineFormat.Bold) + val state2 = awaitItem() + assertThat(state2.actions[ComposerAction.BOLD], equalTo(ActionState.ENABLED)) + state2.toggleInlineFormat(InlineFormat.Bold) + val state3 = awaitItem() + assertThat(state3.actions[ComposerAction.BOLD], equalTo(ActionState.REVERSED)) + state3.toggleInlineFormat(InlineFormat.Bold) + val state4 = awaitItem() + assertThat(state4.actions[ComposerAction.BOLD], equalTo(ActionState.ENABLED)) + } } @Test - fun `setHtml updates the state`() { - state.setHtml("new html") - assertThat(state.messageHtml, equalTo("new html")) - assertThat(state.messageMarkdown, equalTo("new html")) + fun `setHtml updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.messageHtml, state.messageMarkdown) { state } + }.test { + val initialState = awaitItem() + initialState.setHtml("new html") + val nextState = awaitItem() + assertThat(nextState.messageHtml, equalTo("new html")) + assertThat(nextState.messageMarkdown, equalTo("new html")) + } } @Test - fun `requestFocus updates the state`() { - assertThat(state.hasFocus, equalTo(false)) - state.requestFocus() - assertThat(state.hasFocus, equalTo(true)) + fun `requestFocus updates the state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + val state = rememberRichTextEditorState(fake = true) + remember(state.hasFocus) { state } + }.test { + val initialState = awaitItem() + assertThat(initialState.hasFocus, equalTo(false)) + initialState.requestFocus() + val hasFocus = awaitItem().hasFocus + assertThat(hasFocus, equalTo(true)) + } } } \ No newline at end of file diff --git a/platforms/android/library/build.gradle b/platforms/android/library/build.gradle index 7df0c72c0..37de1956b 100644 --- a/platforms/android/library/build.gradle +++ b/platforms/android/library/build.gradle @@ -25,7 +25,7 @@ android { namespace = "io.element.android.wysiwyg" testNamespace = "io.element.android.wysiwyg.test" - compileSdk 33 + compileSdk 34 defaultConfig { minSdk 21 @@ -55,7 +55,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion '1.5.1' + kotlinCompilerExtensionVersion '1.5.3' } testOptions { 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 df5afff80..21beae485 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 @@ -29,7 +29,6 @@ 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.internal.viewmodel.ReplaceTextResult import io.element.android.wysiwyg.utils.* import io.element.android.wysiwyg.utils.HtmlToSpansParser.FormattingSpans.removeFormattingSpans import io.element.android.wysiwyg.view.StyleConfig @@ -217,7 +216,7 @@ class EditorEditText : AppCompatEditText { ) if (result != null) { - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) } @@ -234,7 +233,7 @@ class EditorEditText : AppCompatEditText { val result = viewModel.processInput(EditorInputAction.ReplaceText(copiedString)) if (result != null) { - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) } @@ -302,7 +301,7 @@ class EditorEditText : AppCompatEditText { val result = viewModel.processInput(EditorInputAction.ReplaceText(text.toString())) ?: return super.setText(text, type) - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) } @@ -310,7 +309,7 @@ class EditorEditText : AppCompatEditText { val result = viewModel.processInput(EditorInputAction.ReplaceText(text.toString())) ?: return super.append(text, start, end) - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) } @@ -318,7 +317,7 @@ class EditorEditText : AppCompatEditText { val result = viewModel.processInput(EditorInputAction.ApplyInlineFormat(inlineFormat)) ?: return false - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) return true } @@ -326,7 +325,7 @@ class EditorEditText : AppCompatEditText { fun toggleCodeBlock(): Boolean { val result = viewModel.processInput(EditorInputAction.CodeBlock) ?: return false - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) return true } @@ -334,7 +333,7 @@ class EditorEditText : AppCompatEditText { fun toggleQuote(): Boolean { val result = viewModel.processInput(EditorInputAction.Quote) ?: return false - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) return true } @@ -342,14 +341,14 @@ class EditorEditText : AppCompatEditText { fun undo() { val result = viewModel.processInput(EditorInputAction.Undo) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.first, result.selection.last) } fun redo() { val result = viewModel.processInput(EditorInputAction.Redo) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } @@ -375,7 +374,7 @@ class EditorEditText : AppCompatEditText { if (url != null) EditorInputAction.SetLink(url) else EditorInputAction.RemoveLink ) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } @@ -395,35 +394,35 @@ class EditorEditText : AppCompatEditText { fun insertLink(url: String, text: String) { val result = viewModel.processInput(EditorInputAction.SetLinkWithText(url, text)) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } fun toggleList(ordered: Boolean) { val result = viewModel.processInput(EditorInputAction.ToggleList(ordered)) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } fun indent() { val result = viewModel.processInput(EditorInputAction.Indent) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } fun unindent() { val result = viewModel.processInput(EditorInputAction.Unindent) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } fun setHtml(html: String) { val result = viewModel.processInput(EditorInputAction.ReplaceAllHtml(html)) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } @@ -434,6 +433,10 @@ class EditorEditText : AppCompatEditText { return viewModel.getContentAsMessageHtml() } + fun getInternalHtml(): String { + return viewModel.getInternalHtml() + } + /** * Get the text as markdown. */ @@ -445,7 +448,7 @@ class EditorEditText : AppCompatEditText { fun setMarkdown(markdown: String) { val result = viewModel.processInput(EditorInputAction.ReplaceAllMarkdown(markdown)) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } @@ -462,7 +465,7 @@ class EditorEditText : AppCompatEditText { url = url, ) ) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } @@ -477,7 +480,7 @@ class EditorEditText : AppCompatEditText { value = text, ) ) ?: return - setTextFromComposerUpdate(result) + setTextFromComposerUpdate(result.text) setSelectionFromComposerUpdate(result.selection.last) } @@ -503,13 +506,14 @@ class EditorEditText : AppCompatEditText { * will be updated to reflect this. */ private fun rerender() { - setHtml(viewModel.getInternalHtml()) + val text = viewModel.rerender() + setTextFromComposerUpdate(text) } - private fun setTextFromComposerUpdate(result: ReplaceTextResult) { + private fun setTextFromComposerUpdate(text: CharSequence) { beginBatchEdit() editableText.removeFormattingSpans() - editableText.replace(0, editableText.length, result.text) + editableText.replace(0, editableText.length, text) endBatchEdit() } 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 2b8d6c700..3cccaf2b3 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 @@ -176,7 +176,7 @@ internal class EditorViewModel( * * Note that this should not be used for messages; instead [getContentAsMessageHtml] should be used. */ - internal fun getInternalHtml(): String { + fun getInternalHtml(): String { return composer?.getContentAsHtml().orEmpty() } @@ -187,6 +187,9 @@ internal class EditorViewModel( fun getLinkAction(): LinkAction? = composer?.getLinkAction()?.toApiModel() + fun rerender(): CharSequence = + stringToSpans(getInternalHtml()) + private fun onComposerFailure(error: Throwable, attemptContentRecovery: Boolean = true) { rustErrorCollector?.onRustError(error) diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/InlineFormat.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/InlineFormat.kt index f561de872..9a2502dce 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/InlineFormat.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/InlineFormat.kt @@ -1,15 +1,17 @@ package io.element.android.wysiwyg.view.models +import androidx.compose.runtime.Immutable import uniffi.wysiwyg_composer.ComposerAction /** * Mapping of [ComposerAction] inline format actions. These are text styles that can be applied to * a text selection in the editor. */ +@Immutable sealed interface InlineFormat { - object Bold: InlineFormat - object Italic: InlineFormat - object Underline: InlineFormat - object StrikeThrough: InlineFormat - object InlineCode: InlineFormat + data object Bold: InlineFormat + data object Italic: InlineFormat + data object Underline: InlineFormat + data object StrikeThrough: InlineFormat + data object InlineCode: InlineFormat }