Skip to content

RichEditor 富文本编辑器

基于 Quill 的富文本编辑器组件,支持图片、视频、链接等多种格式。

基础用法

vue
<script setup lang="ts">
import { ref } from 'vue'
import { RichEditor } from '@vben/common-ui'

const content = ref('')
</script>

<template>
  <RichEditor v-model="content" />
</template>

自定义高度

vue
<script setup lang="ts">
import { ref } from 'vue'
import { RichEditor } from '@vben/common-ui'

const content = ref('')
</script>

<template>
  <RichEditor 
    v-model="content" 
    height="500px"
  />
</template>

自定义工具栏

vue
<script setup lang="ts">
import { ref } from 'vue'
import { RichEditor } from '@vben/common-ui'

const content = ref('')

const toolbar = [
  ['bold', 'italic', 'underline', 'strike'],
  ['blockquote', 'code-block'],
  [{ 'header': 1 }, { 'header': 2 }],
  [{ 'list': 'ordered'}, { 'list': 'bullet' }],
  [{ 'indent': '-1'}, { 'indent': '+1' }],
  [{ 'size': ['small', false, 'large', 'huge'] }],
  [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
  [{ 'color': [] }, { 'background': [] }],
  [{ 'align': [] }],
  ['link', 'image', 'video'],
  ['clean']
]
</script>

<template>
  <RichEditor 
    v-model="content"
    :toolbar="toolbar"
  />
</template>

只读模式

vue
<script setup lang="ts">
import { ref } from 'vue'
import { RichEditor } from '@vben/common-ui'

const content = ref('<h1>只读内容</h1><p>这是一段只读的富文本内容</p>')
</script>

<template>
  <RichEditor 
    v-model="content"
    readonly
  />
</template>

API

Props

参数说明类型默认值
modelValue绑定值(HTML 字符串)string''
height编辑器高度string'400px'
placeholder占位文本string'请输入内容...'
readonly是否只读booleanfalse
disabled是否禁用booleanfalse
toolbar工具栏配置array默认工具栏
theme主题'snow' | 'bubble''snow'
uploadImage自定义图片上传(file: File) => Promise<string>-
uploadVideo自定义视频上传(file: File) => Promise<string>-

Events

事件名说明回调参数
update:modelValue内容变化时触发(value: string) => void
change内容变化时触发(content: string, delta: any, source: string) => void
focus获得焦点时触发() => void
blur失去焦点时触发() => void

Methods

方法名说明参数
getHTML获取 HTML 内容-
getText获取纯文本内容-
getLength获取内容长度-
clear清空内容-
focus聚焦编辑器-
blur失焦编辑器-

完整示例

vue
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { RichEditor } from '@vben/common-ui'
import axios from 'axios'

const content = ref('')
const editorRef = ref()

// 自定义图片上传
const handleUploadImage = async (file: File) => {
  const formData = new FormData()
  formData.append('file', file)
  
  try {
    const { data } = await axios.post('/api/upload/image', formData)
    return data.url
  } catch (error) {
    message.error('图片上传失败')
    throw error
  }
}

// 自定义视频上传
const handleUploadVideo = async (file: File) => {
  const formData = new FormData()
  formData.append('file', file)
  
  try {
    const { data } = await axios.post('/api/upload/video', formData)
    return data.url
  } catch (error) {
    message.error('视频上传失败')
    throw error
  }
}

const handleChange = (content: string) => {
  console.log('内容变化:', content)
}

const handleSave = () => {
  const html = editorRef.value?.getHTML()
  const text = editorRef.value?.getText()
  const length = editorRef.value?.getLength()
  
  console.log('HTML:', html)
  console.log('文本:', text)
  console.log('长度:', length)
  
  message.success('保存成功')
}

const handleClear = () => {
  editorRef.value?.clear()
}
</script>

<template>
  <div class="editor-container">
    <RichEditor
      ref="editorRef"
      v-model="content"
      height="500px"
      placeholder="请输入文章内容..."
      :upload-image="handleUploadImage"
      :upload-video="handleUploadVideo"
      @change="handleChange"
    />
    
    <div class="editor-actions">
      <a-space>
        <a-button type="primary" @click="handleSave">保存</a-button>
        <a-button @click="handleClear">清空</a-button>
      </a-space>
    </div>
  </div>
</template>

<style scoped>
.editor-container {
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  overflow: hidden;
}

.editor-actions {
  padding: 16px;
  background: #fafafa;
  border-top: 1px solid #d9d9d9;
}
</style>

工具栏配置

默认工具栏

javascript
const defaultToolbar = [
  ['bold', 'italic', 'underline', 'strike'],        // 加粗、斜体、下划线、删除线
  ['blockquote', 'code-block'],                     // 引用、代码块
  [{ 'header': 1 }, { 'header': 2 }],              // 标题
  [{ 'list': 'ordered'}, { 'list': 'bullet' }],    // 有序列表、无序列表
  [{ 'script': 'sub'}, { 'script': 'super' }],     // 下标、上标
  [{ 'indent': '-1'}, { 'indent': '+1' }],         // 缩进
  [{ 'direction': 'rtl' }],                         // 文本方向
  [{ 'size': ['small', false, 'large', 'huge'] }], // 字体大小
  [{ 'header': [1, 2, 3, 4, 5, 6, false] }],       // 标题级别
  [{ 'color': [] }, { 'background': [] }],         // 字体颜色、背景色
  [{ 'font': [] }],                                 // 字体
  [{ 'align': [] }],                                // 对齐方式
  ['link', 'image', 'video'],                       // 链接、图片、视频
  ['clean']                                         // 清除格式
]

简化工具栏

javascript
const simpleToolbar = [
  ['bold', 'italic', 'underline'],
  [{ 'list': 'ordered'}, { 'list': 'bullet' }],
  ['link', 'image'],
  ['clean']
]

完整工具栏

javascript
const fullToolbar = [
  [{ 'font': [] }],
  [{ 'size': ['small', false, 'large', 'huge'] }],
  ['bold', 'italic', 'underline', 'strike'],
  [{ 'color': [] }, { 'background': [] }],
  [{ 'script': 'sub'}, { 'script': 'super' }],
  [{ 'header': 1 }, { 'header': 2 }],
  ['blockquote', 'code-block'],
  [{ 'list': 'ordered'}, { 'list': 'bullet' }],
  [{ 'indent': '-1'}, { 'indent': '+1' }],
  [{ 'direction': 'rtl' }],
  [{ 'align': [] }],
  ['link', 'image', 'video', 'formula'],
  ['clean']
]

自定义样式

vue
<style>
/* 编辑器容器 */
.ql-container {
  font-size: 14px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
}

/* 工具栏 */
.ql-toolbar {
  background: #fafafa;
  border-bottom: 1px solid #d9d9d9;
}

/* 编辑区域 */
.ql-editor {
  min-height: 300px;
  padding: 16px;
}

/* 占位文本 */
.ql-editor.ql-blank::before {
  color: #999;
  font-style: normal;
}

/* 代码块 */
.ql-editor pre.ql-syntax {
  background: #f5f5f5;
  border-radius: 4px;
  padding: 12px;
}

/* 引用 */
.ql-editor blockquote {
  border-left: 4px solid #1890ff;
  padding-left: 16px;
  margin: 16px 0;
}
</style>

图片处理

图片上传

typescript
const handleUploadImage = async (file: File) => {
  // 检查文件大小
  if (file.size > 2 * 1024 * 1024) {
    message.error('图片大小不能超过 2MB')
    throw new Error('图片过大')
  }
  
  // 检查文件类型
  if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
    message.error('只支持 JPG、PNG、GIF 格式')
    throw new Error('格式不支持')
  }
  
  // 上传图片
  const formData = new FormData()
  formData.append('file', file)
  
  const { data } = await axios.post('/api/upload/image', formData)
  return data.url
}

图片压缩

typescript
import { compressImage } from '@/utils/image'

const handleUploadImage = async (file: File) => {
  // 压缩图片
  const compressed = await compressImage(file, {
    maxWidth: 1920,
    maxHeight: 1080,
    quality: 0.8
  })
  
  // 上传压缩后的图片
  const formData = new FormData()
  formData.append('file', compressed)
  
  const { data } = await axios.post('/api/upload/image', formData)
  return data.url
}

内容过滤

XSS 防护

typescript
import DOMPurify from 'dompurify'

const sanitizeContent = (html: string) => {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'a', 'img', 'video'],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'width', 'height', 'class', 'style']
  })
}

const handleSave = () => {
  const html = editorRef.value?.getHTML()
  const sanitized = sanitizeContent(html)
  // 保存过滤后的内容
}

注意事项

注意

  1. 富文本编辑器会生成 HTML 内容,需要注意 XSS 防护
  2. 图片和视频上传需要配置服务器地址
  3. 建议限制图片大小,避免影响页面性能
  4. 内容保存前建议进行 HTML 过滤

提示

  • 使用 uploadImageuploadVideo 自定义上传逻辑
  • 可以通过 toolbar 属性自定义工具栏
  • 使用 getHTML() 获取 HTML 内容,getText() 获取纯文本
  • 支持 snowbubble 两种主题

相关链接

MIT License