扩展@wangeditor/editor功能,自定义新元素

5,721 阅读5分钟

背景:需要做一个富文本生成H5页面的功能,就简单使用@wangeditor/editor-for-vue库做了个功能

问题:需要支持一个设置内边距的功能,支持自定义边距

思考:把组件库支持的所有菜单过了一遍,好像是不支持这个功能,看到文档说可以自定义新功能,那么应该怎么扩展一个新功能呢,文档上只能看到一些基础的样例和api,并且比较零碎,所以基于源码写是最高效的方法,以引用功能为例。

效果图:

企业微信截图_16897500436875.png

定义新元素-pagePad

定义一个新元素需要包含几个方法:

  • pagePadMenu: 注册新菜单,定义菜单的形式
  • renderElem: 渲染“页边距”元素到编辑器,让编辑器认识“页边距”
  • elem-to-html: 生成“页边距”元素的 HTML,让编辑器知道怎么输出html(否则getHtml不认识“页边距”)
  • parse-elem-html: 解析 HTML 字符串,生成“页边距”元素的数据结构,定义解析 HTML 的逻辑(否则setHtml无效)
  • plugin: 劫持编辑器事件和操作,比如:敲回车时执行什么操作

我最终的代码结构长这样:

image.png

首先需要定义元素的数据结构,这里我只需要设置个左右margin,所以除了type和children外加了一个margin属性,后面转换结构时要按照这个格式

const Resume = {
  type: 'pagePad',
  margin: '20px',
  children: [{ text: '' }]
}

index.js 作为元素的入口文件,统一导出元素相关配置

import { menuConfig } from './menu'
import { elemToHtmlConfig } from './elem-to-html'
import { renderToElemConfig } from './render-elem'
import { parseHtmlConfig } from './parse-elem-html'
import withPagePad from './plugin'
import { MENU_KEY } from './menu'

const PAGEPAD_TYPE = 'pagePad'

// 用来注册自定义的方法
export const module = {
  menus: [menuConfig],
  renderElems: [renderToElemConfig],
  elemsToHtml: [elemToHtmlConfig],
  parseElemsHtml: [parseHtmlConfig],
  editorPlugin: withPagePad
}

// 菜单配置
export const INSERTKEYS = {
  index: 4, // 菜单顺序
  keys: [MENU_KEY]
}

render-elem.js用于定义元素在编辑器中的渲染效果,wangeditor中对blockquote标签写了一些默认样式,这里就借用blockquote标签实现“内边距”功能

import { h } from 'snabbdom'
import { PAGEPAD_TYPE } from './index'

/**
 * @param elem 内边距元素
 * @param children 元素子节点
 * @param editor 编辑器实例
 * @returns vnode 节点(通过 snabbdom.js 的 h 函数生成)
 */
const renderToELem = (elem, children, editor) => {
  const vnode = h(
    'blockquote',
    {
      style: {
        borderLeft: `${elem.margin} solid var(--w-e-textarea-selected-border-color)`,
        borderRight: `${elem.margin} solid var(--w-e-textarea-selected-border-color)`
      }
    },
    children
  )

  return vnode
}

// 编辑器渲染配置
export const renderToElemConfig = {
  type: PAGEPAD_TYPE,
  renderElem: renderToELem
}

elem-to-html.js 负责定义怎么输出 html,也就是 getHtml() 的返回值,这里

import { PAGEPAD_TYPE } from './index'

/**
 * @param elem 元素的数据结构,如上文的 Resume
 * @param childrenHtml 子节点的 HTML 代码
 * @returns 元素的 HTML 字符串
 */
const elemToHtml = (elem, childrenHtml) => {
  const { margin = '' } = elem
  // 生成 HTML 代码,data-w-e-type用于标记元素类型,后面会用
  const html = `<blockquote data-w-e-type="${PAGEPAD_TYPE}" data-margin="${margin}" style="margin: 0px ${margin}">${childrenHtml}</blockquote>`
  return html
}

// 编辑器输出html配置
export const elemToHtmlConfig = {
  type: PAGEPAD_TYPE,
  elemToHtml: elemToHtml
}

parse-elem-html.js 负责定义怎么解析 html 字符串

import { PAGEPAD_TYPE } from './index'

/**
 * @param domElem HTML 对应的 DOM Element
 * @param children 子节点
 * @param editor editor 实例
 * @returns 元素数据结构,如上文的 Resume
 */
const parseHtml = (domElem, children, editor) => {
  const margin = domElem.getAttribute('data-margin') || '0px'
  // 生成元素数据结构
  const myResume = {
    type: PAGEPAD_TYPE,
    margin,
    children
  }

  return myResume
}

// 编辑器解析html配置
export const parseHtmlConfig = {
  selector: 'blockquote[data-w-e-type="pagePad"]', // CSS 选择器,匹配特定的 HTML 标签
  parseElemHtml: parseHtml
}

menu.js 用户定义菜单的交互,这里交互是:点击图标 -> 弹窗输入数字 -> 插入元素

import { SlateEditor, SlateElement, SlateTransforms } from '@wangeditor/editor'
import { PADDING_SVG } from '../icon-svg'
import { PAGEPAD_TYPE } from './index'

// 菜单 key
export const MENU_KEY = 'pagePad'
// 菜单名
export const MENU_NAME = '页边距'

class menu {
  constructor () {
    this.title = MENU_NAME
    this.iconSvg = PADDING_SVG  // 在工具栏展示的图标,不设置图标会显示title
    this.tag = 'button'
    this.showModal = true
    this.modalWidth = 300
    this.margin = '0px'
    this.modalDom = null
  }

  // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
  isActive (editor) {
    return false
  }

  // 获取菜单执行时的 value ,用不到则返回空 字符串或 false
  getValue (editor) {
    return ''
  }

  // 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
  isDisabled (editor) {
    return false
  }

  // 弹出框 modal 的定位:1. 返回某一个 SlateNode; 2. 返回 null (根据当前选区自动定位)
  getModalPositionNode (editor) {
    return null // modal 依据选区定位
  }

  // 定义 modal 内部的 DOM Element
  getModalContentElem (editor) {
    if (this.modalDom) {
      return this.modalDom
    }
    // 创建一个div元素
    const divElement = document.createElement('div')
    divElement.style.marginBottom = '30px'
    divElement.style.marginTop = '10px'
    // 创建页边距输入框
    const marginInput = document.createElement('input')
    marginInput.type = 'text'
    marginInput.placeholder = '请输入页边距'
    marginInput.style.marginBottom = '10px'
    marginInput.addEventListener('change', e => {
      const val = e.target.value
      this.padding = val.includes('px') ? val : val + 'px'
    })

    // 创建确定按钮
    const confirmButton = document.createElement('button')
    confirmButton.textContent = '确定'
    confirmButton.style.marginRight = '20px'
    confirmButton.addEventListener('click', () => {
      // 恢复最近一次非 null 选区。如编辑器 blur 之后,再重新恢复选区,否则“基于当前选区”的操作无效
      editor.restoreSelection()

      const nodeEntries = SlateEditor.nodes(editor, {
        match: (node) => {
          if (SlateElement.isElement(node)) {
            return true
          }
          return false
        },
        universal: true
      })

      const children = []
      if (nodeEntries == null) {
        children.push({ text: '' })
      } else {
        for (const nodeEntry of nodeEntries) {
          const [node] = nodeEntry
          children.push(node)
        }
      }

      const targetNode = {
        type: PAGEPAD_TYPE,
        padding: this.margin,
        children
      }

      SlateTransforms.setNodes(editor, targetNode, { mode: 'highest' })
    })

    // 将页边距输入框、确定按钮和取消按钮添加到div元素中
    divElement.appendChild(marginInput)
    divElement.appendChild(confirmButton)
    
    // 缓存 modal
    this.modalDom = divElement
    return divElement
  }
}

// 自定义菜单配置
export const menuConfig = {
  key: MENU_KEY,
  factory () {
    return new menu()
  }
}

上面的已经完成一个新元素的定义了,可以在组件中使用了,插件看个人需求需不需要定义,如果不需要可跳过

plugin.js 用于劫持编辑器事件和操作,自定义事件行为

/**
 * @description editor 插件,重写 editor API
 */
import { SlateEditor, SlateTransforms, SlatePoint, SlateNode, DomEditor } from '@wangeditor/editor'
import { PAGEPAD_TYPE } from './index'

function withPagePad (editor) {
  const { insertBreak, insertText } = editor
  const newEditor = editor

  // 重写 insertBreak - 换行时插入 p
  newEditor.insertBreak = () => {
    const { selection } = newEditor
    if (selection == null) return insertBreak()

    const [nodeEntry] = SlateEditor.nodes(editor, {
      match: n => DomEditor.checkNodeType(n, PAGEPAD_TYPE),
      universal: true
    })
    if (!nodeEntry) return insertBreak()

    const quoteElem = nodeEntry[0]
    const quotePath = DomEditor.findPath(editor, quoteElem)
    const quoteEndLocation = SlateEditor.end(editor, quotePath)

    if (SlatePoint.equals(quoteEndLocation, selection.focus)) {
      // 光标位于 元素的 最后
      const str = SlateNode.string(quoteElem)
      if (str && str.slice(-1) === '\n') {
        // blockquote 文本最后一个是 \n,就跳出边距节点
        editor.deleteBackward('character') // 删除最后一个 \n

        // 则插入一个 paragraph
        const p = { type: 'paragraph', children: [{ text: '' }] }
        SlateTransforms.insertNodes(newEditor, p, { mode: 'highest' })
        return
      }
    }

    // 情况情况,插入换行符
    insertText('\n')
  }

  return newEditor
}

export default withPagePad

到这里新元素‘页边距’就定义成功了,可以在组件中使用了

<script>
import { Boot } from '@wangeditor/editor'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { module, INSERTKEYS } from './config/richText'

// 注册新元素
Boot.registerModule(module)

export default {
    ...
    data () {
        return {
            toolbarConfig: {
                ...
                insertKeys: INSERTKEYS
            }
        }
    }
}
</script>

<template>
<Toolbar
 ...
 :default-config="toolbarConfig"
/>
</template>