vue 实现富文本编辑器功能

前端富文本编译器使用总结:

UEditor:百度前端的开源项目,功能强大,基于 jQuery,但已经没有再维护,而且限定了后端代码,修改起来比较费劲

bootstrap-wysiwyg:微型,易用,小而美,只是 Bootstrap + jQuery...

kindEditor:功能强大,代码简洁,需要配置后台,而且好久没见更新了

wangEditor:轻量、简洁、易用,但是升级到 3.x 之后,不便于定制化开发。不过作者很勤奋,广义上和我是一家人,打个call

quill:本身功能不多,不过可以自行扩展,api 也很好懂,如果能看懂英文的话...

summernote:没深入研究,UI挺漂亮,也是一款小而美的编辑器,可是我需要大的

在这里着重说一下这个 tinymce这个插件,

优势有三:

1\. GitHub 上星星很多,功能也齐全;

2\. 唯一一个从 word 粘贴过来还能保持绝大部分格式的编辑器;

3\. 不需要找后端人员扫码改接口,前后端分离;

上代码(vue中使用)

1.引入(两个都得引入)

npm install @tinymce/tinymce-vue -S
npm install tinymce -S

2.在 node_modules 中找到 tinymce/skins 目录,然后将 skins 目录拷贝到 static 目录下

// 如果是使用 vue-cli 3.x 构建的 typescript 项目,就放到 public 目录下,文中所有 static 目录相关都这样处理

3.给你们个语言包(https://www.tiny.cloud/download/language-packages/

4.然后将这个语言包放到 static 目录下,为了结构清晰,我包了一层 tinymce 目录

5.import

import tinymce from 'tinymce/tinymce'
import 'tinymce/themes/modern/theme'
//import "tinymce/themes/silver";
//如果有报错的话可以把import 'tinymce/themes/modern/theme'换成import "tinymce/themes/silver";
//如果控制台提示:icons.js文件中Uncaught SyntaxError: Unexpected token <:报错
//就加上import 'tinymce/icons/default';
// 如果还是不好使的话就改成
// import 'tinymce/icons/default/icons'
import 'tinymce/icons/default';
import Editor from '@tinymce/tinymce-vue'

tinymce-vue 是一个组件,需要在 components 中注册,然后直接使用

<editor id="tinymce" v-model="tinymceHTML" :init="tinymceInit"></editor>

这里的 init 是 tinymce 初始化配置项,后面会讲到一些关键的 api,完整 api 可以参考https://www.tiny.cloud/docs/configure/

编辑器需要一个 skin 才能正常工作,所以要设置一个 skin_url 指向之前复制出来的 skin 文件

data () {
    return {
        tinymceHtml: '请输入内容',
        init: {
          language_url: '/static/langs/tinymce/zh_CN.js',
          language: 'zh_CN',
          skin_url: '/static/tinymce/skins/ui/oxide',
          height: 300,
          plugins: 'link lists image code table colorpicker textcolor wordcount contextmenu',
          toolbar: 'bold italic underline strikethrough | fontsizeselect | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist | outdent indent blockquote | undo redo | link unlink image code | removeformat',
          branding: false
        }
    }
 },

6.同时在 mounted 中也需要初始化一次:

mounted(){
  tinymce.init({}) // 特别注意这个空对象的存在,如果这个初始化空对象不存在依旧会报错
}

效果如下图:

image

富文本框上传图片

PS:images_upload_handler自定义上传图片函数能成功调用,automatic_uploads必须设置为 true,我也是踩了坑才知道的,晕死😵

automatic_uploads: true,
images_upload_handler: (blobInfo, success, failure)=> {
      let files = {};
      // 此处我是上传了阿里云oss
      files = new window.File([blobInfo.blob()], blobInfo.blob().name, {type: blobInfo.blob().type});
      aliupload().upload(files, blobInfo.blob().name ? blobInfo.blob().name : "").then(res=>{
        if (res.res.statusCode == '200') {
          success(res.res.requestUrls[0].split('?')[0]);
          failure('上传失败')
        } else {
          failure('上传失败')
          this.$message.warning('上传图片失败!')
        }
      })
    }

完整代码如下:

<template>
  <div>
    <MenuPanel></MenuPanel>
    <DataPanel>
      <div>
        <el-form
          label-width="100%"
          class="demo-ruleForm"
          :label-position="labelPosition"
          v-model="searchValue"
        >
          <el-row>
            <el-col :span="5">
              <el-row>
                <el-col :span="8">
                  <el-form-item label="二级分类名称" prop="name"></el-form-item>
                </el-col>
                <el-col :span="16">
                  <el-input
                    v-model="searchValue.name"
                    placeholder="请输入二级分类名称"
                  ></el-input>
                </el-col>
              </el-row>
            </el-col>
          </el-row>
        </el-form>
      </div>
      <editor id="tinymce" v-model="tinymceHtml" :init="init"></editor>
      <div class="anniu">
        <el-row>
          <el-col :span="15">
            <el-button type="primary" size="small" icon="el-icon-view" @click="preview">预览</el-button>
            <el-button
              type="primary"
              size="small"
              class="line-btn"
              icon="el-icon-document"
              @click="save"
            >保存</el-button>
          </el-col>
        </el-row>
      </div>
      <el-dialog
        :title="searchValue.name"
        :visible.sync="dialogVisible"
        width="50%"
        :show-close="false"
        custom-class="dialogVisible"
        :close-on-click-modal="false"
        top="5vh"
      >
        <div v-html="tinymceHtml"></div>
        <div slot="footer" class="dialog-footer">
          <el-button
            type="primary"
            plain
            icon="el-icon-close"
            class="line-btn"
            @click="closeTinymceHtml"
          >关 闭</el-button>
        </div>
      </el-dialog>
    </DataPanel>
  </div>
</template>

<script lang="ts">
import { Vue, Component, Prop, Emit, Watch } from "vue-property-decorator";
import DataPanel from "../../components/DataPanel.vue";
import MenuPanel from "./component/MenuPanel.vue";
import * as carManage from '../../store/modules/carManage'
import { default as aliupload } from '../../common/util/ossUploadService'
import tinymce from "tinymce/tinymce";
//import "tinymce/themes/silver";
import "tinymce/themes/silver/theme";
import 'tinymce/icons/default';
import Editor from "@tinymce/tinymce-vue";
import "tinymce/plugins/code";
import "tinymce/plugins/table";
import "tinymce/plugins/lists";
import "tinymce/plugins/contextmenu";
import "tinymce/plugins/wordcount";
import "tinymce/plugins/colorpicker";
import "tinymce/plugins/textcolor";
import 'tinymce/plugins/image'
import 'tinymce/plugins/imagetools'
import 'tinymce/plugins/importcss'
import 'tinymce/plugins/paste'

@Component({
  components: {
    DataPanel,
    Editor,
    MenuPanel
  }
})
export default class InstructionsEditPanel extends Vue {
  public labelPosition: string = "right";
  public searchValue = {
    id: "",
    name: ""
  };
  public dialogVisible: boolean = false;
  public Editortext: string = "";
  public tinymceHtml: string = "";
  
  public init = {
    language_url: "/static/tinymce/langs/zh_CN.js",
    language: "zh_CN",
    skin_url: "/static/tinymce/skins/ui/oxide",
    height: 600,
    menubar: false, //顶部菜单栏显示
    plugins: "lists image imagetools importcss code table colorpicker textcolor wordcount contextmenu paste",
    toolbar: "bold italic underline strikethrough | fontsizeselect | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist | outdent indent blockquote | undo redo  | removeformat | table | image",
    branding: false,
    automatic_uploads: true,
    paste_data_images: true,
    paste_retain_style_properties: 'color',
    paste_word_valid_elements: "table[width|border|border-collapse],tr,td[colspan|rowspan|width],th[colspan|rowspan|width],thead,tfoot,tbody,h1,h2,h3,h4,h5,h6,span,strong,p,div",
    images_upload_handler: (blobInfo, success, failure)=> {
      let files = {};
      files = new window.File([blobInfo.blob()], blobInfo.blob().name, {type: blobInfo.blob().type});
      aliupload().upload(files, blobInfo.blob().name ? blobInfo.blob().name : "").then(res=>{
        if (res.res.statusCode == '200') {
          success(res.res.requestUrls[0].split('?')[0]);
          failure('上传失败')
        } else {
          failure('上传失败')
          this.$message.warning('上传图片失败!')
        }
      })
    }
  };
  @carManage.Action
  public getTwoLevelDetail: (payload: carManage.deleteVehicleTypePayload) => Promise<any>;
  @carManage.Action
  public editTwoLevelcation: (payload: carManage.getTwoLevelDetailPayload) => Promise<any>;
  // 预览
  public preview() {
    this.dialogVisible = true;
    if(this.tinymceHtml.indexOf('#000000') > -1){
      this.tinymceHtml = this.tinymceHtml.replace(/#000000/g, "#ffffff")
    }
  }

  public closeTinymceHtml(){
    this.dialogVisible = false;
    if(this.tinymceHtml.indexOf('#fffff') > -1){
      this.tinymceHtml = this.tinymceHtml.replace(/#ffffff/g, "#000000")
    }
  }
  
  public encode(str) {
    // 对字符串进行编码
    var encode = encodeURI(str);
    // 对编码的字符串转化base64
    var base64 = btoa(encode);
    return base64;
  }
  // base64转字符串
  public decode(base64) {
    // 对base64转编码
    var decode = atob(base64);
    // 编码转字符串
    var str = decodeURI(decode);
    return str;
  }
  // 保存
  public save() {
    this.$confirm('是否保存当前内容?', {
      confirmButtonText: '确认',
      cancelButtonText: '取消',
      center: true
    }).then(() => {
      if(this.tinymceHtml.indexOf('<img') > -1 || this.tinymceHtml.indexOf('#000000') > -1){
        this.tinymceHtml = this.tinymceHtml.replace(/<img/g, "<img style='max-width:100%;'").replace(/#000000/g, "#ffffff")
      }
      this.editTwoLevelcation({
        id: this.searchValue.id,
        name: this.searchValue.name,
        twoLevelRemark: this.tinymceHtml
      }).then(res=>{
        this.$message({ message: '保存成功', type: 'success' });
        this.loadData();
        this.$root.$emit('updateDendrogram');
      }).catch(()=>{})
    }).catch(() => {
      this.$message({
        type: 'info',
        message: '已取消保存'
      });
    });
  }

  public mounted() {
    this.searchValue.id = this.$route.query.id;
    tinymce.init({});
    this.loadData();
  }

  public loadData() {
    this.getTwoLevelDetail({id: this.searchValue.id}).then(res=>{
      this.searchValue.name = res ? res.name : '';
      // this.tinymceHtml = res ? this.decode(res.twoLevelRemark) : '';
      this.tinymceHtml = res ? res.twoLevelRemark : '';
      if(this.tinymceHtml.indexOf('#fffff') > -1){
        this.tinymceHtml = this.tinymceHtml.replace(/#ffffff/g, "#000000")
      }
    })
  }

  @Watch("$route")
  routechange(to: any, from: any) {
    this.searchValue.id = this.$route.query.id;
    this.loadData();
  }
}
</script>

<style lang="scss" scoped>
.line-btn {
  background-color: #3563c5;
  border-color: #3563c5;
  color: #fff;
}
.line-btn:hover,
.line-btn:focus {
  background-color: #3563c5;
  border-color: #3563c5;
  opacity: 0.7;
  color: #fff;
}
/deep/ .anniu {
  padding: 10px 0;
}
/deep/ .dialogVisible {
  background: #152025;
  .el-dialog__header {
    border-bottom: 0;
    text-align: center;
    .el-dialog__title {
      color: #fff;
      font-weight: normal;
    }
  }
}
/deep/ .el-dialog__body{
  color: #fff;
}
/deep/ img{
  max-width: 100%;
}
</style>

其中的带plugins为扩展性操作,如果不需要,可以不引入。

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

推荐阅读更多精彩内容