vue3+js+elementPlus使用富文本编辑器@vueup/vue-quill

目录

  • 前言
  • 源码
    • Editor/index.vue
    • Editor/quill.js
  • 整体思路
  • 待解决

前言

本篇文章是基于vue3、js、elementPlus框架进行的,
主要是核心涉及的包是以下三个

  "vue": "^3.2.47",
  "@vueup/vue-quill": "^1.0.0-alpha.40",
  "element-plus": "^2.3.6",

如果有问题的先看下版本是否一致。因为我每次找解决方案的时候,发现了好多问题都是版本不一致导致的。
本篇文章使用到的编辑器包含以下几个功能:

  1. 输入文本
  2. 插入图片(以img标签的形式插入,并且还能在标签上插入文件id)
  3. 工具栏显示为中文
  4. 工具栏hover后有中文提示
  5. 已插入的图片文件的删除

暂时没有完善的:

  1. 图片的大小尺寸修改和支持拖拽图片(后面如果解决了会更新,当前日期是2023年7月4日)

源码

共涉及两个文件,一个Editor/index.vue,一个Editor/quill.js

Editor/index.vue

详细目录是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>

Editor/quill.js

用于使得插入图片标签的时候能够插入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进行比对,这便知道哪些是已经上传过但是又被用户删除的文件了。这个地方是我的难点,因此我想记录一下。

待解决

最后,我想加入图片可以自由调节大小,可拖拽的插件,但是在网上寻求了很多解决方案,始终没有解决,如果有朋友解决了这个问题,麻烦评论区回复我一下,因为富文本编辑器真的经常要用到!!非常感谢,如果我解决了我也会及时更新的!!

你可能感兴趣的:(vue.js,javascript,前端)