Vue 中使用 Tinymce 富文本编辑器

参考链接:https://www.cnblogs.com/wisewrong/p/8985471.html

Tinymce : 从 word 粘贴过来还能保持绝大部分格式的编辑器

一. 下载

 

npm install tinymce -S

 安装之后,在 node_modules 中找到 tinymce/skins 目录,然后将 skins 目录拷贝到 public 目录下

(如果是使用 vue-cli 2.x 构建的 typescript 项目,就放到 static 目录下)

tinymce 默认是英文界面,所以还需要下载一个中文 语言包

将这个语言包放到 public 目录下,为了结构清晰,我包了一层 tinymce 目录

二. 初始化

import tinymce from 'tinymce/tinymce'
// 初始化发现编辑器不显示,报“theme.js:1 Uncaught SyntaxError: Unexpected token <”这个错
// 需要手动引入tinymce主题,在init({})方法里加theme: 'silver',没用。
import 'tinymce/themes/silver/theme'
cnpm install --save tinymce/theme

三. 使用示例

<template>
  <div name='tinymce'>
    <div class='title'><input placeholder="请输入文章标题" v-model="title"/></div>
    <div :class="{fullscreen:fullscreen}" class="tinymce-container editor-container">
      <textarea :id="tinymceId" class="tinymce-textarea"/>
      <div class="editor-custom-btn-container">
        <editorImage color="var(--main-color)" class="editor-upload-btn" @successCBK="imageSuccessCBK" />
      </div>
    </div>
    <div class="publish">
      <span @click="submission">提交</span>
      <span class="cancel">取消</span>
    </div>
  </div>
</template>

<script>
import plainBtn from '@/components/Buttons/plainBtn'

import tinymce from 'tinymce/tinymce'
// import 'tinymce/themes/mobile/theme'
// import 'tinymce/themes/modern/theme'
// 按示例初始化发现编辑器不显示,报“theme.js:1 Uncaught SyntaxError: Unexpected token <”这个错
// 需要手动引入tinymce主题,在init({})方法里加theme: 'silver',没用。
import 'tinymce/themes/silver/theme'

import editorImage from './components/editorImage'
import plugins from './plugins'
import toolbar from './toolbar'

export default {
  name: 'Tinymce',
  components: { editorImage,plainBtn },
  props: {
    id: {
      type: String,
      default: function() {
        return 'vue-tinymce-' + +new Date() + ((Math.random() * 1000).toFixed(0) + '')
      }
    },
    value: {
      type: String,
      default: 'Editor me ...'
    },
    toolbar: {
      type: Array,
      required: false,
      default() {
        return []
      }
    },
    menubar: {
      type: String,
      default: 'file edit insert view format table'
    },
    height: {
      type: Number,
      required: false,
      default: 360
    }
  },
  data() {
    return {
      hasChange: false,
      hasInit: false,
      tinymceId: this.id,
      fullscreen: false,
      languageTypeList: {
        'zh': 'zh_CN'
      },
      title: ''
    }
  },
  computed: {
    language() {
      return this.languageTypeList[this.$store.getters.language]
    }
  },
  watch: {
    value(val) {
      if (!this.hasChange && this.hasInit) {
        this.$nextTick(() =>
          window.tinymce.get(this.tinymceId).setContent(val || ''))
      }
    },
    language() {
      this.destroyTinymce()
      this.$nextTick(() => this.initTinymce())
    }
  },
  mounted() {
    // 注: 在此需要传入一个空对象
    this.initTinymce({})
  },
  activated() {
    this.initTinymce()
  },
  deactivated() {
    this.destroyTinymce()
  },
  destroyed() {
    this.destroyTinymce()
  },
  methods: {
    initTinymce() {
      const _this = this
      // window.tinymce.baseURL = '/tinymces/tinymce'
      window.tinymce.init({
        language: this.language,
        selector: `#${this.tinymceId}`,
        // 安装之后,在 node_modules 中找到 tinymce/skins 目录,然后将 skins 目录拷贝到 public 的 tinymce 目录下
        // 必须加 skin_url 否则会报错
        skin_url: '/tinymce/skins/ui/oxide',
        height: this.height,
        body_class: 'panel-body ',
        object_resizing: false,
        toolbar: this.toolbar.length > 0 ? this.toolbar : toolbar,
        menubar: this.menubar,
        plugins: plugins,
        end_container_on_empty_block: true,
        powerpaste_word_import: 'clean',
        code_dialog_height: 450,
        code_dialog_width: 1000,
        advlist_bullet_styles: 'square',
        advlist_number_styles: 'default',
        imagetools_cors_hosts: ['www.tinymce.com', 'codepen.io'],
        default_link_target: '_blank',
        link_title: false,
        nonbreaking_force_tab: true, // inserting nonbreaking space &nbsp; need Nonbreaking Space Plugin
        init_instance_callback: editor => {
          if (_this.value) {
            editor.setContent(_this.value)
          }
          _this.hasInit = true
          editor.on('NodeChange Change KeyUp SetContent', () => {
            this.hasChange = true
            this.$emit('input', editor.getContent())
          })
        },
        setup(editor) {
          editor.on('FullscreenStateChanged', (e) => {
            _this.fullscreen = e.state
          })
        }
      })
    },
    destroyTinymce() {
      const tinymce = window.tinymce.get(this.tinymceId)
      if (this.fullscreen) {
        tinymce.execCommand('mceFullScreen')
      }
      if (tinymce) {
        tinymce.destroy()
      }
    },
    setContent(value) {
      window.tinymce.get(this.tinymceId).setContent(value)
    },
    getContent() {
      window.tinymce.get(this.tinymceId).getContent()
    },
    imageSuccessCBK(arr) {
      console.log(arr)
      // 处理图片上传
      const _this = this
      arr.forEach(v => {
        window.tinymce.get(_this.tinymceId).insertContent(`<img class="wscnph" src="${v}" >`)
        // tinyMCE.editors[0].setContent(`<img class="wscnph" src="${v}" >`)
      })
    },
    // 点击 提交
    submission() {
      if(this.title.trim() === ''){
        this.$message({
          message: '请输入文章标题',
          type: 'warning'
        })
        return
      }else if(tinyMCE.activeEditor.getContent().trim() === ''){
        this.$message({
          message: '请输入文章内容',
          type: 'warning'
        })
        return
      }
      // console.log(tinyMCE.activeEditor.getContent())
      console.log(tinyMCE.editors[0].getContent())
    }
  }
}
</script>

<style scoped lang='scss'>
.tinymce-container {
  position: relative;
  line-height: normal;
}
.tinymce-container>>>.mce-fullscreen {
  z-index: 10000;
}
.tinymce-textarea {
  visibility: hidden;
  z-index: -1;
}
.editor-custom-btn-container {
  position: absolute;
  right: 4px;
  top: 4px;
  /*z-index: 2005;*/
}
.fullscreen .editor-custom-btn-container {
  z-index: 10000;
  position: fixed;
}
.editor-upload-btn {
  display: inline-block;
}

// 标题样式
.title{
  & input {
  margin: 20px 0;
  height: 36px;
  line-height: 36px;
  width: calc(100% - 10px);
  outline: none;
  border-radius: 2px;
  border: 1px solid #D2D2D2;
  font-size: 16px;
  padding-left: 10px;
  }
  & input:hover {
    border: 1px solid var(--main-color);
  }
}
// 提交,取消按钮
.publish{
  margin: 20px 0px;
}
.publish span{
  display: inline-block;
  height: 30px;
  line-height: 30px;
  padding: 0 10px;
  border:1px solid var(--main-color);
  color: var(--main-color);
  cursor: pointer;
  border-radius: 2px;
  background: #ffffff;
  margin-right: 20px;
}
.publish span:hover{
  background: var(--main-color);
  color:#ffffff;
}
.publish .cancel{
  border:1px solid var(--main-font-color);
  color: var(--main-font-color);
}
.publish .cancel:hover{
  background: var(--main-font-color);
  color:#ffffff;
}
</style>
// plugins.js

const plugins = ['advlist anchor autolink autosave code codesample colorpicker colorpicker contextmenu directionality emoticons fullscreen hr image imagetools insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textcolor textpattern visualblocks visualchars wordcount']

export default plugins
// toolbar.js

// 加粗 斜体 下划线 删除线 bold italic underline strikethroug 
// 居左 居中 居右 两端对齐 alignleft aligncenter alignright  alignjustify
// 清除 格式选择下拉框(缩进、行高) 段落选择下拉框(段落、标题) 字体选择下拉框 字号选择下拉框  alignnone styleselect formatselect fontselect fontsizeselect
// 剪切 复制 粘贴 cut copy paste
// 减少缩进 增加缩进  outdent indent 
// 引用 撤销 恢复 清除格式 blockquote undo redo removeformat
// 下标 上标 网格线 插入的集合按钮 水平线 无序列表 有序列表  subscript superscript visualaid insert hr bullist numlist 
// 添加和修改链接 去除链接格式 打开选中链接 添加和修改图片 特殊符号 粘贴纯文本 link unlink openlink image charmap pastetext
// 打印 预览 作者 print preview anchor
// 分页符 拼写检查 搜索 pagebreak spellchecker searchreplace
// 代码 全屏 插入时间 插入/编辑表格 删除表格 单元格属性 合并单元格 拆分单元格 在当前行之前插入一个新行 在当前行之后插入一个新行 删除当前行 行属性 剪切选定行 复制选定行 在当前行之前粘贴行 在当前行之后粘贴行 在当前列之前插入一个列 在当前列之后插入一个列 删除当前列 code fullscreen  insertdatetime  table tabledelete tablecellprops tablemergecells tablesplitcells tableinsertrowbefore tabledeleterow tablerowprops tablecutrow tablecopyrow tablepasterowbefore tablepasterowafter tableinsertcolbefore tableinsertcolafter tabledeletecol
// 在当前行之前插入一个新行
const toolbar = ['bold italic underline strikethrough | alignleft aligncenter alignright  alignjustify | alignnone styleselect formatselect fontselect fontsizeselect	| cut copy paste | outdent indent | blockquote undo redo removeformat |  subscript superscript visualaid insert hr bullist numlist | link unlink openlink image charmap pastetext | print preview anchor | pagebreak spellchecker searchreplace |  code fullscreen  insertdatetime  table tabledelete tablecellprops tablemergecells tablesplitcells tableinsertrowbefore tabledeleterow tablerowprops tablecutrow tablecopyrow tablepasterowbefore tablepasterowafter tableinsertcolbefore tableinsertcolafter tabledeletecol ','hr bullist numlist link image charmap preview anchor pagebreak insertdatetime media table emoticons forecolor backcolor fullscreen']

export default toolbar

四. 实现上传图片到七云牛

1.下载

cnpm install qiniu-js
    var qiniu = require('qiniu-js')
    // or
    import * as qiniu from 'qiniu-js'

2. upToken的生成

一般都是后端给的,但是前端也可以实现,我们就在这里以前端的方法实现它

@/utils/quillToken.js

import CryptoJS from 'crypto-js'

const utf16to8 = function (str) {
  /*
  * Interfaces:
  * utf8 = utf16to8(utf16)
  * utf16 = utf8to16(utf8)
  */
  var out, i, len, c
  out = ''
  len = str.length
  for (i = 0 ; i < len; i++) {
    c = str.charCodeAt(i)
    if ((c >= 0x0001) && (c <= 0x007F)) {
      out += str.charAt(i)
    } else if (c > 0x07FF) {
      out += String.fromCharCode(0xE0 | ((c >> 12) & 0x0F))
      out += String.fromCharCode(0x80 | ((c >> 6) & 0x3F))
      out += String.fromCharCode(0x80 | ((c >> 0) & 0x3F))
    } else {
      out += String.fromCharCode(0xC0 | ((c >> 6) & 0x1F))
      out += String.fromCharCode(0x80 | ((c >> 0) & 0x3F))
    }
  }
  return out
}

const base64encode = function (str) {
  /*
  * Interfaces:
  * b64 = base64encode(data)
  * data = base64decode(b64)
  */
  var out, i, len
  var c1, c2, c3
  len = str.length
  i = 0
  out = ''
  var base64EncodeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
  while (i < len) {
    c1 = str.charCodeAt(i++) & 0xff
    if (i == len) {
      out += base64EncodeChars.charAt(c1 >> 2)
      out += base64EncodeChars.charAt((c1 & 0x3) << 4)
      out += '=='
      break
    }
    c2 = str.charCodeAt(i++)
    if (i == len) {
      out += base64EncodeChars.charAt(c1 >> 2)
      out += base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4))
      out += base64EncodeChars.charAt((c2 & 0xF) << 2)
      out += '='
      break
    }
    c3 = str.charCodeAt(i++)
    out += base64EncodeChars.charAt(c1 >> 2)
    out += base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4))
    out += base64EncodeChars.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6))
    out += base64EncodeChars.charAt(c3 & 0x3F)
  }
  return out
}

const base64decode = function (str) {
  var c1, c2, c3, c4
  var i, len, out
  len = str.length
  i = 0
  out = ''
  var base64DecodeChars = new Array(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
    52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
    15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
    41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1)
  while (i < len) {
    /* c1 */
    do {
      c1 = base64DecodeChars[str.charCodeAt(i++) & 0xff]
    } while (i < len && c1 == -1)
    if (c1 == -1) break
    /* c2 */
    do {
      c2 = base64DecodeChars[str.charCodeAt(i++) & 0xff]
    } while (i < len && c2 == -1)
    if (c2 == -1) break
    out += String.fromCharCode((c1 << 2) | ((c2 & 0x30) >> 4))
    /* c3 */
    do {
      c3 = str.charCodeAt(i++) & 0xff
      if (c3 == 61) return out
      c3 = base64DecodeChars[c3]
    } while (i < len && c3 == -1)
    if (c3 == -1) break
    out += String.fromCharCode(((c2 & 0XF) << 4) | ((c3 & 0x3C) >> 2))
    /* c4 */
    do {
      c4 = str.charCodeAt(i++) & 0xff
    if (c4 == 61) return out
      c4 = base64DecodeChars[c4]
    } while (i < len && c4 == -1)
    if (c4 == -1) break
    out += String.fromCharCode(((c3 & 0x03) << 6) | c4)
  }
  return out
}

const safe64 = function (base64) {
  base64 = base64.replace(/\+/g, '-')
  base64 = base64.replace(/\//g, '_')
  return base64
}

const genUpToken = function () {
  // 参数  accessKey,secretKey,putPolicy
  var accessKey = 'q5Oqby268SGSsWEBrkwGW9oQ20qzi2-fXl6Xm1zL'
  var secretKey = 'B7IQmIhh38gIHXEDccW8YN4Yath8vHpwf_aifeDW'
  var putPolicy = {
    'scope':'lyajuan',
    'deadline':Math.round(new Date().getTime() / 1000) + 3600
  }
  // SETP 2
  var putPolicy1 = JSON.stringify(putPolicy)
  // SETP 3
  var encoded = base64encode(utf16to8(putPolicy1))
  // SETP 4
  var hash = CryptoJS.HmacSHA1(encoded, secretKey)
  var encodedSigned = hash.toString(CryptoJS.enc.Base64)
  // SETP 5
  var uploadToken = accessKey + ':' + safe64(encodedSigned) + ':' + encoded
  return uploadToken
}
export {
  utf16to8,
  base64encode,
  base64decode,
  safe64 ,
  genUpToken
}

在需要生成 Token 的 .vue 文件中引入

import {genUpToken} from '@/utils/qiniuToken.js'

安装:crypto-js 加密: https://www.jianshu.com/p/a47477e8126a

cnpm install crypto-js --save
<template>
  <div class="chooseImage" @click="choose">
    <svg-icon icon-class='upload'/>选择图片
    <input type="file" class="pickFile" @change="uploadFile" ref='chooseFile' title="上传文件" multiple :style="{background:color,borderColor:color}"/>
  </div>
</template>

<script>
// 引入七云牛js文件
import * as qiniu from 'qiniu-js'
// 生成token的文件
import {genUpToken} from '@/utils/qiniuToken.js'
import store from '@/store'

export default {
  name: 'chooseImage',
  props: {
    color: {
      type: String,
      default: 'var(--main-color)'
    }
  },
  data() {
    return {
      fileList: []
    }
  },
  methods: {
    choose() {
      this.$refs.chooseFile.click()
    },
    // 上传文件
    uploadFile($event) {
      store.dispatch('SetLoading',true)
      const file = $event.target.files
      for(var i=0;i<file.length; i++) {
        // 限制上传文件的大小为200M
        // console.log(file[i])
        if (file[i].size > 209715200) {
          const cur_size = Math.floor(file.size * 100 / 1024 / 1024) / 100
          this.$notify.info({
            title: '消息',
            message: '上传文件大小不得超过200M 当前文件' + cur_size + 'M '
          })
          return false
        }
        // this.showProgress = true
        const token = genUpToken();
        const fileName = file[i].name
        const suffix = fileName.substring(fileName.lastIndexOf('.')) // 后缀名
        const prefix = fileName.substring(0, fileName.lastIndexOf('.'))
        const key = prefix + token + suffix // 上传文件名
        const observer = {
          next: response => {
            // 上传进度'+Math.floor(response.total.percent)+'%'
            // total.loaded: number,已上传大小,单位为字节。
            // total.total: number,本次上传的总量控制信息,单位为字节,注意这里的 total 跟文件大小并不一致。
            // total.percent: number,当前上传进度,范围:0~100。
            this.uploadProgress = Math.floor(response.total.percent)
            if(this.uploadProgress == 100){
              this.$message('上传成功!')
            }
          },
          error: err => {
            // 上传失败触发
            this.$message.error('上传失败' + err.message)
            console.log(err)
          },
          complete: response => {
            this.uploadProgress = 0
            this.showProgress = false
            this.fileList.push('http://poxcqlozi.bkt.clouddn.com/' + response.key)
          }
        }
        // 可通过 subscription.unsubscribe() 停止当前文件上传 
        const putExtra = {
          // 文件原文件名
          fname: '',
          // 用来放置自定义变量
          params: {},
          // 用来限制上传文件类型,为 null 时表示不对文件类型限制
          // 限制类型放到数组里,如 mimeType: 
          mimeType: ['image/png', 'image/jpeg', 'image/gif']
        }
        const config = {
          // 是否使用 cdn 加速域名,默认false
          useCdnDomain: true,
          // 上传域名区域,当为 null 或 undefined 时,自动分析上传域名区域
          region: qiniu.region.z1
        }
        /*
          file: Blob 对象,上传的文件
          key: 文件资源名
          token: 上传验证信息,前端通过接口请求后端获得
          config: object
        */
        // 关键代码
        let options = {
          quality: 0.92,
          noCompressIfLarger: true,
          maxWidth: 800,
          maxHeight: 618
        }
        qiniu.compressImage(file[i], options).then(data => {
          // data : {
          //   dist: 压缩后输出的 blob 对象,或原始的 file,具体看下面的 options 配置
          //   width: 压缩后的图片宽度
          //   height: 压缩后的图片高度
          // }
          var observable = qiniu.upload(data.dist, key, token, putExtra, config)
          var subscription = observable.subscribe(observer) // 上传开始
        });

      }
      
      this.$emit('successCBK', this.fileList)
      this.fileList = []
    }
  }
}
</script>

<style lang="scss" scoped>
.chooseImage{
  text-align: center;
  background: var(--main-color);
  color: #ffffff;
  border-radius: 5px;
  padding: 5px 10px;
  & input{
    display: none;
  }
  & .svg-icon{
    margin-right: 10px;
  }
}
</style>

猜你喜欢

转载自blog.csdn.net/Lyj1010/article/details/89035925