OverviewFilesCommitsIssuesDocsReleasesPeers

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

144 lines · javascript ref:
import { mergeAttributes } from '@tiptap/core'
import { Mention as MentionTipTap } from '@tiptap/extension-mention'
import { Plugin, PluginKey } from 'prosemirror-state'

export function mentionPastePlugin (options) {
  const { suggestion, idKey = 'memberId', labelKey = 'displayName' } = options
  const cache = new Map()
  const getMentionItems = suggestion.items
  let editorViewRef = null

  return new Plugin({
    key: new PluginKey('mentionPaste'),

    view (view) {
      editorViewRef = view
      return {
        destroy () {
          editorViewRef = null
        }
      }
    },

    appendTransaction (transactions, _, newState) {
      const isPaste = transactions.some(tr => tr.getMeta('paste'))
      if (!isPaste) return null

      const tr = newState.tr
      let modified = false

      const promises = []

      newState.doc.descendants((node, pos) => {
        if (!node.isText) return

        const getItem = async (query, start, end) => {
          let items = cache.get(query)

          if (!items) {
            items = (await getMentionItems({ query })) || []
            items = items
              .map(item => ({ ...item, mention: `@${item[labelKey]}` }))
              .sort((a, b) => b[labelKey].length - a[labelKey].length)
            cache.set(query, items)
          }

          return items.find(item =>
            node.text.slice(start, end) === item.mention
          )
        }

        const matches = [...node.text.matchAll(/@[\w]+/g)]
        if (!matches.length) return

        for (const match of matches) {
          const matchText = match[0]
          const start = match.index ?? 0
          const end = start + matchText.length
          const query = matchText.slice(1)

          promises.push(
            (async () => {
              const item = await getItem(query, start, end)
              if (!item) return

              const from = pos + start
              const to = pos + end
              const mentionNode = newState.schema.nodes.mention.create({
                id: item[idKey],
                label: item[labelKey]
              })

              tr.replaceWith(from, to, mentionNode)
              modified = true
            })()
          )
        }
      })

      if (promises.length) {
        Promise.all(promises)
          .then(() => {
            if (modified) {
              if (editorViewRef) editorViewRef.dispatch(tr)
            }
            cache.clear()
          })
          .catch(console.error)
      }

      return null
    }
  })
}

export const Mention = MentionTipTap.extend({
  addOptions () {
    const parent = this.parent?.()

    return {
      ...parent,
      renderHTML ({ options, node }) {
        const isSelf = node.attrs.id === options.myMentionId
        return [
          'span',
          {
            ...mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
            class: `mention ${isSelf ? 'mention-me' : ''}`
          },
          `${options.suggestion.char || ''}${node.attrs.label ?? node.attrs.id}`
        ]
      },
      suggestion: {
        ...parent?.suggestion,
        startOfLine: false,
        allowedPrefixes: [
          ' ',
          // Chinese, Japanese, and Korean
          '\\u2E80-\\u2EFF\\u2F00-\\u2FDF\\u3040-\\u30FF\\u31F0-\\u31FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7AF\\u1100-\\u11FF\\u3130-\\u318F'
        ]
      },
      myMentionId: null
    }
  },

  addStorage () {
    return { suggestionOpen: false }
  },

  addAttributes () {
    return {
      ...this.parent?.(),
      class: { default: 'mention' }
    }
  },

  renderLabel ({ options, node }) {
    return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
  },

  addProseMirrorPlugins () {
    return [...(this.parent?.() || []), mentionPastePlugin(this.options)]
  }
})