简介
wangEditor
基于 slate
内核开发,但不依赖于 React
,所以它本身是无框架依赖的。并且,官方封装了 Vue
、 React
组件,可以很方便的用于 Vue
、 React
等框架
- 安装:
Vue3
版本(具有Vue2
、Vue3
、React
版本)
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_CONF
、uploadImage
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
:获取如从 word
、WPS
复制粘贴的文本
- 再使用
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.vue
(LxEditor.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: {}
}