diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs index 68480d72c..56f145780 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs @@ -270,6 +270,31 @@ impl ComposerModel { )) } + pub fn edit_link_with_text( + self: &Arc, + url: String, + text: String, + attributes: Vec, + ) -> Arc { + let url = Utf16String::from_str(&url); + let text = Utf16String::from_str(&text); + let attrs = attributes + .iter() + .map(|attr| { + ( + Utf16String::from_str(&attr.key), + Utf16String::from_str(&attr.value), + ) + }) + .collect(); + Arc::new(ComposerUpdate::from( + self.inner + .lock() + .unwrap() + .edit_link_with_text(url, text, attrs), + )) + } + /// Creates an at-room mention node and inserts it into the composer at the current selection pub fn insert_at_room_mention(self: &Arc) -> Arc { Arc::new(ComposerUpdate::from( diff --git a/bindings/wysiwyg-ffi/src/ffi_link_actions.rs b/bindings/wysiwyg-ffi/src/ffi_link_actions.rs index 7beba0190..34a9f02d0 100644 --- a/bindings/wysiwyg-ffi/src/ffi_link_actions.rs +++ b/bindings/wysiwyg-ffi/src/ffi_link_actions.rs @@ -4,7 +4,7 @@ use widestring::Utf16String; pub enum LinkAction { CreateWithText, Create, - Edit { url: String }, + Edit { url: String, text: String }, Disabled, } @@ -13,8 +13,9 @@ impl From> for LinkAction { match inner { wysiwyg::LinkAction::CreateWithText => Self::CreateWithText, wysiwyg::LinkAction::Create => Self::Create, - wysiwyg::LinkAction::Edit(url) => Self::Edit { + wysiwyg::LinkAction::Edit(url, text) => Self::Edit { url: url.to_string(), + text: text.to_string(), }, wysiwyg::LinkAction::Disabled => Self::Disabled, } diff --git a/bindings/wysiwyg-wasm/src/lib.rs b/bindings/wysiwyg-wasm/src/lib.rs index e0294a2a5..0e892a64c 100644 --- a/bindings/wysiwyg-wasm/src/lib.rs +++ b/bindings/wysiwyg-wasm/src/lib.rs @@ -316,6 +316,19 @@ impl ComposerModel { )) } + pub fn edit_link_with_text( + &mut self, + url: &str, + text: &str, + attributes: js_sys::Map, + ) -> ComposerUpdate { + ComposerUpdate::from(self.inner.edit_link_with_text( + Utf16String::from_str(url), + Utf16String::from_str(text), + attributes.into_vec(), + )) + } + /// Creates an at-room mention node and inserts it into the composer at the current selection pub fn insert_at_room_mention( &mut self, @@ -831,6 +844,7 @@ pub struct Create; #[wasm_bindgen(getter_with_clone)] pub struct Edit { pub url: String, + pub text: String, } #[derive(Clone)] @@ -860,12 +874,13 @@ impl From> for LinkAction { edit_link: None, disabled: None, }, - wysiwyg::LinkAction::Edit(url) => { + wysiwyg::LinkAction::Edit(url, text) => { let url = url.to_string(); + let text = text.to_string(); Self { create_with_text: None, create: None, - edit_link: Some(Edit { url }), + edit_link: Some(Edit { url, text }), disabled: None, } } diff --git a/crates/wysiwyg/src/composer_model/hyperlinks.rs b/crates/wysiwyg/src/composer_model/hyperlinks.rs index fb8027f97..412f8c618 100644 --- a/crates/wysiwyg/src/composer_model/hyperlinks.rs +++ b/crates/wysiwyg/src/composer_model/hyperlinks.rs @@ -18,6 +18,7 @@ use crate::dom::nodes::dom_node::DomNodeKind; use crate::dom::nodes::dom_node::DomNodeKind::{Link, List}; use crate::dom::nodes::ContainerNodeKind; use crate::dom::nodes::DomNode; +use crate::dom::to_plain_text::ToPlainText; use crate::dom::unicode_string::UnicodeStrExt; use crate::dom::Range; use crate::{ @@ -53,7 +54,10 @@ where LinkAction::Disabled } else { // Otherwise we edit the first link of the selection. - LinkAction::Edit(first_link.get_link_url().unwrap()) + LinkAction::Edit( + first_link.get_link_url().unwrap(), + first_link.to_plain_text(), + ) } } else if s == e || self.is_blank_selection(range) { LinkAction::CreateWithText @@ -121,6 +125,29 @@ where self.set_link_in_range(url, range, attributes) } + pub fn edit_link_with_text( + &mut self, + url: S, + text: S, + attributes: Vec<(S, S)>, + ) -> ComposerUpdate { + self.push_state_to_history(); + let (s, e) = self.safe_selection(); + let range = self.state.dom.find_range(s, e); + let Some(link_loc) = range + .locations + .iter() + .find(|loc| loc.kind == DomNodeKind::Link) else { + panic!("Attempting to edit a link on a range that doesn't contain one") + }; + let start = link_loc.position; + let end = start + link_loc.length; + let new_end = start + text.len(); + self.do_replace_text_in(text, start, end); + let range = self.state.dom.find_range(start, new_end); + self.set_link_in_range(url, range, attributes) + } + fn set_link_in_range( &mut self, mut url: S, diff --git a/crates/wysiwyg/src/link_action.rs b/crates/wysiwyg/src/link_action.rs index 5f361e823..50a243cf9 100644 --- a/crates/wysiwyg/src/link_action.rs +++ b/crates/wysiwyg/src/link_action.rs @@ -24,6 +24,6 @@ pub enum LinkActionUpdate { pub enum LinkAction { CreateWithText, Create, - Edit(S), + Edit(S, S), Disabled, } diff --git a/crates/wysiwyg/src/tests/test_get_link_action.rs b/crates/wysiwyg/src/tests/test_get_link_action.rs index 493f87717..7768e1735 100644 --- a/crates/wysiwyg/src/tests/test_get_link_action.rs +++ b/crates/wysiwyg/src/tests/test_get_link_action.rs @@ -54,7 +54,7 @@ fn get_link_action_from_highlighted_link() { let model = cm("{test}|"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test")) ) } @@ -63,7 +63,7 @@ fn get_link_action_from_cursor_at_the_end_of_a_link() { let model = cm("test|"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test")) ) } @@ -72,7 +72,7 @@ fn get_link_action_from_cursor_inside_a_link() { let model = cm("te|st"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test")) ) } @@ -81,7 +81,7 @@ fn get_link_action_from_cursor_at_the_start_of_a_link() { let model = cm("|test"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test")) ) } @@ -90,7 +90,7 @@ fn get_link_action_from_selection_that_contains_a_link_and_non_links() { let model = cm("{test_bold test}|_link test_bold"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test_link")) ) } @@ -99,7 +99,7 @@ fn get_link_action_from_selection_that_contains_multiple_links() { let model = cm("{test_element test_matrix}|"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test_element")) ) } @@ -108,7 +108,7 @@ fn get_link_action_from_selection_that_contains_multiple_links_partially() { let model = cm("test_{element test}|_matrix"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test_element")) ) } @@ -118,7 +118,7 @@ fn get_link_action_from_selection_that_contains_multiple_links_partially_in_diff let model = cm(" test_{element test}|_matrix"); assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16(" test_element")) ) } @@ -176,7 +176,7 @@ fn get_link_action_on_blank_selection_after_a_link() { // This is the correct behaviour because the end of a link should be considered part of the link itself assert_eq!( model.get_link_action(), - LinkAction::Edit(utf16("https://element.io")) + LinkAction::Edit(utf16("https://element.io"), utf16("test")) ) } @@ -224,7 +224,7 @@ fn get_link_action_on_multiple_link_with_first_immutable() { model.select(Location::from(20), Location::from(20)); assert_eq!( model.get_link_action(), - LinkAction::Edit("https://rust-lang.org".into()), + LinkAction::Edit("https://rust-lang.org".into(), utf16("Rust_mut")), ); } @@ -240,7 +240,7 @@ fn get_link_action_on_multiple_link_with_last_immutable() { model.select(Location::from(0), Location::from(0)); assert_eq!( model.get_link_action(), - LinkAction::Edit("https://rust-lang.org".into()), + LinkAction::Edit("https://rust-lang.org".into(), utf16("Rust_mut")), ); } @@ -281,13 +281,13 @@ fn get_link_action_on_multiple_link_with_first_is_mention() { "#}); assert_eq!( model.get_link_action(), - LinkAction::Edit("https://rust-lang.org".into()), + LinkAction::Edit("https://rust-lang.org".into(), utf16("Rust_mut")), ); // Selecting the link afterwards works model.select(Location::from(10), Location::from(10)); assert_eq!( model.get_link_action(), - LinkAction::Edit("https://rust-lang.org".into()), + LinkAction::Edit("https://rust-lang.org".into(), utf16("Rust_mut")), ); } @@ -300,12 +300,12 @@ fn get_link_action_on_multiple_link_with_last_is_mention() { "#}); assert_eq!( model.get_link_action(), - LinkAction::Edit("https://rust-lang.org".into()), + LinkAction::Edit("https://rust-lang.org".into(), utf16("Rust_mut")), ); // Selecting the mutable link afterwards works model.select(Location::from(0), Location::from(0)); assert_eq!( model.get_link_action(), - LinkAction::Edit("https://rust-lang.org".into()), + LinkAction::Edit("https://rust-lang.org".into(), utf16("Rust_mut")), ); } diff --git a/crates/wysiwyg/src/tests/test_links.rs b/crates/wysiwyg/src/tests/test_links.rs index 5edddb8c9..045ce1321 100644 --- a/crates/wysiwyg/src/tests/test_links.rs +++ b/crates/wysiwyg/src/tests/test_links.rs @@ -943,3 +943,97 @@ fn set_links_in_list_then_add_list_item() { "" ); } + +#[test] +fn edit_entirely_selected_link() { + let mut model = cm("{Mtrix}|"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!(tx(&model), "Matrix|"); +} + +#[test] +fn edit_partially_selected_link() { + let mut model = cm("Mtr{ix}|"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!(tx(&model), "Matrix|"); +} + +#[test] +fn edit_link_from_cursor_position() { + let mut model = cm("Mtrix|"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!(tx(&model), "Matrix|"); +} + +#[test] +fn edit_link_with_multiple_links_edits_first_occurence() { + // Note: this behaviour might change if we allow replacing + // text with the entire extended plain text from the selection. + let mut model = cm("{Mtrix Matrix}|"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!( + tx(&model), + "Matrix| Matrix", + ); +} + +#[test] +fn edit_link_with_multiple_partially_selected_links_edits_first_occurence() { + // Note: this behaviour might change if we allow replacing + // text with the entire extended plain text from the selection. + let mut model = cm("Mt{rix Mat}|rix"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!( + tx(&model), + "Matrix| Matrix", + ); +} + +#[test] +fn edit_entirely_formatted_link_keeps_formatting() { + let mut model = + cm("{Mtrix}|"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!( + tx(&model), + "Matrix|", + ); +} + +#[test] +fn edit_partially_formatted_link_removes_formatting() { + // Note: replacing the text of the link makes the formatting position ambiguous + // it is better to remove it than provide unexpected content. + let mut model = + cm("{Mtrix}|"); + model.edit_link_with_text( + "https://matrix.org".into(), + "Matrix".into(), + vec![], + ); + assert_eq!(tx(&model), "Matrix|"); +} diff --git a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/LinkDialog.kt b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/LinkDialog.kt index 14d4f6f8d..401aa5596 100644 --- a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/LinkDialog.kt +++ b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/LinkDialog.kt @@ -29,11 +29,13 @@ fun LinkDialog( onRemoveLink: () -> Unit, onSetLink: (url: String) -> Unit, onInsertLink: (url: String, text: String) -> Unit, + onEditLink: (url: String, text: String) -> Unit, onDismissRequest: () -> Unit, ) { - val currentUrl = (linkAction as? LinkAction.SetLink)?.currentUrl + val currentUrl = (linkAction as? LinkAction.SetLink)?.currentUrl ?: (linkAction as? LinkAction.EditLink)?.currentUrl + val currentText = (linkAction as? LinkAction.EditLink)?.currentText - var newText by remember { mutableStateOf("") } + var newText by remember { mutableStateOf(currentText ?: "") } var newLink by remember { mutableStateOf(currentUrl ?: "") } Dialog(onDismissRequest = onDismissRequest) { @@ -45,7 +47,7 @@ fun LinkDialog( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (linkAction is LinkAction.InsertLink) { + if (linkAction is LinkAction.InsertLink || linkAction is LinkAction.EditLink) { OutlinedTextField( value = newText, onValueChange = { newText = it }, placeholder = { Text(text = stringResource(R.string.link_text)) @@ -71,6 +73,7 @@ fun LinkDialog( when (linkAction) { LinkAction.InsertLink -> onInsertLink(newLink, newText) is LinkAction.SetLink -> onSetLink(newLink) + is LinkAction.EditLink -> onEditLink(newLink, newText) } onDismissRequest() }) { @@ -80,6 +83,7 @@ fun LinkDialog( when (linkAction) { LinkAction.InsertLink -> R.string.link_insert is LinkAction.SetLink -> R.string.link_set + is LinkAction.EditLink -> R.string.link_edit } ) ) @@ -98,6 +102,7 @@ fun PreviewSetLinkDialog() { onRemoveLink = {}, onSetLink = {}, onInsertLink = { _, _ -> }, + onEditLink = { _, _ -> }, onDismissRequest = {} ) } @@ -110,6 +115,7 @@ fun PreviewInsertLinkDialog() { onRemoveLink = {}, onSetLink = {}, onInsertLink = { _, _ -> }, + onEditLink = { _, _ -> }, onDismissRequest = {} ) } 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 2f88eb388..25165a751 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 @@ -62,6 +62,13 @@ class MainActivity : ComponentActivity() { ) } }, + onEditLink = { url, text -> + coroutineScope.launch { + state.editLink( + url, text + ) + } + }, onDismissRequest = { linkDialogAction = null }) } Surface( diff --git a/platforms/android/example-compose/src/main/res/values/strings.xml b/platforms/android/example-compose/src/main/res/values/strings.xml index d445c3e0e..474adcdd6 100644 --- a/platforms/android/example-compose/src/main/res/values/strings.xml +++ b/platforms/android/example-compose/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ Insert link Text Link + Edit link \ No newline at end of file diff --git a/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/MainActivity.kt b/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/MainActivity.kt index 12b218bc8..240e4e31f 100644 --- a/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/MainActivity.kt +++ b/platforms/android/example-view/src/main/java/io/element/android/wysiwyg/poc/MainActivity.kt @@ -56,6 +56,26 @@ class MainActivity : AppCompatActivity() { dialogBinding.link.performClick() } + override fun openEditLinkDialog( + currentText: String?, + currentLink: String?, + callback: (text: String, url: String) -> Unit + ) { + val dialogBinding = DialogSetLinkBinding.inflate(LayoutInflater.from(context)) + val title = R.string.edit_link + dialogBinding.link.setText(currentLink) + dialogBinding.text.setText(currentText) + AlertDialog.Builder(context) + .setTitle(title) + .setView(dialogBinding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + callback(dialogBinding.text.text.toString(), dialogBinding.link.text.toString()) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + + dialogBinding.link.performClick() + } } } 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 1b396656b..a10975c36 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 @@ -89,6 +89,10 @@ class RichTextEditor : LinearLayout { onSetLinkListener?.openSetLinkDialog(linkAction.currentUrl) { url -> richTextEditText.setLink(url) } + is LinkAction.EditLink -> + onSetLinkListener?.openEditLinkDialog(linkAction.currentText, linkAction.currentUrl) { text, url -> + richTextEditText.editLink(url = url, text = text) + } } } undoButton.setOnClickListener { @@ -199,4 +203,5 @@ class RichTextEditor : LinearLayout { interface OnSetLinkListener { fun openSetLinkDialog(currentLink: String?, callback: (url: String?) -> Unit) fun openInsertLinkDialog(callback: (text: String, url: String) -> Unit) + fun openEditLinkDialog(currentText: String?, currentLink: String?, callback: (text: String, url: String) -> Unit) } 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 95cbfea6d..f7fa2ea01 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 @@ -126,6 +126,19 @@ class RichTextEditorTest { assertEquals("Hello, [element]()", state.messageMarkdown) } + @Test + fun testEditLink() = runTest { + val state = createState() + composeTestRule.showContent(state) + + state.setHtml("Hello, element") + state.editLink("https://matrix.org", "matrix") + composeTestRule.awaitIdle() + + assertEquals("Hello, matrix", state.messageHtml) + assertEquals("Hello, [matrix]()", state.messageMarkdown) + } + @Test fun testLinkActionUpdates() = runTest { val state = createState() @@ -135,10 +148,10 @@ class RichTextEditorTest { composeTestRule.awaitIdle() onView(withText("matrix element plain")).perform(EditorActions.setSelection(0, 0)) - assertEquals(LinkAction.SetLink("https://matrix.org"), state.linkAction) + assertEquals(LinkAction.EditLink("https://matrix.org", "matrix"), state.linkAction) onView(withText("matrix element plain")).perform(EditorActions.setSelection(8, 8)) - assertEquals(LinkAction.SetLink("https://element.io"), state.linkAction) + assertEquals(LinkAction.EditLink("https://element.io", "element"), state.linkAction) onView(withText("matrix element plain")).perform(EditorActions.setSelection(16, 16)) assertEquals(LinkAction.InsertLink, state.linkAction) 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 d474e7e83..0a8ff0d7e 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 @@ -123,6 +123,7 @@ private fun RealEditor( is ViewAction.SetLink -> setLink(it.url) is ViewAction.RemoveLink -> removeLink() is ViewAction.InsertLink -> insertLink(it.url, it.text) + is ViewAction.EditLink -> editLink(it.url, it.text) } } } 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 667d4a828..eab595262 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 @@ -141,6 +141,16 @@ class RichTextEditorState( _viewActions.emit(ViewAction.InsertLink(url, text)) } + /** + * Edit a link with new URL and text. + * + * @param url The link URL to set + * @param text The new text to insert + */ + suspend fun editLink(url: String, text: String) { + _viewActions.emit(ViewAction.EditLink(url, text)) + } + /** * The content of the editor as HTML formatted for sending as a message. */ 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 8db86abe5..3866d1fcd 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 @@ -44,6 +44,7 @@ internal class FakeViewActionCollector( ViewAction.RequestFocus -> requestFocus() is ViewAction.SetHtml -> setHtml(value.html) is ViewAction.SetLink -> setLink(value.url) + is ViewAction.EditLink -> editLink(value.url, value.text) ViewAction.ToggleCodeBlock -> toggleCodeBlock() is ViewAction.ToggleInlineFormat -> toggleInlineFormat(value.inlineFormat) is ViewAction.ToggleList -> toggleList(value.ordered) @@ -107,6 +108,10 @@ internal class FakeViewActionCollector( state.linkAction = url?.let { LinkAction.SetLink(it) } ?: LinkAction.InsertLink } + private fun editLink(url: String?, text: String?) { + state.linkAction = LinkAction.EditLink(currentText = text, currentUrl = url) + } + private fun removeLink() { state.linkAction = LinkAction.InsertLink } 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 index 08f0b305b..fb1444a62 100644 --- 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 @@ -16,4 +16,5 @@ internal sealed class ViewAction { data class SetLink(val url: String?): ViewAction() data object RemoveLink: ViewAction() data class InsertLink(val url: String, val text: String): ViewAction() + data class EditLink(val url: String, val text: String): ViewAction() } 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 34af0bfdc..f0a4832ef 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 @@ -251,6 +251,23 @@ class EditorEditTextInputTests { })) } + + @Test + fun testCreatingAndEditingALink() { + onView(withId(R.id.rich_text_edit_text)) + .perform(ImeActions.setComposingText("link")) + .perform(ImeActions.setSelection(0, 4)) + .perform(EditorActions.setLink("https://element.io")) + .check(matches(TextViewMatcher { + it.editableText.getSpans().first().url == "https://element.io" + })) + .perform(EditorActions.editLink("matrix", "https://matrix.org")) + .check(matches(withText("matrix"))) + .check(matches(TextViewMatcher { + it.editableText.getSpans().first().url == "https://matrix.org" + })) + } + @Test fun testRemovingLink() { onView(withId(R.id.rich_text_edit_text)) diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/EditorActions.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/EditorActions.kt index ceb8c61e9..763d39881 100644 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/EditorActions.kt +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/EditorActions.kt @@ -66,6 +66,20 @@ object Editor { } } + data class EditLink( + val text: String, + val url: String, + ) : ViewAction { + override fun getConstraints(): Matcher = isDisplayed() + + override fun getDescription(): String = "Edit link with text: ($text) and url: $url" + + override fun perform(uiController: UiController?, view: View?) { + val editor = view as? EditorEditText ?: return + editor.editLink(url = url, text = text) + } + } + data class InsertLink( val text: String, val url: String, @@ -231,6 +245,7 @@ object EditorActions { fun setText(text: String) = Editor.SetText(text) fun setHtml(html: String) = Editor.SetHtml(html) fun setLink(url: String) = Editor.SetLink(url) + fun editLink(text: String, url: String) = Editor.EditLink(text, url) fun insertLink(text: String, url: String) = Editor.InsertLink(text, url) fun removeLink() = Editor.RemoveLink fun insertMentionAtSuggestion(text: String, url: String) = Editor.InsertMentionAtSuggestion(text, url) 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 cbcb8c4a6..41c53aca3 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 @@ -366,6 +366,21 @@ class EditorEditText : AppCompatEditText { setSelectionFromComposerUpdate(result.selection.last) } + /** + * Edit text and url for selected link. This method does nothing if there is no link in selection. + * + * @param url The updated link URL + * @param text The updated link text + */ + fun editLink(url: String, text: String) { + val result = viewModel.processInput( + EditorInputAction.EditLink(url, text) + ) ?: return + + setTextFromComposerUpdate(result.text) + setSelectionFromComposerUpdate(result.selection.last) + } + /** * Remove a link for the current selection. Convenience for setLink(null). * diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/view/models/LinkActionExt.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/view/models/LinkActionExt.kt index f4e0ab2f6..3fb91793b 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/view/models/LinkActionExt.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/view/models/LinkActionExt.kt @@ -6,7 +6,7 @@ import uniffi.wysiwyg_composer.LinkAction as InternalLinkAction internal fun InternalLinkAction.toApiModel(): LinkAction? = when (this) { - is InternalLinkAction.Edit -> LinkAction.SetLink(currentUrl = url) + is InternalLinkAction.Edit -> LinkAction.EditLink(currentUrl = url, currentText = text) is InternalLinkAction.Create -> LinkAction.SetLink(currentUrl = null) is InternalLinkAction.CreateWithText -> LinkAction.InsertLink is InternalLinkAction.Disabled -> null diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorInputAction.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorInputAction.kt index cc3e3f571..e90161f37 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorInputAction.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorInputAction.kt @@ -77,6 +77,11 @@ internal sealed interface EditorInputAction { */ data class SetLink(val url: String): EditorInputAction + /** + * Edit a link to the [url] and [text] in the current selection. + */ + data class EditLink(val url: String, val text: String): EditorInputAction + /** * Remove link on the current selection. */ 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 3cccaf2b3..ffe552468 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 @@ -94,6 +94,12 @@ internal class EditorViewModel( attributes = emptyList() ) + is EditorInputAction.EditLink -> composer?.editLinkWithText( + url = action.url, + text = action.text, + attributes = emptyList() + ) + is EditorInputAction.RemoveLink -> composer?.removeLinks() is EditorInputAction.SetLinkWithText -> composer?.setLinkWithText( action.link, diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/LinkAction.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/LinkAction.kt index b088fba8f..4953bf70e 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/LinkAction.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/models/LinkAction.kt @@ -13,4 +13,9 @@ sealed class LinkAction { * Add or change the link url for the current selection, without supplying text. */ data class SetLink(val currentUrl: String?) : LinkAction() + + /** + * Change the link url and text for the current selection. + */ + data class EditLink(val currentUrl: String?, val currentText: String?) : LinkAction() } diff --git a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt index 95ae6e7a7..199a99e6f 100644 --- a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt +++ b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt @@ -214,12 +214,13 @@ internal class EditorViewModelTest { @Test fun `given internal edit link action, when get, it returns the right action`() { - composer.givenLinkAction(ComposerLinkAction.Edit(linkUrl)) + composer.givenLinkAction(ComposerLinkAction.Edit(linkUrl, linkText)) assertThat( viewModel.getLinkAction(), equalTo( - LinkAction.SetLink( - currentUrl = linkUrl + LinkAction.EditLink( + currentUrl = linkUrl, + currentText = linkText ) ) ) diff --git a/platforms/ios/example/Wysiwyg/Views/ComposerActionToolbar.swift b/platforms/ios/example/Wysiwyg/Views/ComposerActionToolbar.swift index 299753bf0..ae0d85956 100644 --- a/platforms/ios/example/Wysiwyg/Views/ComposerActionToolbar.swift +++ b/platforms/ios/example/Wysiwyg/Views/ComposerActionToolbar.swift @@ -52,13 +52,13 @@ struct ComposerActionToolbar: View { func makeAlertConfig() -> AlertConfig { var actions: [AlertConfig.Action] = [.cancel(title: "Cancel")] let createLinkTitle = "Create Link" - let singleTextAction: ([String]) -> Void = { strings in - let urlString = strings[0] - viewModel.select(range: linkAttributedRange) - viewModel.applyLinkOperation(.setLink(urlString: urlString)) - } switch linkAction { case .create: + let singleTextAction: ([String]) -> Void = { strings in + let urlString = strings[0] + viewModel.select(range: linkAttributedRange) + viewModel.applyLinkOperation(.setLink(urlString: urlString)) + } actions.append(createAction(singleTextAction: singleTextAction)) return AlertConfig(title: createLinkTitle, actions: actions) case .createWithText: @@ -70,9 +70,15 @@ struct ComposerActionToolbar: View { } actions.append(createWithTextAction(doubleTextAction: doubleTextAction)) return AlertConfig(title: createLinkTitle, actions: actions) - case let .edit(url): + case let .edit(url, text): let editLinktitle = "Edit Link URL" - actions.append(editTextAction(singleTextAction: singleTextAction, url: url)) + let doubleTextAction: ([String]) -> Void = { strings in + let urlString = strings[0] + let text = strings[1] + viewModel.select(range: linkAttributedRange) + viewModel.applyLinkOperation(.editLink(urlString: urlString, text: text)) + } + actions.append(editTextAction(doubleTextAction: doubleTextAction, url: url, text: text)) let removeAction = { viewModel.select(range: linkAttributedRange) viewModel.applyLinkOperation(.removeLinks) @@ -119,7 +125,7 @@ private extension ComposerActionToolbar { ) } - private func editTextAction(singleTextAction: @escaping ([String]) -> Void, url: String) -> AlertConfig.Action { + private func editTextAction(doubleTextAction: @escaping ([String]) -> Void, url: String, text: String) -> AlertConfig.Action { .textAction( title: "Ok", textFieldsData: [ @@ -128,8 +134,13 @@ private extension ComposerActionToolbar { placeholder: "URL", defaultValue: url ), + .init( + accessibilityIdentifier: .linkTextTextField, + placeholder: "Text", + defaultValue: text + ), ], - action: singleTextAction + action: doubleTextAction ) } } diff --git a/platforms/ios/example/WysiwygUITests/WysiwygUITests+Links.swift b/platforms/ios/example/WysiwygUITests/WysiwygUITests+Links.swift index 9e3aa1794..91a240b38 100644 --- a/platforms/ios/example/WysiwygUITests/WysiwygUITests+Links.swift +++ b/platforms/ios/example/WysiwygUITests/WysiwygUITests+Links.swift @@ -35,24 +35,27 @@ extension WysiwygUITests { // Edit button(.linkButton).tap() - XCTAssertFalse(textField(.linkTextTextField).exists) + XCTAssertTrue(textField(.linkUrlTextField).exists) + XCTAssertTrue(textField(.linkTextTextField).exists) textField(.linkUrlTextField).doubleTap() textField(.linkUrlTextField).typeTextCharByChar("new_url") + textField(.linkTextTextField).doubleTap() + textField(.linkTextTextField).typeTextCharByChar("new text") app.buttons["Ok"].tap() assertTreeEquals( """ └>a "https://new_url" - └>"text" + └>"new text" """ ) // Remove button(.linkButton).tap() - XCTAssertFalse(textField(.linkTextTextField).exists) + XCTAssertTrue(textField(.linkTextTextField).exists) app.buttons["Remove"].tap() assertTreeEquals( """ - └>"text" + └>"new text" """ ) } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift index 548741faa..238785a8c 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift @@ -34,6 +34,7 @@ protocol ComposerModelWrapperProtocol { func enter() -> ComposerUpdate func setLink(url: String, attributes: [Attribute]) -> ComposerUpdate func setLinkWithText(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate + func editLinkWithText(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate func insertMention(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate func insertMentionAtSuggestion(url: String, text: String, suggestion: SuggestionPattern, attributes: [Attribute]) -> ComposerUpdate func removeLinks() -> ComposerUpdate @@ -131,6 +132,10 @@ final class ComposerModelWrapper: ComposerModelWrapperProtocol { func setLinkWithText(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate { execute { try $0.setLinkWithText(url: url, text: text, attributes: attributes) } } + + func editLinkWithText(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate { + execute { try $0.editLinkWithText(url: url, text: text, attributes: attributes) } + } func insertMention(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate { execute { try $0.insertMention(url: url, text: text, attributes: attributes) } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift index b636f75f6..69cd8899a 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift @@ -362,6 +362,8 @@ public extension WysiwygComposerViewModel { switch linkOperation { case let .createLink(urlString, text): update = model.setLinkWithText(url: urlString, text: text, attributes: []) + case let .editLink(urlString, text): + update = model.editLinkWithText(url: urlString, text: text, attributes: []) case let .setLink(urlString): update = model.setLink(url: urlString, attributes: []) case .removeLinks: diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygLinkOperation.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygLinkOperation.swift index 2b363b64a..81ea19c94 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygLinkOperation.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygLinkOperation.swift @@ -18,6 +18,7 @@ import Foundation public enum WysiwygLinkOperation: Equatable { case setLink(urlString: String) + case editLink(urlString: String, text: String) case createLink(urlString: String, text: String) case removeLinks } diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Links.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Links.swift index 291398202..7a6eb90ef 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Links.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Links.swift @@ -34,7 +34,7 @@ extension WysiwygComposerTests { let url = "test_url" ComposerModelWrapper() .action { $0.setLinkWithText(url: url, text: "test", attributes: []) } - .assertLinkAction(.edit(url: "https://\(url)")) + .assertLinkAction(.edit(url: "https://\(url)", text: "test")) } func testSetLinkWithText() {