前言
最近因業務需求在項目中嵌入了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少,無外部依賴,大家用了都說好,嗯,沒錯就是它了。
編輯器配置方面只要能看得懂英文耍起來還是比較簡單的,適配中碰到的大部分問題都可以通過看文檔解決,即便看文檔解決不了網上也有大量的文章能告訴你怎么配置能解決。
當然了,主要是我這里需要解決一些別人覺得超簡單自己一想都很煩人的需求,比如:
- word文檔粘貼進來要帶格式
- 兼容移動端
- word文檔粘貼進來要正常顯示並且還要兼容移動端
- 電腦網頁里粘貼進來內容要正常顯示並且排版還不能亂
- 電腦網頁拷過來的內容還要兼容到移動端
初始化
因為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'
后面有機會再寫下單獨打包的事項,畢竟這貨體積還不小。
插入vue組件模板
<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
具體內容看官網的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>
但是作為一名優秀的程序員,這怎么可能夠嘛。
下面說下打包的事情
塞入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控制下文件的過期時間即可。
這樣,在保證了靈活度的前提下也保證了業務載入的速度。
完,感謝閱讀。