Creating Tiptap Emoji Picker

Creating Tiptap Emoji Picker

·

10 min read

When you are building a text editor for general-purpose writing, sooner or later you will need to support emoji’s for your users.

In this blog post, we will learn to create an emoji picker for your tiptap-based editor.

file

Let’s define the EmojiData Model first.

interface EmojiData {
 /**
   Label, name, meaning, descritption of emoji, user search can happen
   on this text.
 **/
 annotation: string;

/**
 power users uses emoji codes example `:cool:` will insert  emoji
**/
shortcodes: string[];

/**
Unicode or actual emoji character: 😁/🥵/💙
**/
emoji: string; 

/**
fallback url to be used in case where current env doesn't support
this emoji character, or it can be used for custom emojis
**/
url?: string;
}

Our Emoji will be a custom node and not a simple character as the env platform may not support that emoji or we want to use custom emojis like slack.

Let’s get our requirement clear for emoji node:

  • It should insert actual emoji character (text: inline, atom)

  • Emoji should be copied as unicode to clipboard (selectable)

  • If unsupported we should render the provided fallback emoji.

// Emoji-Node.ts

import { Node } from "@tiptap/core";
import isEmojiSupported from "is-emoji-supported";




/**
emoji node:
<span data-node="emoji" data-emoji="🥵" data-emoji-url="...">
 <span >🥵</span>
 <img src="..." />
</span>
**/

const EmojiNode = Node.create({
 // name of our node with which this will be registered
 // Node.name
 name: 'emoji',
 // we want it to be inline element
 group: "inline",
 inline: true,
 // we want node to be selectable
 selectable: true,
 // we want it as non editable node
 // https://tiptap.dev/docs/editor/core-concepts/schema#atom
 atom: true,

 // add attributes to the custom node, these appear on the model/schema
 addAttributes() {
    return {
     // EmojiData['emoji']
      emoji: { default: null },
      // EmojiData['url']
      url: { default: null }
    };
 },

 // parse the initial content or/pasted clipboard, parse it to
 // see if the html-node matches to emoji-node schema
 parseHTML() {
    return [
      {
       // all nodes should be identified with some selector,
       // in our case we use `data-node` our each node in schema
       // which is same as `Node.name`
        tag: 'span[data-node="emoji"]',
        // get the required attribute from the node-schema html
        getAttrs(node) {
          let attrs: false | Record<string, any> = false;
          // get the attributes character/emoji and fallback url
          const emoji = node.getAttribute("data-emoji");
          const url = node.getAttribute("data-url");

          if (emoji || url) {
            attrs = {
              emoji,
              url,
            };
          }

          return attrs;
        },
      },
    ];
  },

  // render the schema-node representation to html content
 renderHTML({ HTMLAttributes, node }) {
    // we need to know if the env supports the emoji
    // unicode char, if it doesn't we should render the fallback image
    let renderEmoji = false;

    const isBrowserEnv = isBrowser();

    if (isBrowserEnv) {
      // it is very interesting concept how emoji supported is checked
      // on browser by rendering it on canvas and doing 
      // some color checks, i'm using the package directly here
      renderEmoji = isEmojiSupported(node.attrs.emoji);
    }

    const { emoji, ...restHTMLAttributes } = HTMLAttributes;

    return [
      // root-node <span data-node="emoji" data-emoji="🥵" data-url="...">
      "span",
      mergeAttributes(restHTMLAttributes, {
        // add all html equivalent attributes
        "data-node": "emoji",
        "data-emoji": node.attrs.emoji,
        "data-url": node.attrs.url,
        // styles to make sure emoji works as intended
        // font-family needs to be inorder as firefox differ from chrome
        style: `user-select: text; font-family: "Twemoji Mozilla", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", sans-serif;`,
      }),
      // <img src="..." fallback or custom emoji
      [
        "img",
        {
          src: node.attrs.url,
          // we will not show image fallback if env supports the emoji
          // also w=1em, h=1em ensures that fallback scales 
          // with font-size of current text
          style: `${
            renderEmoji ? "display: none;" : "display: inline-block;"
          } width: 1em; height: 1em;`,
        },
      ],
      // <span>🥵</span>
      [
        "span",
        {
          role: "img",
          // render the image as inline-block
          style: `${renderEmoji ? "display: inline-block;" : "display: none;"}`,
        },
        node.attrs.emoji,
      ],
    ];
  },

  // we should copy node always as character in clipboard
  // the node schema is specific to use and will not be recognised
  // in any other platform, so make sure you allways copy as unicode
  renderText({ node }) {
    return node.attrs.emoji;
  },

  // allow to programatically insert the emoji
  // user should be able to insert emoji node from editor command
  // create `insertEmoji` command
  addCommands() {
    return {
      insertEmoji({ url, emoji }) {
        return ({ editor, chain }) => {
          // any `atomic` node faces difficulties while selecting
          // in tiptap editor, thus we insert dummy space to allow
          // selections to happen on #Text and in turn on emoji-node
          const shouldInsertSpace = shouldInsertSpaceAfterCurrentCursor(editor);

          return chain()
            .insertContent({
              type: "emoji",
              attrs: {

                url,
                emoji,
              },
            })
            // if next position is alredy a space don't insert anything
            // else insert space for selection sake
            .insertContent(shouldInsertSpace ? " " : "")
            .run();
        };
      },
    };
  },
});

You can checkout the code on my GitHub for emoji node.

Now that we have created the emoji node, it is time to create the emoji picker. In my case I want the picker to be activated as soon as someone presses : and close when they select an emoji or press : back again.

Let’s get requirements clear for picker:

  1. Trigger on :

  2. Open a emoji list as grid in popup near the cursor.

  3. Filter the emoji in the popup list as user types and show matching emojis.

  4. If user matched on one emoji and press : insert that emoji by default let the user choose from the list otherwise.

You can have your own emoji database from emoji-base, or for timebeing simpley use

[
  {
    "shortcodes": ["grinning", "grinning_face"],
    "emoji": "😀",
    "annotation": "grinning face"
  },
  {
    "shortcodes": ["grinning_face_with_big_eyes", "smiley"],
    "emoji": "😃",
    "annotation": "grinning face with big eyes"
  }
]

To trigger popup on press of a characater we will use tiptap-suggestion extension and modify it to match our use case.

import { Extension } from "@tiptap/react";
import Suggestion, {
  SuggestionOptions as NativeSuggestionOption
} from "@tiptap/suggestion";
// my emoji-node package published as npm repo,
// same as what we have built above
import { EmojiNode, EmojiData } from "@vtechguys/tiptap-emoji-node";
import { isEmojiSupported } from "is-emoji-supported";
// the rendering depends on end user in my case i have rendred
// as simple dom of grid items.
import { filterEmoji, getRenderItems, RenderItemsProps } from "./utils";

type SuggestionOptions = Pick<NativeSuggestionOption, "char" | "command">;

type Emoji = EmojiData & {
  search?: string;
};

type EmojiPickerOptions = {
  // list of emojis from your database/json
  emojis: Emoji[];
  // default tiptap suggestion options
  suggestion: SuggestionOptions;
  // we we want database/json to be fetched at later instance
  // this saves resource loaded on first render
  fetch?: () => Promise<Emoji[]>;
  // popup is using tippy so tippy config
  tippy: RenderItemsProps["tippy"];
  // callback to trigger whenever emoji is clicked
  onEmojiSelected?: RenderItemsProps["onEmojiSelected"];
  classes: RenderItemsProps["classes"];
};

/**
 add propperty to emoji if it is supported on current env
**/
const populateEmoji = (emojis: Emoji[]) => {
  emojis.forEach((emoji) => {
    emoji.isSupported = isEmojiSupported(emoji.emoji);
  });

  return emojis;
};

export const EmojiPicker = Extension.create<EmojiPickerOptions>({
  name: "emoji-picker",

  /**
   * Picker needs basic extension for rendering emoji node
   */
  addExtensions() {
    return [EmojiNode];
  },
  /**
   We will store the emojis database as a list in  editor's client storage
  **/
  addStorage() {
    return {
      emojis: [],
    };
  },

  onCreate() {
    /**
     * storage is initialized with emojis and make sure
     * emoji is populated with emoji with isSupported flag
     * the flag indicates what in the given list is supported
     */
    this.storage.emojis = populateEmoji(this.options.emojis) || [];

    /**
     * The init didn't provided emojis,
     * thus we will try to fetch from url
     */
    if (this.storage.emojis.length === 0) {
      this.options.fetch?.()?.then((data) => {
        this.storage.emojis = populateEmoji(data);
      });
    }
  },

  addOptions() {
    return {
      tippy: {
       // width of the popup
        maxWidth: 364,
      },
      emojis: [],
      suggestion: {
       // trigger char is :
        char: ":",
        command: ({ editor, range, props }) => {
          props.command({ editor, range, props });
        },
      },
      // picker dom classes
      classes: {
        root: "emoji-picker__container",
        emojiContainer: "emoji-picker__emoji",
        emojiImage: "emoji-picker__emoji__img",
        emojiChar: "emoji-picker__emoji__char",
      },
    };
  },

  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,

        ...this.options.suggestion,

        items: ({ query }) => {
          // when the user starts typing after triggering emoji
          // filter the matching emojis from the list
          // the retuned filtered emojis[] will be renderd in popup
          return filterEmoji(query, this.storage.emojis);
        },
        // don't allow user to type space
        allowSpaces: false,
       // anywhere in the text we should be able to trigger emoji
        startOfLine: false,

        render: () =>
          // from the returned filtered items render the list inside popup
          getRenderItems({
            tippy: this.options.tippy,
            editor: this.editor,
            onEmojiSelected: this.options.onEmojiSelected,
            classes: this.options.classes,
          }),
      }),
    ];
  },
});

Implmentation of filterEmoji is upto you, I will show the render function which is responsible for rendering the emojis.

export const getRenderItems = (args: RenderItemsProps) => {
  /**
   * Maintains the latest list of rendered items,
   * !Note: Should be updated first in every cycle of render
   */
  let currentItems: EmojiData[];

  let component: ReturnType<typeof initNodeView>;
  let popup: any;

  return {
    onStart: (props: SuggestionProps) => {
      // 1. update the current items first
      currentItems = props.items;
      // 2. init the node view
      component = initNodeView(args);
      // 3. init the popup, tippy
      popup = tippy("body", {
        getReferenceClientRect: props.clientRect as GetReferenceClientRect,
        appendTo: () => document.body,
        content: component.root,
        showOnCreate: true,
        interactive: true,
        trigger: "manual",
        placement: "bottom-start",
        maxWidth: args.tippy.maxWidth,
        zIndex: args.tippy.zIndex || 2147483647,
        ...args.tippy,
      });
      // 4. render after init is completed
      component.render(props);
    },

    onUpdate(props: SuggestionProps) {
      // 1. update the list of items
      currentItems = props.items;
      // 2. render the items with updated props
      component.render(props);
      // 3. update popup
      popup[0].setProps({
        getReferenceClientRect: props.clientRect,
      });
    },

    onKeyDown(props: SuggestionKeyDownProps) {
      /**
       * Esc are treated as escape from popup
       */
      if (props.event.key === "Escape") {
        popup[0].hide();
        return true;
      }

      /**
       * Close the suggestion is user press `:` and there was only one matching element in list,
       * example `:cool` will filter and show  🆒  in picker popup list,
       * now as soon as user press `:` again close the popup and insert the matching emoji, here 🆒
       * as :cool: is short code for 🆒
       */
      if (props.event.key === ":" && currentItems.length === 1) {
        args.editor
          .chain()
          .deleteRange(props.range)
          .insertEmoji(currentItems[0])
          .run();
        return true;
      }

      return component.onKeyDown?.(props);
    },

    onExit(props: SuggestionProps) {
      popup[0].destroy();
      component.destroy();
    },
  };
};

Our Native Javascript NodeView will look like:

/**
 * Init the view of the item
 * @param args
 * @returns
 */
const initNodeView = (args: ViewArgs) => {
  /**
   * record current render props
   */
  let renderProps: SuggestionProps | null = null;

  /**
   * create the root level element
   */

  const root = document.createElement("div");
  root.classList.add(args.classes?.root || "");

  /**
   * create click event on emoji popup root, select and insert the 
   * clicked emoji
   */
  const clickBinding = addEvent(root, "click", (event) => {

    if (!renderProps) {
      return;
    }


    let emoji = null;
    // find the clicked emoji
    // find the position(index) in the list
    const position = Number(((event.target as HTMLElement)?.closest("[data-position]") as HTMLElement)?.dataset?.position);
    // get the emoji at the index
    if (!Number.isNaN(position)) {
      emoji = renderProps?.items?.[position];
    }


    if (!emoji) {
      return;
    }

    // the emoji selected callback is called and if the callback
    // returns true we assume the insertion is being handled by callback
    // else we will do the insertion
    const handled = args.onEmojiSelect?.(event, {
        range: renderProps.range,
        editor: renderProps.editor,
        emoji,
      });

    if (!handled) {
      renderProps.
          editor?.chain?.()
      // as user can type after :
      // which is received as query to filter emoji list
      // we need to remove this query text before insertion emoji
      // the text index range is provided by TipTap suggestion plugin
          ?.deleteRange(renderProps.range)
      // insert the emoji node using the command that we created
          ?.insertEmoji?.(emoji)?.run?.();
    }

  });

  /**
   create each emoji element
    <span data-position="index">
        <img [src, alt] /> (fallback if emoji is not supported)
        <span> (emoji-unicode char)
   */
  const createEmoji = (emoji: EmojiData, index: number) => {
    const emojiContainer = document.createElement("span");
    emojiContainer.classList.add(args.classes?.emojiContainer || "");

    emojiContainer.setAttribute("data-position", `${index}`);

    const isSupported = emoji.isSupported;

    const img = document.createElement("img");
    img.classList.add(args.classes?.emojiImage || "");
    img.setAttribute("src", emoji.url || "");
    img.setAttribute("alt", "Emoji of " + emoji.annotation);
    img.style.display = isSupported ? "none" : "inline-block";

    const char = document.createElement("span");
    img.classList.add(args.classes?.emojiChar || "");

    char.setAttribute("role", "img");
    char.ariaLabel = emoji.annotation;
    char.style.display = isSupported ? "inline-block" : "none";
    char.appendChild(document.createTextNode(emoji.emoji));

    emojiContainer.append(img, char);

    return emojiContainer;
  };

  // a custom nodeview object

  return {
    root,
    /**
     * render the view with given props
     */
    render(props: SuggestionProps) {
      renderProps = props;

      root.innerHTML = "";

      const emojis = props.items.map((emoji, index) =>
        createEmoji(emoji, index)
      );
      root.append(...emojis);
    },
    /**
     * destroy view
     */
    destroy() {
      renderProps = null;
      clickBinding?.destroy();
      root.remove();
    },

    onKeyDown(props: SuggestionKeyDownProps) {
      // TODO: move between emojis in the list

      return false;
    },
  };
};

Once you have all that in place, you will have a emoji picker. You may want to go through the emoji picker code on my github @vtechguys/tiptap-emoji-picker

Stay tuned for more such tiptap extensions blog post.

Did you find this article valuable?

Support Aniket Jha by becoming a sponsor. Any amount is appreciated!