项目背景:做一个AI智能体的项目,sse接口返回markdown格式的流式数据,支持打字机效果,数学公式,流程图等等
效果:
纯预览:

可编辑:

组件代码:
js
<script setup lang="ts">
import {
ref,
watch,
onMounted,
onBeforeUnmount,
computed,
toRaw,
nextTick
// type PropType
} from 'vue'
import Vditor from 'vditor'
import 'vditor/dist/index.css'
import { merge } from 'lodash-es'
// eslint-disable-next-line turbo/no-undeclared-env-vars
const cdn = import.meta.env.DEV ? '/vditor' : '/console/vditor'
// const cdn = 'https://cdn.jsdelivr.net/npm/vditor@3.11.1'
// const cdn = 'http://10.0.102.120:9010/aig/vditor'
const defaultOptions = {
mode: 'ir',
height: 'auto',
minHeight: 0,
placeholder: '开始书写你的内容...',
toolbarConfig: {
pin: true
},
counter: {
enable: true
},
cache: {
enable: false
},
outline: {
enable: true,
position: 'right'
}
}
const toolbarItems = ref([
'emoji',
'headings',
'bold',
'italic',
'strike',
'link',
'|',
'list',
'ordered-list',
'check',
'outdent',
'indent',
'|',
'quote',
'line',
'code',
'inline-code',
'insert-before',
'insert-after',
'|',
'table',
'|',
'undo',
'redo',
'|',
'fullscreen'
])
const props = defineProps({
// 双向绑定值
modelValue: {
type: String,
default: ''
},
// Vditor 配置选项
options: {
type: Object,
default: () => ({})
},
type: {
type: String,
default: 'preview'
},
// 编辑器高度
height: {
type: [Number, String],
default: 'auto'
},
// 是否启用上传功能
enableUpload: {
type: Boolean,
default: true
},
// 禁用编辑器
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'update:modelValue',
'initialized',
'rendered',
'blur',
'focus',
'ready',
'upload',
'copy',
'keydown',
'destroyed'
])
const editorContainer = ref<HTMLElement | null>(null)
const vditorInstance = ref<Vditor | null>(null)
const isSettingValue = ref(false)
const isInitializing = ref(false)
// 处理容器高度
const containerStyles = computed(() => ({
height: typeof props.height === 'number' ? `${props.height}px` : props.height
}))
// 清理容器内容
const cleanContainer = () => {
if (editorContainer.value) {
editorContainer.value.innerHTML = ''
// 移除残留的Vditor相关类名(重要,因为公文写作显示预览模式,再是编辑模式 ,切换的时候会有残留的预览模式的类名,导致toolbar样式异常)
editorContainer.value.className = 'vditor-editor-container'
}
}
// 初始化编辑器
const initEditor = async () => {
if (!editorContainer.value || isInitializing.value) return
isInitializing.value = true
try {
// 先清理容器
cleanContainer()
// 纯预览处理
if (props.type === 'preview') {
await nextTick() // 确保容器清理完成
await Vditor.preview(
editorContainer.value as HTMLDivElement,
props.modelValue,
{
mode: 'light',
cdn
}
)
return
}
// 销毁现有实例
if (vditorInstance.value) {
await destroyEditor()
await nextTick() // 等待销毁完成
}
const mergedOptions: any = {
...merge(defaultOptions, toRaw(props.options)),
input: handleInput,
after: handleInitialized,
focus: handleFocus,
blur: handleBlur,
keydown: handleKeyDown,
value: props.modelValue,
toolbar: toolbarItems.value,
theme: props.options.theme || 'classic',
preview: {
...(props.options.preview || {}),
markdown: {
sanitize: true,
...(props.options.preview?.markdown || {})
}
},
cdn
}
if (props.enableUpload) {
mergedOptions.upload = {
accept: 'image/*,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,.txt',
multiple: true,
...(props.options.upload || {}),
handler: handleUpload
}
}
// 等待DOM完全准备好
await nextTick()
// 创建新实例
vditorInstance.value = new Vditor(editorContainer.value, mergedOptions)
} finally {
isInitializing.value = false
}
}
// 处理输入事件
const handleInput = (value: string) => {
if (isSettingValue.value) {
isSettingValue.value = false
return
}
emit('update:modelValue', value)
emit('rendered', vditorInstance.value)
}
// 处理初始化完成事件
const handleInitialized = () => {
emit('initialized', vditorInstance.value)
emit('ready', vditorInstance.value)
// 监听复制事件
if (vditorInstance.value) {
vditorInstance.value.vditor?.element?.addEventListener('copy', handleCopy)
}
}
// 处理上传
const handleUpload = (files: File[]) => {
if (files.length === 0) return
// 触发自定义上传事件
emit('upload', {
files,
uploadCallback: (successURLs = []) => {
if (!vditorInstance.value) return
// 添加上传成功的图片/文件
successURLs.forEach((url: string) => {
if (
url.endsWith('.jpg') ||
url.endsWith('.png') ||
url.endsWith('.gif')
) {
vditorInstance.value?.insertValue(``)
} else {
vditorInstance.value?.insertValue(`[${url.split('/').pop()}](${url})`)
}
})
}
})
}
// 处理聚焦事件
const handleFocus = () => {
emit('focus', vditorInstance.value)
}
// 处理失焦事件
const handleBlur = () => {
emit('blur', vditorInstance.value)
}
// 处理按键事件
const handleKeyDown = (event: any) => {
emit('keydown', {
event,
instance: vditorInstance.value
})
}
// 处理复制事件
const handleCopy = (event: any) => {
emit('copy', {
event,
instance: vditorInstance.value
})
}
// 销毁编辑器
const destroyEditor = (): Promise<void> => {
return new Promise((resolve) => {
if (
vditorInstance.value &&
vditorInstance.value.vditor &&
vditorInstance.value.vditor.element
) {
// 移除事件监听器
vditorInstance.value.vditor.element.removeEventListener(
'copy',
handleCopy
)
// 销毁Vditor实例
vditorInstance.value.destroy()
vditorInstance.value = null
emit('destroyed')
// 使用requestAnimationFrame确保DOM更新完成
requestAnimationFrame(() => {
resolve()
})
} else if (vditorInstance.value) {
// 如果 vditorInstance.value 存在但 vditor 或 element 不存在,只清空实例
vditorInstance.value = null
emit('destroyed')
resolve()
} else {
resolve()
}
})
}
// 暴露编辑器实例方法
const getVditorInstance = () => vditorInstance.value
// 设置编辑器内容
const setValue = (value: string, clearStack = true) => {
if (vditorInstance.value) {
isSettingValue.value = true
vditorInstance.value.setValue(value, clearStack)
}
}
// 清除内容
const clearContent = () => {
setValue('')
}
// 获取编辑器内容
const getValue = () => {
return vditorInstance.value?.getValue() || ''
}
// 聚焦编辑器
const focusEditor = () => {
vditorInstance.value?.focus()
}
// 失焦编辑器
const blurEditor = () => {
vditorInstance.value?.blur()
}
// 禁用/启用编辑器
const toggleDisabled = (disabled: boolean) => {
if (vditorInstance.value) {
if (disabled) {
vditorInstance.value.disabled()
} else {
vditorInstance.value.enable()
}
}
}
// 监听模型值变化
watch(
() => props.modelValue,
async (newValue) => {
console.log(newValue)
// 纯预览处理
if (props.type === 'preview') {
if (editorContainer.value) {
// editorContainer.value.innerHTML = await Vditor.md2html(newValue, {
// mode: 'light',
// cdn
// })
await Vditor.preview(
editorContainer.value as HTMLDivElement,
props.modelValue,
{
mode: 'light',
cdn
}
)
}
// Vditor.mathRender(editorContainer.value as HTMLElement, {
// cdn,
// math: {
// engine: 'KaTeX',
// inlineDigit: true
// }
// })
return
}
const currentValue = getValue()
// 只有当内容确实改变时才更新编辑器
if (newValue !== currentValue) {
setValue(newValue)
}
}
)
// 监听禁用状态变化
watch(
() => props.disabled,
(disabled) => {
toggleDisabled(disabled)
}
)
// 监听选项变化
watch(
() => props.options,
() => {
void initEditor()
},
{ deep: true }
)
// 监听工具栏变化
// watch(
// () => props.toolbarItems,
// () => {
// void initEditor()
// }
// )
//监听type变化
watch(
() => props.type,
async (newType, oldType) => {
// 避免重复初始化
if (newType === oldType || isInitializing.value) return
// 重新初始化编辑器
await initEditor()
}
)
// 生命周期钩子
onMounted(initEditor)
onBeforeUnmount(destroyEditor)
// 暴露公共方法
defineExpose({
getVditorInstance,
getValue,
setValue,
focusEditor,
blurEditor,
clearContent,
insertValue: (value: string) => vditorInstance.value?.insertValue(value),
getCursorPosition: () => vditorInstance.value?.getCursorPosition() || 0,
enable: () => vditorInstance.value?.enable(),
disabled: () => vditorInstance.value?.disabled(),
destroy: destroyEditor
})
</script>
<template>
<div
ref="editorContainer"
:style="containerStyles"
class="vditor-editor-container"
/>
</template>
<style lang="postcss">
.vditor-editor-container {
img {
width: 70%;
display: block;
margin-top: 20px;
}
border: none;
table {
width: 100%;
tr {
th,
td {
text-align: left;
padding: 8px 12px;
border: 1px solid #f0f0f0;
}
}
}
/* 格式化内容样式 */
h1 {
line-height: max(1.5em, 32px); /* 现代浏览器动态适配 */
}
h2 {
font-size: 18px;
font-weight: 700;
margin: 20px 0 12px;
line-height: 1.4;
}
/* 标题样式 - 只使用h3 */
h1 {
font-size: 24px;
font-weight: 700;
margin: 20px 0 12px;
line-height: 1.4;
}
h3 {
font-size: 16px;
font-weight: 700;
margin: 20px 0 12px;
line-height: 1.4;
}
h1:first-child {
margin-top: 0;
}
h3:first-child {
margin-top: 0;
}
/* 副标题样式 - 使用strong */
strong {
font-weight: 700;
color: #1d2129;
}
p {
line-height: 1.8;
}
/* 确保段落之间有足够间距 */
p + p {
margin-top: 16px;
}
ul,
ol {
padding-left: 1.8rem;
margin: 14px 0 14px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 10px 0;
padding-left: 0.3rem;
line-height: 1.6;
}
li:last-child {
margin-bottom: 10px;
}
strong {
font-weight: 600;
}
/* 分隔线样式 - 更浅的颜色,更大的间距 */
hr {
border: 0;
height: 1px;
background-color: #e1dede !important;
margin: 14px 0;
}
pre {
code {
white-space: pre-wrap;
}
}
}
</style>
怎么使用:
js
<AiMarkdown
class="text-[#1D2129] break-all"
:model-value="message.content"
type="preview"
/>