OverviewFilesCommitsIssuesDocsReleasesPeers

holepunchto-deps / @holepunchto / keet-editor / src / extensions / emoji.js

255 lines · javascript ref:
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 || ''
    ]
  }
})