Vue.js - 富文本编辑器Tiptap使用详解10(获取、绑定编辑器内容,封装编辑器)
作者:hangge | 2026-02-25 11:29
在实际项目中,如何读取编辑器内容、把内容传给父组件或后端保存、以及如何实现与父组件双向绑定(v-model)是否重要。本文通过样例演示如何在自定义组件里使用 value prop + input 事件实现双向绑定。
(2)修改编辑器内容,可以看到下方绑定值同步更新,说明双向数据绑定成功。
(2)点击“保存到后端”按钮,可以看到控制台输出内容如下:
(2)最终效果如下下图所示:
十、获取、绑定编辑器内容
1,创建可复用的 Tiptap 编辑器组件
首先我们创建一个可复用的 Tiptap 编辑器组件 TiptapEditor.vue,其支持 v-model 双向绑定(value prop + input 事件),并支持输出 HTML 或 JSON(由 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 内容中连续空格转换为
if (format === 'html') {
out = out.replace(/ {2,}/g, (match) =>
match.split('').map(() => ' ').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()
// 简单归一化:把连续空白和 归一,避免因为空格转义导致误判
const normalize = s => String(s).replace(/ | /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 内容中连续空格转换为
if (format === 'html') {
out = out.replace(/ {2,}/g, (match) =>
match.split('').map(() => ' ').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()
// 简单归一化:把连续空白和 归一,避免因为空格转义导致误判
const normalize = s => String(s).replace(/ | /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)