Vue.js - 富文本编辑器Tiptap使用详解15(文档内容对比功能实现)
作者:hangge | 2026-03-07 11:47
如果需要实现两个文档版本对比,并对差异高亮显示功能,可以借助 Tiptap(基于 ProseMirror)来实现。简单来说就是需要自己做“对比算法 + 把差异映射回 ProseMirror 文档并用 mark 样式标记”的工作,下面通过样例进行演示。
(2)接着我们创建一个 diffRemoved.js,其作用是提供“删除”高亮显示的视觉标记。所有被 diff.js 标记为删除的内容都应用这个 Mark(不管是文字、图片、表格),然后控制控制其渲染外观(红色、删除线、背景色等)
(3)最后为了让图片也能显示 diff 效果,创建一个 imageDiffPlugin.js。其作用是检查图片节点是否包含 diffAdded 或 diffRemoved 标记,然后将图片渲染出对应的高亮效果(加 outline、加背景色、加删除线框等)
(2)默认左右两个内容都是一样的,我们对右边内容进行修改,比如增加一些内容,删除一些内容。然后点击“开始对比”按钮
十五、文档内容对比功能实现
1,实现思路
(1)拿到两个版本的结构化内容:oldDoc = editor1.getJSON(),newDoc = editor2.getJSON()。不要直接比较 HTML 字符串(易错)。
(2)在节点级别做结构对齐:先尽量按节点序列对齐(例如相同 nodeType 的节点优先配对),针对无法对齐的节点直接标记为新增/删除(整节点)。
(3)对 text 节点做字符级 diff:使用成熟的文本 diff 算法(常见:diff-match-patch 或 npm diff),把文本差异拆成三类:相同 / 插入 / 删除。
(4)把 diff 结果映射回 ProseMirror 节点:将插入、删除的文本分别生成带 mark(如 diff-added、diff-removed)的 text 节点,或用 wrapper node(例如 <span class="diff-added">)。
(5)生成“合并视图”或“高亮视图”:合并视图会在一个文档中同时显示新增与被删(删用红色删除样式),也可以只显示高亮(例如只用颜色标注新增或修改后的文本)。
(6)在 Tiptap 中渲染:通过 editor.commands.setContent(mergedJSON) 或动态构建节点并插入到只读编辑器中,再用 CSS 为 .diff-added / .diff-removed 上样式。
2,准备工作
(1)首先我们需要安装好 Tiptap 需要的的相关扩展,具体可以参考我之前写的文章:
(2)接着我们还需要安装 diff-match-patch 这个依赖库帮助我们进行对比。
提示:diff-match-patch 是 Google 开源的一套“文本差异比对 + 合并 + 模糊匹配”算法库,核心目的是 找出两段文本之间的差异(diff)、把差异应用到文本上(patch)、对文本进行模糊查找(match)。它常被用在富文本编辑器、文档协作、版本比对、内容同步等场景中。
npm install diff-match-patch
3,编写内容差异对比核心模块
(1)首先我们创建一个 diff.js 文件,作用是借助 diff-match-patch 对两份 Tiptap 文档做结构化深度比对,它根据节点类型(文本、图片、表格等)生成带有 diff 标记的文档,供前端用于高亮展示差异。
(2)diff.js 文件代码如下:
// 基于 diff-match-patch 的增强版文档差异比对(支持段落、列表、表格、行内字符级 diff)
import DiffMatchPatch from "diff-match-patch";
const dmp = new DiffMatchPatch();
// -------------------- 基础工具函数 --------------------
/**
* 提取 JSON Node 中的纯文本
* 用于:段落文本比对、列表 item 文本比对、表格 cell 文本比对
*/
function extractPlainText(node) {
if (!node) return "";
if (node.type === "text") return node.text || "";
// 如果是图片等 inline 节点,把它表示成占位符字符(长度为1),便于 LCS / text-diff 对齐
if (
node.type === "image" ||
node.type === "hardBreak" ||
node.type === "emoji" ||
node.type === "inlineCard" // 根据你的 schema 增加
) {
return "\uFFFC"; // 对象替代字符
}
if (!node.content) return "";
return node.content.map((n) => extractPlainText(n)).join("");
}
/** 深拷贝 Node(避免直接修改原节点导致影响渲染) */
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 最长公共子序列 LCS,用于数组比对:
* - 列表:按 li 的纯文本做对齐
* - 表格:按每行拼接文本做对齐
* 返回 index 配对对齐序列
*/
export function lcsIndices(a, b) {
const n = a.length,
m = b.length;
const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
// 动态规划计算 LCS
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
dp[i][j] =
a[i - 1] === b[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
// 根据 dp 表回溯出 index 对
const pairs = [];
let i = n,
j = m;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
pairs.unshift([i - 1, j - 1]);
i--;
j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) i--;
else j--;
}
return pairs;
}
/**
* 使用 diff-match-patch 做字符级 diff
* 输出统一格式 pieces: {type: 'equal'|'insert'|'delete', text}
*/
export function textDiffPieces(oldText, newText) {
const diffs = dmp.diff_main(oldText, newText);
dmp.diff_cleanupSemantic(diffs);
return diffs.map(([op, text]) => ({
type: op === 0 ? "equal" : op === 1 ? "insert" : "delete",
text,
}));
}
/**
* 将字符 diff 片段转成 TipTap textNode
* 在 insert/delete 时添加 mark
*/
export function piecesToTextNodes(
pieces,
markAddedType = "diffAdded",
markRemovedType = "diffRemoved"
) {
const res = [];
for (const p of pieces) {
if (!p || !p.text) continue;
if (p.type === "equal") res.push({ type: "text", text: p.text });
else if (p.type === "insert") {
res.push({
type: "text",
text: p.text,
marks: [{ type: markAddedType }],
});
} else if (p.type === "delete") {
res.push({
type: "text",
text: p.text,
marks: [{ type: markRemovedType }],
});
}
}
return res;
}
// -------------------- 文档结构拆块(段落、列表、表格) --------------------
/**
* 按块分割文档:
* 输出结构:
* [
* { type:'para', text:'xxxx', node: paragraphNode },
* { type:'list', items:[...], node: listNode },
* { type:'table', rows:[...], node: tableNode }
* ]
* 用于高于字符级的结构对齐
*/
export function splitParagraphs(docJson) {
const paras = [];
if (!docJson || !docJson.content) return paras;
for (const node of docJson.content) {
// 段落类节点
if (
node.type === "paragraph" ||
node.type === "heading" ||
node.type === "blockquote" ||
node.type === "image"
) {
const text = extractPlainText(node);
paras.push({ type: "para", node, text });
}
// 列表类节点
else if (node.type === "bulletList" || node.type === "orderedList") {
const items = (node.content || []).map((li) => extractPlainText(li));
paras.push({ type: "list", node, text: items.join(""), items });
}
// 表格类节点
else if (node.type === "table") {
const rows = (node.content || []).map((row) =>
(row.content || []).map((cell) => extractPlainText(cell)).join("|")
);
paras.push({ type: "table", node, text: rows.join(""), rows });
}
// 非上述类型:直接序列化(不会影响结构 diff)
else {
paras.push({ type: "other", node, text: JSON.stringify(node) });
}
}
return paras;
}
// -------------------- mark 工具函数 --------------------
/** 给节点递归打标记,用于:整块删除/整块新增 */
function applyMarkRecursively(node, markType) {
if (!node) return;
if (node.type === "text") {
node.marks = node.marks || [];
node.marks.push({ type: markType });
return;
}
if (Array.isArray(node.content)) {
for (const c of node.content) applyMarkRecursively(c, markType);
}
}
/** 生成 paragraph 节点(字符 diff 后) */
function paragraphWithPieces(pieces, paragraphTemplate) {
// 如果传入了 paragraphTemplate,优先使用 buildParagraphFromPieces,
// 以保证能复用原有的 runs 和 marks;否则 fallback 到原来 behavior。
if (paragraphTemplate) {
// buildParagraphFromPieces 返回一个节点数组(可能多个 paragraph)
const nodes = buildParagraphFromPieces(
pieces,
"both", // side: 'left'/'right'/'both' - 这里用 both 表示我们要同时考虑复用 template
paragraphTemplate
);
// 尝试找到第一个 paragraph 节点并返回;若没有,组装一个空 paragraph
const firstPara = nodes.find((n) => n && n.type === "paragraph");
if (firstPara) return firstPara;
// 如果 nodes 存在但不是 paragraph,包成一个 paragraph( 保守做法 )
if (nodes.length > 0) {
// flatten nodes into paragraph content if they are text/inline
const content = [];
for (const n of nodes) {
if (n.type === "paragraph") {
content.push(...(n.content || []));
} else {
// 如果遇到 block-level node,放在 paragraph 中也许不合语义
// 这里保守处理:将其序列化为 text 节点(避免丢失)
content.push({
type: "text",
text: JSON.stringify(n),
marks: [{ type: "diffRemoved" }],
});
}
}
return { type: "paragraph", content };
}
}
// 原有行为(兼容旧实现):直接把 pieces 转成 text nodes(仅在没有 template 时)
const children = piecesToTextNodes(pieces);
const filtered = children.filter((c) => c && c.text && c.text.length > 0);
return { type: "paragraph", content: filtered };
}
// -------------------- 列表比对算法:按 li 对齐 --------------------
/** 空的 listItem(在左侧删除 / 右侧新增 的占位) */
function buildListPlaceholderItem() {
return { type: "listItem", content: [{ type: "paragraph", content: [] }] };
}
/** 复制列表并替换 content 为新的 listItems */
function cloneListWithItems(listNode, items) {
return {
type: listNode.type,
attrs: listNode.attrs ? deepClone(listNode.attrs) : undefined,
content: items,
};
}
/**
* 列表 diff:
* 1. 先对比 li 的纯文本做 LCS 对齐
* 2. 匹配项保留两边原结构
* 3. 删除项 → 左侧加标记,右侧放占位
* 4. 新增项 → 右侧加标记,左侧放占位
*/
function diffListBlocks(oldNode, newNode) {
const oldItems = oldNode.content || [];
const newItems = newNode.content || [];
const oldTexts = oldItems.map((i) => extractPlainText(i));
const newTexts = newItems.map((i) => extractPlainText(i));
const matches = lcsIndices(oldTexts, newTexts);
const leftItems = [];
const rightItems = [];
let oi = 0,
ni = 0,
mi = 0;
while (oi < oldItems.length || ni < newItems.length) {
const nextPair = matches[mi] || null;
const nextOi = nextPair ? nextPair[0] : Infinity;
const nextNi = nextPair ? nextPair[1] : Infinity;
// 完全对齐的 li
if (oi === nextOi && ni === nextNi) {
leftItems.push(deepClone(oldItems[oi]));
rightItems.push(deepClone(newItems[ni]));
oi++;
ni++;
mi++;
} else {
// 左侧有而右侧没有 → 删除
while (oi < Math.min(nextOi, oldItems.length)) {
const removed = deepClone(oldItems[oi]);
applyMarkRecursively(removed, "diffRemoved");
leftItems.push(removed);
//rightItems.push(buildListPlaceholderItem()); //不插入空行
oi++;
}
// 右侧有而左侧没有 → 新增
while (ni < Math.min(nextNi, newItems.length)) {
const added = deepClone(newItems[ni]);
applyMarkRecursively(added, "diffAdded");
rightItems.push(added);
//leftItems.push(buildListPlaceholderItem()); //不插入空行
ni++;
}
}
if (!nextPair) break;
}
return [
cloneListWithItems(oldNode, leftItems),
cloneListWithItems(newNode, rightItems),
];
}
// -------------------- 表格比对(行、cell 对齐 + 内部字符级 diff) --------------------
/** 生成空 cell,用于新增/删除情况 */
function buildEmptyCell(cellNodeTemplate) {
const paragraphTemplate =
cellNodeTemplate &&
Array.isArray(cellNodeTemplate.content) &&
cellNodeTemplate.content.find((c) => c.type === "paragraph");
const paragraph = paragraphTemplate
? {
type: "paragraph",
attrs: deepClone(paragraphTemplate.attrs),
content: [],
}
: { type: "paragraph", content: [] };
return { type: "tableCell", content: [paragraph] };
}
/** 克隆表格并替换行数组 */
function cloneTableWithRows(tableNode, rows) {
return {
type: tableNode.type,
attrs: tableNode.attrs ? deepClone(tableNode.attrs) : undefined,
content: rows,
};
}
/**
* 表格比对:
* 1. 以每行拼接文本做 LCS,对齐行
* 2. 行内部再对齐 cell(按 cell 拼接文本)
* 3. cell 内部做字符级 diff(保留 colspan/rowspan)
*/
function diffTableBlocks(oldNode, newNode) {
const oldRows = oldNode.content || [];
const newRows = newNode.content || [];
const oldRowTexts = oldRows.map((r) =>
(r.content || []).map((c) => extractPlainText(c)).join("|")
);
const newRowTexts = newRows.map((r) =>
(r.content || []).map((c) => extractPlainText(c)).join("|")
);
const matches = lcsIndices(oldRowTexts, newRowTexts);
const leftRows = [];
const rightRows = [];
let oi = 0,
ni = 0,
mi = 0;
while (oi < oldRows.length || ni < newRows.length) {
const nextPair = matches[mi] || null;
const nextOi = nextPair ? nextPair[0] : Infinity;
const nextNi = nextPair ? nextPair[1] : Infinity;
// 完全对齐的行 → 做 row diff
if (oi === nextOi && ni === nextNi) {
const [lrow, rrow] = diffTableRow(oldRows[oi], newRows[ni]); // ← 使用增强版本
leftRows.push(lrow);
rightRows.push(rrow);
oi++;
ni++;
mi++;
} else {
// 行删除
while (oi < Math.min(nextOi, oldRows.length)) {
const remRow = deepClone(oldRows[oi]);
//applyMarkRecursively(remRow, 'diffRemoved')
//leftRows.push(remRow)
const [lrow, rrow] = diffTableRow(remRow, newRows[oi]);
leftRows.push(lrow);
//rightRows.push({ type: 'tableRow', content: (remRow.content || [])
// .map(() => buildEmptyCell()) })
oi++;
}
// 行新增
while (ni < Math.min(nextNi, newRows.length)) {
const addRow = deepClone(newRows[ni]);
//applyMarkRecursively(addRow, 'diffAdded')
//rightRows.push(addRow)
const [lrow, rrow] = diffTableRow(oldRows[ni], addRow);
rightRows.push(rrow);
//leftRows.push({ type: 'tableRow', content: (addRow.content || [])
// .map(() => buildEmptyCell()) })
ni++;
}
}
if (!nextPair) break;
}
return [
cloneTableWithRows(oldNode, leftRows),
cloneTableWithRows(newNode, rightRows),
];
}
/** 将 paragraph 的 text runs 扁平化为 [{text, marks}] 数组 */
function flattenTextRuns(paragraphNode) {
const runs = [];
if (!paragraphNode || !Array.isArray(paragraphNode.content)) return runs;
for (const ch of paragraphNode.content) {
if (ch.type === "text") {
runs.push({
kind: "text", // 区分 text / inline
text: ch.text || "",
marks: ch.marks ? deepClone(ch.marks) : undefined,
});
} else {
// 对于非 text 的 inline(image、hardBreak等)保留原 node
// 作为不可分割的 run,text 用占位符 '\uFFFC'
runs.push({
kind: "inline",
text: "\uFFFC",
node: deepClone(ch),
marks: ch.marks ? deepClone(ch.marks) : undefined,
});
}
}
return runs;
}
/**
* 从 runs(扁平化)中按 length 消耗出若干字符并返回对应文本节点数组,
* 会保留每段的 marks(如果被切分,会复制 marks 到切分后的段)
* args:
* runsRef: { runs: [...] , idx: 0, offset: 0 } —— 可变引用,记录当前位置
* len: 需要消耗的字符长度
* 返回: [{ type: 'text', text, marks? }, ...]
*/
function consumeRunsForLength(runsRef, len) {
const out = [];
let remaining = len;
const runs = runsRef.runs;
while (remaining > 0 && runsRef.idx < runs.length) {
const run = runs[runsRef.idx];
if (run.kind === "inline") {
// inline 作为一个整体,占用长度 1(占位符),不能从中间截断
if (runsRef.offset > 0) {
// 逻辑上 offset 对 inline 无意义,跳到下一 run
runsRef.idx++;
runsRef.offset = 0;
continue;
}
if (remaining >= 1) {
// 恢复出 inline node(保持 marks)
// 我们返回一个特殊结构,调用处识别 `type: 'inline'` 或保留 node 字段
const inlineNode = runsRef.runs[runsRef.idx].node
? deepClone(runsRef.runs[runsRef.idx].node)
: { type: "text", text: run.text || "" };
// 保证 node 有 marks 数组(与 text run 并列)
if (run.marks) inlineNode.marks = deepClone(run.marks);
out.push({ type: "inline", node: inlineNode });
remaining -= 1;
runsRef.idx++;
runsRef.offset = 0;
} else {
break;
}
} else {
// 普通 text run,可被拆分
const available = run.text.length - runsRef.offset;
const take = Math.min(available, remaining);
const pieceText = run.text.substr(runsRef.offset, take);
const node = { type: "text", text: pieceText };
if (run.marks) node.marks = deepClone(run.marks);
out.push(node);
remaining -= take;
runsRef.offset += take;
if (runsRef.offset >= run.text.length) {
runsRef.idx++;
runsRef.offset = 0;
}
}
}
return out;
}
// diffTableRow: 单行表格 diff,调用 diffTableCell
function diffTableRow(oldRow, newRow) {
const oldCells = oldRow?.content || [];
const newCells = newRow?.content || [];
const oldTexts = oldCells.map((c) => extractPlainText(c));
const newTexts = newCells.map((c) => extractPlainText(c));
const matches = lcsIndices(oldTexts, newTexts);
const leftCells = [];
const rightCells = [];
let oi = 0,
ni = 0,
mi = 0;
while (oi < oldCells.length || ni < newCells.length) {
const nextPair = matches[mi] || null;
const nextOi = nextPair ? nextPair[0] : Infinity;
const nextNi = nextPair ? nextPair[1] : Infinity;
// --- 处理空隙 ---
while (
(oi < nextOi && oi < oldCells.length) ||
(ni < nextNi && ni < newCells.length)
) {
// 严谨判断
const hasOld = oi < nextOi && oi < oldCells.length;
const hasNew = ni < nextNi && ni < newCells.length;
if (hasOld && hasNew) {
// 假设同一位置的单元格被修改 -> 进入 cell 内部 diff
const lResult = diffTableCell(oldCells[oi], newCells[ni], "left");
const rResult = diffTableCell(oldCells[oi], newCells[ni], "right");
leftCells.push(lResult);
rightCells.push(rResult);
oi++;
ni++;
} else if (hasOld) {
// 单元格被删
leftCells.push(diffTableCell(oldCells[oi], null, "left"));
rightCells.push(buildEmptyCell(oldCells[oi]));
oi++;
} else if (hasNew) {
// 单元格新增
leftCells.push(buildEmptyCell(newCells[ni]));
rightCells.push(diffTableCell(null, newCells[ni], "right"));
ni++;
} else {
break;
}
}
// --- 处理完美匹配 ---
if (oi === nextOi && ni === nextNi && nextPair) {
// 即使完全匹配,也建议跑一次 diffTableCell 以保证结构一致性
const lResult = diffTableCell(oldCells[oi], newCells[ni], "left");
const rResult = diffTableCell(oldCells[oi], newCells[ni], "right");
leftCells.push(lResult);
rightCells.push(rResult);
oi++;
ni++;
mi++;
}
}
return [
{ type: "tableRow", content: leftCells },
{ type: "tableRow", content: rightCells },
];
}
// diffTableCell: 段落级 cell diff
function diffTableCell(oldCell, newCell, side = "left") {
const oldChildren = oldCell?.content || [];
const newChildren = newCell?.content || [];
const oldTexts = oldChildren.map(extractPlainText);
const newTexts = newChildren.map(extractPlainText);
const matches = lcsIndices(oldTexts, newTexts);
const resultNodes = [];
let oi = 0;
let ni = 0;
let mi = 0;
while (oi < oldChildren.length || ni < newChildren.length) {
const nextPair = matches[mi] || null;
const nextOi = nextPair ? nextPair[0] : Infinity;
const nextNi = nextPair ? nextPair[1] : Infinity;
// --- 处理空隙(LCS未匹配的区间) ---
while (
(oi < nextOi && oi < oldChildren.length) ||
(ni < nextNi && ni < newChildren.length)
) {
const hasOld = oi < nextOi && oi < oldChildren.length;
const hasNew = ni < nextNi && ni < newChildren.length;
// 1. 两边都有节点 -> 尝试对齐(修改/替换)
if (hasOld && hasNew) {
const oldNode = oldChildren[oi];
const newNode = newChildren[ni];
if (oldNode.type === newNode.type) {
if (oldNode.type === "paragraph") {
// A. 段落:做字符级 Diff (保持不变)
const pieces = textDiffPieces(
extractPlainText(oldNode),
extractPlainText(newNode)
);
const rebuiltNodes = buildParagraphFromPieces(
pieces,
side === "left" ? "left" : "right",
side === "left" ? oldNode : newNode
);
resultNodes.push(...rebuiltNodes);
} else if (oldNode.type === "image") {
// B. 图片(或其他原子块,如 video):视为修改/替换
// 策略:左侧标记为删除,右侧标记为新增
// 左侧:标记为删除
if (side === "left") {
resultNodes.push(markNodeAsModified(oldNode, "diffRemoved"));
}
// 右侧:标记为新增
if (side === "right") {
resultNodes.push(markNodeAsModified(newNode, "diffAdded"));
}
} else {
// C. 其它块级节点(如 heading/blockquote):
// 如果你想做内部字符级 diff,需要在这里调用 diffParagraphWithPieces
// 否则,也按图片处理方式,标记为删除+新增(表示替换)
if (side === "left") {
resultNodes.push(markNodeAsModified(oldNode, "diffRemoved"));
}
if (side === "right") {
resultNodes.push(markNodeAsModified(newNode, "diffAdded"));
}
}
oi++;
ni++;
} else {
// 类型不同:先处理左侧删除
processDeletedNode(oldChildren[oi], resultNodes, side);
oi++;
}
}
// 2. 只有左侧有节点 -> 删除 (保持不变)
else if (hasOld) {
processDeletedNode(oldChildren[oi], resultNodes, side);
oi++;
}
// 3. 只有右侧有节点 -> 新增 (保持不变)
else if (hasNew) {
processInsertedNode(newChildren[ni], resultNodes, side);
ni++;
} else {
break;
}
}
// --- 处理完美匹配 (Anchor) --- (保持不变)
if (oi === nextOi && ni === nextNi && nextPair) {
const targetNode = side === "left" ? oldChildren[oi] : newChildren[ni];
resultNodes.push(deepClone(targetNode));
oi++;
ni++;
mi++;
}
}
return {
type: "tableCell",
attrs: oldCell?.attrs || newCell?.attrs || {},
content:
resultNodes.length > 0
? resultNodes
: [{ type: "paragraph", content: [] }],
};
}
/** 标记节点为修改/新增/删除,针对非文本块(如 image/video) */
function markNodeAsModified(node, markType) {
const marked = deepClone(node);
if (marked.type === "image" || marked.type === "video") {
// 对于 image, video 等带有属性的节点,建议在 attrs 或 marks 上进行标记
marked.attrs = { ...(marked.attrs || {}), "data-diff": markType };
// 确保有 marks 数组(假设你的 schema 允许 block 节点带 marks)
marked.marks = marked.marks || [];
// 避免重复标记
if (!marked.marks.some((m) => m.type === markType)) {
marked.marks.push({ type: markType });
}
} else {
// 对于其他 block 节点,递归应用 mark
applyMarkRecursively(marked, markType);
}
return marked;
}
// --- 辅助函数:处理删除节点 ---
function processDeletedNode(node, resultList, side) {
if (side === "right") return;
if (node.type === "paragraph") {
const pieces = textDiffPieces(extractPlainText(node), "");
const leftPara = buildParagraphFromPieces(pieces, "left", node);
resultList.push(...leftPara);
} else {
// 使用统一的标记函数
resultList.push(markNodeAsModified(node, "diffRemoved"));
}
}
// --- 辅助函数:处理新增节点 ---
function processInsertedNode(node, resultList, side) {
if (side === "left") return;
if (node.type === "paragraph") {
const pieces = textDiffPieces("", extractPlainText(node));
const rightPara = buildParagraphFromPieces(pieces, "right", node);
resultList.push(...rightPara);
} else {
// 使用统一的标记函数
resultList.push(markNodeAsModified(node, "diffAdded"));
}
}
// 返回一个节点数组:可能包含 paragraph 节点、image 节点、其他 inline/block 节点
function buildParagraphFromPieces(pieces, side, paragraphTemplate) {
const templateRuns = paragraphTemplate
? flattenTextRuns(paragraphTemplate)
: [];
const runsRef = { runs: templateRuns, idx: 0, offset: 0 };
// 临时累积当前 paragraph 的 content(text / inline allowed)
let curParaContent = [];
// 最终节点数组
const resultNodes = [];
function flushParagraphIfAny() {
if (curParaContent.length > 0) {
resultNodes.push({
type: "paragraph",
...(paragraphTemplate?.attrs
? { attrs: deepClone(paragraphTemplate.attrs) }
: {}),
content: curParaContent,
});
curParaContent = [];
}
}
for (const p of pieces) {
if (!p || p.text == null) continue;
const txt = p.text;
if (p.type === "equal") {
const consumed = consumeRunsForLength(runsRef, txt.length);
if (consumed.length) {
for (const n of consumed) {
if (n.type === "inline" && n.node) {
// 核心修复点:
// 只有当该节点原本不是作为 inline 元素处理(flattenTextRuns 中 kind 为 inline),
// 或者我们明确知道这个节点在当前上下文必须是 Block 时,才拆分段落。
// 但如果它来自 consumeRuns,说明它在原结构里是段落的子节点。
// 所以,这里应该优先保持在 curParaContent 中。
// 只有当你的 Schema 绝对禁止该节点出现在段落内时,才开启 shouldTreatNodeAsBlock
// 为了修复图片消失问题,建议这里默认为 false,保持原结构。
const forceBreakBlock = false; // 关闭强制拆分
if (forceBreakBlock) {
flushParagraphIfAny();
resultNodes.push(n.node);
} else {
// 保持在段落内部
curParaContent.push(n.node);
}
} else {
curParaContent.push(n);
}
}
} else {
// 原模板不足 -> 退回纯文本(全部作为 paragraph 内容)
// 改成:尽量复用 templateRuns 中当前 run 的 marks,如果不可得则不带 marks
const currentRun =
runsRef.runs && runsRef.runs[runsRef.idx]
? runsRef.runs[runsRef.idx]
: null;
const fallbackMarks =
currentRun && currentRun.marks
? deepClone(currentRun.marks)
: undefined;
const textNode = { type: "text", text: txt };
if (fallbackMarks) textNode.marks = fallbackMarks;
curParaContent.push(textNode);
}
} else if (p.type === "delete" && (side === "left" || side === "both")) {
const consumed = consumeRunsForLength(runsRef, txt.length);
if (consumed.length) {
for (const n of consumed) {
if (n.type === "inline" && n.node) {
const node = deepClone(n.node);
node.marks = node.marks || [];
node.marks.push({ type: "diffRemoved" });
if (shouldTreatNodeAsBlock(node)) {
flushParagraphIfAny();
resultNodes.push(node);
} else {
curParaContent.push(node);
}
} else {
curParaContent.push({
...n,
marks: [...(n.marks || []), { type: "diffRemoved" }],
});
}
}
} else {
curParaContent.push({
type: "text",
text: txt,
marks: [{ type: "diffRemoved" }],
});
}
} else if (p.type === "insert" && (side === "right" || side === "both")) {
const consumed = consumeRunsForLength(runsRef, txt.length);
if (consumed.length) {
for (const n of consumed) {
if (n.type === "inline" && n.node) {
const node = deepClone(n.node);
node.marks = node.marks || [];
node.marks.push({ type: "diffAdded" });
if (shouldTreatNodeAsBlock(node)) {
flushParagraphIfAny();
resultNodes.push(node);
} else {
curParaContent.push(node);
}
} else {
curParaContent.push({
...n,
marks: [...(n.marks || []), { type: "diffAdded" }],
});
}
}
} else {
curParaContent.push({
type: "text",
text: txt,
marks: [{ type: "diffAdded" }],
});
}
}
}
// 刷新尾段
flushParagraphIfAny();
// 如果完全为空,放入一个空 paragraph(避免空 cell)
if (resultNodes.length === 0) {
resultNodes.push({
type: "paragraph",
...(paragraphTemplate?.attrs
? { attrs: deepClone(paragraphTemplate.attrs) }
: {}),
content: [{ type: "text", text: "\u200B" }],
});
}
return resultNodes; // 注意:返回数组,而不是单个 paragraph
}
// 判断一个 node 是否应该作为 block-level 单独节点插入(默认认为 image 属于 block)
function shouldTreatNodeAsBlock(node) {
if (!node || !node.type) return false;
// 根据你的 schema 做调整:
// 一般 image、video、media、iframe 被当作 block(不可放 paragraph.content)
const blockLike = new Set(["image", "video", "iframe", "media", "embed"]);
return blockLike.has(node.type);
}
// -------------------- 主流程(构造左右两个 diff 文档) --------------------
/**
* 整体 diff 操作入口:
* 输入:oldDoc, newDoc(TipTap JSON)
* 输出:{ leftDoc, rightDoc }
* 两边结构完全对齐,增加/删除/修改用 mark 标记
*/
export function diffDocsToSideBySideDocs(oldDoc, newDoc) {
const oldBlocks = splitParagraphs(oldDoc);
const newBlocks = splitParagraphs(newDoc);
const oldTexts = oldBlocks.map((b) => b.text);
const newTexts = newBlocks.map((b) => b.text);
const matched = lcsIndices(oldTexts, newTexts);
const leftContent = [];
const rightContent = [];
let oi = 0,
ni = 0,
mi = 0;
while (oi < oldBlocks.length || ni < newBlocks.length) {
const nextPair = matched[mi] || null;
const nextOi = nextPair ? nextPair[0] : Infinity;
const nextNi = nextPair ? nextPair[1] : Infinity;
// 列表对齐
if (
oi < oldBlocks.length &&
ni < newBlocks.length &&
oldBlocks[oi] &&
newBlocks[ni] &&
oldBlocks[oi].type === "list" &&
newBlocks[ni].type === "list"
) {
const [l, r] = diffListBlocks(oldBlocks[oi].node, newBlocks[ni].node);
leftContent.push(l);
rightContent.push(r);
if (oi == nextOi && ni == nextNi) {
mi++;
}
oi++;
ni++;
continue;
}
// 表格对齐
if (
oi < oldBlocks.length &&
ni < newBlocks.length &&
oldBlocks[oi] &&
newBlocks[ni] &&
oldBlocks[oi].type === "table" &&
newBlocks[ni].type === "table"
) {
const [l, r] = diffTableBlocks(oldBlocks[oi].node, newBlocks[ni].node);
leftContent.push(l);
rightContent.push(r);
if (oi == nextOi && ni == nextNi) {
mi++;
}
oi++;
ni++;
continue;
}
// 普通块对齐
if (oi === nextOi && ni === nextNi) {
leftContent.push(oldBlocks[oi].node);
rightContent.push(newBlocks[ni].node);
oi++;
ni++;
mi++;
} else {
// 左多右少 → 删除块
while (oi < Math.min(nextOi, oldBlocks.length)) {
const b = oldBlocks[oi];
if (b.type === "list")
leftContent.push(markWholeBlockAs(b.node, "deleted"));
else if (b.type === "table")
leftContent.push(markWholeBlockAs(b.node, "deleted"));
else {
leftContent.push(nodeToMarkedParagraph(b.node, "deleted"));
//rightContent.push({ type: "paragraph", content: [] }); //不插入空行
}
oi++;
}
// 右多左少 → 新增块
while (ni < Math.min(nextNi, newBlocks.length)) {
const b = newBlocks[ni];
if (b.type === "list")
rightContent.push(markWholeBlockAs(b.node, "added"));
else if (b.type === "table")
rightContent.push(markWholeBlockAs(b.node, "added"));
else {
rightContent.push(nodeToMarkedParagraph(b.node, "added"));
//leftContent.push({ type: "paragraph", content: [] }); //不插入空行
}
ni++;
}
}
if (!nextPair) break;
}
return {
leftDoc: { type: "doc", content: leftContent },
rightDoc: { type: "doc", content: rightContent },
};
}
/** 整块添加 mark */
function markWholeBlockAs(node, mode) {
const copy = deepClone(node);
applyMarkRecursively(copy, mode === "added" ? "diffAdded" : "diffRemoved");
return copy;
}
/**
* 将任意节点转成 "带 diff 标记的 paragraph" 输出
* 特殊处理:image 节点不能破坏结构 → 直接打 data-diff
*/
function nodeToMarkedParagraph(node, mode /* 'added'|'deleted' */) {
if (!node) return { type: "paragraph", content: [] };
// --- 1) 如果 node 本身就是 image 节点,直接返回 image node 并加 data-diff 属性 ---
if (node.type === "image") {
const attrs = Object.assign({}, node.attrs || {}, {
"data-diff": mode === "added" ? "diffAdded" : "diffRemoved",
});
return {
type: "image",
attrs,
marks: [{ type: mode === "added" ? "diffAdded" : "diffRemoved" }],
};
} // --- 2) 如果 node 是 paragraph 且其唯一子节点是 image(常见情况),把 image 提取出来并标记 ---
if (
node.type === "paragraph" &&
Array.isArray(node.content) &&
node.content.length === 1 &&
node.content[0].type === "image"
) {
const img = node.content[0];
const attrs = Object.assign({}, img.attrs || {}, {
"data-diff": mode === "added" ? "diffAdded" : "diffRemoved",
});
return {
type: "image",
attrs,
marks: [{ type: mode === "added" ? "diffAdded" : "diffRemoved" }],
};
}
// --- 3) 对于普通文本段落,按原先逻辑做字符 diff → paragraph(不 stringify) ---
if (
node.type === "paragraph" ||
node.type === "heading" ||
node.type === "blockquote"
) {
const childText = extractPlainText(node);
const pieces =
mode === "added"
? textDiffPieces("", childText)
: textDiffPieces(childText, "");
return paragraphWithPieces(pieces, node);
}
// --- 4) 其它非文本、非 image 的节点:尽量 preserve structure if possible ---
// 如果是某些内嵌媒体(video、iframe)且有 attrs.src,可以 try to return node with data-diff attr
if (
node.attrs &&
node.attrs.src &&
(node.type === "video" || node.type === "iframe" || node.type === "media")
) {
const attrs = Object.assign({}, node.attrs, {
"data-diff": mode === "added" ? "added" : "removed",
});
attrs.__diff = mode === "added" ? "diffAdded" : "diffRemoved";
return { type: node.type, attrs };
}
// 最后退回到把 node 序列化为一段文本(仅在无法安全恢复节点时才使用)
const txt = JSON.stringify(node);
return {
type: "paragraph",
content: [
{
type: "text",
text: txt,
marks: [{ type: mode === "added" ? "diffAdded" : "diffRemoved" }],
},
],
};
}
4,创建标记扩展和插件
(1)首先我们创建一个 diffAdded.js,其作用是提供“新增”高亮显示的视觉标记。所有 diff.js 标记为新增的内容都应用这个 Mark(不管是文字、图片、表格),然后控制这些内容应该如何显示(绿色 / 下划线 / 背景色等)。
import { Mark } from "@tiptap/core";
// 定义一个自定义标记(Mark)用于“新增差异高亮”
export default Mark.create({
// Mark 名称,可在 schema 和运行时代码中引用
name: "diffAdded",
// 可选项配置(这里未使用任何特殊参数)
addOptions() {
return { HTMLAttributes: {} };
},
// 解析 HTML 时,当遇到 <span data-diff="added">...</span> 时
// 识别为 diffAdded 这个 Mark
// 目的:使从 HTML → ProseMirror Node 时能恢复 diff 标记
parseHTML() {
return [{ tag: 'span[data-diff="added"]' }];
},
// 生成 HTML时,Tiptap 会把带有 diffAdded mark 的文本
// 渲染成 <span data-diff="added" ...>内容</span>
// style 用于设置高亮样式(绿色背景)
renderHTML({ HTMLAttributes }) {
return [
"span",
{
"data-diff": "added", // 自定义标记,用于 DOM 区分
style: "background-color:#e6ffea; padding:0 2px; border-radius:2px;",
// 上面的样式使新增内容显示为绿色背景高亮
},
0, // “0” 表示子节点位置(Tiptap 语法)
];
},
});
(2)接着我们创建一个 diffRemoved.js,其作用是提供“删除”高亮显示的视觉标记。所有被 diff.js 标记为删除的内容都应用这个 Mark(不管是文字、图片、表格),然后控制控制其渲染外观(红色、删除线、背景色等)
import { Mark } from "@tiptap/core";
// 定义一个自定义 Mark,用于“删除差异高亮”
export default Mark.create({
// Mark 名称,在 diff 分析阶段会用到
name: "diffRemoved",
// mark 配置项(这里未额外使用)
addOptions() {
return { HTMLAttributes: {} };
},
// HTML → ProseMirror 解析:
// 当 HTML 里出现 <span data-diff="removed">...</span>
// 会被识别为 diffRemoved 这个 Mark
parseHTML() {
return [{ tag: 'span[data-diff="removed"]' }];
},
// ProseMirror → HTML 渲染:
// 带 diffRemoved mark 的文本将被输出为
// <span data-diff="removed" style="...">文本</span>
// 样式用于显示红色背景 + 删除线
renderHTML({ HTMLAttributes }) {
return [
"span",
{
"data-diff": "removed", // 区分此标记的自定义属性
style: // 红色背景 + 删除线,用于表示“旧文本被删除”
"background-color:#ffecec; text-decoration:line-through; padding:0 2px; border-radius:2px;",
},
0, // Tiptap 语法:0 代表此标记包裹的内容
];
},
});
(3)最后为了让图片也能显示 diff 效果,创建一个 imageDiffPlugin.js。其作用是检查图片节点是否包含 diffAdded 或 diffRemoved 标记,然后将图片渲染出对应的高亮效果(加 outline、加背景色、加删除线框等)
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
// 定义一个用于“图片差异高亮”的 Tiptap 扩展插件
export const ImageDiffPlugin = Extension.create({
// 插件名称
name: 'imageDiffPlugin',
// 注册 ProseMirror 插件(Tiptap 底层基于 ProseMirror)
addProseMirrorPlugins() {
return [
new Plugin({
// 插件唯一 key
key: new PluginKey('imageDiffPlugin'),
props: {
// decorations 是 ProseMirror 提供的一个 hook
// 用于对文档节点添加“装饰层”(不会改变文档结构)
decorations(state) {
const decorations = []
// 遍历文档中的所有节点
// node 表示当前节点
// pos 表示当前节点在文档中的位置
state.doc.descendants((node, pos) => {
// 只处理 image 节点,并且必须携带 mark(才能知道是 diffAdded 或 diffRemoved)
if (node.type.name === 'image' && node.marks) {
// 找到图片节点上的 diff 标记(新增/删除)
const diffMark = node.marks.find(m => m.type.name === 'diffAdded'
|| m.type.name === 'diffRemoved')
// 如果没有 diff 标记,代表图片未变化
if (diffMark) {
// 判断新增还是删除
const isAdded = diffMark.type.name === 'diffAdded'
// 赋予对应的 class,用于调试或样式覆盖
const cls = isAdded ? 'diff-added' : 'diff-removed'
// 直接写内联 style,高亮图片边框
const style = isAdded
? 'outline: 2px solid green; border-radius: 4px;'
: 'outline: 2px solid red; border-radius: 4px;'
// 创建一个 Decoration
// Decoration.node(start, end, attrs)
// 用于给节点添加额外属性(class/style)
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: cls, style }))
}
}
})
// 将 decoration 列表应用于文档
// DecorationSet 是只读的,不影响文档内容,只是视觉效果
return DecorationSet.create(state.doc, decorations)
}
}
})
]
}
})
5,使用样例
(1)下面样例代码中我们页面长添加了两个 EditorContent(tiptap 内容编辑框),左侧代表旧版本文档,右侧代表新版本文档。
<template>
<div class="diff-viewer">
<div class="editors">
<div class="editor-box">
<div class="toolbar" role="toolbar" aria-label="Editor toolbar">
<button @click="generateDiff" v-if="!isCompare">开始对比</button>
<button @click="editContent" v-if="isCompare">返回编辑</button>
<div class="separator"></div>
<button @click="toggleOrderedList(oldLeftEditor)" v-if="!isCompare">
有序列表</button>
<div class="separator"></div>
<button @click="addRowAfter(oldLeftEditor)" v-if="!isCompare">添加加行</button>
<button @click="deleteRow(oldLeftEditor)" v-if="!isCompare">删除行</button>
<button @click="mergeCells(oldLeftEditor)" v-if="!isCompare">合并</button>
<button @click="splitCell(oldLeftEditor)" v-if="!isCompare">拆分</button>
</div>
<editor-content v-if="oldLeftEditor" :editor="oldLeftEditor"
ref="oldLeftEditorContent" />
</div>
<div class="editor-box">
<div class="toolbar" role="toolbar" aria-label="Editor toolbar">
<button @click="generateDiff" v-if="!isCompare">开始对比</button>
<button @click="editContent" v-if="isCompare">返回编辑</button>
<div class="separator"></div>
<button @click="toggleOrderedList(newRightEditor)" v-if="!isCompare">
有序列表</button>
<div class="separator"></div>
<button @click="addRowAfter(newRightEditor)" v-if="!isCompare">添加行</button>
<button @click="deleteRow(newRightEditor)" v-if="!isCompare">删除行</button>
<button @click="mergeCells(newRightEditor)" v-if="!isCompare">合并</button>
<button @click="splitCell(newRightEditor)" v-if="!isCompare">拆分</button>
</div>
<editor-content v-if="newRightEditor" :editor="newRightEditor"
ref="newRightEditorContent" />
</div>
</div>
</div>
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-2'
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 DiffAdded from './diffAdded'
import DiffRemoved from './diffRemoved'
import { ImageDiffPlugin } from './imageDiffPlugin'
import { diffDocsToSideBySideDocs } from './diff'
export default {
name: 'DiffViewer',
components: { EditorContent },
data() {
return {
isCompare: false, // 是否开始对比
oldLeftEditor: null, // 左侧编辑器
newRightEditor: null, // 右侧编辑器
syncing: false, // 是否正在同步滚动条
leftContent: `
<p>欢迎访问 <a href="https://www.hangge.com">hangge.com</a></p>
<p>网站最新文章:</p>
<ol>
<li>深入理解计算机系统(原书第2版)</li>
<li>图解HTTP</li>
<li>TCP/IP详解卷1:协议</li>
</ol>
<p><img src="http://localhost:8080/images/logo.png" alt="logo"></p>
<p></p>
<p>网站最近访问人员</p>
<table>
<thead>
<tr><th>用户类型</th><th>用户名称</th><th>用户年龄</th></tr>
</thead>
<tbody>
<tr><td rowspan="2">管理员</td><td>刘小龙</td><td>88</td></tr>
<tr><td>张大伟</td><td>66</td></tr>
<tr><td>访客</td><td>刘二姐</td><td>22</td></tr>
</tbody>
</table>
<p></p>
<p><strong>TCP/IP详解卷1:协议</strong></p>
<p><strong>作者</strong>:谢希仁</p>
<p><strong>出版社</strong>:机械工业出版社</p>
<p><strong>出版年</strong>:2010-1</p>
<p><strong>页数</strong>:296</p>
<p><strong>定价</strong>:38.00元</p>
<p><strong>ISBN</strong>:9787111228258</p>
<p><strong>内容简介</strong></p>
<p>《TCP/IP详解卷1:协议》是一本系统全面的、通俗易懂的计算机网络技术图解教程。
全书共分为10章,主要介绍了TCP/IP协议族的各个方面,包括网络层、传输层、
应用层,并配有大量的实例和练习,是一本必读的计算机网络技术图解教材。</p>
`,
rightContet: ''
}
},
mounted() {
this.rightContet = this.leftContent
this.oldLeftEditor = this.initEditor(this.leftContent)
this.newRightEditor = this.initEditor(this.rightContet)
},
methods: {
// 初始化编辑器
initEditor(content) {
return new Editor({
extensions: [StarterKit, Link, Image, TableKit, TextStyleKit,
Color.configure({
types: ['textStyle'],
}),
// multicolor: true 允许自定义颜色
Highlight.configure({
multicolor: true,
}),
DiffAdded, DiffRemoved, ImageDiffPlugin
],
content: content,
editable: !this.isCompare,
})
},
// 返回编辑状态
editContent() {
this.isCompare = false
// 先销毁旧的编辑器
if (this.oldLeftEditor) { this.oldLeftEditor.destroy(); this.oldLeftEditor = null }
if (this.newRightEditor) { this.newRightEditor.destroy(); this.newRightEditor = null }
this.oldLeftEditor = this.initEditor(this.leftContent)
this.newRightEditor = this.initEditor(this.rightContet)
},
// 生成对比结果
generateDiff() {
this.leftContent = this.oldLeftEditor.getHTML()
this.rightContet = this.newRightEditor.getHTML()
this.isCompare = true
let oldDoc = this.oldLeftEditor.getJSON()
let newDoc = this.newRightEditor.getJSON()
// 获取对比结果
let { leftDoc, rightDoc } = diffDocsToSideBySideDocs(oldDoc, newDoc)
// 清洗结果
leftDoc = this.sanitizeDoc(leftDoc)
rightDoc = this.sanitizeDoc(rightDoc)
// 先销毁旧的编辑器
if (this.oldLeftEditor) { this.oldLeftEditor.destroy(); this.oldLeftEditor = null }
if (this.newRightEditor) { this.newRightEditor.destroy(); this.newRightEditor = null }
// 重新初始化编辑器
this.oldLeftEditor = this.initEditor(leftDoc)
this.newRightEditor = this.initEditor(rightDoc)
// this.$nextTick(() => {
// this.setupScrollSync()
// })
setTimeout(() => {
this.setupScrollSync()
}, 300);
},
// 清洗函数,深拷贝(剥离 Vue Observer),删除空文本节点
sanitizeDoc(doc) {
if (!doc) return null
// 深拷贝避免 Vue2 响应式污染
const cloned = JSON.parse(JSON.stringify(doc))
const clean = node => {
if (!node) return null
// 删除空文本节点
if (node.type === 'text') {
if (!node.text || node.text.trim() === '') {
return null
}
return node
}
if (node.content) {
const filtered = node.content
.map(c => clean(c))
.filter(c => c)
return { ...node, content: filtered }
}
return node
}
return clean(cloned)
},
// 同步滚动条
setupScrollSync() {
const leftEl = this.$refs.oldLeftEditorContent?.$el.querySelector('.ProseMirror')
const rightEl = this.$refs.newRightEditorContent?.$el.querySelector('.ProseMirror')
if (!leftEl || !rightEl) return
const sync = (srcEl, dstEl) => {
if (this.syncing) return
this.syncing = true
const pct = srcEl.scrollTop / (srcEl.scrollHeight - srcEl.clientHeight || 1)
dstEl.scrollTop = pct * (dstEl.scrollHeight - dstEl.clientHeight)
setTimeout(() => { this.syncing = false }, 50)
}
// 先移除之前的监听器(避免重复绑定)
leftEl.onscroll = null
rightEl.onscroll = null
leftEl.addEventListener('scroll', () => sync(leftEl, rightEl))
rightEl.addEventListener('scroll', () => sync(rightEl, leftEl))
},
/* 表格相关操作*/
addRowAfter(target) { target.chain().focus().addRowAfter().run() },
deleteRow(target) { target.chain().focus().deleteRow().run() },
mergeCells(target) { target.chain().focus().mergeCells().run() },
splitCell(target) { target.chain().focus().splitCell().run() },
/* 列表相关操作 */
toggleBulletList(target) { target.chain().focus().toggleBulletList().run() },
toggleOrderedList(target) { target.chain().focus().toggleOrderedList().run() },
},
beforeDestroy() {
if (this.oldLeftEditor) this.oldLeftEditor.destroy()
if (this.newRightEditor) this.newRightEditor.destroy()
}
}
</script>
<style scoped>
.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;
}
/* ====== 关键:为编辑器内 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 th,
::v-deep .ProseMirror td {
border: 1px solid #d1d5db;
/* 灰色边框,可按需调整 #e5e7eb #cbd5e1 等 */
padding: 8px 10px;
vertical-align: middle;
}
/* 表头样式(可选) */
::v-deep .ProseMirror th {
background: #f9fafb;
font-weight: 600;
}
/* 表格在窄屏时不要换行太多(可选) */
::v-deep .ProseMirror td {
word-break: break-word;
}
/* 如果使用 Tailwind 的 prose 可能会有默认的 table 样式,下面加个更高权重以覆盖 */
::v-deep .ProseMirror table,
::v-deep .ProseMirror th,
::v-deep .ProseMirror td {
/* 提高优先级避免被 reset 覆盖 */
border-color: #d1d5db;
}
/* Tiptap 表格单元格被选中时的高亮效果(与官网一致) */
::v-deep .ProseMirror .selectedCell {
background-color: rgba(200, 200, 255, 0.4) !important;
/* 防止 reset 覆盖 */
}
/** 下面是解决列宽拖拽调整时鼠标样式问题的 CSS */
/* 提升优先级:靠右侧 5~8px 时鼠标应变成 col-resize */
::v-deep .ProseMirror table td,
::v-deep .ProseMirror table th {
position: relative;
}
::v-deep .ProseMirror table td::after,
::v-deep .ProseMirror table th::after {
content: "";
position: absolute;
right: -3px;
/* 命中区域 */
top: 0;
width: 6px;
height: 100%;
cursor: col-resize !important;
}
</style>
(2)默认左右两个内容都是一样的,我们对右边内容进行修改,比如增加一些内容,删除一些内容。然后点击“开始对比”按钮

(3)此时左右两侧编辑框就会变成只读状态,并且高亮标记处差异的部分(红色表示删除的、绿色表示新增的),可以看到表格内容也可以做到单元格级别的对比。同时拖动其中一个编辑框的滚动条时另一侧滚动条位置也会随之联动,方便对比查看。

全部评论(0)