vue3 中使用 tinymce 记录

7,825 阅读6分钟

前言

最近项目中在做协议相关功能,要求引入富文本编辑器,有一些功能需求点,包括锚点、表格、预览、从 word 中复制到富文本编辑器中保留相关格式等。

综合业务需求,我这边对 ckEditor5 和 tinymce 进行了尝试。ckEditor5 在接入插件时相对较麻烦,我这边想要封装成一个通用功能星组件,就尝试采用 源码中引入ckEditor5插件,该方式需要去修改底层相关配置,但是我这边得项目底层配置是经过封装的,在接入过程中碰到了难以处理的问题,转而去尝试引入 tinymce,就一感觉:真香!!!

首先,奉上文档:

一、准备工作

  1. 安装

    npm i tinymce
    

    我这里安装的版本是 5.10.2

    安装 tinymce 时会安装所有开源的插件,在 node_modules/tinymce/plugins 下,在需要时直接引入就可以。

    然后,node_modules 中找到 tinymce 目录,将目录中 skins 文件夹复制到新建的public/tinymce 文件夹中,然后去下载相关语言包,下载地址,放到 public/tinymce/language 中,后续需要引入。

    image.png

    tinymce 有三种模式:经典模式(classic,默认)行内模式(inline)清爽模式(Distraction-free),这里介绍最常用的经典模式,其它的模式可自行查看文档。

    tinymce 的插件有开源插件付费插件,目前开源插件能满足我的需求,我这边采用开源插件进行开发。

二、编辑器配置

1. 基本配置

添加最基本的配置:

<template>
     <textarea :id="tinymceId" />
</template>

<script lang="ts">
import { defineComponent, computed, onMounted, onBeforeUnmount, unref } from 'vue'
import type { Editor, RawEditorSettings } from 'tinymce'
import tinymce from 'tinymce/tinymce'
import 'tinymce/themes/silver'
import 'tinymce/icons/default/icons'

export default defineComponent({
   setup(){
   const tinymceId = ref<string>(UUID())
   const editorRef = ref<Editor>()

   const initOptions = computed(():RawEditorSettings => {
       const publicPath = __webpack_public_path__
       return {
         selector: `#${tinymceId.value}`,
         language_url: `${publicPath}tinymce/langs/zh_CN.js`,
         language: 'zh_CN',
         skin_url: `${publicPath}tinymce/skins/ui/oxide`,
         content_css: `${publicPath}tinymce/skins/ui/oxide/content.min.css`,
       }
   })

   onMounted(() => {
       tinymce.init(initOptions.value)
   })
   
   onBeforeUnmount(() => {
       destory()
     })

   function destory() {
       if (tinymce !== null) {
         tinymce?.remove?.(unref(initOptions).selector!)
       }
     }

   return { tinymceId }
   }
})
</script>

效果如下: image.png

2. 编辑器初始化

在初始化 setup 的钩子中可以进行初始化的操作:

  • 向编辑器中填写初始化内容
  • 设置编辑器的 只读/编辑状态
  • 监听编辑器的相关操作
const initOptions = computed(() => {
    return {
      // .....
      setup: (editor: Editor) => {
          editorRef.value = editor
          editor.on('init', initSetup)
     },
   }
})
 
 // 编辑器初始化
 function initSetup() {
   const editor = unref(editorRef)
   if (!editor) {
     return
   }
   const value = props.value || ''

   editor.setContent(value)
   bindModelHandlers(editor)
 }
 
 function setValue(editor, val: string, prevVal?: string) {
   if (
     editor
     && typeof val === 'string'
     && val !== prevVal
     && val !== editor.getContent()
   ) {
     editor.setContent(val)
   }
 }
 
 function bindModelHandlers(editor: any) {
   watch(() => props.value, 
   (val: string, prevVal) => setValue(editor, val, prevVal),
     { immediate: true },
   )
   watch(
     () => props.disabled,
     val => {
       editor.setMode(val ? 'readonly' : 'design')
     },
     { immediate: true },
   )
   editor.on('change keyup undo redo', () => {
     const content = editor.getContent()
     emit('update:value', content)
     emit('change', content)
   })
 }

3. 图片上传配置

使用 images_upload_handler 可自定义上传处理逻辑,该自定义函数需提供三个参数:blobInfo、成功回调、失败回调 和 上传进度。使用该配置,则无需使用其他上传配置选项

const initOptions = computed(() => {
     return {
       // .....
       images_upload_handler: handleImgUpload
    }
 })
 
 
// 图片上传自定义逻辑
function handleImgUpload(blobInfo, success, failure, progress) {
    var xhr, formData;
    var file = blobInfo.blob();//转化为易于理解的file对象
    xhr = new XMLHttpRequest();
    xhr.withCredentials = false;
    xhr.open('POST', '/demo/upimg.php');
    xhr.onload = function() {
        var json;
        if (xhr.status != 200) {
            failFun('HTTP Error: ' + xhr.status);
            return;
        }
        json = JSON.parse(xhr.responseText);
        if (!json || typeof json.location != 'string') {
            failFun('Invalid JSON: ' + xhr.responseText);
            return;
        }
        succFun(json.location);
    };
    formData = new FormData();
    formData.append('file', file, file.name );
    xhr.send(formData);
}

最终效果图:

image.png

4. 完整版本代码

注意:paste_retain_style_properties 属性可以保留复制过来的相关样式,比如要保留字体大小、颜色、背景颜色,可以将其配置为 paste_retain_style_properties: 'font-size color background background-color',如果要保留所有样式可以设置为 all,但是这样会造成代码量很大,并且这个属性将在 6 版本中移除,谨慎使用。

<template>
  <textarea :id="tinymceId" />
</template>

<script lang="ts">
  import {
    defineComponent, computed, onMounted, ref, PropType, unref, watch, onBeforeUnmount,
  } from 'vue'
  import type { Editor, RawEditorSettings } from 'tinymce'
  import tinymce from 'tinymce/tinymce'
  import 'tinymce/themes/silver'
  import 'tinymce/icons/default/icons'
  import 'tinymce/plugins/advlist'
  import 'tinymce/plugins/anchor'
  import 'tinymce/plugins/autolink'
  import 'tinymce/plugins/autosave'
  import 'tinymce/plugins/code'
  import 'tinymce/plugins/codesample'
  import 'tinymce/plugins/directionality'
  import 'tinymce/plugins/fullscreen'
  import 'tinymce/plugins/hr'
  import 'tinymce/plugins/insertdatetime'
  import 'tinymce/plugins/link'
  import 'tinymce/plugins/lists'
  import 'tinymce/plugins/image'
  import 'tinymce/plugins/toc'
  import 'tinymce/plugins/nonbreaking'
  import 'tinymce/plugins/noneditable'
  import 'tinymce/plugins/pagebreak'
  import 'tinymce/plugins/paste'
  import 'tinymce/plugins/preview'
  import 'tinymce/plugins/print'
  import 'tinymce/plugins/save'
  import 'tinymce/plugins/searchreplace'
  import 'tinymce/plugins/spellchecker'
  import 'tinymce/plugins/tabfocus'
  import 'tinymce/plugins/table'
  import 'tinymce/plugins/template'
  import 'tinymce/plugins/textpattern'
  import 'tinymce/plugins/visualblocks'
  import 'tinymce/plugins/visualchars'
  import 'tinymce/plugins/wordcount'

  import { plugins as initialPlugins, toolbar as initialToolbar, fontFormats } from './tinymce'
  import { UUID } from 'uuid'

  type Recordable<T = any> = Record<string, T>

  export default defineComponent({
    props: {
      value: {
        type: String,
      },
      disabled: {
        type: Boolean,
        default: false
      },
      options: {
        type: Object as PropType<Partial<RawEditorSettings>>,
        default: () => ({}),
      },
      toolbar: {
        type: String,
        default: initialToolbar,
      },
      plugins: {
        type: Array as PropType<string[]>,
        default: initialPlugins,
      },
      height: {
        type: [Number, String] as PropType<string | number>,
        required: false,
        default: 400,
      },
      width: {
        type: [Number, String] as PropType<string | number>,
        required: false,
        default: 'auto',
      },
    },
    emits: ['change', 'update:value'],
    setup(props, { emit }) {
      const tinymceId = ref<string>(UUID())
      const editorRef = ref<Editor>()

      const initOptions = computed((): RawEditorSettings => {
        const publicPath = __webpack_public_path__

        const {
          height, options, toolbar, plugins,
        } = props
        return {
          selector: `#${tinymceId.value}`,
          language_url: `${publicPath}tinymce/langs/zh_CN.js`,
          language: 'zh_CN',
          skin_url: `${publicPath}tinymce/skins/ui/oxide`,
          content_css: `${publicPath}tinymce/skins/ui/oxide/content.min.css`,
          images_upload_handler: handleImgUpload,
          images_file_types: 'jpeg,jpg,png,gif,bmp,webp', // 准许的图片格式
          convert_urls: false,
          branding: false, // 隐藏品牌,隐藏状态栏中显示的“ Powered by Tiny ”链接
          placeholder: '请输入内容', // 占位符
          toolbar,
          plugins,
          height,
          toolbar_mode: 'sliding',
          toolbar_sticky: true,
          paste_block_drop: true, // 禁用将内容拖放到编辑器中
          paste_data_images: false, // 粘贴data格式的图像 谷歌浏览器无法粘贴
          font_formats: fontFormats,
          paste_retain_style_properties: 'color border border-left border-right border-bottom border-top', // MS Word 和类似 Office 套件产品保留样式
          paste_webkit_styles: 'none', // 允许在 WebKit 中粘贴时要保留的样式
          paste_tab_spaces: 2, // 将制表符转换成空格的个数
          content_style: `
          html, body                { height:100%; }
          img                       { max-width:100%; display:block;height:auto; }
          a                         { text-decoration: none; }
          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; }
          `,
          ...options,
          setup: (editor: Editor) => {
            editorRef.value = editor
            editor.on('init', initSetup)
          },
        }
      })

      onMounted(() => {
        tinymce.init(initOptions.value)
      })

      onBeforeUnmount(() => {
        destory()
      })

      function destory() {
        if (tinymce !== null) {
          tinymce?.remove?.(unref(initOptions).selector!)
        }
      }

      // 图片上传自定义逻辑
      function handleImgUpload(blobInfo, success, failure, progress) {
        console.log('blobInfo', blobInfo.blob(), blobInfo.filename())
        const { type: fileType, name: fileName } = blobInfo.blob()
        // xxxx 自定义上传逻辑
      }

      // 编辑器初始化
      function initSetup() {
        const editor = unref(editorRef)
        if (!editor) {
          return
        }
        const value = props.value || ''

        editor.setContent(value)
        bindModelHandlers(editor)
      }

      function setValue(editor: Recordable, val: string, prevVal?: string) {
        if (
          editor
          && typeof val === 'string'
          && val !== prevVal
          && val !== editor.getContent()
        ) {
          editor.setContent(val)
        }
      }

      function bindModelHandlers(editor: any) {
        watch(
          () => props.value,
          (val: string, prevVal) => setValue(editor, val, prevVal),
          { immediate: true },
        )
        watch(
          () => props.disabled,
          val => {
            editor.setMode(val ? 'readonly' : 'design')
          },
          { immediate: true },
        )
        editor.on('change keyup undo redo', () => {
          const content = editor.getContent()
          emit('update:value', content)
          emit('change', content)
        })
      }

      return {
        tinymceId,
      }
    },
  })
</script>

tinymce.ts 文件里是 tinymce 的 plugins、toolbar、fontFormats 的配置,这里基本上使用了所有的开源插件,功能比较齐全

// tinymce.ts

// imagetools
export const plugins = [
  'advlist anchor autolink code codesample  directionality  fullscreen hr insertdatetime link lists nonbreaking noneditable pagebreak paste preview print save searchreplace tabfocus  template  textpattern visualblocks visualchars wordcount table image toc',
]

export const toolbar = 'undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | toc alignleft aligncenter alignright alignjustify lineheight | outdent indent | numlist bullist | forecolor backcolor | pagebreak | charmap emoticons | fullscreen preview save print | hr link image | anchor pagebreak | insertdatetime | blockquote removeformat subscript superscript code codesample | searchreplace'

export const fontFormats = '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif,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'

三、 属性配置汇总

width: '100%', //  设置富文本编辑器宽度
height: '100%', //  设置富文本编辑器高度
menubar: false, // 设置富文本编辑器菜单, 默认true
branding: false, // 关闭底部官网提示 默认true
statusbar: true, // 显示底部状态栏 默认true
readonly: false, // 设置只读属性 默认 false
resize: false, // 调节编辑器大小 默认 true
branding: false, // 隐藏状态栏右下角显示的品牌
placeholder: '请输入内容', // 占位符

theme: 'silver', // 主题 必须引入
skin_url: '/tinymce/skins/ui/oxide', // 主题路径
icons: 'custom',  // 自定义图标名称
icons_url: '/tinymce/icons/icons.js', // 自定义图标路径
language_url: '/tinymce/langs/zh_CN.js', // 中文化 默认为英文
language: 'zh_CN', // 设置富文本编辑器语言
content_css: `/tinymce/skins/content/default`, // 富文本编辑器内容区域样式
content_style: 'body, p{font-size: 12px}', // 为内容区编辑自定义css样式

plugins: ['autosave help textpattern lineheight'], // 插件配置
toolbar: 'fontselect styleselect fontsizeselect restoredraft undo redo | bold italic underline strikethrough subscript superscript removeformat forecolor backcolor lineheight align outdent indent help', // 工具栏配置
toolbar_mode: 'sliding', // sliding生效条件toolbar必须为字符串,且有'|'区分,不能为数组
toolbar_sticky: true, // 粘性工具栏 默认false (在向下滚动网页直到不再可见编辑器时,将工具栏和菜单停靠在屏幕顶部)

// 快速工具栏配置,需引入插件 quickbars
quickbars_selection_toolbar: 'bold italic underline strikethrough | link h2 h3 h4 blockquote', // 设置 快速选择 触发提供的工具栏 需引入插件  默认 'alignleft aligncenter alignright' 设置为false禁用
quickbars_insert_toolbar: 'quickimage quicktable', // 设置 快速插入 触发提供的工具栏 需引入插件quickbars 默认 quickimage quicktable 设置为false禁用

// font 相关配置
fontsize_formats: '12px 14px 16px 18px 20px 22px 24px 26px 36px 48px 56px', // 工具栏自定义字体大小选项
font_formats: "微软雅黑='微软雅黑'; 宋体='宋体'; 黑体='黑体'; 仿宋='仿宋'; 楷体='楷体'; 隶书='隶书'; 幼圆='幼圆'; 方正舒体='方正舒体'; 方正姚体='方正姚体'; 等线='等线'; 华文彩云='华文彩云'; 华文仿宋='华文仿宋'; 华文行楷='华文行楷'; 华文楷体='华文楷体'; 华文隶书='华文隶书'; Andale Mono=andale mono,times; Arial=arial; Arial Black=arial black;avant garde; Book Antiqua=book antiqua;palatino; Comic Sans MS=comic sans ms; Courier New=courier new;courier; Georgia=georgia; Helvetica=helvetica; Impact=impact;chicago; Symbol=symbol; Tahoma=tahoma;arial; sans-serif; Terminal=terminal,monaco; Times New Roman=times new roman,times; Trebuchet MS=trebuchet ms; Verdana=verdana;geneva; Webdings=webdings; Wingdings=wingdings", // 工具栏自定义字体选项

// autosave 插件配置,需引入插件 autosave
autosave_ask_before_unload: true, // 阻止有内容时浏览器阻塞行为, 默认 true
autosave_interval: '3s', // 设置自动保存为草稿时间 单位只能为s 
autosave_prefix: `editor_${route.path}`, // 设置自动保存为草稿时前缀 本地localStorage中存储
autosave_retention: '300m', // 自动草稿的有效期 单位只能为m(分钟)

// image 相关配置,需引入插件image
images_upload_handler: (blobInfo, success, failure) => {
    // 发送请求, 获取图片路径后, 将路径传给success
    success('xxxx')
}, // 图片上传函数 
image_advtab: true, // 为上传图片窗口添加高级属性

// paste 相关配置,需引入插件paste
paste_data_images: true, // 粘贴data格式的图像
paste_block_drop: true, // 禁用将内容拖放到编辑器中
paste_as_text: true, // 默认粘贴为文本
paste_retain_style_properties: 'color border', // MS Word 和类似 Office 套件产品保留样式

// template 内容模板配置,需引入插件template
templates: [{ title: '标题', description: '描述', content: '内容' }], // 内容模板

// 快速排版配置,需引入插件 textpattern
textpattern_patterns: [
    { start: '*', end: '*', format: 'italic' },
    { start: '**', end: '**', format: 'bold' },
    { start: '#', format: 'h1' },
    { start: '##', format: 'h2' },
    { start: '###', format: 'h3' },
    { start: '####', format: 'h4' },
    { start: '#####', format: 'h5' },
    { start: '######', format: 'h6' },
    { start: '1. ', cmd: 'InsertOrderedList' },
    { start: '* ', cmd: 'InsertUnorderedList' },
    { start: '- ', cmd: 'InsertUnorderedList' }
], // 快速排版  类似于markdown

init_instance_callback: editor => { // 初始化结束后执行, 里面实现双向数据绑定功能
    editor.on('Input undo redo Change execCommand SetContent', (e) => {
    // editor.getContent({ format: ''text }) // 获取纯文本
    $emit('change', editor.getContent())
})
},

setup: (editor) => { // 初始化前执行
// xxxx
}