import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core'
import { Suggestion } from '@tiptap/suggestion'
const getLastChar = (text) => (text ? text.charAt(text.length - 1) : '')
const getLastWord = (text) => text?.match(/([^\s]+)\s*$/)?.[1] || ''
export const Emoji = Node.create({
name: 'emoji',
content: 'text*',
group: 'inline',
inline: true,
selectable: true,
atom: true,
defining: true,
addStorage () {
return {
suggestionOpen: false
}
},
addOptions () {
return {
HTMLAttributes: {
class: 'emoji'
},
suggestion: {
char: ':',
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
const overrideSpace = nodeAfter?.text?.startsWith(' ')
if (overrideSpace) {
range.to += 1
}
const { emoji, shortCodes } = props
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
tag: 'span',
attrs: { 'data-shortcode': shortCodes[0], 'data-emoji': emoji },
text: emoji
},
{
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
}
}
}
},
addCommands () {
return {
setEmoji:
({ key, emoji }) =>
({ commands }) => {
const emojiData = this.options.emojiModule.getEmojiData(key)
if (!emojiData) return false
commands.insertContent(' ')
const { url, shortCodes } = emojiData
if (url) {
commands.insertContent(
`<span class="emoji" data-shortcode="${shortCodes?.[0]}" data-emoji=${emoji}>:${shortCodes?.[0]}:</span>`
)
} else {
commands.insertContent(`<span class="emoji" data-shortcode="${key}" data-emoji="${emoji}">${emoji}</span>`)
}
return true
}
}
},
addAttributes () {
return {
'data-shortcode': {
parseHTML: (element) => element.getAttribute('data-shortcode'),
default: ''
},
'data-emoji': {
parseHTML: (element) => element.getAttribute('data-emoji'),
default: ''
}
}
},
/*
* Replaces emoticon text for emojis, ie :) <3 xD
*/
addInputRules () {
const { getEmojiData } = this.options.emojiModule
const { emojisWithEmoticons } = this.options.suggestion
const contains = (index, text, emoticon) =>
index !== -1 &&
text.slice(index) === emoticon &&
(/\s/.test(text.slice(index - 1, index)) || index === 0)
const findEmoticon = (text) => {
for (const emoji of emojisWithEmoticons) {
// :D :) ;) :* :p :P ;p ;P :| 8) :/ :( :o :O :'( >:( :3 <3
if (Array.isArray(emoji.emoticon)) {
for (const emoticon of emoji.emoticon) {
const index = text.indexOf(emoticon)
if (contains(index, text, emoticon)) {
return { text: text.slice(index), emoji }
}
}
} else {
const index = text.indexOf(emoji.emoticon)
if (contains(index, text, emoji.emoticon)) {
return { text: text.slice(index), emoji }
}
}
}
const emojiMatched = text.match(/:(.*):/) // :+1: :heart_eyes: :keet_music: etc...
if (emojiMatched) {
const emoji = getEmojiData(emojiMatched[1])
if (emoji) {
return { text: text.slice(emojiMatched.index), emoji }
}
}
return null
}
const findMentions = (doc) => {
const mentions = []
doc.descendants((node) => {
if (node.type.name === 'mention') {
mentions.push(`@${node.attrs.label}`)
}
})
return mentions
}
return [
nodeInputRule({
find: (text) => {
const mentions = findMentions(this.editor.state.doc)
for (const mention of mentions) {
text = text.replaceAll(mention, '')
}
const ch = getLastChar(text)
if (ch !== ' ' && ch !== '\n') return
const lastWord = getLastWord(text)
const data = findEmoticon(lastWord)
if (!data) return
return {
data: data.emoji,
text,
replaceWith: ch === '\n' ? `${data.text}\n` : data.text
}
},
type: this.type,
getAttributes: (match) => {
if (match.data) {
const { shortCodes, emoji } = match.data
return {
'data-shortcode': shortCodes?.[0],
'data-emoji': emoji
}
}
}
})
]
},
addProseMirrorPlugins () {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
]
},
parseHTML () {
return [
{
tag: 'span',
getAttrs: (element) => {
const hasClasses = element.hasAttribute('class') && element.classList.contains('emoji')
return hasClasses
? {
'data-shortcode': element.getAttribute('data-shortcode'),
'data-emoji': element.getAttribute('data-emoji')
}
: false
}
}
]
},
renderHTML ({ node, HTMLAttributes }) {
const { getEmojiData } = this.options.emojiModule
const emojiCode = HTMLAttributes['data-shortcode']
const { url } = getEmojiData(emojiCode) || {}
if (url) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { contenteditable: 'false' }),
['img', { class: 'emoji-img', src: url }, 0]
]
}
const { emoji } = getEmojiData(emojiCode) || {}
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { contenteditable: 'false' }),
emoji || emojiCode || node.textContent || ''
]
}
})