Emoji extension for Tiptap Editor
An emoji extension for Tiptap that includes a shortcut triggered by :
to easily insert emojis. The extension also focuses on making the emojis accessible, adhering to a11y (accessibility) standards.
Extension have 3 components:
EmojiCore.jsx
is core part of extension. It handles inserting emoji to tiptap document.Emoji.jsx
is used in Tiptap extension. This component handles passing suggestions toEmojiList.jsx
component.EmojiList.jsx
render list of emoji suggestions when user type:
.
import { mergeAttributes, Node, Range } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
const EmojiCore = Node.create({
name: "emoij",
group: "inline",
inline: true,
selectable: false,
atom: true,
addOptions() {
return {
HTMLAttributes: {},
deleteTriggerWithBackspace: false,
renderText({ node }) {
return `${node.attrs.icon ?? node.attrs.name}`;
},
renderHTML({ options, node }) {
return [
"span",
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${node.attrs.icon ?? node.attrs.name}`,
];
},
suggestion: {
char: ":",
pluginKey: EmojiPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(" ");
if (overrideSpace) {
range.to += 1;
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: "text",
text: " ",
},
])
.run();
window.getSelection()?.collapseToEnd();
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const type = state.schema.nodes[this.name];
const allow = !!$from.parent.type.contentMatch.matchType(type);
return allow;
},
},
};
},
addAttributes() {
return {
name: {
default: null,
parseHTML: (element) => element.getAttribute("data-name"),
renderHTML: (attributes) => {
if (!attributes.name) {
return {};
}
return {
"data-label": attributes.name,
};
},
},
icon: {
default: null,
parseHTML: (element) => element.getAttribute("data-icon"),
renderHTML: (attributes) => {
if (!attributes.icon) {
return {};
}
return {
"data-icon": attributes.icon,
};
},
},
};
},
parseHTML() {
return [
{
tag: `span[data-role="img"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
if (this.options.renderLabel !== undefined) {
return [
"span",
mergeAttributes(
{ "data-role": "img" },
this.options.HTMLAttributes,
HTMLAttributes
),
this.options.renderLabel({
options: this.options,
node,
}),
];
}
const mergedOptions = { ...this.options };
mergedOptions.HTMLAttributes = mergeAttributes(
{ role: "img" },
this.options.HTMLAttributes,
HTMLAttributes
);
const html = this.options.renderHTML({
options: mergedOptions,
node,
});
if (typeof html === "string") {
return [
"span",
mergeAttributes(
{ role: "img" },
this.options.HTMLAttributes,
HTMLAttributes
),
html,
];
}
return html;
},
renderText({ node }) {
if (this.options.renderLabel !== undefined) {
return this.options.renderLabel({
options: this.options,
node,
});
}
return this.options.renderText({
options: this.options,
node,
});
},
addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false;
const { selection } = state;
const { empty, anchor } = selection;
if (!empty) {
return false;
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true;
tr.insertText(
this.options.deleteTriggerWithBackspace
? ""
: this.options.suggestion.char || "",
pos,
pos + node.nodeSize
);
return false;
}
});
return isMention;
}),
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
export default EmojiCore;
import tippy from "tippy.js";
import { ReactRenderer } from "@tiptap/react";
import { init, SearchIndex } from "emoji-mart";
import data from "@emoji-mart/data";
import EmojiCore from "./EmojiCore";
import EmojiList from "./EmojiList";
init({ data });
const Emoji = EmojiCore.configure({
suggestion: {
items: async ({ query }) => {
const results = [];
if (query.length) {
const response = await SearchIndex.search(query);
return response.map((emoji) => ({
id: emoji.id,
name: emoji.name,
icon: emoji.skins[0].native,
shortcode: emoji.skins[0].shortcodes,
}));
}
return results;
},
render: () => {
let popup;
let component;
return {
onStart: (props) => {
component = new ReactRenderer(EmojiList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: () => DOMRect,
});
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props) ?? false;
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
},
});
export default Emoji;
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
const EmojiList = forwardRef((props, ref) => {
const { items, command } = props;
const emojiListRef = useRef([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = items[index];
if (item) {
command(item);
}
};
const enterHandler = () => selectItem(selectedIndex);
const downHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length);
};
const upHandler = () => {
setSelectedIndex(
(prevIndex) => (prevIndex + items.length - 1) % items.length
);
};
useEffect(() => setSelectedIndex(0), [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
if (!items?.length) return null;
return (
<div className="bg-white h-72 overflow-y-auto border rounded-lg overflow-hidden shadow-xl w-56">
<ul>
{items.map((emoji, index) => {
if (selectedIndex === index) {
emojiListRef.current[selectedIndex]?.scrollIntoView({
block: "nearest",
});
}
return (
<li
key={emoji.id}
ref={(element) => {
emojiListRef.current[index] = element;
}}
>
<button
type="button"
onClick={() => selectItem(index)}
className={`text-black flex items-center gap-x-2 px-4 py-2 hover:bg-neutral-100 transition-all ease-in-out cursor-pointer text-left w-full ${
selectedIndex === index ? "bg-neutral-100" : ""
}`}
>
<p className="text-base">{emoji.icon}</p>
<p>{emoji.shortcode}</p>
</button>
</li>
);
})}
</ul>
</div>
);
});
export default EmojiList;
Usage
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Emoji from "./Emoji";
const Editor = () => {
const editor = useEditor({
extensions: [StarterKit, Emoji],
});
return <EditorContent editor={editor} />;
};
export default Editor;