初探wangEditor 5

3,461 阅读3分钟

简介

  • wangEditor 基于 slate 内核开发,但不依赖于 React ,所以它本身是无框架依赖的。并且,官方封装了 VueReact 组件,可以很方便的用于 VueReact 等框架
  • 安装:Vue3版本(具有Vue2Vue3React版本)
yarn add @wangeditor/editor
# 或者 npm install @wangeditor/editor --save

yarn add @wangeditor/editor-for-vue@next
# 或者 npm install @wangeditor/editor-for-vue@next --save

使用到的功能

  • 富文本中上传图片
  • 富文本中自定义复制粘贴
  • 将富文本生成的html代码返回至表单,存至数据库,用于前端展示

基本使用

上传图片及复制粘贴的使用

上传图片

  • 主要用到了菜单配置中的MENU_CONFuploadImage
  • server:服务器地址,是必填项,就是后端定义的API字段名(也可以说是路由),像我代码中就是/api/file/pricure/editor
const editorConfig: Partial<IEditorConfig> = {
  placeholder: '请输入内容...',

  MENU_CONF: {
    uploadImage: {
      server: '/api/file/picture/editor',
      fieldName: 'picture',
      // 单个文件的最大体积限制,默认为 2M
      maxFileSize: 10 * 1024 * 1024, // 10M

      // 最多可上传几个文件,默认为 100
      maxNumberOfFiles: 10,

      // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
      allowedFileTypes: ['image/*'],
      // 上传之前触发
      onBeforeUpload(file: File) {
        return file
      },

      // 上传进度的回调函数
      onProgress(progress: number) {
        console.log('progress', progress)
      },
      // 单个文件上传成功之后
      onSuccess(file: File, res: any) {
        console.log(`${file.name} 上传成功`, res)
      },

      // 单个文件上传失败
      onFailed(file: File, res: any) {
        console.log(`${file.name} 上传失败`, res)
      },
    }
  }
}
  • 需要注意后端返回图片的字段格式是有要求的,需要按照要求来定义,不然就算后端没有报错并且已经接收到图片了,前端也会报错
{
    "errno": 200, // 注意:值是数字,不能是字符串
    "data": {
        "url": "xxx", // 图片 src ,必须
        "alt": "yyy", // 图片描述文字,非必须
        "href": "zzz" // 图片的链接,非必须
    }
}

自定义复制粘贴

  • 在编辑器组件中启用customPaste方法
  • text/html:是获取复制而来的文本转化为的html标签
  • text/plain:是获取复制来的文本
  • text/trf:获取如从 wordWPS复制粘贴的文本
  • 再使用insertText将获取到的插入编辑器中
// 自定义粘贴
const customPaste = (editor: IDomEditor, event: any) => {
  // 实现复制的文字粘贴至富文本
  const html = event.clipboardData.getData('text/html') // 获取粘贴的 html
  const text = event.clipboardData.getData('text/plain') // 获取粘贴的 text
  const rtf = event.clipboardData.getData('text/trf') // 获取粘贴的rtf

  // 插入复制的文本
  editor.insertText(text)
  editor.insertText(rtf)

  event.preventDefault()

  return true // true 允许粘贴行为
}

封装富文本组件,以达到将富文本转换的html赋值到表单数据,通过提交表单将数据存到数据库

index.vueLxEditor.vue

  • 在以下代码中使用到了onChange方法,一旦数据改变就会执行,为达到防抖的效果,使用了watchEffect
// 使用watchEffect实现防抖
let timer: any = null
const getData = (word: string) => {
  return setTimeout(() => {
    emit('articleData', valueHtml.value)
  }, 2000)
}
watchEffect((onInvalidate) => {
  timer = getData(valueHtml.value)
  onInvalidate(() => {
    if (timer) {
      clearTimeout(timer)
    }
  })
})
  • 还根据官方文档进行添加markdown插件,以支持markdown语法
yarn add @wangeditor/plugin-md
import { Boot } from '@wangeditor/editor'
import markdownModule from '@wangeditor/plugin-md'

Boot.registerModule(markdownModule)
  • 详细代码如下:
<template>
  <div style="border: 1px solid #ccc">
    <!-- 工具栏 -->
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editorRef"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <!-- 编辑部分 -->
    <Editor
      style="height: 300px; overflow-y: hidden"
      v-model="valueHtml"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="handleCreated"
      @onChange="handleChange"
      @customPaste="customPaste"
    />
  </div>
  <div>html代码预览:</div>
  <el-input v-model="htmlV" type="textarea" />
</template>

<script lang="ts" setup>
import {
  onBeforeUnmount,
  ref,
  defineProps,
  withDefaults,
  defineEmits,
  shallowRef,
  onMounted,
  watchEffect
} from 'vue'

import { Boot, SlateElement, IModuleConf } from '@wangeditor/editor'

// 引入markdown语法
import markdownModule from '@wangeditor/plugin-md'

import { IEditorConfig, IDomEditor } from '@wangeditor/editor'

import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css' // 引入 css

const props = withDefaults(
  defineProps<{
    article: string
  }>(),
  { article: '' }
)

const emit = defineEmits(['articleData'])

Boot.registerModule(module)
Boot.registerModule(markdownModule)

// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
const mode = ref('default') // 简洁模式
const toolbarConfig = {}
const editorConfig: Partial<IEditorConfig> = {
  placeholder: '请输入内容...',

  MENU_CONF: {
    uploadImage: {
      server: '/api/file/picture/editor',
      fieldName: 'picture',
      // 单个文件的最大体积限制,默认为 2M
      maxFileSize: 10 * 1024 * 1024, // 10M

      // 最多可上传几个文件,默认为 100
      maxNumberOfFiles: 10,

      // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
      allowedFileTypes: ['image/*'],
      // 上传之前触发
      onBeforeUpload(file: File) {
        return file
      },

      // 上传进度的回调函数
      onProgress(progress: number) {
        console.log('progress', progress)
      },
      // 单个文件上传成功之后
      onSuccess(file: File, res: any) {
        console.log(`${file.name} 上传成功`, res)
      },

      // 单个文件上传失败
      onFailed(file: File, res: any) {
        console.log(`${file.name} 上传失败`, res)
      },

      // 上传错误,或者触发 timeout 超时
      onError(file: File, err: any, res: any) {
        console.log(`${file.name} 上传出错`, err, res)
      }
    }
  }
}

// 内容 HTML
const valueHtml = ref('')

if (props.article) {
  valueHtml.value = props.article
}

const handleCreated = async (editor: IDomEditor) => {
  editorRef.value = editor // 记录 editor 实例,重要!
}

const htmlV = ref('')
// 编辑器变化时调用
const handleChange = async (editor: IDomEditor) => {
  // 获取纯html标签内容
  htmlV.value = editor.getHtml()
  valueHtml.value = htmlV.value
}

// 自定义粘贴
const customPaste = (editor: IDomEditor, event: any) => {
  // 实现复制的文字粘贴至富文本
  const html = event.clipboardData.getData('text/html') // 获取粘贴的 html
  const text = event.clipboardData.getData('text/plain') // 获取粘贴的 text
  const rtf = event.clipboardData.getData('text/trf') // 获取粘贴的rtf

  // 插入复制的文本
  editor.insertText(text)
  editor.insertText(rtf)

  event.preventDefault()

  return true // true 允许粘贴行为
}

// 使用watchEffect实现防抖
let timer: any = null
const getData = (word: string) => {
  return setTimeout(() => {
    emit('articleData', valueHtml.value)
  }, 2000)
}
watchEffect((onInvalidate) => {
  timer = getData(valueHtml.value)
  onInvalidate(() => {
    if (timer) {
      clearTimeout(timer)
    }
  })
})

// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
  const editor = editorRef.value
  if (editor == null) return
  editor.destroy()
})
</script>

<style lang="less" scoped>
.editor-content-view {
  border: 3px solid #ccc;
  border-radius: 5px;
  padding: 0 10px;
  margin-top: 20px;
  overflow-x: auto;
}

.editor-content-view p,
.editor-content-view li {
  white-space: pre-wrap; /* 保留空格 */
}

.editor-content-view blockquote {
  border-left: 8px solid #d0e5f2;
  padding: 10px 10px;
  margin: 10px 0;
  background-color: #f1f1f1;
}

.editor-content-view code {
  font-family: monospace;
  background-color: #eee;
  padding: 3px;
  border-radius: 3px;
}
.editor-content-view pre > code {
  display: block;
  padding: 10px;
}

.editor-content-view table {
  border-collapse: collapse;
}
.editor-content-view td,
.editor-content-view th {
  border: 1px solid #ccc;
  min-width: 50px;
  height: 20px;
}
.editor-content-view th {
  background-color: #f1f1f1;
}

.editor-content-view ul,
.editor-content-view ol {
  padding-left: 20px;
}

.editor-content-view input[type='checkbox'] {
  margin-right: 5px;
}
</style>

editor-form.vue

<template>
  <div class="header">
    <slot name="header"></slot>
  </div>
  <el-form :label-width="labelWidth">
    <el-row>
      <template v-for="item in formItems" :key="item.label">
        <el-col v-bind="colLayout">
          <el-form-item
            v-if="!item.isHidden"
            :label="item.label"
            :style="itemStyle"
          >
            <template v-if="item.type == 'input'">
              <el-input
                :placeholder="item.placeholder"
                v-bind="item.otherOptions"
                v-model="formData[`${item.field}`]"
              />
            </template>
            <template v-if="item.type == 'select'">
              <el-select
                :placeholder="item.placeholder"
                style="width: 100%"
                v-model="formData[`${item.field}`]"
              >
                <el-option
                  v-for="option in item.options"
                  :key="option.value"
                  :value="option.value"
                  v-bind="item.otherOptions"
                  >{{ option.label }}
                </el-option>
              </el-select>
            </template>
            <template v-if="item.type == 'upload'">
              <ImageUpload
                :formData="formData"
                :item="item"
                v-model:fileList="fileList"
                :limit="1"
                :size="1024 * 5"
                class="upload"
                @change="handleChange"
              />
            </template>
            <template v-if="item.type == 'textarea'">
              <el-input
                type="textarea"
                style="width: 100%"
                v-model="formData[`${item.field}`]"
                :placeholder="item.placeholder"
              ></el-input>
            </template>
            <template v-if="item.type == 'editor'">
              <LxEditor
                :article="formData[`${item.field}`]"
                @articleData="handleArticleData"
              />
            </template>
          </el-form-item>
        </el-col>
      </template>
    </el-row>
  </el-form>
</template>

<script lang="ts">
import { defineComponent, PropType, ref, watch, reactive } from 'vue'
import { IFormItem } from '../types'

import ImageUpload from '@/components/upload/image-upload.vue'

import LxEditor from '@/components/editor/index.vue'

interface IFileList {
  url: string
  name: string
}

import type { UploadFile } from 'element-plus'

export default defineComponent({
  components: {
    LxEditor,
    ImageUpload
  },
  // 从父级传入参数
  props: {
    formItems: {
      // 为确保传入的数组是一个对象类型数组,把Array 当作 PropType,PropType接收一个泛型
      type: Array as PropType<IFormItem[]>,
      // 如果默认值是一个对象或者数组,需要写成一个函数
      default: () => []
    },
    labelWidth: {
      type: String,
      default: '100px'
    },
    itemStyle: {
      type: Object,
      default: () => ({ padding: '10px 40px' })
    },
    // 设置响应式
    colLayout: {
      type: Object,
      default: () => ({
        xl: 6, // >= 1920px 显示4个(24/6)
        lg: 8, // >= 1200px 显示3个
        md: 12, // >= 992px 显示2个
        sm: 24, // >= 768px 显示1个
        xs: 24 // < 768px 显示1个
      })
    },
    // v-model传入的值叫modelValue
    modelValue: {
      type: Object,
      required: true
    }
  },
  setup(props, { emit }) {
    const formData: any = ref({ ...props.modelValue })

    const fileList: IFileList[] = reactive([])

    // 当formData发生改变时,就向外发送新数据newValue
    // 这才实现了双向绑定
    watch(formData, (newValue) => emit('update:modelValue', newValue), {
      deep: true
    })
    
    // 监听LxEditor组件传出的emit事件
    const handleArticleData = (data: any) => {
      formData.value.article = data
    }

    const handleChange = (e: any) => {
      console.log(e)
      console.log(fileList, 'fileList')
    }

    return {
      formData,
      fileList,
      handleArticleData,
      handleChange
    }
  }
})
</script>

<style lang="less" scoped></style>

PageEditor.vue进行使用editor-form.vue

<template>
  <div class="page-modal">
    <el-dialog
      v-model="dialogVisible"
      :title="modalConfig.title"
      width="90%"
      center
      destroy-on-close
    >
      <EditorForm v-bind="modalConfig" v-model="formData"></EditorForm>
      <slot></slot>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="dialogVisible = false">取 消</el-button>
          <el-button type="primary" @click="handleConfirmClick">发布</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, watch, reactive } from 'vue'
import { useStore } from 'vuex'

import { ElMessage } from 'element-plus'

import { EditorForm } from '@/base-ui/form'

export default defineComponent({
  props: {
    modalConfig: {
      type: Object,
      required: true
    },
    defaultInfo: {
      type: Object,
      default: () => ({})
    },
    otherInfo: {
      type: Object,
      default: () => ({})
    },
    pageName: {
      type: String,
      required: true
    }
  },
  components: {
    EditorForm
  },
  setup(props) {
    const dialogVisible = ref(false)
    const formData = ref<any>({})

    watch(
      () => props.defaultInfo,
      (newValue) => {
        for (const item of props.modalConfig.formItems) {
          formData.value[`${item.field}`] = newValue[`${item.field}`]
        }
      }
    )

    // 点击确定按钮的逻辑
    const store = useStore()
    const handleConfirmClick = () => {
      dialogVisible.value = false
      // 区分是新建按钮还是编辑按钮
      if (Object.keys(props.defaultInfo).length) {
        // 编辑按钮
        store.dispatch('systemModule/editPageDataAction', {
          pageName: props.pageName,
          editData: { ...formData.value, ...props.otherInfo },
          id: props.defaultInfo.id
        })
      } else {
        ElMessage({
          message: '新建成功!',
          type: 'success'
        })

        store.dispatch('systemModule/createPageDataAction', {
          pageName: props.pageName,
          newData: { ...formData.value, ...props.otherInfo }
        })
      }
    }

    return {
      dialogVisible,
      formData,
      handleConfirmClick
    }
  }
})
</script>

<style scoped></style>

editor.config.ts配置文件

// 配置文件
import { IForm } from '@/base-ui/form'

export const editorFormConfig: IForm = {
  title: '新建文章',
  formItems: [
    {
      field: 'title',
      type: 'input',
      label: '文章标题',
      placeholder: '请输入文章标题',
      rule: [{ required: true, message: '请输入文章标题', trigger: 'blur' }]
    },
    {
      field: 'author',
      type: 'input',
      label: '文章作者',
      placeholder: '请输入文章作者',
      rule: [
        {
          required: true,
          message: '请输入作者',
          trigger: 'blur'
        }
      ]
    },
    {
      field: 'classify',
      type: 'select',
      label: '文章分类',
      placeholder: '请选择文章分类',
      options: [
        ...
      ],
      rule: [
        {
          required: true,
          message: '请选择文章分类',
          trigger: 'blur'
        }
      ]
    },
    {
      field: 'image',
      type: 'upload',
      label: '文章封面',
      placeholder: '请上传文章封面'
    },
    {
      field: 'isRecommend',
      type: 'select',
      label: '是否推荐',
      placeholder: '请选择是否推荐',
      options: [
        { label: '是', value: 'true' },
        { label: '否', value: 'false' }
      ],
      rule: [
        {
          required: true,
          message: '请选择是否推荐至首页',
          trigger: 'blur'
        }
      ]
    },
    {
      field: 'about',
      type: 'textarea',
      label: '文章摘要',
      placeholder: '请输入文章摘要',
      rule: [
        {
          required: true,
          message: '请输入文章摘要',
          trigger: 'blur'
        }
      ]
    },
    {
      field: 'article',
      type: 'editor',
      label: '文章详情',
      placeholder: '请输入文章详情',
      rule: [
        {
          required: true,
          message: '请输入文章详情',
          trigger: 'blur'
        }
      ]
    }
  ],
  colLayout: { span: 24 },
  itemStyle: {}
}