简介
wangEditor
基于 slate
内核开发,但不依赖于 React
,所以它本身是无框架依赖的。并且,官方封装了 Vue
、 React
组件,可以很方便的用于 Vue
、 React
等框架
- 安装:
Vue3
版本(具有Vue2
、Vue3
、React
版本)
yarn add @wangeditor/editor
# 或者 npm install @wangeditor/editor
yarn add @wangeditor/editor-for-vue@next
# 或者 npm install @wangeditor/editor-for-vue@next
使用到的功能
- 富文本中上传图片
- 富文本中自定义复制粘贴
- 将富文本生成的
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',
maxFileSize: 10 * 1024 * 1024,
maxNumberOfFiles: 10,
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",
"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')
const text = event.clipboardData.getData('text/plain')
const rtf = event.clipboardData.getData('text/trf')
editor.insertText(text)
editor.insertText(rtf)
event.preventDefault()
return true
}
封装富文本组件,以达到将富文本转换的html
赋值到表单数据,通过提交表单将数据存到数据库
index.vue
(LxEditor.vue
)
- 在以下代码中使用到了
onChange
方法,一旦数据改变就会执行,为达到防抖的效果,使用了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'
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'
const props = withDefaults(
defineProps<{
article: string
}>(),
{ article: '' }
)
const emit = defineEmits(['articleData'])
Boot.registerModule(module)
Boot.registerModule(markdownModule)
const editorRef = shallowRef()
const mode = ref('default')
const toolbarConfig = {}
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: '/api/file/picture/editor',
fieldName: 'picture',
maxFileSize: 10 * 1024 * 1024,
maxNumberOfFiles: 10,
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)
},
onError(file: File, err: any, res: any) {
console.log(`${file.name} 上传出错`, err, res)
}
}
}
}
const valueHtml = ref('')
if (props.article) {
valueHtml.value = props.article
}
const handleCreated = async (editor: IDomEditor) => {
editorRef.value = editor
}
const htmlV = ref('')
const handleChange = async (editor: IDomEditor) => {
htmlV.value = editor.getHtml()
valueHtml.value = htmlV.value
}
const customPaste = (editor: IDomEditor, event: any) => {
const html = event.clipboardData.getData('text/html')
const text = event.clipboardData.getData('text/plain')
const rtf = event.clipboardData.getData('text/trf')
editor.insertText(text)
editor.insertText(rtf)
event.preventDefault()
return true
}
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: {
type: Array as PropType<IFormItem[]>,
default: () => []
},
labelWidth: {
type: String,
default: '100px'
},
itemStyle: {
type: Object,
default: () => ({ padding: '10px 40px' })
},
colLayout: {
type: Object,
default: () => ({
xl: 6,
lg: 8,
md: 12,
sm: 24,
xs: 24
})
},
modelValue: {
type: Object,
required: true
}
},
setup(props, { emit }) {
const formData: any = ref({ ...props.modelValue })
const fileList: IFileList[] = reactive([])
watch(formData, (newValue) => emit('update:modelValue', newValue), {
deep: true
})
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: {}
}