最近因业务需求在项目中嵌入了tinymce这个富文本编辑器,用于知足平台给用户编辑各种新闻内容什么的业务需求,先后也花了很多时间体验和对比了市面上各种开源编辑器。php
*案例demo版本:vue-tinymce-democss
在线预览:vue-tinymce-demo.netlify.com/#/html
由于已经再也不维护了,须要大量修改源码,不少都是专门为jsp等服务器渲染项目写的代码须要删除, 而后越删越惧怕越删越不敢用,依赖jquery
,须要专门用js去parse编辑完成的内容,parse完的内容还可能污染全局css,兼容老浏览器还不错, 可是,咱们不怎么考虑兼容IE。因此,告辞。vue
中文文档,上手快,依赖jquery
,功能少点要花时间去写插件,须要单独为图片上传功能写个接口,老项目忙着上线临时用过,感受并不适合当前业务这么重的编辑功能因而放弃了。node
api友好, 功能少,须要特定的css去解析文本(这点我不大喜欢),ui好看,适合做为论坛回帖功能使用。react
CKEditor目前主流的仍是4.x
的版本,可是文档看着很瞎眼实在是提不起兴致去配置,草草用了下就放弃了,5.x
版本刚从beta结束,须要指定专门的node以及npm版本,虽然功能强大配置灵活ui漂亮不过目前糟糕的兼容性基本是不可能出如今大众视野了。jquery
在线演示:ckeditor.com/ckeditor-5/…
webpack
KindEditor 是一套开源的在线HTML编辑器,主要用于让用户在网站上得到所见即所得编辑效果,开发人员能够用 KindEditor 把传统的多行文本输入框(textarea)替换为可视化的富文本输入框。 KindEditor 使用 JavaScript 编写,能够无缝地与 Java、.NET、PHP、ASP 等程序集成,比较适合在 CMS、商城、论坛、博客、Wiki、电子邮件等互联网应用上使用。
git
主要特色程序员
其次就是丑,不喜欢,不爱用,能够看看界面
知乎最近刚改的文本编辑器就是在draft的基础上开发的,依赖react
, 弃。
虽然看着感受很酷炫,可是,不适合咱们的业务场景啊, api也简陋可怕。
嗯,又一个小而美,放弃
react
,放弃
bootstrap, jquery
, 放弃
轻量级,工具条配置少,IE10+ 根据quillJs封装。
扩展使用:www.jianshu.com/p/dc2492160…
文档好,功能强,bug少,无外部依赖,你们用了都说好,嗯,没错就是它了。
编辑器配置方面只要能看得懂英文耍起来仍是比较简单的,适配中碰到的大部分问题均可以经过看文档解决,即使看文档解决不了网上也有大量的文章能告诉你怎么配置能解决。
官方在线演示地址:www.tinymce.com/docs/demo/f…
固然了,主要是我这里须要解决一些别人以为超简单本身一想都很烦人的需求,好比:
- word文档粘贴进来要带格式
- 兼容移动端
- word文档粘贴进来要正常显示而且还要兼容移动端
- 电脑网页里粘贴进来内容要正常显示而且排版还不能乱
- 电脑网页拷过来的内容还要兼容到移动端
你可能还想要经过一些更高级的方式来使用tinyMCE。
好比npm: npm install tinymce
bower: bowerinstall tinymce 或者
bower install tinymce-src=git://github.com/tinymce/tinymce
composer: php composer.phar require"tinymce/tinymce" ">= 4"
nuget: Install-PackageTinyMCE
sdk: 你能够在这里下载:www.tinymce.com/download/
jQuery: 若是你但愿获得一个jQuery插件形式的tinyMCE,你能够在这里定制:www.tinymce.com/download/cu…。你能够根据你的意愿,定制你须要的功能。这样,你能够获得一个尽量小的适用的tinyMCE。
tinyMCE有不少插件可使用,好比代码编辑模式、高亮模式,图片上传等等。插件拓展或新增了tinyMCE的功能。或者,你也能够自定义一些插件。
关于插件的内容过多,不进行翻译,后续一些插件也以挂出官网的连接形式展现。
你能够定制主题和皮肤,经过threm和spin来配置它们。
这些配置帮助你定制尺寸,width、height、min_width、max_width、min_height、max_height。
你可能还须要自适应尺寸(www.tinymce.com/docs/plugin…)的插件来帮助你使尺寸更智能。或者,你可使用resize配置。
content_css 可用帮助你定制主体区域的样式。
statusbar 设为false能够隐藏状态栏。
www.tinymce.com/docs/get-st…。页尾。
https://www.tinymce.com/docs/get-started/upload-images/
https://www.tinymce.com/docs/get-started/filter-content/
由于tinymce的Plugins
是按需加载的
为了能先快速上手这个编辑器
就先在vue-cli的index.html中默认塞入一条在线cdn地址
<script src="https://cdn.bootcss.com/tinymce/4.7.4/tinymce.min.js"></script>复制代码
记得去下载语言包到本地,
而后就在文件内引入
import './zh_CN.js'复制代码
后面有机会再写下单独打包的事项,毕竟这货体积还不小。
<template>
<div>
<textarea :id= "Id"></textarea>
</div>
</template>复制代码
记得必定要在textarea
外面包一层div,否则...你本身试试看就知道了。
将tinymce经过指定的selector挂载到组件中
<template>
<div>
<textarea :id= "Id"></textarea>
</div>
</template>
<script>
import './zh_CN.js'
export default {
data () {
const Id = Date.now()
return {
Id: Id,
Editor: null,
DefaultConfig: {}
}
},
props: {
value: {
default: '',
type: String
},
config: {
type: Object,
default: () => {
return {
theme: 'modern',
height: 300
}
}
}
},
mounted () {
this.init()
},
beforeDestroy () {
// 销毁tinymce
this.$emit('on-destroy')
window.tinymce.remove(`#${this.Id}`)
},
methods: {
init () {
const self = this
this.Editor = window.tinymce.init({
// 默认配置
...this.DefaultConfig,
// prop内传入的的config
...this.config,
// 挂载的DOM对象
selector: `#${this.Id}`,
setup: (editor) => {
// 抛出 'on-ready' 事件钩子
editor.on(
'init', () => {
self.loading = false
self.$emit('on-ready')
editor.setContent(self.value)
}
)
// 抛出 'input' 事件钩子,同步value数据
editor.on(
'input change undo redo', () => {
self.$emit('input', editor.getContent())
}
)
}
})
}
}
}
</script>复制代码
好了,组件基本的初始化完成,后面正式开始踩坑之旅
具体内容看官网的API就行,英语很差的用chrome翻译下对照着demo也能看个七七八八,固然主要缘由仍是我比较懒。
我这边根据自身业务需求在组件的data
内写了个默认配置
DefaultConfig: {
// GLOBAL
height: 500,
theme: 'modern',
menubar: false,
toolbar: `styleselect | fontselect | formatselect | fontsizeselect | forecolor backcolor | bold italic underline strikethrough | image media | table | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist | preview removeformat hr | paste code link | undo redo | fullscreen `,
plugins: `
paste
importcss
image
code
table
advlist
fullscreen
link
media
lists
textcolor
colorpicker
hr
preview
`,
// CONFIG
forced_root_block: 'p',
force_p_newlines: true,
importcss_append: true,
// CONFIG: ContentStyle 这块很重要, 在最后呈现的页面也要写入这个基本样式保证先后一致, `table`和`img`的问题基本就靠这个来填坑了
content_style: `
* { padding:0; margin:0; }
html, body { height:100%; }
img { max-width:100%; display:block;height:auto; }
a { text-decoration: none; }
iframe { width: 100%; }
p { line-height:1.6; margin: 0px; }
table { word-wrap:break-word; word-break:break-all; max-width:100%; border:none; border-color:#999; }
.mce-object-iframe { width:100%; box-sizing:border-box; margin:0; padding:0; }
ul,ol { list-style-position:inside; }
`,
insert_button_items: 'image link | inserttable',
// CONFIG: Paste
paste_retain_style_properties: 'all',
paste_word_valid_elements: '*[*]', // word须要它
paste_data_images: true, // 粘贴的同时能把内容里的图片自动上传,很是强力的功能
paste_convert_word_fake_lists: false, // 插入word文档须要该属性
paste_webkit_styles: 'all',
paste_merge_formats: true,
nonbreaking_force_tab: false,
paste_auto_cleanup_on_paste: false,
// CONFIG: Font
fontsize_formats: '10px 11px 12px 14px 16px 18px 20px 24px',
// CONFIG: StyleSelect
style_formats: [
{
title: '首行缩进',
block: 'p',
styles: { 'text-indent': '2em' }
},
{
title: '行高',
items: [
{title: '1', styles: { 'line-height': '1' }, inline: 'span'},
{title: '1.5', styles: { 'line-height': '1.5' }, inline: 'span'},
{title: '2', styles: { 'line-height': '2' }, inline: 'span'},
{title: '2.5', styles: { 'line-height': '2.5' }, inline: 'span'},
{title: '3', styles: { 'line-height': '3' }, inline: 'span'}
]
}
],
// FontSelect
font_formats: `
微软雅黑=微软雅黑;
宋体=宋体;
黑体=黑体;
仿宋=仿宋;
楷体=楷体;
隶书=隶书;
幼圆=幼圆;
Andale Mono=andale mono,times;
Arial=arial, helvetica,
sans-serif;
Arial Black=arial black, avant garde;
Book Antiqua=book antiqua,palatino;
Comic Sans MS=comic sans ms,sans-serif;
Courier New=courier new,courier;
Georgia=georgia,palatino;
Helvetica=helvetica;
Impact=impact,chicago;
Symbol=symbol;
Tahoma=tahoma,arial,helvetica,sans-serif;
Terminal=terminal,monaco;
Times New Roman=times new roman,times;
Trebuchet MS=trebuchet ms,geneva;
Verdana=verdana,geneva;
Webdings=webdings;
Wingdings=wingdings,zapf dingbats`,
// Tab
tabfocus_elements: ':prev,:next',
object_resizing: true,
// Image
imagetools_toolbar: 'rotateleft rotateright | flipv fliph | editimage imageoptions'
}复制代码
由于本人比较懒,以上配置导出的代码可能会有代码注入的风险,建议保存的时候再先后端都作下注入过滤,不过通常数据安全问题主要仍是服务器那边的事情?。
后面的图片上传能够单独拆出来作个小配置,直接写到props
里好了。
url: {
default: '',
type: String
},
accept: {
default: 'image/jpeg, image/png',
type: String
},
maxSize: {
default: 2097152,
type: Number
},
withCredentials: {
default: false,
type: Boolean
}复制代码
而后把这套东西塞到init
配置里
// 图片上传
images_upload_handler: function (blobInfo, success, failure) {
if (blobInfo.blob().size > self.maxSize) {
failure('文件体积过大')
}
if (self.accept.indexOf(blobInfo.blob().type) >= 0) {
uploadPic()
} else {
failure('图片格式错误')
}
function uploadPic () {
const xhr = new XMLHttpRequest()
const formData = new FormData()
xhr.withCredentials = self.withCredentials
xhr.open('POST', self.url)
xhr.onload = function () {
if (xhr.status !== 200) {
// 抛出 'on-upload-fail' 钩子
self.$emit('on-upload-fail')
failure('上传失败: ' + xhr.status)
return
}
const json = JSON.parse(xhr.responseText)
// 抛出 'on-upload-success' 钩子
self.$emit('on-upload-complete' , [
json, success, failure
])
}
formData.append('file', blobInfo.blob())
xhr.send(formData)
}
}复制代码
至此, 一个组件的封装基本算是完成了
看下初阶成果
<template>
<div>
<textarea :id= "Id"></textarea>
</div>
</template>
<script>
import './zh_CN.js'
export default {
data () {
const Id = Date.now()
return {
Id: Id,
Editor: null,
DefaultConfig: {
// GLOBAL
height: 500,
theme: 'modern',
menubar: false,
toolbar: `styleselect | fontselect | formatselect | fontsizeselect | forecolor backcolor | bold italic underline strikethrough | image media | table | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist | preview removeformat hr | paste code link | undo redo | fullscreen `,
plugins: `
paste
importcss
image
code
table
advlist
fullscreen
link
media
lists
textcolor
colorpicker
hr
preview
`,
// CONFIG
forced_root_block: 'p',
force_p_newlines: true,
importcss_append: true,
// CONFIG: ContentStyle 这块很重要, 在最后呈现的页面也要写入这个基本样式保证先后一致, `table`和`img`的问题基本就靠这个来填坑了
content_style: `
* { padding:0; margin:0; }
html, body { height:100%; }
img { max-width:100%; display:block;height:auto; }
a { text-decoration: none; }
iframe { width: 100%; }
p { line-height:1.6; margin: 0px; }
table { word-wrap:break-word; word-break:break-all; max-width:100%; border:none; border-color:#999; }
.mce-object-iframe { width:100%; box-sizing:border-box; margin:0; padding:0; }
ul,ol { list-style-position:inside; }
`,
insert_button_items: 'image link | inserttable',
// CONFIG: Paste
paste_retain_style_properties: 'all',
paste_word_valid_elements: '*[*]', // word须要它
paste_data_images: true, // 粘贴的同时能把内容里的图片自动上传,很是强力的功能
paste_convert_word_fake_lists: false, // 插入word文档须要该属性
paste_webkit_styles: 'all',
paste_merge_formats: true,
nonbreaking_force_tab: false,
paste_auto_cleanup_on_paste: false,
// CONFIG: Font
fontsize_formats: '10px 11px 12px 14px 16px 18px 20px 24px',
// CONFIG: StyleSelect
style_formats: [
{
title: '首行缩进',
block: 'p',
styles: { 'text-indent': '2em' }
},
{
title: '行高',
items: [
{title: '1', styles: { 'line-height': '1' }, inline: 'span'},
{title: '1.5', styles: { 'line-height': '1.5' }, inline: 'span'},
{title: '2', styles: { 'line-height': '2' }, inline: 'span'},
{title: '2.5', styles: { 'line-height': '2.5' }, inline: 'span'},
{title: '3', styles: { 'line-height': '3' }, inline: 'span'}
]
}
],
// FontSelect
font_formats: `
微软雅黑=微软雅黑;
宋体=宋体;
黑体=黑体;
仿宋=仿宋;
楷体=楷体;
隶书=隶书;
幼圆=幼圆;
Andale Mono=andale mono,times;
Arial=arial, helvetica,
sans-serif;
Arial Black=arial black, avant garde;
Book Antiqua=book antiqua,palatino;
Comic Sans MS=comic sans ms,sans-serif;
Courier New=courier new,courier;
Georgia=georgia,palatino;
Helvetica=helvetica;
Impact=impact,chicago;
Symbol=symbol;
Tahoma=tahoma,arial,helvetica,sans-serif;
Terminal=terminal,monaco;
Times New Roman=times new roman,times;
Trebuchet MS=trebuchet ms,geneva;
Verdana=verdana,geneva;
Webdings=webdings;
Wingdings=wingdings,zapf dingbats`,
// Tab
tabfocus_elements: ':prev,:next',
object_resizing: true,
// Image
imagetools_toolbar: 'rotateleft rotateright | flipv fliph | editimage imageoptions'
}
}
},
props: {
value: {
default: '',
type: String
},
config: {
type: Object,
default: () => {
return {
theme: 'modern',
height: 300
}
}
},
url: {
default: '',
type: String
},
accept: {
default: 'image/jpeg, image/png',
type: String
},
maxSize: {
default: 2097152,
type: Number
},
withCredentials: {
default: false,
type: Boolean
}
},
mounted () {
this.init()
},
beforeDestroy () {
// 销毁tinymce
this.$emit('on-destroy')
window.tinymce.remove(`$#{this.Id}`)
},
methods: {
init () {
const self = this
this.Editor = window.tinymce.init({
// 默认配置
...this.DefaultConfig,
// 图片上传
images_upload_handler: function (blobInfo, success, failure) {
if (blobInfo.blob().size > self.maxSize) {
failure('文件体积过大')
}
if (self.accept.indexOf(blobInfo.blob().type) >= 0) {
uploadPic()
} else {
failure('图片格式错误')
}
function uploadPic () {
const xhr = new XMLHttpRequest()
const formData = new FormData()
xhr.withCredentials = self.withCredentials
xhr.open('POST', self.url)
xhr.onload = function () {
if (xhr.status !== 200) {
// 抛出 'on-upload-fail' 钩子
self.$emit('on-upload-fail')
failure('上传失败: ' + xhr.status)
return
}
const json = JSON.parse(xhr.responseText)
// 抛出 'on-upload-complete' 钩子
self.$emit('on-upload-complete' , [
json, success, failure
])
}
formData.append('file', blobInfo.blob())
xhr.send(formData)
}
},
// prop内传入的的config
...this.config,
// 挂载的DOM对象
selector: `#${this.Id}`,
setup: (editor) => {
// 抛出 'on-ready' 事件钩子
editor.on(
'init', () => {
self.loading = false
self.$emit('on-ready')
editor.setContent(self.value)
}
)
// 抛出 'input' 事件钩子,同步value数据
editor.on(
'input change undo redo', () => {
self.$emit('input', editor.getContent())
}
)
}
})
}
}
}
</script>复制代码
直接引入组件调用就好了
<template>
<mce-editor
:config = "Config"
v-model = "Value"
:url = "Url"
:max-size = "MaxSize"
:accept = "Accept"
:with-credentials = false
@on-ready = "onEditorReady"
@on-destroy = "onEditorDestroy"
@on-upload-success= "onEditorUploadComplete"
@on-upload-fail = "onEditorUploadFail"
></mce-editor>
</template>复制代码
可是做为一名优秀的程序员,这怎么可可以嘛。
下面说下打包的事情
为了加快页面载入速度就要首先解决载入文件过多的问题,而大部分时间用户并不须要每次打开页面都先加载一遍editor的核心文件,而editor自己也要按需加载内容,一开始想把每一个plugin都搞成独立组件模块按需载入,可是这就要涉及到修改编辑器自己源码,或者说对window.tinymce删掉点特性,这些都太麻烦也都有风险,对后面的代码维护影响也大,索性就都先留着。
后面边作边改吧
仍是以vue-cli为例
把官网下载的包塞到stataic
文件夹中
而后删掉index.html
模版中的cdn代码吧不须要了
固然这里有俩选择
要么作成一个异步组件,单独打包,按需载入
要么直接引入到main.js
中将包打成为一个巨无霸
因此我选择前者,
首先老规矩 引入编辑器主体
import '../../static/tinymce/tinymce.min.js'复制代码
而后刷新下页面,不出意外应该是报这么个错Uncaught SyntaxError: Unexpected token <
眼尖的朋友应该知道是怎么回事了theme.js:1
在默认配置下, tinymce载入的theme的路径竟然是这个
Request URL:http://localhost:8080/themes/modern/theme.js
而后我跑去官网搜了下api 只搜到一个叫document_base_url的api,可是根据多年程序员的直觉经验告诉我 不是这货(嗯,我在这里卡住了),网上翻了下各地文献,都没有啊,
那怎么办呢
因而我就跑去看源码...可是4万行...算了...
而后我就在控台打印了下tinymce对象,而后发现了一个叫baseURL
的string
对象,嗯,有但愿了。
在源码里搜了下baseURL
蹦出来这段代码 .... 算了有不少段...
大体思想就是经过当前URI拆出来个baseURL,改掉就好了
window.tinymce.baseURL = '/static/tinymce'复制代码
若是须要载入的地址是另外一个好比本身公司的cdn的路径,那改为全路径就好了
window.tinymce.baseURL = 'http://cdn.xxx.com/static/tinymce'复制代码
貌似路径的问题解决了
可是新的问题又出现了,
插件下过来都是带min的,但默认载入的插件都是不带min的,必定是我源码没看仔细,
而后我又搜了一下代码
if (!baseURL && document.currentScript) {
src = document.currentScript.src;
if (src.indexOf('.min') != -1) {
suffix = '.min';
}
baseURL = src.substring(0, src.lastIndexOf('/'));
}复制代码
但愿就在眼前,貌似是业务我载入的方式是直接导入到模块的,因而一个叫suffix
的默认值为空了,因而我去又加了行代码:
window.tinymce.suffix = '.min'复制代码
成功!
你看嘛,超级简单的是否是,根本不用改源码,网上说的动不动就去改源码什么的不要信啊不要信,大部分面向对象的事情改个默认值就好了。
对了,还记得前面的语言包嘛,
下过来塞到/static/tinymce/langs
文件夹里
而后删掉
import './zh_CN.js'复制代码
这行代码
在DefaultConfig
中放入一个新配置项
language: 'zh_CN'复制代码
好了,后面就是模块打包的事情了,
前面打的包有一个问题是默认配置是载入tinyMce本体,那么就会形成这个包大概有500k的体积,若是这个组件不作异步载入的处理,那么对于某些业务来讲就是灾难。虽然这么作打开只用载入一个文件,业务比较稳定。
但我以为这样不优雅因此最后仍是把它单独拎出来了。
同理,根据这个库自己的特性,咱们彻底能够把这么多个必须的plugin
按须要直接统一打成一个包,直接载入。这样,咱们就又多了一个几百k的plugins包。
而后把plugins包和tinyMce主体包
在不阻塞页面加载的状况下,作个懒加载提早缓存好文件方便后面使用,而组件自己在挂载前作个监听window.tinymce全局变量的方法,而后cdn控制下文件的过时时间便可。
这样,在保证了灵活度的前提下也保证了业务载入的速度。
欢迎你们进沟通交流群互动:
微信号添加请备注