前言
- 扩展wangeditor,支持点击插入变量按钮
- 弹框展示变量选项,确定后将变量插入光标所在的位置。
{{杰克}}
变量为一个整体,删除时该标签全部删除,支持自定义参数,自定义样式。
就是实现如下图中的效果。点击链接 体验一下。
技术预研
一、如何实现点击按钮显示选择变量弹框
官方提供了扩展新菜单的方法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,这个方案有一个问题,插入变量之后会换行展示,体验不友好
终极方案:改造官方示例,自定义新元素;主要过程如下:
开搞
一、点击按钮,展示弹框
- 第一步 定义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大块
- 定义数据结构,可以自定义参数,用来跟后端传递数据
// 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
}
- 定义 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
- 在编辑器中渲染新元素,通过设置
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
- 上一步注册完之后就可以正常插入新元素了,但是在调用
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
- 此时可以通过
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
- 统一入口,用于最后注册
// 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)
碰到的问题
- 变量无法插入到指定的位置,会插入到文末,插入前需要先调用
editor.restoreSelection()
恢复编辑器选区。 - 回显的时候无法正确赋值,需要先调用
editor.focus()
让编辑器获取到焦点,再进行赋值