富文本编辑器使用心得

最近因业务需求在项目中嵌入了tinymce这个编辑器,用于满足平台给用户编辑各类文章。

各大WYSIWYG编辑器的简单比较

UEditor: 因为已经不再维护了,需要大量修改源码,很多都是专门为jsp等服务器渲染项目写的代码需要删除, 然后越删越害怕越删越不敢用,依赖jquery,需要专门用js去parse编辑完成的内容,parse完的内容还可能污染全局css,兼容老浏览器还不错, 但是,我们不怎么考虑兼容IE。所以,告辞。


wangEditor: 中文文档,上手快,依赖jquery,功能少点要花时间去写插件,需要单独为图片上传功能写个接口,老项目忙着上线临时用过,感觉并不适合当前业务这么重的编辑功能于是放弃了。


Quill:api友好, 功能少,需要特定的css去解析文本(这点我不大喜欢),ui好看,适合作为论坛回帖功能使用。


CKEditor: CKEditor目前主流的还是4.x的版本,但是文档看着很瞎眼实在是提不起兴致去配置,草草用了下就放弃了,5.x版本刚从beta结束,需要指定专门的node以及npm版本,虽然功能强大配置灵活ui漂亮不过目前糟糕的兼容性基本是不可能出现在大众视野了。


KingEditor: 丑,不喜欢,不爱用


Draft-js: 知乎最近刚改的文本编辑器就是在draft的基础上开发的,依赖react, 弃。


Medium-editor: 虽然看着感觉很酷炫,但是,不适合我们的业务场景啊, api也简陋可怕。


trix: 嗯,又一个小而美,放弃


Slate:react,放弃


Bootstrap-wysiwyg:bootstrap, jquery, 放弃

tinymce: 文档好,功能强,bug少,无外部依赖,大家用了都说好,嗯,没错就是它了。

https://www.tiny.cloud/docs/quick-start/

编辑器配置方面只要能看得懂英文耍起来还是比较简单的,适配中碰到的大部分问题都可以通过看文档解决,即便看文档解决不了网上也有大量的文章能告诉你怎么配置能解决。

当然了,主要是我这里需要解决一些别人觉得超简单自己一想都很烦人的需求,比如:

word文档粘贴进来要带格式

兼容移动端

word文档粘贴进来要正常显示并且还要兼容移动端

电脑网页里粘贴进来内容要正常显示并且排版还不能乱

电脑网页拷过来的内容还要兼容到移动端

初始化

因为tinymce的Plugins是按需加载的

为了能先快速上手这个编辑器

就先在vue-cli的index.html中默认塞入一条在线cdn地址

记得去下载语言包到本地,

然后就在文件内引入

import'./zh_CN.js'

后面有机会再写下单独打包的事项,毕竟这货体积还不小。

插入vue组件模板

记得一定要在textarea外面包一层div,不然...你自己试试看就知道了。

组件基础配置

将tinymce通过指定的selector挂载到组件中

import'./zh_CN.js'exportdefault{ data () {constId =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 () {// 销毁tinymcethis.$emit('on-destroy')window.tinymce.remove(`#${this.Id}`) },methods: { init () {constself =thisthis.Editor =window.tinymce.init({// 默认配置...this.DefaultConfig,// prop内传入的的config...this.config,// 挂载的DOM对象selector:`#${this.Id}`,setup:(editor) =>{// 抛出 'on-ready' 事件钩子editor.on('init', () => { self.loading =falseself.$emit('on-ready') editor.setContent(self.value) } )// 抛出 'input' 事件钩子,同步value数据editor.on('input change undo redo', () => { self.$emit('input', editor.getContent()) } ) } }) } } }

好了,组件基本的初始化完成,后面正式开始踩坑之旅

API

具体内容看官网的API就行,英语不好的用chrome翻译下对照着demo也能看个七七八八,当然主要原因还是我比较懒。

我这边根据自身业务需求在组件的data内写了个默认配置

TinyMCE默认的工具栏按钮列表:

newdocument(新文档)

bold(加粗)

italic(斜体)

underline(下划线)

strikethrough(删除线)

alignleft(左对齐)

aligncenter(居中对齐)

alignright(右对齐)

alignjustify(两端对齐)

styleselect(格式设置)

formatselect(段落格式)

fontselect(字体选择)

fontsizeselect(字号选择)

cut(剪切)

copy(复制)

paste(粘贴)

bullist(项目列表UL)

numlist(编号列表OL)

outdent(减少缩进)

indent(增加缩进)

blockquote(引用)

undo(撤销)

redo(重做/重复)

removeformat(清除格式)

subscript(下角标)

superscript(上角标)

DefaultConfig: {// GLOBALheight: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 `,// CONFIGforced_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: Pastepaste_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: Fontfontsize_formats:'10px 11px 12px 14px 16px 18px 20px 24px',// CONFIG: StyleSelectstyle_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'}      ]    }  ],// FontSelectfont_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`,// Tabtabfocus_elements:':prev,:next',object_resizing:true,// Imageimagetools_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() {

let formData =new FormData()

// 服务端接收文件的参数名,文件数据,文件名

formData.append('upfile', blobInfo.blob(), blobInfo.filename())

axios({

method:'POST',

// 这里是你的上传地址

url:'http://utils.aibaoxian.com/upLoadFileService?systemId=S10000064',

data: formData,

}).then((res) => {

if (res.data.head.errorCode ==='0000') {

let url ='http://utils.aibaoxian.com/file/' + res.data.body.fileCode +'_' + res.data.body.fileInfo[0].saveFileName

success(url)

}

}).catch(() => {

failure('上传失败')

})

}

  }

至此, 一个组件的封装基本算是完成了

看下初阶成果

import'./zh_CN.js'exportdefault{ data () {constId =Date.now()return{Id: Id,Editor:null,DefaultConfig: {// GLOBALheight: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 `,// CONFIGforced_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: Pastepaste_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: Fontfontsize_formats:'10px 11px 12px 14px 16px 18px 20px 24px',// CONFIG: StyleSelectstyle_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'} ] } ],// FontSelectfont_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`,// Tabtabfocus_elements:':prev,:next',object_resizing:true,// Imageimagetools_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 () {// 销毁tinymcethis.$emit('on-destroy')window.tinymce.remove(`$#{this.Id}`) },methods: { init () {constself =thisthis.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('图片格式错误') }functionuploadPic(){constxhr =newXMLHttpRequest()constformData =newFormData() 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}constjson =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 =falseself.$emit('on-ready') editor.setContent(self.value) } )// 抛出 'input' 事件钩子,同步value数据editor.on('input change undo redo', () => { self.$emit('input', editor.getContent()) } ) } }) } } }

直接引入组件调用就行了

 

塞入webpack

为了加快页面载入速度就要首先解决载入文件过多的问题,而大部分时间用户并不需要每次打开页面都先加载一遍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控制下文件的过期时间即可。

这样,在保证了灵活度的前提下也保证了业务载入的速度。

完,感谢阅读。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容