在 Vue 项目中引入 tinymce 富文本编辑器
项目中原本使用的富文本编辑器是 wangEditor,这是一个很轻量、简洁编辑器
但是公司的业务升级,想要一个功能更全面的编辑器,我找了好久,目前常见的编辑器有这些:
UEditor:百度前端的开源项目,功能强大,基于 jQuery,但已经没有再维护,而且限定了后端代码,修改起来比较费劲
bootstrap-wysiwyg:微型,易用,小而美,只是 Bootstrap + jQuery...
kindEditor:功能强大,代码简洁,需要配置后台,而且好久没见更新了
wangEditor:轻量、简洁、易用,但是升级到 3.x 之后,不便于定制化开发。不过作者很勤奋,广义上和我是一家人,打个call
quill:本身功能不多,不过可以自行扩展,api 也很好懂,如果能看懂英文的话...
summernote:没深入研究,UI挺漂亮,也是一款小而美的编辑器,可是我需要大的
在有这么参考的情况下,我最终还是选择了 tinymce 这个不搞黑科技连官网都打不开的编辑器(简直是自讨苦吃),主要因为两点:
1. GitHub 上星星很多,功能也齐全;
2. 唯一一个从 word 粘贴过来还能保持绝大部分格式的编辑器;
3. 不需要找后端人员扫码改接口,前后端分离;
4. 说好的两点呢!
注意:这篇博客仅适用于 Tinymce 4.x
除了 tinymce 之外,CKEditor 5 也是一个大而全的富文本编辑器
但 CK5 门槛较高,有兴趣的可以看这篇文章《CKEditor 5 摸爬滚打(一)—— 从零构建定制化工程项目》
一、资源下载
tinymce 官方为 vue 项目提供了一个组件 tinymce-vue
npm install @tinymce/tinymce-vue -S
在 vscode、webstorm 的终端运行这段代码可能会报错,最好使用操作系统自带的命令行工具
如果有购买 tinymce 的服务,可以参考 tinymce-vue 的说明,通过 api-key 直接使用 tinymce
像我这样没购买的,还是要老老实实下载 tinymce
npm install tinymce -S
如果你用是vue2.0版本的话不要安装这个,vue2中不能使用@tinymce/tinymce-vue为4以上版本;
安装低版本的:例如:npm install @tinymce/tinymce-vue@3.0.1 -S
而tinymce版本大小无所谓
安装之后,在 node_modules 中找到 tinymce/skins 目录,然后将 skins 目录拷贝到 static 目录下
// 如果是使用 vue-cli 3.x 构建的 typescript 项目,就放到 public 目录下,文中所有 static 目录相关都这样处理
tinymce 默认是英文界面,所以还需要下载一个中文语言包(记得用科学的办法来访问!)
然后将这个语言包放到 static 目录下,为了结构清晰,我包了一层 tinymce 目录
二、初始化
在页面中引入以下文件
import tinymce from 'tinymce/tinymce' import 'tinymce/themes/modern/theme' import Editor from '@tinymce/tinymce-vue'
经评论区提醒,如果找不到 import 'tinymce/themes/modern/theme'
可以替换成 import 'tinymce/themes/silver/theme'
tinymce-vue 是一个组件,需要在 components 中注册,然后直接使用
<Editor id="tinymce" v-model="tinymceHtml" :init="editorInit"></Editor>
这里的 init 是 tinymce 初始化配置项,后面会讲到一些关键的 api,完整 api 可以参考官方文档
编辑器需要一个 skin 才能正常工作,所以要设置一个 skin_url 指向之前复制出来的 skin 文件
editorInit: { language_url: '/static/tinymce/zh_CN.js', language: 'zh_CN', skin_url: '/static/tinymce/skins/lightgray', height: 300 }
// vue-cli 3.x 创建的 typescript 项目,将 url 中的 static 去掉,即 skin_url: '/tinymce/skins/lightgray'
同时在 mounted 中也需要初始化一次:
如果在这里传入上面的 init 对象,并不能生效,但什么参数都不传也会报错,所以这里传入一个空对象
有朋友反映这里有可能出现以下报错
这是因为 init 参数地址错误,请核对下 init 参数中的几个路径是否正确
如果参数无误,可以先删除 language_url 和 language 再试
三、扩展插件
完成了上面的初始化之后,就已经能正常运行编辑器了,但只有一些基本功能
tinymce 通过添加插件 plugins 的方式来添加功能
比如要添加一个上传图片的功能,就需要用到 image 插件,添加超链接需要用到 link 插件
同时还需要在页面引入这些插件:
添加了插件之后,默认会在工具栏 toolbar 上添加对应的功能按钮,toolbar 也可以自定义
贴一下完整的组件代码:
<template>
<div class="tinymce">
<editor id="tinymce" v-model="Html" :init="tinymceInit" @input="echoEditor"></editor>
<!-- <div v-html="Html"></div> -->
</div>
</template>
<script>
import uploadApi from '@/api/uploadApi/upload'
import tinymce from 'tinymce/tinymce'
import 'tinymce/themes/silver/theme'
import Editor from '@tinymce/tinymce-vue'
import 'tinymce/icons/default/icons'
import 'tinymce/plugins/image'
import 'tinymce/plugins/link'
import 'tinymce/plugins/code'
import 'tinymce/plugins/table'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/contextmenu'
import 'tinymce/plugins/wordcount'
import 'tinymce/plugins/colorpicker'
import 'tinymce/plugins/textcolor'
import 'tinymce/plugins/print'
import 'tinymce/plugins/preview'
import 'tinymce/plugins/searchreplace'
import 'tinymce/plugins/autolink'
import 'tinymce/plugins/directionality'
import 'tinymce/plugins/visualblocks'
import 'tinymce/plugins/visualchars'
import 'tinymce/plugins/media'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/template'
import 'tinymce/plugins/codesample'
import 'tinymce/plugins/charmap'
import 'tinymce/plugins/hr'
import 'tinymce/plugins/pagebreak'
import 'tinymce/plugins/nonbreaking'
import 'tinymce/plugins/anchor'
import 'tinymce/plugins/insertdatetime'
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/wordcount'
import 'tinymce/plugins/imagetools'
import 'tinymce/plugins/textpattern'
import 'tinymce/plugins/help'
import 'tinymce/plugins/emoticons'
import 'tinymce/plugins/autosave'
import 'tinymce/plugins/autoresize'
// import 'tinymce/baseURL/icons/custom'
// import '../../../public/tinymce/plugins/emoticons'
import '../../../public/tinymce/plugins/bdmap/plugin'
import '../../../public/tinymce/plugins/indent2em/plugin'
import '../../../public/tinymce/plugins/axupimgs/plugin'
// import 'tinymce/plugins/formatpainter'
export default {
name: 'Tinymce',
data() {
return {
Html: '',
tinymceInit: {
language_url: '../../../tinymce/zh_CN.js',
language: 'zh_CN',
skin_url: '../../../tinymce/skins/ui/oxide',
emoticons_database_url: '../../../tinymce/plugins/emoticons/js/emojis.js',
plugins:
'print preview searchreplace autolink directionality visualblocks visualchars fullscreen image link media template code codesample table charmap hr pagebreak nonbreaking anchor insertdatetime advlist lists wordcount imagetools textpattern help emoticons autosave autoresize bdmap indent2em axupimgs',
// toolbar:
// 'code undo redo restoredraft | cut copy paste pastetext | forecolor backcolor bold italic underline strikethrough link anchor | alignleft aligncenter alignright alignjustify outdent indent | \
// styleselect formatselect fontselect fontsizeselect | bullist numlist | blockquote subscript superscript removeformat | \
// table image media charmap emoticons hr pagebreak insertdatetime print preview | fullscreen | bdmap indent2em lineheight axupimgs',
toolbar: [
'code undo redo restoredraft | cut copy paste pastetext | forecolor backcolor bold italic underline strikethrough link anchor | alignleft aligncenter alignright alignjustify outdent indent | \
styleselect formatselect fontselect fontsizeselect | bullist numlist | blockquote subscript superscript removeformat | \
table image media charmap emoticons hr pagebreak insertdatetime print preview | fullscreen | bdmap indent2em lineheight axupimgs'
],
height: 650, //编辑器高度
min_height: 400,
// icons: 'custom',
// inline: true,
// statusbar: false,
/*content_css: [ //可设置编辑区内容展示的css,谨慎使用
'/static/reset.css',
'/static/ax.css',
'/static/css.css',
],*/
fontsize_formats: '12px 14px 16px 18px 24px 36px 48px 56px 72px',
font_formats:
'微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;',
link_list: [
{ title: '预置链接1', value: 'http://www.tinymce.com' },
{ title: '预置链接2', value: 'http://tinymce.ax-z.cn' }
],
image_list: [
{ title: '预置图片1', value: 'https://www.tiny.cloud/images/glyph-tinymce@2x.png' },
{ title: '预置图片2', value: 'https://www.baidu.com/img/bd_logo1.png' }
],
image_class_list: [
{ title: 'None', value: '' }
// { title: 'Some class', value: 'class-name' }
],
importcss_append: true,
//自定义文件选择器的回调内容
file_picker_callback: function (callback, value, meta) {
if (meta.filetype === 'file') {
// // 要先模拟出一个input用于上传本地文件
var input = document.createElement('input')
input.setAttribute('type', 'file')
// 你可以给input加accept属性来限制上传的文件类型
// 例如:input.setAttribute('accept', '.jpg,.png')
input.setAttribute('accept', '.doc,.docx,.ppt,.pptx,.pdf,.xlsx')
input.click()
input.onchange = function () {
var file = this.files[0]
uploadApi.getUpload({ bucketName: 'notice', objectName: file.name }).then(res => {
console.log(res.data.downloadPath)
uploadApi.uploadFile({ url: res.data.uploadPath, file: file }).then(data => {
callback(res.data.downloadPath, { text: file.name })
})
})
}
// callback('https://www.baidu.com/img/bd_logo1.png', { text: 'My text' })
}
if (meta.filetype === 'image') {
// callback('https://www.baidu.com/img/bd_logo1.png', { alt: 'My alt text' })
// // 要先模拟出一个input用于上传本地文件
var input = document.createElement('input')
input.setAttribute('type', 'file')
// 你可以给input加accept属性来限制上传的文件类型
// 例如:input.setAttribute('accept', '.jpg,.png')
input.setAttribute('accept', '.jpg,.png,.jpeg,.svg')
input.click()
input.onchange = function () {
var file = this.files[0]
uploadApi.getUpload({ bucketName: 'notice', objectName: file.name }).then(res => {
console.log(res.data.downloadPath)
uploadApi.uploadFile({ url: res.data.uploadPath, file: file }).then(data => {
callback(res.data.downloadPath, { text: file.name })
})
})
}
}
if (meta.filetype === 'media') {
// callback('https://www.baidu.com/img/bd_logo1.png', { alt: 'My alt text' })
// // 要先模拟出一个input用于上传本地文件
var input = document.createElement('input')
input.setAttribute('type', 'file')
// 你可以给input加accept属性来限制上传的文件类型
// 例如:input.setAttribute('accept', '.jpg,.png')
input.setAttribute('accept', '.mp4,.mov,.wmv,.flv')
input.click()
input.onchange = function () {
var file = this.files[0]
uploadApi.getUpload({ bucketName: 'notice', objectName: file.name }).then(res => {
console.log(res.data.downloadPath)
uploadApi.uploadFile({ url: res.data.uploadPath, file: file }).then(data => {
callback(res.data.downloadPath, { text: file.name })
})
})
}
// callback('movie.mp4', { source2: 'alt.ogg', poster: 'https://www.baidu.com/img/bd_logo1.png' })
}
},
//多图片上传在富文本内展示的路径(全路径时为空)
images_upload_base_path: '',
images_upload_handler: function (blobInfo, succFun, failFun) {
var file = blobInfo.blob() //转化为易于理解的file对象
console.log(file)
// // 要先模拟出一个input用于上传本地文件
var input = document.createElement('input')
input.setAttribute('type', 'file')
// 你可以给input加accept属性来限制上传的文件类型
// 例如:input.setAttribute('accept', '.jpg,.png')
uploadApi.getUpload({ bucketName: 'notice', objectName: file.name }).then(res => {
console.log(res.data.downloadPath)
uploadApi.uploadFile({ url: res.data.uploadPath, file: file }).then(data => {
succFun(res.data.downloadPath)
})
})
// callback('http
},
toolbar_sticky: true,
autosave_ask_before_unload: false
}
}
},
mounted() {
tinymce.init({})
},
methods: {
echoEditor() {
this.$emit('echoEditor', this.Html)
}
},
props: {
tinymceHtml: {
type: String,
default: ''
}
},
watch: {
tinymceHtml: {
handler(val) {
this.Html = val
},
immediate: true,
deep: true
}
},
components: { Editor }
}
</script>
四、上传图片
tinymce 提供了 images_upload_url 等 api 让用户配置上传图片的相关参数
但为了在不麻烦后端的前提下适配自家的项目,还是得用 images_upload_handler 来自定义一个上传方法
这个方法会提供三个参数:blobInfo, success, failure
其中 blobinfo 是一个对象,包含上传文件的信息:
success 和 failure 是函数,上传成功的时候向 success 传入一个图片地址,失败的时候向 failure 传入报错信息
贴一下我自己的上传方法,使用了 axios 发送请求
handleImgUpload (blobInfo, success, failure) { let formdata = new FormData() formdata.set('upload_file', blobInfo.blob()) axios.post('/api/upload', formdata).then(res => { success(res.data.data.src) }).catch(res => { failure('error') }) }