本篇文章是基于vue3、js、elementPlus框架进行的,
主要是核心涉及的包是以下三个
"vue": "^3.2.47",
"@vueup/vue-quill": "^1.0.0-alpha.40",
"element-plus": "^2.3.6",
如果有问题的先看下版本是否一致。因为我每次找解决方案的时候,发现了好多问题都是版本不一致导致的。
本篇文章使用到的编辑器包含以下几个功能:
暂时没有完善的:
共涉及两个文件,一个Editor/index.vue,一个Editor/quill.js
详细目录是src/components/Editor/index.vue
<template>
<el-upload :action="uploadUrl" :before-upload="handleBeforeUpload" :on-success="handleUploadSuccess" name="richTextFile"
:on-error="handleUploadError" :show-file-list="false" class="editor-img-uploader" accept=".jpeg,.jpg,.png">
<i ref="uploadRef" class="Plus editor-img-uploader"></i>
</el-upload>
<div class="editor">
<QuillEditor id="editorId" ref="myQuillEditor" v-model:content="editorContent" contentType="html"
@update:content="onContentChange" :options="options" />
</div>
</template>
<script setup>
import { QuillEditor, Quill } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { getCurrentInstance, reactive, ref, toRaw, computed, onMounted } from "vue";
// 引入插入图片标签自定义的类
import './quill'
// 注册图片拖拽和大小修改插件(不起效果暂时屏蔽)
// import { ImageDrop } from 'quill-image-drop-module';
// import {ImageResize} from 'quill-image-resize-module';
// Quill.register('modules/ImageDrop', ImageDrop);
// Quill.register('modules/imageResize', ImageResize);
const { proxy } = getCurrentInstance();
const emit = defineEmits(['update:content', 'getFileId', 'handleRichTextContentChange'])
const props = defineProps({
/* 编辑器的内容 */
content: {
type: String,
default: '',
},
/* 只读 */
readOnly: {
type: Boolean,
default: false,
},
// 上传文件大小限制(MB)
fileSize: {
type: Number,
default: 10,
},
})
const editorContent = computed({
get: () => props.content,
set: (val) => {
emit('update:content', val)
}
});
const myQuillEditor = ref(null)
const uploadUrl = ref(import.meta.env.VITE_BASEURL + '/sysFiles/upload') // 上传的图片服务器地址
const oldContent = ref('')
const options = reactive({
theme: 'snow',
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'], // 清除文本格式
['link', 'image'], // 链接、图片、视频
],
handlers: {
// 重写图片上传事件
image: function (value) {
if (value) {
//调用图片上传
proxy.$refs.uploadRef.click()
} else {
Quill.format("image", true);
}
},
},
// ImageDrop: true,//支持图片拖拽
// imageResize: { //支持图片大小尺寸修改
// displayStyles: {
// backgroundColor: 'black',
// border: 'none',
// color: 'white'
// },
// modules: ['Resize', 'DisplaySize','Toolbar']
// }
}
},
placeholder: '请输入公告内容...',
readOnly: props.readOnly,
clipboard: {
matchers: [
['img', (node, delta) => {
const src = node.getAttribute('src');
const id = node.getAttribute('id');
delta.insert({ image: { src, 'id': id } });
}],
],
},
})
// toolbar标题(此项是用来增加hover标题)
const titleConfig = ref([
{ Choice: '.ql-insertMetric', title: '跳转配置' },
{ Choice: '.ql-bold', title: '加粗' },
{ Choice: '.ql-italic', title: '斜体' },
{ Choice: '.ql-underline', title: '下划线' },
{ Choice: '.ql-header', title: '段落格式' },
{ Choice: '.ql-strike', title: '删除线' },
{ Choice: '.ql-blockquote', title: '块引用' },
{ Choice: '.ql-code', title: '插入代码' },
{ Choice: '.ql-code-block', title: '插入代码段' },
{ Choice: '.ql-font', title: '字体' },
{ Choice: '.ql-size', title: '字体大小' },
{ Choice: '.ql-list[value="ordered"]', title: '编号列表' },
{ Choice: '.ql-list[value="bullet"]', title: '项目列表' },
{ Choice: '.ql-direction', title: '文本方向' },
{ Choice: '.ql-header[value="1"]', title: 'h1' },
{ Choice: '.ql-header[value="2"]', title: 'h2' },
{ Choice: '.ql-align', title: '对齐方式' },
{ Choice: '.ql-color', title: '字体颜色' },
{ Choice: '.ql-background', title: '背景颜色' },
{ Choice: '.ql-image', title: '图像' },
{ Choice: '.ql-video', title: '视频' },
{ Choice: '.ql-link', title: '添加链接' },
{ Choice: '.ql-formula', title: '插入公式' },
{ Choice: '.ql-clean', title: '清除字体格式' },
{ Choice: '.ql-script[value="sub"]', title: '下标' },
{ Choice: '.ql-script[value="super"]', title: '上标' },
{ Choice: '.ql-indent[value="-1"]', title: '向左缩进' },
{ Choice: '.ql-indent[value="+1"]', title: '向右缩进' },
{ Choice: '.ql-header .ql-picker-label', title: '标题大小' },
{ Choice: '.ql-header .ql-picker-item[data-value="1"]', title: '标题一' },
{ Choice: '.ql-header .ql-picker-item[data-value="2"]', title: '标题二' },
{ Choice: '.ql-header .ql-picker-item[data-value="3"]', title: '标题三' },
{ Choice: '.ql-header .ql-picker-item[data-value="4"]', title: '标题四' },
{ Choice: '.ql-header .ql-picker-item[data-value="5"]', title: '标题五' },
{ Choice: '.ql-header .ql-picker-item[data-value="6"]', title: '标题六' },
{ Choice: '.ql-header .ql-picker-item:last-child', title: '标准' },
{ Choice: '.ql-size .ql-picker-item[data-value="small"]', title: '小号' },
{ Choice: '.ql-size .ql-picker-item[data-value="large"]', title: '大号' },
{ Choice: '.ql-size .ql-picker-item[data-value="huge"]', title: '超大号' },
{ Choice: '.ql-size .ql-picker-item:nth-child(2)', title: '标准' },
{ Choice: '.ql-align .ql-picker-item:first-child', title: '居左对齐' },
{ Choice: '.ql-align .ql-picker-item[data-value="center"]', title: '居中对齐' },
{ Choice: '.ql-align .ql-picker-item[data-value="right"]', title: '居右对齐' },
{ Choice: '.ql-align .ql-picker-item[data-value="justify"]', title: '两端对齐' }
])
// 上传前校检格式和大小
function handleBeforeUpload(file) {
const type = ["image/jpeg", "image/jpg", "image/png", "image/svg"];
const isJPG = type.includes(file.type);
//检验文件格式
if (!isJPG) {
ElMessage.error(`图片格式错误!只能上传jpeg/jpg/png格式`)
return false
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize
if (!isLt) {
ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`)
return false
}
}
return true
}
// 监听富文本内容变化,删除被服务器中被用户回车删除的图片
function onContentChange(content) {
emit('handleRichTextContentChange', content)
}
// 上传成功处理
function handleUploadSuccess(res, file) {
// 如果上传成功
if (res.status == 200) {
let rawMyQuillEditor = toRaw(myQuillEditor.value)
// 获取富文本实例
let quill = rawMyQuillEditor.getQuill();
// 获取光标位置
let length = quill.selection.savedRange.index;
// 插入图片,res为服务器返回的图片链接地址
const imageUrl = import.meta.env.VITE_BASE_FILE_PREFIX + res.body[0].lowPath;
const imageId = res.body[0].id;
quill.insertEmbed(length, 'image', {
url: imageUrl,
id: imageId,
});
quill.setSelection(length + 1);
emit('getFileId', res.body[0].id)
} else {
ElMessage.error('图片插入失败')
}
}
// 上传失败处理
function handleUploadError() {
ElMessage.error('图片插入失败')
}
// 增加hover工具栏有中文提示
function initTitle() {
document.getElementsByClassName('ql-editor')[0].dataset.placeholder = ''
for (let item of titleConfig.value) {
let tip = document.querySelector('.ql-toolbar ' + item.Choice)
if (!tip) continue
tip.setAttribute('title', item.title)
}
}
onMounted(() => {
initTitle()
oldContent.value = props.content
})
</script>
//通过css样式来汉化
<style>
.editor,
.ql-toolbar {
white-space: pre-wrap !important;
line-height: normal !important;
}
.editor-img-uploader {
display: none;
}
.ql-editor {
min-height: 200px;
max-height: 300px;
overflow: auto;
}
.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>
用于使得插入图片标签的时候能够插入id在图片标签上,不然直接使用insertEmbed方法是无法插入id在img标签上的
import { Quill } from '@vueup/vue-quill'
var BlockEmbed = Quill.import('blots/block/embed')
class ImageBlot extends BlockEmbed {
static create(value) {
let node = super.create();
node.setAttribute('src', value.url);
node.setAttribute('id', value.id)
// node.setAttribute('width', value.width)
// node.setAttribute('height', value.height)
return node;
}
static value(node) {
return {
url: node.getAttribute('src'),
id: node.getAttribute('id'),
}
}
}
ImageBlot.blotName = 'image';
ImageBlot.tagName = 'img';
Quill.register(ImageBlot)
父组件中的handleRichTextContentChange事件
// 根据富文本实时变化,观察有没有删除已经上传的id
function handleRichTextContentChange(content) {
const currentIds = getRichTextIds(content)
if (uploadedRichTextIds.value.length > 0) {
// 拿当前form里面已经上传的id来进行查询,如果不存在currentIds里面,则已经被删除
uploadedRichTextIds.value.find(oldId => {
if (!currentIds.includes(oldId) && !removedRichTextIds.value.includes(oldId)) {
removedRichTextIds.value.push(oldId) //向删除的id里面推入被删除的项
let index = uploadedRichTextIds.value.indexOf(oldId)
uploadedRichTextIds.value.splice(index, 1) //删除已上传的过程记录变量
}
})
}
}
父组件的getFileId方法
// 富文本组件随时更新已经上传的富文本id
function getFileId(id) {
uploadedRichTextIds.value.push(id)
console.log('uploadedRichTextIds', uploadedRichTextIds.value);
}
父组件的getRichTextIds 方法,用于获取富文本中含有的图片的id集合
/**
*
* @param {String} content //富文本字符串
* @param {Array} ids //富文本里面的图片文件id集合
*/
function getRichTextIds(content) {
const ids = []
const myDiv = document.createElement("div");
myDiv.innerHTML = content;
const imgDom = myDiv.getElementsByTagName('img')
for (let i = 0; i < imgDom.length; i++) {
// 只有富文本处的img标签是有id的
if (imgDom[i].src && imgDom[i].id) {
ids.push(imgDom[i].id)
}
}
return ids
}
最终我会向后端提交removedRichTextIds,这些是已经在富文本编辑过程中已经上传到服务器中的文件id,需要被删除掉,不然服务器会一直存储着这些文件,造成服务器的空间紧张
文本输入、汉化工具栏、增加hover提示整体都是比较简单的传统思路,只是上传图片没有采用base64的方式,是因为base64插入一两张后,整个富文本就会变得巨大无比,导致整个页面加载都非常卡顿,因此只能采用插入img标签的形式。在插入img标签之后需要被回显成正常的图片,因此也就只能实时上传,用后端返回的路径来拼接显示。
虽然这样轻量了,但是问题也来了,如果用户使用回车删除了该图片,在服务器还是会存在该张图片。因此在用户删除时,也要删除服务器中该文件。
因此,我们通过id来确定用户到底删除的是哪张图片。首先在插入图片时,就将upload后后端返回的id插入到对应图片的img标签上,用id属性名=id属性值的方式绑定到img标签上。同时使用一个记录变量uploadedRichTextIds 来记录已经上传的id,通过富文本编辑器本身自带的事件change来监听当前的富文本内容,通过getRichTextIds方法获取当前富文本中的img标签里面的id组合,和uploadedRichTextIds中的id进行比对,这便知道哪些是已经上传过但是又被用户删除的文件了。这个地方是我的难点,因此我想记录一下。
最后,我想加入图片可以自由调节大小,可拖拽的插件,但是在网上寻求了很多解决方案,始终没有解决,如果有朋友解决了这个问题,麻烦评论区回复我一下,因为富文本编辑器真的经常要用到!!非常感谢,如果我解决了我也会及时更新的!!