vue3富文本编辑器的二次封装开发-Tinymce

欢迎点击领取 -《前端面试题进阶指南》:前端登顶之巅-最全面的前端知识点梳理总结

*分享一个使用比较久的🪜

简介

1、安装:pnpm add tinymce / pnpm add @tinymce/tinymce-vue ===> Vue3 + tinymce + @tinymce/tinymce-vue

2、功能实现图片上传、基金卡片插入、收益卡片插入、源代码复用、最大长度限制、自定义表情包插入、文本内容输入、预览等功能

代码展示

在components文件下创建TinymceEditor.vue文件作为公共组件

js 复制代码
<template>
  <div>
    <Editor ref="EditorRefs" v-model="content" :init="myTinyInit" />
    <div class="editor_footer">
      <span v-if="wordlimit">
        <span>{{ wordLenght }}</span>
        <span> / </span>
        <span>{{ wordlimit.max }}</span> 字符
      </span>
    </div>
    <el-dialog title="自定义表情包" v-model="dialogVisible" width="45%">
      <div class="emoji">
        <div class="emoji-item" v-for="item in 40" :key="item">
          <img :src="`/src/assets/emoji/${item}.webp`" alt="" @click="chooseEmoji(item)" />
        </div>
      </div>
    </el-dialog>
    <button @click="handlePreview">预览</button>
  </div>
</template>

<script lang="ts" setup>
import './wordlimit' // 限制字符文件
import tinymce from 'tinymce/tinymce'
import Editor from '@tinymce/tinymce-vue'
import 'tinymce/icons/default/icons'
import 'tinymce/themes/silver'
import 'tinymce/models/dom/model'
import 'tinymce/plugins/table'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/link'
import 'tinymce/plugins/help'
import 'tinymce/plugins/wordcount'
import 'tinymce/plugins/code'
import 'tinymce/plugins/preview'
import 'tinymce/plugins/visualblocks'
import 'tinymce/plugins/visualchars'
import 'tinymce/plugins/fullscreen'
import '/public/tinymce/plugins/image/index.js'

import { sumLetter } from '@/utils/utilTool'
import { computed, onMounted, reactive, ref, watch } from 'vue'

const props = withDefaults(
  defineProps<{
    modelValue?: string
    plugins?: string
    toolbar?: string
    wordlimit?: any
  }>(),
  {
    plugins: 'image code wordcount wordlimit preview', // 默认开启工具库
    toolbar: 'image emoji fund---icon income-icon code' // 富文本编辑器工具
  }
)

const emit = defineEmits(['input'])

const wordLenght = ref<number | string>(0)

const content = ref<string>('')

const EditorRefs = ref<any>()

const dialogVisible = ref<boolean>(false)

const myTinyInit = reactive({
  width: '100%',
  height: 600, // 默认高度
  statusbar: false,
  language_url: '/tinymce/langs/zh_CN.js', // 配置汉化-> 需下载对应汉化包引入
  language: 'zh_CN', // 语言标识
  branding: false, // 不显示右下角logo
  auto_update: false, // 不进行自动更新
  resize: true, // 可以调整大小
  menubar: false, // 关闭顶部菜单
  skin_url: '/tinymce/skins/ui/oxide', // 手动引入CSS
  content_css: '/tinymce/skins/content/default/content.css', // 手动引入CSS
  toolbar_mode: 'wrap',
  plugins: props?.plugins, // 插件
  toolbar: props?.toolbar, // 功能按钮
  wordlimit: props?.wordlimit, // 字数限制
  image_caption: false,
  paste_data_images: true,

  //粘贴图片后,自动上传
  urlconverter_callback: function (url, node, on_save, name) {
    return url
  },

  images_upload_handler: (blobInfo) =>
    new Promise((resolve, reject) => {
      console.log(blobInfo.blob())
      const formData = new FormData()
      formData.append('file', blobInfo.blob(), blobInfo.filename())
      resolve('https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/image-20230512090059968.png')
      // axios
      //   .post(`/api/backend/upload`, formData, {
      //     headers: {
      //       'Content-Type': 'multipart/form-data',
      //       Authorization: 'Bearer ' + store.state.user.accessToken,
      //     },
      //   })
      //   .then((res) => {
      //     if (res.data.code === 1) {
      //       resolve(`/image_manipulation${res.data.data.filePath}`)
      //     } else {
      //       ElNotification.warning(res.data.msg)
      //     }
      //   })
      //   .catch((error) => {
      //     reject(error)
      //   })
    }),

  setup: (editor) => { // 自定义图标内容及触发点击事件等功能
    editor.ui.registry.addIcon(
      'fund---icon',
      '<svg t="1696250970925" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="24834" width="21" height="21"><path d="M512 133.12c208.91648 0 378.88 169.96352 378.88 378.88s-169.96352 378.88-378.88 378.88-378.88-169.96352-378.88-378.88 169.96352-378.88 378.88-378.88m0-71.68c-248.83712 0-450.56 201.72288-450.56 450.56s201.72288 450.56 450.56 450.56 450.56-201.72288 450.56-450.56-201.72288-450.56-450.56-450.56z" fill="#2c2c2c" p-id="24835"></path><path d="M624.74752 263.6288a35.72224 35.72224 0 0 0-25.344 10.496L512 361.52832 424.59648 274.1248a35.73248 35.73248 0 0 0-25.344-10.496 35.84 35.84 0 0 0-25.344 61.17888L451.07712 401.9712H348.16a35.84 35.84 0 1 0 0 71.68h128v66.56H348.16a35.84 35.84 0 1 0 0 71.68h128v133.12a35.84 35.84 0 1 0 71.68 0v-133.12h128a35.84 35.84 0 1 0 0-71.68h-128v-66.56h128a35.84 35.84 0 1 0 0-71.68h-102.91712l77.16352-77.16352a35.84 35.84 0 0 0-25.33888-61.17888z" fill="#2c2c2c" p-id="24836"></path></svg>'
    )
    editor.ui.registry.addIcon(
      'income-icon',
      '<svg t="1696250530786" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15004" width="21" height="21"><path d="M920 152v720H104V152h816z m-67.2 67.2H171.2v585.6h681.6V219.2z" fill="#2c2c2c" p-id="15005"></path><path d="M32 152m7 0l946 0q7 0 7 7l0 53.2q0 7-7 7l-946 0q-7 0-7-7l0-53.2q0-7 7-7Z" fill="#2c2c2c" p-id="15006"></path><path d="M450.906 417.788l122.187 122.19 115.4-115.401 47.518 47.517-115.4 115.4-47.517 47.518-122.189-122.19-115.399 115.401-47.517-47.517 162.917-162.918z" fill="#2c2c2c" p-id="15007"></path><path d="M300.8 718.4H368v86.4h-67.2v-86.4z m120-86.4H488v172.8h-67.2V632z m120 48H608v124.8h-67.2V680z m120-67.2H728v192h-67.2v-192z" fill="#2c2c2c" p-id="15008"></path></svg>'
    )

    editor.ui.registry.addButton('emoji', {
      icon: 'emoji',
      tooltip: '自定义表情包',
      onAction: () => {
        dialogVisible.value = true
      }
    })

    editor.ui.registry.addButton('fund---icon', {
      icon: 'fund---icon',
      tooltip: '基金',
      onAction: () => {
        editor.insertContent('Hello')
      }
    })

    editor.ui.registry.addButton('income-icon', {
      icon: 'income-icon',
      tooltip: '晒收益',
      onAction: () => {
        editor.insertContent('Hello')
      }
    })
  },

  init_instance_callback: (editor: any) => {
    editor.on('input', () => getEditorWordLen())
  }
})

const initContent = computed(() => {
  return props.modelValue
})

// 选择自定义表情包
const chooseEmoji = (item) => {
  const editor = EditorRefs.value.getEditor()
  const range = editor.selection.getRng()
  const imgNode = editor.getDoc().createElement('img')
  imgNode.width = 32
  imgNode.height = 32
  imgNode.style = 'vertical-align: bottom;'
  imgNode.src = `/src/assets/emoji/${item}.webp` // 注意写你的项目相对路径
  range.insertNode(imgNode)
  dialogVisible.value = false
  editor.execCommand('seleceAll')
  editor.selection.getRng().collapse()
  editor.focus()
}

const getEditorWordLen = () => {
  const content = tinymce.activeEditor.getContent({ format: 'text' })
  const wordObj = sumLetter(content)
  wordLenght.value = wordObj?.txt?.length || 0
}

const handlePreview = () => {
  const editor = tinymce.activeEditor
  editor.on('preview', (editor) => {
    console.log(editor)
  })
}

onMounted(() => {
  tinymce.init({})
  setTimeout(() => getEditorWordLen(), 800)
})

watch(
  initContent,
  (newVal) => {
    content.value = newVal
  },
  { deep: true, immediate: true }
)

watch(
  content,
  (newVal) => {
    emit('input', newVal)
  },
  { deep: true }
)
</script>

<script lang="ts">
export default { name: 'TinymceEditor' }
</script>

<style scoped lang="scss">
.emoji {
  display: flex;
  flex-wrap: wrap;
}

.emoji-item {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-left: 10px;
  margin-bottom: 8px;
  cursor: pointer;

  img {
    width: 48px;
    height: 48px;
  }
}

.editor_footer {
  margin-top: 20px;
  font-size: 13px;
}
</style>

创建wordlimit.ts文件,作为限制字符的触发条件

js 复制代码
import tinymce from 'tinymce/tinymce'
import { ElMessage } from 'element-plus'
import { sumLetter } from '@/utils/utilTool'

tinymce.PluginManager.add('wordlimit', function (editor): any {
  const pluginName = '字数限制'
  const app = tinymce.util.Tools.resolve('tinymce.util.Delay')
  const Tools = tinymce.util.Tools.resolve('tinymce.util.Tools')
  const wordlimit_event = editor.getParam('ax_wordlimit_event', 'SetContent Undo Redo Keyup input paste')
  const options = editor.getParam('wordlimit', {}, 'object')
  let close = null

  const toast = function (message) {
    close && close.close()
    close = ElMessage.error(message)
    return
  }

  // 默认配置
  const defaults = {
    spaces: false, // 是否含空格
    isInput: false, // 是否在超出后还可以输入
    maxMessage: '超出最大输入字符数量!',
    changeCallback: () => {}, // 自定义的回调方法
    changeMaxCallback: () => {},
    toast // 提示弹窗
  }

  class WordLimit {
    constructor(editor, options) {
      options = Tools.extend(defaults, options)
      let preCount = 0
      let _wordCount = 0
      let oldContent = editor.getContent()
      const WordCount = editor.plugins.wordcount

      editor.on(wordlimit_event, function (e) {
        const content = editor.getContent() || e.content || ''
        if (!options.spaces) {
          _wordCount = WordCount.body.getCharacterCount()
        } else {
          _wordCount = WordCount.body.getCharacterCountWithoutSpaces()
        }
        options.changeCallback({
          ...options,
          editor,
          num: _wordCount,
          content,
          ...sumLetter(content)
        })
        if (_wordCount > options.max) {
          preCount = _wordCount
          if (options.isInput == !1) {
            editor.setContent(oldContent)
            if (!options.spaces) {
              _wordCount = WordCount.body.getCharacterCount()
            } else {
              _wordCount = WordCount.body.getCharacterCountWithoutSpaces()
            }
          }
          editor.getBody().blur()
          editor.fire('wordlimit', {
            maxCount: options.max,
            wordCount: _wordCount,
            preCount: preCount,
            isPaste: e.type === 'paste' || e.paste || false
          })
          toast('最多只能输入' + options.max + '个字')
        }
        oldContent = editor.getContent()
      })
    }
  }

  const setup = function () {
    if (!options && !options.max) return false
    if (!editor.plugins.wordcount) return toast('请先在tinymce的plugins配置wordlimit之前加入wordcount插件')
    app.setEditorTimeout(
      editor,
      function () {
        const editDom = editor.getContainer()
        const wordNum: any = editDom.querySelector('button.tox-statusbar__wordcount')
        const statusbarpath: any = editDom.querySelector('.tox-statusbar__path')
        statusbarpath ? statusbarpath.remove() : void null
        if (wordNum?.innerText?.indexOf('字符') == -1) wordNum.click()
        new WordLimit(editor, options)
      },
      300
    )
  }

  setup()

  return {
    getMetadata: function () {
      return {
        name: pluginName
      }
    }
  }
})
使用
js 复制代码
<template>
  <div class="post_contaniner">
    <div style="width: 100%">
      <TinymceEditor v-model="content" @input="inputContent" :wordlimit="{ max: 300 }" />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'

const content = ref('Hello World')

const inputContent = (newVal) => {
  console.log(newVal)
  content.value = newVal
}
</script>

<style scoped lang="scss">
.post_contaniner {
  .right {
    flex: 1;
    box-shadow: 0 1px 10px 3px #dbdbdb;
    margin-right: 10px;
    padding: 10px;
    box-sizing: border-box;
  }
}
</style>
相关推荐
&活在当下&5 天前
element plus的table组件,点击table的数据是,会出现一个黑色边框
vue3·element plus
&活在当下&5 天前
Element plus 下拉框组件选中一个选项后显示的是 value 而不是 label
前端·javascript·vue3·element plus
瑶琴AI前端5 天前
从0到1实现vue3+vite++elementuiPlus+ts的后台管理系统(一)
前端·typescript·vue3
啊·贤14 天前
初级报错:循环引用
前端·javascript·vue3·axios
代码老祖16 天前
vue3+view-ui-plus+vite+less 实现自定义iview样式
前端·ui·vue3·vite·view design
i紸定i18 天前
uniapp使用ucharts修改Y、X轴标题超出换行
微信小程序·小程序·uni-app·vue·vue3·ucharts
Serenity_Qin18 天前
vue3 + ts 使用 el-tree
前端·vue.js·typescript·vue3·element-plus
前端李易安21 天前
vue3中是如何实现双向数据绑定的
前端·javascript·vue.js·vue3
陈逸子风21 天前
(系列十三)Vue3+Echarts搭建超好看的系统面板
vue3·webapi·权限·流程·表单
叫我菜菜就好23 天前
【广告投放系统】头条可视化投放平台vue3+element-plus+vite落地历程和心得体会
前端·elementui·vue3·vite·头条可视化广告平台