一.各种编辑器分析
Quill
这是另一个常用的富文本编辑器,它提供了许多可定制的功能和事件,并且也有一2个官方的 Vue 3 组件
wangEditor5
wangEditor5用在Vue3中自定义扩展音频、视频、图片菜单;并扩展音频元素节点,保证音频节点的插入、读取、回写功能正常;支持动态修改尺寸
ueditor
UEditor是由百度web前端研发部开发所见即所得富文本web编辑器,具有轻量,可定制,注重用户体验等特点,开源基于MIT协议,允许自由使用和修改代码。
二. 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', 'video'] // 链接、图片、视频
],
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()
- 获取鼠标以上所有配置
editorRef.value.getConfig().hoverbarKeys
(三) 配置
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
editImage(hoverbarKeys 上的菜单)–图片可以设置宽高
import {
IModalMenu,
IDomEditor,
DomEditor,
genModalInputElems,
genModalButtonElems,
t
} from '@wangeditor/core'
import { Node, Range } from 'slate'
import { Dom7Array } from 'dom7'
import { updateImageNode } from '../utils/helper'
import $ from '../utils/dom'
import { genRandomStr, getWH } from '../utils/index'
import { ImageElement, ImageStyle } from '../utils/custom-types'
/**
* 生成唯一的 DOM ID
*/
function genDomID(): string {
return genRandomStr('w-e-edit-image')
}
class EditImage{
private $content: Dom7Array | null = null
private readonly srcInputId = genDomID()
private readonly hrefInputId = genDomID()
private readonly widthId = genDomID()
private readonly heightId = genDomID()
private readonly buttonId = genDomID()
constructor() {
this.title = '修改图片'
this.tag = 'button'
this.showModal = true
this.modalWidth = 300
this.iconSvg =
'<svg t="1693205321165" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4106" width="32" height="32"><path d="M358.165868 554.624796c-0.533143 0.680499-1.066285 1.391696-1.303692 2.251274l-41.104163 150.700257c-2.400676 8.772804 0.059352 18.226107 6.550183 24.892947 4.860704 4.742001 11.261485 7.350408 18.077727 7.350408 2.252297 0 4.504594-0.267083 6.727215-0.860601l149.630902-40.808428c0.23843 0 0.357134 0.207731 0.534166 0.207731 1.718131 0 3.408633-0.62217 4.683672-1.927909l400.113747-400.054395c11.883655-11.897981 18.404162-28.109198 18.404162-45.74281 0-19.989263-8.476045-39.963177-23.324218-54.767348l-37.786605-37.844933c-14.81645-14.848173-34.822087-23.338544-54.797024-23.338544-17.631566 0-33.842783 6.520507-45.75816 18.388812L358.758362 553.232077C358.344946 553.615816 358.462626 554.179658 358.165868 554.624796M862.924953 257.19778l-39.742143 39.71349-64.428382-65.451688 39.180348-39.179324c6.193049-6.222725 18.194384-5.318122 25.308409 1.822508l37.813211 37.845956c3.943822 3.941775 6.195096 9.18622 6.195096 14.372336C867.223862 250.574942 865.710392 254.42769 862.924953 257.19778M429.322487 560.907896l288.712541-288.728914 64.459081 65.49569L494.314711 625.838721 429.322487 560.907896zM376.718409 677.970032l20.863167-76.580143 55.656601 55.657624L376.718409 677.970032z" fill="#595959" p-id="4107"></path><path d="M888.265084 415.735539c-15.144932 0-27.562752 12.313443-27.620058 27.665083l0 372.98283c0 19.559475-15.885805 35.444257-35.475979 35.444257L194.220958 851.827709c-19.559475 0-35.504632-15.883759-35.504632-35.444257L158.716326 207.602222c0-19.575848 15.945157-35.474956 35.504632-35.474956l406.367171 0c15.231913 0 27.592428-12.371772 27.592428-27.606755 0-15.202237-12.360516-27.590382-27.592428-27.590382L190.013123 116.930129c-47.684022 0-86.49291 38.779212-86.49291 86.49291L103.520213 820.59231c0 47.713698 38.808888 86.47756 86.49291 86.47756l639.334083 0c47.715745 0 86.509283-38.763862 86.509283-86.47756L915.856489 443.222567C915.794068 428.048983 903.408993 415.735539 888.265084 415.735539" fill="#595959" p-id="4108"></path></svg>'
}
// 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
return false
}
// 获取当前选择的图片节点信息
getImageNode(editor: IDomEditor): Node | null {
return DomEditor.getSelectedNodeByType(editor, 'image')
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
return ''
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
const { selection } = editor
if (selection == null) return true
if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用
const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
// 未匹配到 image node 则禁用
if (imageNode == null) return true
return false
}
//点击菜单时触发的函数
exec(editor, value) {
// 点击菜单时,弹出 modal 之前,不需要执行其他代码
// 此处空着即可
}
// 弹出框 modal 的定位:1. 返回某一个 SlateNode; 2. 返回 null (根据当前选区自动定位)
getModalPositionNode(editor: IDomEditor): Node | null {
return this.getImageNode(editor)
}
// 定义 modal 内部的 DOM Element
getModalContentElem(editor: IDomEditor) {
const { srcInputId, altInputId, hrefInputId, widthId, heightId, buttonId } =
this
const selectedImageNode = this.getImageNode(editor)
if (selectedImageNode == null) {
throw new Error('Not found selected image node')
}
// 获取 input button elem
const [srcContainerElem, inputSrcElem] = genModalInputElems(
t('图片地址'),
srcInputId
)
const $inputSrc = $(inputSrcElem)
const [hrefContainerElem, inputHrefElem] = genModalInputElems(
t('图片连接'),
hrefInputId
)
const $inputHref = $(inputHrefElem)
const [wContainerElem, inputWElem] = genModalInputElems(
t('图片宽度'),
widthId
)
const $inputW = $(inputWElem)
const [hContainerElem, inputHElem] = genModalInputElems(
t('图片高度'),
heightId
)
const $inputH = $(inputHElem)
const [buttonContainerElem] = genModalButtonElems(buttonId, t('确定'))
if (this.$content == null) {
// 第一次渲染
const $content = $('<div></div>')
// 绑定事件(第一次渲染时绑定,不要重复绑定)
$content.on('click', `#${buttonId}`, e => {
e.preventDefault()
const src = $content.find(`#${srcInputId}`).val()
const href = $content.find(`#${hrefInputId}`).val()
const width = $content.find(`#${widthId}`).val()
const height = $content.find(`#${heightId}`).val()
const style = {
width: width + 'px',
height: height + 'px'
}
this.updateImage(editor, src, alt, href, style)
editor.hidePanelOrModal() // 隐藏 modal
})
// 记录属性,重要
this.$content = $content
}
const $content = this.$content
$content.empty() // 先清空内容
// append inputs and button
$content.append(srcContainerElem)
$content.append(hrefContainerElem)
$content.append(wContainerElem)
$content.append(hContainerElem)
$content.append(buttonContainerElem)
// 设置 input val
const { src, alt = '', href = '' } = selectedImageNode as ImageElement
$inputSrc.val(src)
// $inputAlt.val(alt)
$inputHref.val(href)
const { width, height } = getWH(editor.getHtml(), src)
const w = width ? width.replace('px', '') : width
$inputW.val(w)
const h = height ? height.replace('px', '') : height
$inputH.val(h)
// focus 一个 input(异步,此时 DOM 尚未渲染)
setTimeout(() => {
$inputSrc.focus()
})
return $content[0]
}
private updateImage(
editor: IDomEditor,
src: string,
alt = '',
href = '',
style: ImageStyle = {}
) {
if (!src) return
// 还原选区
editor.restoreSelection()
if (this.isDisabled(editor)) return
// 修改图片信息
updateImageNode(editor, src, alt, href, style)
}
}
export default EditImage
上面定义几种模式的菜单后,注册菜单到wangEditor ,再插入菜单到工具栏
import { Boot } from "@wangeditor/editor"
/*** 自定义扩展菜单工具栏功能 */
import MyButtonMenu from "./MyButtonMenu";
import MySelectMenu from "./MySelectMenu";
import AudioMenu from './AudioMenu'
import EditImagefrom './EditImage'
import MyModalMenu from './MyModalMenu'
const MenusList = [
{
key: 'EditImage',
class: EditImage,
title: '修改图片',
iseparate: false, //是否单独一行
index: 24 // 菜单要在工具栏显示的位置
},
{
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>
<MyEditor
v-model="activeNewsItem.content"
:setEditorToolbar="editorConfig"
@previewPage="previewPageClick"
/>
</div>
</template>
<script setup name="ProjectList">
import MyEditor from '@/components/MyEditor'
const activeNewsItem = ref({
content: '3'
})
const editorConfig = ref([])
/**
* 预览页面
*/
const previewPageClick = () => {
console.log('预览 获取html', activeNewsItem.value.content)
}
</script>
MyEditor组件
MyEditor/index.vye
<template>
<div>
<!-- 编辑器 -->
<div class="wangeditorStyle border-1 border-solid border-#ccc z-99">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:editorId="editorId"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
:style="styles"
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
:editorId="editorId"
@onCreated="handleCreated"
@onChange="handleChange"
@onBlur="handleBlur"
/>
</div>
<!-- 上传本地文件 start -->
<el-upload
class="avatar-uploader-editor"
action="#"
:before-upload="beforeAvatarUpload"
accept=".mp3, .wma, .Aac, .ogg, .mpc"
:show-file-list="false"
>
<el-button type="default" style="display: none; font-size: 14px"
><el-icon><UploadFilled /></el-icon>上传</el-button
>
</el-upload>
<!-- 网络音频 -->
<!-- 素材上传 -->
</div>
</template>
<script setup name="MyEditor">
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { onBeforeUnmount, ref, shallowRef, onMounted, computed } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { SlateTransforms } from '@wangeditor/editor'
import registerMenu from './toolbars/index'
import setToolbarKeys from './utils/setConfig'
import { getBase64,getSelection } from './utils/index'
import { isNumber } from '@/utils/validate'
/*** 自定义插件 */
import customAlert from './plugin/customAlert'
// 业务组件
import NetworkModel from './editorComponents/NetworkModel.vue'
import MaterialModel from './editorComponents/materialModel.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
}
})
const emit = defineEmits(['change', 'update:modelValue', 'previewPage'])
// 编辑器内容 html
const valueHtml = ref(null)
/******************************************* 配置 start *****************/
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
const mode = ref('default')
/**** 工具栏配置 */
const toolbarConfig = {
toolbarKeys:
props.setEditorToolbar && props.setEditorToolbar.length > 0
? props.setEditorToolbar
: setToolbarKeys
}
/**** 编辑器配置 */
const editorConfig = computed(() => {
return Object.assign({
placeholder: '请输入内容...',
readOnly: props.readonly,
autoFocus: false,
scroll: true,
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)
}
}
}
})
})
/**** 自定义扩展新功能 */
//注册自定义菜单点击事件 事件监听
const audioNetwork = ref({})
const materialParam = ref({})
const initMediaMenuEvent = () => {
const editor = editorRef.value
/*** 音频模块 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 => {
// 素材音频....
})
/**预览页面 */
editor.on('PreviewPageClick', () => {
emit('previewPage', 'previewPage')
})
}
/**
* 弹出框 确定回调事件
* @param {*} row 获取的参数
*/
const modelValOK = row => {
const editor = editorRef.value
let node = []
let current_path = null
switch (row.type) {
case 'audioNetwork':
// 网络音频
current_path = getSelection()
audioNetwork.value.isVisible = false
if (!row.data.url) {
return false
}
node.push({
type: 'video',
poster: row.data.poster || '',
src: row.data.url,
children: [{ text: 'ddd' }] // 该字段必须要
})
SlateTransforms.insertNodes(editor, node, { at: [current_path] })
break
case 'image':
// 素材图片
materialParam.value.isVisible = false
if (!row.data || row.data.length == 0) {
return false
}
node = JSON.parse(JSON.stringify(row.data))
node.map(value => {
value.type = 'image'
value.src = value.url
value.children = [{ text: value.name }] // 该字段必须要
})
SlateTransforms.insertNodes(editor, node)
break
default:
current_path = getSelection()
// 素材音频 视频
materialParam.value.isVisible = false
if (!row.data || row.data.length == 0) {
editor.undo() //撤销
return false
}
node = JSON.parse(JSON.stringify(row.data))
node.map(value => {
value.type = 'video'
value.src = value.url
value.children = [{ text: value.name }] // 该字段必须要
})
SlateTransforms.insertNodes(editor, node, { at: [current_path] })
break
}
}
/*本地音频上传 限制条件
*
* @param {*} rawFile
*/
function beforeAvatarUpload(rawFile) {
if (rawFile.size / 1024 / 1024 > 5) {
proxy.$modal.msgError('单个文件不能超过5MB!')
return false
}
const node = [
{
type: 'video',
src: 'https://www.runoob.com/try/demo_source/horse.mp3',
children: [{ text: 'bbb' }]
}
]
SlateTransforms.insertNodes(editorRef.value, node)
}
//创建实例触发的事件
const handleCreated = editor => {
editorRef.value = editor // 记录 editor 实例,重要!
registerMenu(editor, toolbarConfig) // 注册自定义菜单
initMediaMenuEvent() // 注册自定义菜单点击事件
}
// 回调函数 值发生改变触发
const handleChange = editor => {
emit('change', editor)
}
const handleBlur = editor => {}
/******************************************* 配置 end *****************/
const styles = computed(() => {
let style = {}
if (props.height) {
style.height = isNumber(props.height) ? `${props.height}px` : props.height
}
style.overflowY = 'hidden'
return style
})
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy() // 销毁,并移除 editor
})
watch(
() => props.modelValue,
v => {
if (v === unref(valueHtml)) return
valueHtml.value = v
},
{ immediate: true }
)
// 监听
watch(
() => valueHtml.value,
val => {
emit('update:modelValue', val)
}
)
</script>
<style lang="scss">
.wangeditorStyle {
// border: 1px solid #ccc;
.dropPanelSelStyle {
.btn {
display: block;
width: 100%;
text-align: left;
svg {
vertical-align: text-bottom;
}
}
}
}
</style>
(六) 注意点
- editorRef 必须用 shallowRef
- 组件销毁时,要及时销毁编辑器
(七) 功能点不足
- customUpload自定义上传跟插件自带的上传不能一起使用;插件自定义上传插件必须填写server
- 插件自带上传功能中maxNumberOfFiles 没有效果
- 网络上传图片和视频 只有该文件允许访问 才可以上传成功【但是WangEditor并没有报错提示,只是编辑器内容展示不了】
- 无查看源码功能(不能直接编辑html代码 和内容切换)
- 无预览内容功能
可以简单用一个预览的菜单按钮,然后document.write 出来
/**预览页面 */
editor.on('PreviewPageClick', () => {
let printPages = window.open('', '_blank')
printPages.document.write(unref(valueHtml))
})
- 不兼容音频插入
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)
用这种方式插入的话,在手机端预览这个内容(编辑器生成的内容)的话是有问题,ios不能识别video播放音频。所以需要在移动端预览页面 把音频的video改成audio 才可以
// 音频mp3 的video标签替换成audio 及type
var replacedContent = cnt.replace(
/<video([\s\S]*?)<\/video>/g,
function (match) {
if (match.includes('.mp3')) {
return match
.replace(/<video/g, '<audio')
.replace(/type="video\/mp4"/g, 'type="audio/mpeg"')
} else {
return match
}
}
)
dataInfos.value.content = replacedContent
ii. 费脑子开发
这里要注意,这边会涉及到wangEditor中ModalMenu、插件、新元素等方面的内容,具体可以参考官方文档。这边所涉及的源代码是在wangeditor的video源码的上做更改的。涉及多个文件。记得安装snabbdom.js这个包。
- 插入视频(自定义如音频走视频的模式)SlateTransforms.insertNodes(editor, node) 找不到鼠标在编辑器内焦点 ,而是直接把节点插入到后面【我找了很多方式不行,目前是通过获取鼠标的上个节点位置,然后插入】
- 自定义编辑器alert -customAlert 不行【我照着官网弄,但是没效果,具体不知道为啥 ,后期在研究】
(八) 集成后最终效果的演示视频
wangEditor5+vue3编辑器
(九) 参考
- wangEditor5在Vue3中的自定义图片+视频+音频菜单
- vue3+ts+wangEditor5菜单栏添加自定义图标按钮
- wangEditor富文本框的使用
四 UEditor
有已经封装好vue3的相关的组件:vue-ueditor-wrap
vue2
(一)安装
- 安装组件
# vue-ueditor-wrap v3 仅支持 Vue 3
npm i vue-ueditor-wrap@3.x -S
# or
yarn add vue-ueditor-wrap@3.x
- 需要相关静态资源做辅助
UEditor 并不支持通过 npm 的方式来安装,vue-ueditor-wrap 也只是一个 Vue 组件,组件本身并不是 UEditor 的 Vue 版。
下载放到public文件下,然后命名为UEditor
(二)使用
全局安装
import {VueUeditorWrap} from "vue-ueditor-wrap";
app.use(VueUeditorWrap)
或者页面组件里使用
import {VueUeditorWrap} from "vue-ueditor-wrap";
组件使用:
<template>
<div>
<vue-ueditor-wrap editor-id="editor" v-model="content" :config="myConfig" />
</div>
</template>
<script setup>
import {VueUeditorWrap} from "vue-ueditor-wrap";
let content = ref('e + UEditor + v-model双向绑定')
let myConfig = {
// 编辑器不自动被内容撑高
autoHeightEnabled: false,
// 初始容器高度
initialFrameHeight: 450,
// 初始容器宽度
initialFrameWidth: 800,
// 上传文件接口(这个地址是我为了方便各位体验文件上传功能搭建的临时接口,请勿在生产环境使用!!!)
serverUrl: '/api/upload/index',
// UEditor 资源文件的存放路径,如果你使用的是 vue-cli 生成的项目,通常不需要设置该选项,vue-ueditor-wrap 会自动处理常见的情况
UEDITOR_HOME_URL: '/UEditor/',
}
</script>
文章来源:https://www.toymoban.com/news/detail-521621.html
参考
vue3+springboot集成百度编辑器UEditor-Plus解决图片上传问题文章来源地址https://www.toymoban.com/news/detail-521621.html
到了这里,关于vue3+wangEditor5/vue-quill自定义上传音频+视频的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!