@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
14
14
limitations under the License.
15
15
*/
16
16
17
+ import { EMOTICON_TO_EMOJI } from "@matrix-org/emojibase-bindings" ;
17
18
import { AllowedMentionAttributes , MappedSuggestion } from "@matrix-org/matrix-wysiwyg" ;
18
19
import { SyntheticEvent , useState , SetStateAction } from "react" ;
19
20
import { logger } from "matrix-js-sdk/src/logger" ;
@@ -41,6 +42,7 @@ type SuggestionState = Suggestion | null;
41
42
*
42
43
* @param editorRef - a ref to the div that is the composer textbox
43
44
* @param setText - setter function to set the content of the composer
45
+ * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
44
46
* @returns
45
47
* - `handleMention`: a function that will insert @ or # mentions which are selected from
46
48
* the autocomplete into the composer, given an href, the text to display, and any additional attributes
@@ -53,10 +55,12 @@ type SuggestionState = Suggestion | null;
53
55
export function useSuggestion (
54
56
editorRef : React . RefObject < HTMLDivElement > ,
55
57
setText : ( text ?: string ) => void ,
58
+ isAutoReplaceEmojiEnabled ?: boolean ,
56
59
) : {
57
60
handleMention : ( href : string , displayName : string , attributes : AllowedMentionAttributes ) => void ;
58
61
handleAtRoomMention : ( attributes : AllowedMentionAttributes ) => void ;
59
62
handleCommand : ( text : string ) => void ;
63
+ handleEmojiReplacement : ( ) => void ;
60
64
onSelect : ( event : SyntheticEvent < HTMLDivElement > ) => void ;
61
65
suggestion : MappedSuggestion | null ;
62
66
} {
@@ -77,7 +81,7 @@ export function useSuggestion(
77
81
78
82
// We create a `selectionchange` handler here because we need to know when the user has moved the cursor,
79
83
// we can not depend on input events only
80
- const onSelect = ( ) : void => processSelectionChange ( editorRef , setSuggestionData ) ;
84
+ const onSelect = ( ) : void => processSelectionChange ( editorRef , setSuggestionData , isAutoReplaceEmojiEnabled ) ;
81
85
82
86
const handleMention = ( href : string , displayName : string , attributes : AllowedMentionAttributes ) : void =>
83
87
processMention ( href , displayName , attributes , suggestionData , setSuggestionData , setText ) ;
@@ -88,11 +92,14 @@ export function useSuggestion(
88
92
const handleCommand = ( replacementText : string ) : void =>
89
93
processCommand ( replacementText , suggestionData , setSuggestionData , setText ) ;
90
94
95
+ const handleEmojiReplacement = ( ) : void => processEmojiReplacement ( suggestionData , setSuggestionData , setText ) ;
96
+
91
97
return {
92
98
suggestion : suggestionData ?. mappedSuggestion ?? null ,
93
99
handleCommand,
94
100
handleMention,
95
101
handleAtRoomMention,
102
+ handleEmojiReplacement,
96
103
onSelect,
97
104
} ;
98
105
}
@@ -103,10 +110,12 @@ export function useSuggestion(
103
110
*
104
111
* @param editorRef - ref to the composer
105
112
* @param setSuggestionData - the setter for the suggestion state
113
+ * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
106
114
*/
107
115
export function processSelectionChange (
108
116
editorRef : React . RefObject < HTMLDivElement > ,
109
117
setSuggestionData : React . Dispatch < React . SetStateAction < SuggestionState > > ,
118
+ isAutoReplaceEmojiEnabled ?: boolean ,
110
119
) : void {
111
120
const selection = document . getSelection ( ) ;
112
121
@@ -132,7 +141,12 @@ export function processSelectionChange(
132
141
133
142
const firstTextNode = document . createNodeIterator ( editorRef . current , NodeFilter . SHOW_TEXT ) . nextNode ( ) ;
134
143
const isFirstTextNode = currentNode === firstTextNode ;
135
- const foundSuggestion = findSuggestionInText ( currentNode . textContent , currentOffset , isFirstTextNode ) ;
144
+ const foundSuggestion = findSuggestionInText (
145
+ currentNode . textContent ,
146
+ currentOffset ,
147
+ isFirstTextNode ,
148
+ isAutoReplaceEmojiEnabled ,
149
+ ) ;
136
150
137
151
// if we have not found a suggestion, return, clearing the suggestion state
138
152
if ( foundSuggestion === null ) {
@@ -241,6 +255,42 @@ export function processCommand(
241
255
setSuggestionData ( null ) ;
242
256
}
243
257
258
+ /**
259
+ * Replaces the relevant part of the editor text, replacing the plain text emoitcon with the suggested emoji.
260
+ *
261
+ * @param suggestionData - representation of the part of the DOM that will be replaced
262
+ * @param setSuggestionData - setter function to set the suggestion state
263
+ * @param setText - setter function to set the content of the composer
264
+ */
265
+ export function processEmojiReplacement (
266
+ suggestionData : SuggestionState ,
267
+ setSuggestionData : React . Dispatch < React . SetStateAction < SuggestionState > > ,
268
+ setText : ( text ?: string ) => void ,
269
+ ) : void {
270
+ // if we do not have a suggestion of the correct type, return early
271
+ if ( suggestionData === null || suggestionData . mappedSuggestion . type !== `custom` ) {
272
+ return ;
273
+ }
274
+ const { node, mappedSuggestion } = suggestionData ;
275
+ const existingContent = node . textContent ;
276
+
277
+ if ( existingContent == null ) {
278
+ return ;
279
+ }
280
+
281
+ // replace the emoticon with the suggesed emoji
282
+ const newContent =
283
+ existingContent . slice ( 0 , suggestionData . startOffset ) +
284
+ mappedSuggestion . text +
285
+ existingContent . slice ( suggestionData . endOffset ) ;
286
+
287
+ node . textContent = newContent ;
288
+
289
+ document . getSelection ( ) ?. setBaseAndExtent ( node , newContent . length , node , newContent . length ) ;
290
+ setText ( newContent ) ;
291
+ setSuggestionData ( null ) ;
292
+ }
293
+
244
294
/**
245
295
* Given some text content from a node and the cursor position, find the word that the cursor is currently inside
246
296
* and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if
@@ -250,12 +300,14 @@ export function processCommand(
250
300
* @param offset - the current cursor offset position within the node
251
301
* @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine
252
302
* if a command suggestion is found or not
303
+ * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
253
304
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
254
305
*/
255
306
export function findSuggestionInText (
256
307
text : string ,
257
308
offset : number ,
258
309
isFirstTextNode : boolean ,
310
+ isAutoReplaceEmojiEnabled ?: boolean ,
259
311
) : { mappedSuggestion : MappedSuggestion ; startOffset : number ; endOffset : number } | null {
260
312
// Return null early if the offset is outside the content
261
313
if ( offset < 0 || offset > text . length ) {
@@ -281,7 +333,7 @@ export function findSuggestionInText(
281
333
282
334
// Get the word at the cursor then check if it contains a suggestion or not
283
335
const wordAtCursor = text . slice ( startSliceIndex , endSliceIndex ) ;
284
- const mappedSuggestion = getMappedSuggestion ( wordAtCursor ) ;
336
+ const mappedSuggestion = getMappedSuggestion ( wordAtCursor , isAutoReplaceEmojiEnabled ) ;
285
337
286
338
/**
287
339
* If we have a word that could be a command, it is not a valid command if:
@@ -339,9 +391,17 @@ function shouldIncrementEndIndex(text: string, index: number): boolean {
339
391
* Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null.
340
392
*
341
393
* @param text - string to check for a suggestion
394
+ * @param isAutoReplaceEmojiEnabled - whether plain text emoticons should be auto replaced with emojis
342
395
* @returns a `MappedSuggestion` if a suggestion is present, null otherwise
343
396
*/
344
- export function getMappedSuggestion ( text : string ) : MappedSuggestion | null {
397
+ export function getMappedSuggestion ( text : string , isAutoReplaceEmojiEnabled ?: boolean ) : MappedSuggestion | null {
398
+ if ( isAutoReplaceEmojiEnabled ) {
399
+ const emoji = EMOTICON_TO_EMOJI . get ( text . toLocaleLowerCase ( ) ) ;
400
+ if ( emoji ?. unicode ) {
401
+ return { keyChar : "" , text : emoji . unicode , type : "custom" } ;
402
+ }
403
+ }
404
+
345
405
const firstChar = text . charAt ( 0 ) ;
346
406
const restOfString = text . slice ( 1 ) ;
347
407
0 commit comments