quill 编辑器实现上传图片、文件,图片缩放,图片文字粘贴,生成表格,toolbar添加title,自定义font等功能

公司项目需要一个开源且扩展性高的富文本编辑器。经调研比较,有TinyMCE, quill-editor,  wang-editor等. 最终经过比较决定使用quill-editor.

下面说一下自己的踩坑经过。

1、vue-quill-editor  和 quill 

因为公司项目是使用的vue-cli-3。所以刚开始使用的是vue-quill-editor,等做到table功能的时候,发现需要使用

"quill": "^2.0.0-dev.3"的版本,所以后期又在quill的基础上做了一遍。
 
2、webpack版本问题
引入quill-image-resize-module做图片缩放功能的时候,需要在全局注入quill。即vue.config.js中进行配置,但配置完发现项目启动不了,排查以后发现是webpack的版本问题。于是将
 "webpack": "^5.30.0" => 更换为 "webpack": "^4.42.0" 
 
3、图片文字复制粘贴功能
项目中有复制图片和文字的需求,经查阅资料。实现了单张图片复制粘贴上传,及文字的复制粘贴。不支持图片和文字混合粘贴,以及多张图片粘贴(此功能应该从底层是不支持的,如果有实现了该功能的小伙伴,希望能够共享一下,谢谢)。
 
4、实现table表格功能
"quill": "^2.0.0-dev.3" 的版本是支持 quill-better-table 的。table可以实现单元格的宽度缩放,合并,新增,删除,以及底色调整。
 
5、实现文本编辑器的内容回显及编辑功能
项目中需要从列表页跳转到详情页或者编辑页,此处就需要禁用编辑器的功能以及数据回显。此处主要用到了
this.quill.enable(false);  this.quill.clipboard.dangerouslyPasteHTML(newValue) 两个方法,可参考https://www.kancloud.cn/liuwave/quill/1409366
 
6、给toobar增加title,自定义font
功能实现以后,发现图标不能让人一眼就看出是什么功能,所以给每个图标增加了title提示。(图标icon也是可以自定义替换的,我在这里没做)
font的自带的字体库只有3个,不满足需求。所以又重新自定义了font字体;此处可参考 https://stackoverflow.com/questions/43728080/how-to-add-font-types-on-quill-js-with-toolbar-options/43728780#43728780
 
下边粘贴一下代码,如有需要可查看
依赖下载及webpack配置
安装quill
npm install quill@2.0.0-dev.3 --save

安装quill-better-table
npm install quill-better-table

安装quill-image-resize-module
npm install quill-image-resize-module 

"webpack": "^5.30.0"   =>  需更换为 "webpack": "^4.42.0" 
cnpm install webpack@4.42.0 --save-dev 

vue.confi.js 配置
const webpack = require('webpack')
chainWebpack(config) {
    config.plugin('provide').use(webpack.ProvidePlugin, [{
       'window.Quill':'quill/dist/quill.js'
       'Quill':'quill/dist/quill.js'
    }]);
}
View Code

组件代码(vue + ts)

<template>
  <div class="home-container">
    <div class="content">
      <span v-show="false">
        <!-- upload img -->
        <el-upload
          action=""
          multiple
          :http-request="uploadImg"
          accept=".gif, .jpg, .png, .bmp, .jpeg, .JPG, .PNG"
          :disabled="disabled"
        >
          <el-button size="small" type="primary" class="upload-img"
            >Upload img</el-button
          >
        </el-upload>
        <!--  upload file -->
        <el-upload
          action=""
          multiple
          :http-request="uploadFile"
          accept=".txt, .pdf, .ppt, .pptx, pptm, .doc, .docx, docm, .zip,.rar,.xls,.xlsx, .xlsm, .mp4, .exe, .dsd, .epub, .chm, .epub, .gul, .hwp, .tif, .ttf"
          :disabled="disabled">
          <el-button size="small" type="primary" class="upload-file">Upload file</el-button>
        </el-upload>
      </span>
      <div id="editor-wrapper" ref="myQuillEditor"></div>
       <!-- v-html="content" -->
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "vue-property-decorator";
import Quill from 'quill' // "webpack": "^4.42.0" and "quill": "^2.0.0-dev.3"
import "quill/dist/quill.snow.css"; // bubble
import { ImageExtend } from 'quill-image-extend-module'
import imageResize from 'quill-image-resize-module'
import QuillBetterTable from 'quill-better-table'
import 'quill-better-table/dist/quill-better-table.css'
import { upload } from "@/services/file";

Quill.register('modules/ImageExtend', ImageExtend)
Quill.register('modules/imageResize', imageResize)
Quill.register({
  'modules/better-table': QuillBetterTable
}, true)

let Link = Quill.import("formats/link");
class FileBlot extends Link {
  // Link Blot
  static create(value) {
    let node = undefined;
    if (value && !value.href) {
      node = super.create(value);
    } else {
      node = super.create(value.href);
      node.innerText = value.innerText;
      node.download = value.innerText;
    }
    return node;
  }
}
FileBlot["blotName"] = "link";
FileBlot["tagName"] = "A";
Quill.register(FileBlot);

// specify the fonts you would
let fonts = ['Arial', 'Georgia', 'SimSun', 'SimHei'];
// generate code friendly names
function getFontName(font) {
  return font.toLowerCase().replace(/\s/g, "-");
}
let fontNames = fonts.map(font => getFontName(font));
// add fonts to style
let fontStyles = "";
fonts.forEach(function(font) {
  let fontName = getFontName(font);
  fontStyles += ".ql-snow .ql-picker.ql-font .ql-picker-label[data-value=" + fontName + "]::before, .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=" + fontName + "]::before {" +
      "content: '" + font + "';" +
      "font-family: '" + font + "', sans-serif;" +
      "}" +
      ".ql-font-" + fontName + "{" +
      " font-family: '" + font + "', sans-serif;" +
      "}";
});
let node = document.createElement('style');
node.innerHTML = fontStyles;
document.body.appendChild(node);

// Add fonts to whitelist
let Font = Quill.import('formats/font');
Font.whitelist = fontNames;
Quill.register(Font, true);

const toolbarOptions = [
  ["bold", "italic", "underline", "strike"], // toggled buttons
  ["blockquote", "code-block"],
  [{ header: 1 }, { header: 2 }], // custom button values
  [{ list: "ordered" }, { list: "bullet" }],
  [{ script: "sub" }, { script: "super" }], // superscript/subscript
  [{ indent: "-1" }, { indent: "+1" }], // outdent/indent
  [{ direction: "rtl" }], // text direction
  [{ size: ["small", false, "large", "huge"] }], // custom dropdown
  [{ header: [1, 2, 3, 4, 5, 6, false] }],
  [{ color: [] }, { background: [] }], // dropdown with defaults from theme
  [{ font: fontNames }],
  [{ align: [] }],
  ["image"],
  ["link"],
  ["clean"], // remove formatting button
  [{ 'table': 'TD' }]
];

@Component({
  components: {},
})

export default class DocumentCreatPage extends Vue {
  @Prop({ default: "" }) private actionUrl!: string;

  @Prop({
    default: () => '',
    type: String
  })
  content!: '';

  @Prop({
    default: () => false,
    type: Boolean
  })
  disabled!: false;

  //Echo content while editing
  @Watch("content", { deep: true })
  onValueChange(newValue) {
    this.quill.clipboard.dangerouslyPasteHTML(newValue)
  }

  // Is the editor editable
  @Watch("disabled", { deep: true })
  onDisabledChange(newValue) {
    if (newValue) {
      this.quill.enable(false);
    } else {
      this.quill.enable();
    }
  }

  type = "add";
  quill = null
  options = {
    theme: "snow",
    placeholder: "please input",
    modules: {
      clipboard: {
        // paste event.
        matchers: [['img', this.handleCustomMatcher]],
      },

      imageResize: {},
      toolbar: {
        container: toolbarOptions,
        // toolbar event
        handlers: {
          image: function (value) {
            if (value) {
              (document.querySelector(".upload-img") as any).click();
            } else {
              this.quill.format("image", false);
            }
          },
          link: function (value) {
            if (value) {
              (document.querySelector(".upload-file") as any).click();
            }
          },
          'table': function (val) {
            let module = this.quill.getModule('better-table')
            module.getTable()  // current selection
            module.getTable(this.quill.getSelection())
            module.insertTable(3, 3) // init table
          },
          'table-insert-row': function () {
            this.quill.getModule('table').insertRowBelow()
          },
          'table-insert-column': function () {
            this.quill.getModule('table').insertColumnRight()
          },
          'table-delete-row': function () {
            this.quill.getModule('table').deleteRow()
          },
          'table-delete-column': function () {
            this.quill.getModule('table').deleteColumn()
          }
        },
      },
      table: true,
      'better-table': {
        operationMenu: {
          items: {
            unmergeCells: {
              text: 'Another unmerge cells name'
            }
          },
          color: {
            colors: ['green', 'red', 'yellow', 'blue', 'white'],
            text: 'Background Colors:'
          }
        }
      },
      keyboard: {
        bindings: QuillBetterTable.keyboardBindings
      },
    },
  };

  mounted() {
    let dom = this.$el.querySelector('#editor-wrapper')
    this.quill = new Quill(dom, this.options);
    this.quill.on('text-change', () => {
      this.$emit('editContent', this.quill.root.innerHTML)
    });

    this.toolbarAaddTitle()
  }
  beforeDestroy() {
    this.quill = null;
    delete this.quill;
  }
  toolbarAaddTitle() {
    const toolbar = document.querySelector('.ql-toolbar');
    const toolbarBtn = toolbar.querySelectorAll('.ql-formats button');
    const toolbarSpan = toolbar.querySelectorAll('.ql-formats span');
    toolbarBtn.forEach((item) => {
      if(item.className === 'ql-bold') {
        item['title'] = 'bold'
      } else if (item.className === 'ql-italic') {
        item['title'] = 'italic'
      } else if (item.className === 'ql-underline') {
        item['title'] = 'underline'
      } else if (item.className === 'ql-strike') {
        item['title'] = 'strike'
      } else if (item.className === 'ql-blockquote') {
        item['title'] = 'blockquote'
      } else if (item.className === 'ql-code-block') {
        item['title'] = 'code-block'
      } else if (item.className === 'ql-header') {
        item['value'] ==='1' ? item['title'] = 'H1': item['title'] = 'H2'
      }  else if (item.className === 'ql-list') {
        item['value'] ==='ordered' ? item['title'] = 'order-list': item['title'] = 'bullet-list'
      } else if (item.className === 'ql-script') {
        item['value'] ==='sub' ? item['title'] = 'sub-script': item['title'] = 'super-script'
      } else if (item.className === 'ql-indent') {
        item['value'] ==='-1' ? item['title'] = 'left-indent': item['title'] = 'right-indent'
      } else if (item.className === 'ql-direction') {
        item['title'] = 'left-direction'
      } else if (item.className === 'ql-image') {
        item['title'] = 'upload-images'
      } else if (item.className === 'ql-link') {
        item['title'] = 'upload-files'
      } else if (item.className === 'ql-clean') {
        item['title'] = 'clean'
      } else if (item.className === 'ql-table') {
        item['title'] = 'table'
      }
    })
    toolbarSpan.forEach((item) => {
      if(item.classList[0] === 'ql-size') {
        item['title'] = 'font-size'
      } else if (item.classList[0] === 'ql-header') {
        item['title'] = 'header-size'
      } else if (item.classList[0] === 'ql-color') {
        item['title'] = 'font-color'
      } else if (item.classList[0] === 'ql-background') {
        item['title'] = 'background-color'
      } else if (item.classList[0] === 'ql-font') {
        item['title'] = 'font-family'
      } else if (item.classList[0] === 'ql-align') {
        item['title'] = 'align'
      } else if (item.classList[0] === 'ql-size') {
        item['title'] = 'font-size'
      }
    })
  }
  handleCustomMatcher(node, Delta) {
    console.log(node, Delta)
    let ops = []
    Delta.ops.forEach(op => {
      if (op.insert && typeof op.insert === 'string') { // if paste img,there will be a object
        ops.push({
          insert: op.insert,
          attributes: op.atributes
        })
      } else {
        // user this.quill.container,this.quill.root => There was a problem when listening to the image paste for the first time
        this.quill.container.addEventListener("paste",
          (evt) => {
            this.type = 'edit'
            if (
              evt.clipboardData &&
              evt.clipboardData.files &&
              evt.clipboardData.files.length
            ) {
              evt.preventDefault();
              this.uploadImg(evt.clipboardData.files[0]);
            }
          }, false);
      }
    })
    Delta.ops = ops
    return Delta
  }
  // upload file
  uploadFile(blobInfo) {
    let formData = new FormData();
    if (this.type === 'edit') {
      formData.append("fileContent", blobInfo);
      formData.append("fileName", blobInfo.name);
    } else {
      formData.append("fileContent", blobInfo.file);
      formData.append("fileName", blobInfo.file.name);
    }
    upload(formData).then((res) => {
      if (res.errno === 0) {
        let address = this.actionUrl + res.data[0].path;
        this.$emit('uploadFileId', res.data[0].attachmentID);
        this.handleFileSuccess(address, blobInfo.file);
      } else {
        console.log("ERROR");
      }
    });
  }
  // upload img
  uploadImg(blobInfo) {
    console.log('uploadImg', blobInfo, blobInfo.name)
    let formData = new FormData();
    if (this.type === 'edit') {
      formData.append("fileContent", blobInfo);
      formData.append("fileName", blobInfo.name);
    } else {
      formData.append("fileContent", blobInfo.file);
      formData.append("fileName", blobInfo.file.name);
    }
    upload(formData).then((res) => {
      if (res.errno === 0) {
        let address = this.actionUrl + res.data[0].path;
        this.$emit('uploadFileId', res.data[0].attachmentID);
        this.handleImgSuccess(address);
      } else {
        console.log("ERROR");
      }
    });
  }

  // file upload success,callback
  handleFileSuccess(res, file) {
    let fileNameLength = file.name.length;
    let length = this.quill.getSelection().index;
    this.quill.insertEmbed(
      length,
      "link",
      { href: res, innerText: file.name },
      "api"
    );
    this.quill.setSelection(length + fileNameLength);
  }

  // img upload success,callback
  handleImgSuccess(res) {
    if (res) {
      let length = this.quill.getSelection().index; // get cursor position
      this.quill.insertEmbed(length, "image", res); // insert img,res is the image link address returned by the server
      this.quill.setSelection(length + 1); // adjust cursor to the end
    } else {
      this.$message.error("Picture insertion failed");
    }
  }
}
</script>

<style lang="scss" scoped>
@import "./index.scss";
</style>
<style>
@import "./theme.scss";
</style>
View Code

 

posted @ 2022-03-02 11:04  TigerZhang  阅读(3314)  评论(0编辑  收藏  举报