vue3+wangeditor5的问题记录,自定义菜单+打字机

2,024 阅读4分钟

本文适用于wangeditor5在vue3中的使用:

目前遇到个需求是类似对话的功能,根据用户提出的问题,把答案回显到富文本里。

  1. 官网文档:www.wangeditor.com

安装

npm install @wangeditor/editor
npm install @wangeditor/editor-for-vue

初始化

页面editor.vue

<template>
    <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />    
    <Editor v-model="valueHtml" :defaultConfig="editorConfig" 
        :defaultContent="defaultContent" :mode="mode" 
        @onCreated="handleCreated"  />
</template>
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
const valueHtml = ref('');
const editorRef = shallowRef();
// 配置工具栏,显示哪些菜单,以及菜单的排序、分组
const toolbarConfig = {  
    toolbarKeys: [],
};
// 编辑器配置
const editorConfig = {  
    placeholder: '请输入文本',  
    hoverbarKeys: {    
        'text': {      
        menuKeys: [],    
    }    
    // 其他配置项...  
    },
};
const defaultContent = [
    {  
        type: 'paragraph',  
        lineHeight: '',  
        children: [    
            { text: '', fontFamily: '', fontSize: '' },  
        ]
    }
];
//editor初始化成功
const handleCreated = (editor: any) => {  
    editorRef.value = editor;  
    console.log('Editor准备就绪:');
};
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {  
    if (editorRef.value == null || !editorRef.value) return;  
    editorRef.value.destroy();
});
</script>

首先是自定义菜单遇到的问题:

  • wangeditor的自定义菜单只允许注册一次,重复注册会报错。
  • 解决方案:注册的过程前判断一下menus是否存在当前自定义菜单。

在注册菜单的时候

import { Boot } from "@wangeditor/editor";
const allRegisterMenu = editor.getAllMenuKeys();
const allRegisterMenu = editor.getAllMenuKeys(); 
// 获取所有已注册的菜单
if (allRegisterMenu.indexOf(自定义菜单的key) < 0) {     
    // 如果未注册,则注册       
    const menuObj = {              
        key: item.key,              
        factory() {                    
            return new 自定义菜单的class名()              
        }        
    }        
    Boot.registerMenu(menuObj);
}
  • 在退出当前编辑器页面后重新进入或者多次打开弹窗时,自定义菜单的自定义exec事件调用vue实例的方法,比如emit或者动态事件,会导致菜单事件绑定的是首次实例的方法,访问不到当前页面实例的方法。
  • 解决方案:使用editor菜单的emit方法自定义事件,然后编辑器实例监听。

新建一个menu.ts用来定义menu的类,以button类型菜单为例

import type { IButtonMenu, IDomEditor } from "@wangeditor/editor";
class menuButton implements IButtonMenu {  
    tag: string;  
    title: string;  
    constructor() {    
        this.title = 'menu';    
        this.tag = 'button';  
    }  
    getValue(editor: IDomEditor): string | boolean {    
        return 'menu';  
    }  
    isActive() {    
        return false;  
    }  
    isDisabled(editor: IDomEditor): boolean {    
        return false;  
    }  
    exec(editor: IDomEditor, value: string | boolean) {    
        if (this.isDisabled(editor)) {      
            return;    
        }    
        editor.emit('menuClick', value);  //重点
    }
}
export { menuButton }

新建一个register.ts用来注册菜单,如果想添加到toolbarConfig,可以把toolbarConfig传进来

import { menuButton } from "./menu";
import { Boot } from "@wangeditor/editor";
import type { IDomEditor } from "@wangeditor/editor";
const MenusList = [  
    {    
        key: 'menuConfig',    
        class: menuButton,  
    }
]
const registerMenu = function (editor: IDomEditor, toolbarConfig) {  
    const allRegisterMenu = editor.getAllMenuKeys();   
    for (let item of MenusList) {    
        if (allRegisterMenu.indexOf(item.key) < 0) {       
            const menuObj = {        
                key: item.key,        
                factory() {          
                    return new item.class()        
                }      
            }      
            Boot.registerMenu(menuObj);    
        }  
    }
    // 如果注册到菜单栏可以在menulist里定义好index插入进去
    // toolbarConfig.insertKeys = {    
    //   index: MenusList[0].index,    
    //   keys: keys    
    // }
}
export default registerMenu;

回到editor.vue

//editor初始化成功
const handleCreated = (editor: any) => {  
    editorRef.value = editor;  
    console.log('Editor准备就绪:');  
    // 注册自定义menu  
    registerMenu(editor);
    // 监听事件
    editor.on('menuClick', (value: any) => {
        // 执行操作,访问当前vue的emit或者自定义动态事件等等
    });
};

这样就可以在重复进入页面后不会重新注册菜单并且可以访问vue的实例

操作内容切换的问题:

Cannot find a descendant at path [3] in node: {"children":[{"type":"paragraph","children":[{"text":""}]}]

这个问题出现的操作有很多种,这是我遇到的几个注意点:

  • 首先一定要只创建一个实例,退出时编辑器调用编辑器destory的方法,或者v-if强行重新生成新实例。
  • 最好不要使用keep-alive包裹,会缓存对编辑器的操作。
  • 进入编辑器在onCreated时候再去渲染数据,使用v-model双向绑定来插入。
  • 如果这个报错没有影响到显示或者需求不需要别的操作,使用try catch忽略报错也不是不可以。
  • 插入图片视频内容使用insertNode方法。

打字机效果

一般这种情况是使用markdown格式文本,时间原因我们前后端目前没有去处理,后续我们也会去使用markdown,这个目前没有使用markdown的jy可以看看

自己写的打字机效果,时间问题没有去优化,如果各位有更好的方式请忽略

一般插入的数据是一整段html格式文本,我是采用DOMParser解析然后循环插入,为了减少操作,我只处理了两层节点,文本p,标题和列表ul li,然后使用callback进行渲染富文本,sse流接口的话onmessage的时候调用解析文本方法就行。

let nodeList: any = [];  
let time = 400;
// 显示的文本  
const showHtml = ref<string>('');  
// 组合后的文本 html  
let assemblyHtml: string = '';  
// 元数据下标  
let dataIndex = ref<number>(0);  
// 段落文本 text  
let paraText: string = '';  
// 处理中的文本下标  
let textIndex = ref<number>(0);  
// 处理中的文本 html  
let processHtml = ref<string>('');

function handleWriteData(data: any) {    
// 创使用DOMParser解析HTML字符串    
const parser = new DOMParser();    
const doc = parser.parseFromString(data, 'text/html');    
const paragraphs: any = Array.from(doc.body.childNodes);
for (let child of paragraphs) {      
    // 确保我们处理的是元素节点并有子节点(而不是文本节点或其他类型的节点)      
    if (child.nodeType === Node.ELEMENT_NODE 
          && child.childNodes.length && child.childNodes[0]) 
    {        
        // 输出标签名和文本内容          
        let node = child.childNodes[0];        
        if (node.nodeType == 3) {          
            nodeList.push({            
                parNode: '',            
                node: child.tagName.toLowerCase(),            
                text: child.textContent,            
                style: child?.style?.cssText || '',          
            });        
        } else {          
            for (let subChild of child.childNodes) {            
                if (subChild.nodeType === Node.ELEMENT_NODE) {              
                    nodeList.push({                
                        parNode: child.tagName.toLowerCase(),                
                        node: subChild.tagName.toLowerCase(),                
                        text: subChild.textContent,                
                        style: ''              
                    });            
                };          
            };        
        };      
    };    
};
}

// 依次处理文本
function initParaHtml(callback: () => void) {    
    if (!nodeList.length) return    
    let dataItem = nodeList[dataIndex.value]
    // 重制内容用来停止打字    
    if (paraText.length >= dataItem.text.length) {      
        dataIndex.value++      
        paraText = ''      
        textIndex.value = 0      
        processHtml.value = ''    
    }    
    handleTimeoutData(nodeList[dataIndex.value], callback)
};
// 打字效果和倒计时,目前只处理到二级元素层级
function handleTimeoutData(item: any, callback: () => void) {    
    if (!item) return    
    function getVal() {      
        let line = item.text[textIndex.value]      
        if (line) {        
            paraText += line        
            if (item.parNode) {          
                processHtml.value = `
                   <${item.parNode}>
                      <${item.node}>${paraText}</${item.node}>
                   </${item.parNode}>
                  `        
            } else {          
                processHtml.value = `
                   <${item.node} style='${item.style}'>${paraText}</${item.node}>
              `        
            }        
            textIndex.value++        
            setTimeout(getVal, time)        
            showHtml.value = assemblyHtml + processHtml.value        
            callback()      
        } else {        
            assemblyHtml = assemblyHtml + processHtml.value        
            initParaHtml(callback)      
        }    
    }    
    getVal()
};

然后页面操作:

let textValue = '<p>你好,我是富文本内容</p>';
handleWriteData(textValue);  
// 如果在打字中,停止执行打印方法,可以自定义一个值去判断
if (hasTypeWriter.value) return;  
initParaHtml(() => {    
    valueHtml.value = showHtml.value;    
    nextTick(() => {      
        let wscrollBox: Element | null = document.querySelector('.w-e-scroll');
        if (wscrollBox && wscrollBox?.scrollHeight > wscrollBox?.clientHeight) {
            wscrollBox.scrollTop = wscrollBox.scrollHeight;
        };
    });
});

滚动的问题:之前使用光标一直置于文本末,但是会操作编辑器api会导致出现不顺畅的问题,所以我建议打字的时候使编辑器失去焦点,然后获取富文本滚动元素,打字的时候设置top值滚动到底部。

结尾

富文本编辑器这个东西确实全是坑,流泪~~~,如果是大公司有自己开发的就方便许多。

使用后的感受,由于作者时间原因这个库已经不维护了,所以后面框架升级了可能会遇到问题,如果大家只是简单的富文本回显内容可以使用,作者在官网也给出了react,vue框架的demo,可以快速应用到项目中(日常够用,而且确实有些内置api很好用,不用二次开发)。如果大家有充足的时间去开发,不建议大家使用这个库,可以去二次开发quill.js,tinymce等等。