From 01ae603482e84aeac23337236d25066a1bb98d2c Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 5 Jul 2024 09:11:35 +0100 Subject: [PATCH 1/9] Add emoji suggestions, tests and add it to the sample app. --- .../wysiwyg-ffi/src/ffi_composer_model.rs | 12 ++++ .../wysiwyg-ffi/src/ffi_composer_update.rs | 21 +++++- bindings/wysiwyg-ffi/src/ffi_pattern_key.rs | 3 + bindings/wysiwyg-wasm/src/lib.rs | 65 ++++++++++++++++--- crates/wysiwyg/src/composer_model/base.rs | 16 ++++- .../wysiwyg/src/composer_model/menu_action.rs | 22 +++++-- .../src/composer_model/replace_text.rs | 10 ++- crates/wysiwyg/src/pattern_key.rs | 20 +++++- crates/wysiwyg/src/tests.rs | 1 + .../src/tests/test_emoji_replacement.rs | 33 ++++++++++ crates/wysiwyg/src/tests/test_suggestions.rs | 2 +- platforms/web/lib/composer.ts | 32 +++++++++ platforms/web/lib/suggestion.test.tsx | 26 ++++++-- platforms/web/lib/suggestion.ts | 12 +++- platforms/web/lib/testUtils/Editor.tsx | 9 ++- platforms/web/lib/types.ts | 2 +- platforms/web/lib/useComposerModel.ts | 14 +++- platforms/web/lib/useListeners/event.ts | 2 + .../web/lib/useListeners/useListeners.ts | 2 + platforms/web/lib/useWysiwyg.ts | 4 ++ platforms/web/src/App.tsx | 2 + 21 files changed, 278 insertions(+), 32 deletions(-) create mode 100644 crates/wysiwyg/src/tests/test_emoji_replacement.rs diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs index bf7021883..b9b9e8132 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs @@ -49,6 +49,16 @@ impl ComposerModel { Ok(Arc::new(ComposerUpdate::from(update))) } + pub fn set_custom_suggestion_patterns( + self: &Arc, + custom_suggestion_patterns: Vec, + ) { + self.inner + .lock() + .unwrap() + .set_custom_suggestion_patterns(custom_suggestion_patterns) + } + pub fn get_content_as_html(self: &Arc) -> String { self.inner.lock().unwrap().get_content_as_html().to_string() } @@ -139,11 +149,13 @@ impl ComposerModel { self: &Arc, new_text: String, suggestion: SuggestionPattern, + append_space: bool, ) -> Arc { Arc::new(ComposerUpdate::from( self.inner.lock().unwrap().replace_text_suggestion( Utf16String::from_str(&new_text), wysiwyg::SuggestionPattern::from(suggestion), + append_space, ), )) } diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_update.rs b/bindings/wysiwyg-ffi/src/ffi_composer_update.rs index e4967c441..369e98321 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_update.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_update.rs @@ -129,6 +129,25 @@ mod test { ) } + #[test] + fn menu_action_is_updated_for_custom_suggestion() { + let model = Arc::new(ComposerModel::new()); + model.set_custom_suggestion_patterns(vec![":)".into()]); + let update = model.replace_text("That's great! :)".into()); + + assert_eq!( + update.menu_action(), + MenuAction::Suggestion { + suggestion_pattern: SuggestionPattern { + key: crate::PatternKey::Custom(":)".into()), + text: ":)".into(), + start: 14, + end: 16, + } + }, + ) + } + #[test] fn test_replace_whole_suggestion_with_mention_ffi() { let mut model = Arc::new(ComposerModel::new()); @@ -229,7 +248,7 @@ mod test { #[test] fn test_replace_text_with_escaped_html_in_mention_ffi() { - let mut model = Arc::new(ComposerModel::new()); + let model = Arc::new(ComposerModel::new()); model.replace_text("hello ".into()); let update = model.replace_text("@alic".into()); diff --git a/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs b/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs index ecb5b07ed..753732295 100644 --- a/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs +++ b/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs @@ -17,6 +17,7 @@ pub enum PatternKey { At, Hash, Slash, + Custom(String), } impl From for PatternKey { @@ -25,6 +26,7 @@ impl From for PatternKey { wysiwyg::PatternKey::At => Self::At, wysiwyg::PatternKey::Hash => Self::Hash, wysiwyg::PatternKey::Slash => Self::Slash, + wysiwyg::PatternKey::Custom(key) => Self::Custom(key), } } } @@ -35,6 +37,7 @@ impl From for wysiwyg::PatternKey { PatternKey::At => Self::At, PatternKey::Hash => Self::Hash, PatternKey::Slash => Self::Slash, + PatternKey::Custom(key) => Self::Custom(key), } } } diff --git a/bindings/wysiwyg-wasm/src/lib.rs b/bindings/wysiwyg-wasm/src/lib.rs index e0294a2a5..e8d554a75 100644 --- a/bindings/wysiwyg-wasm/src/lib.rs +++ b/bindings/wysiwyg-wasm/src/lib.rs @@ -92,6 +92,20 @@ impl ToUtf16TupleVec for js_sys::Map { } } +trait ToStringVec { + fn into_vec(self) -> Vec; +} + +impl ToStringVec for js_sys::Array { + fn into_vec(self) -> Vec { + let mut vec = vec![]; + self.for_each(&mut |element, _, _| { + vec.push(element.as_string().unwrap()); + }); + vec + } +} + #[wasm_bindgen] #[derive(Default)] pub struct ComposerModel { @@ -185,10 +199,12 @@ impl ComposerModel { &mut self, new_text: &str, suggestion: &SuggestionPattern, + append_space: bool, ) -> ComposerUpdate { ComposerUpdate::from(self.inner.replace_text_suggestion( Utf16String::from_str(new_text), wysiwyg::SuggestionPattern::from(suggestion.clone()), + append_space, )) } @@ -316,6 +332,15 @@ impl ComposerModel { )) } + pub fn set_custom_suggestion_patterns( + &mut self, + custom_suggestion_patterns: js_sys::Array, + ) { + self.inner.set_custom_suggestion_patterns( + custom_suggestion_patterns.into_vec(), + ); + } + /// Creates an at-room mention node and inserts it into the composer at the current selection pub fn insert_at_room_mention( &mut self, @@ -693,28 +718,52 @@ impl From for wysiwyg::SuggestionPattern { #[wasm_bindgen] #[derive(Clone)] -pub enum PatternKey { +pub enum PatternKeyType { At, Hash, Slash, + Custom, +} + +#[derive(Clone)] +#[wasm_bindgen(getter_with_clone)] +pub struct PatternKey { + pub key_type: PatternKeyType, + pub custom_key_value: Option, } impl From for PatternKey { fn from(inner: wysiwyg::PatternKey) -> Self { match inner { - wysiwyg::PatternKey::At => Self::At, - wysiwyg::PatternKey::Hash => Self::Hash, - wysiwyg::PatternKey::Slash => Self::Slash, + wysiwyg::PatternKey::At => Self { + key_type: PatternKeyType::At, + custom_key_value: None, + }, + wysiwyg::PatternKey::Hash => Self { + key_type: PatternKeyType::Hash, + custom_key_value: None, + }, + wysiwyg::PatternKey::Slash => Self { + key_type: PatternKeyType::Slash, + custom_key_value: None, + }, + wysiwyg::PatternKey::Custom(key) => Self { + key_type: PatternKeyType::Custom, + custom_key_value: Some(key), + }, } } } impl From for wysiwyg::PatternKey { fn from(key: PatternKey) -> Self { - match key { - PatternKey::At => Self::At, - PatternKey::Hash => Self::Hash, - PatternKey::Slash => Self::Slash, + match key.key_type { + PatternKeyType::At => Self::At, + PatternKeyType::Hash => Self::Hash, + PatternKeyType::Slash => Self::Slash, + PatternKeyType::Custom => { + Self::Custom(key.custom_key_value.unwrap()) + } } } } diff --git a/crates/wysiwyg/src/composer_model/base.rs b/crates/wysiwyg/src/composer_model/base.rs index be12eccd2..05e8f8027 100644 --- a/crates/wysiwyg/src/composer_model/base.rs +++ b/crates/wysiwyg/src/composer_model/base.rs @@ -24,7 +24,7 @@ use crate::{ ComposerAction, ComposerUpdate, DomHandle, Location, ToHtml, ToMarkdown, ToTree, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Clone, Default)] pub struct ComposerModel @@ -42,6 +42,9 @@ where /// The states of the buttons for each action e.g. bold, undo pub(crate) action_states: HashMap, + + /// A list of suggestion patterns provided by the client at runtime + pub(crate) custom_suggestion_patterns: HashSet, } impl ComposerModel @@ -54,6 +57,7 @@ where previous_states: Vec::new(), next_states: Vec::new(), action_states: HashMap::new(), // TODO: Calculate state based on ComposerState + custom_suggestion_patterns: HashSet::new(), }; instance.compute_menu_state(MenuStateComputeType::AlwaysUpdate); instance @@ -65,6 +69,7 @@ where previous_states: Vec::new(), next_states: Vec::new(), action_states: HashMap::new(), // TODO: Calculate state based on ComposerState + custom_suggestion_patterns: HashSet::new(), } } @@ -85,6 +90,7 @@ where previous_states: Vec::new(), next_states: Vec::new(), action_states: HashMap::new(), // TODO: Calculate state based on ComposerState + custom_suggestion_patterns: HashSet::new(), }; model.compute_menu_state(MenuStateComputeType::AlwaysUpdate); Self::post_process_dom(&mut model.state.dom); @@ -125,6 +131,14 @@ where self.set_content_from_html(&html) } + pub fn set_custom_suggestion_patterns( + &mut self, + custom_suggestion_patterns: Vec, + ) { + self.custom_suggestion_patterns = + HashSet::from_iter(custom_suggestion_patterns) + } + pub fn action_states(&self) -> &HashMap { &self.action_states } diff --git a/crates/wysiwyg/src/composer_model/menu_action.rs b/crates/wysiwyg/src/composer_model/menu_action.rs index aba997088..8d8b8b813 100644 --- a/crates/wysiwyg/src/composer_model/menu_action.rs +++ b/crates/wysiwyg/src/composer_model/menu_action.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::HashSet; + use crate::{ dom::{ unicode_string::{UnicodeStr, UnicodeStringExt}, @@ -37,7 +39,12 @@ where return MenuAction::None; } let (raw_text, start, end) = self.extended_text(range); - if let Some((key, text)) = Self::pattern_for_text(raw_text, start) { + + if let Some((key, text)) = Self::pattern_for_text( + raw_text, + start, + &self.custom_suggestion_patterns, + ) { MenuAction::Suggestion(SuggestionPattern { key, text, @@ -79,14 +86,19 @@ where fn pattern_for_text( mut text: S, start_location: usize, + custom_suggestion_patterns: &HashSet, ) -> Option<(PatternKey, String)> { - let Some(first_char) = text.pop_first() else { - return None; - }; - let Some(key) = PatternKey::from_char(first_char) else { + let Some(key) = PatternKey::from_string_and_suggestions( + text.to_string(), + custom_suggestion_patterns, + ) else { return None; }; + if key.is_static_pattern() { + text.pop_first(); + } + // Exclude slash patterns that are not at the beginning of the document // and any selection that contains inner whitespaces. if (key == PatternKey::Slash && start_location > 0) diff --git a/crates/wysiwyg/src/composer_model/replace_text.rs b/crates/wysiwyg/src/composer_model/replace_text.rs index 7101eecd1..1586b3c79 100644 --- a/crates/wysiwyg/src/composer_model/replace_text.rs +++ b/crates/wysiwyg/src/composer_model/replace_text.rs @@ -49,10 +49,16 @@ where &mut self, new_text: S, suggestion: SuggestionPattern, + append_space: bool, ) -> ComposerUpdate { self.push_state_to_history(); - self.do_replace_text_in(new_text, suggestion.start, suggestion.end); - self.do_replace_text(" ".into()) + let replace_suggestion_update = + self.do_replace_text_in(new_text, suggestion.start, suggestion.end); + if append_space { + self.do_replace_text(" ".into()) + } else { + replace_suggestion_update + } } #[deprecated(since = "0.20.0")] diff --git a/crates/wysiwyg/src/pattern_key.rs b/crates/wysiwyg/src/pattern_key.rs index 750b2ef35..9afd5f1d8 100644 --- a/crates/wysiwyg/src/pattern_key.rs +++ b/crates/wysiwyg/src/pattern_key.rs @@ -12,16 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::HashSet; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum PatternKey { At, Hash, Slash, + Custom(String), } impl PatternKey { - pub(crate) fn from_char(char: char) -> Option { - match char { + pub(crate) fn is_static_pattern(&self) -> bool { + matches!(self, Self::At | Self::Hash | Self::Slash) + } + + pub(crate) fn from_string_and_suggestions( + string: String, + custom_suggestion_patterns: &HashSet, + ) -> Option { + if custom_suggestion_patterns.contains(&string) { + return Some(Self::Custom(string)); + } + let Some(first_char) = string.chars().nth(0) else { + return None; + }; + match first_char { '\u{0040}' => Some(Self::At), '\u{0023}' => Some(Self::Hash), '\u{002F}' => Some(Self::Slash), diff --git a/crates/wysiwyg/src/tests.rs b/crates/wysiwyg/src/tests.rs index a23867121..b9d946baf 100644 --- a/crates/wysiwyg/src/tests.rs +++ b/crates/wysiwyg/src/tests.rs @@ -16,6 +16,7 @@ pub mod test_characters; pub mod test_deleting; +pub mod test_emoji_replacement; pub mod test_formatting; pub mod test_get_link_action; pub mod test_links; diff --git a/crates/wysiwyg/src/tests/test_emoji_replacement.rs b/crates/wysiwyg/src/tests/test_emoji_replacement.rs new file mode 100644 index 000000000..bbaa388fe --- /dev/null +++ b/crates/wysiwyg/src/tests/test_emoji_replacement.rs @@ -0,0 +1,33 @@ +// 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::tx, ComposerModel, MenuAction, PatternKey, +}; + +#[test] +fn can_do_plain_text_to_empji_replacement() { + let mut model: ComposerModel = ComposerModel::new(); + model.set_custom_suggestion_patterns(vec![":)".into()]); + let update = model.replace_text("Hey That's great! :)".into()); + let MenuAction::Suggestion(suggestion) = update.menu_action else { + panic!("No suggestion pattern found") + }; + assert_eq!(suggestion.key, PatternKey::Custom(":)".into()),); + model.replace_text_suggestion("🙂".into(), suggestion, false); + + assert_eq!(tx(&model), "Hey That's great! 🙂|"); +} diff --git a/crates/wysiwyg/src/tests/test_suggestions.rs b/crates/wysiwyg/src/tests/test_suggestions.rs index 5bf40645f..ea13ccd1b 100644 --- a/crates/wysiwyg/src/tests/test_suggestions.rs +++ b/crates/wysiwyg/src/tests/test_suggestions.rs @@ -23,6 +23,6 @@ fn test_replace_text_suggestion() { let MenuAction::Suggestion(suggestion) = update.menu_action else { panic!("No suggestion pattern found") }; - model.replace_text_suggestion("/invite".into(), suggestion); + model.replace_text_suggestion("/invite".into(), suggestion, true); assert_eq!(tx(&model), "/invite |"); } diff --git a/platforms/web/lib/composer.ts b/platforms/web/lib/composer.ts index d98772e8c..560509a73 100644 --- a/platforms/web/lib/composer.ts +++ b/platforms/web/lib/composer.ts @@ -51,6 +51,7 @@ export function processInput( editor: HTMLElement, suggestion: SuggestionPattern | null, inputEventProcessor?: InputEventProcessor, + emojiSuggestions?: Map, ) { const event = processEvent( e, @@ -116,6 +117,7 @@ export function processInput( composerModel.replace_text_suggestion( event.data, suggestion, + true, ), 'replace_text_suggestion', ); @@ -170,6 +172,11 @@ export function processInput( return action(composerModel.ordered_list(), 'ordered_list'); case 'insertLineBreak': case 'insertParagraph': + insertAnyEmojiSuggestions( + composerModel, + suggestion, + emojiSuggestions, + ); return action(composerModel.enter(), 'enter'); case 'insertReplacementText': { // Remove br tag @@ -187,6 +194,13 @@ export function processInput( case 'insertFromComposition': case 'insertText': if (event.data) { + if (event.data == ' ') { + insertAnyEmojiSuggestions( + composerModel, + suggestion, + emojiSuggestions, + ); + } return action( composerModel.replace_text(event.data), 'replace_text', @@ -227,4 +241,22 @@ export function processInput( console.error(e); return null; } + + function insertAnyEmojiSuggestions( + composerModel: ComposerModel, + suggestion: SuggestionPattern | null, + emojiSuggestions?: Map, + ) { + if ( + emojiSuggestions && + suggestion && + suggestion.key.key_type == 3 && + suggestion.key.custom_key_value + ) { + let emoji = emojiSuggestions.get(suggestion.key.custom_key_value); + if (emoji) { + composerModel.replace_text_suggestion(emoji, suggestion, false); + } + } + } } diff --git a/platforms/web/lib/suggestion.test.tsx b/platforms/web/lib/suggestion.test.tsx index 693a01acd..e32aaa55a 100644 --- a/platforms/web/lib/suggestion.test.tsx +++ b/platforms/web/lib/suggestion.test.tsx @@ -25,7 +25,13 @@ import { describe('getSuggestionChar', () => { it('returns the expected character', () => { SUGGESTIONS.forEach((suggestionCharacter, index) => { - const suggestion = { key: index } as unknown as SuggestionPattern; + const suggestion = { + key: { key_type: index }, + } as unknown as SuggestionPattern; + console.log('suggestionCharacter'); + console.log(suggestion); + console.log(getSuggestionChar(suggestion)); + console.log(suggestionCharacter); expect(getSuggestionChar(suggestion)).toBe(suggestionCharacter); }); }); @@ -38,15 +44,21 @@ describe('getSuggestionChar', () => { describe('getSuggestionType', () => { it('returns the expected type for a user or room mention', () => { - const userSuggestion = { key: 0 } as unknown as SuggestionPattern; - const roomSuggestion = { key: 1 } as unknown as SuggestionPattern; + const userSuggestion = { + key: { key_type: 0 }, + } as unknown as SuggestionPattern; + const roomSuggestion = { + key: { key_type: 1 }, + } as unknown as SuggestionPattern; expect(getSuggestionType(userSuggestion)).toBe('mention'); expect(getSuggestionType(roomSuggestion)).toBe('mention'); }); it('returns the expected type for a slash command', () => { - const slashSuggestion = { key: 2 } as unknown as SuggestionPattern; + const slashSuggestion = { + key: { key_type: 2 }, + } as unknown as SuggestionPattern; expect(getSuggestionType(slashSuggestion)).toBe('command'); }); @@ -68,7 +80,11 @@ describe('mapSuggestion', () => { free: () => {}, start: 1, end: 2, - key: 0, + key: { + free: () => {}, + key_type: 0, + custom_key_value: undefined, + }, text: 'some text', }; diff --git a/platforms/web/lib/suggestion.ts b/platforms/web/lib/suggestion.ts index 10fdde428..97922bc77 100644 --- a/platforms/web/lib/suggestion.ts +++ b/platforms/web/lib/suggestion.ts @@ -21,18 +21,26 @@ import { MappedSuggestion, SuggestionChar, SuggestionType } from './types'; export function getSuggestionChar( suggestion: SuggestionPattern, ): SuggestionChar { - return SUGGESTIONS[suggestion.key] || ''; + console.log('getSuggestionChar'); + console.log(suggestion); + console.log(suggestion.key); + console.log(suggestion.key.key_type); + var a = SUGGESTIONS[suggestion.key.key_type] || ''; + console.log(`a: ${a}`); + return a; } export function getSuggestionType( suggestion: SuggestionPattern, ): SuggestionType { - switch (suggestion.key) { + switch (suggestion.key.key_type) { case 0: case 1: return 'mention'; case 2: return 'command'; + case 3: + return 'custom'; default: return 'unknown'; } diff --git a/platforms/web/lib/testUtils/Editor.tsx b/platforms/web/lib/testUtils/Editor.tsx index 3ee3afeac..8ce87f120 100644 --- a/platforms/web/lib/testUtils/Editor.tsx +++ b/platforms/web/lib/testUtils/Editor.tsx @@ -23,15 +23,22 @@ interface EditorProps { initialContent?: string; inputEventProcessor?: InputEventProcessor; actionsRef?: MutableRefObject; + emojiSuggestions?: Map; } export const Editor = forwardRef(function Editor( - { initialContent, inputEventProcessor, actionsRef }: EditorProps, + { + initialContent, + inputEventProcessor, + actionsRef, + emojiSuggestions, + }: EditorProps, forwardRef, ) { const { ref, isWysiwygReady, wysiwyg, actionStates, content } = useWysiwyg({ initialContent, inputEventProcessor, + emojiSuggestions, }); if (actionsRef) actionsRef.current = wysiwyg; diff --git a/platforms/web/lib/types.ts b/platforms/web/lib/types.ts index 243fd606c..822c944c1 100644 --- a/platforms/web/lib/types.ts +++ b/platforms/web/lib/types.ts @@ -65,7 +65,7 @@ export type InputEventProcessor = ( ) => WysiwygEvent | null; export type SuggestionChar = typeof SUGGESTIONS[number] | ''; -export type SuggestionType = 'mention' | 'command' | 'unknown'; +export type SuggestionType = 'mention' | 'command' | 'custom' | 'unknown'; export type MappedSuggestion = { keyChar: SuggestionChar; text: string; diff --git a/platforms/web/lib/useComposerModel.ts b/platforms/web/lib/useComposerModel.ts index 0aef26eae..bfdff4bc5 100644 --- a/platforms/web/lib/useComposerModel.ts +++ b/platforms/web/lib/useComposerModel.ts @@ -57,6 +57,7 @@ export async function initOnce() { export function useComposerModel( editorRef: RefObject, initialContent?: string, + customSuggestionPatterns?: Array, ) { const [composerModel, setComposerModel] = useState( null, @@ -66,6 +67,7 @@ export function useComposerModel( async (initialContent?: string) => { await initOnce(); + let contentModel: ComposerModel; if (initialContent) { try { const newModel = new_composer_model_from_html( @@ -73,7 +75,7 @@ export function useComposerModel( 0, initialContent.length, ); - setComposerModel(newModel); + contentModel = newModel; if (editorRef.current) { // we need to use the rust model as the source of truth, to allow it to do things @@ -88,11 +90,17 @@ export function useComposerModel( } } catch (e) { // if the initialisation fails, due to a parsing failure of the html, fallback to an empty composer - setComposerModel(new_composer_model()); + contentModel = new_composer_model(); } } else { - setComposerModel(new_composer_model()); + contentModel = new_composer_model(); } + if (customSuggestionPatterns) { + contentModel.set_custom_suggestion_patterns( + customSuggestionPatterns, + ); + } + setComposerModel(contentModel); }, [setComposerModel, editorRef], ); diff --git a/platforms/web/lib/useListeners/event.ts b/platforms/web/lib/useListeners/event.ts index 03c53b885..ea7fe7b6e 100644 --- a/platforms/web/lib/useListeners/event.ts +++ b/platforms/web/lib/useListeners/event.ts @@ -184,6 +184,7 @@ export function handleInput( formattingFunctions: FormattingFunctions, suggestion: SuggestionPattern | null, inputEventProcessor?: InputEventProcessor, + emojiSuggestions?: Map, ): | { content?: string; @@ -199,6 +200,7 @@ export function handleInput( editor, suggestion, inputEventProcessor, + emojiSuggestions, ); if (update) { const repl = update.text_update().replace_all; diff --git a/platforms/web/lib/useListeners/useListeners.ts b/platforms/web/lib/useListeners/useListeners.ts index e02f03440..a3c48a826 100644 --- a/platforms/web/lib/useListeners/useListeners.ts +++ b/platforms/web/lib/useListeners/useListeners.ts @@ -43,6 +43,7 @@ export function useListeners( formattingFunctions: FormattingFunctions, onError: (content?: string) => void, inputEventProcessor?: InputEventProcessor, + emojiSuggestions?: Map, ) { const [state, setState] = useState({ content: null, @@ -85,6 +86,7 @@ export function useListeners( formattingFunctions, state.suggestion, inputEventProcessor, + emojiSuggestions, ); if (res) { diff --git a/platforms/web/lib/useWysiwyg.ts b/platforms/web/lib/useWysiwyg.ts index 8a51f033a..67b062ef9 100644 --- a/platforms/web/lib/useWysiwyg.ts +++ b/platforms/web/lib/useWysiwyg.ts @@ -54,15 +54,18 @@ export type WysiwygProps = { isAutoFocusEnabled?: boolean; inputEventProcessor?: InputEventProcessor; initialContent?: string; + emojiSuggestions?: Map; }; export function useWysiwyg(wysiwygProps?: WysiwygProps) { const ref = useEditor(); const modelRef = useRef(null); + let keys = wysiwygProps?.emojiSuggestions?.keys(); const { composerModel, onError } = useComposerModel( ref, wysiwygProps?.initialContent, + keys ? Array.from(keys) : undefined, ); const { testRef, utilities: testUtilities } = useTestCases( ref, @@ -80,6 +83,7 @@ export function useWysiwyg(wysiwygProps?: WysiwygProps) { formattingFunctions, onError, wysiwygProps?.inputEventProcessor, + wysiwygProps?.emojiSuggestions, ); useEditorFocus(ref, wysiwygProps?.isAutoFocusEnabled); diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index 355af57bb..6805129df 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -83,10 +83,12 @@ function App() { return e; }; + let emojiSuggestions = new Map([[':)', '🙂']]); const { ref, isWysiwygReady, actionStates, wysiwyg, debug, suggestion } = useWysiwyg({ isAutoFocusEnabled: true, inputEventProcessor, + emojiSuggestions: emojiSuggestions, }); const onEnterToSendChanged = () => { From ea7d971c82daa5e11239cea35a8e8e32d923d7cd Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 5 Jul 2024 11:47:09 +0100 Subject: [PATCH 2/9] Fix lint errors --- platforms/web/.eslintignore | 2 ++ platforms/web/lib/composer.ts | 4 ++-- platforms/web/lib/constants.ts | 2 +- platforms/web/lib/suggestion.ts | 8 +------- platforms/web/lib/useComposerModel.ts | 2 +- platforms/web/lib/useListeners/useListeners.ts | 1 + platforms/web/lib/useWysiwyg.ts | 16 +++++++++++++--- platforms/web/src/App.tsx | 3 +-- 8 files changed, 22 insertions(+), 16 deletions(-) diff --git a/platforms/web/.eslintignore b/platforms/web/.eslintignore index c1997a6cf..b3cb871d6 100644 --- a/platforms/web/.eslintignore +++ b/platforms/web/.eslintignore @@ -9,3 +9,5 @@ vite.demo.config.ts scripts cypress example-wysiwyg +coverage +.eslintignore \ No newline at end of file diff --git a/platforms/web/lib/composer.ts b/platforms/web/lib/composer.ts index 9a1a3546e..8c61feeae 100644 --- a/platforms/web/lib/composer.ts +++ b/platforms/web/lib/composer.ts @@ -250,14 +250,14 @@ export function processInput( composerModel: ComposerModel, suggestion: SuggestionPattern | null, emojiSuggestions?: Map, - ) { + ): void { if ( emojiSuggestions && suggestion && suggestion.key.key_type == 3 && suggestion.key.custom_key_value ) { - let emoji = emojiSuggestions.get(suggestion.key.custom_key_value); + const emoji = emojiSuggestions.get(suggestion.key.custom_key_value); if (emoji) { composerModel.replace_text_suggestion(emoji, suggestion, false); } diff --git a/platforms/web/lib/constants.ts b/platforms/web/lib/constants.ts index d884cf648..83f6fabfb 100644 --- a/platforms/web/lib/constants.ts +++ b/platforms/web/lib/constants.ts @@ -32,4 +32,4 @@ export const ACTION_TYPES = [ 'unindent', ] as const; -export const SUGGESTIONS = ['@', '#', '/'] as const; +export const SUGGESTIONS = ['@', '#', '/', ''] as const; diff --git a/platforms/web/lib/suggestion.ts b/platforms/web/lib/suggestion.ts index 97922bc77..fe3dad1ae 100644 --- a/platforms/web/lib/suggestion.ts +++ b/platforms/web/lib/suggestion.ts @@ -21,13 +21,7 @@ import { MappedSuggestion, SuggestionChar, SuggestionType } from './types'; export function getSuggestionChar( suggestion: SuggestionPattern, ): SuggestionChar { - console.log('getSuggestionChar'); - console.log(suggestion); - console.log(suggestion.key); - console.log(suggestion.key.key_type); - var a = SUGGESTIONS[suggestion.key.key_type] || ''; - console.log(`a: ${a}`); - return a; + return SUGGESTIONS[suggestion.key.key_type] || ''; } export function getSuggestionType( diff --git a/platforms/web/lib/useComposerModel.ts b/platforms/web/lib/useComposerModel.ts index 65874fb16..1a2b1d85c 100644 --- a/platforms/web/lib/useComposerModel.ts +++ b/platforms/web/lib/useComposerModel.ts @@ -105,7 +105,7 @@ export function useComposerModel( } setComposerModel(contentModel); }, - [setComposerModel, editorRef], + [setComposerModel, editorRef, customSuggestionPatterns], ); useEffect(() => { diff --git a/platforms/web/lib/useListeners/useListeners.ts b/platforms/web/lib/useListeners/useListeners.ts index be86d2a3d..b0f95e49e 100644 --- a/platforms/web/lib/useListeners/useListeners.ts +++ b/platforms/web/lib/useListeners/useListeners.ts @@ -233,6 +233,7 @@ export function useListeners( }; }, [ editorRef, + emojiSuggestions, composerModel, formattingFunctions, modelRef, diff --git a/platforms/web/lib/useWysiwyg.ts b/platforms/web/lib/useWysiwyg.ts index 49f5623bc..9dcde0ab7 100644 --- a/platforms/web/lib/useWysiwyg.ts +++ b/platforms/web/lib/useWysiwyg.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RefObject, useEffect, useMemo, useRef } from 'react'; +import { RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { AllActionStates, @@ -79,15 +79,25 @@ export type UseWysiwyg = { messageContent: string | null; }; +function getEmojiKeys(emojiSuggestions?: Map): string[] { + const keys = emojiSuggestions?.keys(); + return keys ? Array.from(keys) : []; +} + export function useWysiwyg(wysiwygProps?: WysiwygProps): UseWysiwyg { const ref = useEditor(); const modelRef = useRef(null); + const [emojiKeys, setEmojiKeys] = useState( + getEmojiKeys(wysiwygProps?.emojiSuggestions), + ); + useEffect(() => { + setEmojiKeys(getEmojiKeys(wysiwygProps?.emojiSuggestions)); + }, [wysiwygProps?.emojiSuggestions]); - let keys = wysiwygProps?.emojiSuggestions?.keys(); const { composerModel, onError } = useComposerModel( ref, wysiwygProps?.initialContent, - keys ? Array.from(keys) : undefined, + emojiKeys, ); const { testRef, utilities: testUtilities } = useTestCases( ref, diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index a32620e83..1714549eb 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -55,7 +55,7 @@ function Button({ onClick, imagePath, alt, state }: ButtonProps): ReactElement { ); } - +const emojiSuggestions = new Map([[':)', '🙂']]); function App(): ReactElement { const [enterToSend, setEnterToSend] = useState(true); @@ -83,7 +83,6 @@ function App(): ReactElement { return e; }; - let emojiSuggestions = new Map([[':)', '🙂']]); const { ref, isWysiwygReady, actionStates, wysiwyg, debug, suggestion } = useWysiwyg({ isAutoFocusEnabled: true, From a5847e4b07604d3aad1453dc33ae0ae70f31926a Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 5 Jul 2024 13:30:03 +0100 Subject: [PATCH 3/9] Add Editor unit test to cover composer actions --- platforms/web/lib/useWysiwyg.test.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/platforms/web/lib/useWysiwyg.test.tsx b/platforms/web/lib/useWysiwyg.test.tsx index 0a6a788e0..ad9a931ce 100644 --- a/platforms/web/lib/useWysiwyg.test.tsx +++ b/platforms/web/lib/useWysiwyg.test.tsx @@ -146,6 +146,28 @@ describe('useWysiwyg', () => { expect(mention).toHaveAttribute('style', testStyle); }); + test('Typing plain text converts to emoji', async () => { + const emojiSuggestions = new Map([[':)', '🙂']]); + render( + , + ); + + const textbox = screen.getByRole('textbox'); + await waitFor(() => + expect(textbox).toHaveAttribute('contentEditable', 'true'), + ); + fireEvent.input(textbox, { + data: 'test :)', + inputType: 'insertText', + }); + fireEvent.input(textbox, { + data: ' ', + inputType: 'insertText', + }); + + await expect(textbox).toHaveTextContent('test 🙂'); + }); + test('Create wysiwyg with initial content', async () => { // Given const content = 'foo
bar'; From be41c3c1ab2cd1995e9843069d0d08bb78cf256b Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 5 Jul 2024 13:43:36 +0100 Subject: [PATCH 4/9] lint --- platforms/web/lib/useWysiwyg.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/web/lib/useWysiwyg.test.tsx b/platforms/web/lib/useWysiwyg.test.tsx index ad9a931ce..1ac8c846b 100644 --- a/platforms/web/lib/useWysiwyg.test.tsx +++ b/platforms/web/lib/useWysiwyg.test.tsx @@ -149,7 +149,7 @@ describe('useWysiwyg', () => { test('Typing plain text converts to emoji', async () => { const emojiSuggestions = new Map([[':)', '🙂']]); render( - , + , ); const textbox = screen.getByRole('textbox'); From ecc11f1aaace9e4c9c6374636ee540707a44799f Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 5 Jul 2024 14:40:25 +0100 Subject: [PATCH 5/9] Fix comment and remove console.log's --- crates/wysiwyg/src/composer_model/base.rs | 2 +- platforms/web/lib/suggestion.test.tsx | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/wysiwyg/src/composer_model/base.rs b/crates/wysiwyg/src/composer_model/base.rs index 05e8f8027..c83fca57f 100644 --- a/crates/wysiwyg/src/composer_model/base.rs +++ b/crates/wysiwyg/src/composer_model/base.rs @@ -43,7 +43,7 @@ where /// The states of the buttons for each action e.g. bold, undo pub(crate) action_states: HashMap, - /// A list of suggestion patterns provided by the client at runtime + /// Suggestion patterns provided by the client at runtime pub(crate) custom_suggestion_patterns: HashSet, } diff --git a/platforms/web/lib/suggestion.test.tsx b/platforms/web/lib/suggestion.test.tsx index e32aaa55a..0e4a12e78 100644 --- a/platforms/web/lib/suggestion.test.tsx +++ b/platforms/web/lib/suggestion.test.tsx @@ -28,10 +28,6 @@ describe('getSuggestionChar', () => { const suggestion = { key: { key_type: index }, } as unknown as SuggestionPattern; - console.log('suggestionCharacter'); - console.log(suggestion); - console.log(getSuggestionChar(suggestion)); - console.log(suggestionCharacter); expect(getSuggestionChar(suggestion)).toBe(suggestionCharacter); }); }); From 64a0b3931280b18496ac9e520aeef058b83ac6c2 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 17 Jul 2024 19:08:33 +0100 Subject: [PATCH 6/9] Fix iOS build --- platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift | 2 ++ .../WysiwygComposer/Components/ComposerModelWrapper.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift b/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift index adbc47740..ca6a7f133 100644 --- a/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift +++ b/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift @@ -73,6 +73,8 @@ struct WysiwygSuggestionList: View { .accessibilityIdentifier(command.accessibilityIdentifier) } } + case .custom: + EmptyView() } } .padding(.horizontal, 8) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift index fb8b4a8ec..7afee0780 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift @@ -113,7 +113,7 @@ final class ComposerModelWrapper: ComposerModelWrapperProtocol { } func replaceTextSuggestion(newText: String, suggestion: SuggestionPattern) -> ComposerUpdate { - execute { try $0.replaceTextSuggestion(newText: newText, suggestion: suggestion) } + execute { try $0.replaceTextSuggestion(newText: newText, suggestion: suggestion, appendSpace: true) } } func backspace() -> ComposerUpdate { From 3925cf3fbe640b07f775099ae4676713c2cf73af Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 17 Jul 2024 19:22:15 +0100 Subject: [PATCH 7/9] fix ios build --- .../Sources/WysiwygComposer/Extensions/PatternKey.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift index 2e5f1ab7a..898485883 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift @@ -22,7 +22,7 @@ public extension PatternKey { return .user case .hash: return .room - case .slash: + case .slash, .custom: return nil } } From 87cdf23f7d14ef7f472bd92cb5a34bc605f9746a Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 17 Jul 2024 20:28:12 +0100 Subject: [PATCH 8/9] fix android --- .../java/io/element/wysiwyg/compose/SuggestionsView.kt | 8 +++++--- .../android/wysiwyg/internal/viewmodel/EditorViewModel.kt | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt index 7db44b34d..bc01ac813 100644 --- a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt +++ b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt @@ -75,10 +75,12 @@ private fun processSuggestion(suggestion: MenuAction.Suggestion, roomMemberSugge val slashCommands = listOf("leave", "shrug").map(Mention::SlashCommand) val everyone = Mention.NotifyEveryone val names = when (suggestion.suggestionPattern.key) { - PatternKey.AT -> people + everyone - PatternKey.HASH -> rooms - PatternKey.SLASH -> slashCommands + PatternKey.At -> people + everyone + PatternKey.Hash -> rooms + PatternKey.Slash -> slashCommands + is PatternKey.Custom -> listOf() } + val suggestions = names .filter { it.display.contains(text) } roomMemberSuggestions.clear() 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 9680da065..858f4bf72 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 @@ -300,6 +300,7 @@ internal class EditorViewModel( composer?.replaceTextSuggestion( suggestion = suggestion, newText = action.value, + appendSpace = true ) }.onFailure( ::onComposerFailure From 0bb2c9ebe84d8e1e5bb67e238a01be38bdd27f3f Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 18 Jul 2024 11:35:21 +0100 Subject: [PATCH 9/9] Fix test --- .../element/android/wysiwyg/viewmodel/EditorViewModelTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 344e2ddeb..1233dcfd1 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 @@ -297,7 +297,7 @@ internal class EditorViewModelTest { val name = "jonny" val url = "https://matrix.to/#/@test:matrix.org" val suggestionPattern = - SuggestionPattern(PatternKey.AT, text = "jonny", 0.toUInt(), 5.toUInt()) + SuggestionPattern(PatternKey.At, text = "jonny", 0.toUInt(), 5.toUInt()) composer.givenReplaceTextResult(MockComposerUpdateFactory.create( menuAction = MenuAction.Suggestion(suggestionPattern) )) @@ -315,7 +315,7 @@ internal class EditorViewModelTest { @Test fun `when process insert @room mention at suggestion action, it returns a text update`() { val suggestionPattern = - SuggestionPattern(PatternKey.AT, text = "room", 0.toUInt(), 4.toUInt()) + SuggestionPattern(PatternKey.At, text = "room", 0.toUInt(), 4.toUInt()) composer.givenReplaceTextResult(MockComposerUpdateFactory.create( menuAction = MenuAction.Suggestion(suggestionPattern) ))