一.各种编辑器分析
Quill
这是另一个常用的富文本编辑器,它提供了许多可定制的功能和事件,并且也有一2个官方的 Vue 3 组件
wangEditor5
wangEditor5用在Vue3中自定义扩展音频、视频、图片菜单;并扩展音频元素节点,保证音频节点的插入、读取、回写功能正常;支持动态修改尺寸
二. vue-quill
官网地址
(一)安装
npm install @vueup/vue-quill@alpha --save
(二)使用
Editor/index.vue
<template>
<div class="editor">
<el-upload
class="avatar-uploader-editor"
action="#"
:before-upload="beforeAvatarUpload"
accept=".jpg, .png, .gif, .jpeg"
:http-request="handleFileChange"
:show-file-list="false"
>
<el-button type="default" style="display: none; font-size: 14px"
><el-icon><UploadFilled /></el-icon>上传图片</el-button
>
</el-upload>
<quill-editor
ref="editorRef"
v-model:content="content"
contentType="html"
@textChange="e => $emit('update:modelValue', content)"
@blur="changeQuillEditor"
:options="options"
:style="styles"
/>
</div>
</template>
<script setup>
import { QuillEditor, Quill } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
const { proxy } = getCurrentInstance()
const props = defineProps({
/* 编辑器的内容 */
modelValue: {
type: String
},
/* 高度 */
height: {
type: Number,
default: null
},
/* 最小高度 */
minHeight: {
type: Number,
default: null
},
/* 只读 */
readOnly: {
type: Boolean,
default: false
}
})
const options = ref({
theme: 'snow',
bounds: document.body,
debug: 'warn',
modules: {
// 工具栏配置
toolbar: {
container: [
['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
['blockquote', 'code-block'], // 引用 代码块
[{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
[{ indent: '-1' }, { indent: '+1' }], // 缩进
[{ size: ['small', false, 'large', 'huge'] }], // 字体大小
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
[{ align: [] }], // 对齐方式
['clean'], // 清除文本格式
['image'] // 链接、图片、视频
],
handlers: {
image: function (value) {
if (value) {
if (props.readOnly) {
return false
}
// 触发input框选择图片文件
document.querySelector('.avatar-uploader-editor input').click()
} else {
Quill.format('image', false)
}
}
}
}
},
placeholder: props.readOnly ? '' : '请输入内容',
readOnly: props.readOnly
// theme: 'snow'
})
const styles = computed(() => {
let style = {}
if (props.minHeight) {
style.minHeight = `${props.minHeight}px`
}
if (props.height) {
style.height = `${props.height}px`
}
return style
})
/**** 上传图片 start */
const editorRef = ref(null)
/**文件上传 限制条件
*
* @param {*} rawFile
*/
function beforeAvatarUpload(rawFile) {
if (rawFile.size / 1024 / 1024 > 5) {
proxy.$modal.msgError('单个文件不能超过5MB!')
return false
}
let quill = toRaw(editorRef.value).getQuill()
// 把图片转成base64
getBase64(rawFile, url => {
let length = quill.selection.savedRange.index
// 插入图片,res为服务器返回的图片链接地址
quill.insertEmbed(length, 'image', url)
})
}
//工具函数
const getBase64 = (img, callback) => {
const reader = new FileReader()
reader.addEventListener('load', () => callback(reader.result))
reader.readAsDataURL(img)
}
//
async function handleFileChange(params) {
let formData = new FormData()
formData.append('file', params.file)
}
/**** 上传图片 end */
const content = ref('')
watch(
() => props.modelValue,
v => {
if (v !== content.value) {
content.value = v === undefined ? '<p></p>' : v
}
},
{ immediate: true }
)
/** 鼠标移开 */
const emit = defineEmits(['myClick'])
const changeQuillEditor = () => {
emit('myClick', content.value)
}
</script>
<style>
.editor,
.ql-toolbar {
white-space: pre-wrap !important;
line-height: normal !important;
}
.quill-img {
display: none;
}
.ql-snow .ql-tooltip[data-mode='link']::before {
content: '请输入链接地址:';
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0px;
content: '保存';
padding-right: 0px;
}
.ql-snow .ql-tooltip[data-mode='video']::before {
content: '请输入视频地址:';
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: '14px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
content: '10px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
content: '18px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
content: '32px';
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: '文本';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
content: '标题1';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
content: '标题2';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
content: '标题3';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
content: '标题4';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
content: '标题5';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
content: '标题6';
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: '标准字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
content: '衬线字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
content: '等宽字体';
}
</style>
(三)功能注意点
- 通过按钮上传图片,图片不能到指定位置
解决:
let quill = toRaw(editorRef.value).getQuill()
let length = quill.selection.savedRange.index
// 插入图片,res为服务器返回的图片链接地址
quill.insertEmbed(length, 'image', url)
- 清除编辑器的表单验证
<template>
<div class="myEditor">
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
label-width="100px"
>
<editor
v-model="formModel.detail"
:min-height="192"
@myClick="changeText"
:readOnly="formModel.id ? true : false"
class="w-100%"
/>
</el-form>
</div>
</template>
<script setup name="myEditor">
/** 清除编辑器的表单验证 */
const changeText = text => {
if ((text && text == '<p></p>') || text == '<p><br></p>') {
formModel.value.detail = null
proxy.$refs['formRef'].validateField('detail')
return false
}
if (formModel.value.detail) {
proxy.$refs['formRef'].clearValidate('detail') // clearValidate()取消验证方法
}
}
</script>
(四)参考
- vue3使用vueup/vue-quill富文本、并限制输入字数
三. wangeditor5
官网地址
(一) 安装
yarn add @wangeditor/editor
或者 npm install @wangeditor/editor --save
yarn add @wangeditor/editor-for-vue@next
或者 npm install @wangeditor/editor-for-vue@next --save
(二) 常见api
wangEditor 提供了丰富的 API ,可以进行任何编辑器操作。可参考文档
const editor = editorRef.value
- 插入内容文本:
editor.insertText(' 222 ')
- 插入节点:
import { SlateTransforms } from '@wangeditor/editor'
const node2 = [
{
type: 'video',
src: 'https://www.runoob.com/try/demo_source/horse.mp3',
children: [{ text: 'bbb' }]
}
]
SlateTransforms.insertNodes(editor, node2)
- 获取所有已注册的菜单
editor.getAllMenuKeys();
- 获取html
editor.getHtml()
- 获取所有配置参数
editor.getConfig()
-
(三) 配置
1. 编辑器配置
支持 readOnly autoFocus maxLength 等配置,可参考文档。
// 编辑器配置
const editorConfig = {
placeholder: '请输入内容...',
readOnly: props.readonly,
autoFocus: false,
scroll: true,
// 可继续其他配置...
MENU_CONF: { /* 菜单配置 */ }
}
请注意,该文档中的所有回调函数,都不能以配置的形式传入,如 onCreated onChange onDestroyed 等。这些回调函数必须以 Vue 事件的方式传入。
<Editor
:editorId="editorId"
:defaultConfig="editorConfig"
:defaultContent="defaultContent"
:defaultHtml="defaultHtml"
style="height: 500px"
<!-- 回调函数,以 Vue 事件形式 -->
@onCreated="handleCreated"
@onChange="handleChange"
@onDestroyed="handleDestroyed"
@onFocus="handleFocus"
@onBlur="handleBlur"
@customAlert="customAlert"
@customPaste="customPaste"
/>
2.工具栏配置
修改工具栏的菜单,如隐藏某些菜单,重新排序分组,就可以使用该配置。支持 toolbarKeys 和 excludeKeys,可参考文档。
/ 工具栏配置
const toolbarConfig = {
toolbarKeys: [ /* 显示哪些菜单,如何排序、分组 */
'undo', // 撤销
'enter', // 回车
'bulletedList', // 无序列表
'numberedList',// 有序列表
'insertTable',// 插入table
// 菜单组,包含多个菜单
{
key: 'group-more-style', // 必填,要以 group 开头
title: '更多样式', // 必填
iconSvg:
'<svg viewBox="0 0 1024 1024"><path d="M959.877 128l0.123 0.123v767.775l-0.123 0.122H64.102l-0.122-0.122V128.123l0.122-0.123h895.775zM960 64H64C28.795 64 0 92.795 0 128v768c0 35.205 28.795 64 64 64h896c35.205 0 64-28.795 64-64V128c0-35.205-28.795-64-64-64zM832 288.01c0 53.023-42.988 96.01-96.01 96.01s-96.01-42.987-96.01-96.01S682.967 192 735.99 192 832 234.988 832 288.01zM896 832H128V704l224.01-384 256 320h64l224.01-192z"></path></svg>', // 可选
menuKeys: ['insertImage', 'uploadImage', 'materialImage'] // 下级菜单 key ,必填
}],
excludeKeys: [ /* 隐藏哪些菜单 */],
}
<Toolbar
:editorId="editorId"
:defaultConfig="toolbarConfig" <!-- 传入配置 -->
style="border-bottom: 1px solid #ccc"
/>
3.菜单配置
对某个菜单进行配置,例如配置颜色、字体、字号,配置上传图片的 API 地址等,可以使用菜单配置。具体参考文档。
const editorConfig = computed(() => {
return Object.assign({
placeholder: '请输入内容...',
readOnly: props.readonly,
autoFocus: false,
scroll: true,
bgColor:{
colors: ['#000', '#333', '#666']
},
MENU_CONF: {
// 上传本地图片
uploadImage: {
/**
*
* @param {*} file 文件
* @param {*} insertFn 输入到编辑器
*/
async customUpload(file, insertFn) {
if (file.size / 1024 / 1024 > 5) {
customAlert('单个文件不能超过5MB!', 'warning')
return false
}
// 把图片转成base64
getBase64(file, url => {
insertFn(url)
})
}
},
// 上传本地视频
uploadVideo: {
async customUpload(file, insertFn) {
const url = 'https://www.runoob.com/try/demo_source/movie.ogg'
insertFn(url)
}
}
}
})
})
(四) 自定义扩展新功能
1. 注册新菜单
ButtonMenu
MyButtonMenu.js
class MyButtonMenu {
constructor() {
this.title = '按钮菜单';
this.tag = 'button'
}
// 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
return false
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
return ''
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
return false;
}
//点击菜单时触发的函数
exec(editor, value) {
if (this.isDisabled(editor)) {
return;
}
editor.emit('MyButtonMenuClick');
}
}
export default MyButtonMenu
ModalMenu
MyModalMenu.js
class MyModalMenu {
constructor() {
this.title = '弹出框菜单';
this.tag = 'button'
this.showModal = true
this.modalWidth = 300
this.iconSvg = '<svg t="1688026657351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4449" width="128" height="128"><path d="M609.28 481.28L481.28 399.36c-10.24-5.12-25.6-5.12-35.84 0-15.36 5.12-20.48 15.36-20.48 30.72v158.72c0 15.36 10.24 25.6 20.48 30.72 5.12 0 10.24 5.12 15.36 5.12 5.12 0 15.36 0 20.48-5.12l128-81.92c10.24-5.12 15.36-15.36 15.36-30.72 0-5.12-5.12-15.36-15.36-25.6zM476.16 563.2V455.68L563.2 512l-87.04 51.2z m0 0" p-id="4450" fill="#515151"></path><path d="M824.32 737.28h-51.2v-409.6c0-40.96-35.84-76.8-76.8-76.8h-409.6v-51.2c0-15.36-10.24-25.6-25.6-25.6s-25.6 10.24-25.6 25.6v51.2h-51.2c-15.36 0-25.6 10.24-25.6 25.6s10.24 25.6 25.6 25.6h51.2v409.6c0 40.96 35.84 76.8 76.8 76.8h409.6v51.2c0 15.36 10.24 25.6 25.6 25.6s25.6-10.24 25.6-25.6v-51.2h51.2c15.36 0 25.6-10.24 25.6-25.6s-10.24-25.6-25.6-25.6z m-506.88 0c-15.36 0-25.6-10.24-25.6-25.6v-409.6h409.6c15.36 0 25.6 10.24 25.6 25.6v409.6h-409.6z m0 0" p-id="4451" fill="#515151"></path></svg>'
}
// 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
return false
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
return ''
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
return false;
}
//点击菜单时触发的函数
exec(editor, value) {
if (this.isDisabled(editor)) {
return;
}
editor.emit('setModelClick');
}
// 弹出框 modal 的定位:1. 返回某一个 SlateNode; 2. 返回 null (根据当前选区自动定位)
getModalPositionNode(editor) {
return null // modal 依据选区定位
}
// 定义 modal 内部的 DOM Element
getModalContentElem(editor) {
//这里面的内容除了编辑器里面的函数其它全是html+css动态创建标签
const parentDiv = document.createElement("div");
parentDiv.className = 'CssClass0';//设置css样式
parentDiv.style.cssText = "color:#333";
const updatabutton1 = document.createElement("button");
const h2 = document.createElement("h2");
const updatabutton = document.createElement("button");
const file1 = document.createElement("input");
file1.style.cssText = "display:block;";
h2.innerText = "大文件上传";
updatabutton.innerText = "插入";
updatabutton1.innerText = "上传";
parentDiv.appendChild(h2);
parentDiv.appendChild(file1);
parentDiv.appendChild(updatabutton);
parentDiv.appendChild(updatabutton1);
//点击后删除事件
function uploadBtnEvent() {
editor.focus();//先获得焦点,再插入,就能成功
editor.dangerouslyInsertHtml("<a href='#'>百度</a>", true);;
editor.hidePanelOrModal();
}
//添加事件
updatabutton.addEventListener('click', uploadBtnEvent)
return parentDiv
}
}
export default MyModalMenu
SelectMenu
MySelectMenu.js
class MySelectMenu {
constructor() {
this.title = 'audio'
this.tag = 'select'
this.width = 60
}
getOptions(editor) {
const options = [
{ value: 'beijing', text: '北京', styleForRenderMenuList: { 'font-size': '32px', 'font-weight': 'bold' } },
{ value: 'shanghai', text: '上海', selected: true },
{ value: 'shenzhen', text: '深圳' }
]
return options
}
getValue(editor) {
return 'shanghai' // 匹配 options 其中一个 value
}
isActive(editor) {
return false // or true
}
isDisabled(editor) {
return false // or true
}
exec(editor, value) {
editor.insertText(value) // value 即 this.getValue(editor) 的返回值
editor.insertText(' ')
}
}
export default MySelectMenu
DropPanelMenu
AudioMenu.js
class AudioMenu {
constructor() {
this.title = 'Audio'
this.tag = 'button'
this.showDropPanel = true
}
getValue(editor) {
return ''
}
isActive(editor) {
return false // or true
}
isDisabled(editor) {
return false // or true
}
exec(editor, value) {
// do nothing 什么都不用做
}
getPanelContentElem(editor) {
//这里面的内容除了编辑器里面的函数其它全是html+css动态创建标签
const parentDiv = document.createElement("div");
parentDiv.className = 'dropPanelSelStyle';//设置css样式
const btn1 = createButton("网络音频", '<svg t="1688026336282" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1516" width="128" height="128"><path d="M876.15 960h-728.3C66.33 960 0 894.1 0 813.11V209.89C0 128.9 66.33 63 147.85 63h728.29C957.67 63 1024 128.9 1024 209.89V813.1c0 81-66.33 146.9-147.85 146.9z m-728.3-822.56c-40.21 0-72.93 32.5-72.93 72.45V813.1c0 39.95 32.71 72.45 72.93 72.45h728.29c40.21 0 72.93-32.5 72.93-72.45V209.89c0-39.95-32.71-72.45-72.93-72.45H147.85z m0 0" p-id="1517"></path><path d="M693 327.37v271.77c0 34.99-35.51 67.91-79.05 73.22-43.55 5.32-79.06-16.98-79.06-51.97 0-34.99 35.51-67.91 79.06-73.23 29.67-3.76 46.13 5.84 46.13 5.84V398c0-16.46-18.01-10.63-18.01-10.63l-162.91 50.94s-18.53 6.87-18.53 22.29v187.53c0 34.99-32.4 67.39-75.95 73.74-43.54 6.35-79.05-15.42-79.05-50.41 0-35.12 34.99-68.56 79.05-74.91 29.81-4.28 43.03 4.8 43.03 4.8V407.07c0-22.29 18.02-45.62 40.3-52.1l184.16-56.24c22.3-6.35 40.31 6.35 40.83 28.64z" p-id="1518" data-spm-anchor-id="a313x.7781069.0.i0" class="selected" fill="#2c2c2c"></path></svg>', () => {
editor.emit('audioNetworkClick');
}, { "class": "btn" });
const btn2 = createButton("上传音频", '<svg t="1688026632239" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4018" width="128" height="128"><path d="M465.49 363.13c0-21.44 17.33-38.82 38.54-38.82 21.47 0 38.8 17.38 38.8 38.82v440.98c0 21.47-17.33 38.54-38.8 38.54-21.21 0-38.54-17.07-38.54-38.54V363.13z m352.87 303.89c0-21.44 17.1-39.08 38.54-39.08 21.47 0 39.1 17.64 39.1 39.08v89.22h89.18c21.47 0 38.82 17.33 38.82 38.8 0 21.16-17.35 38.54-38.82 38.54H896v89.46c0 21.46-17.63 38.54-39.1 38.54-21.44 0-38.54-17.08-38.54-38.54v-89.46h-89.71c-21.19 0-38.57-17.38-38.57-38.54 0-21.47 17.38-38.8 38.57-38.8h89.71v-89.22zM288.23 537.39c0-21.5 17.33-38.82 39.05-38.82 21.49 0 38.54 17.32 38.54 38.82v266.72c0 21.47-17.05 38.54-38.54 38.54-21.72 0-39.05-17.07-39.05-38.54V537.39zM38.82 961.58C17.35 961.58 0 944.5 0 923.04c0-21.47 17.35-38.54 38.82-38.54h555.76c21.47 0 38.82 17.07 38.82 38.54 0 21.46-17.35 38.54-38.82 38.54H38.82z m72.66-663.13c0-21.19 17.63-38.54 39.1-38.54 20.91 0 38.54 17.35 38.54 38.54v505.66c0 21.47-17.63 38.54-38.54 38.54-21.47 0-39.1-17.07-39.1-38.54V298.45z m708-80.67c0-21.47 17.61-39.07 38.52-39.07 21.49 0 39.1 17.61 39.1 39.07v309.96c0 20.91-17.61 38.54-39.1 38.54-20.91 0-38.52-17.63-38.52-38.54V217.78zM642.47 96.14c0-21.47 17.35-38.54 38.54-38.54 21.75 0 38.82 17.07 38.82 38.54v462.44c0 21.47-17.07 38.83-38.82 38.83-21.19 0-38.54-17.35-38.54-38.83V96.14z" fill="#515151" p-id="4019"></path></svg>', () => {
editor.emit('audioLocalClick');
}, { "class": "btn" });
const btn3 = createButton("素材上传", '<svg t="1688026657351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4449" width="128" height="128"><path d="M609.28 481.28L481.28 399.36c-10.24-5.12-25.6-5.12-35.84 0-15.36 5.12-20.48 15.36-20.48 30.72v158.72c0 15.36 10.24 25.6 20.48 30.72 5.12 0 10.24 5.12 15.36 5.12 5.12 0 15.36 0 20.48-5.12l128-81.92c10.24-5.12 15.36-15.36 15.36-30.72 0-5.12-5.12-15.36-15.36-25.6zM476.16 563.2V455.68L563.2 512l-87.04 51.2z m0 0" p-id="4450" fill="#515151"></path><path d="M824.32 737.28h-51.2v-409.6c0-40.96-35.84-76.8-76.8-76.8h-409.6v-51.2c0-15.36-10.24-25.6-25.6-25.6s-25.6 10.24-25.6 25.6v51.2h-51.2c-15.36 0-25.6 10.24-25.6 25.6s10.24 25.6 25.6 25.6h51.2v409.6c0 40.96 35.84 76.8 76.8 76.8h409.6v51.2c0 15.36 10.24 25.6 25.6 25.6s25.6-10.24 25.6-25.6v-51.2h51.2c15.36 0 25.6-10.24 25.6-25.6s-10.24-25.6-25.6-25.6z m-506.88 0c-15.36 0-25.6-10.24-25.6-25.6v-409.6h409.6c15.36 0 25.6 10.24 25.6 25.6v409.6h-409.6z m0 0" p-id="4451" fill="#515151"></path></svg>', () => {
editor.emit('materialClick', 'audio');
}, { "class": "btn" });
parentDiv.appendChild(btn1);
parentDiv.appendChild(btn2);
parentDiv.appendChild(btn3);
return parentDiv
}
}
// 创建按钮
function createButton(text, svgText, method, attribute) {
var button = document.createElement("button");
button.innerHTML += svgText + '<span class="title">' + text + '</span>'
button.addEventListener("click", method);
// 添加额外属性
for (var key in attribute) {
button.setAttribute(key, attribute[key]);
}
return button;
}
export default AudioMenu
上面定义几种模式的菜单后,注册菜单到wangEditor ,再插入菜单到工具栏
import { Boot } from "@wangeditor/editor"
/*** 自定义扩展菜单工具栏功能 */
import MyButtonMenu from "./MyButtonMenu";
import MySelectMenu from "./MySelectMenu";
import AudioMenu from './AudioMenu'
import MyModalMenu from './MyModalMenu'
const MenusList = [
{
key: 'MyButtonMenu',
class: MyButtonMenu,
title: '按钮菜单',
iseparate: false,//是否单独一行
index: 24 // 菜单要在工具栏显示的位置
},
{
key: 'MyModalMenu',
class: MyModalMenu,
title: '弹出框菜单',
iseparate: false,//是否单独一行
index: 25 // 菜单要在工具栏显示的位置
},
{
key: 'MySelectMenu',
class: MySelectMenu,
title: '下拉框选择',
iseparate: false,//是否单独一行
index: 26
},
{
key: 'AudioMenu',
class: AudioMenu,
title: '上传音频',
iseparate: false,//是否单独一行
index: 27
},
]
/**
* 自定义扩展菜单工具栏
* @param {*} editor 编辑器
* @param {*} toolbarConfig 工具栏
*/
const registerMenu = function (editor, toolbarConfig) {
const allRegisterMenu = editor.getAllMenuKeys(); // 获取所有已注册的菜单
let keys = [];
for (let item of MenusList) {
if (allRegisterMenu.indexOf(item.key) < 0) { // 1.如果未注册,则注册
const menuObj = {
key: item.key,
factory() {
return new item.class()
}
}
Boot.registerMenu(menuObj);
}
if (item.iseparate) {
//如果是单行的则注册在toolbar
keys.push(item.key)
}
}
//2. 插入菜单到工具栏
toolbarConfig.insertKeys = {
index: MenusList[0].index,
keys: keys
}
}
export default registerMenu
创建编辑器时 注册
import registerMenu from './toolbars/index'
const initMediaMenuEvent = () => {
const editor = editorRef.value
editor.on('MyButtonMenuClick', () => {
console.log('按钮菜单')
})
/*** 音频模块 start */
editor.on('audioNetworkClick', () => {
audioNetwork.value.isVisible = true
editor.hidePanelOrModal()
})
editor.on('audioLocalClick', () => {
// 本地音频
document.querySelector('.avatar-uploader-editor input').click() //触发input框选择图片文件
})
/**
*素材音频以及素材上传
* type 类型
*/
editor.on('materialClick', type => {
// 素材音频
materialParam.value.isVisible = true
materialParam.value.type = type
switch (type) {
case 'audio':
editor.insertBreak() //换行
materialParam.value.title = '请选择音频'
break
case 'image':
materialParam.value.title = '请选择图片'
break
case 'video':
editor.insertBreak() //换行
materialParam.value.title = '请选择视频'
break
}
editor.hidePanelOrModal()
})
}
//创建实例触发的事件
const handleCreated = editor => {
editorRef.value = editor // 记录 editor 实例,重要!
registerMenu(editor, toolbarConfig) // 注册自定义菜单
initMediaMenuEvent() // 注册自定义菜单点击事件
}
注意:
- 必须在创建编辑器之前注册。
- 全局只能注册一次,不要重复注册
(五) 简单使用
<template>
<div style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
style="height: 500px; overflow-y: hidden"
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
/>
</div>
</template>
<script setup name="MyEditor">
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { onBeforeUnmount, ref, shallowRef, onMounted } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
const { proxy } = getCurrentInstance()
const props = defineProps({
/* 编辑器的内容 */
modelValue: {
type: String
},
/*** 编辑器id */
editorId: {
type: String,
default: 'wangeEditor-1'
},
/**配置项 */
setEditorToolbar: {
type: Array,
default: () => undefined
},
/* 高度 */
height: {
type: Number,
default: 500
},
/* 只读 */
readonly: {
type: Boolean,
default: false
}
})
// 编辑器内容 html
const valueHtml = ref(null)
/******************************************* 配置 start *****************/
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
const mode = ref('default')
/**** 工具栏配置 */
const toolbarConfig = {}
/**** 编辑器配置 */
const editorConfig = {
placeholder: '请输入内容1...',
MENU_CONF: {
// 上传本地图片
uploadImage: {
/*** 插件自带上传功能*/
// server: 'http://192.168.3.56:81/dev-api/file/attachFile/attachUpload',
// fieldName: 'file',
// maxFileSize: 1 * 1024 * 1024, // 1M
// allowedFileTypes: ['.jpg, .png, .gif, .jpeg'],
// base64LimitSize: 5 * 1024,
// maxNumberOfFiles: 1,
// headers: {
// Authorization: 'Bearer ' +'eyJhbGciOiJIUzUxMi....A'
// },
// // 单个文件上传失败
// onFailed(file, res) {
// console.log(`${file.name} 上传失败`, res)
// },
// onSuccess(file, res) {
// console.log(`${file.name} 上传成功`, res)
// },
// // 上传错误,或者触发 timeout 超时
// onError(file, err, res) {
// proxy.$modal.msgError(res)
// console.log(res)
// }
/**自定义上传功能
*
* @param {*} file 文件
* @param {*} insertFn 输入到编辑器
*/
async customUpload(file, insertFn) {
console.log(editorRef.value.getConfig(), '---')
if (file.size / 1024 / 1024 > 5) {
proxy.$modal.msgError('单个文件不能超过5MB!')
return false
}
// 把图片转成base64
getBase64(file, url => {
insertFn(url)
})
}
},
// 上传本地视频
uploadVideo: {
maxNumberOfFiles: 0,
customUpload(file, insertFn) {
const url = 'https://www.runoob.com/try/demo_source/movie.ogg'
insertFn(url)
}
}
}
}
//工具函数 图片转出base64
const getBase64 = (img, callback) => {
const reader = new FileReader()
reader.addEventListener('load', () => callback(reader.result))
reader.readAsDataURL(img)
}
//创建实例触发的事件
const handleCreated = editor => {
editorRef.value = editor // 记录 editor 实例,重要!
}
/******************************************* 配置 end *****************/
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
watch(
() => props.modelValue,
v => {
if (v !== valueHtml.value) {
valueHtml.value = v === undefined ? '<p></p>' : v
}
},
{ immediate: true }
)
</script>
(六) 注意点
- editorRef 必须用 shallowRef
- 组件销毁时,要及时销毁编辑器
(七) 功能点不足
- customUpload自定义上传跟插件自带的上传不能一起使用;插件自定义上传插件必须填写server
- 插件自带上传功能中maxNumberOfFiles 没有效果
- 网络上传图片和视频 只有该文件允许访问 才可以上传成功【但是WangEditor并没有报错提示,只是编辑器内容展示不了】
- 不兼容音频插入
i.懒人开发。
其实在HTML中,音频文件也可以直接使用video标签播放,所以对于不想折腾的读者,可以直接使用video插入视频的方式来实现播放音频。
const node = [
{
type: ‘video’,
src: ‘https://www.runoob.com/try/demo_source/horse.mp3’,
children: [{ text: ‘bbb’ }]
}
]
SlateTransforms.insertNodes(editor, node)
ii. 费脑子开发
这里要注意,这边会涉及到wangEditor中ModalMenu、插件、新元素等方面的内容,具体可以参考官方文档。这边所涉及的源代码是在wangeditor的video源码的上做更改的。涉及多个文件。记得安装snabbdom.js这个包。
- 插入视频(自定义如音频走视频的模式)SlateTransforms.insertNodes(editor, node) 找不到鼠标在编辑器内焦点 ,而是直接把节点插入到后面【我找了很多方式不行,目前是通过获取鼠标的上个节点位置,然后插入】
- 自定义编辑器alert -customAlert 不行【我照着官网弄,但是没效果,具体不知道为啥 ,后期在研究】
(八) 最终效果的演示视频
wangEditor5+vue3编辑器
(九) 参考
- wangEditor5在Vue3中的自定义图片+视频+音频菜单
- vue3+ts+wangEditor5菜单栏添加自定义图标按钮
- wangEditor富文本框的使用