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.
EmojiCore.jsx 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 ;
Emoji.jsx 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 ;
EmojiList.jsx 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 ;