OverviewFilesCommitsIssuesDocsReleasesPeers

holepunchto-deps / @holepunchto / keet-editor / src / extensions / link-preview.js

127 lines · javascript ref:
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'

import { getUrls, debounce } from './helpers/index.js'

const TAG_NAME = 'link-preview'
const MAX_PREVIEW_ITEMS = 5

export const handleUpdate = debounce((editor, extension) => {
  const doc = editor.getJSON()
  const state = extension.storage

  const urls = getUrls(doc, (href) => {
    const cmd = editor.chain().focus().extendMarkRange('link')
    if (href) cmd.setLink({ href }).run()
    else cmd.unsetLink().run()
  })

  for (const url of [...urls].slice(0, MAX_PREVIEW_ITEMS)) {
    if (state.active.has(url) || state.failed.has(url)) continue
    editor.commands.setLinkPreview({ url })
    state.active.add(url)
  }

  for (const url of state.active) {
    if (!state.failed.has(url) && urls.has(url)) continue
    editor.commands.removeLinkPreview({ url })
    state.active.delete(url)
  }
}, 600)

export const LinkPreview = Node.create({
  name: TAG_NAME,
  group: 'block',
  atom: true,

  addStorage () {
    return {
      active: new Set(),
      failed: new Set()
    }
  },

  addAttributes () {
    return {
      url: {
        default: null
      },
      title: {
        default: null
      },
      description: {
        default: null
      },
      image: {
        default: null
      }
    }
  },

  parseHTML () {
    return [{ tag: TAG_NAME }]
  },

  renderHTML ({ HTMLAttributes }) {
    return [TAG_NAME, mergeAttributes(HTMLAttributes)]
  },

  addCommands () {
    return {
      saveFile: (url, file) => () => {
        if (file) this.options.store.set(url, file)
        return true
      },

      removeFile: (url) => () => {
        this.options.store.del(url)
        return true
      },

      setLinkPreview:
        ({ url }) =>
          ({ state, dispatch }) => {
            const { tr, schema, doc } = state
            const node = schema.nodes[TAG_NAME].create({ url })
            const transaction = tr.insert(doc.content.size, node)

            if (dispatch && transaction.docChanged) {
              dispatch(transaction)
            }

            return true
          },

      removeLinkPreview:
        ({ url }) =>
          ({ state, dispatch }) => {
            const { tr, doc } = state

            let transaction = tr
            let removed = false

            doc.descendants((node, pos) => {
              if (node.type.name === TAG_NAME && node.attrs.url === url) {
                transaction = transaction.delete(pos, pos + node.nodeSize)
                removed = true
              }
            })

            if (dispatch && removed) {
              dispatch(transaction)
            }

            return true
          }
    }
  },

  addNodeView () {
    return ReactNodeViewRenderer(this.options.Component, { className: 'inline-block' })
  },

  onUpdate ({ editor }) {
    handleUpdate(editor, this)
  }
})