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.
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:
Trigger on
:
Open a emoji list as grid in popup near the cursor.
Filter the emoji in the popup list as user types and show matching emojis.
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.