diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs index 631a28e56..71ca4c26f 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs @@ -258,10 +258,32 @@ impl ComposerModel { )) } - /// This function creates a link with the first argument being the href, the second being the - /// display text, the third being the (rust model) suggestion that is being replaced and the - /// final argument being a list of attributes that will be added to the Link. - pub fn set_link_suggestion( + /// Creates a mention node and inserts it into the composer at the current selection + pub fn insert_mention( + 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().insert_mention(url, text, attrs), + )) + } + + /// Creates a mention node and inserts it into the composer, replacing the + /// text content defined by the suggestion + pub fn insert_mention_at_suggestion( self: &Arc, url: String, text: String, @@ -284,7 +306,7 @@ impl ComposerModel { self.inner .lock() .unwrap() - .set_link_suggestion(url, text, suggestion, attrs), + .insert_mention_at_suggestion(url, text, suggestion, attrs), )) } diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_update.rs b/bindings/wysiwyg-ffi/src/ffi_composer_update.rs index fed0381d9..327f7f089 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_update.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_update.rs @@ -121,35 +121,119 @@ mod test { } #[test] - fn test_set_link_suggestion_ffi() { - let model = Arc::new(ComposerModel::new()); - let update = model.replace_text("@alic".into()); + fn test_replace_whole_suggestion_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + + insert_mention_at_cursor(&mut model); + + assert_eq!( + model.get_content_as_html(), + "Alice\u{a0}", + ) + } + + #[test] + fn test_replace_end_of_text_node_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + model.replace_text("hello ".into()); + + insert_mention_at_cursor(&mut model); + + assert_eq!( + model.get_content_as_html(), + "hello Alice\u{a0}", + ) + } + + #[test] + fn test_replace_start_of_text_node_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + model.replace_text(" says hello".into()); + model.select(0, 0); + + insert_mention_at_cursor(&mut model); + + assert_eq!( + model.get_content_as_html(), + "Alice says hello", + ) + } + + #[test] + fn test_replace_text_in_middle_of_node_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + model.replace_text("Like said".into()); + model.select(5, 5); // "Like | said" + + insert_mention_at_cursor(&mut model); + + assert_eq!( + model.get_content_as_html(), + "Like Alice said", + ) + } + + #[test] + fn test_replace_text_in_second_paragraph_node_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + model.replace_text("hello".into()); + model.enter(); + insert_mention_at_cursor(&mut model); + + assert_eq!( + model.get_content_as_html(), + "

hello

Alice\u{a0}

", + ) + } + + #[test] + fn test_replace_text_in_second_list_item_start_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + + model.ordered_list(); + model.replace_text("hello".into()); + model.enter(); + + insert_mention_at_cursor(&mut model); + + assert_eq!( + model.get_content_as_html(), + "
  1. hello
  2. Alice\u{a0}
", + ) + } + + #[test] + fn test_replace_text_in_second_list_item_end_with_mention_ffi() { + let mut model = Arc::new(ComposerModel::new()); + model.ordered_list(); + model.replace_text("hello".into()); + model.enter(); + model.replace_text("there ".into()); - let MenuAction::Suggestion { suggestion_pattern } = - update.menu_action() else - { - panic!("No suggestion found"); - }; + insert_mention_at_cursor(&mut model); - model.set_link_suggestion( + assert_eq!( + model.get_content_as_html(), + "
  1. hello
  2. there Alice\u{a0}
", + ) + } + + // TODO remove attributes when Rust model can parse url directly + // https://github.com/matrix-org/matrix-rich-text-editor/issues/709 + fn insert_mention_at_cursor(model: &mut Arc) { + let update = model.replace_text("@alic".into()); + let MenuAction::Suggestion{suggestion_pattern} = update.menu_action() else { + panic!("No suggestion pattern found") + }; + model.insert_mention_at_suggestion( "https://matrix.to/#/@alice:matrix.org".into(), "Alice".into(), suggestion_pattern, - vec![ - Attribute { - key: "contenteditable".into(), - value: "false".into(), - }, - Attribute { - key: "data-mention-type".into(), - value: "user".into(), - }, - ], + vec![Attribute { + key: "data-mention-type".into(), + value: "user".into(), + }], ); - assert_eq!( - model.get_content_as_html(), - "Alice\u{a0}", - ) } fn redo_indent_unindent_disabled() -> HashMap { diff --git a/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl b/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl index d6ab7ff56..36f99f41a 100644 --- a/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl +++ b/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl @@ -47,8 +47,9 @@ interface ComposerModel { ComposerUpdate unindent(); ComposerUpdate set_link(string url, sequence attributes); ComposerUpdate set_link_with_text(string url, string text, sequence attributes); - ComposerUpdate set_link_suggestion(string url, string text, SuggestionPattern suggestion, sequence attributes); ComposerUpdate remove_links(); + ComposerUpdate insert_mention(string url, string text, sequence attributes); + ComposerUpdate insert_mention_at_suggestion(string url, string text, SuggestionPattern suggestion, sequence attributes); ComposerUpdate code_block(); ComposerUpdate quote(); void debug_panic(); diff --git a/bindings/wysiwyg-wasm/src/lib.rs b/bindings/wysiwyg-wasm/src/lib.rs index dea8e05e6..dd865334b 100644 --- a/bindings/wysiwyg-wasm/src/lib.rs +++ b/bindings/wysiwyg-wasm/src/lib.rs @@ -312,17 +312,30 @@ impl ComposerModel { )) } - /// This function creates a link with the first argument being the href, the second being the - /// display text, the third being the (rust model) suggestion that is being replaced and the - /// final argument being a map of html attributes that will be added to the Link. - pub fn set_link_suggestion( + /// Creates a mention node and inserts it into the composer at the current selection + pub fn insert_mention( + &mut self, + url: &str, + text: &str, + attributes: js_sys::Map, + ) -> ComposerUpdate { + ComposerUpdate::from(self.inner.insert_mention( + Utf16String::from_str(url), + Utf16String::from_str(text), + attributes.into_vec(), + )) + } + + /// Creates a mention node and inserts it into the composer, replacing the + /// text content defined by the suggestion + pub fn insert_mention_at_suggestion( &mut self, url: &str, text: &str, suggestion: &SuggestionPattern, attributes: js_sys::Map, ) -> ComposerUpdate { - ComposerUpdate::from(self.inner.set_link_suggestion( + ComposerUpdate::from(self.inner.insert_mention_at_suggestion( Utf16String::from_str(url), Utf16String::from_str(text), wysiwyg::SuggestionPattern::from(suggestion.clone()), diff --git a/crates/wysiwyg/src/composer_model.rs b/crates/wysiwyg/src/composer_model.rs index f8cd9d767..9d3fe90bd 100644 --- a/crates/wysiwyg/src/composer_model.rs +++ b/crates/wysiwyg/src/composer_model.rs @@ -20,6 +20,7 @@ pub mod format; mod format_inline_code; pub mod hyperlinks; pub mod lists; +pub mod mentions; pub mod menu_action; pub mod menu_state; pub mod new_lines; diff --git a/crates/wysiwyg/src/composer_model/example_format.rs b/crates/wysiwyg/src/composer_model/example_format.rs index bb4d56154..867abd3e5 100644 --- a/crates/wysiwyg/src/composer_model/example_format.rs +++ b/crates/wysiwyg/src/composer_model/example_format.rs @@ -350,18 +350,18 @@ impl SelectionWriter { /// after a mention node /// /// * `buf` - the output buffer up to and including the given node - /// * `pos` - the buffer position immediately after the node + /// * `start_pos` - the buffer position immediately before the node pub fn write_selection_mention_node( &mut self, buf: &mut S, - pos: usize, + start_pos: usize, node: &MentionNode, ) { if let Some(loc) = self.locations.get(&node.handle()) { let strings_to_add = self.state.advance(loc, 1); for (str, i) in strings_to_add.into_iter().rev() { - let i = if i == 0 { 0 } else { buf.len() }; - buf.insert(pos + i, &S::from(str)); + let insert_pos = if i == 0 { start_pos } else { buf.len() }; + buf.insert(insert_pos, &S::from(str)); } } } diff --git a/crates/wysiwyg/src/composer_model/hyperlinks.rs b/crates/wysiwyg/src/composer_model/hyperlinks.rs index 4fc881e41..fb8027f97 100644 --- a/crates/wysiwyg/src/composer_model/hyperlinks.rs +++ b/crates/wysiwyg/src/composer_model/hyperlinks.rs @@ -21,8 +21,7 @@ use crate::dom::nodes::DomNode; use crate::dom::unicode_string::UnicodeStrExt; use crate::dom::Range; use crate::{ - ComposerModel, ComposerUpdate, DomHandle, LinkAction, Location, - SuggestionPattern, UnicodeString, + ComposerModel, ComposerUpdate, DomHandle, LinkAction, UnicodeString, }; use email_address::*; use url::{ParseError, Url}; @@ -63,24 +62,6 @@ where } } - pub fn set_link_suggestion( - &mut self, - url: S, - text: S, - suggestion: SuggestionPattern, - attributes: Vec<(S, S)>, - ) -> ComposerUpdate { - // TODO - this function allows us to accept a Vec of attributes to add to the Link we create, - // but these attributes will be present in the html of the message we output. We may need to - // add a step in the future that strips these attributes from the html before it is sent. - - self.do_replace_text_in(S::default(), suggestion.start, suggestion.end); - self.state.start = Location::from(suggestion.start); - self.state.end = self.state.start; - self.set_link_with_text(url, text, attributes); - self.do_replace_text(" ".into()) - } - fn is_blank_selection(&self, range: Range) -> bool { for leaf in range.leaves() { match leaf.kind { diff --git a/crates/wysiwyg/src/composer_model/mentions.rs b/crates/wysiwyg/src/composer_model/mentions.rs new file mode 100644 index 000000000..b28d69e2e --- /dev/null +++ b/crates/wysiwyg/src/composer_model/mentions.rs @@ -0,0 +1,112 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{ + dom::DomLocation, ComposerModel, ComposerUpdate, DomNode, Location, + SuggestionPattern, UnicodeString, +}; + +impl ComposerModel +where + S: UnicodeString, +{ + /// Remove the suggestion text and then insert a mention into the composer, using the following rules + /// - Do not insert a mention if the range includes link or code leaves + /// - If the composer contains a selection, remove the contents of the selection + /// prior to inserting a mention at the cursor. + /// - If the composer contains a cursor, insert a mention at the cursor + pub fn insert_mention_at_suggestion( + &mut self, + url: S, + text: S, + suggestion: SuggestionPattern, + attributes: Vec<(S, S)>, + ) -> ComposerUpdate { + if self.should_not_insert_mention() { + return ComposerUpdate::keep(); + } + + self.push_state_to_history(); + self.do_replace_text_in(S::default(), suggestion.start, suggestion.end); + self.state.start = Location::from(suggestion.start); + self.state.end = self.state.start; + self.do_insert_mention(url, text, attributes) + } + + /// Inserts a mention into the composer. It uses the following rules: + /// - Do not insert a mention if the range includes link or code leaves + /// - If the composer contains a selection, remove the contents of the selection + /// prior to inserting a mention at the cursor. + /// - If the composer contains a cursor, insert a mention at the cursor + pub fn insert_mention( + &mut self, + url: S, + text: S, + attributes: Vec<(S, S)>, + ) -> ComposerUpdate { + if self.should_not_insert_mention() { + return ComposerUpdate::keep(); + } + + self.push_state_to_history(); + if self.has_selection() { + self.do_replace_text(S::default()); + } + self.do_insert_mention(url, text, attributes) + } + + /// Creates a new mention node then inserts the node at the cursor position. It adds a trailing space when the inserted + /// mention is the last node in it's parent. + fn do_insert_mention( + &mut self, + url: S, + text: S, + attributes: Vec<(S, S)>, + ) -> ComposerUpdate { + let (start, end) = self.safe_selection(); + let range = self.state.dom.find_range(start, end); + + let new_node = DomNode::new_mention(url, text, attributes); + let new_cursor_index = start + new_node.text_len(); + + let handle = self.state.dom.insert_node_at_cursor(&range, new_node); + + // manually move the cursor to the end of the mention + self.state.start = Location::from(new_cursor_index); + self.state.end = self.state.start; + + // add a trailing space in cases when we do not have a next sibling + if self.state.dom.is_last_in_parent(&handle) { + self.do_replace_text(" ".into()) + } else { + self.create_update_replace_all() + } + } + + /// Utility function for the insert_mention* methods. It returns false if the range + /// includes any link or code type leaves. + /// + /// Related issue is here: + /// https://github.com/matrix-org/matrix-rich-text-editor/issues/702 + /// We do not allow mentions to be inserted into links, the planned behaviour is + /// detailed in the above issue. + fn should_not_insert_mention(&self) -> bool { + let (start, end) = self.safe_selection(); + let range = self.state.dom.find_range(start, end); + + range.locations.iter().any(|l: &DomLocation| { + l.kind.is_link_kind() || l.kind.is_code_kind() + }) + } +} diff --git a/crates/wysiwyg/src/composer_model/menu_action.rs b/crates/wysiwyg/src/composer_model/menu_action.rs index 4d0edc790..aba997088 100644 --- a/crates/wysiwyg/src/composer_model/menu_action.rs +++ b/crates/wysiwyg/src/composer_model/menu_action.rs @@ -28,7 +28,12 @@ where pub(crate) fn compute_menu_action(&self) -> MenuAction { let (s, e) = self.safe_selection(); let range = self.state.dom.find_range(s, e); - if range.locations.iter().any(|l| l.kind.is_code_kind()) { + + if range + .locations + .iter() + .any(|l| l.kind.is_code_kind() || l.kind.is_link_kind()) + { return MenuAction::None; } let (raw_text, start, end) = self.extended_text(range); diff --git a/crates/wysiwyg/src/dom.rs b/crates/wysiwyg/src/dom.rs index d1d6371a5..1a430b231 100644 --- a/crates/wysiwyg/src/dom.rs +++ b/crates/wysiwyg/src/dom.rs @@ -23,6 +23,7 @@ pub mod dom_struct; pub mod find_extended_range; pub mod find_range; pub mod find_result; +pub mod insert_node_at_cursor; pub mod insert_parent; pub mod iter; pub mod join_nodes; diff --git a/crates/wysiwyg/src/dom/dom_struct.rs b/crates/wysiwyg/src/dom/dom_struct.rs index 101d759ed..435262cf6 100644 --- a/crates/wysiwyg/src/dom/dom_struct.rs +++ b/crates/wysiwyg/src/dom/dom_struct.rs @@ -428,12 +428,14 @@ where /// If handle points to a text node, this text node may be split if needed. /// If handle points to a line break node, offset should definitely be 1, /// and the new node will be inserted after it. + /// + /// Returns the handle of the inserted node. pub fn insert_into_text( &mut self, handle: &DomHandle, offset: usize, new_node: DomNode, - ) { + ) -> DomHandle { enum Where { Before, During, @@ -469,10 +471,10 @@ where }; match wh { - Where::Before => { - self.parent_mut(handle) - .insert_child(handle.index_in_parent(), new_node); - } + Where::Before => self + .parent_mut(handle) + .insert_child(handle.index_in_parent(), new_node) + .handle(), Where::During => { // Splice new_node in between this text node and a new one let old_node = self.lookup_node_mut(handle); @@ -483,19 +485,22 @@ where old_text_node.set_data(before_text); let new_text_node = DomNode::new_text(after_text); let parent = self.parent_mut(handle); - parent.insert_child(handle.index_in_parent() + 1, new_node); + let inserted_handle = parent + .insert_child(handle.index_in_parent() + 1, new_node) + .handle(); parent.insert_child( handle.index_in_parent() + 2, new_text_node, ); + inserted_handle } else { panic!("Can't insert in the middle of non-text node!"); } } - Where::After => { - self.parent_mut(handle) - .insert_child(handle.index_in_parent() + 1, new_node); - } + Where::After => self + .parent_mut(handle) + .insert_child(handle.index_in_parent() + 1, new_node) + .handle(), } } diff --git a/crates/wysiwyg/src/dom/insert_node_at_cursor.rs b/crates/wysiwyg/src/dom/insert_node_at_cursor.rs new file mode 100644 index 000000000..0eb7a8afa --- /dev/null +++ b/crates/wysiwyg/src/dom/insert_node_at_cursor.rs @@ -0,0 +1,199 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{DomHandle, DomNode, UnicodeString}; + +use super::{Dom, DomLocation, Range}; + +impl Dom +where + S: UnicodeString, +{ + // Inserts the new node at the current cursor position if possible, panics if + // the range passed is a selection + pub fn insert_node_at_cursor( + &mut self, + range: &Range, + new_node: DomNode, + ) -> DomHandle { + if range.is_selection() { + panic!("Attempted to use `insert_node_at_cursor` with a selection") + } + + #[cfg(any(test, feature = "assert-invariants"))] + self.assert_invariants(); + + let inserted_handle: DomHandle; + + // manipulate the state of the dom as required + if let Some(leaf) = range.leaves().next() { + // when we have a leaf, the way we treat the insertion depends on the cursor position inside that leaf + let cursor_at_end = leaf.start_offset == leaf.length; + let cursor_at_start = leaf.start_offset == 0; + let leaf_is_placeholder = + self.lookup_node(&leaf.node_handle).is_placeholder(); + + if leaf_is_placeholder || cursor_at_start { + // insert the new node before a placeholder leaf or one that contains a cursor at the start + inserted_handle = self.insert_at(&leaf.node_handle, new_node); + } else if cursor_at_end { + // insert the new node after a leaf that contains a cursor at the end + inserted_handle = self + .append(&self.parent(&leaf.node_handle).handle(), new_node); + } else { + // otherwise insert the new node in the middle of a text node + inserted_handle = self.insert_into_text( + &leaf.node_handle, + leaf.start_offset, + new_node, + ) + } + } else { + // if we don't have a leaf, try to find the first container that we're inside + let first_location: Option<&DomLocation> = + range.locations.iter().find(|l| l.start_offset < l.length); + match first_location { + // if we haven't found anything, we're inserting into an empty dom + None => { + inserted_handle = self.append_at_end_of_document(new_node); + } + Some(container) => { + inserted_handle = + self.append(&container.node_handle, new_node); + } + }; + } + + #[cfg(any(test, feature = "assert-invariants"))] + self.assert_invariants(); + + inserted_handle + } +} + +#[cfg(test)] +mod test { + use crate::{ + tests::{testutils_composer_model::cm, testutils_conversion::utf16}, + DomNode, ToHtml, + }; + #[test] + #[should_panic] + fn panics_if_passed_selection() { + let mut model = cm("{something}|"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + } + + #[test] + fn inserts_node_in_empty_model() { + let mut model = cm("|"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + + assert_eq!(model.state.dom.to_html(), "") + } + + #[test] + fn inserts_node_into_empty_container() { + let mut model = cm("

|

"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + + assert_eq!(model.state.dom.to_html(), "

") + } + + #[test] + fn inserts_node_into_leaf_start() { + let mut model = cm("

|this is a leaf

"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + + assert_eq!( + model.state.dom.to_html(), + "

this is a leaf

" + ) + } + + #[test] + fn inserts_node_into_leaf_middle() { + let mut model = cm("

this is| a leaf

"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + + assert_eq!( + model.state.dom.to_html(), + "

this is a leaf

" + ) + } + + #[test] + fn inserts_node_into_leaf_end() { + let mut model = cm("

this is a leaf|

"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + + assert_eq!( + model.state.dom.to_html(), + "

this is a leaf

" + ) + } + + #[test] + fn inserts_node_into_empty_paragraph() { + let mut model = cm("

 

 |

 

"); + let (start, end) = model.safe_selection(); + let range = model.state.dom.find_range(start, end); + + model.state.dom.insert_node_at_cursor( + &range, + DomNode::new_link(utf16("href"), vec![], vec![]), + ); + + assert_eq!( + model.state.dom.to_html(), + "

\u{a0}

\u{a0}

\u{a0}

" + ) + } +} diff --git a/crates/wysiwyg/src/dom/nodes/dom_node.rs b/crates/wysiwyg/src/dom/nodes/dom_node.rs index 33459f0fb..c77811e5a 100644 --- a/crates/wysiwyg/src/dom/nodes/dom_node.rs +++ b/crates/wysiwyg/src/dom/nodes/dom_node.rs @@ -256,6 +256,14 @@ where } } + /// Returns if this node is a placeholder, as used in empty paragraphs + pub fn is_placeholder(&self) -> bool { + match self { + DomNode::Text(n) => n.data() == "\u{A0}", + _ => false, + } + } + /// Returns true if given node can be pushed into self without any specific change. pub(crate) fn can_push(&self, other_node: &DomNode) -> bool { match (self, other_node) { @@ -549,6 +557,10 @@ impl DomNodeKind { Self::CodeBlock | Self::Formatting(InlineFormatType::InlineCode) ) } + + pub fn is_link_kind(&self) -> bool { + matches!(self, Self::Link) + } } #[cfg(test)] diff --git a/crates/wysiwyg/src/tests.rs b/crates/wysiwyg/src/tests.rs index cd48a2838..f684f94cb 100644 --- a/crates/wysiwyg/src/tests.rs +++ b/crates/wysiwyg/src/tests.rs @@ -21,6 +21,7 @@ pub mod test_get_link_action; pub mod test_links; pub mod test_lists; pub mod test_lists_with_blocks; +pub mod test_mentions; pub mod test_menu_action; pub mod test_menu_state; pub mod test_paragraphs; diff --git a/crates/wysiwyg/src/tests/test_mentions.rs b/crates/wysiwyg/src/tests/test_mentions.rs new file mode 100644 index 000000000..e1a861191 --- /dev/null +++ b/crates/wysiwyg/src/tests/test_mentions.rs @@ -0,0 +1,546 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use widestring::Utf16String; + +use crate::{ + tests::testutils_composer_model::{cm, tx}, + ComposerModel, MenuAction, +}; + +/** + * ATTRIBUTE TESTS + */ +#[test] +fn mention_without_attributes() { + let mut model = cm("|"); + insert_mention_at_cursor(&mut model); + + assert_eq!( + tx(&model), + "Alice |", + ); +} + +#[test] +fn mention_with_attributes() { + let mut model = cm("|"); + let update = model.replace_text("@alic".into()); + let MenuAction::Suggestion(suggestion) = update.menu_action else { + panic!("No suggestion pattern found") + }; + model.insert_mention_at_suggestion( + "https://matrix.to/#/@alice:matrix.org".into(), + "Alice".into(), + suggestion, + vec![("data-mention-type".into(), "user".into())], + ); + assert_eq!( + tx(&model), + "Alice |", + ); +} + +/** + * INSERT AT CURSOR + */ +/** + * TEXT NODE + */ +#[test] +fn text_node_replace_all() { + let mut model = cm("|"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Alice |", + ); +} + +#[test] +fn text_node_replace_start() { + let mut model = cm("| says hello"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Alice| says hello", + ); +} + +#[test] +fn text_node_replace_middle() { + let mut model = cm("Like | said"); + insert_mention_at_cursor(&mut model); + assert_eq!(tx(&model), + "Like Alice| said"); +} + +#[test] +fn text_node_replace_end() { + let mut model = cm("hello |"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "hello Alice |", + ); +} + +/** + * LINEBREAK NODES + */ +#[test] +fn linebreak_insert_before() { + let mut model = cm("|
"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Alice|
", + ); +} + +#[test] +fn linebreak_insert_after() { + let mut model = cm("
|"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "
Alice |", + ); +} + +/** + * MENTION NODES + */ +#[test] +fn mention_insert_before() { + let mut model = cm("|test"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Alice|test", + ); +} + +#[test] +fn mention_insert_after() { + let mut model = + cm("test|"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "testAlice |", + ); +} + +/** + * CONTAINER NODES + */ +/** + * FORMATTING NODES + */ +#[test] +fn formatting_node_replace_all() { + let mut model = cm("|"); + let update = model.replace_text("@alic".into()); + let MenuAction::Suggestion(suggestion) = update.menu_action else { + panic!("No suggestion pattern found") + }; + model.insert_mention_at_suggestion( + "https://matrix.to/#/@alice:matrix.org".into(), + "Alice".into(), + suggestion, + vec![], + ); + assert_eq!( + tx(&model), + "Alice |", + ); +} + +#[test] +fn formatting_node_replace_start() { + let mut model = cm("| says hello"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Alice| says hello", + ); +} + +#[test] +fn formatting_node_replace_middle() { + let mut model = cm("Like | said"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Like Alice| said", + ); +} + +#[test] +fn formatting_node_replace_end() { + let mut model = cm("hello |"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "hello Alice |", + ); +} + +#[test] +#[should_panic] +fn formatting_node_inline_code() { + let mut model = cm("
hello |
"); + insert_mention_at_cursor(&mut model); +} + +/** + * LINK NODES + */ +#[test] +fn link_insert_before() { + let mut model = + cm("| regular link"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "Alice| regular link", + ); +} + +// TODO - change behaviour to allow inserting mentions into links +// see issue https://github.com/matrix-org/matrix-rich-text-editor/issues/702 +#[test] +#[should_panic] +fn link_insert_middle() { + let mut model = + cm("regular | link"); + insert_mention_at_cursor(&mut model); +} + +#[test] +fn link_insert_after() { + let mut model = + cm("regular link |"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "regular link Alice |", + ); +} + +/** + * LIST ITEM + */ +#[test] +fn list_item_insert_into_empty() { + let mut model = cm("
  1. |
"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "
  1. Alice |
", + ); +} + +#[test] +fn list_item_replace_start() { + let mut model = cm("
  1. | says hello
"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "
  1. Alice| says hello
", + ); +} + +#[test] +fn list_item_replace_middle() { + let mut model = cm("
  1. Like | said
"); + insert_mention_at_cursor(&mut model); + assert_eq!(tx(&model), + "
  1. Like Alice| said
"); +} + +#[test] +fn list_item_replace_end() { + let mut model = cm("
  1. hello |
"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "
  1. hello Alice |
", + ); +} + +/** + * CodeBlock + */ +#[test] +#[should_panic] +fn codeblock_insert_anywhere() { + let mut model = cm("regular | link"); + insert_mention_at_cursor(&mut model); +} + +/** + * Quote + */ +#[test] +fn quote_insert_into_empty() { + let mut model = cm("

|

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

Alice |

", + ); +} + +#[test] +fn quote_replace_start() { + let mut model = cm("

| says hello

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

Alice| says hello

", + ); +} + +#[test] +fn quote_replace_middle() { + let mut model = cm("

Like | said

"); + insert_mention_at_cursor(&mut model); + assert_eq!(tx(&model), + "

Like Alice| said

"); +} + +#[test] +fn quote_replace_end() { + let mut model = cm("

hello |

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

hello Alice |

", + ); +} + +/** + * PARAGRAPH + */ +#[test] +fn paragraph_insert_into_empty() { + let mut model = cm("

|

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

Alice |

", + ); +} + +#[test] +fn paragraph_insert_into_empty_second() { + let mut model = cm("

hello

 |

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

hello

Alice |

", + ); +} + +#[test] +fn paragraph_replace_start() { + let mut model = cm("

| says hello

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

Alice| says hello

", + ); +} + +#[test] +fn paragraph_replace_middle() { + let mut model = cm("

Like | said

"); + insert_mention_at_cursor(&mut model); + assert_eq!(tx(&model), + "

Like Alice| said

"); +} + +#[test] +fn paragraph_replace_end() { + let mut model = cm("

hello |

"); + insert_mention_at_cursor(&mut model); + assert_eq!( + tx(&model), + "

hello Alice |

", + ); +} + +/** + * INSERT INTO SELECTION + */ + +#[test] +fn selection_plain_text_replace() { + let mut model = cm("{replace_me}|"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "Alice |" + ); +} + +#[test] +fn selection_plain_text_start() { + let mut model = cm("{replace}|_me"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "Alice|_me" + ); +} + +#[test] +fn selection_plain_text_middle() { + let mut model = cm("replac{e}|_me"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "replacAlice|_me" + ); +} + +#[test] +fn selection_formatting_inside() { + let mut model = cm("hello {replace_me}|!"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "hello Alice|!" + ); +} + +#[test] +fn selection_formatting_spanning() { + let mut model = cm("hello {replace_me}|!"); + insert_mention_at_selection(&mut model); + assert_eq!(tx(&model), "hello Alice |!"); +} + +#[test] +fn selection_formatting_inline_code() { + // should not allow insertion + let mut model = cm("hello {replace_me}|!"); + insert_mention_at_selection(&mut model); + assert_eq!(tx(&model), "hello {replace_me}|!"); +} + +// TODO - change behaviour to allow inserting mentions into links +// see issue https://github.com/matrix-org/matrix-rich-text-editor/issues/702 +#[test] +fn selection_link_inside() { + let mut model = cm("hello {replace_me}|!"); + insert_mention_at_selection(&mut model); + assert_eq!(tx(&model), "hello {replace_me}|!"); +} + +#[test] +fn selection_link_spanning_partial() { + let mut model = + cm("hello {replace_me}|something"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "hello {replace_me}|something" + ); +} + +#[test] +fn selection_link_spanning_all() { + let mut model = + cm("hello {replacesomething_me}|!"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "hello {replacesomething_me}|!" + ); +} + +#[test] +fn selection_list_item_spanning() { + let mut model = cm("
  1. hello {replace
  2. _me}|!
"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "
  1. hello Alice|!
" + ); +} + +#[test] +fn selection_codeblock() { + // should not allow insertion + let mut model = cm("
hello {replace_me}|!
"); + insert_mention_at_selection(&mut model); + assert_eq!(tx(&model), "
hello {replace_me}|!
"); +} + +#[test] +fn selection_quote() { + let mut model = cm("

hello {replace_me}|!

"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "

hello Alice|!

" + ); +} + +#[test] +fn selection_paragraph_middle() { + let mut model = cm("

hello {replace_me}|!

"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "

hello Alice|!

" + ); +} + +#[test] +fn selection_paragraph_spanning() { + let mut model = cm("

hello {replace

_me}|!

"); + insert_mention_at_selection(&mut model); + assert_eq!( + tx(&model), + "

hello Alice|!

" + ); +} + +/** + * HELPER FUNCTIONS + */ +fn insert_mention_at_cursor(model: &mut ComposerModel) { + let update = model.replace_text("@alic".into()); + let MenuAction::Suggestion(suggestion) = update.menu_action else { + panic!("No suggestion pattern found") + }; + model.insert_mention_at_suggestion( + "https://matrix.to/#/@alice:matrix.org".into(), + "Alice".into(), + suggestion, + vec![], + ); +} + +fn insert_mention_at_selection(model: &mut ComposerModel) { + model.insert_mention( + "https://matrix.to/#/@alice:matrix.org".into(), + "Alice".into(), + vec![], + ); +} diff --git a/crates/wysiwyg/src/tests/test_menu_action.rs b/crates/wysiwyg/src/tests/test_menu_action.rs index ffcc076a3..6e7ebd3cb 100644 --- a/crates/wysiwyg/src/tests/test_menu_action.rs +++ b/crates/wysiwyg/src/tests/test_menu_action.rs @@ -91,6 +91,14 @@ fn at_pattern_is_not_detected_in_inline_code() { assert_eq!(model.compute_menu_action(), MenuAction::None); } +// TODO - change behaviour to allow inserting mentions into links +// see issue https://github.com/matrix-org/matrix-rich-text-editor/issues/702 +#[test] +fn at_pattern_is_not_detected_in_link() { + let model = cm("some @abc| link"); + assert_eq!(model.compute_menu_action(), MenuAction::None); +} + #[test] fn at_pattern_is_detected_if_cursor_is_right_before() { let model = cm("|@alic"); diff --git a/crates/wysiwyg/src/tests/test_suggestions.rs b/crates/wysiwyg/src/tests/test_suggestions.rs index 17e43a633..5bf40645f 100644 --- a/crates/wysiwyg/src/tests/test_suggestions.rs +++ b/crates/wysiwyg/src/tests/test_suggestions.rs @@ -26,44 +26,3 @@ fn test_replace_text_suggestion() { model.replace_text_suggestion("/invite".into(), suggestion); assert_eq!(tx(&model), "/invite |"); } - -#[test] -fn test_set_link_suggestion_no_attributes() { - let mut model = cm("|"); - let update = model.replace_text("@alic".into()); - let MenuAction::Suggestion(suggestion) = update.menu_action else { - panic!("No suggestion pattern found") - }; - model.set_link_suggestion( - "https://matrix.to/#/@alice:matrix.org".into(), - "Alice".into(), - suggestion, - vec![], - ); - assert_eq!( - tx(&model), - "Alice |", - ); -} - -#[test] -fn test_set_link_suggestion_with_attributes() { - let mut model = cm("|"); - let update = model.replace_text("@alic".into()); - let MenuAction::Suggestion(suggestion) = update.menu_action else { - panic!("No suggestion pattern found") - }; - model.set_link_suggestion( - "https://matrix.to/#/@alice:matrix.org".into(), - "Alice".into(), - suggestion, - vec![ - ("contenteditable".into(), "false".into()), - ("data-mention-type".into(), "user".into()), - ], - ); - assert_eq!( - tx(&model), - "Alice |", - ); -} diff --git a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt b/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt index 2bdf45a91..1b396656b 100644 --- a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt +++ b/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/RichTextEditor.kt @@ -15,8 +15,7 @@ import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction import io.element.android.wysiwyg.poc.databinding.ViewRichTextEditorBinding import io.element.android.wysiwyg.poc.matrix.Mention -import io.element.android.wysiwyg.poc.matrix.MatrixMentionLinkDisplayHandler -import io.element.android.wysiwyg.poc.matrix.MatrixRoomKeywordDisplayHandler +import io.element.android.wysiwyg.poc.matrix.MatrixMentionMentionDisplayHandler import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction import uniffi.wysiwyg_composer.MenuAction @@ -120,8 +119,7 @@ class RichTextEditor : LinearLayout { EditorEditText.OnMenuActionChangedListener { menuAction -> updateSuggestions(menuAction) } - richTextEditText.linkDisplayHandler = MatrixMentionLinkDisplayHandler() - richTextEditText.keywordDisplayHandler = MatrixRoomKeywordDisplayHandler() + richTextEditText.mentionDisplayHandler = MatrixMentionMentionDisplayHandler() } } diff --git a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixMentionLinkDisplayHandler.kt b/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixMentionLinkDisplayHandler.kt deleted file mode 100644 index 18b0e6e52..000000000 --- a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixMentionLinkDisplayHandler.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.element.android.wysiwyg.poc.matrix - -import io.element.android.wysiwyg.display.TextDisplay -import io.element.android.wysiwyg.display.LinkDisplayHandler - -/** - * Convenience implementation of a [LinkDisplayHandler] that detects Matrix mentions and - * displays them as default pills. - */ -class MatrixMentionLinkDisplayHandler : LinkDisplayHandler { - override fun resolveLinkDisplay(text: String, url: String): TextDisplay = - when (url.startsWith("https://matrix.to/#/")) { - true -> TextDisplay.Pill - false -> TextDisplay.Plain - } -} \ No newline at end of file diff --git a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixMentionMentionDisplayHandler.kt b/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixMentionMentionDisplayHandler.kt new file mode 100644 index 000000000..bcec37ecc --- /dev/null +++ b/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixMentionMentionDisplayHandler.kt @@ -0,0 +1,16 @@ +package io.element.android.wysiwyg.poc.matrix + +import io.element.android.wysiwyg.display.TextDisplay +import io.element.android.wysiwyg.display.MentionDisplayHandler + +/** + * Convenience implementation of a [MentionDisplayHandler] that detects Matrix mentions and + * displays them as default pills. + */ +class MatrixMentionMentionDisplayHandler : MentionDisplayHandler { + override fun resolveMentionDisplay(text: String, url: String): TextDisplay = + TextDisplay.Pill + + override fun resolveAtRoomMentionDisplay(): TextDisplay = + TextDisplay.Pill +} \ No newline at end of file diff --git a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixRoomKeywordDisplayHandler.kt b/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixRoomKeywordDisplayHandler.kt deleted file mode 100644 index 9cb2d20b9..000000000 --- a/platforms/android/example/src/main/java/io/element/android/wysiwyg/poc/matrix/MatrixRoomKeywordDisplayHandler.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.element.android.wysiwyg.poc.matrix - -import io.element.android.wysiwyg.display.KeywordDisplayHandler -import io.element.android.wysiwyg.display.TextDisplay - -/** - * Convenience implementation of a [KeywordDisplayHandler] that detects Matrix @room - * mentions displays them as default pills. - */ -class MatrixRoomKeywordDisplayHandler : KeywordDisplayHandler { - override val keywords: List = - listOf("@room") - - override fun resolveKeywordDisplay(text: String): TextDisplay = - when (text) { - "@room" -> TextDisplay.Pill - else -> TextDisplay.Plain - } -} \ No newline at end of file diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/EditorEditTextInputTests.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/EditorEditTextInputTests.kt index ba5e46d34..f83ef98a3 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 @@ -1,7 +1,6 @@ package io.element.android.wysiwyg import android.content.ClipData -import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.graphics.Typeface @@ -12,7 +11,6 @@ import android.text.style.ReplacementSpan import android.text.style.StyleSpan import android.view.KeyEvent import android.view.View -import android.view.inputmethod.InputContentInfo import android.widget.EditText import android.widget.TextView import androidx.core.text.getSpans @@ -29,7 +27,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.wysiwyg.display.TextDisplay -import io.element.android.wysiwyg.fakes.SimpleKeywordDisplayHandler import io.element.android.wysiwyg.test.R import io.element.android.wysiwyg.test.utils.* import io.element.android.wysiwyg.utils.RustErrorCollector @@ -87,9 +84,9 @@ class EditorEditTextInputTests { @Test fun testBackspacePill() { onView(withId(R.id.rich_text_edit_text)) - .perform(EditorActions.setLinkDisplayHandler { _, _ -> TextDisplay.Pill }) + .perform(EditorActions.setMentionDisplayHandler(TestMentionDisplayHandler(TextDisplay.Pill))) .perform(typeText("Hello @")) - .perform(EditorActions.setLinkSuggestion("alice", "link")) + .perform(EditorActions.insertMentionAtSuggestion("alice", "link")) .perform(pressKey(KeyEvent.KEYCODE_DEL)) // Delete the space added after the pill .perform(pressKey(KeyEvent.KEYCODE_DEL)) // Delete the pill .perform(pressKey(KeyEvent.KEYCODE_DEL)) // Delete the trailing space after "Hello" @@ -281,8 +278,8 @@ class EditorEditTextInputTests { fun testSettingLinkSuggestion() { onView(withId(R.id.rich_text_edit_text)) .perform(ImeActions.setComposingText("@jonny")) - .perform(EditorActions.setLinkDisplayHandler { _, _ -> TextDisplay.Pill }) - .perform(EditorActions.setLinkSuggestion("jonny", "https://matrix.to/#/@test:matrix.org")) + .perform(EditorActions.setMentionDisplayHandler(TestMentionDisplayHandler(TextDisplay.Pill))) + .perform(EditorActions.insertMentionAtSuggestion("jonny", "https://matrix.to/#/@test:matrix.org")) .check(matches(TextViewMatcher { it.editableText.getSpans().isNotEmpty() })) @@ -292,13 +289,17 @@ class EditorEditTextInputTests { fun testSettingMultipleLinkSuggestionWithCustomReplacements() { onView(withId(R.id.rich_text_edit_text)) .perform(ImeActions.setComposingText("@jonny")) - .perform(EditorActions.setLinkDisplayHandler { _, _ -> TextDisplay.Custom(PillSpan( - R.color.fake_color - )) }) - .perform(EditorActions.setLinkSuggestion("jonny", "https://matrix.to/#/@test:matrix.org")) + .perform(EditorActions.setMentionDisplayHandler( + TestMentionDisplayHandler( + TextDisplay.Custom( + PillSpan(R.color.fake_color) + ) + ) + )) + .perform(EditorActions.insertMentionAtSuggestion("jonny", "https://matrix.to/#/@test:matrix.org")) .perform(typeText(" ")) .perform(ImeActions.setComposingText("@jonny")) - .perform(EditorActions.setLinkSuggestion("jonny", "https://matrix.to/#/@test:matrix.org")) + .perform(EditorActions.insertMentionAtSuggestion("jonny", "https://matrix.to/#/@test:matrix.org")) .check(matches(TextViewMatcher { it.editableText.getSpans().count() == 2 })) @@ -308,7 +309,7 @@ class EditorEditTextInputTests { fun testReplaceTextSuggestion() { onView(withId(R.id.rich_text_edit_text)) .perform(ImeActions.setComposingText("@r")) - .perform(EditorActions.setKeywordDisplayHandler(SimpleKeywordDisplayHandler("@room"))) + .perform(EditorActions.setMentionDisplayHandler(TestMentionDisplayHandler(TextDisplay.Pill))) .perform(EditorActions.replaceTextSuggestion("@room")) .check(matches(TextViewMatcher { it.editableText.getSpans().isNotEmpty() @@ -319,9 +320,9 @@ class EditorEditTextInputTests { fun testReplacingMultipleTextSuggestionsWithCustomReplacements() { onView(withId(R.id.rich_text_edit_text)) .perform(ImeActions.setComposingText("@r")) - .perform(EditorActions.setKeywordDisplayHandler( - SimpleKeywordDisplayHandler("@room", - displayAs = TextDisplay.Custom(PillSpan(R.color.fake_color))) + .perform(EditorActions.setMentionDisplayHandler( + TestMentionDisplayHandler( + TextDisplay.Custom(PillSpan(R.color.fake_color))) ) ) .perform(EditorActions.replaceTextSuggestion("@room")) diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/fakes/SimpleKeywordDisplayHandler.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/fakes/SimpleKeywordDisplayHandler.kt deleted file mode 100644 index 0542f4fc5..000000000 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/fakes/SimpleKeywordDisplayHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.element.android.wysiwyg.fakes - -import io.element.android.wysiwyg.display.KeywordDisplayHandler -import io.element.android.wysiwyg.display.TextDisplay - -class SimpleKeywordDisplayHandler( - private val keyword: String = "@room", - private val displayAs: TextDisplay = TextDisplay.Pill, -) : KeywordDisplayHandler { - override val keywords: List - get() = listOf(keyword) - - override fun resolveKeywordDisplay(text: String): TextDisplay = - when (text) { - keyword -> displayAs - else -> TextDisplay.Plain - } -} \ No newline at end of file diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt index 9806cc669..6242520f6 100644 --- a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/inputhandlers/InterceptInputConnectionIntegrationTest.kt @@ -29,8 +29,7 @@ class InterceptInputConnectionIntegrationTest { AndroidResourcesHelper(app), html, createFakeStyleConfig(), - linkDisplayHandler = null, - keywordDisplayHandler = null, + mentionDisplayHandler = null, ) }, ) 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 f5aedeb6e..ceb8c61e9 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 @@ -9,9 +9,8 @@ import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.display.KeywordDisplayHandler import io.element.android.wysiwyg.view.models.InlineFormat -import io.element.android.wysiwyg.display.LinkDisplayHandler +import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.utils.RustErrorCollector import org.hamcrest.Matcher @@ -81,7 +80,7 @@ object Editor { } } - class SetLinkSuggestion( + class InsertMentionAtSuggestion( private val text: String, private val url: String, ) : ViewAction { @@ -95,8 +94,8 @@ object Editor { } } - class SetLinkDisplayHandler( - private val linkDisplayHandler: LinkDisplayHandler, + class SetMentionDisplayHandler( + private val mentionDisplayHandler: MentionDisplayHandler, ) : ViewAction { override fun getConstraints(): Matcher = isDisplayed() @@ -104,20 +103,7 @@ object Editor { override fun perform(uiController: UiController?, view: View?) { val editor = view as? EditorEditText ?: return - editor.linkDisplayHandler = linkDisplayHandler - } - } - - class SetKeywordDisplayHandler( - private val keywordDisplayHandler: KeywordDisplayHandler, - ) : ViewAction { - override fun getConstraints(): Matcher = isDisplayed() - - override fun getDescription(): String = "Set keyword display handler" - - override fun perform(uiController: UiController?, view: View?) { - val editor = view as? EditorEditText ?: return - editor.keywordDisplayHandler = keywordDisplayHandler + editor.mentionDisplayHandler = mentionDisplayHandler } } @@ -247,10 +233,9 @@ object EditorActions { fun setLink(url: String) = Editor.SetLink(url) fun insertLink(text: String, url: String) = Editor.InsertLink(text, url) fun removeLink() = Editor.RemoveLink - fun setLinkSuggestion(text: String, url: String) = Editor.SetLinkSuggestion(text, url) - fun setLinkDisplayHandler(linkDisplayHandler: LinkDisplayHandler) = Editor.SetLinkDisplayHandler(linkDisplayHandler) + fun insertMentionAtSuggestion(text: String, url: String) = Editor.InsertMentionAtSuggestion(text, url) + fun setMentionDisplayHandler(mentionDisplayHandler: MentionDisplayHandler) = Editor.SetMentionDisplayHandler(mentionDisplayHandler) fun replaceTextSuggestion(text: String) = Editor.ReplaceTextSuggestion(text) - fun setKeywordDisplayHandler(keywordDisplayHandler: KeywordDisplayHandler) = Editor.SetKeywordDisplayHandler(keywordDisplayHandler) fun toggleList(ordered: Boolean) = Editor.ToggleList(ordered) fun undo() = Editor.Undo fun redo() = Editor.Redo diff --git a/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/TestMentionDisplayHandler.kt b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/TestMentionDisplayHandler.kt new file mode 100644 index 000000000..5950e014d --- /dev/null +++ b/platforms/android/library/src/androidTest/java/io/element/android/wysiwyg/test/utils/TestMentionDisplayHandler.kt @@ -0,0 +1,11 @@ +package io.element.android.wysiwyg.test.utils + +import io.element.android.wysiwyg.display.MentionDisplayHandler +import io.element.android.wysiwyg.display.TextDisplay + +class TestMentionDisplayHandler( + val textDisplay: TextDisplay, +) : MentionDisplayHandler { + override fun resolveAtRoomMentionDisplay(): TextDisplay = TextDisplay.Pill + override fun resolveMentionDisplay(text: String, url: String): TextDisplay = TextDisplay.Pill +} \ No newline at end of file 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 8149facc7..7624309e1 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 @@ -21,7 +21,6 @@ import androidx.annotation.VisibleForTesting import androidx.core.graphics.withTranslation import androidx.lifecycle.* import com.google.android.material.textfield.TextInputEditText -import io.element.android.wysiwyg.display.KeywordDisplayHandler import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelper import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelperFactory import io.element.android.wysiwyg.inputhandlers.InterceptInputConnection @@ -31,10 +30,9 @@ import io.element.android.wysiwyg.view.models.LinkAction import io.element.android.wysiwyg.internal.viewmodel.ReplaceTextResult import io.element.android.wysiwyg.internal.view.EditorEditTextAttributeReader import io.element.android.wysiwyg.internal.view.viewModel -import io.element.android.wysiwyg.internal.display.MemoizingLinkDisplayHandler +import io.element.android.wysiwyg.internal.display.MemoizingMentionDisplayHandler import io.element.android.wysiwyg.internal.viewmodel.EditorViewModel -import io.element.android.wysiwyg.display.LinkDisplayHandler -import io.element.android.wysiwyg.internal.display.MemoizedKeywordDisplayHandler +import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.utils.* import io.element.android.wysiwyg.utils.HtmlToSpansParser.FormattingSpans.removeFormattingSpans import io.element.android.wysiwyg.view.StyleConfig @@ -61,8 +59,7 @@ class EditorEditText : TextInputEditText { HtmlToSpansParser( resourcesProvider, html, styleConfig = styleConfig, - linkDisplayHandler = linkDisplayHandler, - keywordDisplayHandler = keywordDisplayHandler, + mentionDisplayHandler = mentionDisplayHandler, ) }, ) @@ -109,20 +106,11 @@ class EditorEditText : TextInputEditText { } /** - * Set the link display handler to display links in a custom way. - * For example, to transform links into pills. + * Set the mention display handler to display mentions in a custom way. */ - var linkDisplayHandler: LinkDisplayHandler? = null + var mentionDisplayHandler: MentionDisplayHandler? = null set(value) { - field = value?.let { MemoizingLinkDisplayHandler(it) } - } - /** - * Set the keyword display handler to display keywords in a custom way. - * For example, to transform keywords into pills. - */ - var keywordDisplayHandler: KeywordDisplayHandler? = null - set(value) { - field = value?.let { MemoizedKeywordDisplayHandler(it) } + field = value?.let { MemoizingMentionDisplayHandler(it) } } var selectionChangeListener: OnSelectionChangeListener? = null var actionStatesChangedListener: OnActionStatesChangedListener? = null diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/KeywordDisplayHandler.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/KeywordDisplayHandler.kt deleted file mode 100644 index cee92b77c..000000000 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/KeywordDisplayHandler.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.element.android.wysiwyg.display - -/** - * Clients can implement a link display handler to let the editor - * know how to display links. - */ -interface KeywordDisplayHandler { - val keywords: List - - /** - * Return the method with which to display a given keyword - */ - fun resolveKeywordDisplay(text: String): TextDisplay -} - diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/LinkDisplayHandler.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/LinkDisplayHandler.kt deleted file mode 100644 index 9ae2b5077..000000000 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/LinkDisplayHandler.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.element.android.wysiwyg.display - -/** - * Clients can implement a link display handler to let the editor - * know how to display links. - */ -fun interface LinkDisplayHandler { - /** - * Return the method with which to display a given link - */ - fun resolveLinkDisplay(text: String, url: String): TextDisplay -} diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/MentionDisplayHandler.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/MentionDisplayHandler.kt new file mode 100644 index 000000000..a8d0b7e35 --- /dev/null +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/display/MentionDisplayHandler.kt @@ -0,0 +1,17 @@ +package io.element.android.wysiwyg.display + +/** + * Clients can implement a mention display handler to let the editor + * know how to display mentions. + */ +interface MentionDisplayHandler { + /** + * Return the method with which to display a given mention + */ + fun resolveMentionDisplay(text: String, url: String): TextDisplay + + /** + * Return the method with which to display an at-room mention + */ + fun resolveAtRoomMentionDisplay(): TextDisplay +} diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedKeywordDisplayHandler.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedKeywordDisplayHandler.kt deleted file mode 100644 index 5062f61ce..000000000 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedKeywordDisplayHandler.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.element.android.wysiwyg.internal.display - -import io.element.android.wysiwyg.display.KeywordDisplayHandler -import io.element.android.wysiwyg.display.TextDisplay - -internal class MemoizedKeywordDisplayHandler( - private val delegate: KeywordDisplayHandler, -): KeywordDisplayHandler { - private val cache = mutableMapOf() - override val keywords: List get() = - delegate.keywords - - override fun resolveKeywordDisplay(text: String): TextDisplay { - val cached = cache[text] - - if(cached != null) { - return cached - } - - val calculated = delegate.resolveKeywordDisplay(text) - - cache[text] = calculated - - return calculated - } -} \ No newline at end of file diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedLinkDisplayHandler.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedLinkDisplayHandler.kt index 091ad0fc7..0fe561431 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedLinkDisplayHandler.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/display/MemoizedLinkDisplayHandler.kt @@ -1,17 +1,18 @@ package io.element.android.wysiwyg.internal.display import io.element.android.wysiwyg.display.TextDisplay -import io.element.android.wysiwyg.display.LinkDisplayHandler +import io.element.android.wysiwyg.display.MentionDisplayHandler /** - * This [LinkDisplayHandler] ensures that the editor does not request how to display the same item + * This [MentionDisplayHandler] ensures that the editor does not request how to display the same item * from the host app on every editor update by caching the results in memory. */ -internal class MemoizingLinkDisplayHandler( - private val delegate: LinkDisplayHandler -): LinkDisplayHandler { +internal class MemoizingMentionDisplayHandler( + private val delegate: MentionDisplayHandler +): MentionDisplayHandler { private val cache = mutableMapOf, TextDisplay>() - override fun resolveLinkDisplay(text: String, url: String): TextDisplay { + private var atRoomCache: TextDisplay? = null + override fun resolveMentionDisplay(text: String, url: String): TextDisplay { val key = text to url val cached = cache[key] @@ -19,10 +20,22 @@ internal class MemoizingLinkDisplayHandler( return cached } - val calculated = delegate.resolveLinkDisplay(text, url) + val calculated = delegate.resolveMentionDisplay(text, url) cache[key] = calculated return calculated } + + override fun resolveAtRoomMentionDisplay(): TextDisplay { + atRoomCache?.let { + return it + } + + val calculated = delegate.resolveAtRoomMentionDisplay() + + atRoomCache = calculated + + return calculated + } } \ No newline at end of file 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 5c32eb9bc..a66cab4c7 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,7 +94,7 @@ internal class EditorViewModel( is EditorInputAction.Quote -> composer?.quote() is EditorInputAction.Indent -> composer?.indent() is EditorInputAction.Unindent -> composer?.unindent() - is EditorInputAction.SetLinkSuggestion -> setLinkSuggestion(action) + is EditorInputAction.SetLinkSuggestion -> insertMentionAtSuggestion(action) } }.onFailure(::onComposerFailure) .getOrNull() @@ -176,14 +176,14 @@ internal class EditorViewModel( } } - private fun setLinkSuggestion(action: EditorInputAction.SetLinkSuggestion): ComposerUpdate? { + private fun insertMentionAtSuggestion(action: EditorInputAction.SetLinkSuggestion): ComposerUpdate? { val (url, text) = action val suggestion = (curMenuAction as? MenuAction.Suggestion) ?.suggestionPattern ?: return null - return composer?.setLinkSuggestion( + return composer?.insertMentionAtSuggestion( url = url, text = text, suggestion = suggestion, diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt index f45243aa2..1bcd3919e 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/utils/HtmlToSpansParser.kt @@ -10,9 +10,8 @@ import android.text.style.StyleSpan import android.text.style.UnderlineSpan import androidx.core.text.getSpans import io.element.android.wysiwyg.BuildConfig -import io.element.android.wysiwyg.display.KeywordDisplayHandler import io.element.android.wysiwyg.display.TextDisplay -import io.element.android.wysiwyg.display.LinkDisplayHandler +import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.view.StyleConfig import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.spans.BlockSpan @@ -21,7 +20,7 @@ import io.element.android.wysiwyg.view.spans.CustomReplacementSpan import io.element.android.wysiwyg.view.spans.ExtraCharacterSpan import io.element.android.wysiwyg.view.spans.InlineCodeSpan import io.element.android.wysiwyg.view.spans.LinkSpan -import io.element.android.wysiwyg.view.spans.PlainKeywordDisplaySpan +import io.element.android.wysiwyg.view.spans.PlainAtRoomMentionDisplaySpan import io.element.android.wysiwyg.view.spans.OrderedListSpan import io.element.android.wysiwyg.view.spans.PillSpan import io.element.android.wysiwyg.view.spans.QuoteSpan @@ -45,8 +44,7 @@ internal class HtmlToSpansParser( private val resourcesHelper: ResourcesHelper, private val html: String, private val styleConfig: StyleConfig, - private val linkDisplayHandler: LinkDisplayHandler?, - private val keywordDisplayHandler: KeywordDisplayHandler?, + private val mentionDisplayHandler: MentionDisplayHandler?, ) : ContentHandler { /** @@ -63,7 +61,7 @@ internal class HtmlToSpansParser( // Spans created to be used as 'marks' while parsing private sealed interface PlaceholderSpan { - data class Hyperlink(val link: String): PlaceholderSpan + data class Hyperlink(val link: String, val contentEditable: Boolean): PlaceholderSpan sealed interface ListBlock: PlaceholderSpan { class Ordered: ListBlock class Unordered: ListBlock @@ -109,7 +107,7 @@ internal class HtmlToSpansParser( text.setSpan(spanToAdd.span, spanToAdd.start, spanToAdd.end, spanToAdd.flags) } text.removePlaceholderSpans() - text.addKeywordSpans() + text.addAtRoomSpans() if (BuildConfig.DEBUG) text.assertOnlyAllowedSpans() return text } @@ -157,7 +155,8 @@ internal class HtmlToSpansParser( } "a" -> { val url = attrs?.getValue("href") ?: return - handleHyperlinkStart(url) + val contentEditable = attrs?.getValue("contenteditable")?.toBoolean() + handleHyperlinkStart(url, contentEditable ?: true) } "ul", "ol" -> { addLeadingLineBreakIfNeeded(text.length) @@ -306,8 +305,8 @@ internal class HtmlToSpansParser( replacePlaceholderWithPendingSpan(last.span, span, last.start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } - private fun handleHyperlinkStart(url: String) { - val hyperlink = PlaceholderSpan.Hyperlink(url) + private fun handleHyperlinkStart(url: String, contentEditable: Boolean) { + val hyperlink = PlaceholderSpan.Hyperlink(url, contentEditable) addPlaceHolderSpan(hyperlink) } @@ -315,8 +314,29 @@ internal class HtmlToSpansParser( val last = getLastPending() ?: return val url = last.span.link val innerText = text.subSequence(last.start, text.length).toString() - val textDisplay = linkDisplayHandler?.resolveLinkDisplay(innerText, url) - ?: TextDisplay.Plain + + // If the link is not editable, tag all but the first character of the anchor text with + // ExtraCharacterSpans. These characters will then be taken into account when translating + // between editor and composer model indices (see [EditorIndexMapper]). + val isContentEditable = !last.span.contentEditable + if (isContentEditable && text.length > 1) { + addPendingSpan( + ExtraCharacterSpan(), + last.start + 1, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + // TODO: use data-mention-type instead + // https://github.com/matrix-org/matrix-rich-text-editor/issues/709 + val isMention = !last.span.contentEditable + val textDisplay = if(isMention) { + mentionDisplayHandler?.resolveMentionDisplay(innerText, url) + ?: TextDisplay.Plain + } else { + TextDisplay.Plain + } + when (textDisplay) { is TextDisplay.Custom -> { val span = CustomReplacementSpan(textDisplay.customSpan) @@ -485,26 +505,25 @@ internal class HtmlToSpansParser( return resourcesHelper.dpToPx(this) } - private fun Editable.addKeywordSpans() = - keywordDisplayHandler?.keywords?.forEach { keyword -> - val display = keywordDisplayHandler.resolveKeywordDisplay(keyword) - Regex(Regex.escape(keyword)) - .findAll(text).forEach eachMatch@{ match -> - val start = match.range.first - val end = match.range.last + 1 - if (text.getSpans(start, end, PlainKeywordDisplaySpan::class.java).isNotEmpty()) { - return@eachMatch - } - val span = when (display) { - is TextDisplay.Custom -> CustomReplacementSpan(display.customSpan) - TextDisplay.Pill -> PillSpan( - resourcesHelper.getColor(styleConfig.pill.backgroundColor) - ) - TextDisplay.Plain -> null - } - text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + private fun Editable.addAtRoomSpans() { + val display = mentionDisplayHandler?.resolveAtRoomMentionDisplay() ?: return + Regex(Regex.escape("@room")) + .findAll(text).forEach eachMatch@{ match -> + val start = match.range.first + val end = match.range.last + 1 + if (text.getSpans(start, end, PlainAtRoomMentionDisplaySpan::class.java).isNotEmpty()) { + return@eachMatch } - } + val span = when (display) { + is TextDisplay.Custom -> CustomReplacementSpan(display.customSpan) + TextDisplay.Pill -> PillSpan( + resourcesHelper.getColor(styleConfig.pill.backgroundColor) + ) + TextDisplay.Plain -> null + } + text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } companion object FormattingSpans { diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/CodeBlockSpan.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/CodeBlockSpan.kt index e294d6cfd..9c7bfd80a 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/CodeBlockSpan.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/CodeBlockSpan.kt @@ -19,7 +19,7 @@ class CodeBlockSpan( @FloatRange(from = 0.0) relativeSizeProportion: Float = CodeSpanConstants.DEFAULT_RELATIVE_SIZE_PROPORTION, ) : MetricAffectingSpan(), BlockSpan, LeadingMarginSpan, LineHeightSpan, UpdateAppearance, - PlainKeywordDisplaySpan { + PlainAtRoomMentionDisplaySpan { private val monoTypefaceSpan = TypefaceSpan("monospace") private val relativeSizeSpan = RelativeSizeSpan(relativeSizeProportion) diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/InlineCodeSpan.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/InlineCodeSpan.kt index 102665c70..eceff7112 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/InlineCodeSpan.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/InlineCodeSpan.kt @@ -23,7 +23,7 @@ import androidx.annotation.FloatRange class InlineCodeSpan( @FloatRange(from = 0.0) relativeSizeProportion: Float = CodeSpanConstants.DEFAULT_RELATIVE_SIZE_PROPORTION, -) : TypefaceSpan("monospace"), PlainKeywordDisplaySpan { +) : TypefaceSpan("monospace"), PlainAtRoomMentionDisplaySpan { private val relativeSizeSpan = RelativeSizeSpan(relativeSizeProportion) override fun updateMeasureState(paint: TextPaint) { diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/LinkSpan.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/LinkSpan.kt index dfb115ee2..f66ecf54d 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/LinkSpan.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/LinkSpan.kt @@ -5,7 +5,7 @@ import android.text.style.URLSpan internal class LinkSpan( url: String -) : URLSpan(url), PlainKeywordDisplaySpan { +) : URLSpan(url), PlainAtRoomMentionDisplaySpan { override fun updateDrawState(ds: TextPaint) { // Check if the text is already underlined (for example by an UnderlineSpan) val wasUnderlinedByAnotherSpan = ds.isUnderlineText diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/PlainAtRoomMentionDisplaySpan.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/PlainAtRoomMentionDisplaySpan.kt new file mode 100644 index 000000000..6008fe8a4 --- /dev/null +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/PlainAtRoomMentionDisplaySpan.kt @@ -0,0 +1,7 @@ +package io.element.android.wysiwyg.view.spans + +/** + * Used to override any [MentionDisplayHandler] and force text to be plain. + * This can be used, for example, inside a code block where text must be displayed as-is. + */ +internal interface PlainAtRoomMentionDisplaySpan \ No newline at end of file diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/PlainKeywordDisplaySpan.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/PlainKeywordDisplaySpan.kt deleted file mode 100644 index dc5dd85eb..000000000 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/view/spans/PlainKeywordDisplaySpan.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.element.android.wysiwyg.view.spans - -import io.element.android.wysiwyg.display.KeywordDisplayHandler - -/** - * Used to override any [KeywordDisplayHandler] and force text to be plain. - * This can be used, for example, inside a code block where text must be displayed as-is. - */ -internal interface PlainKeywordDisplaySpan \ No newline at end of file diff --git a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/mocks/MockComposer.kt b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/mocks/MockComposer.kt index cc51f6b42..0774b7b5b 100644 --- a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/mocks/MockComposer.kt +++ b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/mocks/MockComposer.kt @@ -87,11 +87,11 @@ class MockComposer { update: ComposerUpdate = MockComposerUpdateFactory.create(), ) = every { instance.removeLinks() } returns update - fun givenSetLinkSuggestionResult( + fun givenInsertMentionFromSuggestionResult( name: String, link: String, update: ComposerUpdate = MockComposerUpdateFactory.create(), - ) = every { instance.setLinkSuggestion(url = link, attributes = emptyList(), text = name, suggestion = any()) } returns update + ) = every { instance.insertMentionAtSuggestion(url = link, attributes = emptyList(), text = name, suggestion = any()) } returns update fun givenReplaceAllHtmlResult( html: String, diff --git a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt index cd8809007..4e5ff4986 100644 --- a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt +++ b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/utils/HtmlToSpansParserTest.kt @@ -1,12 +1,10 @@ package io.element.android.wysiwyg.utils import android.text.Spanned -import io.element.android.wysiwyg.display.KeywordDisplayHandler import io.element.android.wysiwyg.display.TextDisplay -import io.element.android.wysiwyg.display.LinkDisplayHandler +import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.test.fakes.createFakeStyleConfig import io.element.android.wysiwyg.test.utils.dumpSpans -import io.element.android.wysiwyg.view.spans.CustomReplacementSpan import io.element.android.wysiwyg.view.spans.PillSpan import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo @@ -130,72 +128,44 @@ class HtmlToSpansParserTest { } @Test - fun testLinkDisplayWithCustomLinkDisplayHandler() { + fun testMentionDisplayWithCustomMentionDisplayHandler() { val html = """ link - jonny + jonny + @room """.trimIndent() - val spanned = convertHtml(html, linkDisplayHandler = { _, url -> - if(url.contains("element.io")) { + val spanned = convertHtml(html, mentionDisplayHandler = object : MentionDisplayHandler { + override fun resolveAtRoomMentionDisplay(): TextDisplay = TextDisplay.Pill - } else { - TextDisplay.Plain - } - }) - assertThat( - spanned.dumpSpans(), equalTo( - listOf( - "link: io.element.android.wysiwyg.view.spans.PillSpan (0-4) fl=#33", - "jonny: io.element.android.wysiwyg.view.spans.LinkSpan (5-10) fl=#33" - ) - ) - ) - assertThat( - spanned.toString(), equalTo("link\njonny") - ) - } - @Test - fun testKeywordDisplayWithCustomKeywordDisplayHandler() { - val keyword1 = "\$hello" - val keyword2 = "anotherkeyword" - val keyword3 = "plainkeyword" - val html = "$keyword1 $keyword2 $keyword3" - val spanned = convertHtml(html, keywordDisplayHandler = object: KeywordDisplayHandler { - override val keywords: List = listOf(keyword1, keyword2) - override fun resolveKeywordDisplay(text: String): TextDisplay = - when(text) { - keyword1 -> TextDisplay.Pill - keyword2 -> TextDisplay.Custom(PillSpan(0)) - keyword3 -> TextDisplay.Plain - else -> TextDisplay.Plain - } + override fun resolveMentionDisplay(text: String, url: String): TextDisplay = + TextDisplay.Pill }) assertThat( spanned.dumpSpans(), equalTo( listOf( - "\$hello: io.element.android.wysiwyg.view.spans.PillSpan (0-6) fl=#33", - "anotherkeyword: io.element.android.wysiwyg.view.spans.CustomReplacementSpan (7-21) fl=#33" + "link: io.element.android.wysiwyg.view.spans.LinkSpan (0-4) fl=#33", + "jonny: io.element.android.wysiwyg.view.spans.PillSpan (5-10) fl=#33", + "onny: io.element.android.wysiwyg.view.spans.ExtraCharacterSpan (6-10) fl=#33", + "@room: io.element.android.wysiwyg.view.spans.PillSpan (11-16) fl=#33", ) ) ) assertThat( - spanned.toString(), equalTo("\$hello anotherkeyword plainkeyword") + spanned.toString(), equalTo("link\njonny\n@room") ) } private fun convertHtml( html: String, - linkDisplayHandler: LinkDisplayHandler? = null, - keywordDisplayHandler: KeywordDisplayHandler? = null, + mentionDisplayHandler: MentionDisplayHandler? = null, ): Spanned { val app = RuntimeEnvironment.getApplication() return HtmlToSpansParser( resourcesHelper = AndroidResourcesHelper(application = app), html = html, styleConfig = createFakeStyleConfig(), - linkDisplayHandler = linkDisplayHandler, - keywordDisplayHandler = keywordDisplayHandler, + mentionDisplayHandler = mentionDisplayHandler, ).convert() } } 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 1b9cbddba..95ae6e7a7 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 @@ -292,7 +292,7 @@ internal class EditorViewModelTest { } @Test - fun `when process set link suggestion action, it returns a text update`() { + fun `when process insert mention at suggestion action, it returns a text update`() { val name = "jonny" val url = "https://matrix.to/#/@test:matrix.org" val suggestionPattern = @@ -302,11 +302,11 @@ internal class EditorViewModelTest { )) viewModel.processInput(EditorInputAction.ReplaceText("@jonny")) - composer.givenSetLinkSuggestionResult(name, url, composerStateUpdate) + composer.givenInsertMentionFromSuggestionResult(name, url, composerStateUpdate) val result = viewModel.processInput(EditorInputAction.SetLinkSuggestion(url, name)) verify { - composer.instance.setLinkSuggestion(url, attributes = emptyList(), text = name, suggestion = suggestionPattern) + composer.instance.insertMentionAtSuggestion(url, attributes = emptyList(), text = name, suggestion = suggestionPattern) } assertThat(result, equalTo(replaceTextResult)) } diff --git a/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj b/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj index 099071271..70e7b7c2c 100644 --- a/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj +++ b/platforms/ios/example/Wysiwyg.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ A6C2157528C0E95C00C8E727 /* View+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157428C0E95C00C8E727 /* View+Accessibility.swift */; }; A6C2157928C0F62000C8E727 /* WysiwygActionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157828C0F62000C8E727 /* WysiwygActionToolbar.swift */; }; A6C2157C28C0FAAD00C8E727 /* WysiwygAction+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C2157B28C0FAAD00C8E727 /* WysiwygAction+Utils.swift */; }; - A6E13E4829A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4729A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift */; }; + A6E13E4829A7AB4E00A85A55 /* WysiwygMentionReplacer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4729A7AB4E00A85A55 /* WysiwygMentionReplacer.swift */; }; A6E13E4B29A8EE6D00A85A55 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4A29A8EE6D00A85A55 /* AppDelegate.swift */; }; A6E13E4D29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4C29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift */; }; A6E13E4F29A8F00200A85A55 /* WysiwygTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E13E4E29A8F00200A85A55 /* WysiwygTextAttachment.swift */; }; @@ -81,7 +81,7 @@ A6C2157428C0E95C00C8E727 /* View+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Accessibility.swift"; sourceTree = ""; }; A6C2157828C0F62000C8E727 /* WysiwygActionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygActionToolbar.swift; sourceTree = ""; }; A6C2157B28C0FAAD00C8E727 /* WysiwygAction+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WysiwygAction+Utils.swift"; sourceTree = ""; }; - A6E13E4729A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygPermalinkReplacer.swift; sourceTree = ""; }; + A6E13E4729A7AB4E00A85A55 /* WysiwygMentionReplacer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygMentionReplacer.swift; sourceTree = ""; }; A6E13E4929A8EC0200A85A55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; A6E13E4A29A8EE6D00A85A55 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A6E13E4C29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WysiwygAttachmentViewProvider.swift; sourceTree = ""; }; @@ -244,7 +244,7 @@ A6E13E5029A8F06E00A85A55 /* SerializationService.swift */, A6E13E5429A8F1C400A85A55 /* WysiwygAttachmentView.swift */, A6E13E4C29A8EF3500A85A55 /* WysiwygAttachmentViewProvider.swift */, - A6E13E4729A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift */, + A6E13E4729A7AB4E00A85A55 /* WysiwygMentionReplacer.swift */, A6E13E4E29A8F00200A85A55 /* WysiwygTextAttachment.swift */, A6E13E5229A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift */, ); @@ -405,7 +405,7 @@ A6C2157528C0E95C00C8E727 /* View+Accessibility.swift in Sources */, A6472CAD2886CF830021A0E8 /* ContentView.swift in Sources */, A6E13E5329A8F0DD00A85A55 /* WysiwygTextAttachmentData.swift in Sources */, - A6E13E4829A7AB4E00A85A55 /* WysiwygPermalinkReplacer.swift in Sources */, + A6E13E4829A7AB4E00A85A55 /* WysiwygMentionReplacer.swift in Sources */, A6F4D0CF29AE0C1500087A3E /* Users.swift in Sources */, A6472CAB2886CF830021A0E8 /* WysiwygApp.swift in Sources */, A6C2157928C0F62000C8E727 /* WysiwygActionToolbar.swift in Sources */, diff --git a/platforms/ios/example/Wysiwyg.xcodeproj/xcshareddata/xcschemes/Wysiwyg.xcscheme b/platforms/ios/example/Wysiwyg.xcodeproj/xcshareddata/xcschemes/Wysiwyg.xcscheme index 11d723d23..4be4e30fb 100644 --- a/platforms/ios/example/Wysiwyg.xcodeproj/xcshareddata/xcschemes/Wysiwyg.xcscheme +++ b/platforms/ios/example/Wysiwyg.xcodeproj/xcshareddata/xcschemes/Wysiwyg.xcscheme @@ -85,6 +85,11 @@ BlueprintName = "WysiwygUITests" ReferencedContainer = "container:Wysiwyg.xcodeproj"> + + + + diff --git a/platforms/ios/example/Wysiwyg/Pills/WysiwygPermalinkReplacer.swift b/platforms/ios/example/Wysiwyg/Pills/WysiwygMentionReplacer.swift similarity index 96% rename from platforms/ios/example/Wysiwyg/Pills/WysiwygPermalinkReplacer.swift rename to platforms/ios/example/Wysiwyg/Pills/WysiwygMentionReplacer.swift index 55695bcc6..628c927ce 100644 --- a/platforms/ios/example/Wysiwyg/Pills/WysiwygPermalinkReplacer.swift +++ b/platforms/ios/example/Wysiwyg/Pills/WysiwygMentionReplacer.swift @@ -18,8 +18,8 @@ import Foundation import UIKit import WysiwygComposer -final class WysiwygPermalinkReplacer: PermalinkReplacer { - func replacementForLink(_ url: String, text: String) -> NSAttributedString? { +final class WysiwygMentionReplacer: MentionReplacer { + func replacementForMention(_ url: String, text: String) -> NSAttributedString? { if #available(iOS 15.0, *), url.starts(with: "https://matrix.to/#/"), let attachment = WysiwygTextAttachment(displayName: text, diff --git a/platforms/ios/example/Wysiwyg/Views/ContentView.swift b/platforms/ios/example/Wysiwyg/Views/ContentView.swift index 0644a856a..d604881ce 100644 --- a/platforms/ios/example/Wysiwyg/Views/ContentView.swift +++ b/platforms/ios/example/Wysiwyg/Views/ContentView.swift @@ -28,7 +28,7 @@ struct ContentView: View { @StateObject private var viewModel = WysiwygComposerViewModel( minHeight: WysiwygSharedConstants.composerMinHeight, maxExpandedHeight: WysiwygSharedConstants.composerMaxExtendedHeight, - permalinkReplacer: WysiwygPermalinkReplacer() + mentionReplacer: WysiwygMentionReplacer() ) var body: some View { diff --git a/platforms/ios/example/WysiwygUITests/WysiwygUITests+PlainTextMode.swift b/platforms/ios/example/WysiwygUITests/WysiwygUITests+PlainTextMode.swift index d7a9cd8e6..538f45e13 100644 --- a/platforms/ios/example/WysiwygUITests/WysiwygUITests+PlainTextMode.swift +++ b/platforms/ios/example/WysiwygUITests/WysiwygUITests+PlainTextMode.swift @@ -39,6 +39,7 @@ extension WysiwygUITests { assertTextViewContent("text __bold__ *italic*") } + // FIXME: disabled for now, should be re-enabled when this is supported func testPlainTextModePreservesPills() throws { // Create a Pill in RTE. textView.typeTextCharByChar("@ali") diff --git a/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift b/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift index 34d2ec13f..71b08af8d 100644 --- a/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift +++ b/platforms/ios/example/WysiwygUITests/WysiwygUITests+Suggestions.swift @@ -27,18 +27,10 @@ extension WysiwygUITests { assertTextViewContent("\u{00A0}") assertTreeEquals( """ - ├>a "https://matrix.to/#/@alice:matrix.org" - │ └>"Alice" + ├>mention "Alice", https://matrix.to/#/@alice:matrix.org └>" " """ ) - // Removing the whitespace afterwards disables the - // link button as the caret is right after the pill. - app.keys["delete"].tap() - XCTAssertFalse(button(.linkButton).isEnabled) - // Link button can be re-enabled. - app.keys["space"].tap() - XCTAssertTrue(button(.linkButton).isEnabled) } func testHashMention() throws { @@ -49,8 +41,7 @@ extension WysiwygUITests { // assertTextViewContent(" ") assertTreeEquals( """ - ├>a "https://matrix.to/#/#room1:matrix.org" - │ └>"Room 1" + ├>mention "Room 1", https://matrix.to/#/#room1:matrix.org └>" " """ ) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/DTHTMLElement.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/DTHTMLElement.swift index 460bc5afd..a76e65799 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/DTHTMLElement.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/DTHTMLElement.swift @@ -21,6 +21,16 @@ extension DTHTMLElement { func sanitize() { guard let childNodes = childNodes as? [DTHTMLElement] else { return } + if tag == .a, + attributes["data-mention-type"] != nil, + let textNode = self.childNodes.first as? DTTextHTMLElement { + let mentionTextNode = MentionTextNodeHTMLElement(from: textNode) + removeAllChildNodes() + addChildNode(mentionTextNode) + mentionTextNode.inheritAttributes(from: self) + mentionTextNode.interpretAttributes() + } + if childNodes.count == 1, let child = childNodes.first as? DTTextHTMLElement { if child.text() == .nbsp { // Removing NBSP character from e.g.

 

since it is only used to @@ -66,11 +76,14 @@ extension DTHTMLElement { private enum DTHTMLElementTag: String { case pre case code + case a } private extension DTHTMLElement { var tag: DTHTMLElementTag? { - DTHTMLElementTag(rawValue: name) + guard let name else { return nil } + + return DTHTMLElementTag(rawValue: name) } func createDiscardableElement() -> PlaceholderTextHTMLElement { diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/PlaceholderTextHTMLElement.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/PlaceholderTextHTMLElement.swift index 253965616..9983b8517 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/PlaceholderTextHTMLElement.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/DTCoreText/PlaceholderTextHTMLElement.swift @@ -40,3 +40,20 @@ final class PlaceholderTextHTMLElement: DTTextHTMLElement { return dict } } + +final class MentionTextNodeHTMLElement: DTTextHTMLElement { + init(from textNode: DTTextHTMLElement) { + super.init() + setText(textNode.text()) + } + + override func attributesForAttributedStringRepresentation() -> [AnyHashable: Any]! { + var dict = super.attributesForAttributedStringRepresentation() ?? [AnyHashable: Any]() + // Insert a key to mark this as a mention post-parsing. + dict[NSAttributedString.Key.mention] = MentionContent( + rustLength: 1, + url: parent().attributes["href"] as? String ?? "" + ) + return dict + } +} diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift index 6dfe18be3..c37bfc95b 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString+Range.swift @@ -65,7 +65,7 @@ extension NSAttributedString { public var htmlChars: String { NSMutableAttributedString(attributedString: self) .removeDiscardableContent() - .restoreReplacements() + .addPlaceholderForReplacements() .string } @@ -91,27 +91,27 @@ extension NSAttributedString { } /// Compute an array of all parts of the attributed string that have been replaced - /// with `PermalinkReplacer` usage within the given range. + /// with `MentionReplacer` usage within the given range. /// /// - Parameter range: the range on which the elements should be detected. Entire range if omitted /// - Returns: an array of `Replacement`. - func replacementTextRanges(in range: NSRange? = nil) -> [Replacement] { - var replacements = [Replacement]() + func mentionReplacementTextRanges(in range: NSRange? = nil) -> [MentionReplacement] { + var replacements = [MentionReplacement]() - enumerateTypedAttribute(.originalContent) { (originalContent: OriginalContent, range: NSRange, _) in - replacements.append(Replacement(range: range, originalContent: originalContent)) + enumerateTypedAttribute(.mention) { (mentionContent: MentionContent, range: NSRange, _) in + replacements.append(MentionReplacement(range: range, content: mentionContent)) } return replacements } /// Compute an array of all parts of the attributed string that have been replaced - /// with `PermalinkReplacer` usage up to the provided index. + /// with `MentionReplacer` usage up to the provided index. /// /// - Parameter attributedIndex: the position until which the ranges should be computed. /// - Returns: an array of range and offsets. - func replacementTextRanges(to attributedIndex: Int) -> [Replacement] { - replacementTextRanges(in: .init(location: 0, length: attributedIndex)) + func mentionReplacementTextRanges(to attributedIndex: Int) -> [MentionReplacement] { + mentionReplacementTextRanges(in: .init(location: 0, length: attributedIndex)) } /// Find occurences of parts of the attributed string that have been replaced @@ -122,8 +122,13 @@ extension NSAttributedString { /// - Parameter attributedIndex: the index inside the attributed representation /// - Returns: Total offset of replacement ranges func replacementsOffsetAt(at attributedIndex: Int) -> Int { - replacementTextRanges(to: attributedIndex) - .compactMap { $0.range.upperBound <= attributedIndex ? Optional($0.offset) : nil } + mentionReplacementTextRanges(to: attributedIndex) + .map { $0.range.upperBound <= attributedIndex + ? $0.offset + : attributedIndex > $0.range.location + ? attributedIndex - $0.range.location - $0.content.rustLength + : 0 + } .reduce(0, -) } @@ -161,7 +166,7 @@ extension NSAttributedString { // Iterate replacement ranges in order and only account those // that are still in range after previous offset update. - attributedIndex = replacementTextRanges(to: attributedIndex) + attributedIndex = mentionReplacementTextRanges(to: attributedIndex) .reduce(attributedIndex) { $1.range.location < $0 ? $0 + $1.offset : $0 } guard attributedIndex <= length else { @@ -187,14 +192,20 @@ extension NSMutableAttributedString { return self } - /// Restore original content from `Replacement` within the attributed string. + /// Replace`Replacement` within the attributed string + /// by placeholders which have the expected Rust model length. /// /// - Returns: self (discardable) @discardableResult - func restoreReplacements() -> Self { - replacementTextRanges().reversed().forEach { - replaceCharacters(in: $0.range, with: $0.originalContent.text) - } + func addPlaceholderForReplacements() -> Self { + mentionReplacementTextRanges() + .filter { $0.range.length != $0.content.rustLength } + .reversed() + .forEach { + replaceCharacters(in: $0.range, + with: String(repeating: Character.object, + count: $0.content.rustLength)) + } return self } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift index 576d1d07a..f09a9ded9 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSAttributedString.Key.swift @@ -31,7 +31,6 @@ extension NSAttributedString.Key { /// Attribute for parts of the string that should be removed for HTML selection computation. /// Should include both placeholder characters such as NBSP and ZWSP, as well as list prefixes. static let discardableText: NSAttributedString.Key = .init(rawValue: "DiscardableAttributeKey") - /// Attribute for the original content of a replacement. This should be added anytime a part of the attributed string - /// is replaced, in order for the composer to compute the expected HTML/attributed range properly. - static let originalContent: NSAttributedString.Key = .init(rawValue: "OriginalContentAttributeKey") + /// Attribute for a mention It contains data the composer requires in order to compute the expected HTML/attributed range properly. + static let mention: NSAttributedString.Key = .init(rawValue: "mentionAttributeKey") } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift index a0b0bd425..888ca5e4c 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/NSMutableAttributedString.swift @@ -41,21 +41,21 @@ extension NSMutableAttributedString { applyDiscardableToListPrefixes() } - /// Replace parts of the attributed string that represents links by - /// a new attributed string part provided by the hosting app `HTMLPermalinkReplacer`. + /// Replace parts of the attributed string that represents mentions by + /// a new attributed string part provided by the hosting app `MentionReplacer`. /// - /// - Parameter permalinkReplacer: The permalink replacer providing new attributed strings. - func replaceLinks(with permalinkReplacer: HTMLPermalinkReplacer) { - enumerateTypedAttribute(.link) { (url: URL, range: NSRange, _) in - if let replacement = permalinkReplacer.replacementForLink( - url.absoluteString, + /// - Parameter mentionReplacer: The mention replacer providing new attributed strings. + func replaceMentions(with mentionReplacer: HTMLMentionReplacer?) { + enumerateTypedAttribute(.mention) { (originalContent: MentionContent, range: NSRange, _) in + if let replacement = mentionReplacer?.replacementForMention( + originalContent.url, text: self.mutableString.substring(with: range) ) { - let originalText = self.attributedSubstring(from: range).string self.replaceCharacters(in: range, with: replacement) - self.addAttribute(.originalContent, - value: OriginalContent(text: originalText), - range: .init(location: range.location, length: replacement.length)) + // TODO: find a way to avoid re-applying the attribute (make the replacement mutable ?) + self.addAttribute(.mention, + value: originalContent, + range: NSRange(location: range.location, length: replacement.length)) } } } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/String+Character.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/String+Character.swift index 32504204a..053d44bba 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/String+Character.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Extensions/String+Character.swift @@ -27,11 +27,19 @@ public extension String { static let lineFeed = "\n" /// String containing a single slash character(`/`) static let slash = "/" + /// String containing an object replacement character. + static let object = "\u{FFFC}" } public extension Character { + /// NBSP character (`\u{00A0}`) static let nbsp = Character(.nbsp) + /// ZWSP character (`\u{200B}`) static let zwsp = Character(.zwsp) + /// Line feed character (`\n`) static let lineFeed = Character(.lineFeed) + /// Slash character(`/`) static let slash = Character(.slash) + /// Object replacement character. + static let object = Character(.object) } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLPermalinkReplacer.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLMentionReplacer.swift similarity index 63% rename from platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLPermalinkReplacer.swift rename to platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLMentionReplacer.swift index 7f5675be9..5eaf8c3fa 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLPermalinkReplacer.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLMentionReplacer.swift @@ -16,16 +16,16 @@ import Foundation -/// Defines an API for permalink replacement with other objects (e.g. pills) -public protocol HTMLPermalinkReplacer { - /// Called when the parser of the composer steps upon a link. +/// Defines an API for mention replacement with other objects (e.g. pills) +public protocol HTMLMentionReplacer { + /// Called when the parser of the composer steps upon a mention. /// This can be used to provide custom attributed string parts, such - /// as a pillified representation of a link. + /// as a pillified representation of a mention. /// If nothing is provided, the composer will use a standard link. /// /// - Parameters: - /// - url: URL of the link - /// - text: Text of the link - /// - Returns: Replacement for the attributed link. - func replacementForLink(_ url: String, text: String) -> NSAttributedString? + /// - url: URL of the mention's permalink + /// - text: Display text of the mention + /// - Returns: Replacement for the mention. + func replacementForMention(_ url: String, text: String) -> NSAttributedString? } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift index 798bbe18d..38abf7825 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/HTMLParser.swift @@ -59,12 +59,12 @@ public final class HTMLParser { /// - html: HTML to parse /// - encoding: String encoding to use /// - style: Style to apply for HTML parsing - /// - permalinkReplacer:An object that might replace detected links. + /// - mentionReplacer:An object that might replace detected mentions. /// - Returns: An attributed string representation of the HTML content public static func parse(html: String, encoding: String.Encoding = .utf16, style: HTMLParserStyle = .standard, - permalinkReplacer: HTMLPermalinkReplacer? = nil) throws -> NSAttributedString { + mentionReplacer: HTMLMentionReplacer? = nil) throws -> NSAttributedString { guard !html.isEmpty else { return NSAttributedString(string: "") } @@ -97,10 +97,7 @@ public final class HTMLParser { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) mutableAttributedString.applyPostParsingCustomAttributes(style: style) - - if let permalinkReplacer { - mutableAttributedString.replaceLinks(with: permalinkReplacer) - } + mutableAttributedString.replaceMentions(with: mentionReplacer) removeTrailingNewlineIfNeeded(from: mutableAttributedString, given: html) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/OriginalContent.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/MentionContent.swift similarity index 85% rename from platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/OriginalContent.swift rename to platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/MentionContent.swift index 5b30d709e..92294087d 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/OriginalContent.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/MentionContent.swift @@ -15,7 +15,9 @@ // /// A struct that can be used as an attribute to persist the original content of a replaced part of an `NSAttributedString`. -struct OriginalContent { - /// The original text of the content that has been replaced. - let text: String +struct MentionContent { + /// The length of the replaced content in the Rust model. + let rustLength: Int + + let url: String } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Replacement.swift b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/MentionReplacement.swift similarity index 88% rename from platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Replacement.swift rename to platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/MentionReplacement.swift index e7d225cd8..37274d170 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/Replacement.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/HTMLParser/MentionReplacement.swift @@ -17,19 +17,19 @@ import Foundation /// Represents a replacement in an instance of `NSAttributedString` -struct Replacement { +struct MentionReplacement { /// Range of the `NSAttributedString` where the replacement is located. let range: NSRange /// Data of the original content of the `NSAttributedString`. - let originalContent: OriginalContent + let content: MentionContent } // MARK: - Helpers -extension Replacement { +extension MentionReplacement { /// Computes the offset between the replacement and the original part (i.e. if the original length /// is greater than the replacement range, this offset will be negative). var offset: Int { - range.length - originalContent.text.count + range.length - content.rustLength } } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift index afc6bf922..6834306e1 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift @@ -32,7 +32,8 @@ protocol ComposerModelWrapperProtocol { func enter() -> ComposerUpdate func setLink(url: String, attributes: [Attribute]) -> ComposerUpdate func setLinkWithText(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate - func setLinkSuggestion(url: String, text: String, suggestion: SuggestionPattern, 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 func toTree() -> String func getCurrentDomState() -> ComposerState @@ -121,9 +122,12 @@ final class ComposerModelWrapper: ComposerModelWrapperProtocol { execute { try $0.setLinkWithText(url: url, text: text, attributes: attributes) } } - func setLinkSuggestion(url: String, text: String, suggestion: SuggestionPattern, attributes: [Attribute]) -> ComposerUpdate { - let attributes = suggestion.key.mentionType?.attributes ?? [] - return execute { try $0.setLinkSuggestion(url: url, text: text, suggestion: suggestion, attributes: attributes) } + func insertMention(url: String, text: String, attributes: [Attribute]) -> ComposerUpdate { + execute { try $0.insertMention(url: url, text: text, attributes: attributes) } + } + + func insertMentionAtSuggestion(url: String, text: String, suggestion: SuggestionPattern, attributes: [Attribute]) -> ComposerUpdate { + execute { try $0.insertMentionAtSuggestion(url: url, text: text, suggestion: suggestion, attributes: attributes) } } func removeLinks() -> ComposerUpdate { 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 080755e4a..119acc367 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygComposerView/WysiwygComposerViewModel.swift @@ -40,8 +40,8 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa /// The composer minimal height. public let minHeight: CGFloat - /// The permalink replacer defined by the hosting application. - public var permalinkReplacer: PermalinkReplacer? + /// The mention replacer defined by the hosting application. + public var mentionReplacer: MentionReplacer? /// Published object for the composer attributed content. @Published public var attributedContent: WysiwygComposerAttributedContent = .init() /// Published value for the content of the text view in plain text mode. @@ -126,12 +126,12 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa maxCompressedHeight: CGFloat = 200, maxExpandedHeight: CGFloat = 300, parserStyle: HTMLParserStyle = .standard, - permalinkReplacer: PermalinkReplacer? = nil) { + mentionReplacer: MentionReplacer? = nil) { self.minHeight = minHeight self.maxCompressedHeight = maxCompressedHeight self.maxExpandedHeight = maxExpandedHeight self.parserStyle = parserStyle - self.permalinkReplacer = permalinkReplacer + self.mentionReplacer = mentionReplacer textView.linkTextAttributes[.foregroundColor] = parserStyle.linkColor model = ComposerModelWrapper() @@ -162,7 +162,7 @@ public class WysiwygComposerViewModel: WysiwygComposerViewModelProtocol, Observa } deinit { - permalinkReplacer = nil + mentionReplacer = nil } } @@ -242,13 +242,14 @@ public extension WysiwygComposerViewModel { func setMention(url: String, name: String, mentionType: WysiwygMentionType) { let update: ComposerUpdate if let suggestionPattern, suggestionPattern.key == mentionType.patternKey { - update = model.setLinkSuggestion(url: url, - text: name, - suggestion: suggestionPattern, - attributes: mentionType.attributes) + update = model.insertMentionAtSuggestion(url: url, + text: name, + suggestion: suggestionPattern, + attributes: mentionType.attributes) } else { - _ = model.setLinkWithText(url: url, text: name, attributes: mentionType.attributes) - update = model.replaceText(newText: " ") + update = model.insertMention(url: url, + text: name, + attributes: mentionType.attributes) } applyUpdate(update) hasPendingFormats = true @@ -446,7 +447,7 @@ private extension WysiwygComposerViewModel { let html = String(utf16CodeUnits: codeUnits, count: codeUnits.count) let attributed = try HTMLParser.parse(html: html, style: parserStyle, - permalinkReplacer: permalinkReplacer) + mentionReplacer: mentionReplacer) // FIXME: handle error for out of bounds index let htmlSelection = NSRange(location: Int(start), length: Int(end - start)) let textSelection = try attributed.attributedRange(from: htmlSelection) @@ -506,8 +507,8 @@ private extension WysiwygComposerViewModel { if enabled { var attributed = NSAttributedString(string: model.getContentAsMarkdown(), attributes: defaultTextAttributes) - if let permalinkReplacer { - attributed = permalinkReplacer.postProcessMarkdown(in: attributed) + if let mentionReplacer { + attributed = mentionReplacer.postProcessMarkdown(in: attributed) } textView.attributedText = attributed updateCompressedHeightIfNeeded() @@ -575,9 +576,9 @@ private extension WysiwygComposerViewModel { /// - Returns: A markdown string. func computeMarkdownContent() -> String { let markdownContent: String - if let permalinkReplacer, let attributedText = textView.attributedText { - // `PermalinkReplacer` should restore altered content to valid markdown. - markdownContent = permalinkReplacer.restoreMarkdown(in: attributedText) + if let mentionReplacer, let attributedText = textView.attributedText { + // `MentionReplacer` should restore altered content to valid markdown. + markdownContent = mentionReplacer.restoreMarkdown(in: attributedText) } else { markdownContent = textView.text } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygMentionType.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygMentionType.swift index f5c66264d..3197fcb07 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygMentionType.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/WysiwygMentionType.swift @@ -43,7 +43,6 @@ extension WysiwygMentionType { var attributes: [Attribute] { [ Attribute(key: "data-mention-type", value: rawValue), - Attribute(key: "contenteditable", value: "false"), ] } } diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Tools/PermalinkReplacer.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Tools/MentionReplacer.swift similarity index 90% rename from platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Tools/PermalinkReplacer.swift rename to platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Tools/MentionReplacer.swift index c3a38396c..4e6f96517 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Tools/PermalinkReplacer.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Tools/MentionReplacer.swift @@ -17,8 +17,8 @@ import Foundation import HTMLParser -/// Extension protocol for HTMLParser's `HTMLPermalinkReplacer` that handles replacement for markdown. -public protocol PermalinkReplacer: HTMLPermalinkReplacer { +/// Extension protocol for HTMLParser's `MentionReplacer` that handles replacement for markdown. +public protocol MentionReplacer: HTMLMentionReplacer { /// Called when the composer switches to plain text mode or when /// the client sets an HTML body as the current content of the composer /// in plain text mode. Provides the ability for the client to replace diff --git a/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests+PermalinkReplacer.swift b/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests+PermalinkReplacer.swift index aaa88e263..2787d399d 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests+PermalinkReplacer.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/HTMLParserTests/HTMLParserTests+PermalinkReplacer.swift @@ -19,18 +19,18 @@ import XCTest extension HTMLParserTests { func testReplaceLinks() throws { - let html = "Alice:\(String.nbsp)" - let attributed = try HTMLParser.parse(html: html, permalinkReplacer: CustomHTMLPermalinkReplacer()) + let html = "Alice:\(String.nbsp)" + let attributed = try HTMLParser.parse(html: html, mentionReplacer: CustomHTMLMentionReplacer()) // A text attachment is added. XCTAssertTrue(attributed.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) // The original content is added to the new part of the attributed string. - let originalContent = attributed.attribute(.originalContent, at: 0, effectiveRange: nil) as? OriginalContent + let originalContent = attributed.attribute(.mention, at: 0, effectiveRange: nil) as? MentionContent XCTAssertEqual( - originalContent?.text, - "Alice" + originalContent?.rustLength, + 1 ) // HTML and attributed range matches - let htmlRange = NSRange(location: 0, length: 5) + let htmlRange = NSRange(location: 0, length: 1) let attributedRange = NSRange(location: 0, length: 1) XCTAssertEqual( try attributed.attributedRange(from: htmlRange), @@ -43,37 +43,74 @@ extension HTMLParserTests { // HTML chars match content. XCTAssertEqual( attributed.htmlChars, - "Alice:\(String.nbsp)" + "\(String.object):\(String.nbsp)" + ) + } + + func testMentionsAreNotReplaced() throws { + let html = "Alice:\(String.nbsp)" + let attributed = try HTMLParser.parse(html: html, mentionReplacer: nil) + // No text attachment. + XCTAssertFalse(attributed.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + // The original content is still added to the new part of the attributed string. + let originalContent = attributed.attribute(.mention, at: 0, effectiveRange: nil) as? MentionContent + XCTAssertEqual( + originalContent?.rustLength, + 1 + ) + // HTML and attributed range matches + let htmlRange = NSRange(location: 0, length: 1) + let attributedRange = NSRange(location: 0, length: 5) + XCTAssertEqual( + try attributed.attributedRange(from: htmlRange), + attributedRange + ) + XCTAssertEqual( + try attributed.htmlRange(from: attributedRange), + htmlRange + ) + + // Positions in the middle of the mention should translate to the end of it + XCTAssertEqual(try attributed.htmlPosition(at: 1), 1) + XCTAssertEqual(try attributed.htmlPosition(at: 2), 1) + XCTAssertEqual(try attributed.htmlPosition(at: 3), 1) + XCTAssertEqual(try attributed.htmlPosition(at: 4), 1) + + // HTML chars match content. + XCTAssertEqual( + attributed.htmlChars, + "\(String.object):\(String.nbsp)" ) } func testReplaceMultipleLinks() throws { let html = """ - Alice \ - Alice\(String.nbsp) + Alice \ + Alice\(String.nbsp) """ - let attributed = try HTMLParser.parse(html: html, permalinkReplacer: CustomHTMLPermalinkReplacer()) - // HTML position matches. + let attributed = try HTMLParser.parse(html: html, mentionReplacer: CustomHTMLMentionReplacer()) + // HTML position matches exactly (Rust model mention length is 1, and so is the length of a pill). XCTAssertEqual(try attributed.htmlPosition(at: 0), 0) - XCTAssertEqual(try attributed.htmlPosition(at: 1), 5) - XCTAssertEqual(try attributed.htmlPosition(at: 2), 6) - XCTAssertEqual(try attributed.htmlPosition(at: 3), 11) - XCTAssertEqual(try attributed.htmlPosition(at: 4), 12) + XCTAssertEqual(try attributed.htmlPosition(at: 1), 1) + XCTAssertEqual(try attributed.htmlPosition(at: 2), 2) + XCTAssertEqual(try attributed.htmlPosition(at: 3), 3) + XCTAssertEqual(try attributed.htmlPosition(at: 4), 4) // Out of bound attributed position throws do { _ = try attributed.htmlPosition(at: 5) + XCTFail("HTML position call should have thrown") } catch { XCTAssertEqual(error as? AttributedRangeError, AttributedRangeError.outOfBoundsAttributedIndex(index: 5)) } - // Attributed position matches + // Attributed position matches exactly (Rust model mention length is 1, and so is the length of a pill). XCTAssertEqual(try attributed.attributedPosition(at: 0), 0) - XCTAssertEqual(try attributed.attributedPosition(at: 5), 1) - XCTAssertEqual(try attributed.attributedPosition(at: 6), 2) - XCTAssertEqual(try attributed.attributedPosition(at: 11), 3) - XCTAssertEqual(try attributed.attributedPosition(at: 12), 4) + XCTAssertEqual(try attributed.attributedPosition(at: 1), 1) + XCTAssertEqual(try attributed.attributedPosition(at: 2), 2) + XCTAssertEqual(try attributed.attributedPosition(at: 3), 3) + XCTAssertEqual(try attributed.attributedPosition(at: 4), 4) - let firstLinkHtmlRange = NSRange(location: 0, length: 5) + let firstLinkHtmlRange = NSRange(location: 0, length: 1) let firstLinkAttributedRange = NSRange(location: 0, length: 1) XCTAssertEqual( try attributed.attributedRange(from: firstLinkHtmlRange), @@ -84,7 +121,7 @@ extension HTMLParserTests { firstLinkHtmlRange ) - let secondLinkHtmlRange = NSRange(location: 6, length: 5) + let secondLinkHtmlRange = NSRange(location: 2, length: 1) let secondLinkAttributedRange = NSRange(location: 2, length: 1) XCTAssertEqual( try attributed.attributedRange(from: secondLinkHtmlRange), @@ -97,13 +134,68 @@ extension HTMLParserTests { // HTML chars match content. XCTAssertEqual( attributed.htmlChars, - "Alice Alice\(String.nbsp)" + "\(String.object) \(String.object)\(String.nbsp)" + ) + } + + func testMultipleMentionsAreNotReplaced() throws { + let html = """ + Alice \ + Alice\(String.nbsp) + """ + let attributed = try HTMLParser.parse(html: html, mentionReplacer: nil) + // HTML position matches. + XCTAssertEqual(try attributed.htmlPosition(at: 0), 0) + XCTAssertEqual(try attributed.htmlPosition(at: 5), 1) + XCTAssertEqual(try attributed.htmlPosition(at: 6), 2) + XCTAssertEqual(try attributed.htmlPosition(at: 11), 3) + XCTAssertEqual(try attributed.htmlPosition(at: 12), 4) + // Out of bound attributed position throws + do { + _ = try attributed.htmlPosition(at: 13) + XCTFail("HTML position call should have thrown") + } catch { + XCTAssertEqual(error as? AttributedRangeError, AttributedRangeError.outOfBoundsAttributedIndex(index: 13)) + } + + // Attributed position matches. + XCTAssertEqual(try attributed.attributedPosition(at: 0), 0) + XCTAssertEqual(try attributed.attributedPosition(at: 1), 5) + XCTAssertEqual(try attributed.attributedPosition(at: 2), 6) + XCTAssertEqual(try attributed.attributedPosition(at: 3), 11) + XCTAssertEqual(try attributed.attributedPosition(at: 4), 12) + + let firstLinkHtmlRange = NSRange(location: 0, length: 1) + let firstLinkAttributedRange = NSRange(location: 0, length: 5) + XCTAssertEqual( + try attributed.attributedRange(from: firstLinkHtmlRange), + firstLinkAttributedRange + ) + XCTAssertEqual( + try attributed.htmlRange(from: firstLinkAttributedRange), + firstLinkHtmlRange + ) + + let secondLinkHtmlRange = NSRange(location: 2, length: 1) + let secondLinkAttributedRange = NSRange(location: 6, length: 5) + XCTAssertEqual( + try attributed.attributedRange(from: secondLinkHtmlRange), + secondLinkAttributedRange + ) + XCTAssertEqual( + try attributed.htmlRange(from: secondLinkAttributedRange), + secondLinkHtmlRange + ) + // HTML chars match content. + XCTAssertEqual( + attributed.htmlChars, + "\(String.object) \(String.object)\(String.nbsp)" ) } } -private class CustomHTMLPermalinkReplacer: HTMLPermalinkReplacer { - func replacementForLink(_ url: String, text: String) -> NSAttributedString? { +private class CustomHTMLMentionReplacer: HTMLMentionReplacer { + func replacementForMention(_ url: String, text: String) -> NSAttributedString? { if url.starts(with: "https://matrix.to/#/"), let image = UIImage(systemName: "link") { // Set a text attachment with an arbitrary image. diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift index 6fb626c5b..b323ec39a 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/Components/WysiwygComposerView/WysiwygComposerViewModelTests+Suggestions.swift @@ -54,7 +54,7 @@ extension WysiwygComposerViewModelTests { XCTAssertEqual( viewModel.content.html, """ - Alice\u{00A0} + Alice\u{00A0} """ ) } @@ -68,7 +68,7 @@ extension WysiwygComposerViewModelTests { XCTAssertEqual( viewModel.content.html, """ - TextAlice\u{00A0} + TextAlice\u{00A0} """ ) } @@ -81,7 +81,7 @@ extension WysiwygComposerViewModelTests { XCTAssertEqual( viewModel.content.html, """ - Alice Text + AliceText """ ) } @@ -92,7 +92,7 @@ extension WysiwygComposerViewModelTests { XCTAssertEqual( viewModel.content.html, """ - Room 1\u{00A0} + Room 1\u{00A0} """ ) } @@ -104,7 +104,7 @@ extension WysiwygComposerViewModelTests { XCTAssertEqual( viewModel.content.html, """ - TextRoom 1\u{00A0} + TextRoom 1\u{00A0} """ ) } @@ -116,7 +116,7 @@ extension WysiwygComposerViewModelTests { XCTAssertEqual( viewModel.content.html, """ - Room 1 Text + Room 1Text """ ) } diff --git a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Suggestions.swift b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Suggestions.swift index ab0529f3c..ab85e435e 100644 --- a/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Suggestions.swift +++ b/platforms/ios/lib/WysiwygComposer/Tests/WysiwygComposerTests/WysiwygComposerTests+Suggestions.swift @@ -31,7 +31,7 @@ extension WysiwygComposerTests { model .action { - $0.setLinkSuggestion( + $0.insertMentionAtSuggestion( url: "https://matrix.to/#/@alice:matrix.org", text: "Alice", suggestion: suggestionPattern, @@ -40,9 +40,40 @@ extension WysiwygComposerTests { } .assertHtml( """ - Alice\(String.nbsp) + Alice\(String.nbsp) """ ) + .assertSelection(start: 2, end: 2) + } + + func testNonLeadingSuggestionForAtPattern() { + let model = ComposerModelWrapper() + let update = model.replaceText(newText: "Hello @alic") + + guard case .suggestion(suggestionPattern: let suggestionPattern) = update.menuAction(), + let attributes = suggestionPattern.key.mentionType?.attributes + else { + XCTFail("No user suggestion found") + return + } + + model + .action { + $0.insertMentionAtSuggestion( + url: "https://matrix.to/#/@alice:matrix.org", + text: "Alice", + suggestion: suggestionPattern, + attributes: attributes + ) + } + .assertHtml( + """ + Hello Alice\(String.nbsp) + """ + ) + .assertSelection(start: 8, end: 8) } func testSuggestionForHashPattern() { @@ -58,7 +89,7 @@ extension WysiwygComposerTests { model .action { - $0.setLinkSuggestion( + $0.insertMentionAtSuggestion( url: "https://matrix.to/#/#room1:matrix.org", text: "Room 1", suggestion: suggestionPattern, @@ -67,7 +98,7 @@ extension WysiwygComposerTests { } .assertHtml( """ - Room 1\(String.nbsp) + Room 1\(String.nbsp) """ ) } diff --git a/platforms/web/lib/composer.ts b/platforms/web/lib/composer.ts index c97ba0786..d4447e403 100644 --- a/platforms/web/lib/composer.ts +++ b/platforms/web/lib/composer.ts @@ -73,19 +73,16 @@ export function processInput( case 'insertSuggestion': { if (suggestion && isSuggestionEvent(event)) { const { text, url, attributes } = event.data; - const defaultMap = new Map(); - defaultMap.set('contenteditable', 'false'); - Object.entries(attributes).forEach(([key, value]) => { - defaultMap.set(key, value); - }); + const attributesMap = new Map(Object.entries(attributes)); + return action( - composerModel.set_link_suggestion( + composerModel.insert_mention_at_suggestion( url, text, suggestion, - defaultMap, + attributesMap, ), - 'set_link_suggestion', + 'insert_mention_at_suggestion', ); } break; diff --git a/platforms/web/lib/dom.test.ts b/platforms/web/lib/dom.test.ts index 28769e34a..2f0ecc7ef 100644 --- a/platforms/web/lib/dom.test.ts +++ b/platforms/web/lib/dom.test.ts @@ -429,12 +429,13 @@ describe('computeNodeAndOffset', () => { expect(offset).toBe(0); }); - // eslint-disable-next-line max-len - it('returns the beginning of the editor if we try to select the leading edge of non-editable node', () => { + // TODO remove attributes from mentions when Rust model can parse url + // https://github.com/matrix-org/matrix-rich-text-editor/issues/709 + it('can find the beginning of a mention correctly', () => { // When - // this simulates having a mention in the html setEditorHtml( - 'test', + // eslint-disable-next-line max-len + 'test ', ); const { node, offset } = computeNodeAndOffset(editor, 0); @@ -442,6 +443,73 @@ describe('computeNodeAndOffset', () => { expect(node).toBe(editor); expect(offset).toBe(0); }); + + it('can find the end of a mention correctly', () => { + // When + // we have a mention, ie a tag with a data-mention-type attribute + setEditorHtml( + // eslint-disable-next-line max-len + 'test ', + ); + const { node, offset } = computeNodeAndOffset(editor, 1); + + // Then + expect(node).toBe(editor.childNodes[1]); + expect(offset).toBe(0); + }); + + it('can find the nbsp after a mention correctly', () => { + // When + // we have a mention, ie a tag with a data-mention-type attribute + setEditorHtml( + // eslint-disable-next-line max-len + 'test ', + ); + const { node, offset } = computeNodeAndOffset(editor, 2); + + // Then + expect(node).toBe(editor.childNodes[1]); + expect(offset).toBe(1); + }); + + it('can find the beginning of a mention inside a paragraph', () => { + // When + setEditorHtml( + // eslint-disable-next-line max-len + '

test 

', + ); + const { node, offset } = computeNodeAndOffset(editor, 0); + + // Then + expect(node).toBe(editor.childNodes[0]); + expect(offset).toBe(0); + }); + + it('can find the start of nbsp after a mention inside a paragraph', () => { + // When + setEditorHtml( + // eslint-disable-next-line max-len + '

test 

', + ); + const { node, offset } = computeNodeAndOffset(editor, 1); + + // Then + expect(node).toBe(editor.childNodes[0].childNodes[1]); + expect(offset).toBe(0); + }); + + it('can find the end of nbsp after a mention inside a paragraph', () => { + // When + setEditorHtml( + // eslint-disable-next-line max-len + '

test 

', + ); + const { node, offset } = computeNodeAndOffset(editor, 2); + + // Then + expect(node).toBe(editor.childNodes[0].childNodes[1]); + expect(offset).toBe(1); + }); }); describe('countCodeunit', () => { @@ -545,6 +613,28 @@ describe('countCodeunit', () => { expect(countCodeunit(editor, thirdTextNode, 1)).toBe(10); expect(countCodeunit(editor, thirdTextNode, 2)).toBe(11); }); + + it('Should count mentions as having length 1', () => { + // When + // we use the presence of a data-mention-type attribute to determine + // if we have a mention, the tag is unimportant + setEditorHtml( + // eslint-disable-next-line max-len + 'hello Alice', + ); + const helloTextNode = editor.childNodes[0]; + const mentionTextNode = editor.childNodes[1].childNodes[0]; + + // Then + expect(countCodeunit(editor, helloTextNode, 0)).toBe(0); + expect(countCodeunit(editor, helloTextNode, 6)).toBe(6); + expect(countCodeunit(editor, mentionTextNode, 0)).toBe(6); + expect(countCodeunit(editor, mentionTextNode, 1)).toBe(7); + expect(countCodeunit(editor, mentionTextNode, 2)).toBe(7); + expect(countCodeunit(editor, mentionTextNode, 3)).toBe(7); + expect(countCodeunit(editor, mentionTextNode, 4)).toBe(7); + expect(countCodeunit(editor, mentionTextNode, 5)).toBe(7); + }); }); describe('getCurrentSelection', () => { diff --git a/platforms/web/lib/dom.ts b/platforms/web/lib/dom.ts index 004c197d6..9b6361391 100644 --- a/platforms/web/lib/dom.ts +++ b/platforms/web/lib/dom.ts @@ -84,6 +84,16 @@ export function refreshComposerView( createNode(li, 'span', '"', new Map([['class', 'quote']])); createNode(li, 'span', `${child.text(composerModel)}`); createNode(li, 'span', '"', new Map([['class', 'quote']])); + } else if (nodeType === 'mention') { + const li = createNode(list, 'li'); + createNode( + li, + 'span', + '"Mention - ', + new Map([['class', 'quote']]), + ); + createNode(li, 'span', `${child.text(composerModel)}`); + createNode(li, 'span', '"', new Map([['class', 'quote']])); } else { console.error(`Unknown node type: ${nodeType}`); } @@ -173,7 +183,53 @@ export function computeNodeAndOffset( const isEmptyListItem = currentNode.nodeName === 'LI' && !currentNode.hasChildNodes(); - if (currentNode.nodeType === Node.TEXT_NODE) { + const isTextNode = currentNode.nodeType === Node.TEXT_NODE; + const isTextNodeInsideMention = + isTextNode && + currentNode.parentElement?.hasAttribute('data-mention-type'); + + if (isTextNodeInsideMention) { + // Special casing for mention nodes. They will be a node with a single + // text node child. We can therefore guarantee that the text node will + // have both parent and grandparent nodes. + + // We _may_ need an extra offset if we're inside a p tag + const shouldAddOffset = textNodeNeedsExtraOffset(currentNode); + const extraOffset = shouldAddOffset ? 1 : 0; + + const remainingCodeunits = codeunits - extraOffset; + + // We have only _found_ the node if we have 0 or 1 remainingCodeunits + // as we treat a mention as having length 1 + if (remainingCodeunits <= 1) { + if (remainingCodeunits === 0) { + // if we have hit the beginning of the node, we either want to + // put the cursor at the end of the previous sibling (if it has + // one) or at the 0th index of the parent otherwise + if (currentNode.previousSibling) { + return { + node: currentNode.previousSibling, + offset: textLength( + currentNode.previousSibling, + Infinity, + ), + }; + } else { + return { + node: currentNode.parentNode?.parentNode ?? null, + offset: 0, + }; + } + } else { + // setting node to null means if we end up inside or at end of a + // non-editable node somehow, we will return "node not found" + // and so we will keep searching + return { node: null, offset: 0 }; + } + } else { + return { node: null, offset: codeunits - extraOffset - 1 }; + } + } else if (isTextNode) { // For a text node, we need to check to see if it needs an extra offset // which involves climbing the tree through it's ancestors checking for // any of the nodes that require the extra offset. @@ -195,27 +251,6 @@ export function computeNodeAndOffset( if (codeunits <= (currentNode.textContent?.length || 0)) { // we don't need to use that extra offset if we've found the answer - - // special case to handle being inside a non-editable node - // such as a mention - if ( - currentNode.parentElement?.getAttribute('contenteditable') === - 'false' - ) { - // setting node to null means if we end up inside or at end of a - // non-editable node somehow, we will return "node not found" - // and so we will keep searching - let node = null; - - // if we hit the beginning of the node, select start of editor - // as this appears to be the only way this can occur - if (codeunits === 0) { - node = rootNode || currentNode; - } - - return { node, offset: 0 }; - } - return { node: currentNode, offset: codeunits }; } else { // but if we haven't found that answer, apply the extra offset @@ -361,15 +396,25 @@ function findCharacter( found: boolean; offset: number; } { + const isTextNode = currentNode.nodeType === Node.TEXT_NODE; + const isInsideMention = + currentNode.parentElement?.hasAttribute('data-mention-type'); + if (currentNode === nodeToFind) { // We've found the right node - if (currentNode.nodeType === Node.TEXT_NODE) { + if (isTextNode) { // Text node - use the offset to know where we are if (offsetToFind > (currentNode.textContent?.length ?? 0)) { // If the offset is wrong, we didn't find it return { found: false, offset: 0 }; } else { // Otherwise, we did + + // Special case for mention nodes which have length of 1 + if (isInsideMention) { + return { found: true, offset: offsetToFind === 0 ? 0 : 1 }; + } + return { found: true, offset: offsetToFind }; } } else { @@ -382,7 +427,7 @@ function findCharacter( } else { // We have not found the right node yet - if (currentNode.nodeType === Node.TEXT_NODE) { + if (isTextNode) { // Return how many steps forward we progress by skipping // this node. @@ -399,6 +444,11 @@ function findCharacter( return { found: false, offset: extraOffset }; } + // ...and a special case where mentions alwayd have a length of 1 + if (isInsideMention) { + return { found: false, offset: 1 + extraOffset }; + } + return { found: false, offset: (currentNode.textContent?.length ?? 0) + extraOffset, diff --git a/platforms/web/lib/testUtils/Editor.tsx b/platforms/web/lib/testUtils/Editor.tsx index 620af8190..b93e0c816 100644 --- a/platforms/web/lib/testUtils/Editor.tsx +++ b/platforms/web/lib/testUtils/Editor.tsx @@ -122,7 +122,6 @@ export const Editor = forwardRef(function Editor( 'https://matrix.to/#/@test_user:element.io', 'test user', { - 'contentEditable': 'false', 'data-mention-type': 'user', }, ); diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index 642d81e1c..5f6d6b326 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -195,7 +195,6 @@ function App() { 'https://matrix.to/#/@alice_user:element.io', 'Alice', { - 'contentEditable': 'false', 'data-mention-type': suggestion.keyChar === '@' ? 'user'