公司项目需要一个开源且扩展性高的富文本编辑器。经调研比较,有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>
