wangeditor自定义使用案例

741 阅读9分钟

大半年前项目里遇到的需求,一直没整理,虽然不咋难,当时写的时候还是废了一些时间找思路,记录一下

需求点

  1. 复制粘贴、上传图片不走后端接口,转base64,并且压缩图片大小
  2. 自定义下拉框类似于word中的标题级别(如下图),选中后须对应每一项的字体、字号、行高以及段落对齐方式 image.png 完成效果如图 image.png 3.点击按钮在光标所在位置插入文字

思路

  1. 富文本编辑器配置中uploadImage方法内图片上传、复制后的自定义回显方式
  2. 注册新菜单,点击菜单时触发的函数exec中设置编辑器字体、字体大小、行高、对齐方式等,也有一些小细节后续会标出
  3. 插入文字本质就是对编辑器的节点进行操作,参照官网节点操作

首先安装依赖

  • wangeditor/editor:5.1.23
  • wangeditor/editor-for-vue": 1.0.2 直接上代码
<template>
  <div>
    <div class="w-e-for-vue">
      <!-- 工具栏 -->
      <Toolbar class="w-e-for-vue-toolbar" :editor="editor" :defaultConfig="toolbarConfig">
        <div class="btn-op">
          <Button type="text" icon="md-add" @click="fd" style="cursor: pointer; margin-right: 5px"></Button>
          <Button type="text" icon="md-remove" @click="sx" style="cursor: pointer"></Button></div
      ></Toolbar>
      <!-- 编辑器 -->
      <Editor
        class="w-e-for-vue-editor"
        style="height: 597px; overflow-y: hidden"
        :disabled="isReadonly"
        :defaultConfig="editorConfig"
        v-model="content"
        @onChange="onChange"
        @onCreated="onCreated"
        @customPaste="customPaste"
        ref="editorWang"
        id="editorWang"
      >
      </Editor>
    </div>
  </div>
</template>

<script>
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { Boot, SlateTransforms, SlateEditor, SlateElement } from '@wangeditor/editor'
class MySelectMenu {
  constructor() {
    this.title = 'My Select Menu'
    this.tag = 'select'
    this.width = 65
  }

  // 下拉框的选项
  getOptions(editor) {
    return that.optionList
  }

  // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
  isActive(editor) {
    return false
  }

  // 获取菜单执行时的 value ,用不到则返回空 字符串或 false
  getValue(editor) {
    return that.selectFm // 匹配 options 其中一个 value
  }

  // 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
  isDisabled(editor) {
    return false
  }

  // 点击菜单时触发的函数
  exec(editor, value) {
    that.selectFm = value
    that.optionList.map(item => {
      if (item.value == value) {
        item.selected = true
      } else {
        item.selected = false
      }
    })
    let nodeEntries = SlateEditor.nodes(editor, {
      match: node => {
        if (SlateElement.isElement(node)) {
          return true
        }
        return false
      },
      universal: true,
    })
    console.log(nodeEntries, 'nodeEntries')
    let targetNodes = []
    if (nodeEntries == null) {
      console.log('当前未选中的 paragraph')
    } else {
      for (let nodeEntry of nodeEntries) {
        let [node] = nodeEntry
        let newNode = JSON.parse(JSON.stringify([node]))
        console.log(newNode, 'newNode')
        newNode.map(nodes => {
          targetNodes.push({ type: 'paragraph', children: nodes.children })
        })
      }
    }

    SlateTransforms.removeNodes(editor) // 删除第一个空行(重要)
    targetNodes.map(child => {
      child.children.map(t => {
        if (value == '标题') {
          child.lineHeight = '36px'
          child.textIndet = '24px'
          child.textAlign = 'center'
          that.$set(t, 'fontFamily', '方正小标宋_GBK')
          that.$set(t, 'fontSize', '29px')
        } else if (value == '一级标题') {
          child.lineHeight = '36px'
          child.textIndet = '24px'
          child.textAlign = 'justify'
          that.$set(t, 'fontFamily', '方正黑体_GBK')
          that.$set(t, 'fontSize', '21px')
        } else if (value == '二级标题') {
          child.lineHeight = '36px'
          child.textIndet = '24px'
          child.textAlign = 'justify'
          that.$set(t, 'fontFamily', '方正楷体_GBK')
          that.$set(t, 'fontSize', '21px')
        } else if (value == '三级标题' || value == '四级标题' || value == '正文') {
          child.lineHeight = '36px'
          child.textIndet = '24px'
          child.textAlign = 'justify'
          that.$set(t, 'fontFamily', '方正仿宋_GBK')
          that.$set(t, 'fontSize', '21px')
        }
      })
    })
    //向编辑器插入设置过样式的节点
    SlateTransforms.insertNodes(editor, targetNodes, { mode: 'highest' })
  }
} 
import { replaceImagesFileSourceWithInlineRepresentation, extractImageDataFromRtf, findAllImgSrcsFromHtml } from './tool.js'
//引入字体
import '@/assets/fonts/font.css'
let that
export default {
  name: 'wangEditor',
  components: { Editor, Toolbar },
  props: {
    value: {
      type: String,
      default: '',
    },
    // 富文本是否禁用
    isReadonly: {
      type: Boolean,
      default: false,
    },
    // 富文本是否禁用
    inModalShowStatus: {
      type: Boolean,
      default: true,
    },
    // 追加的内容
    insetVal: {
      type: String,
      default: '',
    },
  },
  data() {
    return {
      selectFm: '正文',
      optionList: [
        { value: '标题', text: '标题', selected: false, styleForRenderMenuList: { 'font-size': '28px', 'font-weight': 'bold', 'font-family': '方正小标宋_GBK' } },
        { value: '一级标题', text: '一级标题', selected: false, styleForRenderMenuList: { 'font-size': '24px', 'font-family': '方正黑体_GBK' } },
        { value: '二级标题', text: '二级标题', selected: false, styleForRenderMenuList: { 'font-size': '20px', 'font-family': '方正楷体_GBK' } },
        { value: '三级标题', text: '三级标题', selected: false, styleForRenderMenuList: { 'font-size': '16px', 'font-family': '方正仿宋_GBK' } },
        { value: '四级标题', text: '四级标题', selected: false, styleForRenderMenuList: { 'font-size': '16px', 'font-family': '方正仿宋_GBK' } },
        { value: '正文', text: '正文', selected: true, styleForRenderMenuList: { 'font-size': '16px', 'font-family': '方正仿宋_GBK' } },
      ],
      modal: false,
      scale: 1.2,
      editor: null, // 富文本编辑器对象
      content: null, // 富文本内容
      placeholder: null, // 富文本占位内容
      uploadImageUrl: null, // 富文本上传图片的地址
      // 工具栏配置
      toolbarConfig: {
        toolbarKeys: [
          'selectFM',
          'fontSize',
          'fontFamily',
          'lineHeight',
          '|',
          'blockquote',
          'bold',
          'underline',
          'italic',

          '|',
          'insertTable',
          {
            key: 'group-image',
            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'],
          },
          {
            key: 'group-indent',
            title: '缩进',
            iconSvg: '<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v128H0z m384 192h640v128H384z m0 192h640v128H384z m0 192h640v128H384zM0 832h1024v128H0z m0-128V320l256 192z"></path></svg>',
            menuKeys: ['indent', 'delIndent'],
          },
          {
            key: 'group-justify',
            title: '对齐',
            iconSvg:
              '<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8zM972.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>',
            menuKeys: ['justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify'],
          },
          {
            key: 'group-more-style',
            title: '更多',
            iconSvg:
              '<svg viewBox="0 0 1024 1024"><path d="M204.8 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M505.6 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M806.4 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path></svg>',
            menuKeys: ['through', 'code', 'sup', 'sub', 'clearStyle'],
          },
          '|',
          'color',
          'bgColor',
          'bulletedList',
          'numberedList',
          'todo',
          'emotion',
          'insertLink',
          'codeBlock',
          'divider',
          'undo',
          'redo',
          '|',
          'fullScreen', 
        ], // 显示指定的菜单项
        excludeKeys: ['group-video', 'insertVideo', 'uploadVideo'], // 隐藏指定的菜单项
        insertKeys: {
          keys: [ 'selectFM'], //自定义tab按钮
        },
      },
      // 编辑器配置
      editorConfig: {
        withCredentials: true, //定义该属性为ture表示允许跨域访问
        autoFocus: true,
        placeholder: '',
        uploadVideoShow: false,
        fontFamily: '方正仿宋_GBK',
        MENU_CONF: {
          // 图片上传
          uploadImage: {
            // 自定义上传图片
            async customUpload(file, insertFn) {
              console.log(file)
              const isPic = file.type.split('/')[0] === 'image'
              const isLt2M = file.size / 1024 / 1024 <= 10
              if (!isPic) {
                that.$Message.error('只能上传图片噢~')
              } else if (!isLt2M) {
                that.$Message.error('上传图片大小不能超过 10MB!')
              } else {
                let filePath = that.imageToBase64(file) // 图片转base64 不走上传
                console.log(filePath)
                insertFn(filePath, '', filePath) // 将图片插入编辑器显示
              }
            },
          },
          fontFamily: {
            width: 133,
            fontFamilyList: [
              '黑体',
              '仿宋',
              '方正黑体_GBK',
              '方正小标宋_GBK',
              '方正仿宋_GBK',
              '方正楷体_GBK',
              '楷体',
              '标楷体',
              '华文仿宋',
              '华文楷体',
              '宋体',
              '微软雅黑',
              'Arial',
              'Tahoma',
              'Verdana',
              'Times New Roman',
              'Courier New',
            ],
          },
          lineHeight: {
            lineHeightList: ['10px', '15px', '20px', '25px', '28px', '30px', '33px', '36px', '40px', '46px', '52px', '58px', '64px', '72px'],
          },
          fontSize: {
            fontSizeList: ['12px', '13px', '14px', '15px', '16px', '18px', '20px', '21px', '22px', '24px', '29px', '30px', '32px', '34px', '48px', '56px'],
          },
        },
        pasteTextHandle: function (pasteStr) {
          var newStr = pasteStr.replace(/@font-face{[^>]*div.Section0{page:Section0;}/g, '')
          return newStr
        },
      },
    }
  },
  watch: {
    value: {
      handler: function (newVal) {
        if (newVal != null && newVal != '') {
          if (that.editor) {
            that.$nextTick(() => {
              that.content = newVal
            })
          }
        }
      },
      immediate: true,
    },
    insetVal: {
      handler(newVal, oldVal) {
        if (newVal != null) {
          console.log(newVal)
          this.insertHTML(newVal)
        }
      },
      immediate: true,
    },
    inModalShowStatus: {
      handler: function (newVal, oldVal) {
        if (!newVal) {
          if (this.editor != null) {
            this.editor.destroy()
          }
        } else {
          this.onCreated()
        }
      },
      immediate: true,
    },
  },
  created() {
    let that = this
    // const menu1Conf = {
    //   key: 'zoomD', // 定义 menu key :要保证唯一、不重复(重要)
    //   factory() {
    //     return new MyButtonMenu(that)
    //   },
    // }
    const menu2Conf = {
      key: 'selectFM', // 定义 menu key :要保证唯一、不重复(重要)
      factory() {
        return new MySelectMenu(that)
      },
    } 
    const module = {
      menus: [menu2Conf],
    }
    Boot.registerModule(module)
  },
  mounted() {
    that = this
    this.setSpellCheckFasle() // 设置拼写检查 spellcheck 为假
    document.activeElement.blur() // 取消富文本自动聚焦且禁止虚拟键盘弹出
    // 异步渲染编辑器
    this.$nextTick(() => {
      this.content = this.value
    })
  },
  /**
   * 销毁富文本编辑器
   */
  beforeDestroy() {
    if (this.editor != null) {
      this.editor.destroy()
    }
  },
  methods: {
    imageToBase64(file) {
      return new Promise((resolve, reject) => {
        var reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => {
          console.log('file 转 base64结果:' + reader.result)
          that.compress(reader.result, 1.5, function (base64) {
            console.log(base64)
            resolve(base64)
          })
          //   resolve(reader.result)
        }
        reader.onerror = function (error) {
          reject(error)
          console.log('Error: ', error)
        }
      })
    },
    /* 压缩base64图片*/
    compress(
      base64, // 源图片
      rate, // 缩放比例
      callback, // 回调
    ) {
      //处理缩放,转格式
      var _img = new Image()
      _img.src = base64
      _img.onload = function () {
        var _canvas = document.createElement('canvas')
        var w = this.width / rate
        var h = this.height / rate
        _canvas.setAttribute('width', w)
        _canvas.setAttribute('height', h)
        _canvas.getContext('2d').drawImage(this, 0, 0, w, h)
        var base64 = _canvas.toDataURL('image/jpeg')
        _canvas.toBlob(function (blob) {
          if (blob.size > 750 * 1334) {
            //如果还大,继续压缩
            that.compress(base64, rate, callback)
          } else {
            callback(base64)
          }
        }, 'image/jpeg')
      }
    },

    replaceAllImg(html) {
      // 编写逻辑来匹配 img 标签并替换 src
      // 这里使用正则表达式来匹配 img 标签的 src 属性并替换为 data-src
      if (html !== '' && html !== null && html !== undefined) {
        const regex = /<img([^>]*)\ssrc=\s*['"]([^'"]+)['"]([^>]*)>/gi
        const replacedHtml = html.replace(regex, '<img$1 data-src="$2"$3>')
        return replacedHtml
      }
    },
    //插入常用语
    insertHTML(value) {
      if (this.editor) {
        const node = { type: 'paragraph', children: [{ text: value }] }
        this.editor.insertNode(node)
      }
    }, 
    /**
     * 实例化富文本编辑器
     * 文档链接:https://www.wangeditor.com/
     */
    onCreated(editor) {
      this.editor = Object.seal(editor)
      this.setIsDisabled()
    },
    /**
     * 监听富文本编辑器
     */
    onChange(editor) {
      console.log('onChange:', editor)
      //   console.log(editor.getSelectionText())
      this.$emit('onChange', this.editor.getHtml())
    },
    /**
     * this.editor.getConfig().spellcheck = false
     * 由于在配置中关闭拼写检查,值虽然设置成功,但是依然显示红色波浪线
     * 因此在富文本编辑器组件挂载完成后,操作 Dom 元素设置拼写检查 spellcheck 为假
     */
    async setSpellCheckFasle() {
      let doms = await document.getElementsByClassName('w-e-scroll')
      for (let vo of doms) {
        if (vo) {
          if (vo.children.length > 0) {
            vo.children[0].setAttribute('spellcheck', 'false')
          }
        }
      }
    },

    /**
     * 设置富文本是否禁用
     */
    async setIsDisabled() {
      if (this.editor) {
        this.isReadonly ? this.editor.disable() : this.editor.enable()
      }
    },
    /**
     * 获取编辑器文本内容
     */
    getEditorText() {
      const editor = this.editor
      if (editor != null) {
        return editor.getText()
      }
    },

    /**
     * 获取编辑器Html内容
     */
    getEditorHtml() {
      const editor = this.editor
      if (editor != null) {
        return editor.getHtml()
      }
    },
    // 自定义粘贴
    customPaste(editor, event) {
      // 获取粘贴的html部分(??没错粘贴word时候,一部分内容就是html),该部分包含了图片img标签
      let html = event.clipboardData.getData('text/html')
      // 获取rtf数据(从word、wps复制粘贴时有),复制粘贴过程中图片的数据就保存在rtf中
      const rtf = event.clipboardData.getData('text/rtf')
      if (html && rtf) {
        console.log(rtf)
        // 该条件分支即表示要自定义word粘贴
        // 列表缩进会超出边框,直接过滤掉
        html = html.replace(/text\-indent:\-(.*?)pt/gi, '')
        // 从html内容中查找粘贴内容中是否有图片元素,并返回img标签的属性src值的集合
        const imgSrcs = findAllImgSrcsFromHtml(html)
        // 如果有
        if (imgSrcs && Array.isArray(imgSrcs) && imgSrcs.length) {
          // 从rtf内容中查找图片数据
          const rtfImageData = extractImageDataFromRtf(rtf)
          // 如果找到
          if (rtfImageData.length) {
            console.log(imgSrcs, rtfImageData, '---000')
            // TODO:此处可以将图片上传到自己的服务器上
            // 执行替换:将html内容中的img标签的src替换成ref中的图片数据,如果上面上传了则为图片路径
            html = replaceImagesFileSourceWithInlineRepresentation(html, imgSrcs, rtfImageData)
            console.log(html)
            // editor.dangerouslyInsertHtml(html)
          }
        }
        // const newHtml = html.replace(/<img[^>]+src="file:([^"]+)"/g, '')
        //当复制了图片和一些图像文件时 会导致正常的img图片路径是file://本地图片并且不展示,所以将该种图片替换掉
        const newHtml = this.removeFileProtocolFromImgSrc(html)
        editor.dangerouslyInsertHtml(newHtml)
        // 阻止默认的粘贴行为
        event.preventDefault()
        return false
      } else {
        return true
      }
    },
    removeFileProtocolFromImgSrc(htmlContent) {
      console.log(htmlContent)
      // 创建DOM解析器
      const parser = new DOMParser()
      // 解析HTML内容为DOM文档
      const doc = parser.parseFromString(htmlContent, 'text/html')

      // 查找所有img标签并移除src属性以"file:"开头的img标签
      const imgTags = doc.getElementsByTagName('img')
      for (let i = 0; i < imgTags.length; i++) {
        const imgSrc = imgTags[i].getAttribute('src')
        if (imgSrc && imgSrc.startsWith('file:')) {
          imgTags[i].removeAttribute('src')
        }
      }

      // 将修改后的DOM文档转换为HTML内容
      const updatedHtmlContent = new XMLSerializer().serializeToString(doc)
      return updatedHtmlContent
    },
  },
}
</script>

<style src="@wangeditor/editor/dist/css/style.css"></style>
<style scoped>
/* table 样式 */
table {
  border-top: 1px solid #ccc;
  border-left: 1px solid #ccc;
}
table td,
table th {
  border-bottom: 1px solid #ccc;
  border-right: 1px solid #ccc;
  padding: 3px 5px;
}
table th {
  border-bottom: 2px solid #ccc;
  text-align: center;
}

/* blockquote 样式 */
blockquote {
  display: block;
  border-left: 8px solid #d0e5f2;
  padding: 5px 10px;
  margin: 10px 0;
  line-height: 1.4;
  font-size: 100%;
  background-color: #f1f1f1;
}

/* code 样式 */
code {
  display: inline-block;
  *display: inline;
  *zoom: 1;
  background-color: #f1f1f1;
  border-radius: 3px;
  padding: 3px 5px;
  margin: 0 3px;
}
pre code {
  display: block;
}

/* ul ol 样式 */
ul,
ol {
  margin: 10px 0 10px 20px;
}
</style>
<style lang="less" scoped>
.preview-html {
  width: 793px;
  height: 650px;
  overflow-y: scroll;
  padding: 10px 40px;
  background-color: #ffffff;
  color: #000000;
  font-family: 宋体;
  font-size: 14px;
  line-height: 1.3;
}
/deep/ .select-button {
  width: 130px !important;
}
/deep/ .w-e-text-container {
  background: rgba(230, 230, 230, 1);
}
/deep/ .w-e-scroll {
  overflow-y: auto;
  width: 793px;
  margin: 0 auto;
  background: #fff;
  padding: 10px 40px;
}
.w-e-full-screen-container {
  z-index: 99;
}
.w-e-for-vue {
  margin: 0;
  border: 1px solid #ccc;
  z-index: 999;
  .w-e-for-vue-toolbar {
    border-bottom: 1px solid #ccc;
    position: relative;
    .btn-op {
      position: absolute;
      top: 41px;
      left: 126px;
    }
  }
  .w-e-for-vue-editor {
    height: auto;
    /deep/ .w-e-text-container {
      .w-e-text-placeholder {
        top: 6px;
        color: #666;
      }
      pre {
        code {
          text-shadow: unset;
        }
      }
      p {
        margin: 5px 0;
        font-size: 14px; // 设置编辑器的默认字体大小为14px
      }
    }
  }
}
</style>

图片处理的js代码

/**
 * 从html代码中匹配返回图片标签img的属性src的值的集合
 * @param htmlData
 * @return Array
 */
export function findAllImgSrcsFromHtml(htmlData) {
  let imgReg = /<img.*?(?:>|\/>)/gi //匹配图片中的img标签
  let srcReg = /src=[\'\"]?([^\'\"]*)[\'\"]?/i // 匹配图片中的src

  let arr = htmlData.match(imgReg) //筛选出所有的img
  if (!arr || (Array.isArray(arr) && !arr.length)) {
    return false
  }

  let srcArr = []
  for (let i = 0; i < arr.length; i++) {
    let src = arr[i].match(srcReg)
    // 获取图片地址
    srcArr.push(src[1])
  }

  return srcArr
}

/**
 * 从rtf内容中匹配返回图片数据的集合
 * @param rtfData
 * @return Array
 */
export function extractImageDataFromRtf(rtfData) {
  if (!rtfData) {
    return []
  }

  const regexPictureHeader = /{\\pict[\s\S]+?({\\\*\\blipuid\s?[\da-fA-F]+)[\s}]*/
  const regexPicture = new RegExp('(?:(' + regexPictureHeader.source + '))([\\da-fA-F\\s]+)\\}', 'g')
  const images = rtfData.match(regexPicture)
  const result = []

  if (images) {
    for (const image of images) {
      let imageType = false

      if (image.includes('\\pngblip')) {
        imageType = 'image/png'
      } else if (image.includes('\\jpegblip')) {
        imageType = 'image/jpeg'
      }

      if (imageType) {
        result.push({
          hex: image.replace(regexPictureHeader, '').replace(/[^\da-fA-F]/g, ''),
          type: imageType,
        })
      }
    }
  }

  return result
}

/**
 * 将html内容中img标签的属性值替换
 * @param htmlData html内容
 * @param imageSrcs html中img的属性src的值的集合
 * @param imagesHexSources rtf中图片数据的集合,与html内容中的img标签对应
 * @param isBase64Data 是否是Base64的图片数据
 * @return String
 */
export function replaceImagesFileSourceWithInlineRepresentation(htmlData, imageSrcs, imagesHexSources, isBase64Data = true) {
  if (imageSrcs.length === imagesHexSources.length) {
    for (let i = 0; i < imageSrcs.length; i++) {
      const newSrc = isBase64Data ? `data:${imagesHexSources[i].type};base64,${_convertHexToBase64(imagesHexSources[i].hex)}` : imagesHexSources[i]

      htmlData = htmlData.replace(imageSrcs[i], newSrc)
    }
  }

  return htmlData
}

/**
 * 十六进制转base64
 */
export function _convertHexToBase64(hexString) {
  return btoa(
    hexString
      .match(/\w{2}/g)
      .map(char => {
        return String.fromCharCode(parseInt(char, 16))
      })
      .join(''),
  )
}