OverviewFilesCommitsIssuesDocsReleasesPeers

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

122 lines · javascript ref:
import { InputRule, callOrReturn } from '@tiptap/core'
import { Blockquote as BlockquoteTiptap } from '@tiptap/extension-blockquote'
import { canJoin, findWrapping } from '@tiptap/pm/transform'
import { Plugin } from 'prosemirror-state'

import { exitAfterBlock, exitBeforeBlock } from './helpers/index.js'

const inputRegex = /^\s*>\s$/

export const Blockquote = BlockquoteTiptap.extend({
  addOptions () {
    return {
      levels: 1
    }
  },

  addInputRules () {
    return [
      wrappingInputRule({
        find: inputRegex,
        type: this.type,
        depth: this.options.levels
      })
    ]
  },

  addKeyboardShortcuts () {
    const thisExitAfterBlock = exitAfterBlock.bind(this)
    const thisExitBeforeBlock = exitBeforeBlock.bind(this)

    return {
      'Mod-Shift-9': () => this.editor.commands.toggleBlockquote(),
      ArrowUp: () => thisExitBeforeBlock(),
      ArrowLeft: () => thisExitBeforeBlock(),
      ArrowDown: () => thisExitAfterBlock(),
      ArrowRight: () => thisExitAfterBlock()
    }
  },

  addProseMirrorPlugins () {
    return [
      new Plugin({
        props: {
          transformPastedHTML: (html) => {
            const { body } = new window.DOMParser().parseFromString(html, 'text/html')
            const blockquote = body.querySelector('blockquote')
            if (blockquote) {
              removeNestedBlockquotes(blockquote)
            }
            return body.innerHTML
          }
        }
      })
    ]
  }
})

function removeNestedBlockquotes (blockquote) {
  const childNodes = Array.from(blockquote.childNodes)
  childNodes.forEach(child => {
    if (child.nodeName === 'BLOCKQUOTE') {
      removeNestedBlockquotes(child)
      while (child.firstChild) {
        blockquote.insertBefore(child.firstChild, child)
      }
      blockquote.removeChild(child)
    }
  })
}

function wrappingInputRule (config) {
  return new InputRule({
    find: config.find,
    handler: ({ state, range, match }) => {
      const attributes = callOrReturn(config.getAttributes, undefined, match) || {}
      const tr = state.tr.delete(range.from, range.to)
      const $start = tr.doc.resolve(range.from)
      const blockRange = $start.blockRange()
      const wrapping = blockRange && findWrapping(blockRange, config.type, attributes)
      const { selection, storedMarks } = state

      let depth = 0

      // Traverse up the node hierarchy to count the blockquote nesting level
      for (let i = selection.$from.depth; i > 0; i--) {
        const node = selection.$from.node(i)
        if (node.type.name === 'blockquote') {
          depth++
        }
      }

      if (depth >= config.depth || !wrapping) {
        return null
      }

      tr.wrap(blockRange, wrapping)

      if (config.keepMarks && config.editor) {
        const { splittableMarks } = config.editor.extensionManager
        const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks())

        if (marks) {
          const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name))

          tr.ensureMarks(filteredMarks)
        }
      }

      const before = tr.doc.resolve(range.from - 1).nodeBefore

      if (
        before &&
        before.type === config.type &&
        canJoin(tr.doc, range.from - 1) &&
        (!config.joinPredicate || config.joinPredicate(match, before))
      ) {
        tr.join(range.from - 1)
      }
    }
  })
}