TinyMCE 富文本使用指南(自托管)

225 阅读11分钟

背景:TinyMCE富文本使用云托管有限制,每个月只能渲染1000次,所以使用自托管模式:

官网地址:www.tiny.cloud/docs/tinymc… 自托管官网地址:www.tiny.cloud/get-tiny/

  • 注意官网上的一句话:npm 下载到node_module下的内容默认是云托管模式
  • 自托管建议直接下载.zip的包后放到项目的public下面;
  • 官网不建议把富文本的内容放到代码中,经过vite等打包

image.png

image.png

效果:

  1. 在富文本的外层封装了一些方法:可以在外面调用一些方法实现对富文本的操作,自取,不用就删除;
  2. 另外,代码中的useProtectContent.ts的内容,是自定义的功能,我的项目需求是不允许对初始加载的原文直接删除,删除操作只能添加删除线,后续输入的内容可以删除,类似word中的审阅模式;不需要把这个删除,在各个模块不要引入即可

image.png

一、自托管 1、安装

vue的项目中先安装:npm install "@tinymce/tinymce-vue"

2、把官网下载的zip的包解压放在public下面 image.png

3、在index.html中引入脚本js

image.png

4、封装一个组件,目录如下:

image.png

index.vue中的代码如下

<template>
  <div class="tinymce-editor-wrapper">
    <!-- TinyMCE 编辑器 -->
    <Editor
      v-model="editorContent"
      license-key="gpl"
      tinymce-script-src="/tinymce/tinymce.min.js"
      :init="mergedConfig"
      :disabled="disabled"
      @init="handleEditorInit"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import Editor from '@tinymce/tinymce-vue'
import { getDefaultConfig } from './config/defaultConfig'
import { useTinyMCEOperations } from './hooks/useTinyMCEOperations'
import { useProtectContent } from './hooks/useProtectContent'
import type { TinyMCEEditorProps, TinyMCEEditorEmits } from './types'

// Props 定义
const props = withDefaults(defineProps<TinyMCEEditorProps>(), {
  modelValue: '<p>欢迎使用 TinyMCE 富文本编辑器!</p>',
  disabled: false,
  height: 400,
  config: () => ({})
})

// Emits 定义
const emit = defineEmits<TinyMCEEditorEmits>()

// 编辑器内容
const editorContent = computed({
  get: () => props.modelValue,
  set: (value) => {
    emit('update:modelValue', value)
    emit('change', value)
  }
})

// 存储编辑器实例
const editorInstance = ref<any>(null)

// 获取编辑器实例的方法
const getEditorInstance = () => editorInstance.value

// 使用操作 Hook
const {
  addStrikethrough,
  appendAfterString,
  scrollToTarget,
  addBackgroundColor
} = useTinyMCEOperations(getEditorInstance)

// 使用内容保护 Hook
const { setupProtection } = useProtectContent()

// 合并配置
const mergedConfig = computed(() => ({
  ...getDefaultConfig(props.height),
  ...props.config
}))

// 编辑器初始化完成
const handleEditorInit = (_evt: any, editor: any) => {
  editorInstance.value = editor

  // 设置删除线内容保护
  setupProtection(editor)

  emit('init', editor)
  console.log('TinyMCE 编辑器初始化完成', editor)
}

// 暴露方法给父组件
defineExpose({
  getContent: () => editorInstance.value?.getContent() || '',
  setContent: (content: string) => {
    if (editorInstance.value) {
      editorInstance.value.setContent(content)
      editorContent.value = content
    }
  },
  getEditor: () => editorInstance.value,
  addStrikethrough,
  appendAfterString,
  scrollToTarget,
  addBackgroundColor
})
</script>

<style scoped>
.tinymce-editor-wrapper {
  width: 100%;
}

/* TinyMCE 编辑器样式优化 */
:deep(.tox-tinymce) {
  border: 1px solid #d9d9d9;
  border-radius: 4px;
}

:deep(.tox-tinymce:hover) {
  border-color: #40a9ff;
}

:deep(.tox-tinymce.tox-tinymce--focused) {
  border-color: #1890ff;
  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
</style>

config文件夹下的defaultConfig.ts的内容如下:

/**
 * TinyMCE 默认配置
 */
export const getDefaultConfig = (height = 400) => ({
  // 语言
  language: 'zh_CN',
  language_url: '/tinymce/langs/zh_CN.js',

  // 编辑器高度
  height,

  // 菜单栏
  menubar: false,

  // 插件列表
  plugins: [
    'advlist',
    'autolink',
    'lists',
    'link',
    'image',
    'charmap',
    'preview',
    'anchor',
    'searchreplace',
    'visualblocks',
    'code',
    'fullscreen',
    'insertdatetime',
    'media',
    'table',
    'help',
    'wordcount'
  ],

  // 工具栏配置
  toolbar:
    'undo redo | blocks fontfamily fontsize | ' +
    'bold italic underline strikethrough subscript superscript | ' +
    'forecolor backcolor | alignleft aligncenter alignright alignjustify | ' +
    'bullist numlist outdent indent | removeformat | table | code',

  // 工具栏模式
  toolbar_mode: 'sliding',

  // 字体列表配置(中文字体)
  font_family_formats:
    '宋体=SimSun;' +
    '黑体=SimHei;' +
    '微软雅黑=Microsoft YaHei;' +
    '楷体=KaiTi;' +
    '仿宋=FangSong;' +
    '苹方=PingFang SC;' +
    'Arial=arial,helvetica,sans-serif;' +
    'Times New Roman=times new roman,times;' +
    'Courier New=courier new,courier,monospace',

  // 字体大小配置(使用 px 单位)
  font_size_formats:
    '12px 14px 16px 18px 20px 24px 28px 32px 36px 48px 56px 72px',

  // 内容样式
  content_style:
    'body { font-family:Microsoft YaHei,SimSun,Arial,sans-serif; font-size:14px }',

  // 启用快速工具栏
  quickbars_selection_toolbar: 'bold italic | quicklink h2 h3 blockquote',

  // 图片上传配置
  images_upload_handler: (blobInfo: any) =>
    new Promise((resolve, reject) => {
      // 这里可以实现图片上传逻辑
      // 示例:将图片转为 base64
      const reader = new FileReader()
      reader.readAsDataURL(blobInfo.blob())
      reader.onload = () => {
        resolve(reader.result as string)
      }
      reader.onerror = () => {
        reject('图片上传失败')
      }
    }),

  // 允许粘贴图片
  paste_data_images: true,

  // 其他配置
  branding: false, // 隐藏 "Powered by TinyMCE"
  resize: false, // 禁止调整大小
  elementpath: false, // 隐藏底部元素路径
  promotion: false, // 隐藏右上角 "Upgrade" 按钮
  statusbar: false // 隐藏底部状态栏
})

hooks文件夹下的useTinyMCEOperations.ts

import { message } from 'ant-design-vue'

/**
 * TinyMCE 字符串操作 Hook
 */
export const useTinyMCEOperations = (getEditorInstance: () => any) => {
  /**
   * 给指定的字符串添加删除线
   * @param targetString 目标字符串
   */
  const addStrikethrough = (targetString: string) => {
    if (!targetString.trim()) {
      message.warning('目标字符串不能为空')
      return false
    }

    const editorInstance = getEditorInstance()
    if (!editorInstance) {
      message.error('编辑器未初始化')
      return false
    }

    // 获取编辑器内容
    const currentContent = editorInstance.getContent()

    // 使用正则表达式查找目标字符串(全局匹配)
    const regex = new RegExp(
      targetString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
      'g'
    )

    // 检查是否找到目标字符串
    if (!regex.test(currentContent)) {
      message.warning(`未找到字符串:"${targetString}"`)
      return false
    }

    // 替换所有匹配的字符串,添加删除线标签
    const newContent = currentContent.replace(
      regex,
      `<del>${targetString}</del>`
    )

    // 设置新内容
    editorInstance.setContent(newContent)

    message.success(`已为 "${targetString}" 添加删除线`)
    return true
  }

  /**
   * 在指定的字符串后面添加新的字符串
   * @param targetString 目标字符串
   * @param appendText 要添加的新字符串
   * @param textColor 添加文字的颜色(可选,十六进制颜色值,如 #ff0000)
   */
  const appendAfterString = (
    targetString: string,
    appendText: string,
    textColor?: string
  ) => {
    if (!targetString.trim()) {
      message.warning('目标字符串不能为空')
      return false
    }

    if (!appendText.trim()) {
      message.warning('要添加的字符串不能为空')
      return false
    }

    const editorInstance = getEditorInstance()
    if (!editorInstance) {
      message.error('编辑器未初始化')
      return false
    }

    // 获取编辑器内容
    const currentContent = editorInstance.getContent()

    // 转义特殊字符用于正则表达式
    const escapedTarget = targetString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

    // 创建正则表达式,匹配目标字符串(可能被标签包裹)
    const regex = new RegExp(`(<[^>]*>)*${escapedTarget}(<\\/[^>]*>)*`, 'g')

    // 根据是否提供颜色参数,构建要添加的文本
    const formattedAppendText = textColor
      ? `<span style="color: ${textColor}">${appendText}</span>`
      : appendText

    let matchCount = 0
    const newContent = currentContent.replace(regex, (match: string) => {
      matchCount++

      // 检查匹配的字符串是否被标签包裹
      const hasOpenTag = match.startsWith('<')
      const hasCloseTag = match.endsWith('>')

      if (hasOpenTag && hasCloseTag) {
        // 被标签包裹的情况,在闭合标签后添加新字符串
        return match + formattedAppendText
      } else {
        // 纯文本的情况,直接在后面添加
        return match + formattedAppendText
      }
    })

    // 检查是否找到目标字符串
    if (matchCount === 0) {
      message.warning(`未找到字符串:"${targetString}"`)
      return false
    }

    // 设置新内容
    editorInstance.setContent(newContent)

    const colorInfo = textColor ? `(颜色: ${textColor})` : ''
    message.success(
      `已在 "${targetString}" 后添加 "${appendText}"${colorInfo}(共 ${matchCount} 处)`
    )
    return true
  }

  /**
   * 滚动到指定字符串位置并高亮
   * @param targetString 目标字符串
   */
  const scrollToTarget = (targetString: string) => {
    if (!targetString.trim()) {
      message.warning('目标字符串不能为空')
      return false
    }

    const editorInstance = getEditorInstance()
    if (!editorInstance) {
      message.error('编辑器未初始化')
      return false
    }

    // 清除之前的搜索高亮
    editorInstance.execCommand('mceRemoveSearchHighlight')

    // 搜索字符串
    const found = editorInstance.plugins.searchreplace.find(
      targetString,
      false,
      false
    )

    if (!found || found === 0) {
      message.warning(`未找到字符串:"${targetString}"`)
      return false
    }

    // 获取编辑器的 body
    const body = editorInstance.getBody()

    // 查找第一个高亮的元素
    const firstHighlight = body.querySelector('span[data-mce-bogus="1"]')

    if (firstHighlight) {
      // 滚动到第一个高亮元素
      firstHighlight.scrollIntoView({
        behavior: 'smooth',
        block: 'center'
      })

      // 设置光标位置
      editorInstance.selection.select(firstHighlight)
      editorInstance.selection.collapse(false)

      if (found === 1) {
        message.success(`已定位到 "${targetString}"`)
      } else {
        message.success(`找到 ${found} 个匹配项,已定位到第一个`)
      }
      return true
    }

    return false
  }

  /**
   * 给指定的字符串添加背景色
   * @param targetString 目标字符串
   * @param backgroundColor 背景颜色(十六进制)
   */
  const addBackgroundColor = (
    targetString: string,
    backgroundColor: string
  ) => {
    if (!targetString.trim()) {
      message.warning('目标字符串不能为空')
      return false
    }

    if (!backgroundColor) {
      message.warning('请选择背景颜色')
      return false
    }

    const editorInstance = getEditorInstance()
    if (!editorInstance) {
      message.error('编辑器未初始化')
      return false
    }

    // 获取编辑器内容
    const currentContent = editorInstance.getContent()

    // 转义特殊字符
    const escapedTarget = targetString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

    // 创建正则表达式,匹配目标字符串(可能被标签包裹)
    const regex = new RegExp(`(<[^>]*>)*${escapedTarget}(<\\/[^>]*>)*`, 'g')

    let matchCount = 0
    const newContent = currentContent.replace(regex, (match: string) => {
      matchCount++

      // 检查匹配的字符串是否被标签包裹
      const hasOpenTag = match.startsWith('<')
      const hasCloseTag = match.endsWith('>')

      if (hasOpenTag && hasCloseTag) {
        // 被标签包裹的情况,在标签外添加背景色
        return `<span style="background-color: ${backgroundColor}">${match}</span>`
      } else {
        // 纯文本的情况,直接添加背景色
        return `<span style="background-color: ${backgroundColor}">${match}</span>`
      }
    })

    // 检查是否找到目标字符串
    if (matchCount === 0) {
      message.warning(`未找到字符串:"${targetString}"`)
      return false
    }

    // 设置新内容
    editorInstance.setContent(newContent)

    message.success(
      `已为 "${targetString}" 添加背景色 ${backgroundColor}(共 ${matchCount} 处)`
    )
    return true
  }

  return {
    addStrikethrough,
    appendAfterString,
    scrollToTarget,
    addBackgroundColor
  }
}

useProtectContent.ts内容:

/* eslint-disable max-depth */
/* eslint-disable max-lines-per-function */
/**
 * 内容保护 Hook - 将删除操作转换为添加删除线
 * 重构版本:使用更简洁的实现方式,减少复杂的 DOM 操作
 * 支持原始内容保护:原始内容只能添加删除线,用户新输入的内容可以正常删除
 */

import { isOriginalContent, clearOriginalMark } from '../utils/contentMarker'

/**
 * 检查节点或其父节点是否已有删除线样式
 */
const hasStrikethroughStyle = (node: Node | null): boolean => {
  if (!node) return false

  let current: Node | null = node
  while (current && current.nodeType !== Node.DOCUMENT_NODE) {
    if (current.nodeType === Node.ELEMENT_NODE) {
      const element = current as HTMLElement
      const tagName = element.tagName?.toLowerCase()

      // 检查是否是删除线标签
      if (tagName === 'del' || tagName === 'strike' || tagName === 's') {
        return true
      }

      // 检查 style 属性
      if (element.style?.textDecoration?.includes('line-through')) {
        return true
      }
    }
    current = current.parentNode
  }

  return false
}

/**
 * 给删除线元素添加 data-original 属性
 */
const markStrikethroughAsOriginal = (node: Node | null) => {
  console.log('🔍 markStrikethroughAsOriginal 被调用,输入节点:', node)

  if (!node) {
    console.log('❌ 节点为空,退出')
    return
  }

  // 策略1:如果当前节点就是删除线元素
  if (node.nodeType === Node.ELEMENT_NODE) {
    const element = node as HTMLElement
    const tagName = element.tagName?.toLowerCase()
    if (tagName === 's' || tagName === 'strike' || tagName === 'del') {
      console.log('✅ 当前节点就是删除线元素')
      element.setAttribute('data-original', 'true')
      console.log('✅ 已添加 data-original 属性')
      return
    }
  }

  // 策略2:向上查找删除线元素
  let current: Node | null = node.parentNode
  while (current && current.nodeType !== Node.DOCUMENT_NODE) {
    if (current.nodeType === Node.ELEMENT_NODE) {
      const element = current as HTMLElement
      const tagName = element.tagName?.toLowerCase()

      if (tagName === 's' || tagName === 'strike' || tagName === 'del') {
        console.log('✅ 在父元素中找到删除线元素')
        element.setAttribute('data-original', 'true')
        console.log('✅ 已添加 data-original 属性')
        return
      }
    }
    current = current.parentNode
  }

  // 策略3:在当前节点内部查找删除线元素(适用于父容器的情况)
  if (node.nodeType === Node.ELEMENT_NODE) {
    const element = node as HTMLElement
    const strikethroughElements = element.querySelectorAll('s, strike, del')

    console.log(
      `🔍 在节点内部找到 ${strikethroughElements.length} 个删除线元素`
    )

    if (strikethroughElements.length > 0) {
      strikethroughElements.forEach((el) => {
        el.setAttribute('data-original', 'true')
        console.log('✅ 已给内部删除线元素添加 data-original 属性:', el)
      })
      return
    }
  }

  console.log('❌ 没有找到删除线元素')
}

/**
 * 处理有选中内容的情况
 */
const handleSelectedContent = (editor: any) => {
  try {
    const selection = editor.selection
    const selectedText = selection.getContent({ format: 'text' })

    // 如果没有选中文本内容,不做处理
    if (!selectedText || selectedText.trim() === '') {
      return
    }

    const selectedHtml = selection.getContent({ format: 'html' })

    // 检查选中内容是否全部或部分已有删除线
    const hasStrikethrough =
      selectedHtml.includes('<del') ||
      selectedHtml.includes('<strike') ||
      selectedHtml.includes('<s>')

    if (hasStrikethrough) {
      // 如果已有删除线,跳过该内容并折叠选区到末尾
      const range = selection.getRng()
      range.collapse(false)
      selection.setRng(range)

      editor.notificationManager.open({
        text: '选中内容包含删除线,已跳过',
        type: 'info',
        timeout: 1000
      })
      return
    }

    // 保存当前位置
    const bookmark = selection.getBookmark()

    // 给选中内容添加删除线
    editor.execCommand('Strikethrough')

    // 给新创建的删除线元素添加 data-original 属性
    setTimeout(() => {
      selection.moveToBookmark(bookmark)
      const node = selection.getNode()
      markStrikethroughAsOriginal(node)

      // 折叠选区到末尾
      const range = selection.getRng()
      range.collapse(false)
      selection.setRng(range)
    }, 0)

    editor.notificationManager.open({
      text: '已添加删除线',
      type: 'success',
      timeout: 1000
    })
  } catch (err) {
    console.error('处理选中内容失败:', err)
  }
}

/**
 * 处理 Backspace 键 - 给光标前一个字符添加删除线
 */
const handleBackspace = (editor: any) => {
  try {
    const selection = editor.selection

    // 获取浏览器原生 Selection 对象
    const win = editor.getWin()
    const nativeSelection = win.getSelection()

    if (!nativeSelection) {
      return
    }

    // 保存当前位置
    const currentRange = nativeSelection.getRangeAt(0).cloneRange()

    // 使用原生 API 向前扩展选择一个字符
    nativeSelection.modify('extend', 'backward', 'character')

    // 获取选中的文本
    const selectedText = nativeSelection.toString()

    // 如果没有选中任何字符(可能在文档开头)
    if (!selectedText || selectedText.length === 0) {
      // 恢复原位置
      nativeSelection.removeAllRanges()
      nativeSelection.addRange(currentRange)
      return
    }

    // 检查选中的字符是否已有删除线
    const anchorNode = nativeSelection.anchorNode
    if (hasStrikethroughStyle(anchorNode)) {
      // 已有删除线,跳过这个字符,继续向前移动光标
      const newRange = nativeSelection.getRangeAt(0)
      newRange.collapse(true)
      nativeSelection.removeAllRanges()
      nativeSelection.addRange(newRange)

      editor.notificationManager.open({
        text: '该字符已有删除线',
        type: 'info',
        timeout: 800
      })
      return
    }

    // 同步选区到 TinyMCE
    const tinyRange = editor.dom.createRng()
    const nativeRange = nativeSelection.getRangeAt(0)
    tinyRange.setStart(nativeRange.startContainer, nativeRange.startOffset)
    tinyRange.setEnd(nativeRange.endContainer, nativeRange.endOffset)
    selection.setRng(tinyRange)

    // 保存位置
    const bookmark = selection.getBookmark()

    // 添加删除线
    editor.execCommand('Strikethrough')

    // 给新创建的删除线元素添加 data-original 属性,然后移动光标
    setTimeout(() => {
      selection.moveToBookmark(bookmark)
      const node = selection.getNode()
      markStrikethroughAsOriginal(node)

      // 移动光标到删除线之前
      const newRange = selection.getRng()
      newRange.collapse(true)
      selection.setRng(newRange)
    }, 0)
  } catch (err) {
    console.error('处理 Backspace 失败:', err)
  }
}

/**
 * 处理 Delete 键 - 给光标后一个字符添加删除线
 */
const handleDelete = (editor: any) => {
  try {
    const selection = editor.selection

    // 获取浏览器原生 Selection 对象
    const win = editor.getWin()
    const nativeSelection = win.getSelection()

    if (!nativeSelection) {
      return
    }

    // 保存当前位置
    const currentRange = nativeSelection.getRangeAt(0).cloneRange()

    // 使用原生 API 向后扩展选择一个字符
    nativeSelection.modify('extend', 'forward', 'character')

    // 获取选中的文本
    const selectedText = nativeSelection.toString()

    // 如果没有选中任何字符(可能在文档末尾)
    if (!selectedText || selectedText.length === 0) {
      // 恢复原位置
      nativeSelection.removeAllRanges()
      nativeSelection.addRange(currentRange)
      return
    }

    // 检查选中的字符是否已有删除线
    const anchorNode = nativeSelection.anchorNode
    if (hasStrikethroughStyle(anchorNode)) {
      // 已有删除线,跳过这个字符,继续向后移动光标
      const newRange = nativeSelection.getRangeAt(0)
      newRange.collapse(false)
      nativeSelection.removeAllRanges()
      nativeSelection.addRange(newRange)

      editor.notificationManager.open({
        text: '该字符已有删除线',
        type: 'info',
        timeout: 800
      })
      return
    }

    // 同步选区到 TinyMCE
    const tinyRange = editor.dom.createRng()
    const nativeRange = nativeSelection.getRangeAt(0)
    tinyRange.setStart(nativeRange.startContainer, nativeRange.startOffset)
    tinyRange.setEnd(nativeRange.endContainer, nativeRange.endOffset)
    selection.setRng(tinyRange)

    // 保存位置
    const bookmark = selection.getBookmark()

    // 添加删除线
    editor.execCommand('Strikethrough')

    // 给新创建的删除线元素添加 data-original 属性,然后移动光标
    setTimeout(() => {
      selection.moveToBookmark(bookmark)
      const node = selection.getNode()
      markStrikethroughAsOriginal(node)

      // 移动光标到删除线之前(保持在原位置)
      const newRange = selection.getRng()
      newRange.collapse(true)
      selection.setRng(newRange)
    }, 0)
  } catch (err) {
    console.error('处理 Delete 失败:', err)
  }
}

/**
 * 检查节点是否需要保护(只检查直接父元素,不向上遍历)
 */
const checkShouldProtect = (node: Node): boolean => {
  if (node.nodeType === Node.TEXT_NODE) {
    const parentElement = node.parentElement
    return parentElement?.hasAttribute('data-original') || false
  }
  if (node.nodeType === Node.ELEMENT_NODE) {
    const element = node as Element
    return element.hasAttribute('data-original')
  }
  return false
}

/**
 * 递归查找最深的文本节点
 */
const findDeepestTextNode = (
  node: Node,
  direction: 'last' | 'first'
): Node | null => {
  if (node.nodeType === Node.TEXT_NODE) {
    return node
  }
  if (node.nodeType === Node.ELEMENT_NODE) {
    const children = node.childNodes
    if (children.length === 0) return null

    if (direction === 'last') {
      // 从最后一个子节点开始递归查找
      for (let i = children.length - 1; i >= 0; i--) {
        const result = findDeepestTextNode(children[i], direction)
        if (result) return result
      }
    } else {
      // 从第一个子节点开始递归查找
      for (let i = 0; i < children.length; i++) {
        const result = findDeepestTextNode(children[i], direction)
        if (result) return result
      }
    }
  }
  return null
}

/**
 * 获取要删除的字符所在的节点(用于光标模式)
 */
const getCharNodeForDelete = (
  targetNode: Node,
  offset: number,
  isBackspace: boolean
): Node | null => {
  console.log('🔍 getCharNodeForDelete 被调用')
  console.log('🔍 targetNode:', targetNode)
  console.log('🔍 offset:', offset)
  console.log('🔍 isBackspace:', isBackspace)

  // 情况1:targetNode 是文本节点
  if (targetNode.nodeType === Node.TEXT_NODE) {
    if (isBackspace) {
      // Backspace:检查光标前的字符
      if (offset > 0) {
        console.log('🔍 文本节点,光标前有字符,返回当前节点')
        return targetNode
      }
      // 光标在文本开头,检查前一个兄弟节点
      const prevSibling = targetNode.previousSibling
      console.log('🔍 文本节点在开头,检查前一个兄弟:', prevSibling)

      if (prevSibling) {
        // 递归查找前一个兄弟节点中最后的文本节点
        const result = findDeepestTextNode(prevSibling, 'last')
        console.log('🔍 找到的最深文本节点:', result)
        return result || prevSibling
      }

      // 如果没有前一个兄弟节点,向上查找父节点的前一个兄弟
      console.log('🔍 没有前一个兄弟,向上查找父节点')
      let parent = targetNode.parentNode
      while (parent && parent.nodeType !== Node.DOCUMENT_NODE) {
        const parentPrevSibling = parent.previousSibling
        console.log('🔍 父节点的前一个兄弟:', parentPrevSibling)
        if (parentPrevSibling) {
          const result = findDeepestTextNode(parentPrevSibling, 'last')
          console.log('🔍 找到的最深文本节点:', result)
          return result || parentPrevSibling
        }
        parent = parent.parentNode
      }
    } else {
      // Delete:检查光标后的字符
      const textLength = targetNode.textContent?.length || 0
      if (offset < textLength) {
        console.log('🔍 文本节点,光标后有字符,返回当前节点')
        return targetNode
      }
      // 光标在文本末尾,检查后一个兄弟节点
      const nextSibling = targetNode.nextSibling
      console.log('🔍 文本节点在末尾,检查后一个兄弟:', nextSibling)

      if (nextSibling) {
        // 递归查找后一个兄弟节点中第一个文本节点
        const result = findDeepestTextNode(nextSibling, 'first')
        console.log('🔍 找到的最深文本节点:', result)
        return result || nextSibling
      }

      // 如果没有后一个兄弟节点,向上查找父节点的后一个兄弟
      console.log('🔍 没有后一个兄弟,向上查找父节点')
      let parent = targetNode.parentNode
      while (parent && parent.nodeType !== Node.DOCUMENT_NODE) {
        const parentNextSibling = parent.nextSibling
        console.log('🔍 父节点的后一个兄弟:', parentNextSibling)
        if (parentNextSibling) {
          const result = findDeepestTextNode(parentNextSibling, 'first')
          console.log('🔍 找到的最深文本节点:', result)
          return result || parentNextSibling
        }
        parent = parent.parentNode
      }
    }
  }

  // 情况2:targetNode 是元素节点,offset 表示子节点索引
  if (targetNode.nodeType === Node.ELEMENT_NODE) {
    const childNodes = targetNode.childNodes
    console.log('🔍 元素节点,子节点数量:', childNodes.length)

    if (isBackspace) {
      // Backspace:检查 offset-1 位置的子节点(光标前的节点)
      if (offset > 0 && childNodes[offset - 1]) {
        const prevChild = childNodes[offset - 1]
        console.log('🔍 元素节点,光标前的子节点:', prevChild)
        // 递归查找最后的文本节点
        const result = findDeepestTextNode(prevChild, 'last')
        console.log('🔍 找到的最深文本节点:', result)
        return result || prevChild
      }
    } else {
      // Delete:检查 offset 位置的子节点(光标后的节点)
      if (offset < childNodes.length && childNodes[offset]) {
        const nextChild = childNodes[offset]
        console.log('🔍 元素节点,光标后的子节点:', nextChild)
        // 递归查找第一个文本节点
        const result = findDeepestTextNode(nextChild, 'first')
        console.log('🔍 找到的最深文本节点:', result)
        return result || nextChild
      }
    }
  }

  console.log('🔍 未找到要删除的字符节点')
  return null
}

export const useProtectContent = () => {
  /**
   * 设置删除线内容保护
   * @param editor TinyMCE 编辑器实例
   * @param protectOriginal 是否只保护原始内容(true: 只保护原始内容,false: 保护所有内容)
   */
  const setupProtection = (editor: any, protectOriginal = true) => {
    if (!editor) return

    // 中文输入法状态跟踪
    let isComposing = false

    // 方法1:拦截 TinyMCE 的删除命令(仅在需要保护时拦截)
    editor.on('BeforeExecCommand', (e: any) => {
      const command = e.command
      // 拦截所有可能触发删除的命令
      if (
        command === 'Delete' ||
        command === 'ForwardDelete' ||
        command === 'mceInsertContent'
      ) {
        // 检查是否需要保护
        const selection = editor.selection
        const range = selection.getRng()
        const targetNode = range.collapsed
          ? range.startContainer
          : range.commonAncestorContainer

        // 如果是原始内容保护模式
        if (protectOriginal) {
          // 检查是否需要保护(只检查直接父元素)
          const shouldProtect = checkShouldProtect(targetNode)

          // 只拦截原始内容的删除
          if (shouldProtect) {
            e.preventDefault()
            console.log('拦截了 TinyMCE 命令(原始内容):', command)
          } else {
            console.log('允许 TinyMCE 命令(用户内容):', command)
          }
        } else {
          // 全保护模式,拦截所有删除
          e.preventDefault()
          console.log('拦截了 TinyMCE 命令(全保护):', command)
        }
      }
    })

    // 方法2:在原生 DOM 层面拦截(优先级最高)
    const editorBody = editor.getBody()
    if (editorBody) {
      // 使用 capture 模式,在事件捕获阶段就拦截
      editorBody.addEventListener(
        'keydown',
        (e: KeyboardEvent) => {
          const isBackspace = e.keyCode === 8 || e.key === 'Backspace'
          const isDelete = e.keyCode === 46 || e.key === 'Delete'

          if (!isBackspace && !isDelete) return

          // 获取当前选区
          const selection = editor.selection
          const range = selection.getRng()

          console.log('=== 删除操作开始 ===')
          console.log('protectOriginal:', protectOriginal)
          console.log('range.collapsed:', range.collapsed)

          // 如果启用了原始内容保护模式
          if (protectOriginal) {
            if (range.collapsed) {
              // 光标模式:需要检查光标前/后的字符
              const targetNode = range.startContainer
              const offset = range.startOffset

              console.log('光标模式 - targetNode:', targetNode)
              console.log('光标模式 - nodeType:', targetNode.nodeType)
              console.log('光标模式 - nodeName:', targetNode.nodeName)
              console.log('光标模式 - offset:', offset)
              console.log(
                '光标模式 - textContent:',
                targetNode.textContent?.substring(0, 50)
              )

              // 获取要删除的字符所在的节点
              const charNode = getCharNodeForDelete(
                targetNode,
                offset,
                isBackspace
              )
              console.log('要删除的字符所在节点:', charNode)

              const shouldProtect = charNode
                ? checkShouldProtect(charNode)
                : checkShouldProtect(targetNode)
              console.log('光标模式 - shouldProtect:', shouldProtect)

              if (!shouldProtect) {
                console.log('✅ 允许删除用户输入的内容(光标模式)')
                return
              }

              console.log('❌ 拦截原始内容的删除操作(光标模式)')
            } else {
              // 选区模式:需要智能处理混合内容
              const startNode = range.startContainer
              const endNode = range.endContainer

              console.log('选区模式 - startNode:', startNode)
              console.log('选区模式 - startNode.nodeType:', startNode.nodeType)
              console.log('选区模式 - startNode.nodeName:', startNode.nodeName)
              console.log('选区模式 - endNode:', endNode)
              console.log('选区模式 - endNode.nodeType:', endNode.nodeType)
              console.log('选区模式 - endNode.nodeName:', endNode.nodeName)

              const startProtect = checkShouldProtect(startNode)
              const endProtect = checkShouldProtect(endNode)

              console.log('起始节点需要保护:', startProtect)
              console.log('结束节点需要保护:', endProtect)

              // 情况1:纯用户输入(起始和结束都不需要保护)
              if (!startProtect && !endProtect) {
                console.log('✅ 允许删除纯用户输入内容')
                return
              }

              // 情况2:纯原始内容(起始和结束都需要保护)
              if (startProtect && endProtect) {
                console.log('❌ 拦截纯原始内容的删除,添加删除线')
                // 继续执行添加删除线逻辑
              } else {
                // 情况3:混合内容(部分原始,部分用户输入)
                console.log('⚠️ 混合内容:需要智能处理')
                // TODO: 实现混合删除逻辑
                // 暂时先拦截,添加删除线
              }
            }
          } else {
            console.log('全保护模式,拦截所有删除')
          }

          // 拦截删除操作(原始内容或全保护模式)
          e.preventDefault()
          e.stopPropagation()
          e.stopImmediatePropagation()

          // 情况1:有选中内容 - 给选中的内容添加删除线
          if (!range.collapsed) {
            handleSelectedContent(editor)
            return false
          }

          // 情况2:光标在某个位置 - 给光标前/后的字符添加删除线
          if (isBackspace) {
            handleBackspace(editor)
          } else if (isDelete) {
            handleDelete(editor)
          }

          return false
        },
        true // 使用捕获模式,优先级最高
      )
    }

    // 方法3:TinyMCE 的 keydown 事件作为备份(已由 DOM 捕获阶段处理,这里可以移除)
    // editor.on('keydown', ...) - 不再需要,DOM 捕获阶段已经处理

    // 处理粘贴事件,清除粘贴内容的 data-original 标记
    // 使粘贴的内容成为"用户输入",可以被删除
    editor.on('PastePreProcess', (e: any) => {
      if (protectOriginal && e.content) {
        // 创建临时容器
        const tempDiv = document.createElement('div')
        tempDiv.innerHTML = e.content

        // 清除所有元素的 data-original 标记
        const clearMarks = (element: Element) => {
          clearOriginalMark(element)
        }

        Array.from(tempDiv.children).forEach((child) => {
          clearMarks(child)
        })

        // 更新粘贴内容
        e.content = tempDiv.innerHTML
        console.log('已清除粘贴内容的原始标记')
      }
    })

    // 监听输入事件,处理用户输入继承父元素格式的问题
    if (protectOriginal) {
      // 检查光标是否在删除线元素内
      const findStrikethroughParent = (
        node: Node | null
      ): HTMLElement | null => {
        let current = node
        while (current && current.nodeType !== Node.DOCUMENT_NODE) {
          if (current.nodeType === Node.ELEMENT_NODE) {
            const element = current as HTMLElement
            const tagName = element.tagName?.toLowerCase()
            if (tagName === 's' || tagName === 'strike' || tagName === 'del') {
              return element
            }
          }
          current = current.parentNode
        }
        return null
      }

      // 辅助函数:在删除线后插入文本
      const insertAfterStrikethrough = (
        strikethroughParent: HTMLElement,
        inputChar: string,
        selection: Selection
      ) => {
        const parent = strikethroughParent.parentNode
        if (!parent) return

        const nextSibling = strikethroughParent.nextSibling
        const newTextNode = document.createTextNode(inputChar)

        if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
          // 追加到已有文本节点
          const existingText = nextSibling.textContent || ''
          nextSibling.textContent = inputChar + existingText

          const newRange = document.createRange()
          newRange.setStart(nextSibling, inputChar.length)
          newRange.setEnd(nextSibling, inputChar.length)
          selection.removeAllRanges()
          selection.addRange(newRange)
        } else if (
          nextSibling &&
          nextSibling.nodeType === Node.ELEMENT_NODE &&
          (nextSibling as Element).tagName === 'SPAN'
        ) {
          // 插入到 span 内
          const spanElement = nextSibling as Element
          const firstChild = spanElement.firstChild

          if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
            const existingText = firstChild.textContent || ''
            firstChild.textContent = inputChar + existingText

            const newRange = document.createRange()
            newRange.setStart(firstChild, inputChar.length)
            newRange.setEnd(firstChild, inputChar.length)
            selection.removeAllRanges()
            selection.addRange(newRange)
          } else {
            spanElement.insertBefore(newTextNode, spanElement.firstChild)

            const newRange = document.createRange()
            newRange.setStart(newTextNode, inputChar.length)
            newRange.setEnd(newTextNode, inputChar.length)
            selection.removeAllRanges()
            selection.addRange(newRange)
          }
        } else {
          // 创建新的 span(用户输入内容用红色标记)
          const newSpan = document.createElement('span')
          newSpan.style.color = 'red' // 用户输入的内容显示为红色
          newSpan.appendChild(newTextNode)

          parent.insertBefore(newSpan, nextSibling)

          const newRange = document.createRange()
          newRange.setStart(newTextNode, inputChar.length)
          newRange.setEnd(newTextNode, inputChar.length)
          selection.removeAllRanges()
          selection.addRange(newRange)
        }

        console.log('🔧 已在删除线外插入新字符')
      }

      // 在字符输入前拦截(beforeinput 事件)
      const editorBody = editor.getBody()
      editorBody.addEventListener(
        'beforeinput',
        (e: InputEvent) => {
          // 只处理字符输入
          if (
            e.inputType !== 'insertText' &&
            e.inputType !== 'insertCompositionText'
          ) {
            return
          }

          // 关键修复:使用 TinyMCE 编辑器的 window 对象,而不是全局 window
          const editorWin = editor.getWin()
          const selection = editorWin.getSelection()
          if (!selection || selection.rangeCount === 0) return

          const range = selection.getRangeAt(0)
          const cursorNode = range.startContainer

          console.log('⌨️ beforeinput - 准备输入:', e.data)
          console.log('⌨️ 当前节点:', cursorNode)
          console.log('⌨️ 节点类型:', cursorNode.nodeType)
          console.log('⌨️ 节点名称:', cursorNode.nodeName)
          console.log('⌨️ 节点内容:', cursorNode.textContent?.substring(0, 50))
          console.log('⌨️ 是否正在输入中文:', isComposing)

          // 如果正在输入中文,不拦截(等待 compositionend)
          if (isComposing) {
            console.log('⌨️ 中文输入中,跳过拦截')
            return
          }

          // 检查是否在删除线内
          const strikethroughParent = findStrikethroughParent(cursorNode)
          console.log('⌨️ 找到的删除线父元素:', strikethroughParent)

          if (strikethroughParent) {
            console.log('🔧 在删除线内输入,拦截并手动处理')
            e.preventDefault()

            const inputChar = e.data || ''
            insertAfterStrikethrough(strikethroughParent, inputChar, selection)
            editor.nodeChanged()
            return
          }

          // 检查是否在原始内容区域
          const isOriginal = isOriginalContent(cursorNode)
          console.log('⌨️ 是否是原始内容:', isOriginal)

          if (isOriginal) {
            // 在原始内容区域输入,需要拦截并用 span 包裹
            console.log('🔧 在原始区域输入,拦截并用 span 包裹')
            e.preventDefault()

            const inputChar = e.data || ''

            // 检查光标是否在一个没有 data-original 的 span 内(用户之前输入的)
            let targetSpan: HTMLElement | null = null
            let parent = cursorNode.parentElement

            while (parent) {
              if (
                parent.tagName === 'SPAN' &&
                !parent.hasAttribute('data-original')
              ) {
                targetSpan = parent
                break
              }
              // 如果遇到有 data-original 的元素,停止查找
              if (parent.hasAttribute('data-original')) {
                break
              }
              parent = parent.parentElement
            }

            if (targetSpan && cursorNode.nodeType === Node.TEXT_NODE) {
              // 光标在用户之前输入的 span 内,直接追加文本
              console.log('🔧 追加到已有的用户输入 span')
              const textNode = cursorNode as Text
              const offset = range.startOffset
              const beforeText =
                textNode.textContent?.substring(0, offset) || ''
              const afterText = textNode.textContent?.substring(offset) || ''
              const newText = beforeText + inputChar + afterText
              textNode.textContent = newText

              // 移动光标
              const newRange = document.createRange()
              const newOffset =
                (typeof offset === 'number' ? offset : 0) + inputChar.length
              newRange.setStart(textNode, newOffset)
              newRange.setEnd(textNode, newOffset)
              selection.removeAllRanges()
              selection.addRange(newRange)
            } else {
              // 创建新的 span 元素包裹用户输入(用户输入内容用红色标记)
              console.log('🔧 创建新的 span 包裹输入')
              const newSpan = document.createElement('span')
              newSpan.style.color = 'red' // 用户输入的内容显示为红色
              const textNode = document.createTextNode(inputChar)
              newSpan.appendChild(textNode)

              // 在当前光标位置插入
              range.deleteContents() // 删除选中内容(如果有)
              range.insertNode(newSpan)

              // 将光标移动到新插入内容的末尾
              const newRange = document.createRange()
              newRange.setStartAfter(newSpan)
              newRange.setEndAfter(newSpan)
              selection.removeAllRanges()
              selection.addRange(newRange)
            }

            console.log('🔧 已处理用户输入')
            editor.nodeChanged()
          } else {
            console.log('⌨️ 不是原始内容,允许正常输入')
          }
        },
        true
      )

      // 监听中文输入法事件
      editorBody.addEventListener('compositionstart', () => {
        console.log('🎌 compositionstart - 开始输入中文')
        isComposing = true
      })

      editorBody.addEventListener('compositionupdate', (e: any) => {
        console.log('🎌 compositionupdate - 中文输入更新:', e.data)
      })

      editorBody.addEventListener('compositionend', (e: any) => {
        console.log('🎌 compositionend - 中文输入完成:', e.data)
        isComposing = false

        // 中文输入完成后,需要检查并处理
        setTimeout(() => {
          const editorWin = editor.getWin()
          const selection = editorWin.getSelection()
          if (!selection || selection.rangeCount === 0) return

          const range = selection.getRangeAt(0)
          const cursorNode = range.startContainer

          console.log('🎌 检查中文输入位置')
          console.log('🎌 当前节点:', cursorNode)

          // 检查是否在删除线内
          const strikethroughParent = findStrikethroughParent(cursorNode)
          if (strikethroughParent) {
            console.log('🎌 中文输入在删除线内,需要移出')
            // 找到刚输入的中文文本
            const inputText = e.data || ''
            if (inputText) {
              // 删除删除线内的中文
              const textNode = cursorNode as Text
              const text = textNode.textContent || ''
              const newText = text.replace(inputText, '')
              textNode.textContent = newText

              // 在删除线后插入(用户输入内容用红色标记)
              const newSpan = editor.getDoc().createElement('span')
              newSpan.style.color = 'red' // 用户输入的内容显示为红色
              newSpan.textContent = inputText
              strikethroughParent.parentNode?.insertBefore(
                newSpan,
                strikethroughParent.nextSibling
              )

              // 移动光标到新 span 后
              const newRange = editor.getDoc().createRange()
              newRange.setStartAfter(newSpan)
              newRange.collapse(true)
              selection.removeAllRanges()
              selection.addRange(newRange)

              editor.nodeChanged()
            }
            return
          }

          // 检查是否在原始内容区域
          const isOriginal = isOriginalContent(cursorNode)
          if (isOriginal) {
            console.log('🎌 中文输入在原始区域,需要用 span 包裹')
            const inputText = e.data || ''
            if (inputText) {
              // 找到刚输入的文本在文本节点中的位置
              const textNode = cursorNode as Text
              const text = textNode.textContent || ''
              const inputIndex = text.lastIndexOf(inputText)

              if (inputIndex !== -1) {
                // 分割文本节点
                const beforeText = text.substring(0, inputIndex)
                // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
                const afterText = text.substring(inputIndex + inputText.length)

                // 创建新的 span 包裹输入的中文(用户输入内容用红色标记)
                const newSpan = editor.getDoc().createElement('span')
                newSpan.style.color = 'red' // 用户输入的内容显示为红色
                newSpan.textContent = inputText

                // 重建 DOM 结构
                const parent = textNode.parentNode
                if (parent) {
                  // 创建前半部分文本节点
                  if (beforeText) {
                    const beforeNode = editor
                      .getDoc()
                      .createTextNode(beforeText)
                    parent.insertBefore(beforeNode, textNode)
                  }

                  // 插入新 span
                  parent.insertBefore(newSpan, textNode)

                  // 创建后半部分文本节点
                  if (afterText) {
                    const afterNode = editor.getDoc().createTextNode(afterText)
                    parent.insertBefore(afterNode, textNode)
                  }

                  // 删除原文本节点
                  parent.removeChild(textNode)

                  // 移动光标到新 span 后
                  const newRange = editor.getDoc().createRange()
                  newRange.setStartAfter(newSpan)
                  newRange.collapse(true)
                  selection.removeAllRanges()
                  selection.addRange(newRange)

                  editor.nodeChanged()
                }
              }
            }
          }
        }, 0)
      })
    }

    // 监听内容变化,维护 data-original 属性
    if (protectOriginal) {
      // 存储初始的原始元素(在加载时标记)
      const originalElements = new Set<Element>()

      // 初始化时收集所有原始元素
      setTimeout(() => {
        const body = editor.getBody()
        const allOriginalElements = body.querySelectorAll(
          '[data-original="true"]'
        )
        allOriginalElements.forEach((el: Element) => originalElements.add(el))
        console.log('📋 初始化:收集到', originalElements.size, '个原始元素')
      }, 100)

      editor.on('NodeChange', () => {
        const body = editor.getBody()

        // 策略1:维护删除线元素的 data-original 属性
        const strikethroughElements = body.querySelectorAll('s, strike, del')
        strikethroughElements.forEach((el: Element) => {
          let parent = el.parentElement
          let parentHasOriginal = false

          while (parent && parent !== body) {
            if (parent.hasAttribute('data-original')) {
              parentHasOriginal = true
              break
            }
            parent = parent.parentElement
          }

          if (parentHasOriginal && !el.hasAttribute('data-original')) {
            el.setAttribute('data-original', 'true')
            console.log('🔧 NodeChange: 恢复删除线的 data-original 属性')
          }
        })

        // 策略2:恢复原始容器元素的 data-original 属性
        // 查找包含删除线的 span 和 p 元素
        originalElements.forEach((originalEl) => {
          // 查找编辑器中对应的元素(通过类名、标签等特征)
          if (originalEl.nodeType === Node.ELEMENT_NODE) {
            const element = originalEl as HTMLElement
            const tagName = element.tagName?.toLowerCase()

            // 如果是 p 或 span 元素,查找包含删除线的对应元素
            if (tagName === 'p' || tagName === 'span') {
              // 通过子元素特征(包含删除线)来识别
              const currentElements = body.querySelectorAll(
                `${tagName}:has(s[data-original="true"]), ${tagName}:has(strike[data-original="true"]), ${tagName}:has(del[data-original="true"])`
              )

              currentElements.forEach((el: Element) => {
                if (!el.hasAttribute('data-original')) {
                  el.setAttribute('data-original', 'true')
                  console.log(
                    '🔧 NodeChange: 恢复容器元素的 data-original 属性:',
                    tagName
                  )
                }
              })
            }
          }
        })
      })
    }

    const mode = protectOriginal ? '原始内容保护' : '全内容保护'
    console.log(`📝 审查模式已启用 - ${mode} - 删除操作将转换为添加删除线`)
  }

  /**
   * 移除保护(如果需要)
   */
  const removeProtection = (editor: any) => {
    if (!editor) return
    console.log('📝 审查模式已关闭')
  }

  return {
    setupProtection,
    removeProtection
  }
}


types文件夹下的index.ts

/**
 * TinyMCE 编辑器组件 Props
 */
export interface TinyMCEEditorProps {
  modelValue?: string // v-model 绑定的内容
  disabled?: boolean // 是否禁用编辑器
  height?: number // 编辑器高度
  config?: Record<string, any> // 自定义配置
}

/**
 * TinyMCE 编辑器组件 Emits
 */
export type TinyMCEEditorEmits = {
  (e: 'update:modelValue', value: string): void
  (e: 'init', editor: any): void
  (e: 'change', value: string): void
}

/**
 * TinyMCE 编辑器实例方法
 */
export interface TinyMCEEditorInstance {
  getContent: () => string
  setContent: (content: string) => void
  getEditor: () => any
  addStrikethrough: (targetString: string) => boolean
  appendAfterString: (
    targetString: string,
    appendText: string,
    textColor?: string
  ) => boolean
  scrollToTarget: (targetString: string) => boolean
  addBackgroundColor: (targetString: string, backgroundColor: string) => boolean
}

怎么使用:父组件代码

<template>
  <div class="review-records-container">
    <div class="editor-wrapper">
      <h2 class="page-title">审查记录编辑</h2>

      <!-- TinyMCE 编辑器组件 -->
      <TinyMCEEditor
        ref="editorRef"
        v-model="content"
        :height="400"
        @init="handleEditorInit"
        @change="handleContentChange"
      />

      <!-- 字符串操作按钮 -->
      <div class="operation-buttons">
        <h3 class="section-title">字符串操作测试</h3>
        <div class="button-group">
          <a-button type="primary" @click="handleAddStrikethrough">
            给 “合同编号” 添加删除线
          </a-button>
          <a-button type="primary" @click="handleAppendString">
            在 “合同编号” 后添加 "富文本编辑器"
          </a-button>
          <a-button type="primary" @click="handleAppendStringWithColor">
            在 “合同编号” 后添加红色 "【重点】"
          </a-button>
          <a-button type="primary" @click="handleScrollToTarget">
            定位到 “合同编号”
          </a-button>
          <a-button type="primary" @click="handleAddBackgroundColor">
            给 “合同编号” 添加黄色背景
          </a-button>
        </div>
      </div>

      <!-- 操作按钮 -->
      <div class="action-buttons">
        <h3 class="section-title">编辑器操作</h3>
        <div class="button-group">
          <a-button type="primary" @click="handleLoadData">
            模拟加载数据
          </a-button>
          <a-button @click="handleClear">清空内容</a-button>
          <a-button type="primary" @click="handleSave">保存</a-button>
          <a-button @click="handleGetContent">获取内容</a-button>
        </div>
      </div>

      <!-- 内容预览区域 -->
      <div v-if="previewContent" class="preview-section">
        <h3>内容预览:</h3>
        <div class="preview-content" v-html="previewContent"></div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import TinyMCEEditor from '@/components/TinyMCEEditor/index.vue'
import type { TinyMCEEditorInstance } from '@/components/TinyMCEEditor/types'

// 编辑器引用
const editorRef = ref<TinyMCEEditorInstance>()

// 编辑器内容
const content = ref('')

// 预览内容
const previewContent = ref('')

// 编辑器初始化完成
const handleEditorInit = (editor: any) => {
  console.log('TinyMCE 编辑器初始化完成', editor)
  message.success('编辑器加载成功')
}

// 内容变化
const handleContentChange = (value: string) => {
  console.log('内容已变化:', value)
}

// ========== 字符串操作测试 ==========

// 添加删除线
const handleAddStrikethrough = () => {
  editorRef.value?.addStrikethrough('合同编号')
}

// 追加字符串
const handleAppendString = () => {
  editorRef.value?.appendAfterString('合同编号', '富文本编辑器')
}

// 追加带颜色的字符串
const handleAppendStringWithColor = () => {
  editorRef.value?.appendAfterString('合同编号', '【重点】', '#ff0000')
}

// 定位到目标
const handleScrollToTarget = () => {
  editorRef.value?.scrollToTarget('合同编号')
}

// 添加背景色
const handleAddBackgroundColor = () => {
  editorRef.value?.addBackgroundColor('北京市朝阳区', '#ffff00')
}

// ========== 编辑器操作 ==========

// 模拟异步加载数据
const handleLoadData = () => {
  message.info('正在加载数据...')

  // 模拟异步请求(1秒后返回数据)
  setTimeout(() => {
    content.value = `
     <p class="MsoNormal"><s><span style="font-family: 宋体;">合同编号</span></s><span style="font-family: 宋体;">:</span>FLC-2024-<s><span style="font-family: 宋体;">001</span></s><span style="font-family: 宋体;">发现的地方大幅度发地方吗</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">签订地点:北京市朝阳区</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">签订日期:</span>2025年4月5日</p>
<p class="MsoNormal"><!-- [if !supportLists]--><span style="mso-list: Ignore;">第一条&nbsp;</span><!--[endif]--><strong><span style="font-family: 宋体;">合同双方</span></strong><strong><span class="15">&nbsp;</span></strong></p>
<p class="MsoNormal"><!-- [if !supportLists]--><span style="mso-list: Ignore;">第二条&nbsp;</span><!--[endif]--><strong><span style="font-family: 宋体;">TinyMCE</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">甲方(出租人):</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">公司名称:中融信达融资租赁有限公司</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">法定代表人:李明远</span></strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">注册地址:北京市朝阳区金融大街</span>8号国贸大厦B座12层</strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">统一社会信用代码:</span>91110105MA01XK7Y3P</strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">联系电话:</span>010-88886666</strong></p>
<p class="MsoNormal"><strong><span style="font-family: 宋体;">乙方(承租人):</span></strong></p>
<p class="MsoNormal"><span style="font-family: 宋体;">公司名称:华兴智能制造有限公司</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">法定代表人:张伟强</span></p>
<p class="MsoNormal"><span style="font-family: 宋体;">注册地址:江苏省苏州市工业园区星湖街</span>188号科技产业园A栋5楼</p>
<p class="MsoNormal"><span style="font-family: 宋体;">统一社会信用代码:</span>91320594MA21N5LJ8Q</p>
<p class="MsoNormal"><span style="font-family: 宋体;">联系电话:</span>0512-66669999</p>
<p class="MsoNormal"><span style="font-family: 宋体;">甲乙双方根据《中华人民共和国民法典》《中华人民共和国合同法》及相关法律法规,本着平等自愿、诚实信用的原则,经协商一致,就融资租赁事宜订立本合同。</span></p>
<p class="MsoNormal"><strong><span class="15"><span style="font-family: 宋体;">第二条</span> <span style="font-family: 宋体;">租赁物</span></span></strong></p>
<p class="MsoNormal">2.1 租赁物名称:数控五轴加工中心设备</p>
<p class="MsoNormal">2.2 型号规格:ZX-5000CNC</p>
<p class="MsoNormal">2.3 制造厂商:江苏精工机械股份有限公司</p>
<p class="MsoNormal">2.4 数量:2台</p>
<p class="MsoNormal">2.5 出厂编号:JG20240301、JG20240302</p>
<p class="MsoNormal">2.6 技术参数及配置详见附件一《租赁物技术说明书》</p>
<p class="MsoNormal">2.7 租赁物总价款:人民币贰佰肆拾万元整(&yen;2,400,000.00)</p>
<p class="MsoNormal"><strong><span class="15"><span style="font-family: 宋体;">第三
    `
    message.success('数据加载成功')
  }, 1000)
}

// 清空内容
const handleClear = () => {
  content.value = ''
  previewContent.value = ''
  message.info('内容已清空')
}

// 保存内容
const handleSave = () => {
  if (
    !content.value ||
    content.value.trim() === '<p></p>' ||
    content.value.trim() === ''
  ) {
    message.warning('内容为空,无需保存')
    return
  }

  // 这里可以实现保存到后端的逻辑
  console.log('保存的内容:', content.value)
  message.success('保存成功')
}

// 获取内容
const handleGetContent = () => {
  const editorContent = editorRef.value?.getContent()
  if (
    !editorContent ||
    editorContent.trim() === '<p></p>' ||
    editorContent.trim() === ''
  ) {
    message.warning('内容为空')
    return
  }

  previewContent.value = editorContent
  console.log('当前内容:', editorContent)
  message.success('内容已在下方预览')
}
</script>

<style scoped>
.review-records-container {
  padding: 24px;
  background: #f5f5f5;
  min-height: 100vh;
}

.editor-wrapper {
  max-width: 1200px;
  margin: 0 auto;
  background: white;
  padding: 24px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.page-title {
  font-size: 24px;
  font-weight: 600;
  color: #1d2129;
  margin-bottom: 24px;
  padding-bottom: 12px;
  border-bottom: 2px solid #1890ff;
}

.operation-buttons,
.action-buttons {
  margin-top: 24px;
  padding: 20px;
  background: #f9f9f9;
  border-radius: 8px;
  border: 1px solid #e8e8e8;
}

.section-title {
  font-size: 16px;
  font-weight: 600;
  color: #1d2129;
  margin-bottom: 16px;
}

.button-group {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
}

.preview-section {
  margin-top: 24px;
  padding: 16px;
  background: #fafafa;
  border-radius: 4px;
  border: 1px solid #e8e8e8;
}

.preview-section h3 {
  font-size: 16px;
  font-weight: 600;
  color: #1d2129;
  margin-bottom: 12px;
}

.preview-content {
  padding: 16px;
  background: white;
  border-radius: 4px;
  min-height: 100px;
  line-height: 1.6;
}
</style>