实现高级交互:自定义变量插入,让WangEditor更智能!

avatar
前端工程师 @天鹅到家

前言

  1. 扩展wangeditor,支持点击插入变量按钮
  2. 弹框展示变量选项,确定后将变量插入光标所在的位置。
  3. {{杰克}}变量为一个整体,删除时该标签全部删除,支持自定义参数,自定义样式。

就是实现如下图中的效果。点击链接 体验一下

123.gif

技术预研

一、如何实现点击按钮显示选择变量弹框

官方提供了扩展新菜单的方法ModalMenu,但是得用js创建DOM并填充内容,非常不灵活。无法利用当前开发完成的变量弹框,所以采用ButtonMenu加自定义事件来控制弹框显示隐藏。参考:在 React 中更方便的扩展 Menu ,替代原有的 ModalMenu 方案

// modalMenu.js
getModalContentElem(editor) {                      
 
    const $content = $('<div></div>')
    const $button = $('<button>do something</button>')
    $content.append($button)
 
    $button.on('click', () => {
        editor.insertText(' hello ')
    })
 
    return $content[0] // 返回 DOM Element 类型
}  

二、如何将变量插入正文中,并满足想要的效果

尝试一:插入变量到正文中,尝试过用js插入自定义fragement片段,此方案在删除操作时,会将插入的所有变量全部删除

const insertVarFragement = () => {
  const range = window.getSelection().getRangeAt(0)
  const fragment = document.createDocumentFragment()
 
  const span = document.createElement('span')
  span.contentEditable = 'false'
  span.innerText = 'your text'
  span.style.color = 'red'
  fragment.appendChild(span)
 
  range.insertNode(fragment)
}

尝试二:调用官方的api插入自定义的html片段,由于wangeditor不支持自定义节点属性(官网说明),所以设置的样式跟禁止编辑属性都没有生效,建议采用自定义新元素的方式实现

editor.dangerouslyInsertHtml('<h1 style="color:red" contentEditable="false">标题</h1>')

尝试三:参考github上的issue,这个方案有一个问题,插入变量之后会换行展示,体验不友好

image.png

终极方案:改造官方示例,自定义新元素;主要过程如下:

开搞

一、点击按钮,展示弹框

  • 第一步 定义MyButtonMenu类,控制弹窗显示隐藏
// baseModalMenu.ts
import { IDomEditor, IButtonMenu } from '@wangeditor/editor'

export default class MyButtonMenu implements IButtonMenu {
    title: string;
    tag: string;
    $ele: HTMLDivElement;
    $root: any;
    ifInit = false;
    iconSvg?: string | undefined;

    constructor () {
      this.title = '插入变量' // 自定义菜单标题,鼠标移入菜单展示的文字
      this.iconSvg = '<svg t="1689149013419" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1444" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M863.328262 481.340895l-317.344013 0.099772L545.984249 162.816826c0-17.664722-14.336138-32.00086-32.00086-32.00086s-31.99914 14.336138-31.99914 32.00086l0 318.400215-322.368714-0.17718c-0.032684 0-0.063647 0-0.096331 0-17.632039 0-31.935493 14.239806-32.00086 31.904529-0.096331 17.664722 14.208843 32.031824 31.871845 32.095471l322.59234 0.17718 0 319.167424c0 17.695686 14.336138 32.00086 31.99914 32.00086s32.00086-14.303454 32.00086-32.00086L545.982529 545.440667l317.087703-0.099772c0.063647 0 0.096331 0 0.127295 0 17.632039 0 31.935493-14.239806 32.00086-31.904529S880.960301 481.404542 863.328262 481.340895z" fill="#575B66" p-id="1445"></path></svg>' // 可选
      this.tag = 'button'

      this.$ele = document.createElement('div')
    }

    // 点击菜单时触发的函数
    exec (editor: IDomEditor, value: string | boolean) {
      // @ts-ignore
      editor.showVariable()
    }

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

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

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

export const variableConf = {
  key: 'variable', // =====>要插入变量我是关键,记住我的名字
  factory () {
    return new MyButtonMenu()
  }
}

  • 注册菜单,并插入到菜单栏
import '@wangeditor/editor/dist/css/style.css'
import { createEditor, createToolbar, Boot } from '@wangeditor/editor'
Boot.registerMenu(variableConf)

const toolbarConfig = {
    insertKeys: {
        index: 31,
        keys: ['variable'] // ====>对应上文定义的key
    }
}
editorRef.value = createEditor({
    selector: '#editor-container',
    config: editorConfig
})

// editor实例上挂载自定义函数,让点击菜单时调用
editorRef.value.showVariable = () => {
    //执行展示弹框的代码
}

createToolbar({
    editor: editorRef.value,
    selector: '#toolbar-container',
    config: toolbarConfig
})

二、实现插入变量到正文

定义一个新元素,总共分为5大块

  1. 定义数据结构,可以自定义参数,用来跟后端传递数据
// custom-types.ts
type EmptyText = {
  text: ''
}
export type MentionElement = {
  varId: string // 变量id
  varName: string // 变量预览值
  isNotNull: any // 是否必填 0 必填;1非必填
  type: 'mention'
  value: string
  info: any
  children: EmptyText[] // void 元素必须有一个空 text
}
  1. 定义 inline 和 void,可以将自定义节点改为行内元素,默认为块元素,会换行展示
// plugin.ts
import { DomEditor, IDomEditor } from '@wangeditor/editor'
import { IExtendConfig } from './interface'

function getMentionConfig (editor: IDomEditor) {
  const { EXTEND_CONF } = editor.getConfig()
  const { mentionConfig } = EXTEND_CONF as IExtendConfig
  return mentionConfig
}

function withMention<T extends IDomEditor> (editor: T) {
  const { insertText, isInline, isVoid } = editor
  const newEditor = editor
  console.log('hhhhhhhhh')

  // 重写 insertText
  newEditor.insertText = t => {
    // 选过选中了 void 元素
    const elems = DomEditor.getSelectedElems(newEditor)
    const isSelectedVoidElem = elems.some(elem => newEditor.isVoid(elem))
    if (isSelectedVoidElem) {
      insertText(t)
      return
    }

    insertText(t)
  }

  // 重写 isInline
  newEditor.isInline = elem => {
    const type = DomEditor.getNodeType(elem)
    if (type === 'mention') {
      return true
    }

    return isInline(elem)
  }

  // 重写 isVoid
  newEditor.isVoid = elem => {
    const type = DomEditor.getNodeType(elem)
    if (type === 'mention') {
      return true
    }

    return isVoid(elem)
  }

  return newEditor
}

export default withMention

  1. 在编辑器中渲染新元素,通过设置contentEditable属性为false,禁止该元素编辑。
// render-elem.ts
import { h, VNode } from 'snabbdom'
import { DomEditor, IDomEditor, SlateElement } from '@wangeditor/editor'
import { MentionElement } from './custom-types'

function renderMention (elem: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  // 当前节点是否选中
  const selected = DomEditor.isNodeSelected(editor, elem)
  const { value = '' } = elem as MentionElement

  // 构建 vnode
  const vnode = h(
    'span',
    {
      props: {
        contentEditable: false // 不可编辑
      },
      style: {
        marginLeft: '3px',
        marginRight: '3px',
        color: 'rgb(56,92,223)',
        background: selected // 选中/不选中,样式不一样
          ? '#eee' // wangEditor 提供了 css var https://www.wangeditor.com/v5/theme.html
          : '',
        borderBottom: '1px solid rgb(56,92,223)',
        borderRadius: '3px',
        padding: '0 3px'
      }
    },
    `${value}` // 如 `@张三`
  )

  return vnode
}

const conf = {
  type: 'mention', // 节点 type ,重要!!!
  renderElem: renderMention
}

export default conf

  1. 上一步注册完之后就可以正常插入新元素了,但是在调用editor.getHtml()获取到的html中没有新元素,需要将新元素转为html
// elem-to-html.ts
import { SlateElement } from '@wangeditor/editor'
import { MentionElement } from './custom-types'

// 生成 html 的函数
function mentionToHtml (elem: SlateElement, childrenHtml: string): string {
  const { value = '', varId = '', varName = '', isNotNull = '' } = elem as MentionElement
  return `<span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-content-var="1" data-is-not-null="${isNotNull}" data-var-name="${varName}" data-var-id="${varId}" data-value="${value}">${value}</span>`
}

// 配置
const conf = {
  type: 'mention', // 节点 type ,重要!!!
  elemToHtml: mentionToHtml
}

export default conf

  1. 此时可以通过editor.getHtml()获取到HTML,但是通过editor.setHtml(html)时却无效,还需要自定义解析HTML
// parse-elem-html.ts
import { DOMElement } from './dom'
import { IDomEditor, SlateDescendant, SlateElement } from '@wangeditor/editor'
import { MentionElement } from './custom-types'

function parseHtml (
  elem: DOMElement,
  children: SlateDescendant[],
  editor: IDomEditor
): SlateElement {
  // elem HTML 结构 <span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="张三" data-info="xxx">@张三</span>

  const value = elem.getAttribute('data-value') || ''
  const varId = elem.getAttribute('data-var-id') || ''
  const varName = elem.getAttribute('data-var-name') || ''
  const isNotNull = elem.getAttribute('data-is-not-null') || ''

  return {
    type: 'mention',
    value,
    varId,
    varName,
    isNotNull,
    children: [{ text: '' }] // void node 必须有一个空白 text
  } as MentionElement
}

const parseHtmlConf = {
  selector: 'span[data-w-e-type="mention"]',
  parseElemHtml: parseHtml
}

export default parseHtmlConf

  1. 统一入口,用于最后注册
// index.ts
import { IModuleConf } from '@wangeditor/editor'
import withMention from './plugin'
import renderElemConf from './render-elem'
import elemToHtmlConf from './elem-to-html'
import parseHtmlConf from './parse-elem-html'

const module: Partial<IModuleConf> = {
  editorPlugin: withMention,
  renderElems: [renderElemConf],
  elemsToHtml: [elemToHtmlConf],
  parseElemsHtml: [parseHtmlConf]
}

export default module

import { Boot } from '@wangeditor/editor'
import module from './plugins/module'

Boot.registerModule(module)

碰到的问题

  1. 变量无法插入到指定的位置,会插入到文末,插入前需要先调用editor.restoreSelection()恢复编辑器选区。
  2. 回显的时候无法正确赋值,需要先调用editor.focus()让编辑器获取到焦点,再进行赋值

参考链接

源码

gitee.com/wyljp/wange…

Cloud Studio Template