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的版本问题。于是将
View Code
View Code
"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' }]); }
组件代码(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>