Vue之富文本tinymce爬坑錄

前言

最近因業務需求在項目中嵌入了tinymce這個富文本編輯器,用於知足平臺給用戶編輯各種新聞內容什麼的業務需求,先後也花了很多時間體驗和對比了市面上各種開源編輯器。php

*案例demo版本:vue-tinymce-democss

在線預覽:vue-tinymce-demo.netlify.com/#/html

各大WYSIWYG編輯器的簡單比較

一、UEditor

由於已經再也不維護了,須要大量修改源碼,不少都是專門爲jsp等服務器渲染項目寫的代碼須要刪除, 而後越刪越懼怕越刪越不敢用,依賴jquery,須要專門用js去parse編輯完成的內容,parse完的內容還可能污染全局css,兼容老瀏覽器還不錯, 可是,咱們不怎麼考慮兼容IE。因此,告辭。vue

二、wangEditor

中文文檔,上手快,依賴jquery,功能少點要花時間去寫插件,須要單獨爲圖片上傳功能寫個接口,老項目忙着上線臨時用過,感受並不適合當前業務這麼重的編輯功能因而放棄了。node

三、Quill

api友好, 功能少,須要特定的css去解析文本(這點我不大喜歡),ui好看,適合做爲論壇回帖功能使用。react

四、CKEditor

CKEditor目前主流的仍是4.x的版本,可是文檔看着很瞎眼實在是提不起興致去配置,草草用了下就放棄了,5.x版本剛從beta結束,須要指定專門的node以及npm版本,雖然功能強大配置靈活ui漂亮不過目前糟糕的兼容性基本是不可能出如今大衆視野了。jquery

在線演示:ckeditor.com/ckeditor-5/…
webpack

五、KingEditor

KindEditor 是一套開源的在線HTML編輯器,主要用於讓用戶在網站上得到所見即所得編輯效果,開發人員能夠用 KindEditor 把傳統的多行文本輸入框(textarea)替換爲可視化的富文本輸入框。 KindEditor 使用 JavaScript 編寫,能夠無縫地與 Java、.NET、PHP、ASP 等程序集成,比較適合在 CMS、商城、論壇、博客、Wiki、電子郵件等互聯網應用上使用。
git

主要特色程序員

  • 快速:體積小,加載速度快
  • 開源:開放源代碼,高水平,高品質
  • 底層:內置自定義 DOM 類庫,精確操做 DOM
  • 擴展:基於插件的設計,全部功能都是插件,可根據需求增減功能
  • 風格:修改編輯器風格很是容易,只需修改一個 CSS 文件
  • 兼容:支持大部分主流瀏覽器,好比 IE、Firefox、Safari、Chrome、Opera

其次就是醜,不喜歡,不愛用,能夠看看界面


六、Draft-js

知乎最近剛改的文本編輯器就是在draft的基礎上開發的,依賴react, 棄。

七、Medium-editor:

雖然看着感受很酷炫,可是,不適合咱們的業務場景啊, api也簡陋可怕。

八、trix:

嗯,又一個小而美,放棄

九、Slate

react,放棄

十、Bootstrap-wysiwyg:

bootstrap, jquery, 放棄

十一、vue-quill-editor

輕量級,工具條配置少,IE10+ 根據quillJs封裝。

擴展使用:www.jianshu.com/p/dc2492160…

十二、tinymce

文檔好,功能強,bug少,無外部依賴,你們用了都說好,嗯,沒錯就是它了。

編輯器配置方面只要能看得懂英文耍起來仍是比較簡單的,適配中碰到的大部分問題均可以經過看文檔解決,即使看文檔解決不了網上也有大量的文章能告訴你怎麼配置能解決。

官方在線演示地址:www.tinymce.com/docs/demo/f…

固然了,主要是我這裏須要解決一些別人以爲超簡單本身一想都很煩人的需求,好比:


  1. word文檔粘貼進來要帶格式
  2. 兼容移動端
  3. word文檔粘貼進來要正常顯示而且還要兼容移動端
  4. 電腦網頁裏粘貼進來內容要正常顯示而且排版還不能亂
  5. 電腦網頁拷過來的內容還要兼容到移動端

高級使用方式

你可能還想要經過一些更高級的方式來使用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的功能。或者,你也能夠自定義一些插件。

關於插件的內容過多,不進行翻譯,後續一些插件也以掛出官網的連接形式展現。

二、自主義UI

一、主題和皮膚

你能夠定製主題和皮膚,經過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/

六、拼寫檢查

www.tinymce.com/docs/get-st…

七、內容過濾

https://www.tinymce.com/docs/get-started/filter-content/

兼容性

移動端:

PC端:

image.png

初始化

由於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對象,而後發現了一個叫baseURLstring對象,嗯,有但願了。

在源碼裏搜了下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控制下文件的過時時間便可。

這樣,在保證了靈活度的前提下也保證了業務載入的速度。

歡迎你們進溝通交流羣互動:

微信號添加請備註

​​image.png

image.png

相關文章
相關標籤/搜索