返回 导航

Vue.js

hangge.com

Vue.js - 富文本编辑器Tiptap使用详解10(获取、绑定编辑器内容,封装编辑器)

作者:hangge | 2026-02-25 11:29
    在实际项目中,如何读取编辑器内容、把内容传给父组件或后端保存、以及如何实现与父组件双向绑定(v-model)是否重要。本文通过样例演示如何在自定义组件里使用 value prop + input 事件实现双向绑定。

十、获取、绑定编辑器内容

1,创建可复用的 Tiptap 编辑器组件

    首先我们创建一个可复用的 Tiptap 编辑器组件 TiptapEditor.vue,其支持 v-model 双向绑定(value prop + input 事件),并支持输出 HTMLJSON(由 outputFormat 决定)。组件代码如下:
<template>
  <div class="tiptap-editor">
    <!-- 你可以在这里插入自定义工具栏 -->
    <editor-content :editor="editor" />
  </div>
</template>

<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
// 推荐:TableKit 一次引入所有 table 相关扩展
import { TableKit } from '@tiptap/extension-table'
import { TextStyleKit } from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import { EditorContent } from '@tiptap/vue-2'
import debounce from 'lodash/debounce' // 可选:用于去抖

import { CustomTable } from './kcd/CustomTable'
import { CustomTableCell } from './kcd/CustomTableCell'
import { TableRow, TableHeader } from "@tiptap/extension-table";
import { CustomParagraph } from './kcd/CustomParagraph'

// 差异比对相关
import DiffAdded from './diff2/diffAdded'
import DiffRemoved from './diff2/diffRemoved'
import { ImageDiffPlugin } from './diff2/imageDiffPlugin'

import Toolbar from './Toolbar.vue'

export default {
  name: 'TiptapEditor',
  components: { EditorContent, Toolbar },
  props: {
    // v-model 支持(父组件用 v-model="content")
    value: { type: [String, Object], default: '' },
    // 输出格式:'html' | 'json'
    outputFormat: { type: String, default: 'html' },
    // 是否在 mounted 时把 value 作为初始内容(true 推荐)
    initFromValue: { type: Boolean, default: true },
    // 去抖延迟(ms),0 表示不去抖
    debounceMs: { type: Number, default: 300 },
  },
  data() {
    return {
      editor: null,
      updatingFromEditor: false, // 防止循环更新
      lastEmitted: null,
      // 内部引用,避免重复创建 listener/handler
      _emitDebounced: null,
      _updateHandler: null,
    }
  },
  mounted() {
    // 使用独立函数创建编辑器,便于重建
    this.createEditor()
  },

  beforeDestroy() {
    // 销毁编辑器并释放资源
    this.destroyEditor()
  },

  watch: {
    // 父组件改变 value 时,把变化同步到 editor(只在非编辑器主动更新时)
    value(newVal) {
      if (!this.editor) return
      if (this.updatingFromEditor) return // 忽略来自编辑器本身的变更
      this.setEditorContentFromProp(newVal)
    },

    outputFormat() {
      // 如果外部切换输出格式,可能需要立即 emit 一次最新值
      if (!this.editor) return
      const fmt = this.outputFormat === 'json' ? 'json' : 'html'
      const out = fmt === 'json' ? this.editor.getJSON() : this.editor.getHTML()
      this.$emit('input', out)
    }
  },

  methods: {
    // ---------- 编辑器生命周期管理 ----------
    /**
     * 创建 Tiptap 编辑器实例
     * - 会先销毁已有实例,防止重复
     * - 初始化内容、扩展、去抖函数
     * - 注册 update 事件监听
     * - 如果 initFromValue=true 且 value 非空,会同步内容
     */
    createEditor(val) {
      // 获取初始数据
      let initialContent = val
      if (!initialContent) {
        initialContent = this.initFromValue && this.value ? this.value : '<p></p>'
      }

      if (this.editor) this.destroyEditor()

      this._emitDebounced = this.createEmitDebounced()

      this.editor = new Editor({
        extensions: [
          StarterKit.configure({ paragraph: false }),
          Link,
          Image,
          TextStyleKit,
          Color.configure({ types: ['textStyle'] }),
          Highlight.configure({ multicolor: true }),
          TableRow,
          TableHeader,
          CustomTableCell,
          CustomTable,
          CustomParagraph,
          DiffAdded,
          DiffRemoved,
          ImageDiffPlugin,
        ],
        content: initialContent,
      })

      // 注册编辑器 update 监听
      this._updateHandler = this.createUpdateHandler()
      this.editor.on('update', this._updateHandler)
    },

    /**
     * 销毁编辑器实例
     * - 移除 update 事件监听
     * - 调用 editor.destroy 释放资源
     * - 清理去抖函数和内部状态
     */
    destroyEditor() {
      this.removeUpdateHandler()
      if (this.editor?.destroy) this.editor.destroy()
      this.editor = null

      // 取消去抖
      this._emitDebounced?.cancel?.()
      this._emitDebounced = null

      this._updateHandler = null
      this.lastEmitted = null
      this.updatingFromEditor = false
    },

    /**
     * 对外接口:重建编辑器实例
     * - 先销毁已有实例
     * - 再重新创建
     */
    reinitEditor(val) {
      this.destroyEditor()
      this.createEditor(val)
      this.updatingFromEditor = true
      const out = this.outputFormat === 'json' ? this.editor.getJSON() : this.editor.getHTML()
      this.$emit('input', out)
      setTimeout(() => {
        this.updatingFromEditor = false
      }, 300);
    },


    // ---------- 内部辅助函数 ----------

    /**
     * 创建去抖的内容输出函数
     * - 防止短时间内频繁触发 v-model 或事件
     * - format 可选 'html' 或 'json'
     */
    createEmitDebounced() {
      const emitContent = (format) => {
        if (!this.editor) return

        let out = format === 'json' ? this.editor.getJSON() : this.editor.getHTML()

        // 避免重复 emit
        if (JSON.stringify(out) === JSON.stringify(this.lastEmitted)) return

        // HTML 内容中连续空格转换为 &nbsp;
        if (format === 'html') {
          out = out.replace(/ {2,}/g, (match) =>
            match.split('').map(() => '&nbsp;').join('')
          )
        }

        this.lastEmitted = out

        // Vue2 v-model
        this.$emit('input', out)
        this.$emit('update:content', out)
      }

      if (this._emitDebounced?.cancel) this._emitDebounced.cancel()
      return this.debounceMs > 0 ? debounce(emitContent, this.debounceMs) : emitContent
    },

    /**
     * 创建编辑器 update 事件处理函数
     * - 每次编辑器内容更新都会调用
     * - 调用去抖 emitContent 输出 v-model
     * - 防止循环更新,设置 updatingFromEditor 标志
     */
    createUpdateHandler() {
      return () => {
        this.updatingFromEditor = true
        const fmt = this.outputFormat === 'json' ? 'json' : 'html'
        this._emitDebounced(fmt)
        setTimeout(() => (this.updatingFromEditor = false), Math.max(0, this.debounceMs))
      }
    },

    /**
     * 移除编辑器 update 事件监听
     * - 避免编辑器 destroy 或重建时重复绑定
     */
    removeUpdateHandler() {
      if (this.editor && this._updateHandler && this.editor.off) {
        try { this.editor.off('update', this._updateHandler) } catch (e) { }
      }
    },

    // ---------- 内容相关工具 ----------

    /**
     * 根据传入 prop 设置编辑器内容
     * - 支持 HTML 字符串或 JSON 对象
     * - 如果编辑器有焦点(用户正在输入),则不主动覆盖内容(避免打断光标)
     * - 只有在内容确实不同的时候才调用 setContent,调用前会尝试保存/恢复选区
     */
    setEditorContentFromProp(val) {
      try {
        if (!this.editor) return

        // 清空处理
        if (!val) {
          // 只有在编辑器不聚焦时清空,避免用户正在输入时被清空
          if (!this.editor.isFocused) {
            this.editor.commands?.clearContent?.()
          }
          return
        }

        // 如果当前是编辑器主动更新(通过 createUpdateHandler 标记),跳过
        if (this.updatingFromEditor) return

        const isObject = typeof val === 'object'
        // 快速判断当前编辑器内容是否与传入一致,避免不必要的 setContent
        if (isObject) {
          const curJSON = this.editor.getJSON()
          if (JSON.stringify(curJSON) === JSON.stringify(val)) return
        } else {
          const curHTML = this.editor.getHTML()
          // 简单归一化:把连续空白和 &nbsp; 归一,避免因为空格转义导致误判
          const normalize = s => String(s).replace(/&nbsp;| /g, ' ').replace(/\s+/g, ' ').trim()
          if (normalize(curHTML) === normalize(val)) return
        }

        // 如果编辑器有焦点(用户正在输入),不要覆盖内容以免干扰光标
        if (this.editor.isFocused) {
          // 可选:如果希望在编辑时也同步(极少场景),可以把这里改为尝试局部合并而不是setContent
          return
        }

        // 到这里:确实需要更新编辑器内容 -> 尝试保存选区并恢复
        const selection = this.editor.state.selection
        const selFrom = selection?.from ?? null
        const selTo = selection?.to ?? null

        // 设置内容
        if (isObject) this.editor.commands.setContent(val)
        else this.editor.commands.setContent(String(val))

        // 恢复选区(仅当之前有选区信息且在新文档长度范围内)
        if (selFrom != null && selTo != null) {
          const docSize = this.editor.state.doc.content.size
          const newFrom = Math.min(selFrom, docSize)
          const newTo = Math.min(selTo, docSize)
          try {
            // Tiptap 提供 setTextSelection 命令
            this.editor.commands.setTextSelection({ from: newFrom, to: newTo })
            // focus 编辑器以显示光标(可选)
            this.editor.view.focus()
          } catch (e) {
            // 恢复失败也不要抛错
            // console.warn('恢复选区失败:', e)
          }
        }
      } catch (e) {
        console.warn('设置编辑器内容失败:', e)
      }
    },

    /**
     * 获取 HTML 格式内容
     */
    getHTML() {
      return this.editor?.getHTML() || ''
    },

    /**
     * 获取 JSON 格式内容
     */
    getJSON() {
      return this.editor?.getJSON() || {}
    },
  }
}
</script>

<style scoped>
.tiptap-editor {
  min-height: 200px;
  padding: 12px;
}

.diff-viewer {
  margin: 1rem auto
}

.editors {
  display: flex;
  gap: 1rem
}

.editor-box {
  flex: 1
}

.toolbar {
  padding: 8px;
  background: #fafafa;
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.toolbar button {
  padding: 6px 8px;
  border: 1px solid #eaeaea;
  background: white;
  border-radius: 4px;
  cursor: pointer;
}

.toolbar button.active {
  background: #e6f7ff;
  border-color: #91d5ff;
  font-weight: 600;
}

.toolbar .separator {
  border-left: 1px solid #eaeaea;
  margin: 0 6px;
}

/* 编辑器样式 */
::v-deep .ProseMirror {
  max-height: 700px;
  /* 最大高度 */
  height: 700px;
  /* 固定高度可选 */
  overflow-y: auto;
  /* 超出显示滚动条 */
  padding: 0.5rem;
  /* 内边距可选 */
  /* border: 1px solid #ddd; */
  /* 可选边框 */
  border-radius: 4px;
  white-space: pre-wrap;
}

/* ====== 关键:为编辑器内 table 添加样式 ====== */
/* Vue2 scoped 下使用深度选择器确保样式能命中 ProseMirror 渲染的元素 */
/* 如果你的环境不识别 ::v-deep,请试试 >>> 或 /deep/ */
::v-deep .ProseMirror table {
  width: 100%;
  border-collapse: collapse;
  table-layout: auto;
  margin: 6px 0;
}

::v-deep .ProseMirror td,
::v-deep .ProseMirror p {
  white-space: pre-wrap;
}
</style>

2,使用可复用的 Tiptap 编辑器组件

(1)下面样例我们使用这个封装的 Tiptap 编辑器组件进行双向数据绑定。
提示TiptapEditor 默认输出的是 HTML,如果需要输出 JSON 则将 outputFormat 属性设置为 json 即可。
<template>
  <div class="editor-wrap">
    <div class="toolbar" role="toolbar" aria-label="Editor toolbar">
      <button @click="save">保存到后端</button>
    </div>
    <TiptapEditor ref="editor" v-model="contentHtml" :debounceMs="200" />
    <div class="toolbar" role="toolbar" aria-label="Editor toolbar">
        绑定数据
    </div>
    {{ contentHtml }}
  </div>
</template>

<script>
import axios from 'axios'
import TiptapEditor from './TiptapEditor.vue'

export default {
  components: { TiptapEditor },
  data() {
    return { contentHtml: '<p>初始化内容</p>' }
  },
  methods: {
    async save() {
      // 保存到后端
      await axios.post('/api/save', { html: this.contentHtml })
      alert('已保存')
    }
  }
}
</script>

<style scoped>
.editor-wrap { border: 1px solid #ddd; border-radius:6px; }
.toolbar { padding:8px; background:#fafafa; display:flex; gap:6px; flex-wrap:wrap; }
.toolbar button { padding:6px 8px; border:1px solid #cca1a1; 
    background:white; border-radius:4px; cursor:pointer; }
.toolbar button.active { background:#e6f7ff; border-color:#91d5ff; font-weight:600; }
.editor-content { min-height:200px; padding:12px; }
</style>


(2)修改编辑器内容,可以看到下方绑定值同步更新,说明双向数据绑定成功。

附一:同时保存 HTML 与 JSON

(1)有时我们需要同时保存 HTML(用于直接展示)与 JSON(用于恢复/迁移/差异合并),我们可以使用封装 TiptapEditor 组件的 .getHTML().getJSON() 方法获取对应数据。
async save() {
    // 直接获取html和json内容
    const html = this.$refs.editor.getHTML()
    const json = this.$refs.editor.getJSON()
    console.log('html:', html)
    console.log('json:', json)
    // 保存到后端
    await axios.post('/api/save', { html: this.contentHtml })
    alert('已保存')
}

(2)点击“保存到后端”按钮,可以看到控制台输出内容如下:

附二:封装一个带工具栏的双向绑定编辑器

(1)我之前的文章演示了如何封装一个顶部的工具栏,这里将其与该双向绑定功能做个集成,最终 TiptapEditor.vue 代码如下:
<template>
  <div class="tiptap-editor">
    <Toolbar :editor="editor" :buttons="buttons" :maxVisible="12" :showLabels="false" 
      :dark="false" size="small" v-if="editor">
      <template #right>
        <div class="flex items-center gap-2">
          <el-button size="small" @click="saveContent">保存</el-button>
        </div>
      </template>
    </Toolbar>
    <editor-content :editor="editor" />
  </div>
</template>

<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
// 推荐:TableKit 一次引入所有 table 相关扩展
import { TableKit } from '@tiptap/extension-table'
import { TextStyleKit } from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import { EditorContent } from '@tiptap/vue-2'
import debounce from 'lodash/debounce' // 可选:用于去抖

import { CustomTable } from './kcd/CustomTable'
import { CustomTableCell } from './kcd/CustomTableCell'
import { TableRow, TableHeader } from "@tiptap/extension-table";
import { CustomParagraph } from './kcd/CustomParagraph'

// 差异比对相关
import DiffAdded from './diff2/diffAdded'
import DiffRemoved from './diff2/diffRemoved'
import { ImageDiffPlugin } from './diff2/imageDiffPlugin'

import Toolbar from './Toolbar.vue'

export default {
  name: 'TiptapEditor',
  components: { EditorContent, Toolbar },
  props: {
    // v-model 支持(父组件用 v-model="content")
    value: { type: [String, Object], default: '' },
    // 输出格式:'html' | 'json'
    outputFormat: { type: String, default: 'html' },
    // 是否在 mounted 时把 value 作为初始内容(true 推荐)
    initFromValue: { type: Boolean, default: true },
    // 去抖延迟(ms),0 表示不去抖
    debounceMs: { type: Number, default: 300 },
  },
  data() {
    return {
      editor: null,
      updatingFromEditor: false, // 防止循环更新
      lastEmitted: null,
      // 内部引用,避免重复创建 listener/handler
      _emitDebounced: null,
      _updateHandler: null,
      buttons: [
        { label: '加粗', icon: 'fas fa-bold', name: 'bold', command: 'toggleBold' },
        { label: '斜体', icon: 'fas fa-italic', name: 'italic', command: 'toggleItalic' },
        { label: '下划线', icon: 'fas fa-underline', name: 'underline', 
          command: 'toggleUnderline' },
        { label: '删除线', icon: 'fas fa-strikethrough', name: 'strike', command: 'toggleStrike' },
        // 文字颜色设置
        {
          type: 'dropdown',
          label: '文字颜色',
          icon: 'fas fa-palette',
          items: [
            {
              label: '黑色',
              icon: 'fas fa-circle text-black',
              onClick: (editor) => editor.chain().focus().setColor('#000000').run(),
            },
            {
              label: '蓝色',
              icon: 'fas fa-circle text-blue-500',
              onClick: (editor) => editor.chain().focus().setColor('#0066ff').run(),
            },
            {
              label: '绿色',
              icon: 'fas fa-circle text-green-500',
              onClick: (editor) => editor.chain().focus().setColor('#22bb33').run(),
            },
            {
              label: '清除颜色',
              icon: 'fas fa-ban',
              onClick: (editor) => editor.chain().focus().unsetColor().run(),
            },
            {
              label: '自定义颜色…',
              icon: 'fas fa-eye-dropper',
              onClick: (editor) => {
                const value = prompt('请输入颜色,例如 #ffaa00')
                if (value && /^#([0-9a-fA-F]{6})$/.test(value)) {
                  editor.chain().focus().setColor(value).run()
                } else {
                  alert('请输入合法 HEX 颜色')
                }
              },
            },
          ],
        },

        // 字体大小设置
        {
          type: 'dropdown',
          icon: 'fas fa-text-height',
          label: '字体大小',
          items: [
            {
              label: '14px',
              command: 'setFontSize',
              args: ['14px'],
              name: 'textStyle',
              attrs: { fontSize: '14px' }
            },
            {
              label: '16px(默认)',
              command: 'setFontSize',
              args: ['16px'],
              name: 'textStyle',
              attrs: { fontSize: '16px' }
            },
            {
              label: '18px',
              command: 'setFontSize',
              args: ['18px'],
              name: 'textStyle',
              attrs: { fontSize: '18px' }
            },

            // 自定义输入
            {
              label: '自定义大小…',
              icon: 'fas fa-keyboard',
              onClick: (editor) => {
                const val = prompt('请输入字体大小,例如 22px 或 1.5rem')
                if (!val) return
                const valid = /^(\d+(px|em|rem|pt)|\d+(\.\d+)?(em|rem))$/.test(val)
                if (!valid) {
                  alert('请输入合法的大小,例如:18px、1.2rem')
                  return
                }
                editor.chain().focus().setFontSize(val).run()
              }
            },

            // 清除字体大小
            {
              label: '清除大小',
              icon: 'fas fa-ban',
              onClick: (editor) => editor.chain().focus().unsetFontSize().run()
            }
          ]
        },
        // 列表
        {
          label: '项目符号', icon: 'fas fa-list-ul', command: 'toggleBulletList',
          name: 'bulletList'
        },
        {
          label: '编号列表', icon: 'fas fa-list-ol', command: 'toggleOrderedList',
          name: 'orderedList'
        },

        // 下拉:标题级别选择
        {
          type: 'dropdown',
          label: '标题',
          icon: 'fas fa-heading',
          items: [
            { label: '正文 (p)', command: (editor) => editor.chain().focus().setParagraph().run() },
            {
              label: 'H1', command: 'toggleHeading', args: [1],
              onClick: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run()
            },
            {
              label: 'H2', command: 'toggleHeading', args: [2],
              onClick: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run()
            },
            {
              label: 'H3', command: 'toggleHeading', args: [3],
              onClick: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run()
            },
          ],
        },
        // 自定义链接插入(手动 prompt)
        {
          label: '插入链接', icon: 'fas fa-link',
          onClick: (editor) => {
            const url = prompt('请输入 URL (以 http(s):// 开头):')
            if (url) editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
          }
        },

        // 图片(示例:插入图片)
        {
          label: '插入图片', icon: 'far fa-image',
          onClick: (editor) => {
            const url = prompt('图片 URL:')
            if (url) editor.chain().focus().setImage({ src: url }).run()
          }
        },
        // 自定义清除格式
        {
          label: '清除格式', icon: 'fas fa-eraser',
          onClick: (editor) => editor.chain().focus().clearNodes().unsetAllMarks().run()
        },
        {
          label: '撤销', icon: 'fas fa-undo',
          command: (editor) => editor.chain().focus().undo().run()
        },
        {
          label: '重做', icon: 'fas fa-redo',
          command: (editor) => editor.chain().focus().redo().run()
        },
      ]
    }
  },
  mounted() {
    // 使用独立函数创建编辑器,便于重建
    this.createEditor()
  },

  beforeDestroy() {
    // 销毁编辑器并释放资源
    this.destroyEditor()
  },

  watch: {
    // 父组件改变 value 时,把变化同步到 editor(只在非编辑器主动更新时)
    value(newVal) {
      if (!this.editor) return
      if (this.updatingFromEditor) return // 忽略来自编辑器本身的变更
      this.setEditorContentFromProp(newVal)
    },

    outputFormat() {
      // 如果外部切换输出格式,可能需要立即 emit 一次最新值
      if (!this.editor) return
      const fmt = this.outputFormat === 'json' ? 'json' : 'html'
      const out = fmt === 'json' ? this.editor.getJSON() : this.editor.getHTML()
      this.$emit('input', out)
    }
  },

  methods: {
    // ---------- 编辑器生命周期管理 ----------
    /**
     * 创建 Tiptap 编辑器实例
     * - 会先销毁已有实例,防止重复
     * - 初始化内容、扩展、去抖函数
     * - 注册 update 事件监听
     * - 如果 initFromValue=true 且 value 非空,会同步内容
     */
    createEditor(val) {
      // 获取初始数据
      let initialContent = val
      if (!initialContent) {
        initialContent = this.initFromValue && this.value ? this.value : '<p></p>'
      }

      if (this.editor) this.destroyEditor()

      this._emitDebounced = this.createEmitDebounced()

      this.editor = new Editor({
        extensions: [
          StarterKit.configure({ paragraph: false }),
          Link,
          Image,
          TextStyleKit,
          Color.configure({ types: ['textStyle'] }),
          Highlight.configure({ multicolor: true }),
          TableRow,
          TableHeader,
          CustomTableCell,
          CustomTable,
          CustomParagraph,
          DiffAdded,
          DiffRemoved,
          ImageDiffPlugin,
        ],
        content: initialContent,
      })

      // 注册编辑器 update 监听
      this._updateHandler = this.createUpdateHandler()
      this.editor.on('update', this._updateHandler)
    },

    /**
     * 销毁编辑器实例
     * - 移除 update 事件监听
     * - 调用 editor.destroy 释放资源
     * - 清理去抖函数和内部状态
     */
    destroyEditor() {
      this.removeUpdateHandler()
      if (this.editor?.destroy) this.editor.destroy()
      this.editor = null

      // 取消去抖
      this._emitDebounced?.cancel?.()
      this._emitDebounced = null

      this._updateHandler = null
      this.lastEmitted = null
      this.updatingFromEditor = false
    },

    /**
     * 对外接口:重建编辑器实例
     * - 先销毁已有实例
     * - 再重新创建
     */
    reinitEditor(val) {
      this.destroyEditor()
      this.createEditor(val)
      this.updatingFromEditor = true
      const out = this.outputFormat === 'json' ? this.editor.getJSON() : this.editor.getHTML()
      this.$emit('input', out)
      setTimeout(() => {
        this.updatingFromEditor = false
      }, 300);
    },
    saveContent() {
      const html = this.editor.getHTML()
      console.log('保存的 HTML', html)
      alert('保存的 HTML 长度:' + html.length)
    },
    // ---------- 内部辅助函数 ----------

    /**
     * 创建去抖的内容输出函数
     * - 防止短时间内频繁触发 v-model 或事件
     * - format 可选 'html' 或 'json'
     */
    createEmitDebounced() {
      const emitContent = (format) => {
        if (!this.editor) return

        let out = format === 'json' ? this.editor.getJSON() : this.editor.getHTML()

        // 避免重复 emit
        if (JSON.stringify(out) === JSON.stringify(this.lastEmitted)) return

        // HTML 内容中连续空格转换为 &nbsp;
        if (format === 'html') {
          out = out.replace(/ {2,}/g, (match) =>
            match.split('').map(() => '&nbsp;').join('')
          )
        }

        this.lastEmitted = out

        // Vue2 v-model
        this.$emit('input', out)
        this.$emit('update:content', out)
      }

      if (this._emitDebounced?.cancel) this._emitDebounced.cancel()
      return this.debounceMs > 0 ? debounce(emitContent, this.debounceMs) : emitContent
    },

    /**
     * 创建编辑器 update 事件处理函数
     * - 每次编辑器内容更新都会调用
     * - 调用去抖 emitContent 输出 v-model
     * - 防止循环更新,设置 updatingFromEditor 标志
     */
    createUpdateHandler() {
      return () => {
        this.updatingFromEditor = true
        const fmt = this.outputFormat === 'json' ? 'json' : 'html'
        this._emitDebounced(fmt)
        setTimeout(() => (this.updatingFromEditor = false), Math.max(0, this.debounceMs))
      }
    },

    /**
     * 移除编辑器 update 事件监听
     * - 避免编辑器 destroy 或重建时重复绑定
     */
    removeUpdateHandler() {
      if (this.editor && this._updateHandler && this.editor.off) {
        try { this.editor.off('update', this._updateHandler) } catch (e) { }
      }
    },

    // ---------- 内容相关工具 ----------

    /**
 * 根据传入 prop 设置编辑器内容
 * - 支持 HTML 字符串或 JSON 对象
 * - 如果编辑器有焦点(用户正在输入),则不主动覆盖内容(避免打断光标)
 * - 只有在内容确实不同的时候才调用 setContent,调用前会尝试保存/恢复选区
 */
    setEditorContentFromProp(val) {
      try {
        if (!this.editor) return

        // 清空处理
        if (!val) {
          // 只有在编辑器不聚焦时清空,避免用户正在输入时被清空
          if (!this.editor.isFocused) {
            this.editor.commands?.clearContent?.()
          }
          return
        }

        // 如果当前是编辑器主动更新(通过 createUpdateHandler 标记),跳过
        if (this.updatingFromEditor) return

        const isObject = typeof val === 'object'
        // 快速判断当前编辑器内容是否与传入一致,避免不必要的 setContent
        if (isObject) {
          const curJSON = this.editor.getJSON()
          if (JSON.stringify(curJSON) === JSON.stringify(val)) return
        } else {
          const curHTML = this.editor.getHTML()
          // 简单归一化:把连续空白和 &nbsp; 归一,避免因为空格转义导致误判
          const normalize = s => String(s).replace(/&nbsp;| /g, ' ').replace(/\s+/g, ' ').trim()
          if (normalize(curHTML) === normalize(val)) return
        }

        // 如果编辑器有焦点(用户正在输入),不要覆盖内容以免干扰光标
        if (this.editor.isFocused) {
          // 可选:如果确实希望在编辑时也同步(极少场景),可以把这改为尝试局部合并而不是setContent
          return
        }

        // 到这里:确实需要更新编辑器内容 -> 尝试保存选区并恢复
        const selection = this.editor.state.selection
        const selFrom = selection?.from ?? null
        const selTo = selection?.to ?? null

        // 设置内容
        if (isObject) this.editor.commands.setContent(val)
        else this.editor.commands.setContent(String(val))

        // 恢复选区(仅当之前有选区信息且在新文档长度范围内)
        if (selFrom != null && selTo != null) {
          const docSize = this.editor.state.doc.content.size
          const newFrom = Math.min(selFrom, docSize)
          const newTo = Math.min(selTo, docSize)
          try {
            // Tiptap 提供 setTextSelection 命令
            this.editor.commands.setTextSelection({ from: newFrom, to: newTo })
            // focus 编辑器以显示光标(可选)
            this.editor.view.focus()
          } catch (e) {
            // 恢复失败也不要抛错
            // console.warn('恢复选区失败:', e)
          }
        }
      } catch (e) {
        console.warn('设置编辑器内容失败:', e)
      }
    },

    /**
     * 获取 HTML 格式内容
     */
    getHTML() {
      return this.editor?.getHTML() || ''
    },

    /**
     * 获取 JSON 格式内容
     */
    getJSON() {
      return this.editor?.getJSON() || {}
    },
  }
}
</script>

<style scoped>
.tiptap-editor {
  min-height: 200px;
  padding: 12px;
}

.diff-viewer {
  margin: 1rem auto
}

.editors {
  display: flex;
  gap: 1rem
}

.editor-box {
  flex: 1
}

.toolbar {
  padding: 8px;
  background: #fafafa;
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.toolbar button {
  padding: 6px 8px;
  border: 1px solid #eaeaea;
  background: white;
  border-radius: 4px;
  cursor: pointer;
}

.toolbar button.active {
  background: #e6f7ff;
  border-color: #91d5ff;
  font-weight: 600;
}

.toolbar .separator {
  border-left: 1px solid #eaeaea;
  margin: 0 6px;
}

/* 编辑器样式 */
::v-deep .ProseMirror {
  max-height: 700px;
  /* 最大高度 */
  height: 700px;
  /* 固定高度可选 */
  overflow-y: auto;
  /* 超出显示滚动条 */
  padding: 0.5rem;
  /* 内边距可选 */
  /* border: 1px solid #ddd; */
  /* 可选边框 */
  border-radius: 4px;
  white-space: pre-wrap;
}

/* ====== 关键:为编辑器内 table 添加样式 ====== */
/* Vue2 scoped 下使用深度选择器确保样式能命中 ProseMirror 渲染的元素 */
/* 如果你的环境不识别 ::v-deep,请试试 >>> 或 /deep/ */
::v-deep .ProseMirror table {
  width: 100%;
  border-collapse: collapse;
  table-layout: auto;
  margin: 6px 0;
}

::v-deep .ProseMirror td,
::v-deep .ProseMirror p {
  white-space: pre-wrap;
}
</style>

(2)最终效果如下下图所示:
评论

全部评论(0)

回到顶部