Vditor:markdown组件的使用

官网:b3log.org/vditor/

项目背景:做一个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(`![](${url})`)
        } 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"
      />
相关推荐
飞哥数智坊3 天前
分享一个 VS Code 插件:一键把 Markdown 网络图片存本地
markdown·visual studio code
Layer3 天前
CommonMark 解析策略与 cmark 工程核心代码解析
架构·markdown·设计
Source.Liu8 天前
【pulldown-cmark】 初学者指南
rust·markdown·pulldown-cmark
Damon小智10 天前
仓颉 Markdown 解析库在 HarmonyOS 应用中的实践
华为·typescript·harmonyos·markdown·三方库
Source.Liu11 天前
【BuildFlow & 筑流】品牌命名与项目定位说明
c++·qt·rust·markdown·librecad
siaikin13 天前
基于 Astro Starlight 的多框架文档
前端·vue.js·markdown
深海的鲸同学 luvi15 天前
【HarmonyOS】原生 Markdown 渲染解决方案 —— @luvi/lv-markdown-in
华为·harmonyos·markdown·原生渲染
secondyoung24 天前
Markdown转换为Word:Pandoc模板使用指南
开发语言·经验分享·笔记·c#·编辑器·word·markdown
Source.Liu25 天前
【mdBook】6 在持续集成中运行 mdbook
markdown