Creating a Slack-like emoji search experience in an Angular 2+ app

Blog / Jake Renzella / July 29, 2020
For many years, A2I2 has contributed to an open-source learning management system called Doubtfire, which aims to drastically redefine the learning experience of university students.

Developed in Rails and Angular, the platform enables students to communicate directly with their lecturers and tutors in a variety of ways, including via a messaging system.

We are always looking at ways of modernising the discussion system, and in this blog post, we review how we built an extensive and user-friendly Emoji picker into Doubtfire. The feature allows users to search relevant terms and send the desired emoji from the text-input of the website, without having to wade through pages of emoji. For example, typing :thumbs-up: in the comments section will show results for relevant emoji, while you’re typing your message!

Final products:



Part 1: Adding the emoji picker element

Importing the library

We’re starting off by using the Angular emoji picker library ngx-emoji-mart. The library provides a list of emoji search terms, and a simple graphical emoji picker which can be inserted into the DOM if the user wishes.

npm install @ctrl/ngx-emoji-mart

Once installed and modules imported (read the GitHub README for details) it’s fairly straightforward to have a nice search button which will toggle the emoji picker element:

<emoji-mart
    [hidden]="!this.showEmojiPicker"
    class="emoji-picker"
    title="Pick your emoji..."
    emoji="thumbsup"
    (emojiClick)="addEmoji($event)"
  ></emoji-mart>

Toggling the input box

We have bound the hidden attribute to a boolean (declared in the ts file), and the HTML declaration simply toggles the showEmojiPicker property:

<div id="textFieldContainer">
    <div
      #commentInput
      id="textField"
      [contenteditable]="contentEditableValue()"
      (keydown.enter)="send($event)"
      (keydown)="keyTyped($event)"
      placeholder="Type a message..."
      name="commentComposer"
    ></div>
    <div id="innerButtons">
      <mat-icon
        id="emojiButton"
        (click)="this.showEmojiPicker = !this.showEmojiPicker"
        aria-hidden="false"
        aria-label="Emoji picker button"
        >emoji_emotions</mat-icon
      >
    </div>
  </div>

If we run the example at this point, clicking the emojiButton element will toggle the showEmojiPicker boolean.

Inserting the chosen emoji into our input field

The final piece of the puzzle is to insert the selected emoji into the input/contenteditable field. The handy

(emojiClick)="addEmoji($event)" provides a simple method to do so. Our addEmoji function simply injects the emoji from the event into our string, like so:

addEmoji(e: Event) { this.input.first.nativeElement.innerText += e.emoji.native
  }

You will need to style and place the element according to your environment, but really, you could arguably stop here if you wanted, as this level of integration alone will introduce a nice way for users to search and input emojis into your input fields. However, the main value of this post is to demonstrate how to introduce the headless emoji search.

Part 2: Adding emoji live search with :colon: syntax

At first glance this might not seem difficult: You wait until the user types a : into your text field, then pop up the emoji list, right? But how do you know which text to use as the search term? “Just split the string from the : character”, you say?. What if there are multiple :s in the text? Or what if the user pastes a selection of text that includes multiple :s? Another thing we must consider is that Doubtfire’s comment field is a markdown editor, and the user may actually be typing code into a markdown editor, for which :s would need to be ignored.

Note: Our chat-input element is actually a content-editable <div>. There are many benefits for using a <div> over a traditional input field if you’re looking to heavily customise the behaviour.

Watching for keypresses in the input box

Getting started, the first thing we need to do is start watching for any key press in the input field:

<-- Text field that generates an event on each keypress -->
  <div
    #commentInput
    id="textField"
    [contenteditable]="contentEditableValue()"
    (keydown.enter)="send($event)"
    (keydown)="keyTyped($event)"
    placeholder="Type a message..."
    name="commentComposer"
  ></div>

Any time a key is pressed down in the field, the keyTyped method will fire, and here’s where things start to get… involved.

Finding the :substring: to be replaced

We need to search for a substring from the entire input text, from the position of the caret (cursor), back to the first colon character : that is found.


The following is the keyTyped method for doing this:

keyTyped(e: KeyboardEvent) {
    setTimeout(() => {
      // Get the text from the HTML element
      const commentText: string = this.input.first.nativeElement.innerText;
      // Check if we are in an emoji-input state. Note we simply don't support the emoji selector if the ` character is anywhere in the input, as this may indicate that the user is trying to write code.
      this.emojiSearchMode = !commentText.includes('`') && this.emojiRegex.test(commentText);
  
      if (this.emojiSearchMode) {
        // get the cursor position in the content-editable
        const cursorPosition = this.caretOffset();
  
        // get the text from the start of the string up to the cursor.
        const testText = commentText.slice(0, cursorPosition);
  
        // within this substring, find the last`:`
        const lastColPos = testText.lastIndexOf(':');
  
        // The emoji search term will be from the position after the last :
        // Note, the second parameter is a length not position, so we subtract.
        this.emojiMatch = testText.substr(lastColPos + 1, cursorPosition - lastColPos);
  
        if (this.emojiMatch?.includes(' ')) {
            this.emojiSearchMode = false;
            this.emojiSearchResults = null;
          } else {
            // results is the list of emoji returned.
            const results = this.emojiSearch.search(this.emojiMatch);
            if (results?.length > 0) {
              this.emojiSearchResults = results.slice(0, 15);
            }
          }
        } // timeout to ensure that the innerHTML is updated with the new character.
      }, 0);
    }

The entire functionality is in a zero-duration timeout as it’s enough to ensure that the most recent character is processed and inserted into the innerText.

The method above uses a regular expression to determine if it should be searching for emojis. Here are the related emoji properties used in the component:

    showEmojiPicker: boolean = false;
    emojiSearchMode: boolean = false;
    emojiRegex: RegExp = /(?::)(.*?)(?=:|$)/;
    emojiSearchResults: EmojiData[] = [];
    emojiMatch: string;

Note: /(?::)(.*?)(?=:|$)/ is a regular expression which matches the content after a : and before another : or the end of a line.

Here is the method for finding the caretOffset (thanks to StackOverflow for this one, although I couldn’t find the exact author):

The key method here is the Window.getSelection() method, which returns a Selection object representing the range of text selected by the user or the current position of the caret.

private caretOffset() {
  // find the element reference of the input field
  let element = this.input.first.nativeElement;

  let caretOffset: number = 0;
  let doc = element.ownerDocument || element.document;
  let win = doc.defaultView || doc.parentWindow;
  let sel;

  if (typeof win.getSelection !== 'undefined') {
    sel = win.getSelection();
    let textRange = sel.createRange();
    let preCaretTextRange = doc.body.createTextRange();

    //set the start of the range to be the start of the text element.
    preCaretTextRange.moveToElementText(element);
 
    // set the end of the text range to be the text up until the caret.
    preCaretTextRange.setEndPoint('EndToEnd', textRange);
    return preCaretTextRange.text.length;
}

Inserting the emoji at the correct position

Next up is inserting the emoji into the text at the correct position. Simply appending the emoji seems like it would work, but if the user were to write a message and want to add an emoji retrospectively, it would fail. Here, we will use the most recent match preceded by a colon as the replacement substring condition:

emojiSelected(emoji: string) {
    //replace the text value of the input with the matched emoji, then close the emoji modal
    this.input.first.nativeElement.innerText = this.input.first.nativeElement.innerText.replace(`:${this.emojiMatch}`, emoji);
    this.emojiSearchMode = false;
  }

The last thing we need to do is get the selected emoji into the DOM. For this, we will use a mat-action-list, binding to the emojiSearchResults array. The reversal of the array puts the closest results to the search term at the bottom of the list (closer to the input field).

<mat-action-list [hidden]="!emojiSearchMode" dense id="emojiSearchResults">
    <mat-list-item
      *ngFor="let emoji of emojiSearchResults"
      (click)="emojiSelected(emoji.native)"
    >
      {{emoji.native}} {{emoji.colons}}
    </mat-list-item>
  </mat-action-list>

Add some styling and we’re done! Final product:


Since this article was published, a number of changes and improvements have been made to the Doubtfire Emoji implementation, including sending the emoji as a string to the backend rather than the emoji unicode. The full source code for these components can be found on the Doubtfire repo.