From f8994cb6bdec48ad0cedd41bec742cc989353262 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 16 Dec 2025 15:57:30 -0300 Subject: [PATCH 01/53] feat: add function for mapping doc paragraphs by id --- .../src/extensions/diffing/computeDiff.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/super-editor/src/extensions/diffing/computeDiff.js diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js new file mode 100644 index 000000000..3cccc3af8 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -0,0 +1,18 @@ +import { Node } from 'prosemirror-model'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Collects paragraphs from a ProseMirror document and returns them by paragraph ID. + * @param {Node} pmDoc - ProseMirror document to scan. + * @returns {Map} Map keyed by paraId containing paragraph nodes and positions. + */ +export function extractParagraphs(pmDoc) { + const paragraphMap = new Map(); + pmDoc.descendants((node, pos) => { + if (node.type.name === 'paragraph') { + paragraphMap.set(node.attrs?.paraId ?? uuidv4(), { node, pos }); + return false; // Do not descend further + } + }); + return paragraphMap; +} From f605dc49deb9360e1c11b053a481d91003264aba Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 16 Dec 2025 15:58:04 -0300 Subject: [PATCH 02/53] feat: add function for flattening paragraph text but keep track of positions --- .../src/extensions/diffing/computeDiff.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js index 3cccc3af8..4394204d9 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -16,3 +16,56 @@ export function extractParagraphs(pmDoc) { }); return paragraphMap; } + +/** + * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. + * @param {Node} paragraph - Paragraph node to flatten. + * @param {number} [paragraphPos=0] - Position of the paragraph in the document. + * @returns {{text: string, resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. + */ +export function getTextContent(paragraph, paragraphPos = 0) { + let text = ''; + const segments = []; + + paragraph.nodesBetween( + 0, + paragraph.content.size, + (node, pos) => { + let nodeText = ''; + + if (node.isText) { + nodeText = node.text; + } else if (node.isLeaf && node.type.spec.leafText) { + nodeText = node.type.spec.leafText(node); + } + + if (!nodeText) { + return; + } + + const start = text.length; + const end = start + nodeText.length; + + segments.push({ start, end, pos }); + text += nodeText; + }, + 0, + ); + + const resolvePosition = (index) => { + if (index < 0 || index > text.length) { + return null; + } + + for (const segment of segments) { + if (index >= segment.start && index < segment.end) { + return paragraphPos + 1 + segment.pos + (index - segment.start); + } + } + + // If index points to the end of the string, return the paragraph end + return paragraphPos + 1 + paragraph.content.size; + }; + + return { text, resolvePosition }; +} From 6e1ddaf37b8c4268353db95c43e10258b2ab419c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 16 Dec 2025 16:00:49 -0300 Subject: [PATCH 03/53] feat: add function for calculating text diffs using LCS --- .../src/extensions/diffing/computeDiff.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js index 4394204d9..b9cc70d44 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -69,3 +69,85 @@ export function getTextContent(paragraph, paragraphPos = 0) { return { text, resolvePosition }; } + +/** + * Computes text-level additions and deletions between two strings using LCS, mapping back to document positions. + * @param {string} oldText - Source text. + * @param {string} newText - Target text. + * @param {(index: number) => number|null} positionResolver - Maps string indices to document positions. + * @returns {Array} List of addition/deletion ranges with document positions and text content. + */ +export function getLCSdiff(oldText, newText, positionResolver) { + const oldLen = oldText.length; + const newLen = newText.length; + + // Build LCS length table + const lcs = Array.from({ length: oldLen + 1 }, () => Array(newLen + 1).fill(0)); + for (let i = oldLen - 1; i >= 0; i -= 1) { + for (let j = newLen - 1; j >= 0; j -= 1) { + if (oldText[i] === newText[j]) { + lcs[i][j] = lcs[i + 1][j + 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + } + + // Reconstruct the LCS path to figure out unmatched segments + const matches = []; + for (let i = 0, j = 0; i < oldLen && j < newLen; ) { + if (oldText[i] === newText[j]) { + matches.push({ oldIdx: i, newIdx: j }); + i += 1; + j += 1; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + i += 1; + } else { + j += 1; + } + } + + const diffs = []; + let prevOld = 0; + let prevNew = 0; + + for (const { oldIdx, newIdx } of matches) { + if (oldIdx > prevOld) { + diffs.push({ + type: 'deletion', + startIdx: positionResolver(prevOld), + endIdx: positionResolver(oldIdx), + text: oldText.slice(prevOld, oldIdx), + }); + } + if (newIdx > prevNew) { + diffs.push({ + type: 'addition', + startIdx: positionResolver(prevOld), + endIdx: positionResolver(prevOld), + text: newText.slice(prevNew, newIdx), + }); + } + prevOld = oldIdx + 1; + prevNew = newIdx + 1; + } + + if (prevOld < oldLen) { + diffs.push({ + type: 'deletion', + startIdx: positionResolver(prevOld), + endIdx: positionResolver(oldLen - 1), + text: oldText.slice(prevOld), + }); + } + if (prevNew < newLen) { + diffs.push({ + type: 'addition', + startIdx: positionResolver(prevOld), + endIdx: positionResolver(prevOld), + text: newText.slice(prevNew), + }); + } + + return diffs; +} From 32d4be8cbd09c7a04e630031bc033488f6c1f877 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 16 Dec 2025 16:01:34 -0300 Subject: [PATCH 04/53] feat: add function for calculating paragraph-level diffing --- .../src/extensions/diffing/computeDiff.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js index b9cc70d44..9c8155944 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -1,6 +1,60 @@ import { Node } from 'prosemirror-model'; import { v4 as uuidv4 } from 'uuid'; +/** + * Computes paragraph-level diffs between two ProseMirror documents, returning inserts, deletes and text modifications. + * @param {Node} oldPmDoc - The previous ProseMirror document. + * @param {Node} newPmDoc - The updated ProseMirror document. + * @returns {Array} List of diff objects describing added, deleted or modified paragraphs. + */ +export function computeDiff(oldPmDoc, newPmDoc) { + const diffs = []; + + // 1. Extract all paragraphs from old document and create a map using their IDs + const oldParagraphsMap = extractParagraphs(oldPmDoc); + + // 2. Extract all paragraphs from new document and create a map using their IDs + const newParagraphsMap = extractParagraphs(newPmDoc); + + // 3. Compare paragraphs in old and new documents + let insertPos = 0; + newParagraphsMap.forEach((newPara, paraId) => { + const oldPara = oldParagraphsMap.get(paraId); + if (!oldPara) { + diffs.push({ + type: 'added', + paraId, + node: newPara.node, + text: newPara.node.textContent, + pos: insertPos, + }); + return; + } else if (oldPara.node.textContent !== newPara.node.textContent) { + const oldTextContent = getTextContent(oldPara.node, oldPara.pos); + const newTextContent = getTextContent(newPara.node, newPara.pos); + const textDiffs = getLCSdiff(oldPara.node.textContent, newPara.node.textContent, oldTextContent.resolvePosition); + diffs.push({ + type: 'modified', + paraId, + oldText: oldTextContent.text, + newText: newTextContent.text, + pos: oldPara.pos, + textDiffs, + }); + } + insertPos = oldPara.pos + oldPara.node.nodeSize; + }); + + // 4. Identify deleted paragraphs + oldParagraphsMap.forEach((oldPara, paraId) => { + if (!newParagraphsMap.has(paraId)) { + diffs.push({ type: 'deleted', paraId, node: oldPara.node, pos: oldPara.pos }); + } + }); + + return diffs; +} + /** * Collects paragraphs from a ProseMirror document and returns them by paragraph ID. * @param {Node} pmDoc - ProseMirror document to scan. From 0cba4d351172a40525a9b64cd5bfea5d4339a17e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 17 Dec 2025 17:34:24 -0300 Subject: [PATCH 05/53] feat: add diffing extension --- .../src/extensions/diffing/diffing.js | 18 ++++++++++++++++++ .../src/extensions/diffing/index.js | 1 + packages/super-editor/src/extensions/index.js | 2 ++ 3 files changed, 21 insertions(+) create mode 100644 packages/super-editor/src/extensions/diffing/diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/index.js diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js new file mode 100644 index 000000000..1ebfdc3b0 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -0,0 +1,18 @@ +// @ts-nocheck +import { Extension } from '@core/Extension.js'; +import { computeDiff } from './computeDiff.js'; + +export const Diffing = Extension.create({ + name: 'documentDiffing', + + addCommands() { + return { + compareDocuments: + (updatedDocument) => + ({ state }) => { + const diffs = computeDiff(state.doc, updatedDocument); + return diffs; + }, + }; + }, +}); diff --git a/packages/super-editor/src/extensions/diffing/index.js b/packages/super-editor/src/extensions/diffing/index.js new file mode 100644 index 000000000..0a3aee23b --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/index.js @@ -0,0 +1 @@ +export { Diffing } from './diffing.js'; diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 0343bedf4..e5816d9df 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -69,6 +69,7 @@ import { CustomSelection } from './custom-selection/index.js'; // Helpers import { trackChangesHelpers } from './track-changes/index.js'; +import { Diffing } from './diffing/index.js'; const getRichTextExtensions = () => { return [ @@ -235,6 +236,7 @@ export { trackChangesHelpers, getStarterExtensions, getRichTextExtensions, + Diffing, AiMark, AiAnimationMark, AiLoaderNode, From 60e68fb95bfb49b1fce170f74d8e91a5fbc6f971 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 17 Dec 2025 17:34:41 -0300 Subject: [PATCH 06/53] test: add diffing tests --- .../extensions/diffing/computeDiff.test.js | 225 ++++++++++++++++++ .../src/tests/data/diff_after.docx | Bin 0 -> 16585 bytes .../src/tests/data/diff_before.docx | Bin 0 -> 14583 bytes .../export/export-helpers/export-helpers.js | 2 +- 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/extensions/diffing/computeDiff.test.js create mode 100644 packages/super-editor/src/tests/data/diff_after.docx create mode 100644 packages/super-editor/src/tests/data/diff_before.docx diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js new file mode 100644 index 000000000..6bdf169fa --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -0,0 +1,225 @@ +import { describe, it, expect } from 'vitest'; +import { getTextContent, computeDiff, extractParagraphs, getLCSdiff } from './computeDiff'; + +import { Editor } from '@core/Editor.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; + +export const getDocument = async (name) => { + const buffer = await getTestDataAsBuffer(name); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + const editor = new Editor({ + isHeadless: true, + extensions: getStarterExtensions(), + documentId: 'test-doc', + content: docx, + mode: 'docx', + media, + mediaFiles, + fonts, + annotations: true, + }); + + return editor.state.doc; +}; + +describe('Diff', () => { + it('Compares two documents and identifies added, deleted, and modified paragraphs', async () => { + const docBefore = await getDocument('diff_before.docx'); + const docAfter = await getDocument('diff_after.docx'); + + const diffs = computeDiff(docBefore, docAfter); + console.log(JSON.stringify(diffs, null, 2)); + }); +}); + +describe('extractParagraphs', () => { + it('collects all paragraph nodes keyed by their paraId', () => { + const firstParagraph = { + type: { name: 'paragraph' }, + attrs: { paraId: 'para-1' }, + textContent: 'First paragraph', + }; + const nonParagraph = { + type: { name: 'heading' }, + attrs: { paraId: 'heading-1' }, + }; + const secondParagraph = { + type: { name: 'paragraph' }, + attrs: { paraId: 'para-2' }, + textContent: 'Second paragraph', + }; + const pmDoc = { + descendants: (callback) => { + callback(firstParagraph, 0); + callback(nonParagraph, 5); + callback(secondParagraph, 10); + }, + }; + + const paragraphs = extractParagraphs(pmDoc); + + expect(paragraphs.size).toBe(2); + expect(paragraphs.get('para-1')).toEqual({ node: firstParagraph, pos: 0 }); + expect(paragraphs.get('para-2')).toEqual({ node: secondParagraph, pos: 10 }); + }); + + it('generates unique IDs when paragraph nodes are missing paraId', () => { + const firstParagraph = { + type: { name: 'paragraph' }, + attrs: {}, + textContent: 'Anonymous first', + }; + const secondParagraph = { + type: { name: 'paragraph' }, + attrs: undefined, + textContent: 'Anonymous second', + }; + const pmDoc = { + descendants: (callback) => { + callback(firstParagraph, 2); + callback(secondParagraph, 8); + }, + }; + + const paragraphs = extractParagraphs(pmDoc); + const entries = [...paragraphs.entries()]; + const firstEntry = entries.find(([, value]) => value.node === firstParagraph); + const secondEntry = entries.find(([, value]) => value.node === secondParagraph); + + expect(paragraphs.size).toBe(2); + expect(firstEntry?.[0]).toBeTruthy(); + expect(secondEntry?.[0]).toBeTruthy(); + expect(firstEntry?.[0]).not.toBe(secondEntry?.[0]); + expect(firstEntry?.[1].pos).toBe(2); + expect(secondEntry?.[1].pos).toBe(8); + }); +}); + +describe('getTextContent', () => { + it('Handles basic text nodes', () => { + const mockParagraph = { + content: { + size: 5, + }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text: 'Hello' }, 0); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('Hello'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(4)).toBe(5); + }); + + it('Handles leaf nodes with leafText', () => { + const mockParagraph = { + content: { + size: 4, + }, + nodesBetween: (from, to, callback) => { + callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 0); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('Leaf'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(3)).toBe(4); + }); + + it('Handles mixed content', () => { + const mockParagraph = { + content: { + size: 9, + }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text: 'Hello' }, 0); + callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 5); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('HelloLeaf'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(5)).toBe(6); + expect(result.resolvePosition(9)).toBe(10); + }); + + it('Handles empty content', () => { + const mockParagraph = { + content: { + size: 0, + }, + nodesBetween: () => {}, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe(''); + expect(result.resolvePosition(0)).toBe(1); + }); + + it('Handles nested nodes', () => { + const mockParagraph = { + content: { + size: 6, + }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text: 'Nested' }, 0); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('Nested'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(6)).toBe(7); + }); +}); + +describe('getLCSdiff', () => { + it('returns an empty diff list when both strings are identical', () => { + const resolver = () => 0; + + const diffs = getLCSdiff('unchanged', 'unchanged', resolver); + + expect(diffs).toEqual([]); + }); + + it('detects text insertions and maps them to resolver positions', () => { + const resolver = (index) => index + 10; + + const diffs = getLCSdiff('abc', 'abXc', resolver); + + expect(diffs).toEqual([ + { + type: 'addition', + startIdx: 12, + endIdx: 12, + text: 'X', + }, + ]); + }); + + it('detects deletions and additions in the same diff sequence', () => { + const resolver = (index) => index + 5; + + const diffs = getLCSdiff('abcd', 'abXYd', resolver); + + expect(diffs).toEqual([ + { + type: 'deletion', + startIdx: 7, + endIdx: 8, + text: 'c', + }, + { + type: 'addition', + startIdx: 7, + endIdx: 7, + text: 'XY', + }, + ]); + }); +}); diff --git a/packages/super-editor/src/tests/data/diff_after.docx b/packages/super-editor/src/tests/data/diff_after.docx new file mode 100644 index 0000000000000000000000000000000000000000..b04b965837ddff5c026353bd4fb9c1f11706156b GIT binary patch literal 16585 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 EFHo!ljQ{`u literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before.docx b/packages/super-editor/src/tests/data/diff_before.docx new file mode 100644 index 0000000000000000000000000000000000000000..8cae247f962363005ecacefbc8b179626fb58ec9 GIT binary patch literal 14583 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/export/export-helpers/export-helpers.js b/packages/super-editor/src/tests/export/export-helpers/export-helpers.js index 9ac0aeeb3..82cd13905 100644 --- a/packages/super-editor/src/tests/export/export-helpers/export-helpers.js +++ b/packages/super-editor/src/tests/export/export-helpers/export-helpers.js @@ -37,7 +37,7 @@ export const getTextFromNode = (node) => { * @param {string} name The name of the file in the test data folder * @returns {Promise} The test data as abuffer */ -const getTestDataAsBuffer = async (name) => { +export const getTestDataAsBuffer = async (name) => { try { const basePath = join(__dirname, '../../data', name); return await readFile(basePath); From 58de4945c5db9f94d40bc98bbdf988f098a48618 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 18 Dec 2025 16:28:47 -0300 Subject: [PATCH 07/53] refactor: switch LCS algorith to Myers for performance --- .../src/extensions/diffing/computeDiff.js | 216 ++++++++++++++---- .../extensions/diffing/computeDiff.test.js | 10 +- 2 files changed, 171 insertions(+), 55 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js index 9c8155944..606010d29 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -32,7 +32,7 @@ export function computeDiff(oldPmDoc, newPmDoc) { } else if (oldPara.node.textContent !== newPara.node.textContent) { const oldTextContent = getTextContent(oldPara.node, oldPara.pos); const newTextContent = getTextContent(newPara.node, newPara.pos); - const textDiffs = getLCSdiff(oldPara.node.textContent, newPara.node.textContent, oldTextContent.resolvePosition); + const textDiffs = getTextDiff(oldPara.node.textContent, newPara.node.textContent, oldTextContent.resolvePosition); diffs.push({ type: 'modified', paraId, @@ -125,83 +125,199 @@ export function getTextContent(paragraph, paragraphPos = 0) { } /** - * Computes text-level additions and deletions between two strings using LCS, mapping back to document positions. + * Computes text-level additions and deletions between two strings using Myers diff algorithm, mapping back to document positions. * @param {string} oldText - Source text. * @param {string} newText - Target text. * @param {(index: number) => number|null} positionResolver - Maps string indices to document positions. * @returns {Array} List of addition/deletion ranges with document positions and text content. */ -export function getLCSdiff(oldText, newText, positionResolver) { +export function getTextDiff(oldText, newText, positionResolver) { const oldLen = oldText.length; const newLen = newText.length; - // Build LCS length table - const lcs = Array.from({ length: oldLen + 1 }, () => Array(newLen + 1).fill(0)); - for (let i = oldLen - 1; i >= 0; i -= 1) { - for (let j = newLen - 1; j >= 0; j -= 1) { - if (oldText[i] === newText[j]) { - lcs[i][j] = lcs[i + 1][j + 1] + 1; + if (oldLen === 0 && newLen === 0) { + return []; + } + + // Myers diff bookkeeping: +2 padding keeps diagonal lookups in bounds. + const max = oldLen + newLen; + const size = 2 * max + 3; + const offset = max + 1; + const v = new Array(size).fill(-1); + v[offset + 1] = 0; + + const trace = []; + let foundPath = false; + + for (let d = 0; d <= max && !foundPath; d += 1) { + for (let k = -d; k <= d; k += 2) { + const index = offset + k; + let x; + + if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { + x = v[index + 1]; } else { - lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]); + x = v[index - 1] + 1; + } + + let y = x - k; + while (x < oldLen && y < newLen && oldText[x] === newText[y]) { + x += 1; + y += 1; + } + + v[index] = x; + + if (x >= oldLen && y >= newLen) { + foundPath = true; + break; } } + trace.push(v.slice()); } - // Reconstruct the LCS path to figure out unmatched segments - const matches = []; - for (let i = 0, j = 0; i < oldLen && j < newLen; ) { - if (oldText[i] === newText[j]) { - matches.push({ oldIdx: i, newIdx: j }); - i += 1; - j += 1; - } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { - i += 1; + const operations = backtrackMyers(trace, oldLen, newLen, offset); + return buildDiffFromOperations(operations, oldText, newText, positionResolver); +} + +/** + * Reconstructs the shortest edit script by walking the previously recorded V vectors. + * + * @param {Array} trace - Snapshot of diagonal furthest-reaching points per edit distance. + * @param {number} oldLen - Length of the original string. + * @param {number} newLen - Length of the target string. + * @param {number} offset - Offset applied to diagonal indexes to keep array lookups positive. + * @returns {Array<'equal'|'delete'|'insert'>} Concrete step-by-step operations. + */ +function backtrackMyers(trace, oldLen, newLen, offset) { + const operations = []; + let x = oldLen; + let y = newLen; + + for (let d = trace.length - 1; d > 0; d -= 1) { + const v = trace[d - 1]; + const k = x - y; + const index = offset + k; + + let prevK; + if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { + prevK = k + 1; + } else { + prevK = k - 1; + } + + const prevIndex = offset + prevK; + const prevX = v[prevIndex]; + const prevY = prevX - prevK; + + while (x > prevX && y > prevY) { + x -= 1; + y -= 1; + operations.push('equal'); + } + + if (x === prevX) { + y -= 1; + operations.push('insert'); } else { - j += 1; + x -= 1; + operations.push('delete'); } } + while (x > 0 && y > 0) { + x -= 1; + y -= 1; + operations.push('equal'); + } + + while (x > 0) { + x -= 1; + operations.push('delete'); + } + + while (y > 0) { + y -= 1; + operations.push('insert'); + } + + return operations.reverse(); +} + +/** + * Groups edit operations into contiguous additions/deletions and maps them to document positions. + * + * @param {Array<'equal'|'delete'|'insert'>} operations - Raw operation list produced by the backtracked Myers path. + * @param {string} oldText - Source text. + * @param {string} newText - Target text. + * @param {(index: number) => number|null} positionResolver - Maps string indexes to ProseMirror positions. + * @returns {Array} Final diff payload matching the existing API surface. + */ +function buildDiffFromOperations(operations, oldText, newText, positionResolver) { const diffs = []; - let prevOld = 0; - let prevNew = 0; + let run = null; + let oldIdx = 0; + let newIdx = 0; + let insertionAnchor = 0; + + const flushRun = () => { + if (!run || run.text.length === 0) { + run = null; + return; + } - for (const { oldIdx, newIdx } of matches) { - if (oldIdx > prevOld) { + if (run.type === 'delete') { + const startIdx = positionResolver(run.startOldIdx); + const endIdx = positionResolver(run.endOldIdx); diffs.push({ type: 'deletion', - startIdx: positionResolver(prevOld), - endIdx: positionResolver(oldIdx), - text: oldText.slice(prevOld, oldIdx), + startIdx, + endIdx, + text: run.text, }); - } - if (newIdx > prevNew) { + } else if (run.type === 'insert') { + const startIdx = positionResolver(run.referenceOldIdx); + const endIdx = positionResolver(run.referenceOldIdx); diffs.push({ type: 'addition', - startIdx: positionResolver(prevOld), - endIdx: positionResolver(prevOld), - text: newText.slice(prevNew, newIdx), + startIdx, + endIdx, + text: run.text, }); } - prevOld = oldIdx + 1; - prevNew = newIdx + 1; - } - if (prevOld < oldLen) { - diffs.push({ - type: 'deletion', - startIdx: positionResolver(prevOld), - endIdx: positionResolver(oldLen - 1), - text: oldText.slice(prevOld), - }); - } - if (prevNew < newLen) { - diffs.push({ - type: 'addition', - startIdx: positionResolver(prevOld), - endIdx: positionResolver(prevOld), - text: newText.slice(prevNew), - }); + run = null; + }; + + for (const op of operations) { + if (op === 'equal') { + flushRun(); + oldIdx += 1; + newIdx += 1; + insertionAnchor = oldIdx; + continue; + } + + if (!run || run.type !== op) { + flushRun(); + if (op === 'delete') { + run = { type: 'delete', startOldIdx: oldIdx, endOldIdx: oldIdx, text: '' }; + } else if (op === 'insert') { + run = { type: 'insert', referenceOldIdx: insertionAnchor, text: '' }; + } + } + + if (op === 'delete') { + run.text += oldText[oldIdx]; + oldIdx += 1; + run.endOldIdx = oldIdx; + } else if (op === 'insert') { + run.text += newText[newIdx]; + newIdx += 1; + } } + flushRun(); + return diffs; } diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index 6bdf169fa..a6da92a0b 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getTextContent, computeDiff, extractParagraphs, getLCSdiff } from './computeDiff'; +import { getTextContent, computeDiff, extractParagraphs, getTextDiff } from './computeDiff'; import { Editor } from '@core/Editor.js'; import { getStarterExtensions } from '@extensions/index.js'; @@ -178,11 +178,11 @@ describe('getTextContent', () => { }); }); -describe('getLCSdiff', () => { +describe('getTextDiff', () => { it('returns an empty diff list when both strings are identical', () => { const resolver = () => 0; - const diffs = getLCSdiff('unchanged', 'unchanged', resolver); + const diffs = getTextDiff('unchanged', 'unchanged', resolver); expect(diffs).toEqual([]); }); @@ -190,7 +190,7 @@ describe('getLCSdiff', () => { it('detects text insertions and maps them to resolver positions', () => { const resolver = (index) => index + 10; - const diffs = getLCSdiff('abc', 'abXc', resolver); + const diffs = getTextDiff('abc', 'abXc', resolver); expect(diffs).toEqual([ { @@ -205,7 +205,7 @@ describe('getLCSdiff', () => { it('detects deletions and additions in the same diff sequence', () => { const resolver = (index) => index + 5; - const diffs = getLCSdiff('abcd', 'abXYd', resolver); + const diffs = getTextDiff('abcd', 'abXYd', resolver); expect(diffs).toEqual([ { From 67016b6891a8ef015dfd6096761d41f6d587d30f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 18 Dec 2025 18:13:00 -0300 Subject: [PATCH 08/53] refactor: code structure --- .../diffing/algorithm/myers-diff.js | 118 +++++++ .../diffing/algorithm/paragraph-diffing.js | 277 +++++++++++++++ .../algorithm/paragraph-diffing.test.js | 52 +++ .../diffing/algorithm/text-diffing.js | 102 ++++++ .../diffing/algorithm/text-diffing.test.js | 50 +++ .../src/extensions/diffing/computeDiff.js | 318 +----------------- .../extensions/diffing/computeDiff.test.js | 282 ++++++---------- .../src/extensions/diffing/utils.js | 69 ++++ .../src/extensions/diffing/utils.test.js | 141 ++++++++ .../src/tests/data/diff_after.docx | Bin 16585 -> 14739 bytes .../src/tests/data/diff_after2.docx | Bin 0 -> 13481 bytes .../src/tests/data/diff_before.docx | Bin 14583 -> 14281 bytes .../src/tests/data/diff_before2.docx | Bin 0 -> 13404 bytes 13 files changed, 911 insertions(+), 498 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js create mode 100644 packages/super-editor/src/extensions/diffing/utils.js create mode 100644 packages/super-editor/src/extensions/diffing/utils.test.js create mode 100644 packages/super-editor/src/tests/data/diff_after2.docx create mode 100644 packages/super-editor/src/tests/data/diff_before2.docx diff --git a/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js b/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js new file mode 100644 index 000000000..6704d93a5 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js @@ -0,0 +1,118 @@ +/** + * Computes a Myers diff operation list for arbitrary sequences. + * @param {Array|String} oldSeq + * @param {Array|String} newSeq + * @param {(a: any, b: any) => boolean} isEqual + * @returns {Array<'equal'|'insert'|'delete'>} + */ +export function myersDiff(oldSeq, newSeq, isEqual) { + const oldLen = oldSeq.length; + const newLen = newSeq.length; + + if (oldLen === 0 && newLen === 0) { + return []; + } + + // Myers diff bookkeeping: +2 padding keeps diagonal lookups in bounds. + const max = oldLen + newLen; + const size = 2 * max + 3; + const offset = max + 1; + const v = new Array(size).fill(-1); + v[offset + 1] = 0; + + const trace = []; + let foundPath = false; + + for (let d = 0; d <= max && !foundPath; d += 1) { + for (let k = -d; k <= d; k += 2) { + const index = offset + k; + let x; + + if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { + x = v[index + 1]; + } else { + x = v[index - 1] + 1; + } + + let y = x - k; + while (x < oldLen && y < newLen && isEqual(oldSeq[x], newSeq[y])) { + x += 1; + y += 1; + } + + v[index] = x; + + if (x >= oldLen && y >= newLen) { + foundPath = true; + break; + } + } + trace.push(v.slice()); + } + + return backtrackMyers(trace, oldLen, newLen, offset); +} + +/** + * Reconstructs the shortest edit script by walking the previously recorded V vectors. + * + * @param {Array} trace - Snapshot of diagonal furthest-reaching points per edit distance. + * @param {number} oldLen - Length of the original string. + * @param {number} newLen - Length of the target string. + * @param {number} offset - Offset applied to diagonal indexes to keep array lookups positive. + * @returns {Array<'equal'|'delete'|'insert'>} Concrete step-by-step operations. + */ +function backtrackMyers(trace, oldLen, newLen, offset) { + const operations = []; + let x = oldLen; + let y = newLen; + + for (let d = trace.length - 1; d > 0; d -= 1) { + const v = trace[d - 1]; + const k = x - y; + const index = offset + k; + + let prevK; + if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { + prevK = k + 1; + } else { + prevK = k - 1; + } + + const prevIndex = offset + prevK; + const prevX = v[prevIndex]; + const prevY = prevX - prevK; + + while (x > prevX && y > prevY) { + x -= 1; + y -= 1; + operations.push('equal'); + } + + if (x === prevX) { + y -= 1; + operations.push('insert'); + } else { + x -= 1; + operations.push('delete'); + } + } + + while (x > 0 && y > 0) { + x -= 1; + y -= 1; + operations.push('equal'); + } + + while (x > 0) { + x -= 1; + operations.push('delete'); + } + + while (y > 0) { + y -= 1; + operations.push('insert'); + } + + return operations.reverse(); +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js new file mode 100644 index 000000000..7b3fb6011 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -0,0 +1,277 @@ +import { myersDiff } from './myers-diff.js'; +import { getTextDiff } from './text-diffing.js'; + +// Heuristics that prevent unrelated paragraphs from being paired as modifications. +const SIMILARITY_THRESHOLD = 0.65; +const MIN_LENGTH_FOR_SIMILARITY = 4; + +/** + * A paragraph addition diff emitted when new content is inserted. + * @typedef {Object} AddedParagraphDiff + * @property {'added'} type + * @property {Node} node reference to the ProseMirror node for consumers needing schema details + * @property {string} text textual contents of the inserted paragraph + * @property {number} pos document position where the paragraph was inserted + */ + +/** + * A paragraph deletion diff emitted when content is removed. + * @typedef {Object} DeletedParagraphDiff + * @property {'deleted'} type + * @property {Node} node reference to the original ProseMirror node + * @property {string} oldText text that was removed + * @property {number} pos starting document position of the original paragraph + */ + +/** + * A paragraph modification diff that carries inline text-level changes. + * @typedef {Object} ModifiedParagraphDiff + * @property {'modified'} type + * @property {string} oldText text before the edit + * @property {string} newText text after the edit + * @property {number} pos original document position for anchoring UI + * @property {Array} textDiffs granular inline diff data returned by `getTextDiff` + */ + +/** + * Combined type representing every diff payload produced by `diffParagraphs`. + * @typedef {AddedParagraphDiff|DeletedParagraphDiff|ModifiedParagraphDiff} ParagraphDiff + */ + +/** + * Runs a paragraph-level diff using Myers algorithm to align paragraphs that move, get edited, or are added/removed. + * The extra bookkeeping around the raw diff ensures that downstream consumers can map operations back to paragraph + * positions. + * @param {Array} oldParagraphs + * @param {Array} newParagraphs + * @returns {Array} + */ +export function diffParagraphs(oldParagraphs, newParagraphs) { + // Run Myers diff on the paragraph level to get a base set of operations. + const rawOperations = myersDiff(oldParagraphs, newParagraphs, paragraphComparator); + const operations = reorderParagraphOperations(rawOperations); + + // Build a step-by-step operation list with paragraph indices for easier processing. + let oldIdx = 0; + let newIdx = 0; + const steps = []; + for (const op of operations) { + if (op === 'equal') { + steps.push({ type: 'equal', oldIdx, newIdx }); + oldIdx += 1; + newIdx += 1; + } else if (op === 'delete') { + steps.push({ type: 'delete', oldIdx }); + oldIdx += 1; + } else if (op === 'insert') { + steps.push({ type: 'insert', newIdx }); + newIdx += 1; + } + } + + // Process the operation steps into a normalized diff output. + const diffs = []; + for (let i = 0; i < steps.length; i += 1) { + const step = steps[i]; + + switch (step.type) { + case 'equal': + const oldPara = oldParagraphs[step.oldIdx]; + const newPara = newParagraphs[step.newIdx]; + if (oldPara.text !== newPara.text) { + // Text changed within the same paragraph + const diff = buildModifiedParagraphDiff(oldPara, newPara); + if (diff.textDiffs.length > 0) { + diffs.push(diff); + } + } + break; + + case 'delete': + const nextStep = steps[i + 1]; + + // Check if the next step is an insertion that can be paired as a modification. + if (nextStep?.type === 'insert') { + const oldPara = oldParagraphs[step.oldIdx]; + const newPara = newParagraphs[nextStep.newIdx]; + if (canTreatAsModification(oldPara, newPara)) { + const diff = buildModifiedParagraphDiff(oldPara, newPara); + if (diff.textDiffs.length > 0) { + diffs.push(diff); + } + i += 1; // Skip the next insert step as it's paired + } else { + // The paragraph that was deleted is significantly different from any nearby insertions; treat as a deletion. + diffs.push(buildDeletedParagraphDiff(oldParagraphs[step.oldIdx])); + } + } else { + // No matching insertion; treat as a deletion. + diffs.push(buildDeletedParagraphDiff(oldParagraphs[step.oldIdx])); + } + break; + + case 'insert': + diffs.push(buildAddedParagraphDiff(newParagraphs[step.newIdx])); + break; + } + } + + return diffs; +} + +/** + * Compares two paragraphs for identity based on paraId or text content so the diff can prioritize logical matches. + * This prevents the algorithm from treating the same paragraph as a deletion+insertion when the paraId or text proves + * they refer to the same logical node, which in turn keeps visual diffs stable. + * @param {{node: Node, text: string}} oldParagraph + * @param {{node: Node, text: string}} newParagraph + * @returns {boolean} + */ +function paragraphComparator(oldParagraph, newParagraph) { + const oldId = oldParagraph?.node?.attrs?.paraId; + const newId = newParagraph?.node?.attrs?.paraId; + if (oldId && newId && oldId === newId) { + return true; + } + return oldParagraph?.text === newParagraph?.text; +} + +/** + * Builds a normalized payload describing a paragraph addition, ensuring all consumers receive the same metadata shape. + * @param {{node: Node, pos: number, text: string}} paragraph + * @returns {AddedParagraphDiff} + */ +function buildAddedParagraphDiff(paragraph) { + return { + type: 'added', + node: paragraph.node, + text: paragraph.text, + pos: paragraph.pos, + }; +} + +/** + * Builds a normalized payload describing a paragraph deletion so diff consumers can show removals with all context. + * @param {{node: Node, pos: number}} paragraph + * @returns {DeletedParagraphDiff} + */ +function buildDeletedParagraphDiff(paragraph) { + return { + type: 'deleted', + node: paragraph.node, + oldText: paragraph.text, + pos: paragraph.pos, + }; +} + +/** + * Builds the payload for a paragraph modification, including text-level diffs, so renderers can highlight edits inline. + * @param {{node: Node, pos: number, text: string, resolvePosition: Function}} oldParagraph + * @param {{node: Node, pos: number, text: string, resolvePosition: Function}} newParagraph + * @returns {ModifiedParagraphDiff} + */ +function buildModifiedParagraphDiff(oldParagraph, newParagraph) { + const textDiffs = getTextDiff( + oldParagraph.text, + newParagraph.text, + oldParagraph.resolvePosition, + newParagraph.resolvePosition, + ); + + return { + type: 'modified', + oldText: oldParagraph.text, + newText: newParagraph.text, + pos: oldParagraph.pos, + textDiffs, + }; +} + +/** + * Decides whether a delete/insert pair should be reinterpreted as a modification to minimize noisy diff output. + * This heuristic limits the number of false-positive additions/deletions, which keeps reviewers focused on real edits. + * @param {{node: Node, text: string}} oldParagraph + * @param {{node: Node, text: string}} newParagraph + * @returns {boolean} + */ +function canTreatAsModification(oldParagraph, newParagraph) { + if (paragraphComparator(oldParagraph, newParagraph)) { + return true; + } + + const oldText = oldParagraph?.text ?? ''; + const newText = newParagraph?.text ?? ''; + const maxLength = Math.max(oldText.length, newText.length); + if (maxLength < MIN_LENGTH_FOR_SIMILARITY) { + return false; + } + + const similarity = getTextSimilarityScore(oldText, newText); + + return similarity >= SIMILARITY_THRESHOLD; +} + +/** + * Scores the similarity between two text strings so the diff can decide if they represent the same conceptual paragraph. + * @param {string} oldText + * @param {string} newText + * @returns {number} + */ +function getTextSimilarityScore(oldText, newText) { + if (!oldText && !newText) { + return 1; + } + + const operations = myersDiff(oldText, newText, (a, b) => a === b); + const edits = operations.filter((op) => op !== 'equal').length; + const maxLength = Math.max(oldText.length, newText.length) || 1; + return 1 - edits / maxLength; // Proportion of unchanged characters +} + +/** + * Normalizes Myers diff operations for paragraph comparisons so consecutive replacements are easier to classify. + * Myers tends to emit all deletes before inserts when a paragraph is replaced, even if it's a one-for-one swap, and + * that pattern would otherwise hide opportunities to treat those operations as modifications. Reordering the list here + * ensures higher-level diff logic stays simple while avoiding side effects for other consumers of the same operations. + * @param {Array<'equal'|'delete'|'insert'>} operations + * @returns {Array<'equal'|'delete'|'insert'>} + */ +function reorderParagraphOperations(operations) { + const normalized = []; + + for (let i = 0; i < operations.length; i += 1) { + const op = operations[i]; + if (op !== 'delete') { + normalized.push(op); + continue; + } + + let deleteCount = 0; + while (i < operations.length && operations[i] === 'delete') { + deleteCount += 1; + i += 1; + } + + let insertCount = 0; + let insertCursor = i; + while (insertCursor < operations.length && operations[insertCursor] === 'insert') { + insertCount += 1; + insertCursor += 1; + } + + const pairCount = Math.min(deleteCount, insertCount); + for (let k = 0; k < pairCount; k += 1) { + normalized.push('delete', 'insert'); + } + for (let k = pairCount; k < deleteCount; k += 1) { + normalized.push('delete'); + } + for (let k = pairCount; k < insertCount; k += 1) { + normalized.push('insert'); + } + + i = insertCursor - 1; + } + + return normalized; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js new file mode 100644 index 000000000..6c3edce93 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { diffParagraphs } from './paragraph-diffing.js'; + +const createParagraph = (text, attrs = {}) => ({ + node: { attrs }, + pos: attrs.pos ?? 0, + text, + resolvePosition: (index) => index, +}); + +describe('diffParagraphs', () => { + it('treats similar paragraphs without IDs as modifications', () => { + const oldParagraphs = [createParagraph('Hello world from ProseMirror.')]; + const newParagraphs = [createParagraph('Hello brave new world from ProseMirror.')]; + + const diffs = diffParagraphs(oldParagraphs, newParagraphs); + + expect(diffs).toHaveLength(1); + expect(diffs[0].type).toBe('modified'); + expect(diffs[0].textDiffs.length).toBeGreaterThan(0); + }); + + it('keeps unrelated paragraphs as deletion + addition', () => { + const oldParagraphs = [createParagraph('Alpha paragraph with some text.')]; + const newParagraphs = [createParagraph('Zephyr quickly jinxed the new passage.')]; + + const diffs = diffParagraphs(oldParagraphs, newParagraphs); + + expect(diffs).toHaveLength(2); + expect(diffs[0].type).toBe('deleted'); + expect(diffs[1].type).toBe('added'); + }); + + it('detects modifications even when Myers emits grouped deletes and inserts', () => { + const oldParagraphs = [ + createParagraph('Original introduction paragraph that needs tweaks.'), + createParagraph('Paragraph that will be removed.'), + ]; + const newParagraphs = [ + createParagraph('Original introduction paragraph that now has tweaks.'), + createParagraph('Completely different replacement paragraph.'), + ]; + + const diffs = diffParagraphs(oldParagraphs, newParagraphs); + + expect(diffs).toHaveLength(3); + expect(diffs[0].type).toBe('modified'); + expect(diffs[0].textDiffs.length).toBeGreaterThan(0); + expect(diffs[1].type).toBe('deleted'); + expect(diffs[2].type).toBe('added'); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js new file mode 100644 index 000000000..c3f426aff --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js @@ -0,0 +1,102 @@ +import { myersDiff } from './myers-diff.js'; + +/** + * Computes text-level additions and deletions between two strings using Myers diff algorithm, mapping back to document positions. + * @param {string} oldText - Source text. + * @param {string} newText - Target text. + * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the original document. + * @param {(index: number) => number|null} [newPositionResolver=oldPositionResolver] - Maps string indexes to the updated document. + * @returns {Array} List of addition/deletion ranges with document positions and text content. + */ +export function getTextDiff(oldText, newText, oldPositionResolver, newPositionResolver = oldPositionResolver) { + const oldLen = oldText.length; + const newLen = newText.length; + + if (oldLen === 0 && newLen === 0) { + return []; + } + + const operations = myersDiff(oldText, newText, (a, b) => a === b); + return buildDiffFromOperations(operations, oldText, newText, oldPositionResolver, newPositionResolver); +} + +/** + * Groups edit operations into contiguous additions/deletions and maps them to document positions. + * + * @param {Array<'equal'|'delete'|'insert'>} operations - Raw operation list produced by the backtracked Myers path. + * @param {string} oldText - Source text. + * @param {string} newText - Target text. + * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the previous document. + * @param {(index: number) => number|null} newPositionResolver - Maps string indexes to the updated document. + * @returns {Array} Final diff payload matching the existing API surface. + */ +function buildDiffFromOperations(operations, oldText, newText, oldPositionResolver, newPositionResolver) { + const diffs = []; + let change = null; + let oldIdx = 0; + let newIdx = 0; + const resolveOld = oldPositionResolver ?? (() => null); + const resolveNew = newPositionResolver ?? resolveOld; + + /** Flushes the current change block into the diffs list. */ + const flushChange = () => { + if (!change || change.text.length === 0) { + change = null; + return; + } + + if (change.type === 'delete') { + const startIdx = resolveOld(change.startOldIdx); + const endIdx = resolveOld(change.endOldIdx); + diffs.push({ + type: 'deletion', + startIdx, + endIdx, + text: change.text, + }); + } else if (change.type === 'insert') { + const startIdx = resolveNew(change.startNewIdx); + const endIdx = resolveNew(change.endNewIdx); + diffs.push({ + type: 'addition', + startIdx, + endIdx, + text: change.text, + }); + } + + change = null; + }; + + for (const op of operations) { + if (op === 'equal') { + flushChange(); + oldIdx += 1; + newIdx += 1; + continue; + } + + if (!change || change.type !== op) { + flushChange(); + if (op === 'delete') { + change = { type: 'delete', startOldIdx: oldIdx, endOldIdx: oldIdx, text: '' }; + } else if (op === 'insert') { + change = { type: 'insert', startNewIdx: newIdx, endNewIdx: newIdx, text: '' }; + } + } + + if (op === 'delete') { + change.text += oldText[oldIdx]; + oldIdx += 1; + change.endOldIdx = oldIdx; + } else if (op === 'insert') { + change.text += newText[newIdx]; + newIdx += 1; + change.endNewIdx = newIdx; + } + } + + flushChange(); + + return diffs; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js new file mode 100644 index 000000000..40e5d794a --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { getTextDiff } from './text-diffing'; + +describe('getTextDiff', () => { + it('returns an empty diff list when both strings are identical', () => { + const resolver = () => 0; + + const diffs = getTextDiff('unchanged', 'unchanged', resolver); + + expect(diffs).toEqual([]); + }); + + it('detects text insertions and maps them to resolver positions', () => { + const oldResolver = (index) => index + 10; + const newResolver = (index) => index + 100; + + const diffs = getTextDiff('abc', 'abXc', oldResolver, newResolver); + + expect(diffs).toEqual([ + { + type: 'addition', + startIdx: 102, + endIdx: 103, + text: 'X', + }, + ]); + }); + + it('detects deletions and additions in the same diff sequence', () => { + const oldResolver = (index) => index + 5; + const newResolver = (index) => index + 20; + + const diffs = getTextDiff('abcd', 'abXYd', oldResolver, newResolver); + + expect(diffs).toEqual([ + { + type: 'deletion', + startIdx: 7, + endIdx: 8, + text: 'c', + }, + { + type: 'addition', + startIdx: 22, + endIdx: 24, + text: 'XY', + }, + ]); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js index 606010d29..04c2fbc62 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -1,5 +1,5 @@ -import { Node } from 'prosemirror-model'; -import { v4 as uuidv4 } from 'uuid'; +import { extractParagraphs } from './utils.js'; +import { diffParagraphs } from './algorithm/paragraph-diffing.js'; /** * Computes paragraph-level diffs between two ProseMirror documents, returning inserts, deletes and text modifications. @@ -8,316 +8,8 @@ import { v4 as uuidv4 } from 'uuid'; * @returns {Array} List of diff objects describing added, deleted or modified paragraphs. */ export function computeDiff(oldPmDoc, newPmDoc) { - const diffs = []; + const oldParagraphs = extractParagraphs(oldPmDoc); + const newParagraphs = extractParagraphs(newPmDoc); - // 1. Extract all paragraphs from old document and create a map using their IDs - const oldParagraphsMap = extractParagraphs(oldPmDoc); - - // 2. Extract all paragraphs from new document and create a map using their IDs - const newParagraphsMap = extractParagraphs(newPmDoc); - - // 3. Compare paragraphs in old and new documents - let insertPos = 0; - newParagraphsMap.forEach((newPara, paraId) => { - const oldPara = oldParagraphsMap.get(paraId); - if (!oldPara) { - diffs.push({ - type: 'added', - paraId, - node: newPara.node, - text: newPara.node.textContent, - pos: insertPos, - }); - return; - } else if (oldPara.node.textContent !== newPara.node.textContent) { - const oldTextContent = getTextContent(oldPara.node, oldPara.pos); - const newTextContent = getTextContent(newPara.node, newPara.pos); - const textDiffs = getTextDiff(oldPara.node.textContent, newPara.node.textContent, oldTextContent.resolvePosition); - diffs.push({ - type: 'modified', - paraId, - oldText: oldTextContent.text, - newText: newTextContent.text, - pos: oldPara.pos, - textDiffs, - }); - } - insertPos = oldPara.pos + oldPara.node.nodeSize; - }); - - // 4. Identify deleted paragraphs - oldParagraphsMap.forEach((oldPara, paraId) => { - if (!newParagraphsMap.has(paraId)) { - diffs.push({ type: 'deleted', paraId, node: oldPara.node, pos: oldPara.pos }); - } - }); - - return diffs; -} - -/** - * Collects paragraphs from a ProseMirror document and returns them by paragraph ID. - * @param {Node} pmDoc - ProseMirror document to scan. - * @returns {Map} Map keyed by paraId containing paragraph nodes and positions. - */ -export function extractParagraphs(pmDoc) { - const paragraphMap = new Map(); - pmDoc.descendants((node, pos) => { - if (node.type.name === 'paragraph') { - paragraphMap.set(node.attrs?.paraId ?? uuidv4(), { node, pos }); - return false; // Do not descend further - } - }); - return paragraphMap; -} - -/** - * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. - * @param {Node} paragraph - Paragraph node to flatten. - * @param {number} [paragraphPos=0] - Position of the paragraph in the document. - * @returns {{text: string, resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. - */ -export function getTextContent(paragraph, paragraphPos = 0) { - let text = ''; - const segments = []; - - paragraph.nodesBetween( - 0, - paragraph.content.size, - (node, pos) => { - let nodeText = ''; - - if (node.isText) { - nodeText = node.text; - } else if (node.isLeaf && node.type.spec.leafText) { - nodeText = node.type.spec.leafText(node); - } - - if (!nodeText) { - return; - } - - const start = text.length; - const end = start + nodeText.length; - - segments.push({ start, end, pos }); - text += nodeText; - }, - 0, - ); - - const resolvePosition = (index) => { - if (index < 0 || index > text.length) { - return null; - } - - for (const segment of segments) { - if (index >= segment.start && index < segment.end) { - return paragraphPos + 1 + segment.pos + (index - segment.start); - } - } - - // If index points to the end of the string, return the paragraph end - return paragraphPos + 1 + paragraph.content.size; - }; - - return { text, resolvePosition }; -} - -/** - * Computes text-level additions and deletions between two strings using Myers diff algorithm, mapping back to document positions. - * @param {string} oldText - Source text. - * @param {string} newText - Target text. - * @param {(index: number) => number|null} positionResolver - Maps string indices to document positions. - * @returns {Array} List of addition/deletion ranges with document positions and text content. - */ -export function getTextDiff(oldText, newText, positionResolver) { - const oldLen = oldText.length; - const newLen = newText.length; - - if (oldLen === 0 && newLen === 0) { - return []; - } - - // Myers diff bookkeeping: +2 padding keeps diagonal lookups in bounds. - const max = oldLen + newLen; - const size = 2 * max + 3; - const offset = max + 1; - const v = new Array(size).fill(-1); - v[offset + 1] = 0; - - const trace = []; - let foundPath = false; - - for (let d = 0; d <= max && !foundPath; d += 1) { - for (let k = -d; k <= d; k += 2) { - const index = offset + k; - let x; - - if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { - x = v[index + 1]; - } else { - x = v[index - 1] + 1; - } - - let y = x - k; - while (x < oldLen && y < newLen && oldText[x] === newText[y]) { - x += 1; - y += 1; - } - - v[index] = x; - - if (x >= oldLen && y >= newLen) { - foundPath = true; - break; - } - } - trace.push(v.slice()); - } - - const operations = backtrackMyers(trace, oldLen, newLen, offset); - return buildDiffFromOperations(operations, oldText, newText, positionResolver); -} - -/** - * Reconstructs the shortest edit script by walking the previously recorded V vectors. - * - * @param {Array} trace - Snapshot of diagonal furthest-reaching points per edit distance. - * @param {number} oldLen - Length of the original string. - * @param {number} newLen - Length of the target string. - * @param {number} offset - Offset applied to diagonal indexes to keep array lookups positive. - * @returns {Array<'equal'|'delete'|'insert'>} Concrete step-by-step operations. - */ -function backtrackMyers(trace, oldLen, newLen, offset) { - const operations = []; - let x = oldLen; - let y = newLen; - - for (let d = trace.length - 1; d > 0; d -= 1) { - const v = trace[d - 1]; - const k = x - y; - const index = offset + k; - - let prevK; - if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { - prevK = k + 1; - } else { - prevK = k - 1; - } - - const prevIndex = offset + prevK; - const prevX = v[prevIndex]; - const prevY = prevX - prevK; - - while (x > prevX && y > prevY) { - x -= 1; - y -= 1; - operations.push('equal'); - } - - if (x === prevX) { - y -= 1; - operations.push('insert'); - } else { - x -= 1; - operations.push('delete'); - } - } - - while (x > 0 && y > 0) { - x -= 1; - y -= 1; - operations.push('equal'); - } - - while (x > 0) { - x -= 1; - operations.push('delete'); - } - - while (y > 0) { - y -= 1; - operations.push('insert'); - } - - return operations.reverse(); -} - -/** - * Groups edit operations into contiguous additions/deletions and maps them to document positions. - * - * @param {Array<'equal'|'delete'|'insert'>} operations - Raw operation list produced by the backtracked Myers path. - * @param {string} oldText - Source text. - * @param {string} newText - Target text. - * @param {(index: number) => number|null} positionResolver - Maps string indexes to ProseMirror positions. - * @returns {Array} Final diff payload matching the existing API surface. - */ -function buildDiffFromOperations(operations, oldText, newText, positionResolver) { - const diffs = []; - let run = null; - let oldIdx = 0; - let newIdx = 0; - let insertionAnchor = 0; - - const flushRun = () => { - if (!run || run.text.length === 0) { - run = null; - return; - } - - if (run.type === 'delete') { - const startIdx = positionResolver(run.startOldIdx); - const endIdx = positionResolver(run.endOldIdx); - diffs.push({ - type: 'deletion', - startIdx, - endIdx, - text: run.text, - }); - } else if (run.type === 'insert') { - const startIdx = positionResolver(run.referenceOldIdx); - const endIdx = positionResolver(run.referenceOldIdx); - diffs.push({ - type: 'addition', - startIdx, - endIdx, - text: run.text, - }); - } - - run = null; - }; - - for (const op of operations) { - if (op === 'equal') { - flushRun(); - oldIdx += 1; - newIdx += 1; - insertionAnchor = oldIdx; - continue; - } - - if (!run || run.type !== op) { - flushRun(); - if (op === 'delete') { - run = { type: 'delete', startOldIdx: oldIdx, endOldIdx: oldIdx, text: '' }; - } else if (op === 'insert') { - run = { type: 'insert', referenceOldIdx: insertionAnchor, text: '' }; - } - } - - if (op === 'delete') { - run.text += oldText[oldIdx]; - oldIdx += 1; - run.endOldIdx = oldIdx; - } else if (op === 'insert') { - run.text += newText[newIdx]; - newIdx += 1; - } - } - - flushRun(); - - return diffs; + return diffParagraphs(oldParagraphs, newParagraphs); } diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index a6da92a0b..fcd1772f7 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { getTextContent, computeDiff, extractParagraphs, getTextDiff } from './computeDiff'; +import { computeDiff } from './computeDiff'; import { Editor } from '@core/Editor.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; -export const getDocument = async (name) => { +const getDocument = async (name) => { const buffer = await getTestDataAsBuffer(name); const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); @@ -30,196 +30,108 @@ describe('Diff', () => { const docAfter = await getDocument('diff_after.docx'); const diffs = computeDiff(docBefore, docAfter); - console.log(JSON.stringify(diffs, null, 2)); - }); -}); - -describe('extractParagraphs', () => { - it('collects all paragraph nodes keyed by their paraId', () => { - const firstParagraph = { - type: { name: 'paragraph' }, - attrs: { paraId: 'para-1' }, - textContent: 'First paragraph', - }; - const nonParagraph = { - type: { name: 'heading' }, - attrs: { paraId: 'heading-1' }, - }; - const secondParagraph = { - type: { name: 'paragraph' }, - attrs: { paraId: 'para-2' }, - textContent: 'Second paragraph', - }; - const pmDoc = { - descendants: (callback) => { - callback(firstParagraph, 0); - callback(nonParagraph, 5); - callback(secondParagraph, 10); - }, - }; - - const paragraphs = extractParagraphs(pmDoc); - - expect(paragraphs.size).toBe(2); - expect(paragraphs.get('para-1')).toEqual({ node: firstParagraph, pos: 0 }); - expect(paragraphs.get('para-2')).toEqual({ node: secondParagraph, pos: 10 }); - }); - - it('generates unique IDs when paragraph nodes are missing paraId', () => { - const firstParagraph = { - type: { name: 'paragraph' }, - attrs: {}, - textContent: 'Anonymous first', - }; - const secondParagraph = { - type: { name: 'paragraph' }, - attrs: undefined, - textContent: 'Anonymous second', - }; - const pmDoc = { - descendants: (callback) => { - callback(firstParagraph, 2); - callback(secondParagraph, 8); - }, - }; - - const paragraphs = extractParagraphs(pmDoc); - const entries = [...paragraphs.entries()]; - const firstEntry = entries.find(([, value]) => value.node === firstParagraph); - const secondEntry = entries.find(([, value]) => value.node === secondParagraph); - - expect(paragraphs.size).toBe(2); - expect(firstEntry?.[0]).toBeTruthy(); - expect(secondEntry?.[0]).toBeTruthy(); - expect(firstEntry?.[0]).not.toBe(secondEntry?.[0]); - expect(firstEntry?.[1].pos).toBe(2); - expect(secondEntry?.[1].pos).toBe(8); - }); -}); - -describe('getTextContent', () => { - it('Handles basic text nodes', () => { - const mockParagraph = { - content: { - size: 5, - }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text: 'Hello' }, 0); - }, - }; - - const result = getTextContent(mockParagraph); - expect(result.text).toBe('Hello'); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(4)).toBe(5); - }); - - it('Handles leaf nodes with leafText', () => { - const mockParagraph = { - content: { - size: 4, - }, - nodesBetween: (from, to, callback) => { - callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 0); - }, - }; - - const result = getTextContent(mockParagraph); - expect(result.text).toBe('Leaf'); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(3)).toBe(4); + const getDiff = (type, predicate) => diffs.find((diff) => diff.type === type && predicate(diff)); + + expect(diffs).toHaveLength(15); + expect(diffs.filter((diff) => diff.type === 'modified')).toHaveLength(5); + expect(diffs.filter((diff) => diff.type === 'added')).toHaveLength(5); + expect(diffs.filter((diff) => diff.type === 'deleted')).toHaveLength(5); + + // Modified paragraph with multiple text diffs + let diff = getDiff( + 'modified', + (diff) => diff.oldText === 'Curabitur facilisis ligula suscipit enim pretium, sed porttitor augue consequat.', + ); + expect(diff?.newText).toBe( + 'Curabitur facilisis ligula suscipit enim pretium et nunc ligula, porttitor augue consequat maximus.', + ); + expect(diff?.textDiffs).toHaveLength(6); + + // Deleted paragraph + diff = getDiff( + 'deleted', + (diff) => diff.oldText === 'Vestibulum gravida eros sed nulla malesuada, vel eleifend sapien bibendum.', + ); + expect(diff).toBeDefined(); + + // Added paragraph + diff = getDiff( + 'added', + (diff) => + diff.text === 'Lorem tempor velit eget lorem posuere, id luctus dolor ultricies, to track supplier risks.', + ); + expect(diff).toBeDefined(); + + // Another modified paragraph + diff = getDiff( + 'modified', + (diff) => diff.oldText === 'Quisque posuere risus a ligula cursus vulputate et vitae ipsum.', + ); + expect(diff?.newText).toBe( + 'Quisque dapibus risus convallis ligula cursus vulputate, ornare dictum ipsum et vehicula nisl.', + ); + + // Simple modified paragraph + diff = getDiff('modified', (diff) => diff.oldText === 'OK' && diff.newText === 'No'); + expect(diff).toBeDefined(); + + // Added, trimmed, merged, removed, and moved paragraphs + diff = getDiff('added', (diff) => diff.text === 'Sed et nibh in nulla blandit maximus et dapibus.'); + expect(diff).toBeDefined(); + + const trimmedParagraph = getDiff( + 'modified', + (diff) => + diff.oldText === + 'Sed et nibh in nulla blandit maximus et dapibus. Etiam egestas diam luctus sit amet gravida purus.' && + diff.newText === 'Etiam egestas diam luctus sit amet gravida purus.', + ); + expect(trimmedParagraph).toBeDefined(); + + const mergedParagraph = getDiff( + 'added', + (diff) => + diff.text === + 'Praesent dapibus lacus vitae tellus laoreet, eget facilisis mi facilisis, donec mollis lacus sed nisl posuere, nec feugiat massa fringilla.', + ); + expect(mergedParagraph).toBeDefined(); + + const removedParagraph = getDiff( + 'modified', + (diff) => + diff.oldText === 'Praesent dapibus lacus vitae tellus laoreet, eget facilisis mi facilisis.' && + diff.newText === '', + ); + expect(removedParagraph).toBeDefined(); + + const movedParagraph = getDiff( + 'added', + (diff) => diff.text === 'Aenean hendrerit elit vitae sem fermentum, vel sagittis erat gravida.', + ); + expect(movedParagraph).toBeDefined(); }); - it('Handles mixed content', () => { - const mockParagraph = { - content: { - size: 9, - }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text: 'Hello' }, 0); - callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 5); - }, - }; - - const result = getTextContent(mockParagraph); - expect(result.text).toBe('HelloLeaf'); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(5)).toBe(6); - expect(result.resolvePosition(9)).toBe(10); - }); - - it('Handles empty content', () => { - const mockParagraph = { - content: { - size: 0, - }, - nodesBetween: () => {}, - }; - - const result = getTextContent(mockParagraph); - expect(result.text).toBe(''); - expect(result.resolvePosition(0)).toBe(1); - }); - - it('Handles nested nodes', () => { - const mockParagraph = { - content: { - size: 6, - }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text: 'Nested' }, 0); - }, - }; - - const result = getTextContent(mockParagraph); - expect(result.text).toBe('Nested'); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(6)).toBe(7); - }); -}); + it('Compare two documents with simple changes', async () => { + const docBefore = await getDocument('diff_before2.docx'); + const docAfter = await getDocument('diff_after2.docx'); -describe('getTextDiff', () => { - it('returns an empty diff list when both strings are identical', () => { - const resolver = () => 0; + const diffs = computeDiff(docBefore, docAfter); + expect(diffs).toHaveLength(4); - const diffs = getTextDiff('unchanged', 'unchanged', resolver); + let diff = diffs.find((diff) => diff.type === 'modified' && diff.oldText === 'Here’s some text.'); - expect(diffs).toEqual([]); - }); + expect(diff.newText).toBe('Here’s some NEW text.'); + expect(diff.textDiffs).toHaveLength(1); + expect(diff.textDiffs[0].text).toBe('NEW '); - it('detects text insertions and maps them to resolver positions', () => { - const resolver = (index) => index + 10; + diff = diffs.find((diff) => diff.type === 'deleted' && diff.oldText === 'I deleted this sentence.'); + expect(diff).toBeDefined(); - const diffs = getTextDiff('abc', 'abXc', resolver); - - expect(diffs).toEqual([ - { - type: 'addition', - startIdx: 12, - endIdx: 12, - text: 'X', - }, - ]); - }); + diff = diffs.find((diff) => diff.type === 'added' && diff.text === 'I added this sentence.'); + expect(diff).toBeDefined(); - it('detects deletions and additions in the same diff sequence', () => { - const resolver = (index) => index + 5; - - const diffs = getTextDiff('abcd', 'abXYd', resolver); - - expect(diffs).toEqual([ - { - type: 'deletion', - startIdx: 7, - endIdx: 8, - text: 'c', - }, - { - type: 'addition', - startIdx: 7, - endIdx: 7, - text: 'XY', - }, - ]); + diff = diffs.find((diff) => diff.type === 'modified' && diff.oldText === 'We are not done yet.'); + expect(diff.newText).toBe('We are done now.'); + expect(diff.textDiffs).toHaveLength(3); }); }); diff --git a/packages/super-editor/src/extensions/diffing/utils.js b/packages/super-editor/src/extensions/diffing/utils.js new file mode 100644 index 000000000..3b4cc56ab --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/utils.js @@ -0,0 +1,69 @@ +/** + * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. + * @param {Node} paragraph - Paragraph node to flatten. + * @param {number} [paragraphPos=0] - Position of the paragraph in the document. + * @returns {{text: string, resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. + */ +export function getTextContent(paragraph, paragraphPos = 0) { + let text = ''; + const segments = []; + + paragraph.nodesBetween( + 0, + paragraph.content.size, + (node, pos) => { + let nodeText = ''; + + if (node.isText) { + nodeText = node.text; + } else if (node.isLeaf && node.type.spec.leafText) { + nodeText = node.type.spec.leafText(node); + } + + if (!nodeText) { + return; + } + + const start = text.length; + const end = start + nodeText.length; + + segments.push({ start, end, pos }); + text += nodeText; + }, + 0, + ); + + const resolvePosition = (index) => { + if (index < 0 || index > text.length) { + return null; + } + + for (const segment of segments) { + if (index >= segment.start && index < segment.end) { + return paragraphPos + 1 + segment.pos + (index - segment.start); + } + } + + // If index points to the end of the string, return the paragraph end + return paragraphPos + 1 + paragraph.content.size; + }; + + return { text, resolvePosition }; +} + +/** + * Collects paragraphs from a ProseMirror document and returns them by paragraph ID. + * @param {Node} pmDoc - ProseMirror document to scan. + * @returns {Array<{node: Node, pos: number, text: string, resolvePosition: Function}>} Ordered list of paragraph descriptors. + */ +export function extractParagraphs(pmDoc) { + const paragraphs = []; + pmDoc.descendants((node, pos) => { + if (node.type.name === 'paragraph') { + const { text, resolvePosition } = getTextContent(node, pos); + paragraphs.push({ node, pos, text, resolvePosition }); + return false; // Do not descend further + } + }); + return paragraphs; +} diff --git a/packages/super-editor/src/extensions/diffing/utils.test.js b/packages/super-editor/src/extensions/diffing/utils.test.js new file mode 100644 index 000000000..1c3dd9ddb --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/utils.test.js @@ -0,0 +1,141 @@ +import { extractParagraphs, getTextContent } from './utils'; + +/** + * Creates a lightweight mock paragraph node for tests. + * @param {string} text + * @param {Record} [attrs={}] + * @returns {object} + */ +const createParagraphNode = (text, attrs = {}) => ({ + type: { name: 'paragraph' }, + attrs, + textContent: text, + content: { size: text.length }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text }, 0); + }, +}); + +describe('extractParagraphs', () => { + it('collects paragraph nodes in document order', () => { + const firstParagraph = createParagraphNode('First paragraph', { paraId: 'para-1' }); + const nonParagraph = { + type: { name: 'heading' }, + attrs: { paraId: 'heading-1' }, + }; + const secondParagraph = createParagraphNode('Second paragraph', { paraId: 'para-2' }); + const pmDoc = { + descendants: (callback) => { + callback(firstParagraph, 0); + callback(nonParagraph, 5); + callback(secondParagraph, 10); + }, + }; + + const paragraphs = extractParagraphs(pmDoc); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toMatchObject({ node: firstParagraph, pos: 0, text: 'First paragraph' }); + expect(paragraphs[1]).toMatchObject({ node: secondParagraph, pos: 10, text: 'Second paragraph' }); + }); + + it('includes position resolvers for paragraphs with missing IDs', () => { + const firstParagraph = createParagraphNode('Anonymous first'); + const secondParagraph = createParagraphNode('Anonymous second'); + const pmDoc = { + descendants: (callback) => { + callback(firstParagraph, 2); + callback(secondParagraph, 8); + }, + }; + + const paragraphs = extractParagraphs(pmDoc); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].pos).toBe(2); + expect(paragraphs[1].pos).toBe(8); + expect(paragraphs[0].resolvePosition(0)).toBe(3); + expect(paragraphs[1].resolvePosition(4)).toBe(13); + }); +}); + +describe('getTextContent', () => { + it('Handles basic text nodes', () => { + const mockParagraph = { + content: { + size: 5, + }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text: 'Hello' }, 0); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('Hello'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(4)).toBe(5); + }); + + it('Handles leaf nodes with leafText', () => { + const mockParagraph = { + content: { + size: 4, + }, + nodesBetween: (from, to, callback) => { + callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 0); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('Leaf'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(3)).toBe(4); + }); + + it('Handles mixed content', () => { + const mockParagraph = { + content: { + size: 9, + }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text: 'Hello' }, 0); + callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 5); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('HelloLeaf'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(5)).toBe(6); + expect(result.resolvePosition(9)).toBe(10); + }); + + it('Handles empty content', () => { + const mockParagraph = { + content: { + size: 0, + }, + nodesBetween: () => {}, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe(''); + expect(result.resolvePosition(0)).toBe(1); + }); + + it('Handles nested nodes', () => { + const mockParagraph = { + content: { + size: 6, + }, + nodesBetween: (from, to, callback) => { + callback({ isText: true, text: 'Nested' }, 0); + }, + }; + + const result = getTextContent(mockParagraph); + expect(result.text).toBe('Nested'); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(6)).toBe(7); + }); +}); diff --git a/packages/super-editor/src/tests/data/diff_after.docx b/packages/super-editor/src/tests/data/diff_after.docx index b04b965837ddff5c026353bd4fb9c1f11706156b..75c363abb674c799b6f9918998e7e8b8e6511019 100644 GIT binary patch delta 9019 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 diff --git a/packages/super-editor/src/tests/data/diff_after2.docx b/packages/super-editor/src/tests/data/diff_after2.docx new file mode 100644 index 0000000000000000000000000000000000000000..7f0426d99675989c6ddccd2e43a8f979b364bf9d GIT binary patch literal 13481 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before.docx b/packages/super-editor/src/tests/data/diff_before.docx index 8cae247f962363005ecacefbc8b179626fb58ec9..3fec392b96bcf45d41d125552d1a7163cc13608f 100644 GIT binary patch delta 8427 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 HkM4f~pI(n! diff --git a/packages/super-editor/src/tests/data/diff_before2.docx b/packages/super-editor/src/tests/data/diff_before2.docx new file mode 100644 index 0000000000000000000000000000000000000000..6db7a2b993e99b9560ffc6c2234baa77d0ff69ea GIT binary patch literal 13404 zcmeHuWpErxwsngcEM}H01`91_w!mU$W@cHkn3vEP#-^P-OGq9a+mOk6w{=ne~?>B4I0Lq zNcZ^=3d;jj`6YI+{k0PnR9*=r2G){3QjX=&JXCNa}A*n$Tl-%r{vX!gpDw*Tw#U4W z5uX3d@5OJrLw=846lMLK<~-$$atkg?niURO?VrY;wf4SHXqv=v0A;(rVVbomcSJ-G5uX1qk)}O?m*>yK>z^W0e=)18+$_rLmLApD`4LG zEn(#+ty-^gAP1gd9&kao$`;od5zD+AbY{nd2(`lOFcu#{p$-L=7`)X!vADi@B*Q<_ zEm6`Kg4Gr09v{>ZnuzZ7@Yo8t>CGPzX{r68G1#ao0X{zZGV*#idrHwrS70Y$M=aS` zJ1M61T`UP1(K^cTth;`HQOqQ{F%%EuS_Eol`3BRhVC6+2+KH(kQg!5 zCEj9*R05hm6~qb-ZZGDUi7HbJ$Lc-r+D5KExnrTAeR&S`U9iE! zuG|kmb1&zYi7?G9-$P-1$o<_U?9lEb!42My)M|cBIpzEMyx;_{Xrq~xb--uU z9ZWc7=`@T&4HD;>fSHVn^sTl5&smDZ6$|lN&}T}?gxz^^uJaa_gE7vb(JZ|4-Z1O$ zHTLfVSI$1K7ODjjaEN%fIk%g}zoi^-qAJf4(PJo(J#&~8`S&57zR%oE^>z3WGF$}a zO3E+1^sO(~;2RxS3~wvK`z$PF{312w+*ISm z%~n>y0St?DJtvsdz+@R|aC{~#v_ zw|&Pq{C_9Z1;aY|YoI`xfMUf1K!f}))PH2NKg;!BnGFP3WC8{Me|syB9R-f;z#=Q) z)i2qLj>}6qB>Qn&PLdD`IJ~dX1u71Fw-U;V*@ zp`u;4L5QRIK`(lvO6EdGqq=XUDYm}}QT@n;JrP~Gzs%U4+{Yu#lD6ND2ut>1{>D)# zv3^n6g2+Fd4#@%YH1RTPR`C-+uZzMjh&t!85X@!s-SedZ_q;V&F5D+x_TFj9g@kzK z=M87psNPW3G#a_RwSXFYz;SIg0i!GZ%|QNXn{^w3P&x;ZZ;hgdO-;80xJ-iN{+{TA z|In9^4Rc+YrI61N@GubwiJ9)(7Q7_z=>jt@JDeaqd?J{=AmH}I?;j(}-qIh(ac=~J zgJG9rzbn4&+PCmmUpL^vnFM5WU9^3wnyLa3KHjF#x}m-{1Ef#x$}rO#l`opNY4Hsv z@~h3}wk=-ZP;};Z)l_vY5%`M21Y`kzLSr|<&q5zm(`=uKmSxD{Ko3Nxtj`m1h2~OP9s}ACRsPm6~ zB?nwh1ZkJb+QG_q|_tUabeD@m{d1u`qh@^TkZ4hzQw0DDxgl`YKPaAiHosZjb zvL~+E1AH&HeMMc*k1j_60zd1{vR?0B;wJeXPts&*&q4g^K0Koh2K%)g;Ni`xT2Ivi zQfv0(I3kGXrQlnreynoxYs zL=d;6e#bO+PoW8erGed-93KNEC(IW6-hz!Cej?!7F&G*m4q#^;*D|;O`GZStkFIPL zX_%8OMtY+F!3{t~M>^>EF;q8Q{o^(nwZZz96m~8$p1dBpz&L8|;|vmGiQ^quIJ^tr zljVAn*$@GdxpBXx7^wUecjYiBN!leySOglE?h+1upOUHnEr=j^W*>mq_KglVvoLVH zDc?ryL)mvhw$<-}#^t5nUz^Jl6zvS`X!k^iGP9$V&p&FOk!7+<>kpN(tZStbGp8`Q zVOd>B?n#j*=ocbZWPIZxz(EmzE1-F6y>e|w2zrZ6ISg~Pu7{^3K-o55z78Tn;4N!77%VSa^DXOGgD{3RbIy2^v5xD*6T79 zt<-?&-J9%W)*k4l~qXS>>k*Br{W z1{3XHRTX~!PI~u4rZ(|T%TcsnyuBL7p>7SeZpXd^Z$_8j($T9%xHCJ)|~BUA?7S{Yh1sv~A`mO!ZwFrGPPfXSasSbGY-= zO3Qpop>U~a`mhsTr;MyN4p&_Nw!39(PTqZw@q`QRAw+_0TakK)T1fzf!pnr-Ug|Av zF7^ZcK(rXPw5$!=xjm1YskV}Rr+sgks7zNvM>qx?3nn`0YGwLp zysC)~ayh3XxkQ$5&&T8#bgsY|xr)f5c3fS0SQV(`gj^4c{tVBAERpTetP|`2vIt%8 zg7`W&$iISho_Ze-km9G#p&0*i>Gw~bHY>>%HN5OR_fnJC%KF!sam0S^-z!g zXdA9Vy04{sh?6R_eum+u^cE@0L|!cs;bGTMpgoTvN)Ij#5e-EHS$ zVO`LN%fVVrtr$yxvf3i5A~9))tZCg0c9=;SiN>N&I7Gut>Hs{=p?EW9nb&^1m~WAF z5Ocdq(CQWDuzRinqllKo=@9qpu$UTO)u zj|w(3cx@=@Tw5fQJuP%>@@`iQz~uS&^coL>nDGiM$hP7B^zX^R$kEZv+Qi|v)LO5+ zZnMgU>`hqx)-!d5r&fE6C797Gb4)JMj+hX}qd;PWx{2hQf85;ndWTOnJ(HLntg$1K zd)oGN>gvLM)Hi4{0ST4MlAuo@tdgh8FHFpY-sX1h)J8Dz)t60N_gg;E)rPR+`NiI% z4lka9Me8O^T=FPT$@FXpMDy4!?KYgK1CIG2q$PFIkMSQB= zRw?I?ZFq|SdbP!_;CvOX9I%Hx)7%;q;+b2sRx3Z_;zNid*G*Yc{bOPg< z(IlQ93vVoNi{U-?SEMvSMy$UDv^s3$*2z-VdbN&rHPROJ$-AT7h&8wffWpk7JRGv>J5HA;@dY- zWtzuZ6g9AYp44lt*Yy~GI3aoIMz-ygk4&|J8_bUpwBWCb7DB$$5er$t8y+CYdOTkO z36?XdYbl22q6=N@_o!=(>sRCo^4*cK+_!(EPuG&c;>jH}Ak-r%T^?L|$|47PJZ3yE zq&`E8*a{D7n2r_|u!vqhDKrFWzG;$cbp^fL26Q2mzifrY@0D|=R>rpdYP^wx2-mS2H%#N3gUGb5fz9!h`OcAe+Wj}LYKEn7>g``Xa^X%wT03XRBvP@F&=3==OR^bN?%US`Y`D^LU zusx8xwvG_X6a-Jb`x*zr2YTz8B|SU879_=`3}5HA1H+o6o9h8>wo}89B=QW)Eu%OY zwwv0LgcChUN~~qssZMXR-2y{t0+`OTb4(JxF*sQCNg3`l<7*{9rqWGrCB}&eMS`bs zJ^5IDHSQ%iO0%6|$NF%UZ6`3<@2tAtWBlg4iFZ-VzG{r6$>7X(Ee_i|C(&Z`t zwadgBa%fbU^vTx~3{KN((<-~X)$ez|Wvo8~hl9Tl7T_l`S+K|e0CW)mfb_3g+`-Y! z(&+c0;#h0depM93YrXUZDn8Sh5HEy8WVn=)N}_S0vH`z8o_$hCmy!e&gdU)--08g! zX*)FFb082Vuc=X4EAHcE%vdCA&}x@L_~o@x{A(3A!xn7^2iCK#Bl0=RST09L7stK5 z%hk~_5v63JDDCiPZ@cqvVSZU(9-r2|J-Hc1iu^Hs0pej-BCd9f45Pabn|kji9=@XU z8A@e_X4KK@RDbpyKSRQ#{h~^z6R@m5JmQM;-tNKC)zguMmyyv8Gv1OdOz4#YP6aMA zapaY8KZJj5MxTHo4ECf3Zp9!`Y?K?$O+MI9n+(UMXRAICI~*`Yf_POvWW0;Cr7InU z7SBkCS0Wo`h+z8_i~|y|9sQ+GFC9BvNY8Bh+dD)P&Fyb70o#k%a75}kM3F&Cd^?0@ z)_Umj*yDD#=wd-Li3yeyp$`;Wv^~U;w8AI45*WNo$$0Fh?^!oy1?`)mC)d}D)Fui; z_))a6p$B2ns-JbvN#Ba2p3_$7gZe#_$uIq|8nXNy^JqQ$Oe_=P1D;dY_x8#j3SYk) znyGrm9esvQgzp{+CD5}Ky7naC@e8O+Va`0qDUICW#p9I@=&g{ ze>b?p9+2IqMW7Y7y}v$!F0~VdG~RvCwO~uv)K#KQuZC47Q=>L4qKF$FWr!533OT(! zTX!_NpRf~KF*3%I3%Fj#xkYBA_?iLjg^KUnug&{JFD_)M(vNJ?6UE2LW2NE8>&{X^8R1AAhq;Nlg`&mKHd!c=+si!Q@vl`H}!f$&- zFU{HP#KAc!%Zc-1Wm!`ZDta;(m+&A&pi17iq?94GBarV38VAg2aUinh+TrO7ZFAK7 z57>!AL~P4};tXycnBoNy=Goy{hjvrdP3;CG)*<1qtwpvy;cW)BJ;iST8+c&Dwehrh z8hPg3+}77ZczSwp0z9O;ZFi$cy#^ykB8iDcQ#;YlaGUhRBQYwW5a+x-{|L()?;Sak^?_G(;9gr{*Uoq%phnV<} zqTdFK9C)`s$_mLGE;(j@K!u-~*O%N#lX8{~?4J#Gy{8tA&6eY;AIjSQOvdD^z@VCJ z^3s;bRC|9;O#c{_01i{BEYTZM%{$$k+ zFqrXF@W7|1_w&ub19r@&!rR7Ek5Q{yniSOER_h`K?pA&{#SU9gc04hKoGB$P`jm)T@51shX2?p1D^eEC8RFa9*!;B%!7Y+gR;^F|hwIT&#Tzl!d8GwM+m|93Twnto`pM2c`c=xd8n}n#3>JB$ zTZO8OR&#|?%1$u9sq8v8xXSmy?5Y;KTc;$*?t@OW46W>AD3nZICZaA!P}57wts5Zb z)$UK9cMdIS4|}HLh8Gi)(_Ne_r>n?9$w-ZehD8PPayC`d{kUFe;Cs`hBIft*69_FM zH6JO@3#!&`O?UCLw6FX_3TWOXGlb}_Ngq2%*7!FvV1;5J@cAJfLc%3iLaI~M_~U3+ zFwr{2bLKlVan=>9%<-K#MzvC#EefA_)+2x0+KSC$@z)61;Z}+o$|n)IcSaJiM#2pZ zH!xyq)@yBzqtZMuGi`89FmMmtQY4vNk;7Irm%Aa}v<7WYrMMX*nS@(UZrIX$P7UjU z*n}tb^yXC(v4j7`auyI(=*fxqEOlTBPKXoA;(O_X5uNq0A$mg z+hnR{VmU#GJ#E1NO4A%c=z2L~aP?wa&_Gn``au}EKb+ zk|akAt6q!@N~Z|zi&2`&aEtu^gCT>GE9#z-D+YhXp!Vm7Ll?{rC?#aQ#{A#l=rR5m zpI84-)FOP?I_~XL<<5Dwbmo0H;&@c|LfM4CdW@)aGZHcXqUK*l>j8~J$c=GeB-p8sxsXjJ&ucd_{Au5nN)eczo7M zit#62gNV*5N;;&Nm4M1&Z7Y>XdZdq?MsiI!6yjB*sZ~lJv31yVA~D`(6G0jl`fG&f;AXs6U%aJ+jOXXe(=&meO7(G*OkA@6cMy4y29+(-A&fvoL<| z7Z3uBmk@TvSmUu1&A;TdzN=Og0%s@%3Fn(f9!WEY2KK;*tCkXjCYrx(9E+ak;&k6D zAmp@{1j>0U_9*~C!9E2zB)TQ1Zi3Fef9e{>|DUcvXl+F>tS_a+@$5j-DD!CYypllZ ze~<=mh_L!x6yXTE0a;gym6hTVO6B1-_L?PXr-`Ny+@SPcz?a zS(0F`*|<}PlSIcVA2FJkT6~K%VGX>}|I`h{ep0;SCg#$6{*9+Xal4kB{WqQvAkUmI zE4_w!E|Bcwzd8$)fmE?+f9d@FlhT36-#dT*8i#p|Au!q?tZLQm@CR#{cai8>!#dS=YcwiQ`&NwX%O3;B>%E{OPo}vsdPx z(IQvce!4h*;8^Y{^r9-_69!gz-L*z*Bl(VBg&jw)gI3F_W?4H?#-8-KDYbY#2j|z%xdM4ypHE32;=fJ8V@x*6|d;K20gadmiwRFTCmeT)VkR z%OtSdx`t-%WFfG-^m6t8JUkkP&*R0<0J6FRMiEwU;d=ph=NoeZCW9%5aLh`X-k$i@ zh%Z>#TJIdxrdqk@QF4EDh)^VB!-x)pW5e-6-=2eWz7G%WvG+DM+?A3rb^hg6@L|2b z8Kp=2=8Grwo73hSLOScJE#x7=qHq386_A`@;jzj47UeP=QJaoTFdE@E-I zs`?YSNTp;v?$oU%dV(J>^hT^r1(T&KtLlI`2)&{_r`vk_RMXP_pt};sda*-tC)qq3 zX`zspMQ#b+4=+D04`0oCO+M4>pKXYDL^)WzDKgM))=$o}Lcj0cG&Z?jbmp55`!w*l ziDlBEgt3AfsKHY*?~>2;XxmKvZAy9vsqv{vUh2nb!7?`dSEuJ?Sknkj+W@BR%?ac^ zM9DXXv|OrrbQ#QoLE-pRPOZhdnY&5btP%29JuO2=CT=qQr8$v9?AJP20GozaXpnfL zW5#6vLU1shLaOIS%ab;rAVZPG70vQ=!)etk4UX+yIXqF-~6biU&!YKbcXpq z=Z4A9OUi^$GC9>^Qb+J*fNMS2#n_4pfBgveO73_u_V8*g>Bux7GHgL2IX2WKr*&L5 zR;UwQ!?WOSF^g>Q(*oA)ld6#X0GVa+3N~f*A&qe@2SuO^=ICzHB+a3rrIPVHa*;i> z3M~`E@gnIR=;?_nVGQSy%6v8Vc{{<=jb`hLz5Ybg?XkK|9_nstk0`@onds>J_lb)S zbt^9p3+0>c6{l8TkMPkXE4$VEEn(#-nTpB+UXkCQH`3HyrAU2WQiI;`)ezgJywLl?Lsf0$dqSDB zFPp|*Vjk&36`aMK*|~c$&f!5BN|*vW92a@*VvL2en%lf#2kqdkYDo~zLYvD2BVK4u zNmdTt2W6G!og1o$P@e5jD~FcI)6azUZfBY=AKFj)AZgih={Wc4{6Sr!e z#|ZS$s2p(-t+Qjkc* z9Ysihq)zU!$**2Vwj3NQjAadH)+lxP+Sr*92hPn5p|B84{t=casw~Y0X*{=nKxpuC}((Ep#JD* z`wRUC8Mti&uOolKTvoYZ9GUt(9CZUNd9G0iz-_tG=&W=qGew%FPyNosSGpJIv_oYy zdDYBmEVCP!WJItXOdo8_AiZ$6LGhZ}$(Vk|iQ};tk*F)1utSj0A4I~uycMMFf^Ja| z0Su*j&9ejrIf-t{OvRljQmo+|hwoNF6cWE&r|2h}%`%@C_Ys(P-q)}$)IPe)hRDzyrP;#-4GuBHMol-cV6K*jc_&UT`XR^eddM!@dFZicXqxlM_q)D66mBb->m}v*zDvGR zEq~-TVh(t77Eva29P91{&4qZOgF>OsTxDYL=# z5F)=q>!`xDvZz3{8{0 z>^Ayv_a{w>?43VA%sKB8+cC}am(M+1@>4ObX_~8;mUPa#b_TV^red@6jo=M*%Z(>E zu`icI!A8;t*U-2$;36K^yn0RPWT#Bq?u@Sv@jUdUoAQ<=K1J5eMq>S#va*AaXvAM~pCojzYB0D_-8r;`NpLXj=cH;=@Y^CC_Ph*DvRn{9dT`lR{BXI zHM;jHQ`Cx>-e_Y`{i|M&RmJwTcbM$+J#hpgjcMJzAWKLG#Brqzi3uk3eswptgmkII1!MSHVz-LDls;uNP%wH)U zU#tS7hWEqN1ol1j-bW4)e1U%hhT=a06$F$HSR4KO$1whJTmESO%|jV-Qhz1*t7-fX zC;$)#)W)AI Date: Fri, 19 Dec 2025 17:31:52 -0300 Subject: [PATCH 09/53] feat: compute text similarity using Levenshtein distance --- .../diffing/algorithm/paragraph-diffing.js | 6 +-- .../diffing/algorithm/similarity.js | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/similarity.js diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index 7b3fb6011..3e05cc614 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -1,5 +1,6 @@ import { myersDiff } from './myers-diff.js'; import { getTextDiff } from './text-diffing.js'; +import { levenshteinDistance } from './similarity.js'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. const SIMILARITY_THRESHOLD = 0.65; @@ -222,10 +223,9 @@ function getTextSimilarityScore(oldText, newText) { return 1; } - const operations = myersDiff(oldText, newText, (a, b) => a === b); - const edits = operations.filter((op) => op !== 'equal').length; + const distance = levenshteinDistance(oldText, newText); const maxLength = Math.max(oldText.length, newText.length) || 1; - return 1 - edits / maxLength; // Proportion of unchanged characters + return 1 - distance / maxLength; } /** diff --git a/packages/super-editor/src/extensions/diffing/algorithm/similarity.js b/packages/super-editor/src/extensions/diffing/algorithm/similarity.js new file mode 100644 index 000000000..d0118aabd --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/similarity.js @@ -0,0 +1,42 @@ +/** + * Computes the Levenshtein edit distance between two strings. + * @param {string} a + * @param {string} b + * @returns {number} + */ +export function levenshteinDistance(a, b) { + const lenA = a.length; + const lenB = b.length; + + if (lenA === 0) { + return lenB; + } + if (lenB === 0) { + return lenA; + } + + let previous = new Array(lenB + 1); + let current = new Array(lenB + 1); + + for (let j = 0; j <= lenB; j += 1) { + previous[j] = j; + } + + for (let i = 1; i <= lenA; i += 1) { + current[0] = i; + const charA = a[i - 1]; + + for (let j = 1; j <= lenB; j += 1) { + const charB = b[j - 1]; + const cost = charA === charB ? 0 : 1; + const deletion = previous[j] + 1; + const insertion = current[j - 1] + 1; + const substitution = previous[j - 1] + cost; + current[j] = Math.min(deletion, insertion, substitution); + } + + [previous, current] = [current, previous]; + } + + return previous[lenB]; +} From 46236f7bef5c1943e318176d0c4e18254eb772dd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 22 Dec 2025 14:23:49 -0300 Subject: [PATCH 10/53] feat: identify contiguous text changes as single operation --- .../diffing/algorithm/text-diffing.js | 44 ++++++++++++++++++- .../diffing/algorithm/text-diffing.test.js | 33 +++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js index c3f426aff..43781c0fa 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js @@ -17,7 +17,8 @@ export function getTextDiff(oldText, newText, oldPositionResolver, newPositionRe } const operations = myersDiff(oldText, newText, (a, b) => a === b); - return buildDiffFromOperations(operations, oldText, newText, oldPositionResolver, newPositionResolver); + const normalizedOperations = reorderTextOperations(operations); + return buildDiffFromOperations(normalizedOperations, oldText, newText, oldPositionResolver, newPositionResolver); } /** @@ -100,3 +101,44 @@ function buildDiffFromOperations(operations, oldText, newText, oldPositionResolv return diffs; } + +/** + * Normalizes the Myers operation list so contiguous edit regions are represented by single delete/insert runs. + * Myers may emit interleaved delete/insert steps for a single contiguous region, which would otherwise show up as + * multiple discrete diff entries even though the user edited one continuous block. + * @param {Array<'equal'|'delete'|'insert'>} operations + * @returns {Array<'equal'|'delete'|'insert'>} + */ +function reorderTextOperations(operations) { + const normalized = []; + + for (let i = 0; i < operations.length; i += 1) { + const op = operations[i]; + if (op === 'equal') { + normalized.push(op); + continue; + } + + let deleteCount = 0; + let insertCount = 0; + while (i < operations.length && operations[i] !== 'equal') { + if (operations[i] === 'delete') { + deleteCount += 1; + } else if (operations[i] === 'insert') { + insertCount += 1; + } + i += 1; + } + + for (let k = 0; k < deleteCount; k += 1) { + normalized.push('delete'); + } + for (let k = 0; k < insertCount; k += 1) { + normalized.push('insert'); + } + + i -= 1; // account for the for-loop increment since we advanced i while counting + } + + return normalized; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js index 40e5d794a..c3b6e84c4 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js @@ -1,5 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +vi.mock('./myers-diff.js', async () => { + const actual = await vi.importActual('./myers-diff.js'); + return { + myersDiff: vi.fn(actual.myersDiff), + }; +}); import { getTextDiff } from './text-diffing'; +import { myersDiff } from './myers-diff.js'; describe('getTextDiff', () => { it('returns an empty diff list when both strings are identical', () => { @@ -47,4 +54,28 @@ describe('getTextDiff', () => { }, ]); }); + + it('merges interleaved delete/insert steps within a contiguous change', () => { + const oldResolver = (index) => index + 1; + const newResolver = (index) => index + 50; + const customOperations = ['delete', 'insert', 'delete', 'insert']; + myersDiff.mockImplementationOnce(() => customOperations); + + const diffs = getTextDiff('ab', 'XY', oldResolver, newResolver); + + expect(diffs).toEqual([ + { + type: 'deletion', + startIdx: 1, + endIdx: 3, + text: 'ab', + }, + { + type: 'addition', + startIdx: 50, + endIdx: 52, + text: 'XY', + }, + ]); + }); }); From 89f0e7a44e506df9d964cb3e3c94a49768d0440c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Dec 2025 11:49:46 -0300 Subject: [PATCH 11/53] feat: implement logic for diffing paragraph attributes --- .../diffing/algorithm/attributes-diffing.js | 132 ++++++++++++++++++ .../algorithm/attributes-diffing.test.js | 125 +++++++++++++++++ .../diffing/algorithm/paragraph-diffing.js | 20 ++- .../extensions/diffing/computeDiff.test.js | 56 +++++++- 4 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js new file mode 100644 index 000000000..776fce854 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js @@ -0,0 +1,132 @@ +/** + * @typedef {Object} AttributesDiff + * @property {Record} added + * @property {Record} deleted + * @property {Record} modified + */ + +/** + * Computes the attribute level diff between two arbitrary objects. + * Produces a map of dotted paths to added, deleted and modified values. + * @param {Record} objectA + * @param {Record} objectB + * @returns {AttributesDiff|null} + */ +const IGNORED_ATTRIBUTE_KEYS = new Set(['sdBlockId']); + +export function getAttributesDiff(objectA = {}, objectB = {}) { + const diff = { + added: {}, + deleted: {}, + modified: {}, + }; + + diffObjects(objectA ?? {}, objectB ?? {}, '', diff); + const hasChanges = + Object.keys(diff.added).length > 0 || Object.keys(diff.deleted).length > 0 || Object.keys(diff.modified).length > 0; + + return hasChanges ? diff : null; +} + +/** + * Recursively compares two objects and fills the diff buckets. + * @param {Record} objectA + * @param {Record} objectB + * @param {string} basePath + * @param {AttributesDiff} diff + */ +function diffObjects(objectA, objectB, basePath, diff) { + const keys = new Set([...Object.keys(objectA || {}), ...Object.keys(objectB || {})]); + + for (const key of keys) { + if (IGNORED_ATTRIBUTE_KEYS.has(key)) { + continue; + } + + const path = joinPath(basePath, key); + const hasA = Object.prototype.hasOwnProperty.call(objectA, key); + const hasB = Object.prototype.hasOwnProperty.call(objectB, key); + + if (hasA && !hasB) { + recordDeletedValue(objectA[key], path, diff); + continue; + } + + if (!hasA && hasB) { + recordAddedValue(objectB[key], path, diff); + continue; + } + + const valueA = objectA[key]; + const valueB = objectB[key]; + + if (isPlainObject(valueA) && isPlainObject(valueB)) { + diffObjects(valueA, valueB, path, diff); + continue; + } + + if (valueA !== valueB) { + diff.modified[path] = { + from: valueA, + to: valueB, + }; + } + } +} + +/** + * Records a nested value as an addition, flattening objects into dotted paths. + * @param {any} value + * @param {string} path + * @param {{added: Record}} diff + */ +function recordAddedValue(value, path, diff) { + if (isPlainObject(value)) { + for (const [childKey, childValue] of Object.entries(value)) { + if (IGNORED_ATTRIBUTE_KEYS.has(childKey)) { + continue; + } + recordAddedValue(childValue, joinPath(path, childKey), diff); + } + return; + } + diff.added[path] = value; +} + +/** + * Records a nested value as a deletion, flattening objects into dotted paths. + * @param {any} value + * @param {string} path + * @param {{deleted: Record}} diff + */ +function recordDeletedValue(value, path, diff) { + if (isPlainObject(value)) { + for (const [childKey, childValue] of Object.entries(value)) { + if (IGNORED_ATTRIBUTE_KEYS.has(childKey)) { + continue; + } + recordDeletedValue(childValue, joinPath(path, childKey), diff); + } + return; + } + diff.deleted[path] = value; +} + +/** + * Builds dotted attribute paths. + * @param {string} base + * @param {string} key + * @returns {string} + */ +function joinPath(base, key) { + return base ? `${base}.${key}` : key; +} + +/** + * Determines if a value is a plain object (no arrays or nulls). + * @param {any} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js new file mode 100644 index 000000000..0b9316c83 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { getAttributesDiff } from './attributes-diffing.js'; + +describe('getAttributesDiff', () => { + it('detects nested additions, deletions, and modifications', () => { + const objectA = { + id: 1, + name: 'Alice', + age: 30, + config: { + theme: 'dark', + notifications: true, + additional: { + layout: 'grid', + itemsPerPage: 10, + }, + }, + }; + + const objectB = { + id: 1, + name: 'Alice Smith', + config: { + theme: 'light', + additional: { + layout: 'list', + itemsPerPage: 10, + showSidebar: true, + }, + }, + isActive: true, + }; + + const diff = getAttributesDiff(objectA, objectB); + + expect(diff).toEqual({ + added: { + isActive: true, + 'config.additional.showSidebar': true, + }, + deleted: { + age: 30, + 'config.notifications': true, + }, + modified: { + name: { from: 'Alice', to: 'Alice Smith' }, + 'config.theme': { from: 'dark', to: 'light' }, + 'config.additional.layout': { from: 'grid', to: 'list' }, + }, + }); + }); + + it('returns empty diff when objects are identical', () => { + const objectA = { + name: 'Same', + config: { + theme: 'dark', + }, + }; + + const diff = getAttributesDiff(objectA, { ...objectA }); + + expect(diff).toBeNull(); + }); + + it('handles whole-object additions, removals, and non-object replacements', () => { + const objectA = { + profile: { + preferences: { + email: true, + }, + }, + options: { + advanced: { + mode: 'auto', + }, + }, + }; + + const objectB = { + profile: {}, + options: { + advanced: 'manual', + }, + flags: ['a'], + }; + + const diff = getAttributesDiff(objectA, objectB); + + expect(diff.added).toEqual({ + flags: ['a'], + }); + expect(diff.deleted).toEqual({ + 'profile.preferences.email': true, + }); + expect(diff.modified).toEqual({ + 'options.advanced': { from: { mode: 'auto' }, to: 'manual' }, + }); + }); + + it('ignores keys defined in the ignored attribute list', () => { + const objectA = { + sdBlockId: '123', + nested: { + sdBlockId: '456', + value: 1, + }, + }; + + const objectB = { + nested: { + sdBlockId: '789', + value: 2, + }, + }; + + const diff = getAttributesDiff(objectA, objectB); + + expect(diff.added).toEqual({}); + expect(diff.deleted).toEqual({}); + expect(diff.modified).toEqual({ + 'nested.value': { from: 1, to: 2 }, + }); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index 3e05cc614..f093b33a8 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -1,5 +1,6 @@ import { myersDiff } from './myers-diff.js'; import { getTextDiff } from './text-diffing.js'; +import { getAttributesDiff } from './attributes-diffing.js'; import { levenshteinDistance } from './similarity.js'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. @@ -32,6 +33,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; * @property {string} newText text after the edit * @property {number} pos original document position for anchoring UI * @property {Array} textDiffs granular inline diff data returned by `getTextDiff` + * @property {import('./attributes-diffing.js').AttributesDiff|null} attrsDiff attribute-level changes between the old and new paragraph nodes */ /** @@ -79,10 +81,13 @@ export function diffParagraphs(oldParagraphs, newParagraphs) { case 'equal': const oldPara = oldParagraphs[step.oldIdx]; const newPara = newParagraphs[step.newIdx]; - if (oldPara.text !== newPara.text) { - // Text changed within the same paragraph + if ( + oldPara.text !== newPara.text || + JSON.stringify(oldPara.node.attrs) !== JSON.stringify(newPara.node.attrs) + ) { + // Text or attributes changed within the same paragraph const diff = buildModifiedParagraphDiff(oldPara, newPara); - if (diff.textDiffs.length > 0) { + if (diff) { diffs.push(diff); } } @@ -97,7 +102,7 @@ export function diffParagraphs(oldParagraphs, newParagraphs) { const newPara = newParagraphs[nextStep.newIdx]; if (canTreatAsModification(oldPara, newPara)) { const diff = buildModifiedParagraphDiff(oldPara, newPara); - if (diff.textDiffs.length > 0) { + if (diff) { diffs.push(diff); } i += 1; // Skip the next insert step as it's paired @@ -179,12 +184,19 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { newParagraph.resolvePosition, ); + const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); + + if (textDiffs.length === 0 && !attrsDiff) { + return null; + } + return { type: 'modified', oldText: oldParagraph.text, newText: newParagraph.text, pos: oldParagraph.pos, textDiffs, + attrsDiff, }; } diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index fcd1772f7..e1e707658 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -5,6 +5,9 @@ import { Editor } from '@core/Editor.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; +import { ChangeSet } from 'prosemirror-changeset'; +import { Transform } from 'prosemirror-transform'; + const getDocument = async (name) => { const buffer = await getTestDataAsBuffer(name); const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); @@ -32,10 +35,19 @@ describe('Diff', () => { const diffs = computeDiff(docBefore, docAfter); const getDiff = (type, predicate) => diffs.find((diff) => diff.type === type && predicate(diff)); - expect(diffs).toHaveLength(15); - expect(diffs.filter((diff) => diff.type === 'modified')).toHaveLength(5); - expect(diffs.filter((diff) => diff.type === 'added')).toHaveLength(5); - expect(diffs.filter((diff) => diff.type === 'deleted')).toHaveLength(5); + const modifiedDiffs = diffs.filter((diff) => diff.type === 'modified'); + const addedDiffs = diffs.filter((diff) => diff.type === 'added'); + const deletedDiffs = diffs.filter((diff) => diff.type === 'deleted'); + const attrOnlyDiffs = modifiedDiffs.filter((diff) => diff.textDiffs.length === 0); + + expect(diffs).toHaveLength(19); + expect(modifiedDiffs).toHaveLength(9); + expect(addedDiffs).toHaveLength(5); + expect(deletedDiffs).toHaveLength(5); + expect(attrOnlyDiffs).toHaveLength(4); + attrOnlyDiffs.forEach((diff) => { + expect(diff.attrsDiff).not.toBeNull(); + }); // Modified paragraph with multiple text diffs let diff = getDiff( @@ -109,6 +121,12 @@ describe('Diff', () => { (diff) => diff.text === 'Aenean hendrerit elit vitae sem fermentum, vel sagittis erat gravida.', ); expect(movedParagraph).toBeDefined(); + + // Attribute-only paragraph change + const namParagraph = attrOnlyDiffs.find( + (diff) => diff.oldText === 'Nam ultricies velit vitae purus eleifend pellentesque.', + ); + expect(namParagraph?.attrsDiff?.modified).toBeDefined(); }); it('Compare two documents with simple changes', async () => { @@ -123,6 +141,7 @@ describe('Diff', () => { expect(diff.newText).toBe('Here’s some NEW text.'); expect(diff.textDiffs).toHaveLength(1); expect(diff.textDiffs[0].text).toBe('NEW '); + expect(diff.attrsDiff?.modified?.textId).toBeDefined(); diff = diffs.find((diff) => diff.type === 'deleted' && diff.oldText === 'I deleted this sentence.'); expect(diff).toBeDefined(); @@ -133,5 +152,34 @@ describe('Diff', () => { diff = diffs.find((diff) => diff.type === 'modified' && diff.oldText === 'We are not done yet.'); expect(diff.newText).toBe('We are done now.'); expect(diff.textDiffs).toHaveLength(3); + expect(diff.attrsDiff?.modified?.textId).toBeDefined(); }); + + // it.only('Test prosemirror-changeset', async () => { + // const docA = await getDocument('diff_before.docx'); + // const docB = await getDocument('diff_after.docx'); + // + // // Produce StepMaps that turn A into B + // const tr = new Transform(docA) + // tr.replaceWith(0, docA.content.size, docB.content) + // + // // Diff them: metadata tags each span with the author + // const encoder = { + // encodeCharacter: (char, marks) => (JSON.stringify({type: "char", char, marks})), + // encodeNodeStart: node => (JSON.stringify({type: "open", name: node.type.name, attrs: node.attrs})), + // encodeNodeEnd: node => (JSON.stringify({type: "close", name: node.type.name})), + // compareTokens: (a, b) => JSON.stringify(a) === JSON.stringify(b) + // } + // const originalChangeSet = ChangeSet + // .create(docA, (a, b) => a === b ? a : null, encoder); + // + // debugger; + // const changeSet = originalChangeSet + // .addSteps(docB, tr.mapping.maps) + // + // // Inspect the replacements + // for (const change of changeSet.changes) { + // console.log(JSON.stringify(change, null, 2)); + // } + // }); }); From 30f6d8f48f002750e010091b45ef669205fb5532 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Dec 2025 13:58:04 -0300 Subject: [PATCH 12/53] refactor: extract generic sequence diffing helper --- .../diffing/algorithm/sequence-diffing.js | 117 ++++++++++++++++++ .../algorithm/sequence-diffing.test.js | 73 +++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js new file mode 100644 index 000000000..523599086 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js @@ -0,0 +1,117 @@ +import { myersDiff } from './myers-diff.js'; + +/** + * @typedef {Object} SequenceDiffOptions + * @property {(a: any, b: any) => boolean} [comparator] equality test passed to Myers diff + * @property {(item: any, index: number) => any} buildAdded maps newly inserted entries + * @property {(item: any, index: number) => any} buildDeleted maps removed entries + * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => any|null} buildModified maps paired entries + * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [shouldProcessEqual] decides if equal-aligned entries should emit a modification + * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [canTreatAsModification] determines if delete/insert pairs are modifications + * @property {(operations: Array<'equal'|'delete'|'insert'>) => Array<'equal'|'delete'|'insert'>} [reorderOperations] optional hook to normalize raw Myers operations + */ + +/** + * Generic sequence diff helper built on top of Myers algorithm. + * Allows callers to provide custom comparators and payload builders that determine how + * additions, deletions, and modifications should be reported. + * @param {Array} oldSeq + * @param {Array} newSeq + * @param {SequenceDiffOptions} options + * @returns {Array} + */ +export function diffSequences(oldSeq, newSeq, options) { + if (!options) { + throw new Error('diffSequences requires an options object.'); + } + + const comparator = options.comparator ?? ((a, b) => a === b); + const reorder = options.reorderOperations ?? ((ops) => ops); + const canTreatAsModification = options.canTreatAsModification; + const shouldProcessEqual = options.shouldProcessEqual; + + if (typeof options.buildAdded !== 'function') { + throw new Error('diffSequences requires a buildAdded option.'); + } + if (typeof options.buildDeleted !== 'function') { + throw new Error('diffSequences requires a buildDeleted option.'); + } + if (typeof options.buildModified !== 'function') { + throw new Error('diffSequences requires a buildModified option.'); + } + + const operations = reorder(myersDiff(oldSeq, newSeq, comparator)); + const steps = buildOperationSteps(operations); + + const diffs = []; + for (let i = 0; i < steps.length; i += 1) { + const step = steps[i]; + + if (step.type === 'equal') { + if (!shouldProcessEqual) { + continue; + } + const oldItem = oldSeq[step.oldIdx]; + const newItem = newSeq[step.newIdx]; + if (!shouldProcessEqual(oldItem, newItem, step.oldIdx, step.newIdx)) { + continue; + } + const diff = options.buildModified(oldItem, newItem, step.oldIdx, step.newIdx); + if (diff) { + diffs.push(diff); + } + continue; + } + + if (step.type === 'delete') { + const nextStep = steps[i + 1]; + if ( + nextStep?.type === 'insert' && + typeof canTreatAsModification === 'function' && + canTreatAsModification(oldSeq[step.oldIdx], newSeq[nextStep.newIdx], step.oldIdx, nextStep.newIdx) + ) { + const diff = options.buildModified(oldSeq[step.oldIdx], newSeq[nextStep.newIdx], step.oldIdx, nextStep.newIdx); + if (diff) { + diffs.push(diff); + } + i += 1; + } else { + diffs.push(options.buildDeleted(oldSeq[step.oldIdx], step.oldIdx)); + } + continue; + } + + if (step.type === 'insert') { + diffs.push(options.buildAdded(newSeq[step.newIdx], step.newIdx)); + } + } + + return diffs; +} + +/** + * Translates the raw Myers operations into indexed steps so higher-level logic can reason about positions. + * @param {Array<'equal'|'delete'|'insert'>} operations + * @returns {Array} + */ +function buildOperationSteps(operations) { + let oldIdx = 0; + let newIdx = 0; + const steps = []; + + for (const op of operations) { + if (op === 'equal') { + steps.push({ type: 'equal', oldIdx, newIdx }); + oldIdx += 1; + newIdx += 1; + } else if (op === 'delete') { + steps.push({ type: 'delete', oldIdx }); + oldIdx += 1; + } else if (op === 'insert') { + steps.push({ type: 'insert', newIdx }); + newIdx += 1; + } + } + + return steps; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js new file mode 100644 index 000000000..1d7071ff1 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { diffSequences } from './sequence-diffing.js'; + +const buildAdded = (item) => ({ type: 'added', id: item.id }); +const buildDeleted = (item) => ({ type: 'deleted', id: item.id }); +const buildModified = (oldItem, newItem) => ({ + type: 'modified', + id: oldItem.id ?? newItem.id, + from: oldItem.value, + to: newItem.value, +}); + +describe('diffSequences', () => { + it('detects modifications for equal-aligned items when requested', () => { + const oldSeq = [ + { id: 'a', value: 'Hello' }, + { id: 'b', value: 'World' }, + ]; + const newSeq = [ + { id: 'a', value: 'Hello' }, + { id: 'b', value: 'World!!!' }, + ]; + + const diffs = diffSequences(oldSeq, newSeq, { + comparator: (a, b) => a.id === b.id, + shouldProcessEqual: (oldItem, newItem) => oldItem.value !== newItem.value, + buildAdded, + buildDeleted, + buildModified, + }); + + expect(diffs).toEqual([{ type: 'modified', id: 'b', from: 'World', to: 'World!!!' }]); + }); + + it('pairs delete/insert operations into modifications when allowed', () => { + const oldSeq = [ + { id: 'a', value: 'Alpha' }, + { id: 'b', value: 'Beta' }, + ]; + const newSeq = [ + { id: 'a', value: 'Alpha' }, + { id: 'c', value: 'Beta v2' }, + ]; + + const diffs = diffSequences(oldSeq, newSeq, { + comparator: (a, b) => a.id === b.id, + canTreatAsModification: (oldItem, newItem) => oldItem.value[0] === newItem.value[0], + shouldProcessEqual: () => false, + buildAdded, + buildDeleted, + buildModified, + }); + + expect(diffs).toEqual([{ type: 'modified', id: 'b', from: 'Beta', to: 'Beta v2' }]); + }); + + it('emits additions and deletions when items cannot be paired', () => { + const oldSeq = [{ id: 'a', value: 'Foo' }]; + const newSeq = [{ id: 'b', value: 'Bar' }]; + + const diffs = diffSequences(oldSeq, newSeq, { + comparator: (a, b) => a.id === b.id, + buildAdded, + buildDeleted, + buildModified, + }); + + expect(diffs).toEqual([ + { type: 'deleted', id: 'a' }, + { type: 'added', id: 'b' }, + ]); + }); +}); From f1cde923fdbf214223806d4fb367f3f5b931e925 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Dec 2025 14:08:51 -0300 Subject: [PATCH 13/53] refactor: modify paragraph diffing to reuse generic helper --- .../diffing/algorithm/paragraph-diffing.js | 85 +++---------------- 1 file changed, 12 insertions(+), 73 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index f093b33a8..6ae7a889c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -1,6 +1,7 @@ import { myersDiff } from './myers-diff.js'; import { getTextDiff } from './text-diffing.js'; import { getAttributesDiff } from './attributes-diffing.js'; +import { diffSequences } from './sequence-diffing.js'; import { levenshteinDistance } from './similarity.js'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. @@ -50,79 +51,17 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; * @returns {Array} */ export function diffParagraphs(oldParagraphs, newParagraphs) { - // Run Myers diff on the paragraph level to get a base set of operations. - const rawOperations = myersDiff(oldParagraphs, newParagraphs, paragraphComparator); - const operations = reorderParagraphOperations(rawOperations); - - // Build a step-by-step operation list with paragraph indices for easier processing. - let oldIdx = 0; - let newIdx = 0; - const steps = []; - for (const op of operations) { - if (op === 'equal') { - steps.push({ type: 'equal', oldIdx, newIdx }); - oldIdx += 1; - newIdx += 1; - } else if (op === 'delete') { - steps.push({ type: 'delete', oldIdx }); - oldIdx += 1; - } else if (op === 'insert') { - steps.push({ type: 'insert', newIdx }); - newIdx += 1; - } - } - - // Process the operation steps into a normalized diff output. - const diffs = []; - for (let i = 0; i < steps.length; i += 1) { - const step = steps[i]; - - switch (step.type) { - case 'equal': - const oldPara = oldParagraphs[step.oldIdx]; - const newPara = newParagraphs[step.newIdx]; - if ( - oldPara.text !== newPara.text || - JSON.stringify(oldPara.node.attrs) !== JSON.stringify(newPara.node.attrs) - ) { - // Text or attributes changed within the same paragraph - const diff = buildModifiedParagraphDiff(oldPara, newPara); - if (diff) { - diffs.push(diff); - } - } - break; - - case 'delete': - const nextStep = steps[i + 1]; - - // Check if the next step is an insertion that can be paired as a modification. - if (nextStep?.type === 'insert') { - const oldPara = oldParagraphs[step.oldIdx]; - const newPara = newParagraphs[nextStep.newIdx]; - if (canTreatAsModification(oldPara, newPara)) { - const diff = buildModifiedParagraphDiff(oldPara, newPara); - if (diff) { - diffs.push(diff); - } - i += 1; // Skip the next insert step as it's paired - } else { - // The paragraph that was deleted is significantly different from any nearby insertions; treat as a deletion. - diffs.push(buildDeletedParagraphDiff(oldParagraphs[step.oldIdx])); - } - } else { - // No matching insertion; treat as a deletion. - diffs.push(buildDeletedParagraphDiff(oldParagraphs[step.oldIdx])); - } - break; - - case 'insert': - diffs.push(buildAddedParagraphDiff(newParagraphs[step.newIdx])); - break; - } - } - - return diffs; + return diffSequences(oldParagraphs, newParagraphs, { + comparator: paragraphComparator, + reorderOperations: reorderParagraphOperations, + shouldProcessEqual: (oldParagraph, newParagraph) => + oldParagraph.text !== newParagraph.text || + JSON.stringify(oldParagraph.node.attrs) !== JSON.stringify(newParagraph.node.attrs), + canTreatAsModification, + buildAdded: (paragraph) => buildAddedParagraphDiff(paragraph), + buildDeleted: (paragraph) => buildDeletedParagraphDiff(paragraph), + buildModified: (oldParagraph, newParagraph) => buildModifiedParagraphDiff(oldParagraph, newParagraph), + }); } /** From ea3e98a05a488966bbf2fbad0da53e867bbc5bc7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Dec 2025 17:19:14 -0300 Subject: [PATCH 14/53] refactor: extract operation reordering function This function can then be reused when diffing paragraphs and runs. It helps identifying modifications instead of delete/insert pairs --- .../diffing/algorithm/paragraph-diffing.js | 42 +---------------- .../diffing/algorithm/sequence-diffing.js | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index 6ae7a889c..3ff1e4400 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -1,7 +1,7 @@ import { myersDiff } from './myers-diff.js'; import { getTextDiff } from './text-diffing.js'; import { getAttributesDiff } from './attributes-diffing.js'; -import { diffSequences } from './sequence-diffing.js'; +import { diffSequences, reorderDiffOperations } from './sequence-diffing.js'; import { levenshteinDistance } from './similarity.js'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. @@ -187,42 +187,4 @@ function getTextSimilarityScore(oldText, newText) { * @param {Array<'equal'|'delete'|'insert'>} operations * @returns {Array<'equal'|'delete'|'insert'>} */ -function reorderParagraphOperations(operations) { - const normalized = []; - - for (let i = 0; i < operations.length; i += 1) { - const op = operations[i]; - if (op !== 'delete') { - normalized.push(op); - continue; - } - - let deleteCount = 0; - while (i < operations.length && operations[i] === 'delete') { - deleteCount += 1; - i += 1; - } - - let insertCount = 0; - let insertCursor = i; - while (insertCursor < operations.length && operations[insertCursor] === 'insert') { - insertCount += 1; - insertCursor += 1; - } - - const pairCount = Math.min(deleteCount, insertCount); - for (let k = 0; k < pairCount; k += 1) { - normalized.push('delete', 'insert'); - } - for (let k = pairCount; k < deleteCount; k += 1) { - normalized.push('delete'); - } - for (let k = pairCount; k < insertCount; k += 1) { - normalized.push('insert'); - } - - i = insertCursor - 1; - } - - return normalized; -} +const reorderParagraphOperations = reorderDiffOperations; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js index 523599086..a8d7a9c9d 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js @@ -5,7 +5,7 @@ import { myersDiff } from './myers-diff.js'; * @property {(a: any, b: any) => boolean} [comparator] equality test passed to Myers diff * @property {(item: any, index: number) => any} buildAdded maps newly inserted entries * @property {(item: any, index: number) => any} buildDeleted maps removed entries - * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => any|null} buildModified maps paired entries + * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => any|null} buildModified maps paired entries. If it returns null/undefined, it means no modification should be recorded. * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [shouldProcessEqual] decides if equal-aligned entries should emit a modification * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [canTreatAsModification] determines if delete/insert pairs are modifications * @property {(operations: Array<'equal'|'delete'|'insert'>) => Array<'equal'|'delete'|'insert'>} [reorderOperations] optional hook to normalize raw Myers operations @@ -115,3 +115,48 @@ function buildOperationSteps(operations) { return steps; } + +/** + * Normalizes interleaved delete/insert operations so consumers can treat replacements as paired steps. + * @param {Array<'equal'|'delete'|'insert'>} operations + * @returns {Array<'equal'|'delete'|'insert'>} + */ +export function reorderDiffOperations(operations) { + const normalized = []; + + for (let i = 0; i < operations.length; i += 1) { + const op = operations[i]; + if (op !== 'delete') { + normalized.push(op); + continue; + } + + let deleteCount = 0; + while (i < operations.length && operations[i] === 'delete') { + deleteCount += 1; + i += 1; + } + + let insertCount = 0; + let insertCursor = i; + while (insertCursor < operations.length && operations[insertCursor] === 'insert') { + insertCount += 1; + insertCursor += 1; + } + + const pairCount = Math.min(deleteCount, insertCount); + for (let k = 0; k < pairCount; k += 1) { + normalized.push('delete', 'insert'); + } + for (let k = pairCount; k < deleteCount; k += 1) { + normalized.push('delete'); + } + for (let k = pairCount; k < insertCount; k += 1) { + normalized.push('insert'); + } + + i = insertCursor - 1; + } + + return normalized; +} From c918f106c882b9c5e649595995da85e38c891c95 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 23 Dec 2025 17:20:42 -0300 Subject: [PATCH 15/53] fix: standardize positions for text diffing Always maps starting/ending positions to the old document instead of the new one. --- .../diffing/algorithm/text-diffing.js | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js index 43781c0fa..85d894733 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js @@ -46,26 +46,15 @@ function buildDiffFromOperations(operations, oldText, newText, oldPositionResolv return; } - if (change.type === 'delete') { - const startIdx = resolveOld(change.startOldIdx); - const endIdx = resolveOld(change.endOldIdx); - diffs.push({ - type: 'deletion', - startIdx, - endIdx, - text: change.text, - }); - } else if (change.type === 'insert') { - const startIdx = resolveNew(change.startNewIdx); - const endIdx = resolveNew(change.endNewIdx); - diffs.push({ - type: 'addition', - startIdx, - endIdx, - text: change.text, - }); - } - + const startPos = resolveOld(change.startIdx); + const endPos = resolveOld(change.endIdx); + + diffs.push({ + type: change.type, + startPos, + endPos, + text: change.text, + }); change = null; }; @@ -79,21 +68,16 @@ function buildDiffFromOperations(operations, oldText, newText, oldPositionResolv if (!change || change.type !== op) { flushChange(); - if (op === 'delete') { - change = { type: 'delete', startOldIdx: oldIdx, endOldIdx: oldIdx, text: '' }; - } else if (op === 'insert') { - change = { type: 'insert', startNewIdx: newIdx, endNewIdx: newIdx, text: '' }; - } + change = { type: op, startIdx: oldIdx, endIdx: oldIdx, text: '' }; } if (op === 'delete') { change.text += oldText[oldIdx]; + change.endIdx += 1; oldIdx += 1; - change.endOldIdx = oldIdx; } else if (op === 'insert') { change.text += newText[newIdx]; newIdx += 1; - change.endNewIdx = newIdx; } } From f71179f12e085e1323374fdcd0d8c333a866a125 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 26 Dec 2025 11:48:25 -0300 Subject: [PATCH 16/53] refactor: change text diffing logic to account for formatting --- .../diffing/algorithm/paragraph-diffing.js | 31 +-- .../algorithm/paragraph-diffing.test.js | 23 +- .../diffing/algorithm/sequence-diffing.js | 16 +- .../algorithm/sequence-diffing.test.js | 4 +- .../diffing/algorithm/text-diffing.js | 203 +++++++++--------- .../diffing/algorithm/text-diffing.test.js | 84 +++++--- .../extensions/diffing/computeDiff.test.js | 53 ++--- .../src/extensions/diffing/utils.js | 22 +- .../src/extensions/diffing/utils.test.js | 125 +++++------ .../src/tests/data/diff_after3.docx | Bin 0 -> 13380 bytes .../src/tests/data/diff_after4.docx | Bin 0 -> 13345 bytes .../src/tests/data/diff_before3.docx | Bin 0 -> 13370 bytes .../src/tests/data/diff_before4.docx | Bin 0 -> 13370 bytes 13 files changed, 287 insertions(+), 274 deletions(-) create mode 100644 packages/super-editor/src/tests/data/diff_after3.docx create mode 100644 packages/super-editor/src/tests/data/diff_after4.docx create mode 100644 packages/super-editor/src/tests/data/diff_before3.docx create mode 100644 packages/super-editor/src/tests/data/diff_before4.docx diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index 3ff1e4400..feeafecd4 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -53,9 +53,9 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; export function diffParagraphs(oldParagraphs, newParagraphs) { return diffSequences(oldParagraphs, newParagraphs, { comparator: paragraphComparator, - reorderOperations: reorderParagraphOperations, - shouldProcessEqual: (oldParagraph, newParagraph) => - oldParagraph.text !== newParagraph.text || + reorderOperations: reorderDiffOperations, + shouldProcessEqualAsModification: (oldParagraph, newParagraph) => + JSON.stringify(oldParagraph.text) !== JSON.stringify(newParagraph.text) || JSON.stringify(oldParagraph.node.attrs) !== JSON.stringify(newParagraph.node.attrs), canTreatAsModification, buildAdded: (paragraph) => buildAddedParagraphDiff(paragraph), @@ -78,7 +78,7 @@ function paragraphComparator(oldParagraph, newParagraph) { if (oldId && newId && oldId === newId) { return true; } - return oldParagraph?.text === newParagraph?.text; + return oldParagraph?.fullText === newParagraph?.fullText; } /** @@ -90,7 +90,7 @@ function buildAddedParagraphDiff(paragraph) { return { type: 'added', node: paragraph.node, - text: paragraph.text, + text: paragraph.fullText, pos: paragraph.pos, }; } @@ -104,7 +104,7 @@ function buildDeletedParagraphDiff(paragraph) { return { type: 'deleted', node: paragraph.node, - oldText: paragraph.text, + oldText: paragraph.fullText, pos: paragraph.pos, }; } @@ -124,15 +124,14 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { ); const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); - if (textDiffs.length === 0 && !attrsDiff) { return null; } return { type: 'modified', - oldText: oldParagraph.text, - newText: newParagraph.text, + oldText: oldParagraph.fullText, + newText: newParagraph.fullText, pos: oldParagraph.pos, textDiffs, attrsDiff, @@ -151,8 +150,8 @@ function canTreatAsModification(oldParagraph, newParagraph) { return true; } - const oldText = oldParagraph?.text ?? ''; - const newText = newParagraph?.text ?? ''; + const oldText = oldParagraph.fullText; + const newText = newParagraph.fullText; const maxLength = Math.max(oldText.length, newText.length); if (maxLength < MIN_LENGTH_FOR_SIMILARITY) { return false; @@ -178,13 +177,3 @@ function getTextSimilarityScore(oldText, newText) { const maxLength = Math.max(oldText.length, newText.length) || 1; return 1 - distance / maxLength; } - -/** - * Normalizes Myers diff operations for paragraph comparisons so consecutive replacements are easier to classify. - * Myers tends to emit all deletes before inserts when a paragraph is replaced, even if it's a one-for-one swap, and - * that pattern would otherwise hide opportunities to treat those operations as modifications. Reordering the list here - * ensures higher-level diff logic stays simple while avoiding side effects for other consumers of the same operations. - * @param {Array<'equal'|'delete'|'insert'>} operations - * @returns {Array<'equal'|'delete'|'insert'>} - */ -const reorderParagraphOperations = reorderDiffOperations; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index 6c3edce93..f81180663 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -1,12 +1,23 @@ import { describe, it, expect } from 'vitest'; import { diffParagraphs } from './paragraph-diffing.js'; -const createParagraph = (text, attrs = {}) => ({ - node: { attrs }, - pos: attrs.pos ?? 0, - text, - resolvePosition: (index) => index, -}); +const buildTextRuns = (text, runAttrs = {}) => + text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs) })); + +const createParagraph = (text, attrs = {}, options = {}) => { + const { pos = 0, textAttrs = {} } = options; + const textRuns = buildTextRuns(text, textAttrs); + + return { + node: { attrs }, + pos, + text: textRuns, + resolvePosition: (index) => pos + 1 + index, + get fullText() { + return textRuns.map((c) => c.char).join(''); + }, + }; +}; describe('diffParagraphs', () => { it('treats similar paragraphs without IDs as modifications', () => { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js index a8d7a9c9d..68af0d854 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js @@ -6,7 +6,7 @@ import { myersDiff } from './myers-diff.js'; * @property {(item: any, index: number) => any} buildAdded maps newly inserted entries * @property {(item: any, index: number) => any} buildDeleted maps removed entries * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => any|null} buildModified maps paired entries. If it returns null/undefined, it means no modification should be recorded. - * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [shouldProcessEqual] decides if equal-aligned entries should emit a modification + * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [shouldProcessEqualAsModification] decides if equal-aligned entries should emit a modification * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [canTreatAsModification] determines if delete/insert pairs are modifications * @property {(operations: Array<'equal'|'delete'|'insert'>) => Array<'equal'|'delete'|'insert'>} [reorderOperations] optional hook to normalize raw Myers operations */ @@ -28,7 +28,7 @@ export function diffSequences(oldSeq, newSeq, options) { const comparator = options.comparator ?? ((a, b) => a === b); const reorder = options.reorderOperations ?? ((ops) => ops); const canTreatAsModification = options.canTreatAsModification; - const shouldProcessEqual = options.shouldProcessEqual; + const shouldProcessEqualAsModification = options.shouldProcessEqualAsModification; if (typeof options.buildAdded !== 'function') { throw new Error('diffSequences requires a buildAdded option.'); @@ -48,12 +48,12 @@ export function diffSequences(oldSeq, newSeq, options) { const step = steps[i]; if (step.type === 'equal') { - if (!shouldProcessEqual) { + if (!shouldProcessEqualAsModification) { continue; } const oldItem = oldSeq[step.oldIdx]; const newItem = newSeq[step.newIdx]; - if (!shouldProcessEqual(oldItem, newItem, step.oldIdx, step.newIdx)) { + if (!shouldProcessEqualAsModification(oldItem, newItem, step.oldIdx, step.newIdx)) { continue; } const diff = options.buildModified(oldItem, newItem, step.oldIdx, step.newIdx); @@ -76,13 +76,13 @@ export function diffSequences(oldSeq, newSeq, options) { } i += 1; } else { - diffs.push(options.buildDeleted(oldSeq[step.oldIdx], step.oldIdx)); + diffs.push(options.buildDeleted(oldSeq[step.oldIdx], step.oldIdx, step.newIdx)); } continue; } if (step.type === 'insert') { - diffs.push(options.buildAdded(newSeq[step.newIdx], step.newIdx)); + diffs.push(options.buildAdded(newSeq[step.newIdx], step.oldIdx, step.newIdx)); } } @@ -105,10 +105,10 @@ function buildOperationSteps(operations) { oldIdx += 1; newIdx += 1; } else if (op === 'delete') { - steps.push({ type: 'delete', oldIdx }); + steps.push({ type: 'delete', oldIdx, newIdx }); oldIdx += 1; } else if (op === 'insert') { - steps.push({ type: 'insert', newIdx }); + steps.push({ type: 'insert', oldIdx, newIdx }); newIdx += 1; } } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js index 1d7071ff1..4f771cd12 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js @@ -23,7 +23,7 @@ describe('diffSequences', () => { const diffs = diffSequences(oldSeq, newSeq, { comparator: (a, b) => a.id === b.id, - shouldProcessEqual: (oldItem, newItem) => oldItem.value !== newItem.value, + shouldProcessEqualAsModification: (oldItem, newItem) => oldItem.value !== newItem.value, buildAdded, buildDeleted, buildModified, @@ -45,7 +45,7 @@ describe('diffSequences', () => { const diffs = diffSequences(oldSeq, newSeq, { comparator: (a, b) => a.id === b.id, canTreatAsModification: (oldItem, newItem) => oldItem.value[0] === newItem.value[0], - shouldProcessEqual: () => false, + shouldProcessEqualAsModification: () => false, buildAdded, buildDeleted, buildModified, diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js index 85d894733..b6bff482b 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js @@ -1,128 +1,119 @@ import { myersDiff } from './myers-diff.js'; +import { getAttributesDiff } from './attributes-diffing.js'; +import { diffSequences } from './sequence-diffing.js'; /** * Computes text-level additions and deletions between two strings using Myers diff algorithm, mapping back to document positions. - * @param {string} oldText - Source text. - * @param {string} newText - Target text. + * @param {{char: string, runAttrs: Record}[]} oldText - Source text. + * @param {{char: string, runAttrs: Record}[]} newText - Target text. * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the original document. * @param {(index: number) => number|null} [newPositionResolver=oldPositionResolver] - Maps string indexes to the updated document. * @returns {Array} List of addition/deletion ranges with document positions and text content. */ export function getTextDiff(oldText, newText, oldPositionResolver, newPositionResolver = oldPositionResolver) { - const oldLen = oldText.length; - const newLen = newText.length; - - if (oldLen === 0 && newLen === 0) { - return []; - } - - const operations = myersDiff(oldText, newText, (a, b) => a === b); - const normalizedOperations = reorderTextOperations(operations); - return buildDiffFromOperations(normalizedOperations, oldText, newText, oldPositionResolver, newPositionResolver); + const buildCharDiff = (type, char, oldIdx) => ({ + type, + idx: oldIdx, + text: char.char, + runAttrs: char.runAttrs, + }); + let diffs = diffSequences(oldText, newText, { + comparator: (a, b) => a.char === b.char, + shouldProcessEqualAsModification: (oldChar, newChar) => oldChar.runAttrs !== newChar.runAttrs, + canTreatAsModification: (oldChar, newChar) => false, + buildAdded: (char, oldIdx, newIdx) => buildCharDiff('added', char, oldIdx), + buildDeleted: (char, oldIdx, newIdx) => buildCharDiff('deleted', char, oldIdx), + buildModified: (oldChar, newChar, oldIdx, newIdx) => ({ + type: 'modified', + idx: oldIdx, + newText: newChar.char, + oldText: oldChar.char, + oldAttrs: oldChar.runAttrs, + newAttrs: newChar.runAttrs, + }), + }); + + const groupedDiffs = groupDiffs(diffs, oldPositionResolver, newPositionResolver); + return groupedDiffs; } -/** - * Groups edit operations into contiguous additions/deletions and maps them to document positions. - * - * @param {Array<'equal'|'delete'|'insert'>} operations - Raw operation list produced by the backtracked Myers path. - * @param {string} oldText - Source text. - * @param {string} newText - Target text. - * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the previous document. - * @param {(index: number) => number|null} newPositionResolver - Maps string indexes to the updated document. - * @returns {Array} Final diff payload matching the existing API surface. - */ -function buildDiffFromOperations(operations, oldText, newText, oldPositionResolver, newPositionResolver) { - const diffs = []; - let change = null; - let oldIdx = 0; - let newIdx = 0; - const resolveOld = oldPositionResolver ?? (() => null); - const resolveNew = newPositionResolver ?? resolveOld; +function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { + const grouped = []; + let currentGroup = null; - /** Flushes the current change block into the diffs list. */ - const flushChange = () => { - if (!change || change.text.length === 0) { - change = null; - return; + const compareDiffs = (group, diff) => { + if (group.type !== diff.type) { + return false; } - - const startPos = resolveOld(change.startIdx); - const endPos = resolveOld(change.endIdx); - - diffs.push({ - type: change.type, - startPos, - endPos, - text: change.text, - }); - change = null; - }; - - for (const op of operations) { - if (op === 'equal') { - flushChange(); - oldIdx += 1; - newIdx += 1; - continue; - } - - if (!change || change.type !== op) { - flushChange(); - change = { type: op, startIdx: oldIdx, endIdx: oldIdx, text: '' }; + if (group.type === 'modified') { + return group.oldAttrs === diff.oldAttrs && group.newAttrs === diff.newAttrs; } + return group.runAttrs === diff.runAttrs; + }; - if (op === 'delete') { - change.text += oldText[oldIdx]; - change.endIdx += 1; - oldIdx += 1; - } else if (op === 'insert') { - change.text += newText[newIdx]; - newIdx += 1; - } - } - - flushChange(); - - return diffs; -} - -/** - * Normalizes the Myers operation list so contiguous edit regions are represented by single delete/insert runs. - * Myers may emit interleaved delete/insert steps for a single contiguous region, which would otherwise show up as - * multiple discrete diff entries even though the user edited one continuous block. - * @param {Array<'equal'|'delete'|'insert'>} operations - * @returns {Array<'equal'|'delete'|'insert'>} - */ -function reorderTextOperations(operations) { - const normalized = []; - - for (let i = 0; i < operations.length; i += 1) { - const op = operations[i]; - if (op === 'equal') { - normalized.push(op); - continue; + const comparePositions = (group, diff) => { + if (group.type === 'added') { + return group.startPos === oldPositionResolver(diff.idx); + } else { + return group.endPos + 1 === oldPositionResolver(diff.idx); } + }; - let deleteCount = 0; - let insertCount = 0; - while (i < operations.length && operations[i] !== 'equal') { - if (operations[i] === 'delete') { - deleteCount += 1; - } else if (operations[i] === 'insert') { - insertCount += 1; + for (const diff of diffs) { + if (currentGroup == null) { + currentGroup = { + type: diff.type, + startPos: oldPositionResolver(diff.idx), + endPos: oldPositionResolver(diff.idx), + }; + if (diff.type === 'modified') { + currentGroup.newText = diff.newText; + currentGroup.oldText = diff.oldText; + currentGroup.oldAttrs = diff.oldAttrs; + currentGroup.newAttrs = diff.newAttrs; + } else { + currentGroup.text = diff.text; + currentGroup.runAttrs = diff.runAttrs; + } + } else if (!compareDiffs(currentGroup, diff) || !comparePositions(currentGroup, diff)) { + grouped.push(currentGroup); + currentGroup = { + type: diff.type, + startPos: oldPositionResolver(diff.idx), + endPos: oldPositionResolver(diff.idx), + }; + if (diff.type === 'modified') { + currentGroup.newText = diff.newText; + currentGroup.oldText = diff.oldText; + currentGroup.oldAttrs = diff.oldAttrs; + currentGroup.newAttrs = diff.newAttrs; + } else { + currentGroup.text = diff.text; + currentGroup.runAttrs = diff.runAttrs; + } + } else { + currentGroup.endPos = oldPositionResolver(diff.idx); + if (diff.type === 'modified') { + currentGroup.newText += diff.newText; + currentGroup.oldText += diff.oldText; + } else { + currentGroup.text += diff.text; } - i += 1; - } - - for (let k = 0; k < deleteCount; k += 1) { - normalized.push('delete'); - } - for (let k = 0; k < insertCount; k += 1) { - normalized.push('insert'); } - - i -= 1; // account for the for-loop increment since we advanced i while counting } - return normalized; + if (currentGroup != null) grouped.push(currentGroup); + return grouped.map((group) => { + let ret = { ...group }; + if (group.type === 'modified') { + ret.oldAttrs = JSON.parse(group.oldAttrs); + ret.newAttrs = JSON.parse(group.newAttrs); + ret.runAttrsDiff = getAttributesDiff(ret.oldAttrs, ret.newAttrs); + delete ret.oldAttrs; + delete ret.newAttrs; + } else { + ret.runAttrs = JSON.parse(group.runAttrs); + } + return ret; + }); } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js index c3b6e84c4..9f3005a2a 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js @@ -5,14 +5,15 @@ vi.mock('./myers-diff.js', async () => { myersDiff: vi.fn(actual.myersDiff), }; }); -import { getTextDiff } from './text-diffing'; -import { myersDiff } from './myers-diff.js'; +import { getTextDiff } from './text-diffing.js'; + +const buildTextRuns = (text, runAttrs = {}) => + text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs) })); describe('getTextDiff', () => { it('returns an empty diff list when both strings are identical', () => { - const resolver = () => 0; - - const diffs = getTextDiff('unchanged', 'unchanged', resolver); + const resolver = (index) => index; + const diffs = getTextDiff(buildTextRuns('unchanged'), buildTextRuns('unchanged'), resolver); expect(diffs).toEqual([]); }); @@ -21,14 +22,15 @@ describe('getTextDiff', () => { const oldResolver = (index) => index + 10; const newResolver = (index) => index + 100; - const diffs = getTextDiff('abc', 'abXc', oldResolver, newResolver); + const diffs = getTextDiff(buildTextRuns('abc'), buildTextRuns('abXc'), oldResolver, newResolver); expect(diffs).toEqual([ { - type: 'addition', - startIdx: 102, - endIdx: 103, + type: 'added', + startPos: 12, + endPos: 12, text: 'X', + runAttrs: {}, }, ]); }); @@ -37,44 +39,66 @@ describe('getTextDiff', () => { const oldResolver = (index) => index + 5; const newResolver = (index) => index + 20; - const diffs = getTextDiff('abcd', 'abXYd', oldResolver, newResolver); + const diffs = getTextDiff(buildTextRuns('abcd'), buildTextRuns('abXYd'), oldResolver, newResolver); expect(diffs).toEqual([ { - type: 'deletion', - startIdx: 7, - endIdx: 8, + type: 'deleted', + startPos: 7, + endPos: 7, text: 'c', + runAttrs: {}, }, { - type: 'addition', - startIdx: 22, - endIdx: 24, + type: 'added', + startPos: 8, + endPos: 8, text: 'XY', + runAttrs: {}, }, ]); }); - it('merges interleaved delete/insert steps within a contiguous change', () => { - const oldResolver = (index) => index + 1; - const newResolver = (index) => index + 50; - const customOperations = ['delete', 'insert', 'delete', 'insert']; - myersDiff.mockImplementationOnce(() => customOperations); + it('marks attribute-only changes as modifications and surfaces attribute diffs', () => { + const resolver = (index) => index; - const diffs = getTextDiff('ab', 'XY', oldResolver, newResolver); + const diffs = getTextDiff(buildTextRuns('a', { bold: true }), buildTextRuns('a', { italic: true }), resolver); expect(diffs).toEqual([ { - type: 'deletion', - startIdx: 1, - endIdx: 3, - text: 'ab', + type: 'modified', + startPos: 0, + endPos: 0, + oldText: 'a', + newText: 'a', + runAttrsDiff: { + added: { italic: true }, + deleted: { bold: true }, + modified: {}, + }, }, + ]); + }); + + it('merges contiguous attribute edits that share the same diff metadata', () => { + const resolver = (index) => index + 5; + + const diffs = getTextDiff(buildTextRuns('ab', { bold: true }), buildTextRuns('ab', { bold: false }), resolver); + + expect(diffs).toEqual([ { - type: 'addition', - startIdx: 50, - endIdx: 52, - text: 'XY', + type: 'modified', + startPos: 5, + endPos: 6, + oldText: 'ab', + newText: 'ab', + runAttrsDiff: { + added: {}, + deleted: {}, + modified: { + bold: { from: true, to: false }, + }, + }, }, ]); }); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index e1e707658..d059891ea 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -5,9 +5,6 @@ import { Editor } from '@core/Editor.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; -import { ChangeSet } from 'prosemirror-changeset'; -import { Transform } from 'prosemirror-transform'; - const getDocument = async (name) => { const buffer = await getTestDataAsBuffer(name); const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); @@ -45,9 +42,6 @@ describe('Diff', () => { expect(addedDiffs).toHaveLength(5); expect(deletedDiffs).toHaveLength(5); expect(attrOnlyDiffs).toHaveLength(4); - attrOnlyDiffs.forEach((diff) => { - expect(diff.attrsDiff).not.toBeNull(); - }); // Modified paragraph with multiple text diffs let diff = getDiff( @@ -57,7 +51,9 @@ describe('Diff', () => { expect(diff?.newText).toBe( 'Curabitur facilisis ligula suscipit enim pretium et nunc ligula, porttitor augue consequat maximus.', ); - expect(diff?.textDiffs).toHaveLength(6); + const textPropsChanges = diff?.textDiffs.filter((textDiff) => textDiff.type === 'modified'); + expect(textPropsChanges).toHaveLength(18); + expect(diff?.textDiffs).toHaveLength(24); // Deleted paragraph diff = getDiff( @@ -139,8 +135,10 @@ describe('Diff', () => { let diff = diffs.find((diff) => diff.type === 'modified' && diff.oldText === 'Here’s some text.'); expect(diff.newText).toBe('Here’s some NEW text.'); - expect(diff.textDiffs).toHaveLength(1); - expect(diff.textDiffs[0].text).toBe('NEW '); + expect(diff.textDiffs).toHaveLength(3); + expect(diff.textDiffs[0].newText).toBe(' '); + expect(diff.textDiffs[1].text).toBe('NEW'); + expect(diff.textDiffs[2].text).toBe(' '); expect(diff.attrsDiff?.modified?.textId).toBeDefined(); diff = diffs.find((diff) => diff.type === 'deleted' && diff.oldText === 'I deleted this sentence.'); @@ -155,31 +153,14 @@ describe('Diff', () => { expect(diff.attrsDiff?.modified?.textId).toBeDefined(); }); - // it.only('Test prosemirror-changeset', async () => { - // const docA = await getDocument('diff_before.docx'); - // const docB = await getDocument('diff_after.docx'); - // - // // Produce StepMaps that turn A into B - // const tr = new Transform(docA) - // tr.replaceWith(0, docA.content.size, docB.content) - // - // // Diff them: metadata tags each span with the author - // const encoder = { - // encodeCharacter: (char, marks) => (JSON.stringify({type: "char", char, marks})), - // encodeNodeStart: node => (JSON.stringify({type: "open", name: node.type.name, attrs: node.attrs})), - // encodeNodeEnd: node => (JSON.stringify({type: "close", name: node.type.name})), - // compareTokens: (a, b) => JSON.stringify(a) === JSON.stringify(b) - // } - // const originalChangeSet = ChangeSet - // .create(docA, (a, b) => a === b ? a : null, encoder); - // - // debugger; - // const changeSet = originalChangeSet - // .addSteps(docB, tr.mapping.maps) - // - // // Inspect the replacements - // for (const change of changeSet.changes) { - // console.log(JSON.stringify(change, null, 2)); - // } - // }); + it('Compare another set of two documents with only formatting changes', async () => { + const docBefore = await getDocument('diff_before4.docx'); + const docAfter = await getDocument('diff_after4.docx'); + + const diffs = computeDiff(docBefore, docAfter); + + expect(diffs).toHaveLength(1); + const diff = diffs[0]; + expect(diff.type).toBe('modified'); + }); }); diff --git a/packages/super-editor/src/extensions/diffing/utils.js b/packages/super-editor/src/extensions/diffing/utils.js index 3b4cc56ab..133686530 100644 --- a/packages/super-editor/src/extensions/diffing/utils.js +++ b/packages/super-editor/src/extensions/diffing/utils.js @@ -2,10 +2,10 @@ * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. * @param {Node} paragraph - Paragraph node to flatten. * @param {number} [paragraphPos=0] - Position of the paragraph in the document. - * @returns {{text: string, resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. + * @returns {{text: {char: string, runAttrs: Record}[], resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. */ export function getTextContent(paragraph, paragraphPos = 0) { - let text = ''; + let text = []; const segments = []; paragraph.nodesBetween( @@ -27,8 +27,12 @@ export function getTextContent(paragraph, paragraphPos = 0) { const start = text.length; const end = start + nodeText.length; - segments.push({ start, end, pos }); - text += nodeText; + // Get parent run node and its attributes + const runNode = paragraph.nodeAt(pos - 1); + const runAttrs = runNode.attrs || {}; + + segments.push({ start, end, pos, runAttrs }); + text = text.concat(nodeText.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs) }))); }, 0, ); @@ -61,7 +65,15 @@ export function extractParagraphs(pmDoc) { pmDoc.descendants((node, pos) => { if (node.type.name === 'paragraph') { const { text, resolvePosition } = getTextContent(node, pos); - paragraphs.push({ node, pos, text, resolvePosition }); + paragraphs.push({ + node, + pos, + text, + resolvePosition, + get fullText() { + return text.map((c) => c.char).join(''); + }, + }); return false; // Do not descend further } }); diff --git a/packages/super-editor/src/extensions/diffing/utils.test.js b/packages/super-editor/src/extensions/diffing/utils.test.js index 1c3dd9ddb..fbf85fa72 100644 --- a/packages/super-editor/src/extensions/diffing/utils.test.js +++ b/packages/super-editor/src/extensions/diffing/utils.test.js @@ -1,11 +1,7 @@ import { extractParagraphs, getTextContent } from './utils'; -/** - * Creates a lightweight mock paragraph node for tests. - * @param {string} text - * @param {Record} [attrs={}] - * @returns {object} - */ +const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs) })); + const createParagraphNode = (text, attrs = {}) => ({ type: { name: 'paragraph' }, attrs, @@ -14,8 +10,42 @@ const createParagraphNode = (text, attrs = {}) => ({ nodesBetween: (from, to, callback) => { callback({ isText: true, text }, 0); }, + nodeAt: () => ({ attrs }), }); +const createParagraphWithSegments = (segments, contentSize) => { + const computedSegments = segments.map((segment) => { + const segmentText = segment.text ?? segment.leafText(); + const length = segmentText.length; + return { + ...segment, + length, + start: segment.start ?? 0, + attrs: segment.attrs ?? {}, + }; + }); + const size = + contentSize ?? computedSegments.reduce((max, segment) => Math.max(max, segment.start + segment.length), 0); + const attrsMap = new Map(); + computedSegments.forEach((segment) => { + attrsMap.set(segment.start - 1, segment.attrs); + }); + + return { + content: { size }, + nodesBetween: (from, to, callback) => { + computedSegments.forEach((segment) => { + if (segment.text != null) { + callback({ isText: true, text: segment.text }, segment.start); + } else { + callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); + } + }); + }, + nodeAt: (pos) => ({ attrs: attrsMap.get(pos) ?? {} }), + }; +}; + describe('extractParagraphs', () => { it('collects paragraph nodes in document order', () => { const firstParagraph = createParagraphNode('First paragraph', { paraId: 'para-1' }); @@ -35,8 +65,11 @@ describe('extractParagraphs', () => { const paragraphs = extractParagraphs(pmDoc); expect(paragraphs).toHaveLength(2); - expect(paragraphs[0]).toMatchObject({ node: firstParagraph, pos: 0, text: 'First paragraph' }); - expect(paragraphs[1]).toMatchObject({ node: secondParagraph, pos: 10, text: 'Second paragraph' }); + expect(paragraphs[0]).toMatchObject({ node: firstParagraph, pos: 0 }); + expect(paragraphs[0].text).toEqual(buildRuns('First paragraph', { paraId: 'para-1' })); + expect(paragraphs[0].fullText).toBe('First paragraph'); + expect(paragraphs[1]).toMatchObject({ node: secondParagraph, pos: 10 }); + expect(paragraphs[1].text).toEqual(buildRuns('Second paragraph', { paraId: 'para-2' })); }); it('includes position resolvers for paragraphs with missing IDs', () => { @@ -60,82 +93,54 @@ describe('extractParagraphs', () => { }); describe('getTextContent', () => { - it('Handles basic text nodes', () => { - const mockParagraph = { - content: { - size: 5, - }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text: 'Hello' }, 0); - }, - }; + it('handles basic text nodes', () => { + const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); const result = getTextContent(mockParagraph); - expect(result.text).toBe('Hello'); + expect(result.text).toEqual(buildRuns('Hello', { bold: true })); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(4)).toBe(5); }); - it('Handles leaf nodes with leafText', () => { - const mockParagraph = { - content: { - size: 4, - }, - nodesBetween: (from, to, callback) => { - callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 0); - }, - }; + it('handles leaf nodes with leafText', () => { + const mockParagraph = createParagraphWithSegments( + [{ leafText: () => 'Leaf', start: 0, attrs: { type: 'leaf' } }], + 4, + ); const result = getTextContent(mockParagraph); - expect(result.text).toBe('Leaf'); + expect(result.text).toEqual(buildRuns('Leaf', { type: 'leaf' })); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(3)).toBe(4); }); - it('Handles mixed content', () => { - const mockParagraph = { - content: { - size: 9, - }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text: 'Hello' }, 0); - callback({ isLeaf: true, type: { spec: { leafText: () => 'Leaf' } } }, 5); - }, - }; + it('handles mixed content', () => { + const mockParagraph = createParagraphWithSegments([ + { text: 'Hello', start: 0, attrs: { bold: true } }, + { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, + ]); const result = getTextContent(mockParagraph); - expect(result.text).toBe('HelloLeaf'); + expect(result.text).toEqual([...buildRuns('Hello', { bold: true }), ...buildRuns('Leaf', { italic: true })]); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(5)).toBe(6); expect(result.resolvePosition(9)).toBe(10); }); - it('Handles empty content', () => { - const mockParagraph = { - content: { - size: 0, - }, - nodesBetween: () => {}, - }; + it('handles empty content', () => { + const mockParagraph = createParagraphWithSegments([], 0); const result = getTextContent(mockParagraph); - expect(result.text).toBe(''); + expect(result.text).toEqual([]); expect(result.resolvePosition(0)).toBe(1); }); - it('Handles nested nodes', () => { - const mockParagraph = { - content: { - size: 6, - }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text: 'Nested' }, 0); - }, - }; + it('applies paragraph position offsets to the resolver', () => { + const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); - const result = getTextContent(mockParagraph); - expect(result.text).toBe('Nested'); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(6)).toBe(7); + const result = getTextContent(mockParagraph, 10); + expect(result.text).toEqual(buildRuns('Nested', {})); + expect(result.resolvePosition(0)).toBe(11); + expect(result.resolvePosition(6)).toBe(17); }); }); diff --git a/packages/super-editor/src/tests/data/diff_after3.docx b/packages/super-editor/src/tests/data/diff_after3.docx new file mode 100644 index 0000000000000000000000000000000000000000..df9a28ae24ca01ce66d04e4c608c257e4453e9a6 GIT binary patch literal 13380 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_after4.docx b/packages/super-editor/src/tests/data/diff_after4.docx new file mode 100644 index 0000000000000000000000000000000000000000..bf426c169a8cb49e48d5547280e6c87f241231d0 GIT binary patch literal 13345 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 B)OG*> literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before3.docx b/packages/super-editor/src/tests/data/diff_before3.docx new file mode 100644 index 0000000000000000000000000000000000000000..b094d85c8392e2c10d8c475899d89a8d84bf1b8e GIT binary patch literal 13370 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before4.docx b/packages/super-editor/src/tests/data/diff_before4.docx new file mode 100644 index 0000000000000000000000000000000000000000..155ce140b4203080b329d9e4398b126af9ee3a96 GIT binary patch literal 13370 zcmeHuWpLfdy6rZ^OffUYj+vR6F=l3rnK^dM%*+%sQ_RfF6f-+!X1|>|bMDOK+*hya z{k>OJ>e^CkElFD+se4IY3Je?-00Dpk002aQ_55i|4G;i;@Erhv0)PV56tb~)G`4os zRdTa6cF?AEwX!741qUV127m&O|KH_*@IO!&KVscYk0f%J@Q4`GsBCbMTS5sM#+yj< z=@1gr161WDcA)jO9R*ZD86*bAl9-g~ZjDK$-*0L;)e`!By$k7244y>aI305~s-=Y; znlDCJWSjByYXU>8K93(Qx_kc*sgea8{?h#D*{?F)4 z8+UDMU`cDFwiI&pkT?iB*~qYQH47|W*5bsNMoSJeYNiR&{(5z&AxEBvvsv+;N%GP5 zD}D}m=aJZ$e97{vk&wrZ1dhxKRD=D*-(2*I!zw+MwA`Ai@~LH4a5t`%3rOo8muZCQ zKw`#!q~?~Z<*a7Dkwboq0BPkG*9N{?0A> zc@;Xw3@pd(c4BXFIXyIg-0m90Ja3z?f34E8eB68kz;HP8MLSJ=-gE-;#wwd1Z*Xtr z$?6^q0C;-?1IYiwCGlgh8_$4tCJXd9SfESl+8bLs(9!;C|JN1&2m9aOZoMqF%c6%K zj{nT>#c!%jVYv%4PnOPbas_h<5=v7-8f9hCV&V0LdvOs|`%qtOWO^oU(%m6l)M+DL z`y4w_89uZPdj3JDNAs!G6_60nTEO5sZ@UGTws&jtGE5>tG3*znjutwB4H0u6oIKWv zvP&s^yITlrPE0W|YxtoyFVjwW;V#*mDLaK>Zr(y%(*v@EN2EOvuX!BH8^(mMI*ExU zI;wlEM!Q$(DDFFBgayS7HMS)^9cg-uK^YA8dF#}XdtOIjUq}EobPjX~-IJ$@7P7|c ze&`++AL|*BdY$g34NkhYz!fkt{pBA3V9I)OP1Q6>C=dSt^c)d*0N_0U7vN%JZ$xKg zW9VcB%v-+_ma-Bw0PJyG*8JD%?icpGJ@5bCxp1MgPO_Vn2(% znn@xfpTm)CrVf!1RzI0AKW3*R^AX#tjaNbyBHhOh5{0&E`pD3wzU6D$3e89vO9JKY z!>lu8q}5dVIr&(U;?sWAKxv2-MZ^`9DR-fwhggCaS4->nCI9yysYD&DK!{a0MWc%| z%xDDtQ6Tmdwh#swZ=%g9c);xpSWI>?XGyzHor5p zMOShXj|GA32XhFJy(Y;|3O3ATbjW0J*yb-C*=6b~ifd4fhUJ+SsaFPZ*t4{2@hc-W z-7yGi3x`#kfr`5GeaVk2RB4zyo<06Ln--$0CyP^*p$oED@0Tw;OZelE{l)VXFx8f; zX{i@HU&wKfmct?L#$Q%@GGOzFJn6*N3t5FXh?1j%Zn;LacAwMxT@wxw zEoz(bTh&+thxK`$BuQVMo}y|{O=lTd;jE)m%n0Q(^oC(#iWL zp1A95V9Moat#0;m=dr%nEJm{gkqp3xqA_ZdJ!)&&mUDO{F=2m6mLZQM$2~AKH6Rc( zK;N@^N>1SmO_Nk-*7ap1j@b>x#9UVQlT4@Bms$Q)e)O2qO9(vV z0dAEqe1xxH|4c{oMzsppK+jGR%-UfS2&mb(XOd9zT!^(%+e0+n#>v6*9LMgd#r*@TTVq#yJnyW=|V%@U5ihglml#a!gc`@kh%qA#a z#C`GAA3PW`+I1U*D4HMiqC2X1HgqJa^H!Q{yH|)}H5cYYbou@=V|!vBmmo{pemf#8 z*@vl@{hP%4MMV<=|4=#vJM`1|%Zz#XPXMhRGQS|otjl~bm(35)mwKG@=3x17pLn@@ zr$rZHqUoPEoLM9KgOyXLr1sVV>Tm(aHC6cZuCzD(`KK+`E%-v|?1aA6N**@Voeto# z36lGJq7VLqUqUu4^kf%9K1IMmN5Cg$x^I8xC5B5En0DFW1mWQm!RQ77w224(iNET)9v9X$Ae-x=MW%AH5=8iTn@szL^4c7bKCvsyL~C5O zV9}z@H;~A$K9k$BaDh$Mp5IYj*|C8CC<9h0pkCY5F<@@PXpQ9xKb%_5^W1q8)E4Nw zkX^R4*2DJEFOrYp92@n0I`!SuiEQJ__|!Wifgu{I)&=X93_FDN+@pen;T;qOw9gEl z;!8@Pbq+b5&`3W~X*RUBAd0hb!x`kDwYUfapm*&TZ+PS&bY^bCk{Z}sBlRRWaZ<0^ zi25MUIeLl@xEc#o3c=kjuO^}hUheOwSDQjihX$-q;H zWA&TiO1hu{brpQFVsMu)&8p=4o)^vcPU&@v!<3Emh$r*#HyODf`-hk)v)?K%V{ZE5 z=-TV_=n9vs!F2P-izP{0=48;U8^2*Gpl_&ldA0D!t; z*QgvmLM&sHcQSta-YiGwPVgn!Nr-=l7cpeofGi|1K-iS&9uC>vG^sn+RDVkOh)VsS z@dwqVJ*Yenk_`)!DB}+!amZo0l;{4Iq|-x6!_<{qnw6gv6-nFXe!>(#q>&5g!*_P8 zxjcv3Ppz~qCKbOe7ET?u!|9fOsENZ7H@NL=+L~2x-=ja_f_(^)VBJ=t+@Vw!Kqm7t zrL~uOOPh^lr0tIu!;+RWh6t|aheN_0(W86v{XlBZ<7TF#Y~OC*T`DTuk74$F*z zMzT_oJ`%5Hs*6;{=}0P(CEUf49D~LcI4xfuS=frBM+>70nVgX8@wqR+Xfq+JISMiq9%P2#=QQK1L_Gj3lX9 zp>%Xnj;yu}RWwRCD=+khtwWJotn|TIgjNSEJJ04jhoA|VHxVHndokfKmb+97L7FM|zwLRO-oPzIZDh(QB@t2G#J&LsQV zXBX2OS^I8wR~bs9+yZ9LHDCn6k|_P%{W=T=vb(Xm2S#@@bt455M&B?NuY+0)i@j8_ zR;{VX!%|e&)ZXV+>Wrf)nb3A5y*}G@w*@QsRFV#slBU)9{CP{}RgFd#vss98m7cUZ zZWr$B^|)(cRFCpeUqwvAn(;4B*#mdpZ%8)0Ra$8?-(_zKLz>a&JHDJhAFcC=mx^n= z_I`c@`=W#s(dJx!7`0MwUC2-r@qD{TFets2iLmC-nF8U_bK%Nq^-zB+zLk2Kt&9VQ zlc&+bh4rXtGmYDVoX)jHJkixe!>ZtRMF&it|CwInK@c)tfd$z%?5}sAKhvv&v7@88 zwW-6e)LN&qZnMIQ zYC17HSaU}t_q65d)YXOisAs@*90D?zIl%y5ST#?NUzmsit;Ox!sRe)ht1qj#UT;3( z)rPR+`NiIXE-$X4OgIP&4*(`dQD@YzjX@bZuz3+B@;ad`7*G`-N&=Q;fnlGYd1Rp` zu0tf`FkHkDaAC#_uYt{hXhAxiXfSA=lb={nO;$`Tr4fC*OEata%u41`Q^fJU-wh5; zAIT5ZU5@Hx={~9kF=?{S71Kf{v z!Q<_foh~gRV(<2*2Fo8~D-b-T#n8YGqHxeNhqk zXslN0=mf?ytwl698s1Rg7Q=h&uS9N&giv=2Xm;4ht(7CM@oFMEq!ag8-C0>fdvec8hoqj z>J4r#qPJI6h3fGZSsiSjC-qwUbv?!(R!Bj*fpt5DgP|sHgXuAX8vIr1vyksp#C%rp zh6f0eKF^mx{H09F8nVIJ=x;9edz95Cb<6Sv`R+)V?%S*BQ#Bu8aODr`;p-5UFApv~ z<&XkB9y6ZjQ=i`r+X@e8nvE0|FpFM3Db@#Ry=jqZcLcrM26Vuey=;ZW@0D?;R>Zda zYRnk};_y%8+s0+beF1dQJ=AP5nJ1_` z4*B%2hxxlm?>`HfN2 zvPM98?pnG%Y!4)_r7grV1>RHtzS@D{f!4ZuQQyw52~lY=!`Hdxz^FRu=DJ^p_0%XN zi8RA<%Q#M!^`@pc;Y44O9CJx-vfbN!x4=jmAG-bQ9D|r|6cz?;LYDi?qFrS3ubQK2ve+{n3q$tK zNz~{)D{~eMt+KI3?3$IPJqmUB15?yG)G97-b^D!f8SBr$aPXhO0{lca3kC@|d{PJi zApTddaCEaY{v%WzYp>X^h$4Hfm%Kp6XF3z$h7gMkm5@_NG|X4jmWLqHbfye71E&I%giu zWpC?Xzqfa}IyxpKmrN9;9{S{McitQ3m-XfGY2DkCn{K$!AHx?Q9(E<-YDZ5uviq>9 z|9<@8D;l4XRAy*KEwygdC(p4nL=5ULYBah5O9n&3uGk;#9vodg9hrIQ>D@5mEm^~a zUddopVKWnlU+MQl_(!J=@ae)}PO4#-4HLyixMAHCg8g(pVB2(U)dgaO115 z3{vLXAuzYrM^nHWv$I7L3z|+$upAG4AlstuB8sFIKGBmv=Uq(3Wi$K8vN0oQ{~c;# zeZ5e9{96b=vJMv101Rr?v+g;`TT#?=+A?iWpJy`Zr5|Q}mcL^jwP%m1WkP(wbL#ru zUg^WP*B?ga>fllPWl-Dqve&B2&A}p1XS6fKyhtAGRKp|0CJ|iwmqOc_RpjMks&=RZ zxWniKu9=~$x+$3VhHb_7d293~4O%0zR+ds~?z&dnhoy8VEe`YFip`F;e0D3zl7?;R zIpwxIR4VM>5A3i7WcO&}Ylm&`uMeY1?L;Atbslug+tM_46zkBcW0uNRs}G4N;e1uGg_|k?6_3Wq!d>s3JiZD~;SAcH-|Mw&g*w2R0APaDxc)>~O6^J1J@>cLNe@5%Jd6B3qttH-lQ9;x~W| z9I)Zqc-lOTJoEnE($hq6dU|jI+@-c{ccVn*N@tXq*rv=nQo}eVi9aY|W5b*XL!A&a zvoREhKOun-3lJ4!bs#>CKxe%1Y78QedCZ!SN@0gsqE7C+W86JlwafJ0m1x)j2_yCu zgGzRYiRTdQ9Vm3*-R6)Jl095>%w|M^o1Qa}+(?shmJ95g33k1w6pqc7=c*gb+W+){ z!B>$^E!p&?C6S@#{+x*RF)9HZxU(ncQ5Ft)@@uj0*TXC<;ExdBKCC$; zEau!8Ml)eBlgZ$InWv9)-+?>qn9PK?O(q|sR%!~fC>PC5U7*59ZdXRoj)sL0Xl&nOnu^5^Fg2;%|fwQ^a0dYuBh^hdwYt zIy>d{hB~b&kY?KLoCN3?8>i3=(#w#Z6Lef0@|knGQp9dtha8yEg$xV99CMPf51Hjt z6AL2GjIu{ajOW0dtv{QmR&(bHw2U>i6BO(S2ctBo9Z5j?HVA8ZqFw~)vF4w@_FSsX z2NVfAiGi9Vz=zFUyWrm~5{HAe#>w;S!XJkC9L^X{m^YJbraI z3AbK=m{+SmZQePQq&>`;t{ZMlOipJ}vb>%WGdVpa0xBj2$jjMeb?4*yH$&f>4plL~ z_c9>V^pt!gJTEBPyVadV&(gjM4=JE|mrUF*?5awVkN z)l4`>vVsZM$(}Rcql&XETV;;z#L=sl*ldyc#Iqdv+t!qC7Ky(`$PG1;FcoJu;zw);Bp1wunF&f-wr&$ z_4$F6K^!xKix&HXJi{;!C)Xj`UMM>cjhkd67p-nUgv#s;rJOKSuI`8d)>g@ErH9BmY$}lmce7#8 z5~~};I&2_uQ-aX%s^Larzz%!iE(z41)uj$eb`{FX8oH^Zn*mi+b@~UC_L2jMW5HB} z&(;jI-^Y3Q0Fy=dT``t;tVD}1InD2@l!UN9X>rESs zh+IviqDw;^v>J@!_CybcGjVdLBCNqtvt%*3tF6hId#uU%O6-Bp^9?(RhD9M_Br&z<7IEAfc&GojZXi}h>5iL-OaJ*7Pr1@|4Jq3% zo)933&&;>^S(%;=dJ?3M-hR%nA`pL>gR-B6{+{3>q!QqJ2;c&0|~@05NcpW-uJvE zNyV}KH6MM|c}6W&@K}>RJB1C~iYc;uZ&Kj~od4`ASAta}i#)>Mz|Ou#0`uZ~dGOIEg=< z_ICD4-7}ixOIlAC#tt0IJcVA=M0~=)zFl{$QQJtq=T~LJ)^DTMcB)>|NtCszMGwES z%QP&!N=6di<{rRHH>Ht!X@OVZt@TK8x^iw-MZ;=my2Hk-uy?*OCt$J|^6D+wrq@Jn0=>TnCEm`XP;_T(ie1B>{usu zNa`e)XCo~X^0L4!!OQsa)AI1uyxa5>t-;xbcw3ai=Qkxfn$5b2ITomootq}6*9*>k zQ(-dwkDHjLZOZ7&IDwix#d9wCT#vTjDSIcScMuz%8Wp5gPYaf?;J!LNFTt2aaM}hi zY;TSu?IB3M(WT{5%%RC*6buN*r*dj9)K1?`*k%or#_DSuIWlm6Fj$-wImCLcg#oZ? zdW8mwH#lZY^vwqc(OZWOwOudlK z3g`~;f65J$rInNoA!l%^!Jv%b%K+DYu#2%375>Tr`%3C~G5YXoE$PV6FEaF*SaNi* zLtgv1bo85UbT!Ys`{x-X!=ImF%w^Ps6#73{7A<3uM;}s|)UcBU%3_S{CQVQs8d)lv z%pn!pL#a|T&>b(3+<~5+s1d|)9;wb%ai6#1Kiz0GFWVc8H{Kp=*yN$?rgn+a9hQoY z%>5X@V60t!ahNaLbT2=(`g(+iDp}E~(Ps%GPtH(S8t{tr@w|bm_9{i{$D%sahPNJq zc}K_@1S#$16O7|b$dB}n^N?*O#rZPnwr`!ROzuLL;$Og+rTC))I*;(Cf&Zv!g;8v|T2z$Q8 z<$)eIG^aQ#2bWPr_502Z#X~61cBqv@Q{?F{ITrov0-CkN20b9eu5M{pLN*cK=pvEWaUEUa@lLSVa_z zhyS$YxQlbA!Hx(^d*t$aRZps(mlN@ zAfi6lWny)qVh;|1;HuM}p6xlQ^gM#KB7SN9K;Cbhx}SgDK1}Y^3O@D&iHcsohQBlg zQM)B4b?Kx6+jbW1%AO|S8j@3X2Fo1FBkZdP5eGNPx;cIOP$9Y!($x1)DyR8#jbT53@$VscF8|e{q^txD|0`=KEmT=;#S?V z*4ax)6|*erc?9Z+9%+iQ^>rP=ARX?@zW;s@_}Lb_9|0&5`v`n|$p3yoCP4kBlD>hZ z@vl+fowyb2JbI+SGu0z5!gV%Gdoyg7;$K6KF&lxS#(nIghsf@r2p$6eXZ#t zcj~n?MaHC_s)w97w)YO210iVcOH70PP@U31983hb8A|W1S-e3vV^3kK8QDrViEKFWE z#AQt#r3(*+QMWG$#k6)1>~>f%FPkGX>LNYGcCC56+HH4U&m!y6f8vG2#k7hSp>e%N zp-jK4dISok{?onBI-)4-fbM+(w4nDuT?bIiA#ZPE>p*8{WB-To0L6y?E9L;YRd}qn zoF6@E;F-)bV*Z`H-jp;bwVjEoromQtf6H>FmD41v6>BJgWa7m;mXvL0ZnjO?MXYgc>m>MZOQ*oZcjLN1H^kWYO$-SpldO2j&C? zbew!bPDVXt*Wx5zd@pz;Qbkit^N0BY_b{{^kD7P5L7P0ZUC5%;T@l%p5F(C=Mvl=+ zklJ;i;RfW{;QH{9U!io>V4Im$A(zJyYjzq`Y|&2Az`Phe0i`7{B@$uHFC@?H8tru* zM-Ef2A|SgIc_F(EKHPmtlOlWP5ASB3cZuv6=J?BIA1?VR7}m5bRLzRpXI$HZnqyP3 z*!YHV`#a^w5}eqUilbm6X@je&TCSEX*m0?DK{G_Q#ZtcjC$#?4 zPIuWftQ>%LIta8=q`%v#zOC)A827(s3baw+lKx3X4yXxjBe@_bSySgk*cP%TWA_ojj-VBv`cD=eK8no zjeOGw=>o2uB;hE)ny_EYgfBdZrMA3(v01R*TqvPY?z_uAmMUm}uCs_l;wh7d+Qcpq z)xzVO73=v%u(U)#M3rb0EGM2Th$YOXgl2PQ1s>=nnlOsLP1K$!_-6{Z3Nu$7Sd{mR zh#*u8Zu%0D5$_MZdR=szp1ctU64!T(`paccmSH8(E3CiG$Rbyfp`$Gk;Dv*8PkVsR zj3`!G$!A!+l0Cjy1x5|+hp7wfd+2|R?8pBC_Xae@e;FzWC=IYS`nTxmpC42F>#_XR z{x_di$V>f`;Gfd(zn}m>9MCuZCItTl#0UFTmHl^`Sj<0=zf^enzO@j4R&IY6>nSU} z`__xaVcZT1KwZ9lzDgI>mN9p!E{-5dk wFEjw~NCg1=NAmt1{?91-cX%%C-{617RC%d)K(qPv`2j3o325w648OMi54=e9MF0Q* literal 0 HcmV?d00001 From 867f868d25280b9dc072a71395c163f1d907eaa1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 26 Dec 2025 15:48:45 -0300 Subject: [PATCH 17/53] refactor: change from "type" to "action" --- .../diffing/algorithm/paragraph-diffing.js | 12 +++++----- .../algorithm/paragraph-diffing.test.js | 12 +++++----- .../algorithm/sequence-diffing.test.js | 14 +++++------ .../diffing/algorithm/text-diffing.js | 24 +++++++++---------- .../diffing/algorithm/text-diffing.test.js | 10 ++++---- .../extensions/diffing/computeDiff.test.js | 20 ++++++++-------- 6 files changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index feeafecd4..4890d289d 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -11,7 +11,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; /** * A paragraph addition diff emitted when new content is inserted. * @typedef {Object} AddedParagraphDiff - * @property {'added'} type + * @property {'added'} action * @property {Node} node reference to the ProseMirror node for consumers needing schema details * @property {string} text textual contents of the inserted paragraph * @property {number} pos document position where the paragraph was inserted @@ -20,7 +20,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; /** * A paragraph deletion diff emitted when content is removed. * @typedef {Object} DeletedParagraphDiff - * @property {'deleted'} type + * @property {'deleted'} action * @property {Node} node reference to the original ProseMirror node * @property {string} oldText text that was removed * @property {number} pos starting document position of the original paragraph @@ -29,7 +29,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; /** * A paragraph modification diff that carries inline text-level changes. * @typedef {Object} ModifiedParagraphDiff - * @property {'modified'} type + * @property {'modified'} action * @property {string} oldText text before the edit * @property {string} newText text after the edit * @property {number} pos original document position for anchoring UI @@ -88,7 +88,7 @@ function paragraphComparator(oldParagraph, newParagraph) { */ function buildAddedParagraphDiff(paragraph) { return { - type: 'added', + action: 'added', node: paragraph.node, text: paragraph.fullText, pos: paragraph.pos, @@ -102,7 +102,7 @@ function buildAddedParagraphDiff(paragraph) { */ function buildDeletedParagraphDiff(paragraph) { return { - type: 'deleted', + action: 'deleted', node: paragraph.node, oldText: paragraph.fullText, pos: paragraph.pos, @@ -129,7 +129,7 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { } return { - type: 'modified', + action: 'modified', oldText: oldParagraph.fullText, newText: newParagraph.fullText, pos: oldParagraph.pos, diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index f81180663..b37350eee 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -27,7 +27,7 @@ describe('diffParagraphs', () => { const diffs = diffParagraphs(oldParagraphs, newParagraphs); expect(diffs).toHaveLength(1); - expect(diffs[0].type).toBe('modified'); + expect(diffs[0].action).toBe('modified'); expect(diffs[0].textDiffs.length).toBeGreaterThan(0); }); @@ -38,8 +38,8 @@ describe('diffParagraphs', () => { const diffs = diffParagraphs(oldParagraphs, newParagraphs); expect(diffs).toHaveLength(2); - expect(diffs[0].type).toBe('deleted'); - expect(diffs[1].type).toBe('added'); + expect(diffs[0].action).toBe('deleted'); + expect(diffs[1].action).toBe('added'); }); it('detects modifications even when Myers emits grouped deletes and inserts', () => { @@ -55,9 +55,9 @@ describe('diffParagraphs', () => { const diffs = diffParagraphs(oldParagraphs, newParagraphs); expect(diffs).toHaveLength(3); - expect(diffs[0].type).toBe('modified'); + expect(diffs[0].action).toBe('modified'); expect(diffs[0].textDiffs.length).toBeGreaterThan(0); - expect(diffs[1].type).toBe('deleted'); - expect(diffs[2].type).toBe('added'); + expect(diffs[1].action).toBe('deleted'); + expect(diffs[2].action).toBe('added'); }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js index 4f771cd12..679b4cc63 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { diffSequences } from './sequence-diffing.js'; -const buildAdded = (item) => ({ type: 'added', id: item.id }); -const buildDeleted = (item) => ({ type: 'deleted', id: item.id }); +const buildAdded = (item) => ({ action: 'added', id: item.id }); +const buildDeleted = (item) => ({ action: 'deleted', id: item.id }); const buildModified = (oldItem, newItem) => ({ - type: 'modified', + action: 'modified', id: oldItem.id ?? newItem.id, from: oldItem.value, to: newItem.value, @@ -29,7 +29,7 @@ describe('diffSequences', () => { buildModified, }); - expect(diffs).toEqual([{ type: 'modified', id: 'b', from: 'World', to: 'World!!!' }]); + expect(diffs).toEqual([{ action: 'modified', id: 'b', from: 'World', to: 'World!!!' }]); }); it('pairs delete/insert operations into modifications when allowed', () => { @@ -51,7 +51,7 @@ describe('diffSequences', () => { buildModified, }); - expect(diffs).toEqual([{ type: 'modified', id: 'b', from: 'Beta', to: 'Beta v2' }]); + expect(diffs).toEqual([{ action: 'modified', id: 'b', from: 'Beta', to: 'Beta v2' }]); }); it('emits additions and deletions when items cannot be paired', () => { @@ -66,8 +66,8 @@ describe('diffSequences', () => { }); expect(diffs).toEqual([ - { type: 'deleted', id: 'a' }, - { type: 'added', id: 'b' }, + { action: 'deleted', id: 'a' }, + { action: 'added', id: 'b' }, ]); }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js index b6bff482b..ade376764 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js @@ -11,8 +11,8 @@ import { diffSequences } from './sequence-diffing.js'; * @returns {Array} List of addition/deletion ranges with document positions and text content. */ export function getTextDiff(oldText, newText, oldPositionResolver, newPositionResolver = oldPositionResolver) { - const buildCharDiff = (type, char, oldIdx) => ({ - type, + const buildCharDiff = (action, char, oldIdx) => ({ + action, idx: oldIdx, text: char.char, runAttrs: char.runAttrs, @@ -24,7 +24,7 @@ export function getTextDiff(oldText, newText, oldPositionResolver, newPositionRe buildAdded: (char, oldIdx, newIdx) => buildCharDiff('added', char, oldIdx), buildDeleted: (char, oldIdx, newIdx) => buildCharDiff('deleted', char, oldIdx), buildModified: (oldChar, newChar, oldIdx, newIdx) => ({ - type: 'modified', + action: 'modified', idx: oldIdx, newText: newChar.char, oldText: oldChar.char, @@ -42,17 +42,17 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { let currentGroup = null; const compareDiffs = (group, diff) => { - if (group.type !== diff.type) { + if (group.action !== diff.action) { return false; } - if (group.type === 'modified') { + if (group.action === 'modified') { return group.oldAttrs === diff.oldAttrs && group.newAttrs === diff.newAttrs; } return group.runAttrs === diff.runAttrs; }; const comparePositions = (group, diff) => { - if (group.type === 'added') { + if (group.action === 'added') { return group.startPos === oldPositionResolver(diff.idx); } else { return group.endPos + 1 === oldPositionResolver(diff.idx); @@ -62,11 +62,11 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { for (const diff of diffs) { if (currentGroup == null) { currentGroup = { - type: diff.type, + action: diff.action, startPos: oldPositionResolver(diff.idx), endPos: oldPositionResolver(diff.idx), }; - if (diff.type === 'modified') { + if (diff.action === 'modified') { currentGroup.newText = diff.newText; currentGroup.oldText = diff.oldText; currentGroup.oldAttrs = diff.oldAttrs; @@ -78,11 +78,11 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { } else if (!compareDiffs(currentGroup, diff) || !comparePositions(currentGroup, diff)) { grouped.push(currentGroup); currentGroup = { - type: diff.type, + action: diff.action, startPos: oldPositionResolver(diff.idx), endPos: oldPositionResolver(diff.idx), }; - if (diff.type === 'modified') { + if (diff.action === 'modified') { currentGroup.newText = diff.newText; currentGroup.oldText = diff.oldText; currentGroup.oldAttrs = diff.oldAttrs; @@ -93,7 +93,7 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { } } else { currentGroup.endPos = oldPositionResolver(diff.idx); - if (diff.type === 'modified') { + if (diff.action === 'modified') { currentGroup.newText += diff.newText; currentGroup.oldText += diff.oldText; } else { @@ -105,7 +105,7 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { if (currentGroup != null) grouped.push(currentGroup); return grouped.map((group) => { let ret = { ...group }; - if (group.type === 'modified') { + if (group.action === 'modified') { ret.oldAttrs = JSON.parse(group.oldAttrs); ret.newAttrs = JSON.parse(group.newAttrs); ret.runAttrsDiff = getAttributesDiff(ret.oldAttrs, ret.newAttrs); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js index 9f3005a2a..239aebd2f 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js @@ -26,7 +26,7 @@ describe('getTextDiff', () => { expect(diffs).toEqual([ { - type: 'added', + action: 'added', startPos: 12, endPos: 12, text: 'X', @@ -43,14 +43,14 @@ describe('getTextDiff', () => { expect(diffs).toEqual([ { - type: 'deleted', + action: 'deleted', startPos: 7, endPos: 7, text: 'c', runAttrs: {}, }, { - type: 'added', + action: 'added', startPos: 8, endPos: 8, text: 'XY', @@ -66,7 +66,7 @@ describe('getTextDiff', () => { expect(diffs).toEqual([ { - type: 'modified', + action: 'modified', startPos: 0, endPos: 0, oldText: 'a', @@ -87,7 +87,7 @@ describe('getTextDiff', () => { expect(diffs).toEqual([ { - type: 'modified', + action: 'modified', startPos: 5, endPos: 6, oldText: 'ab', diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index d059891ea..b0df12128 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -30,11 +30,11 @@ describe('Diff', () => { const docAfter = await getDocument('diff_after.docx'); const diffs = computeDiff(docBefore, docAfter); - const getDiff = (type, predicate) => diffs.find((diff) => diff.type === type && predicate(diff)); + const getDiff = (action, predicate) => diffs.find((diff) => diff.action === action && predicate(diff)); - const modifiedDiffs = diffs.filter((diff) => diff.type === 'modified'); - const addedDiffs = diffs.filter((diff) => diff.type === 'added'); - const deletedDiffs = diffs.filter((diff) => diff.type === 'deleted'); + const modifiedDiffs = diffs.filter((diff) => diff.action === 'modified'); + const addedDiffs = diffs.filter((diff) => diff.action === 'added'); + const deletedDiffs = diffs.filter((diff) => diff.action === 'deleted'); const attrOnlyDiffs = modifiedDiffs.filter((diff) => diff.textDiffs.length === 0); expect(diffs).toHaveLength(19); @@ -51,7 +51,7 @@ describe('Diff', () => { expect(diff?.newText).toBe( 'Curabitur facilisis ligula suscipit enim pretium et nunc ligula, porttitor augue consequat maximus.', ); - const textPropsChanges = diff?.textDiffs.filter((textDiff) => textDiff.type === 'modified'); + const textPropsChanges = diff?.textDiffs.filter((textDiff) => textDiff.action === 'modified'); expect(textPropsChanges).toHaveLength(18); expect(diff?.textDiffs).toHaveLength(24); @@ -132,7 +132,7 @@ describe('Diff', () => { const diffs = computeDiff(docBefore, docAfter); expect(diffs).toHaveLength(4); - let diff = diffs.find((diff) => diff.type === 'modified' && diff.oldText === 'Here’s some text.'); + let diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'Here’s some text.'); expect(diff.newText).toBe('Here’s some NEW text.'); expect(diff.textDiffs).toHaveLength(3); @@ -141,13 +141,13 @@ describe('Diff', () => { expect(diff.textDiffs[2].text).toBe(' '); expect(diff.attrsDiff?.modified?.textId).toBeDefined(); - diff = diffs.find((diff) => diff.type === 'deleted' && diff.oldText === 'I deleted this sentence.'); + diff = diffs.find((diff) => diff.action === 'deleted' && diff.oldText === 'I deleted this sentence.'); expect(diff).toBeDefined(); - diff = diffs.find((diff) => diff.type === 'added' && diff.text === 'I added this sentence.'); + diff = diffs.find((diff) => diff.action === 'added' && diff.text === 'I added this sentence.'); expect(diff).toBeDefined(); - diff = diffs.find((diff) => diff.type === 'modified' && diff.oldText === 'We are not done yet.'); + diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'We are not done yet.'); expect(diff.newText).toBe('We are done now.'); expect(diff.textDiffs).toHaveLength(3); expect(diff.attrsDiff?.modified?.textId).toBeDefined(); @@ -161,6 +161,6 @@ describe('Diff', () => { expect(diffs).toHaveLength(1); const diff = diffs[0]; - expect(diff.type).toBe('modified'); + expect(diff.action).toBe('modified'); }); }); From 74f0fab38bc4606339eef461db9ea1ccc8cab2a1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 26 Dec 2025 17:20:30 -0300 Subject: [PATCH 18/53] feat: support diffing non-textual inline nodes --- .../{text-diffing.js => inline-diffing.js} | 118 ++++++++++++++---- ...diffing.test.js => inline-diffing.test.js} | 21 ++-- .../diffing/algorithm/paragraph-diffing.js | 10 +- .../algorithm/paragraph-diffing.test.js | 4 +- .../extensions/diffing/computeDiff.test.js | 44 +++++-- .../src/extensions/diffing/utils.js | 36 ++++-- .../src/extensions/diffing/utils.test.js | 79 ++++++++++-- .../src/tests/data/diff_after5.docx | Bin 0 -> 13330 bytes .../src/tests/data/diff_after6.docx | Bin 0 -> 24121 bytes .../src/tests/data/diff_before5.docx | Bin 0 -> 13370 bytes .../src/tests/data/diff_before6.docx | Bin 0 -> 13422 bytes 11 files changed, 243 insertions(+), 69 deletions(-) rename packages/super-editor/src/extensions/diffing/algorithm/{text-diffing.js => inline-diffing.js} (53%) rename packages/super-editor/src/extensions/diffing/algorithm/{text-diffing.test.js => inline-diffing.test.js} (73%) create mode 100644 packages/super-editor/src/tests/data/diff_after5.docx create mode 100644 packages/super-editor/src/tests/data/diff_after6.docx create mode 100644 packages/super-editor/src/tests/data/diff_before5.docx create mode 100644 packages/super-editor/src/tests/data/diff_before6.docx diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js similarity index 53% rename from packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js rename to packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js index ade376764..638b553d4 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js @@ -4,39 +4,85 @@ import { diffSequences } from './sequence-diffing.js'; /** * Computes text-level additions and deletions between two strings using Myers diff algorithm, mapping back to document positions. - * @param {{char: string, runAttrs: Record}[]} oldText - Source text. - * @param {{char: string, runAttrs: Record}[]} newText - Target text. + * @param {{char: string, runAttrs: Record}[]} oldContent - Source text. + * @param {{char: string, runAttrs: Record}[]} newContent - Target text. * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the original document. * @param {(index: number) => number|null} [newPositionResolver=oldPositionResolver] - Maps string indexes to the updated document. * @returns {Array} List of addition/deletion ranges with document positions and text content. */ -export function getTextDiff(oldText, newText, oldPositionResolver, newPositionResolver = oldPositionResolver) { - const buildCharDiff = (action, char, oldIdx) => ({ - action, - idx: oldIdx, - text: char.char, - runAttrs: char.runAttrs, - }); - let diffs = diffSequences(oldText, newText, { - comparator: (a, b) => a.char === b.char, - shouldProcessEqualAsModification: (oldChar, newChar) => oldChar.runAttrs !== newChar.runAttrs, - canTreatAsModification: (oldChar, newChar) => false, - buildAdded: (char, oldIdx, newIdx) => buildCharDiff('added', char, oldIdx), - buildDeleted: (char, oldIdx, newIdx) => buildCharDiff('deleted', char, oldIdx), - buildModified: (oldChar, newChar, oldIdx, newIdx) => ({ - action: 'modified', - idx: oldIdx, - newText: newChar.char, - oldText: oldChar.char, - oldAttrs: oldChar.runAttrs, - newAttrs: newChar.runAttrs, - }), +export function getInlineDiff(oldContent, newContent, oldPositionResolver, newPositionResolver = oldPositionResolver) { + const buildCharDiff = (action, token, oldIdx) => { + if (token.kind !== 'text') { + return { + action, + idx: oldIdx, + ...token, + }; + } else { + return { + action, + idx: oldIdx, + kind: 'text', + text: token.char, + runAttrs: token.runAttrs, + }; + } + }; + let diffs = diffSequences(oldContent, newContent, { + comparator: inlineComparator, + shouldProcessEqualAsModification, + canTreatAsModification: (oldToken, newToken, oldIdx, newIdx) => + oldToken.kind === newToken.kind && oldToken.kind !== 'text' && oldToken.node.type.type === newToken.node.type, + buildAdded: (token, oldIdx, newIdx) => buildCharDiff('added', token, oldIdx), + buildDeleted: (token, oldIdx, newIdx) => buildCharDiff('deleted', token, oldIdx), + buildModified: (oldToken, newToken, oldIdx, newIdx) => { + if (oldToken.kind !== 'text') { + return { + action: 'modified', + idx: oldIdx, + kind: 'inlineNode', + oldNode: oldToken.node, + newNode: newToken.node, + nodeType: oldToken.nodeType, + }; + } else { + return { + action: 'modified', + idx: oldIdx, + kind: 'text', + newText: newToken.char, + oldText: oldToken.char, + oldAttrs: oldToken.runAttrs, + newAttrs: newToken.runAttrs, + }; + } + }, }); const groupedDiffs = groupDiffs(diffs, oldPositionResolver, newPositionResolver); return groupedDiffs; } +function inlineComparator(a, b) { + if (a.kind !== b.kind) { + return false; + } + + if (a.kind === 'text') { + return a.char === b.char; + } else { + return true; + } +} + +function shouldProcessEqualAsModification(oldToken, newToken) { + if (oldToken.kind === 'text') { + return oldToken.runAttrs !== newToken.runAttrs; + } else { + return JSON.stringify(oldToken.nodeAttrs) !== JSON.stringify(newToken.nodeAttrs); + } +} + function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { const grouped = []; let currentGroup = null; @@ -60,11 +106,33 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { }; for (const diff of diffs) { + if (diff.kind !== 'text') { + if (currentGroup != null) { + grouped.push(currentGroup); + currentGroup = null; + } + grouped.push({ + action: diff.action, + kind: 'inlineNode', + startPos: oldPositionResolver(diff.idx), + endPos: oldPositionResolver(diff.idx), + nodeType: diff.nodeType, + ...(diff.action === 'modified' + ? { + oldNode: diff.oldNode, + newNode: diff.newNode, + diffNodeAttrs: getAttributesDiff(diff.oldAttrs, diff.newAttrs), + } + : { node: diff.node }), + }); + continue; + } if (currentGroup == null) { currentGroup = { action: diff.action, startPos: oldPositionResolver(diff.idx), endPos: oldPositionResolver(diff.idx), + kind: 'text', }; if (diff.action === 'modified') { currentGroup.newText = diff.newText; @@ -81,6 +149,7 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { action: diff.action, startPos: oldPositionResolver(diff.idx), endPos: oldPositionResolver(diff.idx), + kind: 'text', }; if (diff.action === 'modified') { currentGroup.newText = diff.newText; @@ -105,6 +174,9 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { if (currentGroup != null) grouped.push(currentGroup); return grouped.map((group) => { let ret = { ...group }; + if (group.kind === 'inlineNode') { + return ret; + } if (group.action === 'modified') { ret.oldAttrs = JSON.parse(group.oldAttrs); ret.newAttrs = JSON.parse(group.newAttrs); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js similarity index 73% rename from packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js rename to packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 239aebd2f..6a71229ac 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/text-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -5,15 +5,15 @@ vi.mock('./myers-diff.js', async () => { myersDiff: vi.fn(actual.myersDiff), }; }); -import { getTextDiff } from './text-diffing.js'; +import { getInlineDiff } from './inline-diffing.js'; const buildTextRuns = (text, runAttrs = {}) => - text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs) })); + text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs), kind: 'text' })); -describe('getTextDiff', () => { +describe('getInlineDiff', () => { it('returns an empty diff list when both strings are identical', () => { const resolver = (index) => index; - const diffs = getTextDiff(buildTextRuns('unchanged'), buildTextRuns('unchanged'), resolver); + const diffs = getInlineDiff(buildTextRuns('unchanged'), buildTextRuns('unchanged'), resolver); expect(diffs).toEqual([]); }); @@ -22,11 +22,12 @@ describe('getTextDiff', () => { const oldResolver = (index) => index + 10; const newResolver = (index) => index + 100; - const diffs = getTextDiff(buildTextRuns('abc'), buildTextRuns('abXc'), oldResolver, newResolver); + const diffs = getInlineDiff(buildTextRuns('abc'), buildTextRuns('abXc'), oldResolver, newResolver); expect(diffs).toEqual([ { action: 'added', + kind: 'text', startPos: 12, endPos: 12, text: 'X', @@ -39,11 +40,12 @@ describe('getTextDiff', () => { const oldResolver = (index) => index + 5; const newResolver = (index) => index + 20; - const diffs = getTextDiff(buildTextRuns('abcd'), buildTextRuns('abXYd'), oldResolver, newResolver); + const diffs = getInlineDiff(buildTextRuns('abcd'), buildTextRuns('abXYd'), oldResolver, newResolver); expect(diffs).toEqual([ { action: 'deleted', + kind: 'text', startPos: 7, endPos: 7, text: 'c', @@ -51,6 +53,7 @@ describe('getTextDiff', () => { }, { action: 'added', + kind: 'text', startPos: 8, endPos: 8, text: 'XY', @@ -62,11 +65,12 @@ describe('getTextDiff', () => { it('marks attribute-only changes as modifications and surfaces attribute diffs', () => { const resolver = (index) => index; - const diffs = getTextDiff(buildTextRuns('a', { bold: true }), buildTextRuns('a', { italic: true }), resolver); + const diffs = getInlineDiff(buildTextRuns('a', { bold: true }), buildTextRuns('a', { italic: true }), resolver); expect(diffs).toEqual([ { action: 'modified', + kind: 'text', startPos: 0, endPos: 0, oldText: 'a', @@ -83,11 +87,12 @@ describe('getTextDiff', () => { it('merges contiguous attribute edits that share the same diff metadata', () => { const resolver = (index) => index + 5; - const diffs = getTextDiff(buildTextRuns('ab', { bold: true }), buildTextRuns('ab', { bold: false }), resolver); + const diffs = getInlineDiff(buildTextRuns('ab', { bold: true }), buildTextRuns('ab', { bold: false }), resolver); expect(diffs).toEqual([ { action: 'modified', + kind: 'text', startPos: 5, endPos: 6, oldText: 'ab', diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index 4890d289d..a7a6ce0f6 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -1,5 +1,5 @@ import { myersDiff } from './myers-diff.js'; -import { getTextDiff } from './text-diffing.js'; +import { getInlineDiff } from './inline-diffing.js'; import { getAttributesDiff } from './attributes-diffing.js'; import { diffSequences, reorderDiffOperations } from './sequence-diffing.js'; import { levenshteinDistance } from './similarity.js'; @@ -33,7 +33,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; * @property {string} oldText text before the edit * @property {string} newText text after the edit * @property {number} pos original document position for anchoring UI - * @property {Array} textDiffs granular inline diff data returned by `getTextDiff` + * @property {Array} contentDiff granular inline diff data * @property {import('./attributes-diffing.js').AttributesDiff|null} attrsDiff attribute-level changes between the old and new paragraph nodes */ @@ -116,7 +116,7 @@ function buildDeletedParagraphDiff(paragraph) { * @returns {ModifiedParagraphDiff} */ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { - const textDiffs = getTextDiff( + const contentDiff = getInlineDiff( oldParagraph.text, newParagraph.text, oldParagraph.resolvePosition, @@ -124,7 +124,7 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { ); const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); - if (textDiffs.length === 0 && !attrsDiff) { + if (contentDiff.length === 0 && !attrsDiff) { return null; } @@ -133,7 +133,7 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { oldText: oldParagraph.fullText, newText: newParagraph.fullText, pos: oldParagraph.pos, - textDiffs, + contentDiff, attrsDiff, }; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index b37350eee..3429bd906 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -28,7 +28,7 @@ describe('diffParagraphs', () => { expect(diffs).toHaveLength(1); expect(diffs[0].action).toBe('modified'); - expect(diffs[0].textDiffs.length).toBeGreaterThan(0); + expect(diffs[0].contentDiff.length).toBeGreaterThan(0); }); it('keeps unrelated paragraphs as deletion + addition', () => { @@ -56,7 +56,7 @@ describe('diffParagraphs', () => { expect(diffs).toHaveLength(3); expect(diffs[0].action).toBe('modified'); - expect(diffs[0].textDiffs.length).toBeGreaterThan(0); + expect(diffs[0].contentDiff.length).toBeGreaterThan(0); expect(diffs[1].action).toBe('deleted'); expect(diffs[2].action).toBe('added'); }); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index b0df12128..6ee9bef81 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -35,7 +35,7 @@ describe('Diff', () => { const modifiedDiffs = diffs.filter((diff) => diff.action === 'modified'); const addedDiffs = diffs.filter((diff) => diff.action === 'added'); const deletedDiffs = diffs.filter((diff) => diff.action === 'deleted'); - const attrOnlyDiffs = modifiedDiffs.filter((diff) => diff.textDiffs.length === 0); + const attrOnlyDiffs = modifiedDiffs.filter((diff) => diff.contentDiff.length === 0); expect(diffs).toHaveLength(19); expect(modifiedDiffs).toHaveLength(9); @@ -51,9 +51,9 @@ describe('Diff', () => { expect(diff?.newText).toBe( 'Curabitur facilisis ligula suscipit enim pretium et nunc ligula, porttitor augue consequat maximus.', ); - const textPropsChanges = diff?.textDiffs.filter((textDiff) => textDiff.action === 'modified'); + const textPropsChanges = diff?.contentDiff.filter((textDiff) => textDiff.action === 'modified'); expect(textPropsChanges).toHaveLength(18); - expect(diff?.textDiffs).toHaveLength(24); + expect(diff?.contentDiff).toHaveLength(24); // Deleted paragraph diff = getDiff( @@ -135,10 +135,10 @@ describe('Diff', () => { let diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'Here’s some text.'); expect(diff.newText).toBe('Here’s some NEW text.'); - expect(diff.textDiffs).toHaveLength(3); - expect(diff.textDiffs[0].newText).toBe(' '); - expect(diff.textDiffs[1].text).toBe('NEW'); - expect(diff.textDiffs[2].text).toBe(' '); + expect(diff.contentDiff).toHaveLength(3); + expect(diff.contentDiff[0].newText).toBe(' '); + expect(diff.contentDiff[1].text).toBe('NEW'); + expect(diff.contentDiff[2].text).toBe(' '); expect(diff.attrsDiff?.modified?.textId).toBeDefined(); diff = diffs.find((diff) => diff.action === 'deleted' && diff.oldText === 'I deleted this sentence.'); @@ -149,7 +149,7 @@ describe('Diff', () => { diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'We are not done yet.'); expect(diff.newText).toBe('We are done now.'); - expect(diff.textDiffs).toHaveLength(3); + expect(diff.contentDiff).toHaveLength(3); expect(diff.attrsDiff?.modified?.textId).toBeDefined(); }); @@ -163,4 +163,32 @@ describe('Diff', () => { const diff = diffs[0]; expect(diff.action).toBe('modified'); }); + + it('Compare another set of two documents with only formatting changes', async () => { + const docBefore = await getDocument('diff_before5.docx'); + const docAfter = await getDocument('diff_after5.docx'); + + const diffs = computeDiff(docBefore, docAfter); + + expect(diffs).toHaveLength(1); + const diff = diffs[0]; + expect(diff.action).toBe('modified'); + }); + + it('Compare another set of two documents where an image was added', async () => { + const docBefore = await getDocument('diff_before6.docx'); + const docAfter = await getDocument('diff_after6.docx'); + + const diffs = computeDiff(docBefore, docAfter); + expect(diffs).toHaveLength(1); + const diff = diffs[0]; + expect(diff.action).toBe('modified'); + expect(diff.contentDiff).toHaveLength(3); + expect(diff.contentDiff[0].action).toBe('modified'); + expect(diff.contentDiff[0].kind).toBe('text'); + expect(diff.contentDiff[1].action).toBe('added'); + expect(diff.contentDiff[1].kind).toBe('inlineNode'); + expect(diff.contentDiff[2].action).toBe('added'); + expect(diff.contentDiff[2].kind).toBe('text'); + }); }); diff --git a/packages/super-editor/src/extensions/diffing/utils.js b/packages/super-editor/src/extensions/diffing/utils.js index 133686530..f476b13b6 100644 --- a/packages/super-editor/src/extensions/diffing/utils.js +++ b/packages/super-editor/src/extensions/diffing/utils.js @@ -4,8 +4,8 @@ * @param {number} [paragraphPos=0] - Position of the paragraph in the document. * @returns {{text: {char: string, runAttrs: Record}[], resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. */ -export function getTextContent(paragraph, paragraphPos = 0) { - let text = []; +export function getParagraphContent(paragraph, paragraphPos = 0) { + let content = []; const segments = []; paragraph.nodesBetween( @@ -18,27 +18,39 @@ export function getTextContent(paragraph, paragraphPos = 0) { nodeText = node.text; } else if (node.isLeaf && node.type.spec.leafText) { nodeText = node.type.spec.leafText(node); - } - - if (!nodeText) { + } else if (node.type.name !== 'run' && node.isInline) { + const start = content.length; + const end = start + 1; + content.push({ + kind: 'inlineNode', + node: node, + }); + segments.push({ start, end, pos }); + return; + } else { return; } - const start = text.length; + const start = content.length; const end = start + nodeText.length; - // Get parent run node and its attributes const runNode = paragraph.nodeAt(pos - 1); const runAttrs = runNode.attrs || {}; - segments.push({ start, end, pos, runAttrs }); - text = text.concat(nodeText.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs) }))); + segments.push({ start, end, pos }); + const chars = nodeText.split('').map((char) => ({ + kind: 'text', + char, + runAttrs: JSON.stringify(runAttrs), + })); + + content = content.concat(chars); }, 0, ); const resolvePosition = (index) => { - if (index < 0 || index > text.length) { + if (index < 0 || index > content.length) { return null; } @@ -52,7 +64,7 @@ export function getTextContent(paragraph, paragraphPos = 0) { return paragraphPos + 1 + paragraph.content.size; }; - return { text, resolvePosition }; + return { text: content, resolvePosition }; } /** @@ -64,7 +76,7 @@ export function extractParagraphs(pmDoc) { const paragraphs = []; pmDoc.descendants((node, pos) => { if (node.type.name === 'paragraph') { - const { text, resolvePosition } = getTextContent(node, pos); + const { text, resolvePosition } = getParagraphContent(node, pos); paragraphs.push({ node, pos, diff --git a/packages/super-editor/src/extensions/diffing/utils.test.js b/packages/super-editor/src/extensions/diffing/utils.test.js index fbf85fa72..06a6a5448 100644 --- a/packages/super-editor/src/extensions/diffing/utils.test.js +++ b/packages/super-editor/src/extensions/diffing/utils.test.js @@ -1,6 +1,7 @@ -import { extractParagraphs, getTextContent } from './utils'; +import { extractParagraphs, getParagraphContent } from './utils'; -const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs) })); +const buildRuns = (text, attrs = {}) => + text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs), kind: 'text' })); const createParagraphNode = (text, attrs = {}) => ({ type: { name: 'paragraph' }, @@ -15,10 +16,26 @@ const createParagraphNode = (text, attrs = {}) => ({ const createParagraphWithSegments = (segments, contentSize) => { const computedSegments = segments.map((segment) => { + if (segment.inlineNode) { + return { + ...segment, + kind: 'inline', + length: segment.length ?? 1, + start: segment.start ?? 0, + attrs: segment.attrs ?? segment.inlineNode.attrs ?? {}, + inlineNode: { + typeName: segment.inlineNode.typeName ?? 'inline', + attrs: segment.inlineNode.attrs ?? {}, + isLeaf: segment.inlineNode.isLeaf ?? true, + }, + }; + } + const segmentText = segment.text ?? segment.leafText(); const length = segmentText.length; return { ...segment, + kind: segment.text != null ? 'text' : 'leaf', length, start: segment.start ?? 0, attrs: segment.attrs ?? {}, @@ -28,17 +45,28 @@ const createParagraphWithSegments = (segments, contentSize) => { contentSize ?? computedSegments.reduce((max, segment) => Math.max(max, segment.start + segment.length), 0); const attrsMap = new Map(); computedSegments.forEach((segment) => { - attrsMap.set(segment.start - 1, segment.attrs); + const key = segment.kind === 'inline' ? segment.start : segment.start - 1; + attrsMap.set(key, segment.attrs); }); return { content: { size }, nodesBetween: (from, to, callback) => { computedSegments.forEach((segment) => { - if (segment.text != null) { + if (segment.kind === 'text') { callback({ isText: true, text: segment.text }, segment.start); - } else { + } else if (segment.kind === 'leaf') { callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); + } else { + callback( + { + isInline: true, + isLeaf: segment.inlineNode.isLeaf, + type: { name: segment.inlineNode.typeName, spec: {} }, + attrs: segment.inlineNode.attrs, + }, + segment.start, + ); } }); }, @@ -92,11 +120,11 @@ describe('extractParagraphs', () => { }); }); -describe('getTextContent', () => { +describe('getParagraphContent', () => { it('handles basic text nodes', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); - const result = getTextContent(mockParagraph); + const result = getParagraphContent(mockParagraph); expect(result.text).toEqual(buildRuns('Hello', { bold: true })); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(4)).toBe(5); @@ -108,7 +136,7 @@ describe('getTextContent', () => { 4, ); - const result = getTextContent(mockParagraph); + const result = getParagraphContent(mockParagraph); expect(result.text).toEqual(buildRuns('Leaf', { type: 'leaf' })); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(3)).toBe(4); @@ -120,7 +148,7 @@ describe('getTextContent', () => { { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, ]); - const result = getTextContent(mockParagraph); + const result = getParagraphContent(mockParagraph); expect(result.text).toEqual([...buildRuns('Hello', { bold: true }), ...buildRuns('Leaf', { italic: true })]); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(5)).toBe(6); @@ -130,15 +158,44 @@ describe('getTextContent', () => { it('handles empty content', () => { const mockParagraph = createParagraphWithSegments([], 0); - const result = getTextContent(mockParagraph); + const result = getParagraphContent(mockParagraph); expect(result.text).toEqual([]); expect(result.resolvePosition(0)).toBe(1); }); + it('includes inline nodes that have no textual content', () => { + const inlineAttrs = { kind: 'tab', width: 120 }; + const mockParagraph = createParagraphWithSegments([ + { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 0 }, + { text: 'Text', start: 1, attrs: { bold: false } }, + ]); + + const result = getParagraphContent(mockParagraph); + debugger; + expect(result.text[0]).toEqual({ + kind: 'inlineNode', + node: { + attrs: { + kind: 'tab', + width: 120, + }, + isInline: true, + isLeaf: true, + type: { + name: 'tab', + spec: {}, + }, + }, + }); + expect(result.text.slice(1)).toEqual(buildRuns('Text', { bold: false })); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(1)).toBe(2); + }); + it('applies paragraph position offsets to the resolver', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); - const result = getTextContent(mockParagraph, 10); + const result = getParagraphContent(mockParagraph, 10); expect(result.text).toEqual(buildRuns('Nested', {})); expect(result.resolvePosition(0)).toBe(11); expect(result.resolvePosition(6)).toBe(17); diff --git a/packages/super-editor/src/tests/data/diff_after5.docx b/packages/super-editor/src/tests/data/diff_after5.docx new file mode 100644 index 0000000000000000000000000000000000000000..44e2dcbb8b5a730d6e4bec612d426e5035bbe457 GIT binary patch literal 13330 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_after6.docx b/packages/super-editor/src/tests/data/diff_after6.docx new file mode 100644 index 0000000000000000000000000000000000000000..9fc16f08531e7a45ea201105c8b26d682a909f6d GIT binary patch literal 24121 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before5.docx b/packages/super-editor/src/tests/data/diff_before5.docx new file mode 100644 index 0000000000000000000000000000000000000000..155ce140b4203080b329d9e4398b126af9ee3a96 GIT binary patch literal 13370 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before6.docx b/packages/super-editor/src/tests/data/diff_before6.docx new file mode 100644 index 0000000000000000000000000000000000000000..34f9c220cf6e1121647664d01cd937b11a0c983c GIT binary patch literal 13422 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 Date: Mon, 29 Dec 2025 14:07:22 -0300 Subject: [PATCH 19/53] feat: include previous element when building add diff --- .../src/extensions/diffing/algorithm/sequence-diffing.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js index 68af0d854..21fceb821 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js @@ -3,7 +3,7 @@ import { myersDiff } from './myers-diff.js'; /** * @typedef {Object} SequenceDiffOptions * @property {(a: any, b: any) => boolean} [comparator] equality test passed to Myers diff - * @property {(item: any, index: number) => any} buildAdded maps newly inserted entries + * @property {(item: any, oldIdx: number, previousOldItem: any, index: number) => any} buildAdded maps newly inserted entries * @property {(item: any, index: number) => any} buildDeleted maps removed entries * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => any|null} buildModified maps paired entries. If it returns null/undefined, it means no modification should be recorded. * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [shouldProcessEqualAsModification] decides if equal-aligned entries should emit a modification @@ -82,7 +82,10 @@ export function diffSequences(oldSeq, newSeq, options) { } if (step.type === 'insert') { - diffs.push(options.buildAdded(newSeq[step.newIdx], step.oldIdx, step.newIdx)); + const diff = options.buildAdded(newSeq[step.newIdx], step.oldIdx, oldSeq[step.oldIdx - 1], step.newIdx); + if (diff) { + diffs.push(diff); + } } } From ee95c829ecfdedd9f13246fd888798994c4e5ec5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 14:07:46 -0300 Subject: [PATCH 20/53] docs: add JSDoc to inline diffing --- .../diffing/algorithm/inline-diffing.js | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js index 638b553d4..2785d8ea4 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js @@ -1,17 +1,32 @@ -import { myersDiff } from './myers-diff.js'; import { getAttributesDiff } from './attributes-diffing.js'; import { diffSequences } from './sequence-diffing.js'; /** - * Computes text-level additions and deletions between two strings using Myers diff algorithm, mapping back to document positions. - * @param {{char: string, runAttrs: Record}[]} oldContent - Source text. - * @param {{char: string, runAttrs: Record}[]} newContent - Target text. + * @typedef {{kind: 'text', char: string, runAttrs: string}} InlineTextToken + */ + +/** + * @typedef {{kind: 'inlineNode', node: import('prosemirror-model').Node, nodeType?: string}} InlineNodeToken + */ + +/** + * @typedef {InlineTextToken|InlineNodeToken} InlineDiffToken + */ + +/** + * @typedef {{action: 'added'|'deleted'|'modified', kind: 'text'|'inlineNode', startPos: number|null, endPos: number|null, text?: string, oldText?: string, newText?: string, runAttrs?: Record, runAttrsDiff?: import('./attributes-diffing.js').AttributesDiff, node?: import('prosemirror-model').Node, nodeType?: string, oldNode?: import('prosemirror-model').Node, newNode?: import('prosemirror-model').Node}} InlineDiffResult + */ + +/** + * Computes text-level additions and deletions between two sequences using the generic sequence diff, mapping back to document positions. + * @param {InlineDiffToken[]} oldContent - Source tokens. + * @param {InlineDiffToken[]} newContent - Target tokens. * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the original document. * @param {(index: number) => number|null} [newPositionResolver=oldPositionResolver] - Maps string indexes to the updated document. - * @returns {Array} List of addition/deletion ranges with document positions and text content. + * @returns {InlineDiffResult[]} List of grouped inline diffs with document positions and text content. */ export function getInlineDiff(oldContent, newContent, oldPositionResolver, newPositionResolver = oldPositionResolver) { - const buildCharDiff = (action, token, oldIdx) => { + const buildInlineDiff = (action, token, oldIdx) => { if (token.kind !== 'text') { return { action, @@ -31,11 +46,11 @@ export function getInlineDiff(oldContent, newContent, oldPositionResolver, newPo let diffs = diffSequences(oldContent, newContent, { comparator: inlineComparator, shouldProcessEqualAsModification, - canTreatAsModification: (oldToken, newToken, oldIdx, newIdx) => + canTreatAsModification: (oldToken, newToken) => oldToken.kind === newToken.kind && oldToken.kind !== 'text' && oldToken.node.type.type === newToken.node.type, - buildAdded: (token, oldIdx, newIdx) => buildCharDiff('added', token, oldIdx), - buildDeleted: (token, oldIdx, newIdx) => buildCharDiff('deleted', token, oldIdx), - buildModified: (oldToken, newToken, oldIdx, newIdx) => { + buildAdded: (token, oldIdx) => buildInlineDiff('added', token, oldIdx), + buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx), + buildModified: (oldToken, newToken, oldIdx) => { if (oldToken.kind !== 'text') { return { action: 'modified', @@ -63,6 +78,13 @@ export function getInlineDiff(oldContent, newContent, oldPositionResolver, newPo return groupedDiffs; } +/** + * Compares two inline tokens to decide if they can be considered equal for the Myers diff. + * Text tokens compare character equality while inline nodes compare their type. + * @param {InlineDiffToken} a + * @param {InlineDiffToken} b + * @returns {boolean} + */ function inlineComparator(a, b) { if (a.kind !== b.kind) { return false; @@ -71,18 +93,31 @@ function inlineComparator(a, b) { if (a.kind === 'text') { return a.char === b.char; } else { - return true; + return a.node.type === b.node.type; } } +/** + * Determines whether equal tokens should still be treated as modifications, either because run attributes changed or the node payload differs. + * @param {InlineDiffToken} oldToken + * @param {InlineDiffToken} newToken + * @returns {boolean} + */ function shouldProcessEqualAsModification(oldToken, newToken) { if (oldToken.kind === 'text') { return oldToken.runAttrs !== newToken.runAttrs; } else { - return JSON.stringify(oldToken.nodeAttrs) !== JSON.stringify(newToken.nodeAttrs); + return JSON.stringify(oldToken.toJSON()) !== JSON.stringify(newToken.toJSON()); } } +/** + * Groups raw diff operations into contiguous ranges and converts serialized run attrs back to objects. + * @param {Array<{action:'added'|'deleted'|'modified', idx:number, kind:'text'|'inlineNode', text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string, nodeType?: string, node?: import('prosemirror-model').Node, oldNode?: import('prosemirror-model').Node, newNode?: import('prosemirror-model').Node}>} diffs + * @param {(index: number) => number|null} oldPositionResolver + * @param {(index: number) => number|null} newPositionResolver + * @returns {InlineDiffResult[]} + */ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { const grouped = []; let currentGroup = null; From 7ed8605472e77389e5b0c527ca6ccd2bdc66b359 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 14:08:18 -0300 Subject: [PATCH 21/53] fix: handle arrays in attributes diff --- .../diffing/algorithm/attributes-diffing.js | 46 +++++++++++++++++++ .../algorithm/attributes-diffing.test.js | 41 +++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js index 776fce854..2346d5c28 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js @@ -65,6 +65,12 @@ function diffObjects(objectA, objectB, basePath, diff) { continue; } + if (Array.isArray(valueA) && Array.isArray(valueB)) { + if (valueA.length === valueB.length && valueA.every((item, index) => deepEquals(item, valueB[index]))) { + continue; + } + } + if (valueA !== valueB) { diff.modified[path] = { from: valueA, @@ -130,3 +136,43 @@ function joinPath(base, key) { function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } + +/** + * Checks deep equality for primitives, arrays, and plain objects. + * @param {any} a + * @param {any} b + * @returns {boolean} + */ +function deepEquals(a, b) { + if (a === b) { + return true; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEquals(a[i], b[i])) { + return false; + } + } + return true; + } + + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) { + return false; + } + for (const key of keysA) { + if (!deepEquals(a[key], b[key])) { + return false; + } + } + return true; + } + + return false; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js index 0b9316c83..2b9c26998 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js @@ -122,4 +122,45 @@ describe('getAttributesDiff', () => { 'nested.value': { from: 1, to: 2 }, }); }); + + it('handles array equality and modifications', () => { + const objectA = { + tags: ['alpha', 'beta'], + nested: { + metrics: [ + { name: 'views', value: 10 }, + { name: 'likes', value: 5 }, + ], + }, + }; + + const objectB = { + tags: ['alpha', 'beta'], + nested: { + metrics: [ + { name: 'views', value: 12 }, + { name: 'likes', value: 5 }, + ], + }, + }; + + let diff = getAttributesDiff(objectA, objectB); + expect(diff.added).toEqual({}); + expect(diff.deleted).toEqual({}); + expect(diff.modified).toEqual({ + 'nested.metrics': { + from: [ + { name: 'views', value: 10 }, + { name: 'likes', value: 5 }, + ], + to: [ + { name: 'views', value: 12 }, + { name: 'likes', value: 5 }, + ], + }, + }); + + diff = getAttributesDiff(objectA, { ...objectA }); + expect(diff).toBeNull(); + }); }); From 370400daa44b5421b72f4f9a687d153fde7d4bef Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 14:09:03 -0300 Subject: [PATCH 22/53] feat: implement generic diffing for all nodes --- .../diffing/algorithm/generic-diffing.js | 199 ++++++++++++++++++ .../diffing/algorithm/generic-diffing.test.js | 166 +++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js new file mode 100644 index 000000000..b5d160beb --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js @@ -0,0 +1,199 @@ +import { + getParagraphContent, + paragraphComparator, + canTreatAsModification as canTreatParagraphDeletionInsertionAsModification, + shouldProcessEqualAsModification as shouldProcessEqualParagraphsAsModification, + buildAddedParagraphDiff, + buildDeletedParagraphDiff, + buildModifiedParagraphDiff, +} from './paragraph-diffing.js'; +import { diffSequences, reorderDiffOperations } from './sequence-diffing.js'; +import { getAttributesDiff } from './attributes-diffing.js'; + +/** + * @typedef {import('prosemirror-model').Node} PMNode + */ + +/** + * @typedef {Object} BaseNodeInfo + * @property {PMNode} node + * @property {number} pos + */ + +/** + * @typedef {BaseNodeInfo & { + * text: import('./paragraph-diffing.js').ParagraphContentToken[], + * resolvePosition: (idx: number) => number|null, + * fullText: string + * }} ParagraphNodeInfo + */ + +/** + * @typedef {BaseNodeInfo | ParagraphNodeInfo} NodeInfo + */ + +/** + * Produces a sequence diff between two ProseMirror documents, flattening paragraphs for inline-aware comparisons. + * @param {PMNode} oldRoot + * @param {PMNode} newRoot + * @returns {Array} + */ +export function diffNodes(oldRoot, newRoot) { + const oldNodes = normalizeNodes(oldRoot); + const newNodes = normalizeNodes(newRoot); + + const addedNodesSet = new Set(); + return diffSequences(oldNodes, newNodes, { + comparator: nodeComparator, + reorderOperations: reorderDiffOperations, + shouldProcessEqualAsModification, + canTreatAsModification, + buildAdded: (nodeInfo, oldIdx, previousOldNodeInfo) => buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet), + buildDeleted: buildDeletedDiff, + buildModified: buildModifiedDiff, + }); +} + +/** + * Traverses a ProseMirror document and converts paragraphs to richer node info objects. + * @param {PMNode} pmDoc + * @returns {NodeInfo[]} + */ +function normalizeNodes(pmDoc) { + const nodes = []; + pmDoc.descendants((node, pos) => { + if (node.type.name === 'paragraph') { + const { text, resolvePosition } = getParagraphContent(node, pos); + nodes.push({ + node, + pos, + text, + resolvePosition, + get fullText() { + return text.map((c) => c.char).join(''); + }, + }); + return false; // Do not descend further + } else { + nodes.push({ node, pos }); + } + }); + return nodes; +} + +/** + * Compares two node infos to determine if they correspond to the same logical node. + * Paragraphs are compared with `paragraphComparator`, while other nodes are matched by type name. + * @param {NodeInfo} oldNodeInfo + * @param {NodeInfo} newNodeInfo + * @returns {boolean} + */ +function nodeComparator(oldNodeInfo, newNodeInfo) { + if (oldNodeInfo.node.type.name !== newNodeInfo.node.type.name) { + return false; + } + if (oldNodeInfo.node.type.name === 'paragraph') { + return paragraphComparator(oldNodeInfo, newNodeInfo); + } else { + return oldNodeInfo.node.type.name === newNodeInfo.node.type.name; + } +} + +/** + * Decides whether nodes deemed equal by the diff should still be emitted as modifications. + * Paragraph nodes leverage their specialized handler, while other nodes compare attribute JSON. + * @param {NodeInfo} oldNodeInfo + * @param {NodeInfo} newNodeInfo + * @returns {boolean} + */ +function shouldProcessEqualAsModification(oldNodeInfo, newNodeInfo) { + if (oldNodeInfo.node.type.name === 'paragraph' && newNodeInfo.node.type.name === 'paragraph') { + return shouldProcessEqualParagraphsAsModification(oldNodeInfo, newNodeInfo); + } + return JSON.stringify(oldNodeInfo.node.attrs) !== JSON.stringify(newNodeInfo.node.attrs); +} + +/** + * Determines whether a delete/insert pair should instead be surfaced as a modification. + * Only paragraphs qualify because we can measure textual similarity; other nodes remain as-is. + * @param {NodeInfo} deletedNodeInfo + * @param {NodeInfo} insertedNodeInfo + * @returns {boolean} + */ +function canTreatAsModification(deletedNodeInfo, insertedNodeInfo) { + if (deletedNodeInfo.node.type.name === 'paragraph' && insertedNodeInfo.node.type.name === 'paragraph') { + return canTreatParagraphDeletionInsertionAsModification(deletedNodeInfo, insertedNodeInfo); + } + return false; +} + +/** + * Builds the diff payload for an inserted node and tracks descendants to avoid duplicates. + * @param {NodeInfo} nodeInfo + * @param {NodeInfo} previousOldNodeInfo + * @param {Set} addedNodesSet + * @returns {ReturnType|{action:'added', nodeType:string, node:PMNode, pos:number}|null} + */ +function buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet) { + if (addedNodesSet.has(nodeInfo.node)) { + return null; + } + addedNodesSet.add(nodeInfo.node); + if (nodeInfo.node.type.name === 'paragraph') { + return buildAddedParagraphDiff(nodeInfo, previousOldNodeInfo); + } + nodeInfo.node.descendants((childNode) => { + addedNodesSet.add(childNode); + }); + + const pos = previousOldNodeInfo.pos + previousOldNodeInfo.node.nodeSize; + return { + action: 'added', + nodeType: nodeInfo.node.type.name, + node: nodeInfo.node, + pos, + }; +} + +/** + * Builds the diff payload for a deleted node. + * @param {NodeInfo} nodeInfo + * @returns {ReturnType|{action:'deleted', nodeType:string, node:PMNode, pos:number}} + */ +function buildDeletedDiff(nodeInfo) { + if (nodeInfo.node.type.name === 'paragraph') { + return buildDeletedParagraphDiff(nodeInfo); + } + return { + action: 'deleted', + nodeType: nodeInfo.node.type.name, + node: nodeInfo.node, + pos: nodeInfo.pos, + }; +} + +/** + * Builds the diff payload for a modified node. + * Paragraphs delegate to their inline-aware builder, while other nodes report attribute diffs. + * @param {NodeInfo} oldNodeInfo + * @param {NodeInfo} newNodeInfo + * @returns {ReturnType|{action:'modified', nodeType:string, oldNode:PMNode, newNode:PMNode, pos:number, attrsDiff: import('./attributes-diffing.js').AttributesDiff}|null} + */ +function buildModifiedDiff(oldNodeInfo, newNodeInfo) { + if (oldNodeInfo.node.type.name === 'paragraph' && newNodeInfo.node.type.name === 'paragraph') { + return buildModifiedParagraphDiff(oldNodeInfo, newNodeInfo); + } + + const attrsDiff = getAttributesDiff(oldNodeInfo.node.attrs, newNodeInfo.node.attrs); + if (!attrsDiff) { + return null; + } + return { + action: 'modified', + nodeType: oldNodeInfo.node.type.name, + oldNode: oldNodeInfo.node, + newNode: newNodeInfo.node, + pos: newNodeInfo.pos, + attrsDiff, + }; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js new file mode 100644 index 000000000..8c5ef5b2e --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import { diffNodes } from './generic-diffing.js'; + +const createDocFromNodes = (nodes = []) => ({ + descendants(callback) { + nodes.forEach(({ node, pos }) => callback(node, pos)); + }, +}); + +const buildSimpleNode = (typeName, attrs = {}, options = {}) => { + const { nodeSize = 2, children = [] } = options; + const node = { + attrs, + type: { name: typeName, spec: {} }, + nodeSize, + descendants(cb) { + children.forEach((child, index) => { + cb(child, index + 1); + if (typeof child.descendants === 'function') { + child.descendants(cb); + } + }); + }, + }; + node.toJSON = () => ({ type: node.type.name, attrs: node.attrs }); + return node; +}; + +const createParagraph = (text, attrs = {}, options = {}) => { + const { pos = 0, textAttrs = {} } = options; + const paragraphNode = { + attrs, + type: { name: 'paragraph', spec: {} }, + nodeSize: text.length + 2, + content: { size: text.length }, + nodesBetween(_from, _to, callback) { + if (!text.length) { + return; + } + callback( + { + isText: true, + text, + type: { name: 'text', spec: {} }, + isLeaf: false, + isInline: true, + }, + 1, + ); + }, + nodeAt() { + return { attrs: textAttrs }; + }, + }; + paragraphNode.toJSON = () => ({ type: paragraphNode.type.name, attrs: paragraphNode.attrs }); + + return { node: paragraphNode, pos }; +}; + +describe('diffParagraphs', () => { + it('treats similar paragraphs without IDs as modifications', () => { + const oldParagraphs = [createParagraph('Hello world from ProseMirror.')]; + const newParagraphs = [createParagraph('Hello brave new world from ProseMirror.')]; + const oldRoot = { descendants: (cb) => oldParagraphs.forEach((p) => cb(p.node, p.pos)) }; + const newRoot = { descendants: (cb) => newParagraphs.forEach((p) => cb(p.node, p.pos)) }; + + const diffs = diffNodes(oldRoot, newRoot); + + expect(diffs).toHaveLength(1); + expect(diffs[0].action).toBe('modified'); + expect(diffs[0].contentDiff.length).toBeGreaterThan(0); + }); + + it('keeps unrelated paragraphs as deletion + addition', () => { + const oldParagraphs = [createParagraph('Alpha paragraph with some text.')]; + const newParagraphs = [createParagraph('Zephyr quickly jinxed the new passage.')]; + const oldRoot = { descendants: (cb) => oldParagraphs.forEach((p) => cb(p.node, p.pos)) }; + const newRoot = { descendants: (cb) => newParagraphs.forEach((p) => cb(p.node, p.pos)) }; + + const diffs = diffNodes(oldRoot, newRoot); + + expect(diffs).toHaveLength(2); + expect(diffs[0].action).toBe('deleted'); + expect(diffs[1].action).toBe('added'); + }); + + it('detects modifications even when Myers emits grouped deletes and inserts', () => { + const oldParagraphs = [ + createParagraph('Original introduction paragraph that needs tweaks.'), + createParagraph('Paragraph that will be removed.'), + ]; + const newParagraphs = [ + createParagraph('Original introduction paragraph that now has tweaks.'), + createParagraph('Completely different replacement paragraph.'), + ]; + const oldRoot = { descendants: (cb) => oldParagraphs.forEach((p) => cb(p.node, p.pos)) }; + const newRoot = { descendants: (cb) => newParagraphs.forEach((p) => cb(p.node, p.pos)) }; + + const diffs = diffNodes(oldRoot, newRoot); + + expect(diffs).toHaveLength(3); + expect(diffs[0].action).toBe('modified'); + expect(diffs[0].contentDiff.length).toBeGreaterThan(0); + expect(diffs[1].action).toBe('deleted'); + expect(diffs[2].action).toBe('added'); + }); + + it('treats paragraph attribute-only changes as modifications', () => { + const oldParagraph = createParagraph('Consistent text', { align: 'left' }); + const newParagraph = createParagraph('Consistent text', { align: 'right' }); + const diffs = diffNodes(createDocFromNodes([oldParagraph]), createDocFromNodes([newParagraph])); + + expect(diffs).toHaveLength(1); + expect(diffs[0].action).toBe('modified'); + expect(diffs[0].contentDiff).toEqual([]); + expect(diffs[0].attrsDiff?.modified?.align).toEqual({ from: 'left', to: 'right' }); + }); + + it('emits attribute diffs for non-paragraph nodes', () => { + const oldHeading = { node: buildSimpleNode('heading', { level: 1 }), pos: 0 }; + const newHeading = { node: buildSimpleNode('heading', { level: 2 }), pos: 0 }; + const diffs = diffNodes(createDocFromNodes([oldHeading]), createDocFromNodes([newHeading])); + + expect(diffs).toHaveLength(1); + expect(diffs[0]).toMatchObject({ + action: 'modified', + nodeType: 'heading', + }); + expect(diffs[0].attrsDiff?.modified?.level).toEqual({ from: 1, to: 2 }); + }); + + it('deduplicates added nodes and their descendants', () => { + const childNode = buildSimpleNode('image'); + const parentNode = buildSimpleNode('figure', {}, { children: [childNode] }); + const oldParagraph = createParagraph('Base paragraph', {}, { pos: 0 }); + const newParagraph = createParagraph('Base paragraph', {}, { pos: 0 }); + const insertionPos = oldParagraph.pos + oldParagraph.node.nodeSize; + const diffs = diffNodes( + createDocFromNodes([oldParagraph]), + createDocFromNodes([ + newParagraph, + { node: parentNode, pos: insertionPos }, + { node: childNode, pos: insertionPos + 1 }, + ]), + ); + + const additions = diffs.filter((diff) => diff.action === 'added'); + expect(additions).toHaveLength(1); + expect(additions[0].nodeType).toBe('figure'); + }); + + it('computes insertion position based on the previous old node', () => { + const oldParagraph = createParagraph('Hello!', {}, { pos: 0 }); + const newParagraph = createParagraph('Hello!', {}, { pos: 0 }); + const headingNode = buildSimpleNode('heading', { level: 1 }, { nodeSize: 3 }); + const expectedPos = oldParagraph.pos + oldParagraph.node.nodeSize; + + const diffs = diffNodes( + createDocFromNodes([oldParagraph]), + createDocFromNodes([newParagraph, { node: headingNode, pos: expectedPos }]), + ); + + const addition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'heading'); + expect(addition?.pos).toBe(expectedPos); + }); +}); From cc3c3003d9e601b64e4267986dbb9281efcccb12 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 14:09:31 -0300 Subject: [PATCH 23/53] refactor: move paragraph utility functions --- .../diffing/algorithm/paragraph-diffing.js | 154 ++++++-- .../algorithm/paragraph-diffing.test.js | 343 +++++++++++++++--- .../src/extensions/diffing/utils.js | 93 ----- .../src/extensions/diffing/utils.test.js | 203 ----------- 4 files changed, 419 insertions(+), 374 deletions(-) delete mode 100644 packages/super-editor/src/extensions/diffing/utils.js delete mode 100644 packages/super-editor/src/extensions/diffing/utils.test.js diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js index a7a6ce0f6..e852b0189 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js @@ -1,7 +1,5 @@ -import { myersDiff } from './myers-diff.js'; import { getInlineDiff } from './inline-diffing.js'; import { getAttributesDiff } from './attributes-diffing.js'; -import { diffSequences, reorderDiffOperations } from './sequence-diffing.js'; import { levenshteinDistance } from './similarity.js'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. @@ -12,6 +10,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; * A paragraph addition diff emitted when new content is inserted. * @typedef {Object} AddedParagraphDiff * @property {'added'} action + * @property {string} nodeType ProseMirror node.name for downstream handling * @property {Node} node reference to the ProseMirror node for consumers needing schema details * @property {string} text textual contents of the inserted paragraph * @property {number} pos document position where the paragraph was inserted @@ -21,6 +20,7 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; * A paragraph deletion diff emitted when content is removed. * @typedef {Object} DeletedParagraphDiff * @property {'deleted'} action + * @property {string} nodeType ProseMirror node.name for downstream handling * @property {Node} node reference to the original ProseMirror node * @property {string} oldText text that was removed * @property {number} pos starting document position of the original paragraph @@ -30,10 +30,11 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; * A paragraph modification diff that carries inline text-level changes. * @typedef {Object} ModifiedParagraphDiff * @property {'modified'} action + * @property {string} nodeType ProseMirror node.name for downstream handling * @property {string} oldText text before the edit * @property {string} newText text after the edit * @property {number} pos original document position for anchoring UI - * @property {Array} contentDiff granular inline diff data + * @property {ReturnType} contentDiff granular inline diff data * @property {import('./attributes-diffing.js').AttributesDiff|null} attrsDiff attribute-level changes between the old and new paragraph nodes */ @@ -43,36 +44,112 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; */ /** - * Runs a paragraph-level diff using Myers algorithm to align paragraphs that move, get edited, or are added/removed. - * The extra bookkeeping around the raw diff ensures that downstream consumers can map operations back to paragraph - * positions. - * @param {Array} oldParagraphs - * @param {Array} newParagraphs - * @returns {Array} + * A flattened representation of a text token derived from a paragraph. + * @typedef {Object} ParagraphTextToken + * @property {'text'} kind + * @property {string} char + * @property {string} runAttrs JSON stringified run attributes originating from the parent node */ -export function diffParagraphs(oldParagraphs, newParagraphs) { - return diffSequences(oldParagraphs, newParagraphs, { - comparator: paragraphComparator, - reorderOperations: reorderDiffOperations, - shouldProcessEqualAsModification: (oldParagraph, newParagraph) => - JSON.stringify(oldParagraph.text) !== JSON.stringify(newParagraph.text) || - JSON.stringify(oldParagraph.node.attrs) !== JSON.stringify(newParagraph.node.attrs), - canTreatAsModification, - buildAdded: (paragraph) => buildAddedParagraphDiff(paragraph), - buildDeleted: (paragraph) => buildDeletedParagraphDiff(paragraph), - buildModified: (oldParagraph, newParagraph) => buildModifiedParagraphDiff(oldParagraph, newParagraph), - }); + +/** + * A flattened representation of an inline node that is treated as a single token by the diff. + * @typedef {Object} ParagraphInlineNodeToken + * @property {'inlineNode'} kind + * @property {Node} node + */ + +/** + * @typedef {ParagraphTextToken|ParagraphInlineNodeToken} ParagraphContentToken + */ + +/** + * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. + * @param {Node} paragraph - Paragraph node to flatten. + * @param {number} [paragraphPos=0] - Position of the paragraph in the document. + * @returns {{text: ParagraphContentToken[], resolvePosition: (index: number) => number|null}} Concatenated text tokens and a resolver that maps indexes to document positions. + */ +export function getParagraphContent(paragraph, paragraphPos = 0) { + let content = []; + const segments = []; + + paragraph.nodesBetween( + 0, + paragraph.content.size, + (node, pos) => { + let nodeText = ''; + + if (node.isText) { + nodeText = node.text; + } else if (node.isLeaf && node.type.spec.leafText) { + nodeText = node.type.spec.leafText(node); + } else if (node.type.name !== 'run' && node.isInline) { + const start = content.length; + const end = start + 1; + content.push({ + kind: 'inlineNode', + node: node, + }); + segments.push({ start, end, pos }); + return; + } else { + return; + } + + const start = content.length; + const end = start + nodeText.length; + + const runNode = paragraph.nodeAt(pos - 1); + const runAttrs = runNode.attrs || {}; + + segments.push({ start, end, pos }); + const chars = nodeText.split('').map((char) => ({ + kind: 'text', + char, + runAttrs: JSON.stringify(runAttrs), + })); + + content = content.concat(chars); + }, + 0, + ); + + const resolvePosition = (index) => { + if (index < 0 || index > content.length) { + return null; + } + + for (const segment of segments) { + if (index >= segment.start && index < segment.end) { + return paragraphPos + 1 + segment.pos + (index - segment.start); + } + } + + // If index points to the end of the string, return the paragraph end + return paragraphPos + 1 + paragraph.content.size; + }; + + return { text: content, resolvePosition }; +} + +/** + * Determines whether equal paragraph nodes should still be marked as modified because their serialized structure differs. + * @param {{node: Node}} oldParagraph + * @param {{node: Node}} newParagraph + * @returns {boolean} + */ +export function shouldProcessEqualAsModification(oldParagraph, newParagraph) { + return JSON.stringify(oldParagraph.node.toJSON()) !== JSON.stringify(newParagraph.node.toJSON()); } /** * Compares two paragraphs for identity based on paraId or text content so the diff can prioritize logical matches. * This prevents the algorithm from treating the same paragraph as a deletion+insertion when the paraId or text proves * they refer to the same logical node, which in turn keeps visual diffs stable. - * @param {{node: Node, text: string}} oldParagraph - * @param {{node: Node, text: string}} newParagraph + * @param {{node: Node, fullText: string}} oldParagraph + * @param {{node: Node, fullText: string}} newParagraph * @returns {boolean} */ -function paragraphComparator(oldParagraph, newParagraph) { +export function paragraphComparator(oldParagraph, newParagraph) { const oldId = oldParagraph?.node?.attrs?.paraId; const newId = newParagraph?.node?.attrs?.paraId; if (oldId && newId && oldId === newId) { @@ -83,26 +160,30 @@ function paragraphComparator(oldParagraph, newParagraph) { /** * Builds a normalized payload describing a paragraph addition, ensuring all consumers receive the same metadata shape. - * @param {{node: Node, pos: number, text: string}} paragraph + * @param {{node: Node, pos: number, fullText: string}} paragraph + * @param {{node: Node, pos: number}} previousOldNodeInfo node/position reference used to determine insertion point * @returns {AddedParagraphDiff} */ -function buildAddedParagraphDiff(paragraph) { +export function buildAddedParagraphDiff(paragraph, previousOldNodeInfo) { + const pos = previousOldNodeInfo.pos + previousOldNodeInfo.node.nodeSize; return { action: 'added', + nodeType: paragraph.node.type.name, node: paragraph.node, text: paragraph.fullText, - pos: paragraph.pos, + pos: pos, }; } /** * Builds a normalized payload describing a paragraph deletion so diff consumers can show removals with all context. - * @param {{node: Node, pos: number}} paragraph + * @param {{node: Node, pos: number, fullText: string}} paragraph * @returns {DeletedParagraphDiff} */ -function buildDeletedParagraphDiff(paragraph) { +export function buildDeletedParagraphDiff(paragraph) { return { action: 'deleted', + nodeType: paragraph.node.type.name, node: paragraph.node, oldText: paragraph.fullText, pos: paragraph.pos, @@ -111,11 +192,11 @@ function buildDeletedParagraphDiff(paragraph) { /** * Builds the payload for a paragraph modification, including text-level diffs, so renderers can highlight edits inline. - * @param {{node: Node, pos: number, text: string, resolvePosition: Function}} oldParagraph - * @param {{node: Node, pos: number, text: string, resolvePosition: Function}} newParagraph - * @returns {ModifiedParagraphDiff} + * @param {{node: Node, pos: number, text: ParagraphContentToken[], resolvePosition: Function, fullText: string}} oldParagraph + * @param {{node: Node, pos: number, text: ParagraphContentToken[], resolvePosition: Function, fullText: string}} newParagraph + * @returns {ModifiedParagraphDiff|null} */ -function buildModifiedParagraphDiff(oldParagraph, newParagraph) { +export function buildModifiedParagraphDiff(oldParagraph, newParagraph) { const contentDiff = getInlineDiff( oldParagraph.text, newParagraph.text, @@ -130,6 +211,7 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { return { action: 'modified', + nodeType: oldParagraph.node.type.name, oldText: oldParagraph.fullText, newText: newParagraph.fullText, pos: oldParagraph.pos, @@ -141,11 +223,11 @@ function buildModifiedParagraphDiff(oldParagraph, newParagraph) { /** * Decides whether a delete/insert pair should be reinterpreted as a modification to minimize noisy diff output. * This heuristic limits the number of false-positive additions/deletions, which keeps reviewers focused on real edits. - * @param {{node: Node, text: string}} oldParagraph - * @param {{node: Node, text: string}} newParagraph + * @param {{node: Node, fullText: string}} oldParagraph + * @param {{node: Node, fullText: string}} newParagraph * @returns {boolean} */ -function canTreatAsModification(oldParagraph, newParagraph) { +export function canTreatAsModification(oldParagraph, newParagraph) { if (paragraphComparator(oldParagraph, newParagraph)) { return true; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index 3429bd906..b457e38d8 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -1,63 +1,322 @@ import { describe, it, expect } from 'vitest'; -import { diffParagraphs } from './paragraph-diffing.js'; +import { + getParagraphContent, + shouldProcessEqualAsModification, + paragraphComparator, + buildAddedParagraphDiff, + buildDeletedParagraphDiff, + buildModifiedParagraphDiff, + canTreatAsModification, +} from './paragraph-diffing.js'; -const buildTextRuns = (text, runAttrs = {}) => - text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs) })); +const buildRuns = (text, attrs = {}) => + text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs), kind: 'text' })); -const createParagraph = (text, attrs = {}, options = {}) => { - const { pos = 0, textAttrs = {} } = options; - const textRuns = buildTextRuns(text, textAttrs); +const createParagraphNode = (overrides = {}) => ({ + type: { name: 'paragraph', ...(overrides.type || {}) }, + attrs: {}, + nodeSize: 5, + ...overrides, +}); + +const createParagraphInfo = (overrides = {}) => { + const fullText = overrides.fullText ?? 'text'; + const textTokens = overrides.text ?? buildRuns(fullText); return { - node: { attrs }, - pos, - text: textRuns, - resolvePosition: (index) => pos + 1 + index, - get fullText() { - return textRuns.map((c) => c.char).join(''); + node: createParagraphNode(overrides.node), + pos: 0, + fullText, + text: textTokens, + resolvePosition: (idx) => idx, + ...overrides, + }; +}; + +const createParagraphWithSegments = (segments, contentSize) => { + const computedSegments = segments.map((segment) => { + if (segment.inlineNode) { + return { + ...segment, + kind: 'inline', + length: segment.length ?? 1, + start: segment.start ?? 0, + attrs: segment.attrs ?? segment.inlineNode.attrs ?? {}, + inlineNode: { + typeName: segment.inlineNode.typeName ?? 'inline', + attrs: segment.inlineNode.attrs ?? {}, + isLeaf: segment.inlineNode.isLeaf ?? true, + }, + }; + } + + const segmentText = segment.text ?? segment.leafText(); + const length = segmentText.length; + return { + ...segment, + kind: segment.text != null ? 'text' : 'leaf', + length, + start: segment.start ?? 0, + attrs: segment.attrs ?? {}, + }; + }); + const size = + contentSize ?? computedSegments.reduce((max, segment) => Math.max(max, segment.start + segment.length), 0); + const attrsMap = new Map(); + computedSegments.forEach((segment) => { + const key = segment.kind === 'inline' ? segment.start : segment.start - 1; + attrsMap.set(key, segment.attrs); + }); + + return { + content: { size }, + nodesBetween: (from, to, callback) => { + computedSegments.forEach((segment) => { + if (segment.kind === 'text') { + callback({ isText: true, text: segment.text }, segment.start); + } else if (segment.kind === 'leaf') { + callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); + } else { + callback( + { + isInline: true, + isLeaf: segment.inlineNode.isLeaf, + type: { name: segment.inlineNode.typeName, spec: {} }, + attrs: segment.inlineNode.attrs, + }, + segment.start, + ); + } + }); }, + nodeAt: (pos) => ({ attrs: attrsMap.get(pos) ?? {} }), }; }; -describe('diffParagraphs', () => { - it('treats similar paragraphs without IDs as modifications', () => { - const oldParagraphs = [createParagraph('Hello world from ProseMirror.')]; - const newParagraphs = [createParagraph('Hello brave new world from ProseMirror.')]; +describe('getParagraphContent', () => { + it('handles basic text nodes', () => { + const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); + + const result = getParagraphContent(mockParagraph); + expect(result.text).toEqual(buildRuns('Hello', { bold: true })); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(4)).toBe(5); + }); - const diffs = diffParagraphs(oldParagraphs, newParagraphs); + it('handles leaf nodes with leafText', () => { + const mockParagraph = createParagraphWithSegments( + [{ leafText: () => 'Leaf', start: 0, attrs: { type: 'leaf' } }], + 4, + ); - expect(diffs).toHaveLength(1); - expect(diffs[0].action).toBe('modified'); - expect(diffs[0].contentDiff.length).toBeGreaterThan(0); + const result = getParagraphContent(mockParagraph); + expect(result.text).toEqual(buildRuns('Leaf', { type: 'leaf' })); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(3)).toBe(4); }); - it('keeps unrelated paragraphs as deletion + addition', () => { - const oldParagraphs = [createParagraph('Alpha paragraph with some text.')]; - const newParagraphs = [createParagraph('Zephyr quickly jinxed the new passage.')]; + it('handles mixed content', () => { + const mockParagraph = createParagraphWithSegments([ + { text: 'Hello', start: 0, attrs: { bold: true } }, + { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, + ]); - const diffs = diffParagraphs(oldParagraphs, newParagraphs); + const result = getParagraphContent(mockParagraph); + expect(result.text).toEqual([...buildRuns('Hello', { bold: true }), ...buildRuns('Leaf', { italic: true })]); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(5)).toBe(6); + expect(result.resolvePosition(9)).toBe(10); + }); + + it('handles empty content', () => { + const mockParagraph = createParagraphWithSegments([], 0); - expect(diffs).toHaveLength(2); - expect(diffs[0].action).toBe('deleted'); - expect(diffs[1].action).toBe('added'); + const result = getParagraphContent(mockParagraph); + expect(result.text).toEqual([]); + expect(result.resolvePosition(0)).toBe(1); }); - it('detects modifications even when Myers emits grouped deletes and inserts', () => { - const oldParagraphs = [ - createParagraph('Original introduction paragraph that needs tweaks.'), - createParagraph('Paragraph that will be removed.'), - ]; - const newParagraphs = [ - createParagraph('Original introduction paragraph that now has tweaks.'), - createParagraph('Completely different replacement paragraph.'), - ]; + it('includes inline nodes that have no textual content', () => { + const inlineAttrs = { kind: 'tab', width: 120 }; + const mockParagraph = createParagraphWithSegments([ + { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 0 }, + { text: 'Text', start: 1, attrs: { bold: false } }, + ]); + + const result = getParagraphContent(mockParagraph); + expect(result.text[0]).toEqual({ + kind: 'inlineNode', + node: { + attrs: { + kind: 'tab', + width: 120, + }, + isInline: true, + isLeaf: true, + type: { + name: 'tab', + spec: {}, + }, + }, + }); + expect(result.text.slice(1)).toEqual(buildRuns('Text', { bold: false })); + expect(result.resolvePosition(0)).toBe(1); + expect(result.resolvePosition(1)).toBe(2); + }); + + it('applies paragraph position offsets to the resolver', () => { + const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); + + const result = getParagraphContent(mockParagraph, 10); + expect(result.text).toEqual(buildRuns('Nested', {})); + expect(result.resolvePosition(0)).toBe(11); + expect(result.resolvePosition(6)).toBe(17); + }); + + it('returns null when index is outside the flattened text array', () => { + const mockParagraph = createParagraphWithSegments([{ text: 'Hi', start: 0 }], 2); + const { resolvePosition } = getParagraphContent(mockParagraph); + + expect(resolvePosition(-1)).toBeNull(); + expect(resolvePosition(3)).toBeNull(); + expect(resolvePosition(2)).toBe(3); + }); +}); + +describe('shouldProcessEqualAsModification', () => { + it('returns true when node JSON differs', () => { + const baseNode = { toJSON: () => ({ attrs: { bold: true } }) }; + const modifiedNode = { toJSON: () => ({ attrs: { bold: false } }) }; + + expect(shouldProcessEqualAsModification({ node: baseNode }, { node: modifiedNode })).toBe(true); + }); + + it('returns false when serialized nodes are identical', () => { + const node = { toJSON: () => ({ attrs: { bold: true } }) }; + expect(shouldProcessEqualAsModification({ node }, { node })).toBe(false); + }); +}); - const diffs = diffParagraphs(oldParagraphs, newParagraphs); +describe('paragraphComparator', () => { + it('treats paragraphs with the same paraId as equal', () => { + const makeInfo = (id) => ({ node: { attrs: { paraId: id } } }); + expect(paragraphComparator(makeInfo('123'), makeInfo('123'))).toBe(true); + }); + + it('falls back to comparing fullText when ids differ', () => { + const makeInfo = (text) => ({ node: { attrs: {} }, fullText: text }); + expect(paragraphComparator(makeInfo('same text'), makeInfo('same text'))).toBe(true); + }); + + it('returns false for paragraphs with different identity signals', () => { + expect(paragraphComparator({ fullText: 'one' }, { fullText: 'two' })).toBe(false); + }); +}); + +describe('paragraph diff builders', () => { + it('builds added paragraph payloads with consistent metadata', () => { + const paragraph = createParagraphInfo({ + node: createParagraphNode({ type: { name: 'paragraph' } }), + fullText: 'Hello', + }); + const previousNode = { pos: 10, node: { nodeSize: 4 } }; + + expect(buildAddedParagraphDiff(paragraph, previousNode)).toEqual({ + action: 'added', + nodeType: 'paragraph', + node: paragraph.node, + text: 'Hello', + pos: 14, + }); + }); + + it('builds deletion payloads reflecting the original paragraph context', () => { + const paragraph = createParagraphInfo({ pos: 7, fullText: 'Old text' }); + + expect(buildDeletedParagraphDiff(paragraph)).toEqual({ + action: 'deleted', + nodeType: 'paragraph', + node: paragraph.node, + oldText: 'Old text', + pos: 7, + }); + }); + + it('returns a diff with inline changes when content differs', () => { + const oldParagraph = createParagraphInfo({ + pos: 5, + fullText: 'foo', + text: buildRuns('foo'), + node: createParagraphNode({ attrs: { align: 'left' } }), + }); + const newParagraph = createParagraphInfo({ + pos: 5, + fullText: 'bar', + text: buildRuns('bar'), + node: createParagraphNode({ attrs: { align: 'left' } }), + }); + + const diff = buildModifiedParagraphDiff(oldParagraph, newParagraph); + expect(diff).not.toBeNull(); + expect(diff).toMatchObject({ + action: 'modified', + nodeType: 'paragraph', + oldText: 'foo', + newText: 'bar', + pos: 5, + attrsDiff: null, + }); + expect(diff.contentDiff.length).toBeGreaterThan(0); + }); + + it('returns null when neither text nor attributes changed', () => { + const baseParagraph = createParagraphInfo({ + fullText: 'stable', + node: createParagraphNode({ attrs: { align: 'left' } }), + }); + + expect(buildModifiedParagraphDiff(baseParagraph, baseParagraph)).toBeNull(); + }); + + it('returns a diff when only the attributes change', () => { + const oldParagraph = createParagraphInfo({ + node: createParagraphNode({ attrs: { align: 'left' } }), + }); + const newParagraph = createParagraphInfo({ + node: createParagraphNode({ attrs: { align: 'right' } }), + }); + + const diff = buildModifiedParagraphDiff(oldParagraph, newParagraph); + expect(diff).not.toBeNull(); + expect(diff.contentDiff).toEqual([]); + expect(diff.attrsDiff?.modified).toHaveProperty('align'); + }); +}); + +describe('canTreatAsModification', () => { + it('returns true when paragraph comparator matches by paraId', () => { + const buildInfo = (paraId) => ({ + node: { attrs: { paraId } }, + fullText: 'abc', + }); + expect(canTreatAsModification(buildInfo('id'), buildInfo('id'))).toBe(true); + }); + + it('returns false for short paragraphs lacking identity signals', () => { + const a = { node: { attrs: {} }, fullText: 'abc' }; + const b = { node: { attrs: {} }, fullText: 'xyz' }; + expect(canTreatAsModification(a, b)).toBe(false); + }); + + it('returns true when textual similarity exceeds the threshold', () => { + const a = { node: { attrs: {} }, fullText: 'lorem' }; + const b = { node: { attrs: {} }, fullText: 'loren' }; + expect(canTreatAsModification(a, b)).toBe(true); + }); - expect(diffs).toHaveLength(3); - expect(diffs[0].action).toBe('modified'); - expect(diffs[0].contentDiff.length).toBeGreaterThan(0); - expect(diffs[1].action).toBe('deleted'); - expect(diffs[2].action).toBe('added'); + it('returns false when paragraphs are dissimilar', () => { + const a = { node: { attrs: {} }, fullText: 'lorem ipsum' }; + const b = { node: { attrs: {} }, fullText: 'dolor sit' }; + expect(canTreatAsModification(a, b)).toBe(false); }); }); diff --git a/packages/super-editor/src/extensions/diffing/utils.js b/packages/super-editor/src/extensions/diffing/utils.js deleted file mode 100644 index f476b13b6..000000000 --- a/packages/super-editor/src/extensions/diffing/utils.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. - * @param {Node} paragraph - Paragraph node to flatten. - * @param {number} [paragraphPos=0] - Position of the paragraph in the document. - * @returns {{text: {char: string, runAttrs: Record}[], resolvePosition: (index: number) => number|null}} Concatenated text and position resolver. - */ -export function getParagraphContent(paragraph, paragraphPos = 0) { - let content = []; - const segments = []; - - paragraph.nodesBetween( - 0, - paragraph.content.size, - (node, pos) => { - let nodeText = ''; - - if (node.isText) { - nodeText = node.text; - } else if (node.isLeaf && node.type.spec.leafText) { - nodeText = node.type.spec.leafText(node); - } else if (node.type.name !== 'run' && node.isInline) { - const start = content.length; - const end = start + 1; - content.push({ - kind: 'inlineNode', - node: node, - }); - segments.push({ start, end, pos }); - return; - } else { - return; - } - - const start = content.length; - const end = start + nodeText.length; - - const runNode = paragraph.nodeAt(pos - 1); - const runAttrs = runNode.attrs || {}; - - segments.push({ start, end, pos }); - const chars = nodeText.split('').map((char) => ({ - kind: 'text', - char, - runAttrs: JSON.stringify(runAttrs), - })); - - content = content.concat(chars); - }, - 0, - ); - - const resolvePosition = (index) => { - if (index < 0 || index > content.length) { - return null; - } - - for (const segment of segments) { - if (index >= segment.start && index < segment.end) { - return paragraphPos + 1 + segment.pos + (index - segment.start); - } - } - - // If index points to the end of the string, return the paragraph end - return paragraphPos + 1 + paragraph.content.size; - }; - - return { text: content, resolvePosition }; -} - -/** - * Collects paragraphs from a ProseMirror document and returns them by paragraph ID. - * @param {Node} pmDoc - ProseMirror document to scan. - * @returns {Array<{node: Node, pos: number, text: string, resolvePosition: Function}>} Ordered list of paragraph descriptors. - */ -export function extractParagraphs(pmDoc) { - const paragraphs = []; - pmDoc.descendants((node, pos) => { - if (node.type.name === 'paragraph') { - const { text, resolvePosition } = getParagraphContent(node, pos); - paragraphs.push({ - node, - pos, - text, - resolvePosition, - get fullText() { - return text.map((c) => c.char).join(''); - }, - }); - return false; // Do not descend further - } - }); - return paragraphs; -} diff --git a/packages/super-editor/src/extensions/diffing/utils.test.js b/packages/super-editor/src/extensions/diffing/utils.test.js deleted file mode 100644 index 06a6a5448..000000000 --- a/packages/super-editor/src/extensions/diffing/utils.test.js +++ /dev/null @@ -1,203 +0,0 @@ -import { extractParagraphs, getParagraphContent } from './utils'; - -const buildRuns = (text, attrs = {}) => - text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs), kind: 'text' })); - -const createParagraphNode = (text, attrs = {}) => ({ - type: { name: 'paragraph' }, - attrs, - textContent: text, - content: { size: text.length }, - nodesBetween: (from, to, callback) => { - callback({ isText: true, text }, 0); - }, - nodeAt: () => ({ attrs }), -}); - -const createParagraphWithSegments = (segments, contentSize) => { - const computedSegments = segments.map((segment) => { - if (segment.inlineNode) { - return { - ...segment, - kind: 'inline', - length: segment.length ?? 1, - start: segment.start ?? 0, - attrs: segment.attrs ?? segment.inlineNode.attrs ?? {}, - inlineNode: { - typeName: segment.inlineNode.typeName ?? 'inline', - attrs: segment.inlineNode.attrs ?? {}, - isLeaf: segment.inlineNode.isLeaf ?? true, - }, - }; - } - - const segmentText = segment.text ?? segment.leafText(); - const length = segmentText.length; - return { - ...segment, - kind: segment.text != null ? 'text' : 'leaf', - length, - start: segment.start ?? 0, - attrs: segment.attrs ?? {}, - }; - }); - const size = - contentSize ?? computedSegments.reduce((max, segment) => Math.max(max, segment.start + segment.length), 0); - const attrsMap = new Map(); - computedSegments.forEach((segment) => { - const key = segment.kind === 'inline' ? segment.start : segment.start - 1; - attrsMap.set(key, segment.attrs); - }); - - return { - content: { size }, - nodesBetween: (from, to, callback) => { - computedSegments.forEach((segment) => { - if (segment.kind === 'text') { - callback({ isText: true, text: segment.text }, segment.start); - } else if (segment.kind === 'leaf') { - callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); - } else { - callback( - { - isInline: true, - isLeaf: segment.inlineNode.isLeaf, - type: { name: segment.inlineNode.typeName, spec: {} }, - attrs: segment.inlineNode.attrs, - }, - segment.start, - ); - } - }); - }, - nodeAt: (pos) => ({ attrs: attrsMap.get(pos) ?? {} }), - }; -}; - -describe('extractParagraphs', () => { - it('collects paragraph nodes in document order', () => { - const firstParagraph = createParagraphNode('First paragraph', { paraId: 'para-1' }); - const nonParagraph = { - type: { name: 'heading' }, - attrs: { paraId: 'heading-1' }, - }; - const secondParagraph = createParagraphNode('Second paragraph', { paraId: 'para-2' }); - const pmDoc = { - descendants: (callback) => { - callback(firstParagraph, 0); - callback(nonParagraph, 5); - callback(secondParagraph, 10); - }, - }; - - const paragraphs = extractParagraphs(pmDoc); - - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0]).toMatchObject({ node: firstParagraph, pos: 0 }); - expect(paragraphs[0].text).toEqual(buildRuns('First paragraph', { paraId: 'para-1' })); - expect(paragraphs[0].fullText).toBe('First paragraph'); - expect(paragraphs[1]).toMatchObject({ node: secondParagraph, pos: 10 }); - expect(paragraphs[1].text).toEqual(buildRuns('Second paragraph', { paraId: 'para-2' })); - }); - - it('includes position resolvers for paragraphs with missing IDs', () => { - const firstParagraph = createParagraphNode('Anonymous first'); - const secondParagraph = createParagraphNode('Anonymous second'); - const pmDoc = { - descendants: (callback) => { - callback(firstParagraph, 2); - callback(secondParagraph, 8); - }, - }; - - const paragraphs = extractParagraphs(pmDoc); - - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0].pos).toBe(2); - expect(paragraphs[1].pos).toBe(8); - expect(paragraphs[0].resolvePosition(0)).toBe(3); - expect(paragraphs[1].resolvePosition(4)).toBe(13); - }); -}); - -describe('getParagraphContent', () => { - it('handles basic text nodes', () => { - const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); - - const result = getParagraphContent(mockParagraph); - expect(result.text).toEqual(buildRuns('Hello', { bold: true })); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(4)).toBe(5); - }); - - it('handles leaf nodes with leafText', () => { - const mockParagraph = createParagraphWithSegments( - [{ leafText: () => 'Leaf', start: 0, attrs: { type: 'leaf' } }], - 4, - ); - - const result = getParagraphContent(mockParagraph); - expect(result.text).toEqual(buildRuns('Leaf', { type: 'leaf' })); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(3)).toBe(4); - }); - - it('handles mixed content', () => { - const mockParagraph = createParagraphWithSegments([ - { text: 'Hello', start: 0, attrs: { bold: true } }, - { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, - ]); - - const result = getParagraphContent(mockParagraph); - expect(result.text).toEqual([...buildRuns('Hello', { bold: true }), ...buildRuns('Leaf', { italic: true })]); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(5)).toBe(6); - expect(result.resolvePosition(9)).toBe(10); - }); - - it('handles empty content', () => { - const mockParagraph = createParagraphWithSegments([], 0); - - const result = getParagraphContent(mockParagraph); - expect(result.text).toEqual([]); - expect(result.resolvePosition(0)).toBe(1); - }); - - it('includes inline nodes that have no textual content', () => { - const inlineAttrs = { kind: 'tab', width: 120 }; - const mockParagraph = createParagraphWithSegments([ - { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 0 }, - { text: 'Text', start: 1, attrs: { bold: false } }, - ]); - - const result = getParagraphContent(mockParagraph); - debugger; - expect(result.text[0]).toEqual({ - kind: 'inlineNode', - node: { - attrs: { - kind: 'tab', - width: 120, - }, - isInline: true, - isLeaf: true, - type: { - name: 'tab', - spec: {}, - }, - }, - }); - expect(result.text.slice(1)).toEqual(buildRuns('Text', { bold: false })); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(1)).toBe(2); - }); - - it('applies paragraph position offsets to the resolver', () => { - const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); - - const result = getParagraphContent(mockParagraph, 10); - expect(result.text).toEqual(buildRuns('Nested', {})); - expect(result.resolvePosition(0)).toBe(11); - expect(result.resolvePosition(6)).toBe(17); - }); -}); From cbb747d0b3c62302f9df00b36e591824ca17d20e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 14:10:01 -0300 Subject: [PATCH 24/53] feat: use generic diffing for diff computation command --- .../src/extensions/diffing/computeDiff.js | 8 +-- .../extensions/diffing/computeDiff.test.js | 64 ++++++++++++++++++ .../src/tests/data/diff_after7.docx | Bin 0 -> 14189 bytes .../src/tests/data/diff_before7.docx | Bin 0 -> 13831 bytes 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 packages/super-editor/src/tests/data/diff_after7.docx create mode 100644 packages/super-editor/src/tests/data/diff_before7.docx diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js index 04c2fbc62..4025597fe 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.js @@ -1,5 +1,4 @@ -import { extractParagraphs } from './utils.js'; -import { diffParagraphs } from './algorithm/paragraph-diffing.js'; +import { diffNodes } from './algorithm/generic-diffing.js'; /** * Computes paragraph-level diffs between two ProseMirror documents, returning inserts, deletes and text modifications. @@ -8,8 +7,5 @@ import { diffParagraphs } from './algorithm/paragraph-diffing.js'; * @returns {Array} List of diff objects describing added, deleted or modified paragraphs. */ export function computeDiff(oldPmDoc, newPmDoc) { - const oldParagraphs = extractParagraphs(oldPmDoc); - const newParagraphs = extractParagraphs(newPmDoc); - - return diffParagraphs(oldParagraphs, newParagraphs); + return diffNodes(oldPmDoc, newPmDoc); } diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index 6ee9bef81..df75d6b89 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -191,4 +191,68 @@ describe('Diff', () => { expect(diff.contentDiff[2].action).toBe('added'); expect(diff.contentDiff[2].kind).toBe('text'); }); + + it('Compare a complex document with table edits and tracked formatting', async () => { + const docBefore = await getDocument('diff_before7.docx'); + const docAfter = await getDocument('diff_after7.docx'); + + const diffs = computeDiff(docBefore, docAfter); + expect(diffs).toHaveLength(9); + expect(diffs.filter((diff) => diff.action === 'modified')).toHaveLength(6); + expect(diffs.filter((diff) => diff.action === 'added')).toHaveLength(2); + expect(diffs.filter((diff) => diff.action === 'deleted')).toHaveLength(1); + + const formattingDiff = diffs.find( + (diff) => diff.action === 'modified' && diff.oldText === 'This paragraph formatting will change.', + ); + expect(formattingDiff?.contentDiff?.[0]?.runAttrsDiff?.added).toHaveProperty('runProperties.bold', true); + + const upgradedParagraph = diffs.find( + (diff) => diff.action === 'modified' && diff.oldText === 'This paragraph will have words.', + ); + expect(upgradedParagraph?.newText).toBe('This paragraph will have NEW words.'); + expect( + upgradedParagraph?.contentDiff?.some( + (change) => change.action === 'added' && typeof change.text === 'string' && change.text.includes('NEW'), + ), + ).toBe(true); + + const deletion = diffs.find( + (diff) => diff.action === 'deleted' && diff.oldText === 'This paragraph will be deleted.', + ); + expect(deletion).toBeDefined(); + + const wordRemoval = diffs.find( + (diff) => diff.action === 'modified' && diff.oldText === 'This word will be deleted.', + ); + expect(wordRemoval?.newText).toBe('This will be deleted.'); + expect(wordRemoval?.contentDiff).toHaveLength(1); + expect(wordRemoval?.contentDiff?.[0].action).toBe('deleted'); + + const tableModification = diffs.find( + (diff) => diff.action === 'modified' && diff.nodeType === 'table' && diff.oldNode, + ); + expect(tableModification).toBeUndefined(); + + const tableAddition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'table'); + expect(tableAddition?.node?.textContent?.trim()).toBe('New table'); + + const trailingParagraph = diffs.find( + (diff) => diff.action === 'added' && diff.nodeType === 'paragraph' && diff.text === '', + ); + expect(trailingParagraph).toBeDefined(); + + const thirdHeaderDiff = diffs.find( + (diff) => + diff.action === 'modified' && diff.oldText === 'Third header' && diff.newText === 'Third header modified', + ); + expect( + thirdHeaderDiff?.contentDiff?.some((change) => change.action === 'added' && change.text === ' modified'), + ).toBe(true); + + const firstCellDiff = diffs.find( + (diff) => diff.action === 'modified' && diff.oldText === 'First cell' && diff.newText === 'cell', + ); + expect(firstCellDiff?.contentDiff?.[0]?.text).toBe('First '); + }); }); diff --git a/packages/super-editor/src/tests/data/diff_after7.docx b/packages/super-editor/src/tests/data/diff_after7.docx new file mode 100644 index 0000000000000000000000000000000000000000..db208187b525fedbcdfdc556a199c2bba3ce4f1f GIT binary patch literal 14189 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/tests/data/diff_before7.docx b/packages/super-editor/src/tests/data/diff_before7.docx new file mode 100644 index 0000000000000000000000000000000000000000..1d9cfa5789b8484f8b97fd0e21f7aadc6c8aa3be GIT binary patch literal 13831 zcmeHuWpo>9wyn$@Gc!ZX9AnIO%*@QpOo=IGrr0twGdpHxX2;CTY(ID3ncF=*{oY&a z{=HLc>06Rc?JcS5km~G`lLP}t13&_x0RR9oU@dpbN&^G{Ac6n@Pyx`OnnJcVPR2G) zx=QYL#*W(bZq`;r+2Ej5nE=rD{r|K5FFpdb@xwM|4ax@l*~K)VVSI^n z3I|YFo}el(vHh*DZK$B~${;bYRwU#scfVLv`ur!CQmkN*>s-l?FnJTd#p#%Hd|aI0 zrt>wzrr3yQ{3S5R{?&@L^(VFzI|w6bU3cuLBCISh2k4Kkb`OZ8Nr+M*^N3&}2zbU| zS-<=F3oPjuxgE7^9Te^dolF$CxaxT}ZyRwEETcuoX*JUXsQ|rNw2(uugPDwYuOzu> zhh=|9{PRd0EdFFU)kvsgCqgGy`HusAB>ArTMPU_QN?PtsmASMs%XsV8%6a6qk4tpI z3?MOMD=FD!YFVr4Z&XnE5g@HRB3sM&h}1FbH~{WFnE-St{tD5H;a1e#?3IvYoJJl| z%T<^dGq5c8+wt9nC17aonEf?~dCnGp->(X*vN7{@0Mo&=uXd{Voas2!jdkW{{DIx& zC+mAK0O0Kn3?TO}AxRK}({T3gXEN^*2lpP5x(>!xjtum_t^Wzd|Hbk5r=MOL+iB6w z2>t5(CHb46rv3jlUhAl2oTj1)wF#UUclmLjV2EhS z-wIZ%nx<`zD4I{j%-dHtS-nQ=QqGUKBQAL=q3q^QJV8|g*jTLLwl6ol-LpxoVT0^G zdgXhmXIfCt=R48z#$s+is=e~FJ++m^392|pIaqGSlg`JK6BO2Y`r>zt@(Co0eB7UJ zlD@yNnoP2jHpu}<7z&<9Qi?*5dX=V!sED!Y&uUmVON}h;39Fzgt4dm<%%O~fEihI80wniwUj-!(sFi)I3aW@VjVi+5*< z&yi43{X9oSq~{!H6eZ*#^vM)2);@w2`@4}2Y13A8c^7rd2RoH$JdF8pQ?-t0+gkXm zd24&94hW0`n!F-T(EQU{M$S=PF-Fj$55z26smK%>eFlP~jUxIi&cj#cq`vcml84h8 zZRT7sg5UAs`rUCpL2HDnBqCO6(-E)+=2fTk!9Z|Vw@OWAmwZ@MV^x#Vh+g%CTrjR( zZD5$_P>y!E2!ovKkYuI=T-;JXcU!p+h=yk9B~%5+ zEw5`3nk!(y0oSV~Q*i8gE0Jo0=U@!*_2NaedE=6|*>e{0*O|C4^s64!vVC@}G_TDL z`X}0M$UPZ!(``B|P+n`H69`GKfa{<%?ikYT)((9ap`fJVx)98AMLa|qNCVYH&D(OL z!}sP~mNeWKDlz2p-T_G+=wVUSbb6gB7|C0<(L(uKShVRYWJ!b zQWgwa0VW*nhQtJ2XlKr~*CREq2`*V<$`qPhJH{wuAdp)#TE# zNZXdOnC{b)H3*^9>VAbsz;>2G5$a`Z@v%)NM0iR`NVFnqD=@v4MVqwYt6%#;49j<} zeB6cDtq>u~(Tba2aP8-*!H1?ff-?&Zn=3y!`PLH^YA$$^DcV93n{6^O%2IDZW2?Kc z(WSDkBVv*8P+vvwm7IRfXH9t~e+W9gHv>^Vmx5YOdx|7O*xAgb!9}q@i@;)%qsiL$02)UVBwz<437}Ct63QUz5(2 zq5)HHtHbr5?&KJ4t(MfsoiM4O`wKKti!-u8FB21YNbj^DNLiyd#zzq$51(*Y^=VQc zZ<`9iP`4M;o$U>EgLWBBl${wQ$kKle1s@)dUh&txMC+@~LV%s!4!C6LN?@k)SE6CS zw=u;i+io0CAivo42I#5t=NoxfYZTBGW=dMh&WDG@ZJ2y4)_2tEp;}p+uI@lCx>K5F z3fq<@tC|M#Msr?>+lF&;RbNGx9fv^6_-gMmFa)R4dUx=6IUH~El{DrhIqt_el z72*;{6MD_(4&}*?<6qgA$2&t^!)H7TG6x)x_QNYY=V{!E2r5nmkStX#kML=*V)Qh1 zbga@REMz2SZoUa;b1FUVGFi^zbCA>P5HI%&tVJ3#ivNjZ}%2Ha=`gT4PVm;B92i~IC@V+MKW}PFwBs- zK#sP!Bd52hLqENwUAsDcn*Oz(uKu75St_YV!*|PLr}bs=fW=lJQyn4Z3~1r`ZMT7S5(nV=x|iWtrW#pj}Y~0 zHtdP$(*0%H*7zPCVTP2$Rzz6xSC$^me2KM-@}>`;2Z4~BFi&GI)8=JI0D3)?&w{8k zu5-cMwkuvQb-3rv!E)hW<7Mxi7hFk*r;cv8GKTdBDkjm$9c%>D;RBDWD+w6g=x_RR zPg`tS2!w!~MBl2EJZ-Bw9KmH0zU=LaJ_HQ-hOArY$t;8@M8LyDAS9-HY+3S=zyk%Q zT(`MEc=<&zyFkDlh$$Zlsi9T^sS*lq|zDP_FR9XH>927Q~)EM5H7%|YoO$bcgfJ2qr2E)9 zZ_|=y|6wirFmHco8&w|NlF3VaQ3H{DJ|JK z;Ku^5or0ku;sEwGaZUYmkgMEsyL2VXh=W}0F;eS!2<`xCI?{fpPoa81jZa%-G=^)N zlGxcuc=Gz>0;8zek5h<@MNW5M;qb2fPgZM5<^u#o7ACz`VxaO@JmrI+B&nAmVG(HD zdJ8!C-O6SGw;+Px>D>TgyEi)A^!%XFhFn`Q=8_db_T`lzlhR@zzsAx8C3{1A+Fj9s z^vr0L^G{l5Wa(^D1_Q;cYuYKqEI*jsv8*q?>`Ia*803E_OY7k!zv+Gh7A=8qiiEBr67YexqCcg53S0iR=ulT^OQiU_?Bb zL%2!HrtBMJp~-wJzl^!*jbmu5)nh1Fsshu^9V_}m-ZCqVZqtyDEswFT+MbKut?Ur6 zO=s{;N10U>ee|s@yNGLC)p{ z$*YzAqDKSeECz=jl{_WRezgOyHIQioCfd8KCQOMewZojQL%iK|80{bLpw4-qUt+^1 zpmITsa7pP_+%+Z3mIpvvwr@}lA10AD$~hUkMK;UQxf6Uzb{6_P$cGp*Wk3-U6ew)U zau1Khh#5bAMs#6)BWF#_;W(DsHdAwo_|uiwVX2g@Vb0HhA3vQ+fxEPWW60pD&+tfi!+H!)-^j=vc-_r( zlpWd}x=KW4+7o_;W5BUuqLVI{1Bc_)Om&e;xtz!)GK4!nCC8w12TjS9MHaN;>e0ih zLM10;ds_CUc_n0sYz=3eUQHItivw+=o3mpDnMGS>a}9b5x6AbC4|3sxcLLSd8kN+_hY#O*@>F4sAoy>$O{RpSMOxA#Gb+-<~F8|zm?Wnl{MRCb|fFd%D2vmD?G{S?OegMuE;%dkrfQ(P+mn6 zW7G}AZ@I^fy{~up)RR+**}qo8Yxf4Ix*O0 z>1;NzsSj3f2xINpXG8mApv;o#uG4Roo#j-RJNM*-KgENpvzFayH^egu>feaHP_U>Jb z$nez4TCqMg=|O@wehMQ=f8;zKPC;yBWp{HWNUNCaSt9Xw5zpYNHZEOo4=WtKoyP!mKKOF8oZglwA=3}lPguh*8o!CSgDQ23tV9d!d z-C$hY@`_7X=psb!D~{-rD)qyD^sI1#UAaC%pFU$107#*}1BtCS;v|jb2v%(bE&%v$ zRf6<=a+iDzG7gq)geQ}PqZ%@g*UwwWW60q<8V!QHd&C#LmY6Q8!B&Dq!rZ2BOhxNx zt{66Ewk*tJWA*>BtVv9_wf{Ov4WBl0rS>hqVdIH&&&IXAAO(ZAcXBDMN zj$D)^4UY~I7PnFGw)95lMI!{OSa=z+GJ)OlHgKxUwy?JKm>AFuJ4!2+G2?g^TL z?3#tc+qbp5855Qb;t-0D>Pg~VxehAYX@y?t&g|BT9 zVF>$amC12aZO$OsN_${jl$7_=YS)ed=exv8P)d*;xoTHbK^!*Y^|N&3}T4EfU~_OZ0|Z{gh>HM66szRiy7Z(|j2f7^=x zqz~mYLIMC1ivR$`f41U|PVQF5zh{bL?PZ5$Q55gB;uom+bQeNAZE^YPk3)&%%a-#? zk%ohVS4dc)!JogveN9ZLSZe~os$?Sam@q(_Pb{I`oHB)Ud}PhXZ6JKRu}%Qjb=T=p z_prBn?w%Crm}U&yyuRIBA0ACL-GdU4uVyunNW2X5htk1&J}PK`JL!MI6nTg~CqV+3 zUl^e4;L#2{ylkFVLMM6kh7wk@$@cb%BvzzK;SfmHa!8bM#e|nptPPc+h2e>ujiEV0=EGm=je4si8pZ8`72)K$)nWr(*(v%F zmdp|ED`WA>yD-merfE&Dgo$7(-~$XHVAWn3?qhssWM8GrlDZ9iXi*-7!k1(Po8>Xu zHsR_eh6L$%GaPEn5@#|v)HN_VU*J>%?7c`IKjLWjX1_ErOq1{-X>xuXN)j32;-KG& z8l%&f*C=7wqT=I>r~7rmgPz*X%ydrap?o3s(?FxFDK^cbOj6B5vux|2hGE`iuT4bv$+h+oG;RH5ujhs( zPcwaQP#D;3qT0jGY5~uf1F|v0hjf09j};FzKDc7YvSs+w#&{QNi99a~=Y{HT!?-&G zCL+n!3KyOZ?20<^Xp={_#Ja zS>1!?Us){xe_u=i?+eHpqN}UdUH2;f*o&R3nC<&poj6e?1sa{)#5J{eWz`~y3Fa!7 zpJhD6a;lW{io$`Avgy}uG7iz;j9k?}P%q4CqlIYk_3YcAKIAY6e1rxUBlY2o2aoWO&jdumcz;j2=rGEnxTN%8y-yZZ z!)~hpI#k-;Bl=a+u9B|t0&3YWhijoE0A1v`HQ+Ep%()Sr;N~Jy_7z?y(dfY-ZUR9M zLY&uh#XPgoHE7d|vDF8d~|>Q2i@!>_2P8J{$X)jxdRi9 z2_~PrS1nqJ&s1P4&1@>RVorVR5{hJC9;V$G96}-DmU)CIm4DtWO;|ZZ*YRu!Q-^ z(m}W%qinEMqgJo6I9NgwcMd*FF6>%07_&&)sHY>;23V&f)+%vXtKS}Myw?>j{EgCa z-$T6d4KGt~9VJ{>_T@eCI^!g`^c#11Nt(`K#&}gYBJOV1t$IQVf(Bku5gW8{;0l$$ z^G?t{>VoMjTcW9p)P&N>tgGzT#tMo_f08|hRH^wBO-x`A|ICey6mZH9DvdL41G8L1 zzK=K3zdHigz;4A}2>!!on_z*M0rN;$37h!NszC%MPVpwM}{mxv|2f0aE(GFP&y?}kZu_CrL6$i z6Yl_6d9U}!gka!5AphKFyy@rkiew=Y1f3G}Hw>D9&v444m}1cXKN$2k6dJ}m6dGXx zICR0xKv?1l!PzLBN4Wn1P8k#KfAD#a&(m7jPOB!q;T0ZSXA6Wr2SZMWH7`^R2yECN zly8P2*q+q`hG|>ixCnWGgORx2i48gBEZP6_PWVc;ZgXz z-jhqr--T8%bLfOPyS!r-nFGL9Fr(knxCXVNr zMS8bjVO1#!fxlZj7bDh1*uGvA;0U@aB4j-m1{E;V&LV+GvI%!6@LmQS2mHwt$Nk?X z1mxC2NXAzsAu8KmxNl`@vMz- z-1^T&@6UfKZB>(V{KgaZ&NCyzMz498{Z97jpT<0$cdFRbe=+|4Nx*mH@5bN1#(_U$ z2#gMh^G^eJ?1`TYr*t0^$#LHtx){vI!e}3ca=zZmt>r{sp10jRd<-e$6QR0g=uIBv z$kdGbDDsF<2`P7SPhZThNFHG<3wm|KSfk;*pjAFgN7IcjJ-fcEal!P}md_?nBbmis ziGO$!5K(a18;pOHC!};#Vp5(+JFkhWT6W81U=_LZDx%XPU-z)TzddWk<(+rOOvuo( z<@F+8TQN5X(TT_~qj05N)8titw;jS18X!goM zP8pw4#!NjKpE_`!gWB=}?)4n-P=HTzjt>I%%#DifEJH41R)@VfYzsVY-QD?*5u zT^N4a+|-*4`>@p5`a*AqyMX4CT6p20$TP zmy8(hvH1?qq*55+nZkkD-IU~Ey69SR5Xy3eTdbF!$C=g1FSZRhYRsyed-0!9j+Rea zz8YQT86}oTGGDu0Os@^vGxnpT`yMy2On)k4EO7^E@)ohVk~4}F718N zL@(*yQw#NEFBT^GcnB2}>6@@~RRwdZk3iq8+EJahP^V%d>Vb?aNs!ZfsvJ>$t+TV12hLM6T|U5=^gm#iCLsL{!NXA+NqmPls8LK`5Z9i zs*JZ~?r2|lk#XUG+;G;cDP{Rnx`|4Q?^rXNmT!q?YNiKRVC=_A9i~)Jv@atECV9R+ zFs&y;R$kjE94;83jr{B|13sv0807Sq{jjws!Zu?W_dORe)JH1$ulXJ9EFN@$6zB>W zM6x4BeOJd8YoqzM_q?UF#-~HdEuT|)9fuEMo;d>84p|$qi)Fa_ihDL^M!RG}j-9ug zwN-T3%=w0^cvTLwf=sUH34?@~m~L3&h58O*lUn)6b*oIZs%+^wCbSRz+0W8Sbb}ha z7uK!aImHgnp=UxI8eESr*y&g>R= zGj?8uei=~uLchgc2p7pBw3w>5w)s5{&`Dq?lP=$A1!_&%7@Hq^<$L7N?;n!a4KJ*} z6(kCLBfM!oU6>Y-B;)3}9Xx2`5M{5}bwbdq>0h8y*FF>y8Emqd4Ob!d`{K|$$7%>g z5i|Re#4TFeW}izS?MxW#l?96>`ZAEYG{fd|%S%zNzx-*{ENgk(nnIlWf#J$Jy zW@IH(&qAy*pU==n;G!BIN2{f*BvrLFd`l^u)t7thS(X2)`}X{(x1K|#Jv8I>eFFIJ z7cVJ(J&N1D&xq;X)im#7n*W*&x*8iO{^|N9aC+6IiwF&{{zUHiJ;*bUi%rsyO&ud? zl~%Hz7tmEi4q5=B}Jrf9Ox3Rp}1tGvCXkP!~8^uKk<;RnKQNx7GNkLO*Sg1oaW9pn6YWd zt8cGssYR)X$+DP_jhHz=m=Dr%(LOWM=9d*_2|iL;la#%FHRsp!f*9dEe!`9_6A&_) zkIBwp;pSFw0=}V7gY=YRGWl@MBBv(*ssf&8ANQw?1@~#~st;n%y8I>maIunMs-S;pzOk7RJ=bEP?FiU8wx2Do7L&t zVv=FK>hrn#L=h4oYLI(weAcKTTMUjB#fefegj^jnf2qS&8l{OoeSIl5F9e2gu7Hiiy70KMa!1r&-Pmy9q4X?yK15 zsv-nYWQ_g%6NK&DHeUl&;d_Y>I}_=tl%Xf;lwBR-O!SjQClpWUgquwI{010nOc!`k zuBE8cCiGN2WyNuPw$UAlK(k+B>Kz7amHOjgBf!m2>#ke54m@{_%(A4vtr!?U;Wm@G zT~dwix#lWYe~z4QRJ^Parf{nWxhp}o&hX^VGrvs37ctNg>;F4HX;PZZMhv<{)v6DIqC(e`5 zjGGr3z@XfgR2V$4w{7TH`j}LM-c-54Z`Xr!YLA$K`+sC1u=iT>|x3+CqDJ`DuGog?1k~>^|-MVX=i0M%bYxXkV%*sX?zga zWrPf}C@=LKRPmOxa}o&GZZ6Pv)Qc)ugL;8D#72P9d5hq&27i|~@xdIAESenke zDR2-ETSWKW_T&qwgC~VOZu}%+yMHfhd1kHth#1h^tx%ZNE;=mS;Nr ziwq;udJ%H6-Bid7dQ((}yXM!$NMGgazSM0bv4!cd7ZbxJe3{di<@^!X*^^`DaRU?; zm@V4G2N1)y-2%#UJYat`YhO5-73~!}xo@vpJe>>4Sdn#+V#`E`FeB^8Y+a(;*0$z% z_9|ofQY;^`TG-i_EtLEz z{7Z;Q(yykIddE#!Uw^{UT(Xb^!LpY@3sPth>1vgJm1flPw0+m@s|*v?!qYc%8O!SW z)c6oATtmwR(G4|ZPJhGf$VWP@%X>QT%OWxqU=_GeT)93K1=pm8x1r*#2n>Nf{{A)M zsI`);zc2J!XKPILG+(#pZot+7#i2s6<_Mu_d@iy3bVgb{B>^r2gT=imD=wVf1aZM75>*z+8=N$?0>@ld$9Ib@L${BKfo{4 z{{a7`3H~d?U;DB@7}7ufgW+Ggw7=s2wKD&K1^`y*0Dyn1(|?8kYr_09Jb>{}@PDLF YIZ23j_xSBz5;#ETdx~Xb`R&#J0a7W Date: Mon, 29 Dec 2025 14:29:01 -0300 Subject: [PATCH 25/53] refactor: simplify grouping logic during inline diffing --- .../diffing/algorithm/inline-diffing.js | 172 ++++++++++-------- 1 file changed, 96 insertions(+), 76 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js index 2785d8ea4..24412bf8c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js @@ -74,7 +74,7 @@ export function getInlineDiff(oldContent, newContent, oldPositionResolver, newPo }, }); - const groupedDiffs = groupDiffs(diffs, oldPositionResolver, newPositionResolver); + const groupedDiffs = groupDiffs(diffs, oldPositionResolver); return groupedDiffs; } @@ -115,37 +115,38 @@ function shouldProcessEqualAsModification(oldToken, newToken) { * Groups raw diff operations into contiguous ranges and converts serialized run attrs back to objects. * @param {Array<{action:'added'|'deleted'|'modified', idx:number, kind:'text'|'inlineNode', text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string, nodeType?: string, node?: import('prosemirror-model').Node, oldNode?: import('prosemirror-model').Node, newNode?: import('prosemirror-model').Node}>} diffs * @param {(index: number) => number|null} oldPositionResolver - * @param {(index: number) => number|null} newPositionResolver * @returns {InlineDiffResult[]} */ -function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { +function groupDiffs(diffs, oldPositionResolver) { const grouped = []; let currentGroup = null; - const compareDiffs = (group, diff) => { - if (group.action !== diff.action) { - return false; - } - if (group.action === 'modified') { - return group.oldAttrs === diff.oldAttrs && group.newAttrs === diff.newAttrs; + /** + * Finalizes the current text group (if any) and appends it to the grouped result list. + * Resets the working group so the caller can start accumulating the next run. + */ + const pushCurrentGroup = () => { + if (!currentGroup) { + return; } - return group.runAttrs === diff.runAttrs; - }; - - const comparePositions = (group, diff) => { - if (group.action === 'added') { - return group.startPos === oldPositionResolver(diff.idx); + const result = { ...currentGroup }; + if (currentGroup.action === 'modified') { + const oldAttrs = JSON.parse(currentGroup.oldAttrs); + const newAttrs = JSON.parse(currentGroup.newAttrs); + result.runAttrsDiff = getAttributesDiff(oldAttrs, newAttrs); + delete result.oldAttrs; + delete result.newAttrs; } else { - return group.endPos + 1 === oldPositionResolver(diff.idx); + result.runAttrs = JSON.parse(currentGroup.runAttrs); } + grouped.push(result); + currentGroup = null; }; + // Iterate over raw diffs and group text changes where possible for (const diff of diffs) { if (diff.kind !== 'text') { - if (currentGroup != null) { - grouped.push(currentGroup); - currentGroup = null; - } + pushCurrentGroup(); grouped.push({ action: diff.action, kind: 'inlineNode', @@ -162,65 +163,84 @@ function groupDiffs(diffs, oldPositionResolver, newPositionResolver) { }); continue; } - if (currentGroup == null) { - currentGroup = { - action: diff.action, - startPos: oldPositionResolver(diff.idx), - endPos: oldPositionResolver(diff.idx), - kind: 'text', - }; - if (diff.action === 'modified') { - currentGroup.newText = diff.newText; - currentGroup.oldText = diff.oldText; - currentGroup.oldAttrs = diff.oldAttrs; - currentGroup.newAttrs = diff.newAttrs; - } else { - currentGroup.text = diff.text; - currentGroup.runAttrs = diff.runAttrs; - } - } else if (!compareDiffs(currentGroup, diff) || !comparePositions(currentGroup, diff)) { - grouped.push(currentGroup); - currentGroup = { - action: diff.action, - startPos: oldPositionResolver(diff.idx), - endPos: oldPositionResolver(diff.idx), - kind: 'text', - }; - if (diff.action === 'modified') { - currentGroup.newText = diff.newText; - currentGroup.oldText = diff.oldText; - currentGroup.oldAttrs = diff.oldAttrs; - currentGroup.newAttrs = diff.newAttrs; - } else { - currentGroup.text = diff.text; - currentGroup.runAttrs = diff.runAttrs; - } + + if (!currentGroup || !canExtendGroup(currentGroup, diff, oldPositionResolver)) { + pushCurrentGroup(); + currentGroup = createTextGroup(diff, oldPositionResolver); } else { - currentGroup.endPos = oldPositionResolver(diff.idx); - if (diff.action === 'modified') { - currentGroup.newText += diff.newText; - currentGroup.oldText += diff.oldText; - } else { - currentGroup.text += diff.text; - } + extendTextGroup(currentGroup, diff, oldPositionResolver); } } - if (currentGroup != null) grouped.push(currentGroup); - return grouped.map((group) => { - let ret = { ...group }; - if (group.kind === 'inlineNode') { - return ret; - } - if (group.action === 'modified') { - ret.oldAttrs = JSON.parse(group.oldAttrs); - ret.newAttrs = JSON.parse(group.newAttrs); - ret.runAttrsDiff = getAttributesDiff(ret.oldAttrs, ret.newAttrs); - delete ret.oldAttrs; - delete ret.newAttrs; - } else { - ret.runAttrs = JSON.parse(group.runAttrs); + pushCurrentGroup(); + return grouped; +} + +/** + * Builds a fresh text diff group seeded with the current diff token. + * @param {{action:'added'|'deleted'|'modified', idx:number, kind:'text', text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string}} diff + * @param {(index:number)=>number|null} positionResolver + * @returns {{action:'added'|'deleted'|'modified', kind:'text', startPos:number, endPos:number, text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string}} + */ +function createTextGroup(diff, positionResolver) { + const baseGroup = { + action: diff.action, + kind: 'text', + startPos: positionResolver(diff.idx), + endPos: positionResolver(diff.idx), + }; + if (diff.action === 'modified') { + baseGroup.newText = diff.newText; + baseGroup.oldText = diff.oldText; + baseGroup.oldAttrs = diff.oldAttrs; + baseGroup.newAttrs = diff.newAttrs; + } else { + baseGroup.text = diff.text; + baseGroup.runAttrs = diff.runAttrs; + } + return baseGroup; +} + +/** + * Expands the current text group with the incoming diff token. + * Keeps start/end positions updated while concatenating text payloads. + * @param {{action:'added'|'deleted'|'modified', kind:'text', startPos:number, endPos:number, text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string}} group + * @param {{action:'added'|'deleted'|'modified', idx:number, kind:'text', text?: string, runAttrs?: string, newText?: string, oldText?: string}} diff + * @param {(index:number)=>number|null} positionResolver + */ +function extendTextGroup(group, diff, positionResolver) { + group.endPos = positionResolver(diff.idx); + if (group.action === 'modified') { + group.newText += diff.newText; + group.oldText += diff.oldText; + } else { + group.text += diff.text; + } +} + +/** + * Determines whether a text diff token can be merged into the current group. + * Checks action, attributes, and adjacency constraints required by the grouping heuristic. + * @param {{action:'added'|'deleted'|'modified', kind:'text', startPos:number, endPos:number, runAttrs?: string, oldAttrs?: string, newAttrs?: string}} group + * @param {{action:'added'|'deleted'|'modified', idx:number, kind:'text', runAttrs?: string, oldAttrs?: string, newAttrs?: string}} diff + * @param {(index:number)=>number|null} positionResolver + * @returns {boolean} + */ +function canExtendGroup(group, diff, positionResolver) { + if (group.action !== diff.action) { + return false; + } + + if (group.action === 'modified') { + if (group.oldAttrs !== diff.oldAttrs || group.newAttrs !== diff.newAttrs) { + return false; } - return ret; - }); + } else if (group.runAttrs !== diff.runAttrs) { + return false; + } + + if (group.action === 'added') { + return group.startPos === positionResolver(diff.idx); + } + return group.endPos + 1 === positionResolver(diff.idx); } From 57d314ab90ac498c63e0db2651966f0806e0d0a1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 15:32:05 -0300 Subject: [PATCH 26/53] refactor: convert modules to typescript and improve documentation --- .../algorithm/attributes-diffing.test.js | 2 +- ...butes-diffing.js => attributes-diffing.ts} | 97 +++-- .../diffing/algorithm/generic-diffing.test.js | 2 +- ...{generic-diffing.js => generic-diffing.ts} | 152 ++++--- .../diffing/algorithm/inline-diffing.js | 246 ------------ .../diffing/algorithm/inline-diffing.test.js | 6 +- .../diffing/algorithm/inline-diffing.ts | 373 ++++++++++++++++++ .../{myers-diff.js => myers-diff.ts} | 47 ++- .../diffing/algorithm/paragraph-diffing.js | 261 ------------ .../algorithm/paragraph-diffing.test.js | 2 +- .../diffing/algorithm/paragraph-diffing.ts | 288 ++++++++++++++ .../algorithm/sequence-diffing.test.js | 2 +- ...equence-diffing.js => sequence-diffing.ts} | 78 ++-- .../{similarity.js => similarity.ts} | 13 +- .../src/extensions/diffing/computeDiff.js | 11 - .../src/extensions/diffing/computeDiff.ts | 20 + .../src/extensions/diffing/diffing.js | 2 +- 17 files changed, 927 insertions(+), 675 deletions(-) rename packages/super-editor/src/extensions/diffing/algorithm/{attributes-diffing.js => attributes-diffing.ts} (60%) rename packages/super-editor/src/extensions/diffing/algorithm/{generic-diffing.js => generic-diffing.ts} (50%) delete mode 100644 packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts rename packages/super-editor/src/extensions/diffing/algorithm/{myers-diff.js => myers-diff.ts} (58%) delete mode 100644 packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts rename packages/super-editor/src/extensions/diffing/algorithm/{sequence-diffing.js => sequence-diffing.ts} (60%) rename packages/super-editor/src/extensions/diffing/algorithm/{similarity.js => similarity.ts} (71%) delete mode 100644 packages/super-editor/src/extensions/diffing/computeDiff.js create mode 100644 packages/super-editor/src/extensions/diffing/computeDiff.ts diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js index 2b9c26998..cf80ae640 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getAttributesDiff } from './attributes-diffing.js'; +import { getAttributesDiff } from './attributes-diffing.ts'; describe('getAttributesDiff', () => { it('detects nested additions, deletions, and modifications', () => { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts similarity index 60% rename from packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js rename to packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts index 2346d5c28..59f08e459 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts @@ -1,21 +1,35 @@ +const IGNORED_ATTRIBUTE_KEYS = new Set(['sdBlockId']); + /** - * @typedef {Object} AttributesDiff - * @property {Record} added - * @property {Record} deleted - * @property {Record} modified + * Represents a single attribute change capturing the previous and next values. */ +export interface AttributeChange { + from: unknown; + to: unknown; +} + +/** + * Aggregated attribute diff broken down into added, deleted, and modified dotted paths. + */ +export interface AttributesDiff { + added: Record; + deleted: Record; + modified: Record; +} /** * Computes the attribute level diff between two arbitrary objects. * Produces a map of dotted paths to added, deleted and modified values. - * @param {Record} objectA - * @param {Record} objectB - * @returns {AttributesDiff|null} + * + * @param objectA Baseline attributes to compare. + * @param objectB Updated attributes to compare. + * @returns Structured diff or null when objects are effectively equal. */ -const IGNORED_ATTRIBUTE_KEYS = new Set(['sdBlockId']); - -export function getAttributesDiff(objectA = {}, objectB = {}) { - const diff = { +export function getAttributesDiff( + objectA: Record | null | undefined = {}, + objectB: Record | null | undefined = {}, +): AttributesDiff | null { + const diff: AttributesDiff = { added: {}, deleted: {}, modified: {}, @@ -30,12 +44,18 @@ export function getAttributesDiff(objectA = {}, objectB = {}) { /** * Recursively compares two objects and fills the diff buckets. - * @param {Record} objectA - * @param {Record} objectB - * @param {string} basePath - * @param {AttributesDiff} diff + * + * @param objectA Baseline attributes being inspected. + * @param objectB Updated attributes being inspected. + * @param basePath Dotted path prefix used for nested keys. + * @param diff Aggregated diff being mutated. */ -function diffObjects(objectA, objectB, basePath, diff) { +function diffObjects( + objectA: Record, + objectB: Record, + basePath: string, + diff: AttributesDiff, +): void { const keys = new Set([...Object.keys(objectA || {}), ...Object.keys(objectB || {})]); for (const key of keys) { @@ -71,7 +91,7 @@ function diffObjects(objectA, objectB, basePath, diff) { } } - if (valueA !== valueB) { + if (!deepEquals(valueA, valueB)) { diff.modified[path] = { from: valueA, to: valueB, @@ -82,11 +102,12 @@ function diffObjects(objectA, objectB, basePath, diff) { /** * Records a nested value as an addition, flattening objects into dotted paths. - * @param {any} value - * @param {string} path - * @param {{added: Record}} diff + * + * @param value Value being marked as added. + * @param path Dotted attribute path for the value. + * @param diff Bucket used to capture additions. */ -function recordAddedValue(value, path, diff) { +function recordAddedValue(value: unknown, path: string, diff: Pick): void { if (isPlainObject(value)) { for (const [childKey, childValue] of Object.entries(value)) { if (IGNORED_ATTRIBUTE_KEYS.has(childKey)) { @@ -101,11 +122,12 @@ function recordAddedValue(value, path, diff) { /** * Records a nested value as a deletion, flattening objects into dotted paths. - * @param {any} value - * @param {string} path - * @param {{deleted: Record}} diff + * + * @param value Value being marked as removed. + * @param path Dotted attribute path for the value. + * @param diff Bucket used to capture deletions. */ -function recordDeletedValue(value, path, diff) { +function recordDeletedValue(value: unknown, path: string, diff: Pick): void { if (isPlainObject(value)) { for (const [childKey, childValue] of Object.entries(value)) { if (IGNORED_ATTRIBUTE_KEYS.has(childKey)) { @@ -120,30 +142,33 @@ function recordDeletedValue(value, path, diff) { /** * Builds dotted attribute paths. - * @param {string} base - * @param {string} key - * @returns {string} + * + * @param base Existing path prefix. + * @param key Current key being appended. + * @returns Combined dotted path. */ -function joinPath(base, key) { +function joinPath(base: string, key: string): string { return base ? `${base}.${key}` : key; } /** * Determines if a value is a plain object (no arrays or nulls). - * @param {any} value - * @returns {value is Record} + * + * @param value Value to inspect. + * @returns True when the value is a non-null object. */ -function isPlainObject(value) { +function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } /** * Checks deep equality for primitives, arrays, and plain objects. - * @param {any} a - * @param {any} b - * @returns {boolean} + * + * @param a First value. + * @param b Second value. + * @returns True when both values are deeply equal. */ -function deepEquals(a, b) { +function deepEquals(a: unknown, b: unknown): boolean { if (a === b) { return true; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js index 8c5ef5b2e..1dbabf98d 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { diffNodes } from './generic-diffing.js'; +import { diffNodes } from './generic-diffing.ts'; const createDocFromNodes = (nodes = []) => ({ descendants(callback) { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts similarity index 50% rename from packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js rename to packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index b5d160beb..5c0944bf5 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -1,3 +1,4 @@ +import type { Node as PMNode } from 'prosemirror-model'; import { getParagraphContent, paragraphComparator, @@ -6,49 +7,82 @@ import { buildAddedParagraphDiff, buildDeletedParagraphDiff, buildModifiedParagraphDiff, -} from './paragraph-diffing.js'; -import { diffSequences, reorderDiffOperations } from './sequence-diffing.js'; -import { getAttributesDiff } from './attributes-diffing.js'; + type ParagraphContentToken, + type ParagraphDiff, + type ParagraphResolvedSnapshot, +} from './paragraph-diffing.ts'; +import { diffSequences, reorderDiffOperations } from './sequence-diffing.ts'; +import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; /** - * @typedef {import('prosemirror-model').Node} PMNode + * Minimal node metadata extracted during document traversal. */ +type BaseNodeInfo = { + node: PMNode; + pos: number; +}; /** - * @typedef {Object} BaseNodeInfo - * @property {PMNode} node - * @property {number} pos + * Paragraph-specific node info enriched with textual content and resolvers. */ +type ParagraphNodeInfo = ParagraphResolvedSnapshot; +/** + * Union describing every node processed by the generic diff. + */ +type NodeInfo = BaseNodeInfo | ParagraphNodeInfo; + +/** + * Diff payload describing an inserted non-paragraph node. + */ +interface NonParagraphAddedDiff { + action: 'added'; + nodeType: string; + node: PMNode; + pos: number; +} /** - * @typedef {BaseNodeInfo & { - * text: import('./paragraph-diffing.js').ParagraphContentToken[], - * resolvePosition: (idx: number) => number|null, - * fullText: string - * }} ParagraphNodeInfo + * Diff payload describing a deleted non-paragraph node. */ +interface NonParagraphDeletedDiff { + action: 'deleted'; + nodeType: string; + node: PMNode; + pos: number; +} /** - * @typedef {BaseNodeInfo | ParagraphNodeInfo} NodeInfo + * Diff payload describing an attribute-only change on non-paragraph nodes. */ +interface NonParagraphModifiedDiff { + action: 'modified'; + nodeType: string; + oldNode: PMNode; + newNode: PMNode; + pos: number; + attrsDiff: AttributesDiff; +} + +/** + * Union of every diff type emitted by the generic diffing layer. + */ +export type NodeDiff = ParagraphDiff | NonParagraphAddedDiff | NonParagraphDeletedDiff | NonParagraphModifiedDiff; /** * Produces a sequence diff between two ProseMirror documents, flattening paragraphs for inline-aware comparisons. - * @param {PMNode} oldRoot - * @param {PMNode} newRoot - * @returns {Array} */ -export function diffNodes(oldRoot, newRoot) { +export function diffNodes(oldRoot: PMNode, newRoot: PMNode): NodeDiff[] { const oldNodes = normalizeNodes(oldRoot); const newNodes = normalizeNodes(newRoot); - const addedNodesSet = new Set(); - return diffSequences(oldNodes, newNodes, { + const addedNodesSet = new Set(); + return diffSequences(oldNodes, newNodes, { comparator: nodeComparator, reorderOperations: reorderDiffOperations, shouldProcessEqualAsModification, canTreatAsModification, - buildAdded: (nodeInfo, oldIdx, previousOldNodeInfo) => buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet), + buildAdded: (nodeInfo, _oldIdx, previousOldNodeInfo) => + buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet), buildDeleted: buildDeletedDiff, buildModified: buildModifiedDiff, }); @@ -56,58 +90,52 @@ export function diffNodes(oldRoot, newRoot) { /** * Traverses a ProseMirror document and converts paragraphs to richer node info objects. - * @param {PMNode} pmDoc - * @returns {NodeInfo[]} */ -function normalizeNodes(pmDoc) { - const nodes = []; +function normalizeNodes(pmDoc: PMNode): NodeInfo[] { + const nodes: NodeInfo[] = []; pmDoc.descendants((node, pos) => { if (node.type.name === 'paragraph') { const { text, resolvePosition } = getParagraphContent(node, pos); + const fullText = getFullText(text); nodes.push({ node, pos, text, resolvePosition, - get fullText() { - return text.map((c) => c.char).join(''); - }, + fullText, }); - return false; // Do not descend further - } else { - nodes.push({ node, pos }); + return false; } + nodes.push({ node, pos }); + return undefined; }); return nodes; } +function getFullText(tokens: ParagraphContentToken[]): string { + return tokens.map((token) => (token.kind === 'text' ? token.char : '')).join(''); +} + /** * Compares two node infos to determine if they correspond to the same logical node. * Paragraphs are compared with `paragraphComparator`, while other nodes are matched by type name. - * @param {NodeInfo} oldNodeInfo - * @param {NodeInfo} newNodeInfo - * @returns {boolean} */ -function nodeComparator(oldNodeInfo, newNodeInfo) { +function nodeComparator(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): boolean { if (oldNodeInfo.node.type.name !== newNodeInfo.node.type.name) { return false; } - if (oldNodeInfo.node.type.name === 'paragraph') { + if (isParagraphNodeInfo(oldNodeInfo) && isParagraphNodeInfo(newNodeInfo)) { return paragraphComparator(oldNodeInfo, newNodeInfo); - } else { - return oldNodeInfo.node.type.name === newNodeInfo.node.type.name; } + return true; } /** * Decides whether nodes deemed equal by the diff should still be emitted as modifications. * Paragraph nodes leverage their specialized handler, while other nodes compare attribute JSON. - * @param {NodeInfo} oldNodeInfo - * @param {NodeInfo} newNodeInfo - * @returns {boolean} */ -function shouldProcessEqualAsModification(oldNodeInfo, newNodeInfo) { - if (oldNodeInfo.node.type.name === 'paragraph' && newNodeInfo.node.type.name === 'paragraph') { +function shouldProcessEqualAsModification(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): boolean { + if (isParagraphNodeInfo(oldNodeInfo) && isParagraphNodeInfo(newNodeInfo)) { return shouldProcessEqualParagraphsAsModification(oldNodeInfo, newNodeInfo); } return JSON.stringify(oldNodeInfo.node.attrs) !== JSON.stringify(newNodeInfo.node.attrs); @@ -116,12 +144,9 @@ function shouldProcessEqualAsModification(oldNodeInfo, newNodeInfo) { /** * Determines whether a delete/insert pair should instead be surfaced as a modification. * Only paragraphs qualify because we can measure textual similarity; other nodes remain as-is. - * @param {NodeInfo} deletedNodeInfo - * @param {NodeInfo} insertedNodeInfo - * @returns {boolean} */ -function canTreatAsModification(deletedNodeInfo, insertedNodeInfo) { - if (deletedNodeInfo.node.type.name === 'paragraph' && insertedNodeInfo.node.type.name === 'paragraph') { +function canTreatAsModification(deletedNodeInfo: NodeInfo, insertedNodeInfo: NodeInfo): boolean { + if (isParagraphNodeInfo(deletedNodeInfo) && isParagraphNodeInfo(insertedNodeInfo)) { return canTreatParagraphDeletionInsertionAsModification(deletedNodeInfo, insertedNodeInfo); } return false; @@ -129,24 +154,26 @@ function canTreatAsModification(deletedNodeInfo, insertedNodeInfo) { /** * Builds the diff payload for an inserted node and tracks descendants to avoid duplicates. - * @param {NodeInfo} nodeInfo - * @param {NodeInfo} previousOldNodeInfo - * @param {Set} addedNodesSet - * @returns {ReturnType|{action:'added', nodeType:string, node:PMNode, pos:number}|null} */ -function buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet) { +function buildAddedDiff( + nodeInfo: NodeInfo, + previousOldNodeInfo: NodeInfo | undefined, + addedNodesSet: Set, +): NodeDiff | null { if (addedNodesSet.has(nodeInfo.node)) { return null; } addedNodesSet.add(nodeInfo.node); - if (nodeInfo.node.type.name === 'paragraph') { + if (isParagraphNodeInfo(nodeInfo)) { return buildAddedParagraphDiff(nodeInfo, previousOldNodeInfo); } nodeInfo.node.descendants((childNode) => { addedNodesSet.add(childNode); }); - const pos = previousOldNodeInfo.pos + previousOldNodeInfo.node.nodeSize; + const previousPos = previousOldNodeInfo?.pos ?? -1; + const previousSize = previousOldNodeInfo?.node.nodeSize ?? 0; + const pos = previousPos >= 0 ? previousPos + previousSize : 0; return { action: 'added', nodeType: nodeInfo.node.type.name, @@ -157,11 +184,9 @@ function buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet) { /** * Builds the diff payload for a deleted node. - * @param {NodeInfo} nodeInfo - * @returns {ReturnType|{action:'deleted', nodeType:string, node:PMNode, pos:number}} */ -function buildDeletedDiff(nodeInfo) { - if (nodeInfo.node.type.name === 'paragraph') { +function buildDeletedDiff(nodeInfo: NodeInfo): NodeDiff { + if (isParagraphNodeInfo(nodeInfo)) { return buildDeletedParagraphDiff(nodeInfo); } return { @@ -175,12 +200,9 @@ function buildDeletedDiff(nodeInfo) { /** * Builds the diff payload for a modified node. * Paragraphs delegate to their inline-aware builder, while other nodes report attribute diffs. - * @param {NodeInfo} oldNodeInfo - * @param {NodeInfo} newNodeInfo - * @returns {ReturnType|{action:'modified', nodeType:string, oldNode:PMNode, newNode:PMNode, pos:number, attrsDiff: import('./attributes-diffing.js').AttributesDiff}|null} */ -function buildModifiedDiff(oldNodeInfo, newNodeInfo) { - if (oldNodeInfo.node.type.name === 'paragraph' && newNodeInfo.node.type.name === 'paragraph') { +function buildModifiedDiff(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): NodeDiff | null { + if (isParagraphNodeInfo(oldNodeInfo) && isParagraphNodeInfo(newNodeInfo)) { return buildModifiedParagraphDiff(oldNodeInfo, newNodeInfo); } @@ -197,3 +219,7 @@ function buildModifiedDiff(oldNodeInfo, newNodeInfo) { attrsDiff, }; } + +function isParagraphNodeInfo(nodeInfo: NodeInfo): nodeInfo is ParagraphNodeInfo { + return nodeInfo.node.type.name === 'paragraph'; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js deleted file mode 100644 index 24412bf8c..000000000 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.js +++ /dev/null @@ -1,246 +0,0 @@ -import { getAttributesDiff } from './attributes-diffing.js'; -import { diffSequences } from './sequence-diffing.js'; - -/** - * @typedef {{kind: 'text', char: string, runAttrs: string}} InlineTextToken - */ - -/** - * @typedef {{kind: 'inlineNode', node: import('prosemirror-model').Node, nodeType?: string}} InlineNodeToken - */ - -/** - * @typedef {InlineTextToken|InlineNodeToken} InlineDiffToken - */ - -/** - * @typedef {{action: 'added'|'deleted'|'modified', kind: 'text'|'inlineNode', startPos: number|null, endPos: number|null, text?: string, oldText?: string, newText?: string, runAttrs?: Record, runAttrsDiff?: import('./attributes-diffing.js').AttributesDiff, node?: import('prosemirror-model').Node, nodeType?: string, oldNode?: import('prosemirror-model').Node, newNode?: import('prosemirror-model').Node}} InlineDiffResult - */ - -/** - * Computes text-level additions and deletions between two sequences using the generic sequence diff, mapping back to document positions. - * @param {InlineDiffToken[]} oldContent - Source tokens. - * @param {InlineDiffToken[]} newContent - Target tokens. - * @param {(index: number) => number|null} oldPositionResolver - Maps string indexes to the original document. - * @param {(index: number) => number|null} [newPositionResolver=oldPositionResolver] - Maps string indexes to the updated document. - * @returns {InlineDiffResult[]} List of grouped inline diffs with document positions and text content. - */ -export function getInlineDiff(oldContent, newContent, oldPositionResolver, newPositionResolver = oldPositionResolver) { - const buildInlineDiff = (action, token, oldIdx) => { - if (token.kind !== 'text') { - return { - action, - idx: oldIdx, - ...token, - }; - } else { - return { - action, - idx: oldIdx, - kind: 'text', - text: token.char, - runAttrs: token.runAttrs, - }; - } - }; - let diffs = diffSequences(oldContent, newContent, { - comparator: inlineComparator, - shouldProcessEqualAsModification, - canTreatAsModification: (oldToken, newToken) => - oldToken.kind === newToken.kind && oldToken.kind !== 'text' && oldToken.node.type.type === newToken.node.type, - buildAdded: (token, oldIdx) => buildInlineDiff('added', token, oldIdx), - buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx), - buildModified: (oldToken, newToken, oldIdx) => { - if (oldToken.kind !== 'text') { - return { - action: 'modified', - idx: oldIdx, - kind: 'inlineNode', - oldNode: oldToken.node, - newNode: newToken.node, - nodeType: oldToken.nodeType, - }; - } else { - return { - action: 'modified', - idx: oldIdx, - kind: 'text', - newText: newToken.char, - oldText: oldToken.char, - oldAttrs: oldToken.runAttrs, - newAttrs: newToken.runAttrs, - }; - } - }, - }); - - const groupedDiffs = groupDiffs(diffs, oldPositionResolver); - return groupedDiffs; -} - -/** - * Compares two inline tokens to decide if they can be considered equal for the Myers diff. - * Text tokens compare character equality while inline nodes compare their type. - * @param {InlineDiffToken} a - * @param {InlineDiffToken} b - * @returns {boolean} - */ -function inlineComparator(a, b) { - if (a.kind !== b.kind) { - return false; - } - - if (a.kind === 'text') { - return a.char === b.char; - } else { - return a.node.type === b.node.type; - } -} - -/** - * Determines whether equal tokens should still be treated as modifications, either because run attributes changed or the node payload differs. - * @param {InlineDiffToken} oldToken - * @param {InlineDiffToken} newToken - * @returns {boolean} - */ -function shouldProcessEqualAsModification(oldToken, newToken) { - if (oldToken.kind === 'text') { - return oldToken.runAttrs !== newToken.runAttrs; - } else { - return JSON.stringify(oldToken.toJSON()) !== JSON.stringify(newToken.toJSON()); - } -} - -/** - * Groups raw diff operations into contiguous ranges and converts serialized run attrs back to objects. - * @param {Array<{action:'added'|'deleted'|'modified', idx:number, kind:'text'|'inlineNode', text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string, nodeType?: string, node?: import('prosemirror-model').Node, oldNode?: import('prosemirror-model').Node, newNode?: import('prosemirror-model').Node}>} diffs - * @param {(index: number) => number|null} oldPositionResolver - * @returns {InlineDiffResult[]} - */ -function groupDiffs(diffs, oldPositionResolver) { - const grouped = []; - let currentGroup = null; - - /** - * Finalizes the current text group (if any) and appends it to the grouped result list. - * Resets the working group so the caller can start accumulating the next run. - */ - const pushCurrentGroup = () => { - if (!currentGroup) { - return; - } - const result = { ...currentGroup }; - if (currentGroup.action === 'modified') { - const oldAttrs = JSON.parse(currentGroup.oldAttrs); - const newAttrs = JSON.parse(currentGroup.newAttrs); - result.runAttrsDiff = getAttributesDiff(oldAttrs, newAttrs); - delete result.oldAttrs; - delete result.newAttrs; - } else { - result.runAttrs = JSON.parse(currentGroup.runAttrs); - } - grouped.push(result); - currentGroup = null; - }; - - // Iterate over raw diffs and group text changes where possible - for (const diff of diffs) { - if (diff.kind !== 'text') { - pushCurrentGroup(); - grouped.push({ - action: diff.action, - kind: 'inlineNode', - startPos: oldPositionResolver(diff.idx), - endPos: oldPositionResolver(diff.idx), - nodeType: diff.nodeType, - ...(diff.action === 'modified' - ? { - oldNode: diff.oldNode, - newNode: diff.newNode, - diffNodeAttrs: getAttributesDiff(diff.oldAttrs, diff.newAttrs), - } - : { node: diff.node }), - }); - continue; - } - - if (!currentGroup || !canExtendGroup(currentGroup, diff, oldPositionResolver)) { - pushCurrentGroup(); - currentGroup = createTextGroup(diff, oldPositionResolver); - } else { - extendTextGroup(currentGroup, diff, oldPositionResolver); - } - } - - pushCurrentGroup(); - return grouped; -} - -/** - * Builds a fresh text diff group seeded with the current diff token. - * @param {{action:'added'|'deleted'|'modified', idx:number, kind:'text', text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string}} diff - * @param {(index:number)=>number|null} positionResolver - * @returns {{action:'added'|'deleted'|'modified', kind:'text', startPos:number, endPos:number, text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string}} - */ -function createTextGroup(diff, positionResolver) { - const baseGroup = { - action: diff.action, - kind: 'text', - startPos: positionResolver(diff.idx), - endPos: positionResolver(diff.idx), - }; - if (diff.action === 'modified') { - baseGroup.newText = diff.newText; - baseGroup.oldText = diff.oldText; - baseGroup.oldAttrs = diff.oldAttrs; - baseGroup.newAttrs = diff.newAttrs; - } else { - baseGroup.text = diff.text; - baseGroup.runAttrs = diff.runAttrs; - } - return baseGroup; -} - -/** - * Expands the current text group with the incoming diff token. - * Keeps start/end positions updated while concatenating text payloads. - * @param {{action:'added'|'deleted'|'modified', kind:'text', startPos:number, endPos:number, text?: string, runAttrs?: string, newText?: string, oldText?: string, oldAttrs?: string, newAttrs?: string}} group - * @param {{action:'added'|'deleted'|'modified', idx:number, kind:'text', text?: string, runAttrs?: string, newText?: string, oldText?: string}} diff - * @param {(index:number)=>number|null} positionResolver - */ -function extendTextGroup(group, diff, positionResolver) { - group.endPos = positionResolver(diff.idx); - if (group.action === 'modified') { - group.newText += diff.newText; - group.oldText += diff.oldText; - } else { - group.text += diff.text; - } -} - -/** - * Determines whether a text diff token can be merged into the current group. - * Checks action, attributes, and adjacency constraints required by the grouping heuristic. - * @param {{action:'added'|'deleted'|'modified', kind:'text', startPos:number, endPos:number, runAttrs?: string, oldAttrs?: string, newAttrs?: string}} group - * @param {{action:'added'|'deleted'|'modified', idx:number, kind:'text', runAttrs?: string, oldAttrs?: string, newAttrs?: string}} diff - * @param {(index:number)=>number|null} positionResolver - * @returns {boolean} - */ -function canExtendGroup(group, diff, positionResolver) { - if (group.action !== diff.action) { - return false; - } - - if (group.action === 'modified') { - if (group.oldAttrs !== diff.oldAttrs || group.newAttrs !== diff.newAttrs) { - return false; - } - } else if (group.runAttrs !== diff.runAttrs) { - return false; - } - - if (group.action === 'added') { - return group.startPos === positionResolver(diff.idx); - } - return group.endPos + 1 === positionResolver(diff.idx); -} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 6a71229ac..b373826c3 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -1,11 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -vi.mock('./myers-diff.js', async () => { - const actual = await vi.importActual('./myers-diff.js'); +vi.mock('./myers-diff.ts', async () => { + const actual = await vi.importActual('./myers-diff.ts'); return { myersDiff: vi.fn(actual.myersDiff), }; }); -import { getInlineDiff } from './inline-diffing.js'; +import { getInlineDiff } from './inline-diffing.ts'; const buildTextRuns = (text, runAttrs = {}) => text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs), kind: 'text' })); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts new file mode 100644 index 000000000..7cddccb92 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -0,0 +1,373 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +import { diffSequences } from './sequence-diffing.ts'; + +/** + * Supported diff operations for inline changes. + */ +type InlineAction = 'added' | 'deleted' | 'modified'; + +/** + * Serialized representation of a single text character plus its run attributes. + */ +export type InlineTextToken = { + kind: 'text'; + char: string; + runAttrs: string; +}; + +/** + * Flattened inline node token treated as a single diff unit. + */ +export type InlineNodeToken = { + kind: 'inlineNode'; + node: PMNode; + nodeType?: string; + toJSON?: () => unknown; +}; + +/** + * Union of inline token kinds used as input for Myers diffing. + */ +export type InlineDiffToken = InlineTextToken | InlineNodeToken; + +/** + * Intermediate text diff emitted by `diffSequences`. + */ +type RawTextDiff = + | { + action: Exclude; + idx: number; + kind: 'text'; + text: string; + runAttrs: string; + } + | { + action: 'modified'; + idx: number; + kind: 'text'; + newText: string; + oldText: string; + oldAttrs: string; + newAttrs: string; + }; + +/** + * Intermediate inline node diff emitted by `diffSequences`. + */ +type RawInlineNodeDiff = + | { + action: Exclude; + idx: number; + kind: 'inlineNode'; + node: PMNode; + nodeType?: string; + } + | { + action: 'modified'; + idx: number; + kind: 'inlineNode'; + oldNode: PMNode; + newNode: PMNode; + nodeType?: string; + oldAttrs?: Record; + newAttrs?: Record; + }; + +/** + * Combined raw diff union for text and inline node tokens. + */ +type RawDiff = RawTextDiff | RawInlineNodeDiff; + +/** + * Maps flattened string indexes back to ProseMirror document positions. + */ +type PositionResolver = (index: number) => number | null; + +/** + * Final grouped inline diff exposed to downstream consumers. + */ +export interface InlineDiffResult { + action: InlineAction; + kind: 'text' | 'inlineNode'; + startPos: number | null; + endPos: number | null; + text?: string; + oldText?: string; + newText?: string; + runAttrs?: Record; + runAttrsDiff?: AttributesDiff | null; + node?: PMNode; + nodeType?: string; + oldNode?: PMNode; + newNode?: PMNode; + diffNodeAttrs?: AttributesDiff | null; +} + +/** + * Computes text-level additions and deletions between two sequences using the generic sequence diff, mapping back to document positions. + * + * @param oldContent Source tokens. + * @param newContent Target tokens. + * @param oldPositionResolver Maps string indexes to the original document. + * @param newPositionResolver Maps string indexes to the updated document. + * @returns List of grouped inline diffs with document positions and text content. + */ +export function getInlineDiff( + oldContent: InlineDiffToken[], + newContent: InlineDiffToken[], + oldPositionResolver: PositionResolver, + newPositionResolver: PositionResolver = oldPositionResolver, +): InlineDiffResult[] { + void newPositionResolver; + + const buildInlineDiff = (action: InlineAction, token: InlineDiffToken, oldIdx: number): RawDiff => { + if (token.kind !== 'text') { + return { + action, + idx: oldIdx, + kind: 'inlineNode', + node: token.node, + nodeType: token.nodeType, + }; + } + return { + action, + idx: oldIdx, + kind: 'text', + text: token.char, + runAttrs: token.runAttrs, + }; + }; + + const diffs = diffSequences(oldContent, newContent, { + comparator: inlineComparator, + shouldProcessEqualAsModification, + canTreatAsModification: (oldToken, newToken) => + oldToken.kind === newToken.kind && oldToken.kind !== 'text' && oldToken.node.type === newToken.node.type, + buildAdded: (token, oldIdx) => buildInlineDiff('added', token, oldIdx), + buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx), + buildModified: (oldToken, newToken, oldIdx) => { + if (oldToken.kind !== 'text' && newToken.kind !== 'text') { + return { + action: 'modified', + idx: oldIdx, + kind: 'inlineNode', + oldNode: oldToken.node, + newNode: newToken.node, + nodeType: oldToken.nodeType, + }; + } + if (oldToken.kind === 'text' && newToken.kind === 'text') { + return { + action: 'modified', + idx: oldIdx, + kind: 'text', + newText: newToken.char, + oldText: oldToken.char, + oldAttrs: oldToken.runAttrs, + newAttrs: newToken.runAttrs, + }; + } + return null; + }, + }); + + return groupDiffs(diffs, oldPositionResolver); +} + +/** + * Compares two inline tokens to decide if they can be considered equal for the Myers diff. + * Text tokens compare character equality while inline nodes compare their type. + */ +function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean { + if (a.kind !== b.kind) { + return false; + } + + if (a.kind === 'text' && b.kind === 'text') { + return a.char === b.char; + } + if (a.kind === 'inlineNode' && b.kind === 'inlineNode') { + return a.node.type === b.node.type; + } + return false; +} + +/** + * Determines whether equal tokens should still be treated as modifications, either because run attributes changed or the node payload differs. + */ +function shouldProcessEqualAsModification(oldToken: InlineDiffToken, newToken: InlineDiffToken): boolean { + if (oldToken.kind === 'text' && newToken.kind === 'text') { + return oldToken.runAttrs !== newToken.runAttrs; + } + + if (oldToken.kind === 'inlineNode' && newToken.kind === 'inlineNode') { + const oldJSON = oldToken.toJSON?.() ?? oldToken.node.toJSON(); + const newJSON = newToken.toJSON?.() ?? newToken.node.toJSON(); + return JSON.stringify(oldJSON) !== JSON.stringify(newJSON); + } + + return false; +} + +/** + * Accumulator structure used while coalescing contiguous text diffs. + */ +type TextDiffGroup = + | { + action: Exclude; + kind: 'text'; + startPos: number | null; + endPos: number | null; + text: string; + runAttrs: string; + } + | { + action: 'modified'; + kind: 'text'; + startPos: number | null; + endPos: number | null; + newText: string; + oldText: string; + oldAttrs: string; + newAttrs: string; + }; + +/** + * Groups raw diff operations into contiguous ranges and converts serialized run attrs back to objects. + * + * @param diffs Raw diff operations from the sequence diff. + * @param oldPositionResolver Maps text indexes to original document positions. + * @returns Grouped inline diffs with start/end document positions. + */ +function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): InlineDiffResult[] { + const grouped: InlineDiffResult[] = []; + let currentGroup: TextDiffGroup | null = null; + + const pushCurrentGroup = () => { + if (!currentGroup) { + return; + } + const result: InlineDiffResult = { + action: currentGroup.action, + kind: 'text', + startPos: currentGroup.startPos, + endPos: currentGroup.endPos, + }; + + if (currentGroup.action === 'modified') { + const oldAttrs = JSON.parse(currentGroup.oldAttrs); + const newAttrs = JSON.parse(currentGroup.newAttrs); + result.oldText = currentGroup.oldText; + result.newText = currentGroup.newText; + result.runAttrsDiff = getAttributesDiff(oldAttrs, newAttrs); + } else { + result.text = currentGroup.text; + result.runAttrs = JSON.parse(currentGroup.runAttrs); + } + + grouped.push(result); + currentGroup = null; + }; + + for (const diff of diffs) { + if (diff.kind !== 'text') { + pushCurrentGroup(); + grouped.push({ + action: diff.action, + kind: 'inlineNode', + startPos: oldPositionResolver(diff.idx), + endPos: oldPositionResolver(diff.idx), + nodeType: diff.nodeType, + ...(diff.action === 'modified' + ? { + oldNode: diff.oldNode, + newNode: diff.newNode, + diffNodeAttrs: getAttributesDiff(diff.oldAttrs, diff.newAttrs), + } + : { node: diff.node }), + }); + continue; + } + + if (!currentGroup || !canExtendGroup(currentGroup, diff, oldPositionResolver)) { + pushCurrentGroup(); + currentGroup = createTextGroup(diff, oldPositionResolver); + } else { + extendTextGroup(currentGroup, diff, oldPositionResolver); + } + } + + pushCurrentGroup(); + return grouped; +} + +/** + * Builds a fresh text diff group seeded with the current diff token. + */ +function createTextGroup(diff: RawTextDiff, positionResolver: PositionResolver): TextDiffGroup { + const baseGroup = + diff.action === 'modified' + ? { + action: diff.action, + kind: 'text' as const, + startPos: positionResolver(diff.idx), + endPos: positionResolver(diff.idx), + newText: diff.newText, + oldText: diff.oldText, + oldAttrs: diff.oldAttrs, + newAttrs: diff.newAttrs, + } + : { + action: diff.action, + kind: 'text' as const, + startPos: positionResolver(diff.idx), + endPos: positionResolver(diff.idx), + text: diff.text, + runAttrs: diff.runAttrs, + }; + + return baseGroup; +} + +/** + * Expands the current text group with the incoming diff token. + * Keeps start/end positions updated while concatenating text payloads. + */ +function extendTextGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolver: PositionResolver): void { + group.endPos = positionResolver(diff.idx); + if (group.action === 'modified' && diff.action === 'modified') { + group.newText += diff.newText; + group.oldText += diff.oldText; + } else if (group.action !== 'modified' && diff.action !== 'modified') { + group.text += diff.text; + } +} + +/** + * Determines whether a text diff token can be merged into the current group. + * Checks action, attributes, and adjacency constraints required by the grouping heuristic. + */ +function canExtendGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolver: PositionResolver): boolean { + if (group.action !== diff.action) { + return false; + } + + if (group.action === 'modified' && diff.action === 'modified') { + if (group.oldAttrs !== diff.oldAttrs || group.newAttrs !== diff.newAttrs) { + return false; + } + } else if (group.action !== 'modified' && diff.action !== 'modified') { + if (group.runAttrs !== diff.runAttrs) { + return false; + } + } else { + return false; + } + + if (group.action === 'added') { + return group.startPos === positionResolver(diff.idx); + } + return (group.endPos ?? 0) + 1 === positionResolver(diff.idx); +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js b/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.ts similarity index 58% rename from packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js rename to packages/super-editor/src/extensions/diffing/algorithm/myers-diff.ts index 6704d93a5..b90e709c7 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/myers-diff.ts @@ -1,11 +1,26 @@ +/** + * A primitive Myers diff operation describing equality, insertion, or deletion. + */ +export type MyersOperation = 'equal' | 'insert' | 'delete'; + +/** + * Minimal read-only sequence abstraction required by the diff algorithm. + */ +type Sequence = ArrayLike; +/** + * Equality predicate applied while traversing sequences. + */ +type Comparator = (a: T, b: T) => boolean; + /** * Computes a Myers diff operation list for arbitrary sequences. - * @param {Array|String} oldSeq - * @param {Array|String} newSeq - * @param {(a: any, b: any) => boolean} isEqual - * @returns {Array<'equal'|'insert'|'delete'>} + * + * @param oldSeq Original sequence to compare. + * @param newSeq Updated sequence to compare. + * @param isEqual Equality predicate used to determine matching elements. + * @returns Ordered list of diff operations describing how to transform {@link oldSeq} into {@link newSeq}. */ -export function myersDiff(oldSeq, newSeq, isEqual) { +export function myersDiff(oldSeq: Sequence, newSeq: Sequence, isEqual: Comparator): MyersOperation[] { const oldLen = oldSeq.length; const newLen = newSeq.length; @@ -17,16 +32,16 @@ export function myersDiff(oldSeq, newSeq, isEqual) { const max = oldLen + newLen; const size = 2 * max + 3; const offset = max + 1; - const v = new Array(size).fill(-1); + const v = new Array(size).fill(-1); v[offset + 1] = 0; - const trace = []; + const trace: number[][] = []; let foundPath = false; for (let d = 0; d <= max && !foundPath; d += 1) { for (let k = -d; k <= d; k += 2) { const index = offset + k; - let x; + let x: number; if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { x = v[index + 1]; @@ -56,14 +71,14 @@ export function myersDiff(oldSeq, newSeq, isEqual) { /** * Reconstructs the shortest edit script by walking the previously recorded V vectors. * - * @param {Array} trace - Snapshot of diagonal furthest-reaching points per edit distance. - * @param {number} oldLen - Length of the original string. - * @param {number} newLen - Length of the target string. - * @param {number} offset - Offset applied to diagonal indexes to keep array lookups positive. - * @returns {Array<'equal'|'delete'|'insert'>} Concrete step-by-step operations. + * @param trace Snapshot of diagonal furthest-reaching points per edit distance. + * @param oldLen Length of the original sequence. + * @param newLen Length of the target sequence. + * @param offset Offset applied to diagonal indexes to keep array lookups positive. + * @returns Concrete step-by-step operations transforming {@link oldLen} chars into {@link newLen} chars. */ -function backtrackMyers(trace, oldLen, newLen, offset) { - const operations = []; +function backtrackMyers(trace: number[][], oldLen: number, newLen: number, offset: number): MyersOperation[] { + const operations: MyersOperation[] = []; let x = oldLen; let y = newLen; @@ -72,7 +87,7 @@ function backtrackMyers(trace, oldLen, newLen, offset) { const k = x - y; const index = offset + k; - let prevK; + let prevK: number; if (k === -d || (k !== d && v[index - 1] < v[index + 1])) { prevK = k + 1; } else { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js deleted file mode 100644 index e852b0189..000000000 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.js +++ /dev/null @@ -1,261 +0,0 @@ -import { getInlineDiff } from './inline-diffing.js'; -import { getAttributesDiff } from './attributes-diffing.js'; -import { levenshteinDistance } from './similarity.js'; - -// Heuristics that prevent unrelated paragraphs from being paired as modifications. -const SIMILARITY_THRESHOLD = 0.65; -const MIN_LENGTH_FOR_SIMILARITY = 4; - -/** - * A paragraph addition diff emitted when new content is inserted. - * @typedef {Object} AddedParagraphDiff - * @property {'added'} action - * @property {string} nodeType ProseMirror node.name for downstream handling - * @property {Node} node reference to the ProseMirror node for consumers needing schema details - * @property {string} text textual contents of the inserted paragraph - * @property {number} pos document position where the paragraph was inserted - */ - -/** - * A paragraph deletion diff emitted when content is removed. - * @typedef {Object} DeletedParagraphDiff - * @property {'deleted'} action - * @property {string} nodeType ProseMirror node.name for downstream handling - * @property {Node} node reference to the original ProseMirror node - * @property {string} oldText text that was removed - * @property {number} pos starting document position of the original paragraph - */ - -/** - * A paragraph modification diff that carries inline text-level changes. - * @typedef {Object} ModifiedParagraphDiff - * @property {'modified'} action - * @property {string} nodeType ProseMirror node.name for downstream handling - * @property {string} oldText text before the edit - * @property {string} newText text after the edit - * @property {number} pos original document position for anchoring UI - * @property {ReturnType} contentDiff granular inline diff data - * @property {import('./attributes-diffing.js').AttributesDiff|null} attrsDiff attribute-level changes between the old and new paragraph nodes - */ - -/** - * Combined type representing every diff payload produced by `diffParagraphs`. - * @typedef {AddedParagraphDiff|DeletedParagraphDiff|ModifiedParagraphDiff} ParagraphDiff - */ - -/** - * A flattened representation of a text token derived from a paragraph. - * @typedef {Object} ParagraphTextToken - * @property {'text'} kind - * @property {string} char - * @property {string} runAttrs JSON stringified run attributes originating from the parent node - */ - -/** - * A flattened representation of an inline node that is treated as a single token by the diff. - * @typedef {Object} ParagraphInlineNodeToken - * @property {'inlineNode'} kind - * @property {Node} node - */ - -/** - * @typedef {ParagraphTextToken|ParagraphInlineNodeToken} ParagraphContentToken - */ - -/** - * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. - * @param {Node} paragraph - Paragraph node to flatten. - * @param {number} [paragraphPos=0] - Position of the paragraph in the document. - * @returns {{text: ParagraphContentToken[], resolvePosition: (index: number) => number|null}} Concatenated text tokens and a resolver that maps indexes to document positions. - */ -export function getParagraphContent(paragraph, paragraphPos = 0) { - let content = []; - const segments = []; - - paragraph.nodesBetween( - 0, - paragraph.content.size, - (node, pos) => { - let nodeText = ''; - - if (node.isText) { - nodeText = node.text; - } else if (node.isLeaf && node.type.spec.leafText) { - nodeText = node.type.spec.leafText(node); - } else if (node.type.name !== 'run' && node.isInline) { - const start = content.length; - const end = start + 1; - content.push({ - kind: 'inlineNode', - node: node, - }); - segments.push({ start, end, pos }); - return; - } else { - return; - } - - const start = content.length; - const end = start + nodeText.length; - - const runNode = paragraph.nodeAt(pos - 1); - const runAttrs = runNode.attrs || {}; - - segments.push({ start, end, pos }); - const chars = nodeText.split('').map((char) => ({ - kind: 'text', - char, - runAttrs: JSON.stringify(runAttrs), - })); - - content = content.concat(chars); - }, - 0, - ); - - const resolvePosition = (index) => { - if (index < 0 || index > content.length) { - return null; - } - - for (const segment of segments) { - if (index >= segment.start && index < segment.end) { - return paragraphPos + 1 + segment.pos + (index - segment.start); - } - } - - // If index points to the end of the string, return the paragraph end - return paragraphPos + 1 + paragraph.content.size; - }; - - return { text: content, resolvePosition }; -} - -/** - * Determines whether equal paragraph nodes should still be marked as modified because their serialized structure differs. - * @param {{node: Node}} oldParagraph - * @param {{node: Node}} newParagraph - * @returns {boolean} - */ -export function shouldProcessEqualAsModification(oldParagraph, newParagraph) { - return JSON.stringify(oldParagraph.node.toJSON()) !== JSON.stringify(newParagraph.node.toJSON()); -} - -/** - * Compares two paragraphs for identity based on paraId or text content so the diff can prioritize logical matches. - * This prevents the algorithm from treating the same paragraph as a deletion+insertion when the paraId or text proves - * they refer to the same logical node, which in turn keeps visual diffs stable. - * @param {{node: Node, fullText: string}} oldParagraph - * @param {{node: Node, fullText: string}} newParagraph - * @returns {boolean} - */ -export function paragraphComparator(oldParagraph, newParagraph) { - const oldId = oldParagraph?.node?.attrs?.paraId; - const newId = newParagraph?.node?.attrs?.paraId; - if (oldId && newId && oldId === newId) { - return true; - } - return oldParagraph?.fullText === newParagraph?.fullText; -} - -/** - * Builds a normalized payload describing a paragraph addition, ensuring all consumers receive the same metadata shape. - * @param {{node: Node, pos: number, fullText: string}} paragraph - * @param {{node: Node, pos: number}} previousOldNodeInfo node/position reference used to determine insertion point - * @returns {AddedParagraphDiff} - */ -export function buildAddedParagraphDiff(paragraph, previousOldNodeInfo) { - const pos = previousOldNodeInfo.pos + previousOldNodeInfo.node.nodeSize; - return { - action: 'added', - nodeType: paragraph.node.type.name, - node: paragraph.node, - text: paragraph.fullText, - pos: pos, - }; -} - -/** - * Builds a normalized payload describing a paragraph deletion so diff consumers can show removals with all context. - * @param {{node: Node, pos: number, fullText: string}} paragraph - * @returns {DeletedParagraphDiff} - */ -export function buildDeletedParagraphDiff(paragraph) { - return { - action: 'deleted', - nodeType: paragraph.node.type.name, - node: paragraph.node, - oldText: paragraph.fullText, - pos: paragraph.pos, - }; -} - -/** - * Builds the payload for a paragraph modification, including text-level diffs, so renderers can highlight edits inline. - * @param {{node: Node, pos: number, text: ParagraphContentToken[], resolvePosition: Function, fullText: string}} oldParagraph - * @param {{node: Node, pos: number, text: ParagraphContentToken[], resolvePosition: Function, fullText: string}} newParagraph - * @returns {ModifiedParagraphDiff|null} - */ -export function buildModifiedParagraphDiff(oldParagraph, newParagraph) { - const contentDiff = getInlineDiff( - oldParagraph.text, - newParagraph.text, - oldParagraph.resolvePosition, - newParagraph.resolvePosition, - ); - - const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); - if (contentDiff.length === 0 && !attrsDiff) { - return null; - } - - return { - action: 'modified', - nodeType: oldParagraph.node.type.name, - oldText: oldParagraph.fullText, - newText: newParagraph.fullText, - pos: oldParagraph.pos, - contentDiff, - attrsDiff, - }; -} - -/** - * Decides whether a delete/insert pair should be reinterpreted as a modification to minimize noisy diff output. - * This heuristic limits the number of false-positive additions/deletions, which keeps reviewers focused on real edits. - * @param {{node: Node, fullText: string}} oldParagraph - * @param {{node: Node, fullText: string}} newParagraph - * @returns {boolean} - */ -export function canTreatAsModification(oldParagraph, newParagraph) { - if (paragraphComparator(oldParagraph, newParagraph)) { - return true; - } - - const oldText = oldParagraph.fullText; - const newText = newParagraph.fullText; - const maxLength = Math.max(oldText.length, newText.length); - if (maxLength < MIN_LENGTH_FOR_SIMILARITY) { - return false; - } - - const similarity = getTextSimilarityScore(oldText, newText); - - return similarity >= SIMILARITY_THRESHOLD; -} - -/** - * Scores the similarity between two text strings so the diff can decide if they represent the same conceptual paragraph. - * @param {string} oldText - * @param {string} newText - * @returns {number} - */ -function getTextSimilarityScore(oldText, newText) { - if (!oldText && !newText) { - return 1; - } - - const distance = levenshteinDistance(oldText, newText); - const maxLength = Math.max(oldText.length, newText.length) || 1; - return 1 - distance / maxLength; -} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index b457e38d8..5f508e44e 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -7,7 +7,7 @@ import { buildDeletedParagraphDiff, buildModifiedParagraphDiff, canTreatAsModification, -} from './paragraph-diffing.js'; +} from './paragraph-diffing.ts'; const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs), kind: 'text' })); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts new file mode 100644 index 000000000..a875381cc --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -0,0 +1,288 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import { getInlineDiff, type InlineDiffToken, type InlineDiffResult } from './inline-diffing.ts'; +import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +import { levenshteinDistance } from './similarity.ts'; + +// Heuristics that prevent unrelated paragraphs from being paired as modifications. +const SIMILARITY_THRESHOLD = 0.65; +const MIN_LENGTH_FOR_SIMILARITY = 4; + +/** + * Flattened token emitted from a paragraph. Delegates to inline diff tokens. + */ +export type ParagraphContentToken = InlineDiffToken; +/** + * Maps flattened indexes back to the ProseMirror document. + */ +export type PositionResolver = (index: number) => number | null; + +/** + * Internal bookkeeping entry that remembers the start/end indexes for shallow nodes. + */ +interface ParagraphSegment { + start: number; + end: number; + pos: number; +} + +/** + * Computed textual representation of a paragraph plus its index resolver. + */ +export interface ParagraphContent { + text: ParagraphContentToken[]; + resolvePosition: PositionResolver; +} + +/** + * Bare reference to a paragraph node and its document position. + */ +export interface ParagraphNodeReference { + node: PMNode; + pos: number; +} + +/** + * Snapshot of a paragraph that includes its flattened text form. + */ +export interface ParagraphSnapshot extends ParagraphNodeReference { + fullText: string; +} + +/** + * Paragraph snapshot extended with the tokenized content and resolver. + */ +export interface ParagraphResolvedSnapshot extends ParagraphSnapshot { + text: ParagraphContentToken[]; + resolvePosition: PositionResolver; +} + +/** + * Diff payload produced when a paragraph is inserted. + */ +export interface AddedParagraphDiff { + action: 'added'; + nodeType: string; + node: PMNode; + text: string; + pos: number; +} + +/** + * Diff payload produced when a paragraph is deleted. + */ +export interface DeletedParagraphDiff { + action: 'deleted'; + nodeType: string; + node: PMNode; + oldText: string; + pos: number; +} + +/** + * Diff payload emitted when a paragraph changes, including inline edits. + */ +export interface ModifiedParagraphDiff { + action: 'modified'; + nodeType: string; + oldText: string; + newText: string; + pos: number; + contentDiff: InlineDiffResult[]; + attrsDiff: AttributesDiff | null; +} + +/** + * Union of every diff variant the paragraph diffing logic can produce. + */ +export type ParagraphDiff = AddedParagraphDiff | DeletedParagraphDiff | ModifiedParagraphDiff; + +/** + * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. + * + * @param paragraph Paragraph node to flatten. + * @param paragraphPos Position of the paragraph in the document. + * @returns Concatenated text tokens and a resolver that maps indexes to document positions. + */ +export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): ParagraphContent { + const content: ParagraphContentToken[] = []; + const segments: ParagraphSegment[] = []; + + paragraph.nodesBetween( + 0, + paragraph.content.size, + (node, pos) => { + let nodeText = ''; + + if (node.isText) { + nodeText = node.text ?? ''; + } else if (node.isLeaf) { + const leafTextFn = (node.type.spec as { leafText?: (node: PMNode) => string } | undefined)?.leafText; + if (leafTextFn) { + nodeText = leafTextFn(node); + } + } + + if (nodeText) { + const start = content.length; + const end = start + nodeText.length; + const runNode = paragraph.nodeAt(pos - 1); + const runAttrs = runNode?.attrs ?? {}; + segments.push({ start, end, pos }); + const chars = nodeText.split('').map((char) => ({ + kind: 'text', + char, + runAttrs: JSON.stringify(runAttrs), + })); + content.push(...(chars as ParagraphContentToken[])); + return; + } + + if (node.type.name !== 'run' && node.isInline) { + const start = content.length; + const end = start + 1; + content.push({ + kind: 'inlineNode', + node, + }); + segments.push({ start, end, pos }); + } + }, + 0, + ); + + const resolvePosition: PositionResolver = (index) => { + if (index < 0 || index > content.length) { + return null; + } + + for (const segment of segments) { + if (index >= segment.start && index < segment.end) { + return paragraphPos + 1 + segment.pos + (index - segment.start); + } + } + + return paragraphPos + 1 + paragraph.content.size; + }; + + return { text: content, resolvePosition }; +} + +/** + * Determines whether equal paragraph nodes should still be marked as modified because their serialized structure differs. + * + * @param oldParagraph Previous paragraph node reference. + * @param newParagraph Updated paragraph node reference. + * @returns True when the serialized JSON payload differs. + */ +export function shouldProcessEqualAsModification( + oldParagraph: ParagraphNodeReference, + newParagraph: ParagraphNodeReference, +): boolean { + return JSON.stringify(oldParagraph.node.toJSON()) !== JSON.stringify(newParagraph.node.toJSON()); +} + +/** + * Compares two paragraphs for identity based on paraId or text content. + */ +export function paragraphComparator(oldParagraph: ParagraphSnapshot, newParagraph: ParagraphSnapshot): boolean { + const oldId = oldParagraph?.node?.attrs?.paraId; + const newId = newParagraph?.node?.attrs?.paraId; + if (oldId && newId && oldId === newId) { + return true; + } + return oldParagraph?.fullText === newParagraph?.fullText; +} + +/** + * Builds a normalized payload describing a paragraph addition, ensuring all consumers receive the same metadata shape. + */ +export function buildAddedParagraphDiff( + paragraph: ParagraphSnapshot, + previousOldNodeInfo?: ParagraphNodeReference, +): AddedParagraphDiff { + const previousNodeSize = previousOldNodeInfo?.node.nodeSize ?? 0; + const previousPos = previousOldNodeInfo?.pos ?? -1; + const pos = previousPos >= 0 ? previousPos + previousNodeSize : 0; + return { + action: 'added', + nodeType: paragraph.node.type.name, + node: paragraph.node, + text: paragraph.fullText, + pos, + }; +} + +/** + * Builds a normalized payload describing a paragraph deletion so diff consumers can show removals with all context. + */ +export function buildDeletedParagraphDiff(paragraph: ParagraphSnapshot): DeletedParagraphDiff { + return { + action: 'deleted', + nodeType: paragraph.node.type.name, + node: paragraph.node, + oldText: paragraph.fullText, + pos: paragraph.pos, + }; +} + +/** + * Builds the payload for a paragraph modification, including text-level diffs, so renderers can highlight edits inline. + */ +export function buildModifiedParagraphDiff( + oldParagraph: ParagraphResolvedSnapshot, + newParagraph: ParagraphResolvedSnapshot, +): ModifiedParagraphDiff | null { + const contentDiff = getInlineDiff( + oldParagraph.text, + newParagraph.text, + oldParagraph.resolvePosition, + newParagraph.resolvePosition, + ); + + const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); + if (contentDiff.length === 0 && !attrsDiff) { + return null; + } + + return { + action: 'modified', + nodeType: oldParagraph.node.type.name, + oldText: oldParagraph.fullText, + newText: newParagraph.fullText, + pos: oldParagraph.pos, + contentDiff, + attrsDiff, + }; +} + +/** + * Decides whether a delete/insert pair should be reinterpreted as a modification to minimize noisy diff output. + */ +export function canTreatAsModification(oldParagraph: ParagraphSnapshot, newParagraph: ParagraphSnapshot): boolean { + if (paragraphComparator(oldParagraph, newParagraph)) { + return true; + } + + const oldText = oldParagraph.fullText; + const newText = newParagraph.fullText; + const maxLength = Math.max(oldText.length, newText.length); + if (maxLength < MIN_LENGTH_FOR_SIMILARITY) { + return false; + } + + const similarity = getTextSimilarityScore(oldText, newText); + return similarity >= SIMILARITY_THRESHOLD; +} + +/** + * Scores the similarity between two text strings so the diff can decide if they represent the same conceptual paragraph. + */ +function getTextSimilarityScore(oldText: string, newText: string): number { + if (!oldText && !newText) { + return 1; + } + + const distance = levenshteinDistance(oldText, newText); + const maxLength = Math.max(oldText.length, newText.length) || 1; + return 1 - distance / maxLength; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js index 679b4cc63..e953cb82a 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { diffSequences } from './sequence-diffing.js'; +import { diffSequences } from './sequence-diffing.ts'; const buildAdded = (item) => ({ action: 'added', id: item.id }); const buildDeleted = (item) => ({ action: 'deleted', id: item.id }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts similarity index 60% rename from packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js rename to packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts index 21fceb821..1edced791 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts @@ -1,32 +1,52 @@ -import { myersDiff } from './myers-diff.js'; +import { myersDiff, type MyersOperation } from './myers-diff.ts'; /** - * @typedef {Object} SequenceDiffOptions - * @property {(a: any, b: any) => boolean} [comparator] equality test passed to Myers diff - * @property {(item: any, oldIdx: number, previousOldItem: any, index: number) => any} buildAdded maps newly inserted entries - * @property {(item: any, index: number) => any} buildDeleted maps removed entries - * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => any|null} buildModified maps paired entries. If it returns null/undefined, it means no modification should be recorded. - * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [shouldProcessEqualAsModification] decides if equal-aligned entries should emit a modification - * @property {(oldItem: any, newItem: any, oldIndex: number, newIndex: number) => boolean} [canTreatAsModification] determines if delete/insert pairs are modifications - * @property {(operations: Array<'equal'|'delete'|'insert'>) => Array<'equal'|'delete'|'insert'>} [reorderOperations] optional hook to normalize raw Myers operations + * Comparator used to determine whether two sequence values are equal. */ +type Comparator = (a: T, b: T) => boolean; + +/** + * Discrete operation emitted by the Myers diff before higher-level mapping. + */ +type OperationStep = + | { type: 'equal'; oldIdx: number; newIdx: number } + | { type: 'delete'; oldIdx: number; newIdx: number } + | { type: 'insert'; oldIdx: number; newIdx: number }; + +/** + * Hooks and comparators used to translate raw Myers operations into domain-specific diffs. + */ +export interface SequenceDiffOptions { + comparator?: Comparator; + buildAdded: (item: T, oldIdx: number, previousOldItem: T | undefined, newIdx: number) => Added | null | undefined; + buildDeleted: (item: T, oldIdx: number, newIdx: number) => Deleted; + buildModified: (oldItem: T, newItem: T, oldIdx: number, newIdx: number) => Modified | null | undefined; + shouldProcessEqualAsModification?: (oldItem: T, newItem: T, oldIdx: number, newIdx: number) => boolean; + canTreatAsModification?: (deletedItem: T, insertedItem: T, oldIdx: number, newIdx: number) => boolean; + reorderOperations?: (operations: MyersOperation[]) => MyersOperation[]; +} /** * Generic sequence diff helper built on top of Myers algorithm. * Allows callers to provide custom comparators and payload builders that determine how * additions, deletions, and modifications should be reported. - * @param {Array} oldSeq - * @param {Array} newSeq - * @param {SequenceDiffOptions} options - * @returns {Array} + * + * @param oldSeq Original sequence to diff from. + * @param newSeq Target sequence to diff against. + * @param options Hook bundle that controls how additions/deletions/modifications are emitted. + * @returns Sequence of mapped diff payloads produced by the caller-provided builders. */ -export function diffSequences(oldSeq, newSeq, options) { +export function diffSequences( + oldSeq: T[], + newSeq: T[], + options: SequenceDiffOptions, +): Array { if (!options) { throw new Error('diffSequences requires an options object.'); } - const comparator = options.comparator ?? ((a, b) => a === b); - const reorder = options.reorderOperations ?? ((ops) => ops); + const comparator: Comparator = options.comparator ?? ((a: T, b: T) => a === b); + const reorder = options.reorderOperations ?? ((ops: MyersOperation[]) => ops); const canTreatAsModification = options.canTreatAsModification; const shouldProcessEqualAsModification = options.shouldProcessEqualAsModification; @@ -43,7 +63,7 @@ export function diffSequences(oldSeq, newSeq, options) { const operations = reorder(myersDiff(oldSeq, newSeq, comparator)); const steps = buildOperationSteps(operations); - const diffs = []; + const diffs: Array = []; for (let i = 0; i < steps.length; i += 1) { const step = steps[i]; @@ -57,7 +77,7 @@ export function diffSequences(oldSeq, newSeq, options) { continue; } const diff = options.buildModified(oldItem, newItem, step.oldIdx, step.newIdx); - if (diff) { + if (diff != null) { diffs.push(diff); } continue; @@ -71,7 +91,7 @@ export function diffSequences(oldSeq, newSeq, options) { canTreatAsModification(oldSeq[step.oldIdx], newSeq[nextStep.newIdx], step.oldIdx, nextStep.newIdx) ) { const diff = options.buildModified(oldSeq[step.oldIdx], newSeq[nextStep.newIdx], step.oldIdx, nextStep.newIdx); - if (diff) { + if (diff != null) { diffs.push(diff); } i += 1; @@ -83,7 +103,7 @@ export function diffSequences(oldSeq, newSeq, options) { if (step.type === 'insert') { const diff = options.buildAdded(newSeq[step.newIdx], step.oldIdx, oldSeq[step.oldIdx - 1], step.newIdx); - if (diff) { + if (diff != null) { diffs.push(diff); } } @@ -94,13 +114,14 @@ export function diffSequences(oldSeq, newSeq, options) { /** * Translates the raw Myers operations into indexed steps so higher-level logic can reason about positions. - * @param {Array<'equal'|'delete'|'insert'>} operations - * @returns {Array} + * + * @param operations Myers diff operations produced for the input sequences. + * @returns Indexed steps that reference the original `oldSeq` and `newSeq` positions. */ -function buildOperationSteps(operations) { +function buildOperationSteps(operations: MyersOperation[]): OperationStep[] { let oldIdx = 0; let newIdx = 0; - const steps = []; + const steps: OperationStep[] = []; for (const op of operations) { if (op === 'equal') { @@ -121,11 +142,12 @@ function buildOperationSteps(operations) { /** * Normalizes interleaved delete/insert operations so consumers can treat replacements as paired steps. - * @param {Array<'equal'|'delete'|'insert'>} operations - * @returns {Array<'equal'|'delete'|'insert'>} + * + * @param operations Raw Myers operations. + * @returns Normalized operation sequence with deletes and inserts paired. */ -export function reorderDiffOperations(operations) { - const normalized = []; +export function reorderDiffOperations(operations: MyersOperation[]): MyersOperation[] { + const normalized: MyersOperation[] = []; for (let i = 0; i < operations.length; i += 1) { const op = operations[i]; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/similarity.js b/packages/super-editor/src/extensions/diffing/algorithm/similarity.ts similarity index 71% rename from packages/super-editor/src/extensions/diffing/algorithm/similarity.js rename to packages/super-editor/src/extensions/diffing/algorithm/similarity.ts index d0118aabd..60d9604e8 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/similarity.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/similarity.ts @@ -1,10 +1,11 @@ /** * Computes the Levenshtein edit distance between two strings. - * @param {string} a - * @param {string} b - * @returns {number} + * + * @param a First string. + * @param b Second string. + * @returns Minimum number of edits required to transform {@link a} into {@link b}. */ -export function levenshteinDistance(a, b) { +export function levenshteinDistance(a: string, b: string): number { const lenA = a.length; const lenB = b.length; @@ -15,8 +16,8 @@ export function levenshteinDistance(a, b) { return lenA; } - let previous = new Array(lenB + 1); - let current = new Array(lenB + 1); + let previous = new Array(lenB + 1); + let current = new Array(lenB + 1); for (let j = 0; j <= lenB; j += 1) { previous[j] = j; diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.js b/packages/super-editor/src/extensions/diffing/computeDiff.js deleted file mode 100644 index 4025597fe..000000000 --- a/packages/super-editor/src/extensions/diffing/computeDiff.js +++ /dev/null @@ -1,11 +0,0 @@ -import { diffNodes } from './algorithm/generic-diffing.js'; - -/** - * Computes paragraph-level diffs between two ProseMirror documents, returning inserts, deletes and text modifications. - * @param {Node} oldPmDoc - The previous ProseMirror document. - * @param {Node} newPmDoc - The updated ProseMirror document. - * @returns {Array} List of diff objects describing added, deleted or modified paragraphs. - */ -export function computeDiff(oldPmDoc, newPmDoc) { - return diffNodes(oldPmDoc, newPmDoc); -} diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts new file mode 100644 index 000000000..feb84642a --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -0,0 +1,20 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import { diffNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; + +/** + * Computes structural diffs between two ProseMirror documents, emitting insert/delete/modify operations for any block + * node (paragraphs, images, tables, etc.). Paragraph mutations include inline text and inline-node diffs so consumers + * can reflect character-level and formatting changes as well. + * + * Diffs are intended to be replayed on top of the old document in reverse order: `pos` marks the cursor location + * that should be used before applying the diff at that index. For example, consecutive additions that sit between the + * same pair of old nodes will share the same `pos`, so applying them from the end of the list guarantees they appear + * in the correct order in the reconstructed document. + * + * @param oldPmDoc The previous ProseMirror document. + * @param newPmDoc The updated ProseMirror document. + * @returns List of diff objects describing added, deleted or modified nodes (with inline-level diffs for paragraphs). + */ +export function computeDiff(oldPmDoc: PMNode, newPmDoc: PMNode): NodeDiff[] { + return diffNodes(oldPmDoc, newPmDoc); +} diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index 1ebfdc3b0..f90c51821 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -1,6 +1,6 @@ // @ts-nocheck import { Extension } from '@core/Extension.js'; -import { computeDiff } from './computeDiff.js'; +import { computeDiff } from './computeDiff.ts'; export const Diffing = Extension.create({ name: 'documentDiffing', From 2c34b2648dfca4c0fc84fe0e1fd19598b5b23edf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 16:26:05 -0300 Subject: [PATCH 27/53] fix: diffing of inline node attributes --- .../diffing/algorithm/inline-diffing.test.js | 44 +++++++++++++++++++ .../diffing/algorithm/inline-diffing.ts | 4 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index b373826c3..3479fce29 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -10,6 +10,19 @@ import { getInlineDiff } from './inline-diffing.ts'; const buildTextRuns = (text, runAttrs = {}) => text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs), kind: 'text' })); +const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }) => { + const nodeAttrs = { ...attrs }; + return { + kind: 'inlineNode', + nodeType: 'link', + node: { + type, + attrs: nodeAttrs, + toJSON: () => ({ type: 'link', attrs: nodeAttrs }), + }, + }; +}; + describe('getInlineDiff', () => { it('returns an empty diff list when both strings are identical', () => { const resolver = (index) => index; @@ -107,4 +120,35 @@ describe('getInlineDiff', () => { }, ]); }); + + it('surfaces attribute diffs for inline node modifications', () => { + const resolver = (index) => index + 3; + const sharedType = { name: 'link' }; + const oldNode = buildInlineNodeToken({ href: 'https://old.example', label: 'Example' }, sharedType); + const newNode = buildInlineNodeToken({ href: 'https://new.example', label: 'Example' }, sharedType); + + const diffs = getInlineDiff([oldNode], [newNode], resolver); + + expect(diffs).toEqual([ + { + action: 'modified', + kind: 'inlineNode', + nodeType: 'link', + startPos: 3, + endPos: 3, + oldNode: oldNode.node, + newNode: newNode.node, + attrsDiff: { + added: {}, + deleted: {}, + modified: { + href: { + from: 'https://old.example', + to: 'https://new.example', + }, + }, + }, + }, + ]); + }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index 7cddccb92..c5233d2c0 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -101,7 +101,7 @@ export interface InlineDiffResult { nodeType?: string; oldNode?: PMNode; newNode?: PMNode; - diffNodeAttrs?: AttributesDiff | null; + attrsDiff?: AttributesDiff | null; } /** @@ -284,7 +284,7 @@ function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): In ? { oldNode: diff.oldNode, newNode: diff.newNode, - diffNodeAttrs: getAttributesDiff(diff.oldAttrs, diff.newAttrs), + attrsDiff: getAttributesDiff(diff.oldNode.attrs, diff.newNode.attrs), } : { node: diff.node }), }); From bd11e93e6fbd863a3c5b1482ee07bd604e04d9b9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 16:30:40 -0300 Subject: [PATCH 28/53] fix: diff positions for modified non-paragraph nodes --- .../src/extensions/diffing/algorithm/generic-diffing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index 5c0944bf5..71bb0d3ef 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -215,7 +215,7 @@ function buildModifiedDiff(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): NodeDi nodeType: oldNodeInfo.node.type.name, oldNode: oldNodeInfo.node, newNode: newNodeInfo.node, - pos: newNodeInfo.pos, + pos: oldNodeInfo.pos, attrsDiff, }; } From b8a80ba8f440c9235fc6f870bd5509c741f74ee1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 29 Dec 2025 17:24:51 -0300 Subject: [PATCH 29/53] feat: improve diff comparison for table rows --- .../src/extensions/diffing/algorithm/generic-diffing.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index 71bb0d3ef..b8ed91a29 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -126,6 +126,13 @@ function nodeComparator(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): boolean { } if (isParagraphNodeInfo(oldNodeInfo) && isParagraphNodeInfo(newNodeInfo)) { return paragraphComparator(oldNodeInfo, newNodeInfo); + } else if ( + oldNodeInfo.node.type.name === 'tableRow' && + newNodeInfo.node.type.name === 'tableRow' && + oldNodeInfo.node.attrs.paraId && + newNodeInfo.node.attrs.paraId + ) { + return oldNodeInfo.node.attrs.paraId === newNodeInfo.node.attrs.paraId; } return true; } From 77216ba2354f8fabb93242df6aa6d2f693eee169 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 11:00:43 -0300 Subject: [PATCH 30/53] fix: emit single diff when container node is deleted --- .../diffing/algorithm/generic-diffing.test.js | 16 ++++++++++++++++ .../diffing/algorithm/generic-diffing.ts | 12 ++++++++++-- .../diffing/algorithm/sequence-diffing.ts | 7 +++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js index 1dbabf98d..e6663a880 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js @@ -149,6 +149,22 @@ describe('diffParagraphs', () => { expect(additions[0].nodeType).toBe('figure'); }); + it('deduplicates deleted nodes and their descendants', () => { + const childNode = buildSimpleNode('image'); + const parentNode = buildSimpleNode('figure', {}, { children: [childNode] }); + const paragraph = createParagraph('Base paragraph', {}, { pos: 0 }); + const figurePos = paragraph.pos + paragraph.node.nodeSize; + + const diffs = diffNodes( + createDocFromNodes([paragraph, { node: parentNode, pos: figurePos }, { node: childNode, pos: figurePos + 1 }]), + createDocFromNodes([paragraph]), + ); + + const deletions = diffs.filter((diff) => diff.action === 'deleted'); + expect(deletions).toHaveLength(1); + expect(deletions[0].nodeType).toBe('figure'); + }); + it('computes insertion position based on the previous old node', () => { const oldParagraph = createParagraph('Hello!', {}, { pos: 0 }); const newParagraph = createParagraph('Hello!', {}, { pos: 0 }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index b8ed91a29..b2409cc71 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -76,6 +76,7 @@ export function diffNodes(oldRoot: PMNode, newRoot: PMNode): NodeDiff[] { const newNodes = normalizeNodes(newRoot); const addedNodesSet = new Set(); + const deletedNodesSet = new Set(); return diffSequences(oldNodes, newNodes, { comparator: nodeComparator, reorderOperations: reorderDiffOperations, @@ -83,7 +84,7 @@ export function diffNodes(oldRoot: PMNode, newRoot: PMNode): NodeDiff[] { canTreatAsModification, buildAdded: (nodeInfo, _oldIdx, previousOldNodeInfo) => buildAddedDiff(nodeInfo, previousOldNodeInfo, addedNodesSet), - buildDeleted: buildDeletedDiff, + buildDeleted: (nodeInfo) => buildDeletedDiff(nodeInfo, deletedNodesSet), buildModified: buildModifiedDiff, }); } @@ -192,10 +193,17 @@ function buildAddedDiff( /** * Builds the diff payload for a deleted node. */ -function buildDeletedDiff(nodeInfo: NodeInfo): NodeDiff { +function buildDeletedDiff(nodeInfo: NodeInfo, deletedNodesSet: Set): NodeDiff | null { + if (deletedNodesSet.has(nodeInfo.node)) { + return null; + } + deletedNodesSet.add(nodeInfo.node); if (isParagraphNodeInfo(nodeInfo)) { return buildDeletedParagraphDiff(nodeInfo); } + nodeInfo.node.descendants((childNode) => { + deletedNodesSet.add(childNode); + }); return { action: 'deleted', nodeType: nodeInfo.node.type.name, diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts index 1edced791..136184bdc 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts @@ -19,7 +19,7 @@ type OperationStep = export interface SequenceDiffOptions { comparator?: Comparator; buildAdded: (item: T, oldIdx: number, previousOldItem: T | undefined, newIdx: number) => Added | null | undefined; - buildDeleted: (item: T, oldIdx: number, newIdx: number) => Deleted; + buildDeleted: (item: T, oldIdx: number, newIdx: number) => Deleted | null | undefined; buildModified: (oldItem: T, newItem: T, oldIdx: number, newIdx: number) => Modified | null | undefined; shouldProcessEqualAsModification?: (oldItem: T, newItem: T, oldIdx: number, newIdx: number) => boolean; canTreatAsModification?: (deletedItem: T, insertedItem: T, oldIdx: number, newIdx: number) => boolean; @@ -96,7 +96,10 @@ export function diffSequences( } i += 1; } else { - diffs.push(options.buildDeleted(oldSeq[step.oldIdx], step.oldIdx, step.newIdx)); + const diff = options.buildDeleted(oldSeq[step.oldIdx], step.oldIdx, step.newIdx); + if (diff != null) { + diffs.push(diff); + } } continue; } From 531b22a2997fa03c5a564013d43df35d8e726d1c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 11:37:40 -0300 Subject: [PATCH 31/53] fix: compute correct insertion position for first child node --- .../diffing/algorithm/generic-diffing.test.js | 81 ++++++++++++++----- .../diffing/algorithm/generic-diffing.ts | 23 ++++-- .../diffing/algorithm/paragraph-diffing.ts | 12 ++- 3 files changed, 89 insertions(+), 27 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js index e6663a880..bb2af561e 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js @@ -1,11 +1,26 @@ import { describe, it, expect } from 'vitest'; import { diffNodes } from './generic-diffing.ts'; -const createDocFromNodes = (nodes = []) => ({ - descendants(callback) { - nodes.forEach(({ node, pos }) => callback(node, pos)); - }, -}); +const createDocFromNodes = (nodes = []) => { + const docNode = { + type: { name: 'doc', spec: {} }, + descendants(callback) { + const childIndexMap = new WeakMap(); + const depthStack = [docNode]; + for (const entry of nodes) { + const { node, pos, depth = 1 } = entry; + depthStack.length = depth; + const parentNode = depthStack[depth - 1] ?? docNode; + const currentIndex = childIndexMap.get(parentNode) ?? 0; + childIndexMap.set(parentNode, currentIndex + 1); + callback(node, pos, parentNode, currentIndex); + depthStack[depth] = node; + } + }, + }; + + return docNode; +}; const buildSimpleNode = (typeName, attrs = {}, options = {}) => { const { nodeSize = 2, children = [] } = options; @@ -27,7 +42,7 @@ const buildSimpleNode = (typeName, attrs = {}, options = {}) => { }; const createParagraph = (text, attrs = {}, options = {}) => { - const { pos = 0, textAttrs = {} } = options; + const { pos = 0, textAttrs = {}, depth = 1 } = options; const paragraphNode = { attrs, type: { name: 'paragraph', spec: {} }, @@ -54,15 +69,15 @@ const createParagraph = (text, attrs = {}, options = {}) => { }; paragraphNode.toJSON = () => ({ type: paragraphNode.type.name, attrs: paragraphNode.attrs }); - return { node: paragraphNode, pos }; + return { node: paragraphNode, pos, depth }; }; describe('diffParagraphs', () => { it('treats similar paragraphs without IDs as modifications', () => { const oldParagraphs = [createParagraph('Hello world from ProseMirror.')]; const newParagraphs = [createParagraph('Hello brave new world from ProseMirror.')]; - const oldRoot = { descendants: (cb) => oldParagraphs.forEach((p) => cb(p.node, p.pos)) }; - const newRoot = { descendants: (cb) => newParagraphs.forEach((p) => cb(p.node, p.pos)) }; + const oldRoot = createDocFromNodes(oldParagraphs); + const newRoot = createDocFromNodes(newParagraphs); const diffs = diffNodes(oldRoot, newRoot); @@ -74,8 +89,8 @@ describe('diffParagraphs', () => { it('keeps unrelated paragraphs as deletion + addition', () => { const oldParagraphs = [createParagraph('Alpha paragraph with some text.')]; const newParagraphs = [createParagraph('Zephyr quickly jinxed the new passage.')]; - const oldRoot = { descendants: (cb) => oldParagraphs.forEach((p) => cb(p.node, p.pos)) }; - const newRoot = { descendants: (cb) => newParagraphs.forEach((p) => cb(p.node, p.pos)) }; + const oldRoot = createDocFromNodes(oldParagraphs); + const newRoot = createDocFromNodes(newParagraphs); const diffs = diffNodes(oldRoot, newRoot); @@ -93,8 +108,8 @@ describe('diffParagraphs', () => { createParagraph('Original introduction paragraph that now has tweaks.'), createParagraph('Completely different replacement paragraph.'), ]; - const oldRoot = { descendants: (cb) => oldParagraphs.forEach((p) => cb(p.node, p.pos)) }; - const newRoot = { descendants: (cb) => newParagraphs.forEach((p) => cb(p.node, p.pos)) }; + const oldRoot = createDocFromNodes(oldParagraphs); + const newRoot = createDocFromNodes(newParagraphs); const diffs = diffNodes(oldRoot, newRoot); @@ -117,8 +132,8 @@ describe('diffParagraphs', () => { }); it('emits attribute diffs for non-paragraph nodes', () => { - const oldHeading = { node: buildSimpleNode('heading', { level: 1 }), pos: 0 }; - const newHeading = { node: buildSimpleNode('heading', { level: 2 }), pos: 0 }; + const oldHeading = { node: buildSimpleNode('heading', { level: 1 }), pos: 0, depth: 1 }; + const newHeading = { node: buildSimpleNode('heading', { level: 2 }), pos: 0, depth: 1 }; const diffs = diffNodes(createDocFromNodes([oldHeading]), createDocFromNodes([newHeading])); expect(diffs).toHaveLength(1); @@ -139,8 +154,8 @@ describe('diffParagraphs', () => { createDocFromNodes([oldParagraph]), createDocFromNodes([ newParagraph, - { node: parentNode, pos: insertionPos }, - { node: childNode, pos: insertionPos + 1 }, + { node: parentNode, pos: insertionPos, depth: 1 }, + { node: childNode, pos: insertionPos + 1, depth: 2 }, ]), ); @@ -156,7 +171,11 @@ describe('diffParagraphs', () => { const figurePos = paragraph.pos + paragraph.node.nodeSize; const diffs = diffNodes( - createDocFromNodes([paragraph, { node: parentNode, pos: figurePos }, { node: childNode, pos: figurePos + 1 }]), + createDocFromNodes([ + paragraph, + { node: parentNode, pos: figurePos, depth: 1 }, + { node: childNode, pos: figurePos + 1, depth: 2 }, + ]), createDocFromNodes([paragraph]), ); @@ -165,6 +184,30 @@ describe('diffParagraphs', () => { expect(deletions[0].nodeType).toBe('figure'); }); + it('computes insertion position for nodes added to the beginning of a container', () => { + const oldRow = buildSimpleNode('tableRow', { paraId: 'row-1' }, { nodeSize: 4 }); + const oldTable = buildSimpleNode('table', {}, { nodeSize: 10, children: [oldRow] }); + const oldDoc = createDocFromNodes([ + { node: oldTable, pos: 0, depth: 1 }, + { node: oldRow, pos: 1, depth: 2 }, + ]); + + const insertedRow = buildSimpleNode('tableRow', { paraId: 'row-2' }, { nodeSize: 4 }); + const persistedRow = buildSimpleNode('tableRow', { paraId: 'row-1' }, { nodeSize: 4 }); + const newTable = buildSimpleNode('table', {}, { nodeSize: 14, children: [insertedRow, persistedRow] }); + const newDoc = createDocFromNodes([ + { node: newTable, pos: 0, depth: 1 }, + { node: insertedRow, pos: 1, depth: 2 }, + { node: persistedRow, pos: 1 + insertedRow.nodeSize, depth: 2 }, + ]); + + const diffs = diffNodes(oldDoc, newDoc); + + const addition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'tableRow'); + expect(addition).toBeDefined(); + expect(addition.pos).toBe(1); + }); + it('computes insertion position based on the previous old node', () => { const oldParagraph = createParagraph('Hello!', {}, { pos: 0 }); const newParagraph = createParagraph('Hello!', {}, { pos: 0 }); @@ -173,7 +216,7 @@ describe('diffParagraphs', () => { const diffs = diffNodes( createDocFromNodes([oldParagraph]), - createDocFromNodes([newParagraph, { node: headingNode, pos: expectedPos }]), + createDocFromNodes([newParagraph, { node: headingNode, pos: expectedPos, depth: 1 }]), ); const addition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'heading'); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index b2409cc71..9e62aec49 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -20,6 +20,7 @@ import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts' type BaseNodeInfo = { node: PMNode; pos: number; + depth: number; }; /** @@ -94,12 +95,19 @@ export function diffNodes(oldRoot: PMNode, newRoot: PMNode): NodeDiff[] { */ function normalizeNodes(pmDoc: PMNode): NodeInfo[] { const nodes: NodeInfo[] = []; - pmDoc.descendants((node, pos) => { + const depthMap = new WeakMap(); + depthMap.set(pmDoc, -1); + + pmDoc.descendants((node, pos, parent) => { + const parentDepth = parent ? (depthMap.get(parent) ?? -1) : -1; + const depth = parentDepth + 1; + depthMap.set(node, depth); if (node.type.name === 'paragraph') { const { text, resolvePosition } = getParagraphContent(node, pos); const fullText = getFullText(text); nodes.push({ node, + depth, pos, text, resolvePosition, @@ -107,7 +115,7 @@ function normalizeNodes(pmDoc: PMNode): NodeInfo[] { }); return false; } - nodes.push({ node, pos }); + nodes.push({ node, pos, depth }); return undefined; }); return nodes; @@ -179,9 +187,14 @@ function buildAddedDiff( addedNodesSet.add(childNode); }); - const previousPos = previousOldNodeInfo?.pos ?? -1; - const previousSize = previousOldNodeInfo?.node.nodeSize ?? 0; - const pos = previousPos >= 0 ? previousPos + previousSize : 0; + let pos; + if (nodeInfo.depth === previousOldNodeInfo?.depth) { + const previousPos = previousOldNodeInfo?.pos ?? -1; + const previousSize = previousOldNodeInfo?.node.nodeSize ?? 0; + pos = previousPos >= 0 ? previousPos + previousSize : 0; + } else { + pos = (previousOldNodeInfo?.pos ?? -1) + 1; + } return { action: 'added', nodeType: nodeInfo.node.type.name, diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index a875381cc..30215c16a 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -39,6 +39,7 @@ export interface ParagraphContent { export interface ParagraphNodeReference { node: PMNode; pos: number; + depth: number; } /** @@ -200,9 +201,14 @@ export function buildAddedParagraphDiff( paragraph: ParagraphSnapshot, previousOldNodeInfo?: ParagraphNodeReference, ): AddedParagraphDiff { - const previousNodeSize = previousOldNodeInfo?.node.nodeSize ?? 0; - const previousPos = previousOldNodeInfo?.pos ?? -1; - const pos = previousPos >= 0 ? previousPos + previousNodeSize : 0; + let pos; + if (paragraph.depth === previousOldNodeInfo?.depth) { + const previousPos = previousOldNodeInfo?.pos ?? -1; + const previousSize = previousOldNodeInfo?.node.nodeSize ?? 0; + pos = previousPos >= 0 ? previousPos + previousSize : 0; + } else { + pos = (previousOldNodeInfo?.pos ?? -1) + 1; + } return { action: 'added', nodeType: paragraph.node.type.name, From d7c86c64046731a6523f24aa4b8204eee66b1ff0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 11:45:46 -0300 Subject: [PATCH 32/53] fix: remove unused position resolver for inline diffing --- .../src/extensions/diffing/algorithm/inline-diffing.ts | 6 +----- .../src/extensions/diffing/algorithm/paragraph-diffing.ts | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index c5233d2c0..f137051d7 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -109,18 +109,14 @@ export interface InlineDiffResult { * * @param oldContent Source tokens. * @param newContent Target tokens. - * @param oldPositionResolver Maps string indexes to the original document. - * @param newPositionResolver Maps string indexes to the updated document. + * @param oldPositionResolver Maps indexes to the original document. * @returns List of grouped inline diffs with document positions and text content. */ export function getInlineDiff( oldContent: InlineDiffToken[], newContent: InlineDiffToken[], oldPositionResolver: PositionResolver, - newPositionResolver: PositionResolver = oldPositionResolver, ): InlineDiffResult[] { - void newPositionResolver; - const buildInlineDiff = (action: InlineAction, token: InlineDiffToken, oldIdx: number): RawDiff => { if (token.kind !== 'text') { return { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 30215c16a..8a2e2fdbe 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -238,12 +238,7 @@ export function buildModifiedParagraphDiff( oldParagraph: ParagraphResolvedSnapshot, newParagraph: ParagraphResolvedSnapshot, ): ModifiedParagraphDiff | null { - const contentDiff = getInlineDiff( - oldParagraph.text, - newParagraph.text, - oldParagraph.resolvePosition, - newParagraph.resolvePosition, - ); + const contentDiff = getInlineDiff(oldParagraph.text, newParagraph.text, oldParagraph.resolvePosition); const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); if (contentDiff.length === 0 && !attrsDiff) { From 4ae29e8af93b5b21edce57be1d8efef057c0b338 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 12:24:49 -0300 Subject: [PATCH 33/53] refactor: remove serialization/deserialization of run attrs during diff --- .../diffing/algorithm/inline-diffing.test.js | 2 +- .../diffing/algorithm/inline-diffing.ts | 34 ++++++++++--------- .../algorithm/paragraph-diffing.test.js | 3 +- .../diffing/algorithm/paragraph-diffing.ts | 2 +- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 3479fce29..df82545ab 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -8,7 +8,7 @@ vi.mock('./myers-diff.ts', async () => { import { getInlineDiff } from './inline-diffing.ts'; const buildTextRuns = (text, runAttrs = {}) => - text.split('').map((char) => ({ char, runAttrs: JSON.stringify(runAttrs), kind: 'text' })); + text.split('').map((char) => ({ char, runAttrs: { ...runAttrs }, kind: 'text' })); const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }) => { const nodeAttrs = { ...attrs }; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index f137051d7..caac09807 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -13,7 +13,7 @@ type InlineAction = 'added' | 'deleted' | 'modified'; export type InlineTextToken = { kind: 'text'; char: string; - runAttrs: string; + runAttrs: Record; }; /** @@ -40,7 +40,7 @@ type RawTextDiff = idx: number; kind: 'text'; text: string; - runAttrs: string; + runAttrs: Record; } | { action: 'modified'; @@ -48,8 +48,8 @@ type RawTextDiff = kind: 'text'; newText: string; oldText: string; - oldAttrs: string; - newAttrs: string; + oldAttrs: Record; + newAttrs: Record; }; /** @@ -195,12 +195,12 @@ function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean { */ function shouldProcessEqualAsModification(oldToken: InlineDiffToken, newToken: InlineDiffToken): boolean { if (oldToken.kind === 'text' && newToken.kind === 'text') { - return oldToken.runAttrs !== newToken.runAttrs; + return Boolean(getAttributesDiff(oldToken.runAttrs, newToken.runAttrs)); } if (oldToken.kind === 'inlineNode' && newToken.kind === 'inlineNode') { - const oldJSON = oldToken.toJSON?.() ?? oldToken.node.toJSON(); - const newJSON = newToken.toJSON?.() ?? newToken.node.toJSON(); + const oldJSON = oldToken.node.toJSON(); + const newJSON = newToken.node.toJSON(); return JSON.stringify(oldJSON) !== JSON.stringify(newJSON); } @@ -217,7 +217,7 @@ type TextDiffGroup = startPos: number | null; endPos: number | null; text: string; - runAttrs: string; + runAttrs: Record; } | { action: 'modified'; @@ -226,8 +226,8 @@ type TextDiffGroup = endPos: number | null; newText: string; oldText: string; - oldAttrs: string; - newAttrs: string; + oldAttrs: Record; + newAttrs: Record; }; /** @@ -253,14 +253,12 @@ function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): In }; if (currentGroup.action === 'modified') { - const oldAttrs = JSON.parse(currentGroup.oldAttrs); - const newAttrs = JSON.parse(currentGroup.newAttrs); result.oldText = currentGroup.oldText; result.newText = currentGroup.newText; - result.runAttrsDiff = getAttributesDiff(oldAttrs, newAttrs); + result.runAttrsDiff = getAttributesDiff(currentGroup.oldAttrs, currentGroup.newAttrs); } else { result.text = currentGroup.text; - result.runAttrs = JSON.parse(currentGroup.runAttrs); + result.runAttrs = currentGroup.runAttrs; } grouped.push(result); @@ -351,11 +349,11 @@ function canExtendGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolve } if (group.action === 'modified' && diff.action === 'modified') { - if (group.oldAttrs !== diff.oldAttrs || group.newAttrs !== diff.newAttrs) { + if (!areInlineAttrsEqual(group.oldAttrs, diff.oldAttrs) || !areInlineAttrsEqual(group.newAttrs, diff.newAttrs)) { return false; } } else if (group.action !== 'modified' && diff.action !== 'modified') { - if (group.runAttrs !== diff.runAttrs) { + if (!areInlineAttrsEqual(group.runAttrs, diff.runAttrs)) { return false; } } else { @@ -367,3 +365,7 @@ function canExtendGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolve } return (group.endPos ?? 0) + 1 === positionResolver(diff.idx); } + +function areInlineAttrsEqual(a: Record | undefined, b: Record | undefined): boolean { + return !getAttributesDiff(a ?? {}, b ?? {}); +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index 5f508e44e..b4ef6d334 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -9,8 +9,7 @@ import { canTreatAsModification, } from './paragraph-diffing.ts'; -const buildRuns = (text, attrs = {}) => - text.split('').map((char) => ({ char, runAttrs: JSON.stringify(attrs), kind: 'text' })); +const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: attrs, kind: 'text' })); const createParagraphNode = (overrides = {}) => ({ type: { name: 'paragraph', ...(overrides.type || {}) }, diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 8a2e2fdbe..fd5508c7d 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -132,7 +132,7 @@ export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): Paragr const chars = nodeText.split('').map((char) => ({ kind: 'text', char, - runAttrs: JSON.stringify(runAttrs), + runAttrs, })); content.push(...(chars as ParagraphContentToken[])); return; From 1021cdc2fc10972e8fe87e483fa2e720e09e2a53 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 12:30:20 -0300 Subject: [PATCH 34/53] fix: include node type when extracting content from paragraph --- .../src/extensions/diffing/algorithm/paragraph-diffing.test.js | 1 + .../src/extensions/diffing/algorithm/paragraph-diffing.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index b4ef6d334..a243fc53c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -157,6 +157,7 @@ describe('getParagraphContent', () => { spec: {}, }, }, + nodeType: 'tab', }); expect(result.text.slice(1)).toEqual(buildRuns('Text', { bold: false })); expect(result.resolvePosition(0)).toBe(1); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index fd5508c7d..84b2c1160 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -144,6 +144,7 @@ export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): Paragr content.push({ kind: 'inlineNode', node, + nodeType: node.type.name, }); segments.push({ start, end, pos }); } From acb62e40f05059c442c11df2cde3d2f999b69a90 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 12:40:10 -0300 Subject: [PATCH 35/53] refactor: include JSON nodes instead of PM nodes in diffs --- .../diffing/algorithm/generic-diffing.ts | 18 ++++--- .../diffing/algorithm/inline-diffing.test.js | 5 +- .../diffing/algorithm/inline-diffing.ts | 34 +++++++------ .../algorithm/paragraph-diffing.test.js | 50 +++++++++++-------- .../diffing/algorithm/paragraph-diffing.ts | 10 ++-- .../extensions/diffing/computeDiff.test.js | 17 ++++++- 6 files changed, 82 insertions(+), 52 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index 9e62aec49..d377b956d 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -14,6 +14,8 @@ import { import { diffSequences, reorderDiffOperations } from './sequence-diffing.ts'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +type NodeJSON = ReturnType; + /** * Minimal node metadata extracted during document traversal. */ @@ -38,7 +40,7 @@ type NodeInfo = BaseNodeInfo | ParagraphNodeInfo; interface NonParagraphAddedDiff { action: 'added'; nodeType: string; - node: PMNode; + nodeJSON: NodeJSON; pos: number; } @@ -48,7 +50,7 @@ interface NonParagraphAddedDiff { interface NonParagraphDeletedDiff { action: 'deleted'; nodeType: string; - node: PMNode; + nodeJSON: NodeJSON; pos: number; } @@ -58,8 +60,8 @@ interface NonParagraphDeletedDiff { interface NonParagraphModifiedDiff { action: 'modified'; nodeType: string; - oldNode: PMNode; - newNode: PMNode; + oldNodeJSON: NodeJSON; + newNodeJSON: NodeJSON; pos: number; attrsDiff: AttributesDiff; } @@ -198,7 +200,7 @@ function buildAddedDiff( return { action: 'added', nodeType: nodeInfo.node.type.name, - node: nodeInfo.node, + nodeJSON: nodeInfo.node.toJSON(), pos, }; } @@ -220,7 +222,7 @@ function buildDeletedDiff(nodeInfo: NodeInfo, deletedNodesSet: Set): Nod return { action: 'deleted', nodeType: nodeInfo.node.type.name, - node: nodeInfo.node, + nodeJSON: nodeInfo.node.toJSON(), pos: nodeInfo.pos, }; } @@ -241,8 +243,8 @@ function buildModifiedDiff(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): NodeDi return { action: 'modified', nodeType: oldNodeInfo.node.type.name, - oldNode: oldNodeInfo.node, - newNode: newNodeInfo.node, + oldNodeJSON: oldNodeInfo.node.toJSON(), + newNodeJSON: newNodeInfo.node.toJSON(), pos: oldNodeInfo.pos, attrsDiff, }; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index df82545ab..5aa423c87 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -20,6 +20,7 @@ const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }) => { attrs: nodeAttrs, toJSON: () => ({ type: 'link', attrs: nodeAttrs }), }, + nodeJSON: { type: 'link', attrs: nodeAttrs }, }; }; @@ -136,8 +137,8 @@ describe('getInlineDiff', () => { nodeType: 'link', startPos: 3, endPos: 3, - oldNode: oldNode.node, - newNode: newNode.node, + oldNodeJSON: oldNode.nodeJSON, + newNodeJSON: newNode.nodeJSON, attrsDiff: { added: {}, deleted: {}, diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index caac09807..b479e2d48 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -2,6 +2,8 @@ import type { Node as PMNode } from 'prosemirror-model'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; import { diffSequences } from './sequence-diffing.ts'; +type NodeJSON = ReturnType; + /** * Supported diff operations for inline changes. */ @@ -24,6 +26,7 @@ export type InlineNodeToken = { node: PMNode; nodeType?: string; toJSON?: () => unknown; + nodeJSON?: NodeJSON; }; /** @@ -60,18 +63,17 @@ type RawInlineNodeDiff = action: Exclude; idx: number; kind: 'inlineNode'; - node: PMNode; + nodeJSON: NodeJSON; nodeType?: string; } | { action: 'modified'; idx: number; kind: 'inlineNode'; - oldNode: PMNode; - newNode: PMNode; nodeType?: string; - oldAttrs?: Record; - newAttrs?: Record; + oldNodeJSON: NodeJSON; + newNodeJSON: NodeJSON; + attrsDiff: AttributesDiff | null; }; /** @@ -97,10 +99,10 @@ export interface InlineDiffResult { newText?: string; runAttrs?: Record; runAttrsDiff?: AttributesDiff | null; - node?: PMNode; nodeType?: string; - oldNode?: PMNode; - newNode?: PMNode; + nodeJSON?: NodeJSON; + oldNodeJSON?: NodeJSON; + newNodeJSON?: NodeJSON; attrsDiff?: AttributesDiff | null; } @@ -123,7 +125,7 @@ export function getInlineDiff( action, idx: oldIdx, kind: 'inlineNode', - node: token.node, + nodeJSON: token.nodeJSON ?? token.node.toJSON(), nodeType: token.nodeType, }; } @@ -145,13 +147,15 @@ export function getInlineDiff( buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx), buildModified: (oldToken, newToken, oldIdx) => { if (oldToken.kind !== 'text' && newToken.kind !== 'text') { + const attrsDiff = getAttributesDiff(oldToken.node.attrs, newToken.node.attrs); return { action: 'modified', idx: oldIdx, kind: 'inlineNode', - oldNode: oldToken.node, - newNode: newToken.node, + oldNodeJSON: oldToken.node.toJSON(), + newNodeJSON: newToken.node.toJSON(), nodeType: oldToken.nodeType, + attrsDiff, }; } if (oldToken.kind === 'text' && newToken.kind === 'text') { @@ -276,11 +280,11 @@ function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): In nodeType: diff.nodeType, ...(diff.action === 'modified' ? { - oldNode: diff.oldNode, - newNode: diff.newNode, - attrsDiff: getAttributesDiff(diff.oldNode.attrs, diff.newNode.attrs), + oldNodeJSON: diff.oldNodeJSON, + newNodeJSON: diff.newNodeJSON, + attrsDiff: diff.attrsDiff ?? null, } - : { node: diff.node }), + : { nodeJSON: diff.nodeJSON }), }); continue; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index a243fc53c..c9a6b852c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -11,12 +11,18 @@ import { const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: attrs, kind: 'text' })); -const createParagraphNode = (overrides = {}) => ({ - type: { name: 'paragraph', ...(overrides.type || {}) }, - attrs: {}, - nodeSize: 5, - ...overrides, -}); +const createParagraphNode = (overrides = {}) => { + const node = { + type: { name: 'paragraph', ...(overrides.type || {}) }, + attrs: {}, + nodeSize: 5, + ...overrides, + }; + if (typeof node.toJSON !== 'function') { + node.toJSON = () => ({ type: node.type.name, attrs: node.attrs }); + } + return node; +}; const createParagraphInfo = (overrides = {}) => { const fullText = overrides.fullText ?? 'text'; @@ -45,6 +51,12 @@ const createParagraphWithSegments = (segments, contentSize) => { typeName: segment.inlineNode.typeName ?? 'inline', attrs: segment.inlineNode.attrs ?? {}, isLeaf: segment.inlineNode.isLeaf ?? true, + toJSON: + segment.inlineNode.toJSON ?? + (() => ({ + type: segment.inlineNode.typeName ?? 'inline', + attrs: segment.inlineNode.attrs ?? {}, + })), }, }; } @@ -82,6 +94,10 @@ const createParagraphWithSegments = (segments, contentSize) => { isLeaf: segment.inlineNode.isLeaf, type: { name: segment.inlineNode.typeName, spec: {} }, attrs: segment.inlineNode.attrs, + toJSON: () => ({ + type: segment.inlineNode.typeName, + attrs: segment.inlineNode.attrs, + }), }, segment.start, ); @@ -143,21 +159,13 @@ describe('getParagraphContent', () => { ]); const result = getParagraphContent(mockParagraph); - expect(result.text[0]).toEqual({ + expect(result.text[0]).toMatchObject({ kind: 'inlineNode', - node: { - attrs: { - kind: 'tab', - width: 120, - }, - isInline: true, - isLeaf: true, - type: { - name: 'tab', - spec: {}, - }, - }, nodeType: 'tab', + nodeJSON: { + type: 'tab', + attrs: inlineAttrs, + }, }); expect(result.text.slice(1)).toEqual(buildRuns('Text', { bold: false })); expect(result.resolvePosition(0)).toBe(1); @@ -224,7 +232,7 @@ describe('paragraph diff builders', () => { expect(buildAddedParagraphDiff(paragraph, previousNode)).toEqual({ action: 'added', nodeType: 'paragraph', - node: paragraph.node, + nodeJSON: paragraph.node.toJSON(), text: 'Hello', pos: 14, }); @@ -236,7 +244,7 @@ describe('paragraph diff builders', () => { expect(buildDeletedParagraphDiff(paragraph)).toEqual({ action: 'deleted', nodeType: 'paragraph', - node: paragraph.node, + nodeJSON: paragraph.node.toJSON(), oldText: 'Old text', pos: 7, }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 84b2c1160..fdb81100c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -3,6 +3,7 @@ import { getInlineDiff, type InlineDiffToken, type InlineDiffResult } from './in import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; import { levenshteinDistance } from './similarity.ts'; +type NodeJSON = ReturnType; // Heuristics that prevent unrelated paragraphs from being paired as modifications. const SIMILARITY_THRESHOLD = 0.65; const MIN_LENGTH_FOR_SIMILARITY = 4; @@ -63,7 +64,7 @@ export interface ParagraphResolvedSnapshot extends ParagraphSnapshot { export interface AddedParagraphDiff { action: 'added'; nodeType: string; - node: PMNode; + nodeJSON: NodeJSON; text: string; pos: number; } @@ -74,7 +75,7 @@ export interface AddedParagraphDiff { export interface DeletedParagraphDiff { action: 'deleted'; nodeType: string; - node: PMNode; + nodeJSON: NodeJSON; oldText: string; pos: number; } @@ -145,6 +146,7 @@ export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): Paragr kind: 'inlineNode', node, nodeType: node.type.name, + nodeJSON: node.toJSON(), }); segments.push({ start, end, pos }); } @@ -213,7 +215,7 @@ export function buildAddedParagraphDiff( return { action: 'added', nodeType: paragraph.node.type.name, - node: paragraph.node, + nodeJSON: paragraph.node.toJSON(), text: paragraph.fullText, pos, }; @@ -226,7 +228,7 @@ export function buildDeletedParagraphDiff(paragraph: ParagraphSnapshot): Deleted return { action: 'deleted', nodeType: paragraph.node.type.name, - node: paragraph.node, + nodeJSON: paragraph.node.toJSON(), oldText: paragraph.fullText, pos: paragraph.pos, }; diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index df75d6b89..f8c28db8d 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -24,6 +24,19 @@ const getDocument = async (name) => { return editor.state.doc; }; +const getNodeTextContent = (nodeJSON) => { + if (!nodeJSON) { + return ''; + } + if (typeof nodeJSON.text === 'string') { + return nodeJSON.text; + } + if (Array.isArray(nodeJSON.content)) { + return nodeJSON.content.map((child) => getNodeTextContent(child)).join(''); + } + return ''; +}; + describe('Diff', () => { it('Compares two documents and identifies added, deleted, and modified paragraphs', async () => { const docBefore = await getDocument('diff_before.docx'); @@ -230,12 +243,12 @@ describe('Diff', () => { expect(wordRemoval?.contentDiff?.[0].action).toBe('deleted'); const tableModification = diffs.find( - (diff) => diff.action === 'modified' && diff.nodeType === 'table' && diff.oldNode, + (diff) => diff.action === 'modified' && diff.nodeType === 'table' && diff.oldNodeJSON, ); expect(tableModification).toBeUndefined(); const tableAddition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'table'); - expect(tableAddition?.node?.textContent?.trim()).toBe('New table'); + expect(getNodeTextContent(tableAddition?.nodeJSON)?.trim()).toBe('New table'); const trailingParagraph = diffs.find( (diff) => diff.action === 'added' && diff.nodeType === 'paragraph' && diff.text === '', From 174e897fc8e44ac490c3eab18429b79517022a17 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 13:13:13 -0300 Subject: [PATCH 36/53] refactor: simplify typescript interfaces --- .../diffing/algorithm/generic-diffing.ts | 47 ++----- .../algorithm/paragraph-diffing.test.js | 23 ++-- .../diffing/algorithm/paragraph-diffing.ts | 123 ++++++++---------- 3 files changed, 83 insertions(+), 110 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index d377b956d..e441b7c87 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -1,15 +1,14 @@ import type { Node as PMNode } from 'prosemirror-model'; import { - getParagraphContent, + createParagraphSnapshot, paragraphComparator, canTreatAsModification as canTreatParagraphDeletionInsertionAsModification, shouldProcessEqualAsModification as shouldProcessEqualParagraphsAsModification, buildAddedParagraphDiff, buildDeletedParagraphDiff, buildModifiedParagraphDiff, - type ParagraphContentToken, type ParagraphDiff, - type ParagraphResolvedSnapshot, + type ParagraphNodeInfo, } from './paragraph-diffing.ts'; import { diffSequences, reorderDiffOperations } from './sequence-diffing.ts'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; @@ -25,51 +24,44 @@ type BaseNodeInfo = { depth: number; }; -/** - * Paragraph-specific node info enriched with textual content and resolvers. - */ -type ParagraphNodeInfo = ParagraphResolvedSnapshot; /** * Union describing every node processed by the generic diff. */ type NodeInfo = BaseNodeInfo | ParagraphNodeInfo; +interface NodeDiffBase { + action: Action; + nodeType: string; + pos: number; +} + /** * Diff payload describing an inserted non-paragraph node. */ -interface NonParagraphAddedDiff { - action: 'added'; - nodeType: string; +interface NodeAddedDiff extends NodeDiffBase<'added'> { nodeJSON: NodeJSON; - pos: number; } /** * Diff payload describing a deleted non-paragraph node. */ -interface NonParagraphDeletedDiff { - action: 'deleted'; - nodeType: string; +interface NodeDeletedDiff extends NodeDiffBase<'deleted'> { nodeJSON: NodeJSON; - pos: number; } /** * Diff payload describing an attribute-only change on non-paragraph nodes. */ -interface NonParagraphModifiedDiff { - action: 'modified'; - nodeType: string; +interface NodeModifiedDiff extends NodeDiffBase<'modified'> { oldNodeJSON: NodeJSON; newNodeJSON: NodeJSON; - pos: number; attrsDiff: AttributesDiff; } /** * Union of every diff type emitted by the generic diffing layer. */ -export type NodeDiff = ParagraphDiff | NonParagraphAddedDiff | NonParagraphDeletedDiff | NonParagraphModifiedDiff; +export type NodeDiff = ParagraphDiff | NodeAddedDiff | NodeDeletedDiff | NodeModifiedDiff; /** * Produces a sequence diff between two ProseMirror documents, flattening paragraphs for inline-aware comparisons. @@ -105,16 +97,7 @@ function normalizeNodes(pmDoc: PMNode): NodeInfo[] { const depth = parentDepth + 1; depthMap.set(node, depth); if (node.type.name === 'paragraph') { - const { text, resolvePosition } = getParagraphContent(node, pos); - const fullText = getFullText(text); - nodes.push({ - node, - depth, - pos, - text, - resolvePosition, - fullText, - }); + nodes.push(createParagraphSnapshot(node, pos, depth)); return false; } nodes.push({ node, pos, depth }); @@ -123,10 +106,6 @@ function normalizeNodes(pmDoc: PMNode): NodeInfo[] { return nodes; } -function getFullText(tokens: ParagraphContentToken[]): string { - return tokens.map((token) => (token.kind === 'text' ? token.char : '')).join(''); -} - /** * Compares two node infos to determine if they correspond to the same logical node. * Paragraphs are compared with `paragraphComparator`, while other nodes are matched by type name. diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index c9a6b852c..f460f8f6f 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - getParagraphContent, + createParagraphSnapshot, shouldProcessEqualAsModification, paragraphComparator, buildAddedParagraphDiff, @@ -31,6 +31,7 @@ const createParagraphInfo = (overrides = {}) => { return { node: createParagraphNode(overrides.node), pos: 0, + depth: 0, fullText, text: textTokens, resolvePosition: (idx) => idx, @@ -108,11 +109,11 @@ const createParagraphWithSegments = (segments, contentSize) => { }; }; -describe('getParagraphContent', () => { +describe('createParagraphSnapshot', () => { it('handles basic text nodes', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); - const result = getParagraphContent(mockParagraph); + const result = createParagraphSnapshot(mockParagraph, 0, 0); expect(result.text).toEqual(buildRuns('Hello', { bold: true })); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(4)).toBe(5); @@ -124,7 +125,7 @@ describe('getParagraphContent', () => { 4, ); - const result = getParagraphContent(mockParagraph); + const result = createParagraphSnapshot(mockParagraph, 0, 0); expect(result.text).toEqual(buildRuns('Leaf', { type: 'leaf' })); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(3)).toBe(4); @@ -136,7 +137,7 @@ describe('getParagraphContent', () => { { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, ]); - const result = getParagraphContent(mockParagraph); + const result = createParagraphSnapshot(mockParagraph, 0, 0); expect(result.text).toEqual([...buildRuns('Hello', { bold: true }), ...buildRuns('Leaf', { italic: true })]); expect(result.resolvePosition(0)).toBe(1); expect(result.resolvePosition(5)).toBe(6); @@ -146,7 +147,7 @@ describe('getParagraphContent', () => { it('handles empty content', () => { const mockParagraph = createParagraphWithSegments([], 0); - const result = getParagraphContent(mockParagraph); + const result = createParagraphSnapshot(mockParagraph, 0, 0); expect(result.text).toEqual([]); expect(result.resolvePosition(0)).toBe(1); }); @@ -158,7 +159,7 @@ describe('getParagraphContent', () => { { text: 'Text', start: 1, attrs: { bold: false } }, ]); - const result = getParagraphContent(mockParagraph); + const result = createParagraphSnapshot(mockParagraph, 0, 0); expect(result.text[0]).toMatchObject({ kind: 'inlineNode', nodeType: 'tab', @@ -175,7 +176,7 @@ describe('getParagraphContent', () => { it('applies paragraph position offsets to the resolver', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); - const result = getParagraphContent(mockParagraph, 10); + const result = createParagraphSnapshot(mockParagraph, 10, 0); expect(result.text).toEqual(buildRuns('Nested', {})); expect(result.resolvePosition(0)).toBe(11); expect(result.resolvePosition(6)).toBe(17); @@ -183,7 +184,7 @@ describe('getParagraphContent', () => { it('returns null when index is outside the flattened text array', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Hi', start: 0 }], 2); - const { resolvePosition } = getParagraphContent(mockParagraph); + const { resolvePosition } = createParagraphSnapshot(mockParagraph, 0, 0); expect(resolvePosition(-1)).toBeNull(); expect(resolvePosition(3)).toBeNull(); @@ -227,7 +228,7 @@ describe('paragraph diff builders', () => { node: createParagraphNode({ type: { name: 'paragraph' } }), fullText: 'Hello', }); - const previousNode = { pos: 10, node: { nodeSize: 4 } }; + const previousNode = { pos: 10, depth: 0, node: { nodeSize: 4 } }; expect(buildAddedParagraphDiff(paragraph, previousNode)).toEqual({ action: 'added', @@ -269,6 +270,7 @@ describe('paragraph diff builders', () => { expect(diff).toMatchObject({ action: 'modified', nodeType: 'paragraph', + nodeJSON: oldParagraph.node.toJSON(), oldText: 'foo', newText: 'bar', pos: 5, @@ -298,6 +300,7 @@ describe('paragraph diff builders', () => { expect(diff).not.toBeNull(); expect(diff.contentDiff).toEqual([]); expect(diff.attrsDiff?.modified).toHaveProperty('align'); + expect(diff.nodeJSON).toEqual(oldParagraph.node.toJSON()); }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index fdb81100c..4fc1a543f 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -3,95 +3,62 @@ import { getInlineDiff, type InlineDiffToken, type InlineDiffResult } from './in import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; import { levenshteinDistance } from './similarity.ts'; -type NodeJSON = ReturnType; // Heuristics that prevent unrelated paragraphs from being paired as modifications. const SIMILARITY_THRESHOLD = 0.65; const MIN_LENGTH_FOR_SIMILARITY = 4; -/** - * Flattened token emitted from a paragraph. Delegates to inline diff tokens. - */ -export type ParagraphContentToken = InlineDiffToken; +type NodeJSON = ReturnType; + /** * Maps flattened indexes back to the ProseMirror document. */ export type PositionResolver = (index: number) => number | null; /** - * Internal bookkeeping entry that remembers the start/end indexes for shallow nodes. + * Rich snapshot of a paragraph node with flattened content and helpers. */ -interface ParagraphSegment { - start: number; - end: number; - pos: number; -} - -/** - * Computed textual representation of a paragraph plus its index resolver. - */ -export interface ParagraphContent { - text: ParagraphContentToken[]; - resolvePosition: PositionResolver; -} - -/** - * Bare reference to a paragraph node and its document position. - */ -export interface ParagraphNodeReference { +export interface ParagraphNodeInfo { node: PMNode; pos: number; depth: number; -} - -/** - * Snapshot of a paragraph that includes its flattened text form. - */ -export interface ParagraphSnapshot extends ParagraphNodeReference { + text: InlineDiffToken[]; + resolvePosition: PositionResolver; fullText: string; } /** - * Paragraph snapshot extended with the tokenized content and resolver. + * Base shape shared by every paragraph diff payload. */ -export interface ParagraphResolvedSnapshot extends ParagraphSnapshot { - text: ParagraphContentToken[]; - resolvePosition: PositionResolver; +interface ParagraphDiffBase { + action: Action; + nodeType: string; + nodeJSON: NodeJSON; + pos: number; } /** * Diff payload produced when a paragraph is inserted. */ -export interface AddedParagraphDiff { - action: 'added'; - nodeType: string; - nodeJSON: NodeJSON; +export type AddedParagraphDiff = ParagraphDiffBase<'added'> & { text: string; - pos: number; -} +}; /** * Diff payload produced when a paragraph is deleted. */ -export interface DeletedParagraphDiff { - action: 'deleted'; - nodeType: string; - nodeJSON: NodeJSON; +export type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { oldText: string; - pos: number; -} +}; /** * Diff payload emitted when a paragraph changes, including inline edits. */ -export interface ModifiedParagraphDiff { - action: 'modified'; - nodeType: string; +export type ModifiedParagraphDiff = ParagraphDiffBase<'modified'> & { oldText: string; newText: string; - pos: number; contentDiff: InlineDiffResult[]; attrsDiff: AttributesDiff | null; -} +}; /** * Union of every diff variant the paragraph diffing logic can produce. @@ -99,15 +66,38 @@ export interface ModifiedParagraphDiff { export type ParagraphDiff = AddedParagraphDiff | DeletedParagraphDiff | ModifiedParagraphDiff; /** - * Flattens a paragraph node into text and provides a resolver to map string indices back to document positions. + * Creates a reusable snapshot that stores flattened paragraph content plus position metadata. * * @param paragraph Paragraph node to flatten. * @param paragraphPos Position of the paragraph in the document. - * @returns Concatenated text tokens and a resolver that maps indexes to document positions. + * @param depth Depth of the paragraph within the document tree. + * @returns Snapshot containing tokens, resolver, and derived metadata. + */ +export function createParagraphSnapshot(paragraph: PMNode, paragraphPos: number, depth: number): ParagraphNodeInfo { + const { text, resolvePosition } = buildParagraphContent(paragraph, paragraphPos); + return { + node: paragraph, + pos: paragraphPos, + depth, + text, + resolvePosition, + fullText: text.map((token) => (token.kind === 'text' ? token.char : '')).join(''), + }; +} + +/** + * Flattens a paragraph node into inline diff tokens and a resolver that tracks document positions. + * + * @param paragraph Paragraph node being tokenized. + * @param paragraphPos Absolute document position for the paragraph; used to offset resolver results. + * @returns Flattened tokens plus a resolver that maps token indexes back to document positions. */ -export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): ParagraphContent { - const content: ParagraphContentToken[] = []; - const segments: ParagraphSegment[] = []; +function buildParagraphContent( + paragraph: PMNode, + paragraphPos = 0, +): { text: InlineDiffToken[]; resolvePosition: PositionResolver } { + const content: InlineDiffToken[] = []; + const segments: Array<{ start: number; end: number; pos: number }> = []; paragraph.nodesBetween( 0, @@ -135,7 +125,7 @@ export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): Paragr char, runAttrs, })); - content.push(...(chars as ParagraphContentToken[])); + content.push(...(chars as InlineDiffToken[])); return; } @@ -179,8 +169,8 @@ export function getParagraphContent(paragraph: PMNode, paragraphPos = 0): Paragr * @returns True when the serialized JSON payload differs. */ export function shouldProcessEqualAsModification( - oldParagraph: ParagraphNodeReference, - newParagraph: ParagraphNodeReference, + oldParagraph: ParagraphNodeInfo, + newParagraph: ParagraphNodeInfo, ): boolean { return JSON.stringify(oldParagraph.node.toJSON()) !== JSON.stringify(newParagraph.node.toJSON()); } @@ -188,7 +178,7 @@ export function shouldProcessEqualAsModification( /** * Compares two paragraphs for identity based on paraId or text content. */ -export function paragraphComparator(oldParagraph: ParagraphSnapshot, newParagraph: ParagraphSnapshot): boolean { +export function paragraphComparator(oldParagraph: ParagraphNodeInfo, newParagraph: ParagraphNodeInfo): boolean { const oldId = oldParagraph?.node?.attrs?.paraId; const newId = newParagraph?.node?.attrs?.paraId; if (oldId && newId && oldId === newId) { @@ -201,8 +191,8 @@ export function paragraphComparator(oldParagraph: ParagraphSnapshot, newParagrap * Builds a normalized payload describing a paragraph addition, ensuring all consumers receive the same metadata shape. */ export function buildAddedParagraphDiff( - paragraph: ParagraphSnapshot, - previousOldNodeInfo?: ParagraphNodeReference, + paragraph: ParagraphNodeInfo, + previousOldNodeInfo?: Pick, ): AddedParagraphDiff { let pos; if (paragraph.depth === previousOldNodeInfo?.depth) { @@ -224,7 +214,7 @@ export function buildAddedParagraphDiff( /** * Builds a normalized payload describing a paragraph deletion so diff consumers can show removals with all context. */ -export function buildDeletedParagraphDiff(paragraph: ParagraphSnapshot): DeletedParagraphDiff { +export function buildDeletedParagraphDiff(paragraph: ParagraphNodeInfo): DeletedParagraphDiff { return { action: 'deleted', nodeType: paragraph.node.type.name, @@ -238,8 +228,8 @@ export function buildDeletedParagraphDiff(paragraph: ParagraphSnapshot): Deleted * Builds the payload for a paragraph modification, including text-level diffs, so renderers can highlight edits inline. */ export function buildModifiedParagraphDiff( - oldParagraph: ParagraphResolvedSnapshot, - newParagraph: ParagraphResolvedSnapshot, + oldParagraph: ParagraphNodeInfo, + newParagraph: ParagraphNodeInfo, ): ModifiedParagraphDiff | null { const contentDiff = getInlineDiff(oldParagraph.text, newParagraph.text, oldParagraph.resolvePosition); @@ -251,6 +241,7 @@ export function buildModifiedParagraphDiff( return { action: 'modified', nodeType: oldParagraph.node.type.name, + nodeJSON: oldParagraph.node.toJSON(), oldText: oldParagraph.fullText, newText: newParagraph.fullText, pos: oldParagraph.pos, @@ -262,7 +253,7 @@ export function buildModifiedParagraphDiff( /** * Decides whether a delete/insert pair should be reinterpreted as a modification to minimize noisy diff output. */ -export function canTreatAsModification(oldParagraph: ParagraphSnapshot, newParagraph: ParagraphSnapshot): boolean { +export function canTreatAsModification(oldParagraph: ParagraphNodeInfo, newParagraph: ParagraphNodeInfo): boolean { if (paragraphComparator(oldParagraph, newParagraph)) { return true; } From d3acd76ddfa42583adf4890d00c6f93f5310b324 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 13:30:40 -0300 Subject: [PATCH 37/53] refactor: extract insertion position calculation logic into helper --- .../diffing/algorithm/diff-utils.test.ts | 29 +++++++++++++++++++ .../diffing/algorithm/diff-utils.ts | 27 +++++++++++++++++ .../diffing/algorithm/generic-diffing.ts | 11 ++----- .../diffing/algorithm/paragraph-diffing.ts | 11 ++----- 4 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/diff-utils.test.ts create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts diff --git a/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.test.ts b/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.test.ts new file mode 100644 index 000000000..63ef88bc2 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { getInsertionPos } from './diff-utils.ts'; + +const createNodeInfo = ({ pos = 0, depth = 0, nodeSize = 1 } = {}) => ({ + pos, + depth, + node: { nodeSize }, +}); + +describe('getInsertionPos', () => { + it('positions after previous node when depth matches', () => { + const previous = createNodeInfo({ pos: 10, depth: 2, nodeSize: 5 }); + expect(getInsertionPos(2, previous)).toBe(15); + }); + + it('falls back to previous position plus one when depth differs', () => { + const previous = createNodeInfo({ pos: 10, depth: 1, nodeSize: 3 }); + expect(getInsertionPos(2, previous)).toBe(11); + }); + + it('returns zero when there is no previous node info', () => { + expect(getInsertionPos(0, undefined)).toBe(0); + }); + + it('handles previous nodes lacking nodeSize safely', () => { + const previous = { pos: 5, depth: 1, node: {} } as any; + expect(getInsertionPos(1, previous)).toBe(5); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts b/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts new file mode 100644 index 000000000..3d92e934f --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts @@ -0,0 +1,27 @@ +import type { Node as PMNode } from 'prosemirror-model'; + +interface NodePositionInfo { + node: PMNode; + pos: number; + depth: number; +} + +/** + * Computes the insertion point for a node relative to the previous node in the old document tree. + * + * When the previous node shares the same depth, the insertion + * is placed right after the previous node's position. Otherwise, the insertion + * is placed just after the previous node's opening position. + * + * @param currentDepth Depth of the node being inserted. + * @param previousNode Optional info about the preceding node from the old document. + * @returns Absolute document position where the new node should be inserted. + */ +export function getInsertionPos(currentDepth: number, previousNode?: NodePositionInfo): number { + if (currentDepth === previousNode?.depth) { + const previousPos = previousNode?.pos ?? -1; + const previousSize = previousNode?.node.nodeSize ?? 0; + return previousPos >= 0 ? previousPos + previousSize : 0; + } + return (previousNode?.pos ?? -1) + 1; +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index e441b7c87..415193d78 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -12,6 +12,7 @@ import { } from './paragraph-diffing.ts'; import { diffSequences, reorderDiffOperations } from './sequence-diffing.ts'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +import { getInsertionPos } from './diff-utils.ts'; type NodeJSON = ReturnType; @@ -168,19 +169,11 @@ function buildAddedDiff( addedNodesSet.add(childNode); }); - let pos; - if (nodeInfo.depth === previousOldNodeInfo?.depth) { - const previousPos = previousOldNodeInfo?.pos ?? -1; - const previousSize = previousOldNodeInfo?.node.nodeSize ?? 0; - pos = previousPos >= 0 ? previousPos + previousSize : 0; - } else { - pos = (previousOldNodeInfo?.pos ?? -1) + 1; - } return { action: 'added', nodeType: nodeInfo.node.type.name, nodeJSON: nodeInfo.node.toJSON(), - pos, + pos: getInsertionPos(nodeInfo.depth, previousOldNodeInfo), }; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 4fc1a543f..0f15e1c6b 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -1,6 +1,7 @@ import type { Node as PMNode } from 'prosemirror-model'; import { getInlineDiff, type InlineDiffToken, type InlineDiffResult } from './inline-diffing.ts'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +import { getInsertionPos } from './diff-utils.ts'; import { levenshteinDistance } from './similarity.ts'; // Heuristics that prevent unrelated paragraphs from being paired as modifications. @@ -194,20 +195,12 @@ export function buildAddedParagraphDiff( paragraph: ParagraphNodeInfo, previousOldNodeInfo?: Pick, ): AddedParagraphDiff { - let pos; - if (paragraph.depth === previousOldNodeInfo?.depth) { - const previousPos = previousOldNodeInfo?.pos ?? -1; - const previousSize = previousOldNodeInfo?.node.nodeSize ?? 0; - pos = previousPos >= 0 ? previousPos + previousSize : 0; - } else { - pos = (previousOldNodeInfo?.pos ?? -1) + 1; - } return { action: 'added', nodeType: paragraph.node.type.name, nodeJSON: paragraph.node.toJSON(), text: paragraph.fullText, - pos, + pos: getInsertionPos(paragraph.depth, previousOldNodeInfo), }; } From 0772fb7ffdf36c6440ddd4d63dd58b544cbbdcce Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 13:43:21 -0300 Subject: [PATCH 38/53] refactor: simplify resolution of inline node positions --- .../diffing/algorithm/inline-diffing.test.js | 54 +++++----- .../diffing/algorithm/inline-diffing.ts | 98 ++++++++++++++----- .../algorithm/paragraph-diffing.test.js | 72 +++++++++----- .../diffing/algorithm/paragraph-diffing.ts | 69 ++++--------- 4 files changed, 168 insertions(+), 125 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 5aa423c87..5287e0b5e 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -7,10 +7,15 @@ vi.mock('./myers-diff.ts', async () => { }); import { getInlineDiff } from './inline-diffing.ts'; -const buildTextRuns = (text, runAttrs = {}) => - text.split('').map((char) => ({ char, runAttrs: { ...runAttrs }, kind: 'text' })); - -const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }) => { +const buildTextRuns = (text, runAttrs = {}, offsetStart = 0) => + text.split('').map((char, index) => ({ + char, + runAttrs: { ...runAttrs }, + kind: 'text', + offset: offsetStart + index, + })); + +const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }, pos = 0) => { const nodeAttrs = { ...attrs }; return { kind: 'inlineNode', @@ -21,22 +26,22 @@ const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }) => { toJSON: () => ({ type: 'link', attrs: nodeAttrs }), }, nodeJSON: { type: 'link', attrs: nodeAttrs }, + pos, }; }; describe('getInlineDiff', () => { it('returns an empty diff list when both strings are identical', () => { - const resolver = (index) => index; - const diffs = getInlineDiff(buildTextRuns('unchanged'), buildTextRuns('unchanged'), resolver); + const oldRuns = buildTextRuns('unchanged'); + const diffs = getInlineDiff(oldRuns, buildTextRuns('unchanged'), oldRuns.length); expect(diffs).toEqual([]); }); it('detects text insertions and maps them to resolver positions', () => { - const oldResolver = (index) => index + 10; - const newResolver = (index) => index + 100; - - const diffs = getInlineDiff(buildTextRuns('abc'), buildTextRuns('abXc'), oldResolver, newResolver); + const startOffset = 10; + const oldRuns = buildTextRuns('abc', {}, startOffset); + const diffs = getInlineDiff(oldRuns, buildTextRuns('abXc', {}, startOffset), startOffset + oldRuns.length); expect(diffs).toEqual([ { @@ -51,10 +56,9 @@ describe('getInlineDiff', () => { }); it('detects deletions and additions in the same diff sequence', () => { - const oldResolver = (index) => index + 5; - const newResolver = (index) => index + 20; - - const diffs = getInlineDiff(buildTextRuns('abcd'), buildTextRuns('abXYd'), oldResolver, newResolver); + const startOffset = 5; + const oldRuns = buildTextRuns('abcd', {}, startOffset); + const diffs = getInlineDiff(oldRuns, buildTextRuns('abXYd', {}, startOffset), startOffset + oldRuns.length); expect(diffs).toEqual([ { @@ -77,9 +81,8 @@ describe('getInlineDiff', () => { }); it('marks attribute-only changes as modifications and surfaces attribute diffs', () => { - const resolver = (index) => index; - - const diffs = getInlineDiff(buildTextRuns('a', { bold: true }), buildTextRuns('a', { italic: true }), resolver); + const oldRuns = buildTextRuns('a', { bold: true }, 0); + const diffs = getInlineDiff(oldRuns, buildTextRuns('a', { italic: true }), oldRuns.length); expect(diffs).toEqual([ { @@ -99,9 +102,13 @@ describe('getInlineDiff', () => { }); it('merges contiguous attribute edits that share the same diff metadata', () => { - const resolver = (index) => index + 5; - - const diffs = getInlineDiff(buildTextRuns('ab', { bold: true }), buildTextRuns('ab', { bold: false }), resolver); + const startOffset = 5; + const oldRuns = buildTextRuns('ab', { bold: true }, startOffset); + const diffs = getInlineDiff( + oldRuns, + buildTextRuns('ab', { bold: false }, startOffset), + startOffset + oldRuns.length, + ); expect(diffs).toEqual([ { @@ -123,12 +130,11 @@ describe('getInlineDiff', () => { }); it('surfaces attribute diffs for inline node modifications', () => { - const resolver = (index) => index + 3; const sharedType = { name: 'link' }; - const oldNode = buildInlineNodeToken({ href: 'https://old.example', label: 'Example' }, sharedType); - const newNode = buildInlineNodeToken({ href: 'https://new.example', label: 'Example' }, sharedType); + const oldNode = buildInlineNodeToken({ href: 'https://old.example', label: 'Example' }, sharedType, 3); + const newNode = buildInlineNodeToken({ href: 'https://new.example', label: 'Example' }, sharedType, 3); - const diffs = getInlineDiff([oldNode], [newNode], resolver); + const diffs = getInlineDiff([oldNode], [newNode], 4); expect(diffs).toEqual([ { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index b479e2d48..65ea43b51 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -16,6 +16,7 @@ export type InlineTextToken = { kind: 'text'; char: string; runAttrs: Record; + offset?: number | null; }; /** @@ -27,6 +28,7 @@ export type InlineNodeToken = { nodeType?: string; toJSON?: () => unknown; nodeJSON?: NodeJSON; + pos?: number | null; }; /** @@ -81,11 +83,6 @@ type RawInlineNodeDiff = */ type RawDiff = RawTextDiff | RawInlineNodeDiff; -/** - * Maps flattened string indexes back to ProseMirror document positions. - */ -type PositionResolver = (index: number) => number | null; - /** * Final grouped inline diff exposed to downstream consumers. */ @@ -109,15 +106,15 @@ export interface InlineDiffResult { /** * Computes text-level additions and deletions between two sequences using the generic sequence diff, mapping back to document positions. * - * @param oldContent Source tokens. + * @param oldContent Source tokens enriched with document offsets. * @param newContent Target tokens. - * @param oldPositionResolver Maps indexes to the original document. + * @param oldParagraphEndPos Absolute document position at the end of the old paragraph (used for trailing inserts). * @returns List of grouped inline diffs with document positions and text content. */ export function getInlineDiff( oldContent: InlineDiffToken[], newContent: InlineDiffToken[], - oldPositionResolver: PositionResolver, + oldParagraphEndPos: number, ): InlineDiffResult[] { const buildInlineDiff = (action: InlineAction, token: InlineDiffToken, oldIdx: number): RawDiff => { if (token.kind !== 'text') { @@ -173,7 +170,7 @@ export function getInlineDiff( }, }); - return groupDiffs(diffs, oldPositionResolver); + return groupDiffs(diffs, oldContent, oldParagraphEndPos); } /** @@ -235,13 +232,14 @@ type TextDiffGroup = }; /** - * Groups raw diff operations into contiguous ranges and converts serialized run attrs back to objects. + * Groups raw diff operations into contiguous ranges. * * @param diffs Raw diff operations from the sequence diff. - * @param oldPositionResolver Maps text indexes to original document positions. + * @param oldTokens Flattened tokens from the old paragraph, used to derive document positions. + * @param oldParagraphEndPos Absolute document position marking the paragraph boundary. * @returns Grouped inline diffs with start/end document positions. */ -function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): InlineDiffResult[] { +function groupDiffs(diffs: RawDiff[], oldTokens: InlineDiffToken[], oldParagraphEndPos: number): InlineDiffResult[] { const grouped: InlineDiffResult[] = []; let currentGroup: TextDiffGroup | null = null; @@ -275,8 +273,8 @@ function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): In grouped.push({ action: diff.action, kind: 'inlineNode', - startPos: oldPositionResolver(diff.idx), - endPos: oldPositionResolver(diff.idx), + startPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), + endPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), nodeType: diff.nodeType, ...(diff.action === 'modified' ? { @@ -289,11 +287,11 @@ function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): In continue; } - if (!currentGroup || !canExtendGroup(currentGroup, diff, oldPositionResolver)) { + if (!currentGroup || !canExtendGroup(currentGroup, diff, oldTokens, oldParagraphEndPos)) { pushCurrentGroup(); - currentGroup = createTextGroup(diff, oldPositionResolver); + currentGroup = createTextGroup(diff, oldTokens, oldParagraphEndPos); } else { - extendTextGroup(currentGroup, diff, oldPositionResolver); + extendTextGroup(currentGroup, diff, oldTokens, oldParagraphEndPos); } } @@ -304,14 +302,14 @@ function groupDiffs(diffs: RawDiff[], oldPositionResolver: PositionResolver): In /** * Builds a fresh text diff group seeded with the current diff token. */ -function createTextGroup(diff: RawTextDiff, positionResolver: PositionResolver): TextDiffGroup { +function createTextGroup(diff: RawTextDiff, oldTokens: InlineDiffToken[], oldParagraphEndPos: number): TextDiffGroup { const baseGroup = diff.action === 'modified' ? { action: diff.action, kind: 'text' as const, - startPos: positionResolver(diff.idx), - endPos: positionResolver(diff.idx), + startPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), + endPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), newText: diff.newText, oldText: diff.oldText, oldAttrs: diff.oldAttrs, @@ -320,8 +318,8 @@ function createTextGroup(diff: RawTextDiff, positionResolver: PositionResolver): : { action: diff.action, kind: 'text' as const, - startPos: positionResolver(diff.idx), - endPos: positionResolver(diff.idx), + startPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), + endPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), text: diff.text, runAttrs: diff.runAttrs, }; @@ -333,8 +331,13 @@ function createTextGroup(diff: RawTextDiff, positionResolver: PositionResolver): * Expands the current text group with the incoming diff token. * Keeps start/end positions updated while concatenating text payloads. */ -function extendTextGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolver: PositionResolver): void { - group.endPos = positionResolver(diff.idx); +function extendTextGroup( + group: TextDiffGroup, + diff: RawTextDiff, + oldTokens: InlineDiffToken[], + oldParagraphEndPos: number, +): void { + group.endPos = resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos); if (group.action === 'modified' && diff.action === 'modified') { group.newText += diff.newText; group.oldText += diff.oldText; @@ -347,7 +350,12 @@ function extendTextGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolv * Determines whether a text diff token can be merged into the current group. * Checks action, attributes, and adjacency constraints required by the grouping heuristic. */ -function canExtendGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolver: PositionResolver): boolean { +function canExtendGroup( + group: TextDiffGroup, + diff: RawTextDiff, + oldTokens: InlineDiffToken[], + oldParagraphEndPos: number, +): boolean { if (group.action !== diff.action) { return false; } @@ -364,12 +372,48 @@ function canExtendGroup(group: TextDiffGroup, diff: RawTextDiff, positionResolve return false; } + const diffPos = resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos); if (group.action === 'added') { - return group.startPos === positionResolver(diff.idx); + return group.startPos === diffPos; + } + if (diffPos == null || group.endPos == null) { + return false; } - return (group.endPos ?? 0) + 1 === positionResolver(diff.idx); + return group.endPos + 1 === diffPos; } +/** + * Maps a raw diff index back to an absolute document position using the original token offsets. + * + * @param tokens Flattened tokens from the old paragraph. + * @param idx Index provided by the Myers diff output. + * @param paragraphEndPos Absolute document position marking the paragraph boundary; used when idx equals the token length. + * @returns Document position or null when the index is outside the known ranges. + */ +function resolveTokenPosition(tokens: InlineDiffToken[], idx: number, paragraphEndPos: number): number | null { + if (idx < 0) { + return null; + } + const token = tokens[idx]; + if (token) { + if (token.kind === 'text') { + return token.offset ?? null; + } + return token.pos ?? null; + } + if (idx === tokens.length) { + return paragraphEndPos; + } + return null; +} + +/** + * Compares two sets of inline attributes and determines if they are equal. + * + * @param a - The first set of attributes to compare. + * @param b - The second set of attributes to compare. + * @returns `true` if the attributes are equal, `false` otherwise. + */ function areInlineAttrsEqual(a: Record | undefined, b: Record | undefined): boolean { return !getAttributesDiff(a ?? {}, b ?? {}); } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index f460f8f6f..56f97bb12 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -26,15 +26,30 @@ const createParagraphNode = (overrides = {}) => { const createParagraphInfo = (overrides = {}) => { const fullText = overrides.fullText ?? 'text'; - const textTokens = overrides.text ?? buildRuns(fullText); + const paragraphPos = overrides.pos ?? 0; + const baseTokens = + overrides.text ?? + buildRuns(fullText).map((token, index) => ({ + ...token, + offset: paragraphPos + 1 + index, + })); + const textTokens = baseTokens.map((token, index) => { + if (token.kind === 'text' && token.offset == null) { + return { ...token, offset: paragraphPos + 1 + index }; + } + if (token.kind === 'inlineNode' && token.pos == null) { + return { ...token, pos: paragraphPos + 1 + index }; + } + return token; + }); return { node: createParagraphNode(overrides.node), - pos: 0, + pos: paragraphPos, depth: 0, fullText, text: textTokens, - resolvePosition: (idx) => idx, + endPos: overrides.endPos ?? paragraphPos + 1 + fullText.length, ...overrides, }; }; @@ -109,14 +124,19 @@ const createParagraphWithSegments = (segments, contentSize) => { }; }; +const stripOffsets = (tokens) => + tokens.map((token) => + token.kind === 'text' ? { kind: token.kind, char: token.char, runAttrs: token.runAttrs } : token, + ); + describe('createParagraphSnapshot', () => { it('handles basic text nodes', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.text).toEqual(buildRuns('Hello', { bold: true })); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(4)).toBe(5); + expect(stripOffsets(result.text)).toEqual(buildRuns('Hello', { bold: true })); + expect(result.text[0]?.offset).toBe(1); + expect(result.text[4]?.offset).toBe(5); }); it('handles leaf nodes with leafText', () => { @@ -126,9 +146,9 @@ describe('createParagraphSnapshot', () => { ); const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.text).toEqual(buildRuns('Leaf', { type: 'leaf' })); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(3)).toBe(4); + expect(stripOffsets(result.text)).toEqual(buildRuns('Leaf', { type: 'leaf' })); + expect(result.text[0]?.offset).toBe(1); + expect(result.text[3]?.offset).toBe(4); }); it('handles mixed content', () => { @@ -138,10 +158,14 @@ describe('createParagraphSnapshot', () => { ]); const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.text).toEqual([...buildRuns('Hello', { bold: true }), ...buildRuns('Leaf', { italic: true })]); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(5)).toBe(6); - expect(result.resolvePosition(9)).toBe(10); + expect(stripOffsets(result.text)).toEqual([ + ...buildRuns('Hello', { bold: true }), + ...buildRuns('Leaf', { italic: true }), + ]); + expect(result.text[0]?.offset).toBe(1); + expect(result.text[5]?.offset).toBe(6); + expect(result.text[result.text.length - 1]?.offset).toBe(9); + expect(result.endPos).toBe(10); }); it('handles empty content', () => { @@ -149,7 +173,7 @@ describe('createParagraphSnapshot', () => { const result = createParagraphSnapshot(mockParagraph, 0, 0); expect(result.text).toEqual([]); - expect(result.resolvePosition(0)).toBe(1); + expect(result.endPos).toBe(1); }); it('includes inline nodes that have no textual content', () => { @@ -167,28 +191,26 @@ describe('createParagraphSnapshot', () => { type: 'tab', attrs: inlineAttrs, }, + pos: 1, }); - expect(result.text.slice(1)).toEqual(buildRuns('Text', { bold: false })); - expect(result.resolvePosition(0)).toBe(1); - expect(result.resolvePosition(1)).toBe(2); + expect(stripOffsets(result.text.slice(1))).toEqual(buildRuns('Text', { bold: false })); + expect(result.text[1]?.offset).toBe(2); }); it('applies paragraph position offsets to the resolver', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); const result = createParagraphSnapshot(mockParagraph, 10, 0); - expect(result.text).toEqual(buildRuns('Nested', {})); - expect(result.resolvePosition(0)).toBe(11); - expect(result.resolvePosition(6)).toBe(17); + expect(stripOffsets(result.text)).toEqual(buildRuns('Nested', {})); + expect(result.text[0]?.offset).toBe(11); + expect(result.text[5]?.offset).toBe(16); + expect(result.endPos).toBe(17); }); it('returns null when index is outside the flattened text array', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Hi', start: 0 }], 2); - const { resolvePosition } = createParagraphSnapshot(mockParagraph, 0, 0); - - expect(resolvePosition(-1)).toBeNull(); - expect(resolvePosition(3)).toBeNull(); - expect(resolvePosition(2)).toBe(3); + const result = createParagraphSnapshot(mockParagraph, 0, 0); + expect(result.endPos).toBe(3); }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 0f15e1c6b..688deada9 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -10,20 +10,12 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; type NodeJSON = ReturnType; -/** - * Maps flattened indexes back to the ProseMirror document. - */ -export type PositionResolver = (index: number) => number | null; - -/** - * Rich snapshot of a paragraph node with flattened content and helpers. - */ export interface ParagraphNodeInfo { node: PMNode; pos: number; depth: number; text: InlineDiffToken[]; - resolvePosition: PositionResolver; + endPos: number; fullText: string; } @@ -72,34 +64,30 @@ export type ParagraphDiff = AddedParagraphDiff | DeletedParagraphDiff | Modified * @param paragraph Paragraph node to flatten. * @param paragraphPos Position of the paragraph in the document. * @param depth Depth of the paragraph within the document tree. - * @returns Snapshot containing tokens, resolver, and derived metadata. + * @returns Snapshot containing tokens (with offsets) and derived metadata. */ export function createParagraphSnapshot(paragraph: PMNode, paragraphPos: number, depth: number): ParagraphNodeInfo { - const { text, resolvePosition } = buildParagraphContent(paragraph, paragraphPos); + const text = buildParagraphContent(paragraph, paragraphPos); return { node: paragraph, pos: paragraphPos, depth, text, - resolvePosition, + endPos: paragraphPos + 1 + paragraph.content.size, fullText: text.map((token) => (token.kind === 'text' ? token.char : '')).join(''), }; } /** - * Flattens a paragraph node into inline diff tokens and a resolver that tracks document positions. + * Flattens a paragraph node into inline diff tokens, embedding absolute document offsets. * * @param paragraph Paragraph node being tokenized. * @param paragraphPos Absolute document position for the paragraph; used to offset resolver results. - * @returns Flattened tokens plus a resolver that maps token indexes back to document positions. + * @returns Flattened tokens enriched with document offsets. */ -function buildParagraphContent( - paragraph: PMNode, - paragraphPos = 0, -): { text: InlineDiffToken[]; resolvePosition: PositionResolver } { +function buildParagraphContent(paragraph: PMNode, paragraphPos = 0): InlineDiffToken[] { const content: InlineDiffToken[] = []; - const segments: Array<{ start: number; end: number; pos: number }> = []; - + const paragraphOffset = paragraphPos + 1; paragraph.nodesBetween( 0, paragraph.content.size, @@ -116,50 +104,33 @@ function buildParagraphContent( } if (nodeText) { - const start = content.length; - const end = start + nodeText.length; const runNode = paragraph.nodeAt(pos - 1); const runAttrs = runNode?.attrs ?? {}; - segments.push({ start, end, pos }); - const chars = nodeText.split('').map((char) => ({ - kind: 'text', - char, - runAttrs, - })); - content.push(...(chars as InlineDiffToken[])); + const baseOffset = paragraphOffset + pos; + for (let i = 0; i < nodeText.length; i += 1) { + content.push({ + kind: 'text', + char: nodeText[i] ?? '', + runAttrs, + offset: baseOffset + i, + }); + } return; } if (node.type.name !== 'run' && node.isInline) { - const start = content.length; - const end = start + 1; content.push({ kind: 'inlineNode', node, nodeType: node.type.name, nodeJSON: node.toJSON(), + pos: paragraphOffset + pos, }); - segments.push({ start, end, pos }); } }, 0, ); - - const resolvePosition: PositionResolver = (index) => { - if (index < 0 || index > content.length) { - return null; - } - - for (const segment of segments) { - if (index >= segment.start && index < segment.end) { - return paragraphPos + 1 + segment.pos + (index - segment.start); - } - } - - return paragraphPos + 1 + paragraph.content.size; - }; - - return { text: content, resolvePosition }; + return content; } /** @@ -224,7 +195,7 @@ export function buildModifiedParagraphDiff( oldParagraph: ParagraphNodeInfo, newParagraph: ParagraphNodeInfo, ): ModifiedParagraphDiff | null { - const contentDiff = getInlineDiff(oldParagraph.text, newParagraph.text, oldParagraph.resolvePosition); + const contentDiff = getInlineDiff(oldParagraph.text, newParagraph.text, oldParagraph.endPos); const attrsDiff = getAttributesDiff(oldParagraph.node.attrs, newParagraph.node.attrs); if (contentDiff.length === 0 && !attrsDiff) { From aeb5a18757062c80ca2e00c36225a5734ded9c05 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 13:48:22 -0300 Subject: [PATCH 39/53] refactor: remove unecessary exports --- .../src/extensions/diffing/algorithm/paragraph-diffing.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 688deada9..b205aa760 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -32,21 +32,21 @@ interface ParagraphDiffBase { /** * Diff payload produced when a paragraph is inserted. */ -export type AddedParagraphDiff = ParagraphDiffBase<'added'> & { +type AddedParagraphDiff = ParagraphDiffBase<'added'> & { text: string; }; /** * Diff payload produced when a paragraph is deleted. */ -export type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { +type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { oldText: string; }; /** * Diff payload emitted when a paragraph changes, including inline edits. */ -export type ModifiedParagraphDiff = ParagraphDiffBase<'modified'> & { +type ModifiedParagraphDiff = ParagraphDiffBase<'modified'> & { oldText: string; newText: string; contentDiff: InlineDiffResult[]; From 2a247e67b6f6a80493b5886e9ba397ffd7ac8d19 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 15:09:50 -0300 Subject: [PATCH 40/53] refactor: standardize diff attribute names for modifications --- .../diffing/algorithm/paragraph-diffing.test.js | 6 ++++-- .../src/extensions/diffing/algorithm/paragraph-diffing.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index 56f97bb12..b0c558521 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -292,7 +292,8 @@ describe('paragraph diff builders', () => { expect(diff).toMatchObject({ action: 'modified', nodeType: 'paragraph', - nodeJSON: oldParagraph.node.toJSON(), + oldNodeJSON: oldParagraph.node.toJSON(), + newNodeJSON: newParagraph.node.toJSON(), oldText: 'foo', newText: 'bar', pos: 5, @@ -322,7 +323,8 @@ describe('paragraph diff builders', () => { expect(diff).not.toBeNull(); expect(diff.contentDiff).toEqual([]); expect(diff.attrsDiff?.modified).toHaveProperty('align'); - expect(diff.nodeJSON).toEqual(oldParagraph.node.toJSON()); + expect(diff.oldNodeJSON).toEqual(oldParagraph.node.toJSON()); + expect(diff.newNodeJSON).toEqual(newParagraph.node.toJSON()); }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index b205aa760..efe245e27 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -25,7 +25,6 @@ export interface ParagraphNodeInfo { interface ParagraphDiffBase { action: Action; nodeType: string; - nodeJSON: NodeJSON; pos: number; } @@ -33,6 +32,7 @@ interface ParagraphDiffBase { * Diff payload produced when a paragraph is inserted. */ type AddedParagraphDiff = ParagraphDiffBase<'added'> & { + nodeJSON: NodeJSON; text: string; }; @@ -40,6 +40,7 @@ type AddedParagraphDiff = ParagraphDiffBase<'added'> & { * Diff payload produced when a paragraph is deleted. */ type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { + nodeJSON: NodeJSON; oldText: string; }; @@ -47,6 +48,8 @@ type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { * Diff payload emitted when a paragraph changes, including inline edits. */ type ModifiedParagraphDiff = ParagraphDiffBase<'modified'> & { + oldNodeJSON: NodeJSON; + newNodeJSON: NodeJSON; oldText: string; newText: string; contentDiff: InlineDiffResult[]; @@ -205,7 +208,8 @@ export function buildModifiedParagraphDiff( return { action: 'modified', nodeType: oldParagraph.node.type.name, - nodeJSON: oldParagraph.node.toJSON(), + oldNodeJSON: oldParagraph.node.toJSON(), + newNodeJSON: newParagraph.node.toJSON(), oldText: oldParagraph.fullText, newText: newParagraph.fullText, pos: oldParagraph.pos, From 209769c82056df67a082fc975925ac5d11b3aaeb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 30 Dec 2025 15:16:11 -0300 Subject: [PATCH 41/53] docs: add JSDoc to compareDocuments command --- .../super-editor/src/extensions/diffing/diffing.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index f90c51821..7765a38c7 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -7,6 +7,17 @@ export const Diffing = Extension.create({ addCommands() { return { + /** + * Compares the current document against `updatedDocument` and returns the diffs required to + * transform the former into the latter. + * + * These diffs are intended to be replayed on-top of the old document, so apply the + * returned list in reverse (last entry first) to keep insertions that share the same + * `pos` anchor in the correct order. + * + * @param {import('prosemirror-model').Node} updatedDocument + * @returns {import('./computeDiff.ts').NodeDiff[]} + */ compareDocuments: (updatedDocument) => ({ state }) => { From b7fe2ef6b005ed0abec9f756917e5a90a9f90b30 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 10:57:09 -0300 Subject: [PATCH 42/53] feat: add function for diffing marks --- .../diffing/algorithm/attributes-diffing.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts index 59f08e459..26b828615 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts @@ -17,6 +17,15 @@ export interface AttributesDiff { modified: Record; } +/** + * Aggregated marks diff broken down into added, deleted, and modified marks. + */ +export interface MarksDiff { + added: { name: string; attrs: Record }[]; + deleted: { name: string; attrs: Record }[]; + modified: { name: string; oldAttrs: Record; newAttrs: Record }[]; +} + /** * Computes the attribute level diff between two arbitrary objects. * Produces a map of dotted paths to added, deleted and modified values. @@ -42,6 +51,74 @@ export function getAttributesDiff( return hasChanges ? diff : null; } +/** + * Computes the attribute level diff between two sets of ProseMirror marks. + * Produces a map of dotted paths to added, deleted and modified values. + * + * @param marksA Baseline marks to compare. + * @param marksB Updated marks to compare. + * @returns Structured diff or null when marks are effectively equal. + * + */ +export function getMarksDiff( + marksA: Array<{ type: string; attrs?: Record }> | null = [], + marksB: Array<{ type: string; attrs?: Record }> | null = [], +): MarksDiff | null { + marksA = marksA || []; + marksB = marksB || []; + + const normalizeMarkAttrs = (attrs?: Record): Record => { + if (!attrs) { + return {}; + } + const normalized: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (IGNORED_ATTRIBUTE_KEYS.has(key)) { + continue; + } + normalized[key] = value; + } + return normalized; + }; + const marksDiff: MarksDiff = { + added: [], + deleted: [], + modified: [], + }; + const marksMapA = new Map>(); + const marksMapB = new Map>(); + + for (const mark of marksA) { + marksMapA.set(mark.type, normalizeMarkAttrs(mark.attrs)); + } + for (const mark of marksB) { + marksMapB.set(mark.type, normalizeMarkAttrs(mark.attrs)); + } + + const markNames = new Set([...marksMapA.keys(), ...marksMapB.keys()]); + for (const name of markNames) { + const attrsA = marksMapA.get(name); + const attrsB = marksMapB.get(name); + + if (attrsA && !attrsB) { + marksDiff.deleted.push({ name, attrs: attrsA }); + continue; + } + + if (!attrsA && attrsB) { + marksDiff.added.push({ name, attrs: attrsB }); + continue; + } + + if (attrsA && attrsB && !deepEquals(attrsA, attrsB)) { + marksDiff.modified.push({ name, oldAttrs: attrsA, newAttrs: attrsB }); + } + } + + const hasChanges = marksDiff.added.length > 0 || marksDiff.deleted.length > 0 || marksDiff.modified.length > 0; + return hasChanges ? marksDiff : null; +} + /** * Recursively compares two objects and fills the diff buckets. * From 9aa827075cb4cb006d31e73e8046cbc0596ea634 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 10:58:32 -0300 Subject: [PATCH 43/53] feat: take marks into account during diff computation --- .../diffing/algorithm/inline-diffing.test.js | 35 +++++++++++ .../diffing/algorithm/inline-diffing.ts | 49 ++++++++++++++- .../algorithm/paragraph-diffing.test.js | 59 ++++++++++++++++++- .../diffing/algorithm/paragraph-diffing.ts | 1 + 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 5287e0b5e..96584ba14 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -15,6 +15,15 @@ const buildTextRuns = (text, runAttrs = {}, offsetStart = 0) => offset: offsetStart + index, })); +const buildMarkedTextRuns = (text, marks, runAttrs = {}, offsetStart = 0) => + text.split('').map((char, index) => ({ + char, + runAttrs: { ...runAttrs }, + kind: 'text', + offset: offsetStart + index, + marks, + })); + const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }, pos = 0) => { const nodeAttrs = { ...attrs }; return { @@ -97,6 +106,7 @@ describe('getInlineDiff', () => { deleted: { bold: true }, modified: {}, }, + marksDiff: null, }, ]); }); @@ -125,6 +135,31 @@ describe('getInlineDiff', () => { bold: { from: true, to: false }, }, }, + marksDiff: null, + }, + ]); + }); + + it('treats mark-only changes as modifications and surfaces marks diffs', () => { + const oldRuns = buildMarkedTextRuns('a', [{ type: 'bold', attrs: { level: 1 } }]); + const newRuns = buildMarkedTextRuns('a', [{ type: 'italic', attrs: {} }]); + + const diffs = getInlineDiff(oldRuns, newRuns, oldRuns.length); + + expect(diffs).toEqual([ + { + action: 'modified', + kind: 'text', + startPos: 0, + endPos: 0, + oldText: 'a', + newText: 'a', + runAttrsDiff: null, + marksDiff: { + added: [{ name: 'italic', attrs: {} }], + deleted: [{ name: 'bold', attrs: { level: 1 } }], + modified: [], + }, }, ]); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index 65ea43b51..0579e7d55 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -1,8 +1,9 @@ import type { Node as PMNode } from 'prosemirror-model'; -import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +import { getAttributesDiff, getMarksDiff, type AttributesDiff, type MarksDiff } from './attributes-diffing.ts'; import { diffSequences } from './sequence-diffing.ts'; type NodeJSON = ReturnType; +type MarkJSON = { type: string; attrs?: Record }; /** * Supported diff operations for inline changes. @@ -16,6 +17,7 @@ export type InlineTextToken = { kind: 'text'; char: string; runAttrs: Record; + marks: MarkJSON[]; offset?: number | null; }; @@ -46,6 +48,7 @@ type RawTextDiff = kind: 'text'; text: string; runAttrs: Record; + marks: MarkJSON[]; } | { action: 'modified'; @@ -55,6 +58,8 @@ type RawTextDiff = oldText: string; oldAttrs: Record; newAttrs: Record; + oldMarks: MarkJSON[]; + newMarks: MarkJSON[]; }; /** @@ -96,6 +101,8 @@ export interface InlineDiffResult { newText?: string; runAttrs?: Record; runAttrsDiff?: AttributesDiff | null; + marks?: Record[]; + marksDiff?: MarksDiff | null; nodeType?: string; nodeJSON?: NodeJSON; oldNodeJSON?: NodeJSON; @@ -116,7 +123,11 @@ export function getInlineDiff( newContent: InlineDiffToken[], oldParagraphEndPos: number, ): InlineDiffResult[] { - const buildInlineDiff = (action: InlineAction, token: InlineDiffToken, oldIdx: number): RawDiff => { + const buildInlineDiff = ( + action: Exclude, + token: InlineDiffToken, + oldIdx: number, + ): RawDiff => { if (token.kind !== 'text') { return { action, @@ -132,6 +143,7 @@ export function getInlineDiff( kind: 'text', text: token.char, runAttrs: token.runAttrs, + marks: token.marks, }; }; @@ -164,6 +176,8 @@ export function getInlineDiff( oldText: oldToken.char, oldAttrs: oldToken.runAttrs, newAttrs: newToken.runAttrs, + oldMarks: oldToken.marks, + newMarks: newToken.marks, }; } return null; @@ -196,7 +210,11 @@ function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean { */ function shouldProcessEqualAsModification(oldToken: InlineDiffToken, newToken: InlineDiffToken): boolean { if (oldToken.kind === 'text' && newToken.kind === 'text') { - return Boolean(getAttributesDiff(oldToken.runAttrs, newToken.runAttrs)); + return ( + Boolean(getAttributesDiff(oldToken.runAttrs, newToken.runAttrs)) || + oldToken.marks?.length !== newToken.marks?.length || + Boolean(getMarksDiff(oldToken.marks, newToken.marks)) + ); } if (oldToken.kind === 'inlineNode' && newToken.kind === 'inlineNode') { @@ -219,6 +237,7 @@ type TextDiffGroup = endPos: number | null; text: string; runAttrs: Record; + marks: MarkJSON[]; } | { action: 'modified'; @@ -229,6 +248,8 @@ type TextDiffGroup = oldText: string; oldAttrs: Record; newAttrs: Record; + oldMarks: MarkJSON[]; + newMarks: MarkJSON[]; }; /** @@ -258,9 +279,11 @@ function groupDiffs(diffs: RawDiff[], oldTokens: InlineDiffToken[], oldParagraph result.oldText = currentGroup.oldText; result.newText = currentGroup.newText; result.runAttrsDiff = getAttributesDiff(currentGroup.oldAttrs, currentGroup.newAttrs); + result.marksDiff = getMarksDiff(currentGroup.oldMarks, currentGroup.newMarks); } else { result.text = currentGroup.text; result.runAttrs = currentGroup.runAttrs; + result.marks = currentGroup.marks; } grouped.push(result); @@ -314,6 +337,8 @@ function createTextGroup(diff: RawTextDiff, oldTokens: InlineDiffToken[], oldPar oldText: diff.oldText, oldAttrs: diff.oldAttrs, newAttrs: diff.newAttrs, + oldMarks: diff.oldMarks, + newMarks: diff.newMarks, } : { action: diff.action, @@ -322,6 +347,7 @@ function createTextGroup(diff: RawTextDiff, oldTokens: InlineDiffToken[], oldPar endPos: resolveTokenPosition(oldTokens, diff.idx, oldParagraphEndPos), text: diff.text, runAttrs: diff.runAttrs, + marks: diff.marks, }; return baseGroup; @@ -364,10 +390,16 @@ function canExtendGroup( if (!areInlineAttrsEqual(group.oldAttrs, diff.oldAttrs) || !areInlineAttrsEqual(group.newAttrs, diff.newAttrs)) { return false; } + if (!areInlineMarksEqual(group.oldMarks, diff.oldMarks) || !areInlineMarksEqual(group.newMarks, diff.newMarks)) { + return false; + } } else if (group.action !== 'modified' && diff.action !== 'modified') { if (!areInlineAttrsEqual(group.runAttrs, diff.runAttrs)) { return false; } + if (!areInlineMarksEqual(group.marks, diff.marks)) { + return false; + } } else { return false; } @@ -417,3 +449,14 @@ function resolveTokenPosition(tokens: InlineDiffToken[], idx: number, paragraphE function areInlineAttrsEqual(a: Record | undefined, b: Record | undefined): boolean { return !getAttributesDiff(a ?? {}, b ?? {}); } + +/** + * Compares two sets of inline marks and determines if they are equal. + * + * @param a - The first set of marks to compare. + * @param b - The second set of marks to compare. + * @returns `true` if the marks are equal, `false` otherwise. + */ +function areInlineMarksEqual(a: MarkJSON[] | undefined, b: MarkJSON[] | undefined): boolean { + return !getMarksDiff(a ?? [], b ?? []); +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index b0c558521..e7ed1cb5c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -11,6 +11,15 @@ import { const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: attrs, kind: 'text' })); +const buildMarkedRuns = (text, marks, attrs = {}, offsetStart = 0) => + text.split('').map((char, index) => ({ + char, + runAttrs: attrs, + kind: 'text', + marks, + offset: offsetStart + index, + })); + const createParagraphNode = (overrides = {}) => { const node = { type: { name: 'paragraph', ...(overrides.type || {}) }, @@ -100,7 +109,7 @@ const createParagraphWithSegments = (segments, contentSize) => { nodesBetween: (from, to, callback) => { computedSegments.forEach((segment) => { if (segment.kind === 'text') { - callback({ isText: true, text: segment.text }, segment.start); + callback({ isText: true, text: segment.text, marks: segment.marks ?? [] }, segment.start); } else if (segment.kind === 'leaf') { callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); } else { @@ -197,6 +206,15 @@ describe('createParagraphSnapshot', () => { expect(result.text[1]?.offset).toBe(2); }); + it('captures marks from text nodes in the snapshot', () => { + const boldMark = { toJSON: () => ({ type: 'bold', attrs: { level: 2 } }) }; + const mockParagraph = createParagraphWithSegments([{ text: 'Hi', start: 0, marks: [boldMark] }], 2); + + const result = createParagraphSnapshot(mockParagraph, 0, 0); + expect(result.text[0]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); + expect(result.text[1]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); + }); + it('applies paragraph position offsets to the resolver', () => { const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); @@ -302,6 +320,45 @@ describe('paragraph diff builders', () => { expect(diff.contentDiff.length).toBeGreaterThan(0); }); + it('returns a diff when only inline marks change', () => { + const oldParagraph = createParagraphInfo({ + fullText: 'a', + text: buildMarkedRuns('a', [{ type: 'bold', attrs: { level: 1 } }], {}, 1), + node: createParagraphNode({ attrs: { align: 'left' } }), + }); + const newParagraph = createParagraphInfo({ + fullText: 'a', + text: buildMarkedRuns('a', [{ type: 'bold', attrs: { level: 2 } }], {}, 1), + node: createParagraphNode({ attrs: { align: 'left' } }), + }); + + const diff = buildModifiedParagraphDiff(oldParagraph, newParagraph); + expect(diff).not.toBeNull(); + expect(diff?.attrsDiff).toBeNull(); + expect(diff?.contentDiff).toEqual([ + { + action: 'modified', + kind: 'text', + startPos: 1, + endPos: 1, + oldText: 'a', + newText: 'a', + runAttrsDiff: null, + marksDiff: { + added: [], + deleted: [], + modified: [ + { + name: 'bold', + oldAttrs: { level: 1 }, + newAttrs: { level: 2 }, + }, + ], + }, + }, + ]); + }); + it('returns null when neither text nor attributes changed', () => { const baseParagraph = createParagraphInfo({ fullText: 'stable', diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index efe245e27..ab5064f1b 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -116,6 +116,7 @@ function buildParagraphContent(paragraph: PMNode, paragraphPos = 0): InlineDiffT char: nodeText[i] ?? '', runAttrs, offset: baseOffset + i, + marks: node.marks?.map((mark) => mark.toJSON()) ?? [], }); } return; From 9ab2bb7abcfef2913598f9e65d1b602aac3f0218 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 12:10:14 -0300 Subject: [PATCH 44/53] refactor: modify computeDiff signature to account for comments diffing --- .../extensions/diffing/computeDiff.test.js | 65 +++++++++++++------ .../src/extensions/diffing/computeDiff.ts | 28 ++++++-- .../src/extensions/diffing/diffing.js | 4 +- 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index f8c28db8d..512c12ec5 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -5,6 +5,12 @@ import { Editor } from '@core/Editor.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; +/** + * Loads a DOCX fixture and returns the ProseMirror document and schema. + * + * @param {string} name DOCX fixture filename. + * @returns {Promise<{ doc: import('prosemirror-model').Node; schema: import('prosemirror-model').Schema }>} + */ const getDocument = async (name) => { const buffer = await getTestDataAsBuffer(name); const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); @@ -21,9 +27,15 @@ const getDocument = async (name) => { annotations: true, }); - return editor.state.doc; + return { doc: editor.state.doc, schema: editor.schema }; }; +/** + * Flattens a ProseMirror JSON node to its text content. + * + * @param {import('prosemirror-model').Node | import('prosemirror-model').Node['toJSON'] | null | undefined} nodeJSON + * @returns {string} + */ const getNodeTextContent = (nodeJSON) => { if (!nodeJSON) { return ''; @@ -39,10 +51,11 @@ const getNodeTextContent = (nodeJSON) => { describe('Diff', () => { it('Compares two documents and identifies added, deleted, and modified paragraphs', async () => { - const docBefore = await getDocument('diff_before.docx'); - const docAfter = await getDocument('diff_after.docx'); + const { doc: docBefore, schema } = await getDocument('diff_before.docx'); + const { doc: docAfter } = await getDocument('diff_after.docx'); - const diffs = computeDiff(docBefore, docAfter); + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; const getDiff = (action, predicate) => diffs.find((diff) => diff.action === action && predicate(diff)); const modifiedDiffs = diffs.filter((diff) => diff.action === 'modified'); @@ -139,10 +152,11 @@ describe('Diff', () => { }); it('Compare two documents with simple changes', async () => { - const docBefore = await getDocument('diff_before2.docx'); - const docAfter = await getDocument('diff_after2.docx'); + const { doc: docBefore, schema } = await getDocument('diff_before2.docx'); + const { doc: docAfter } = await getDocument('diff_after2.docx'); - const diffs = computeDiff(docBefore, docAfter); + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; expect(diffs).toHaveLength(4); let diff = diffs.find((diff) => diff.action === 'modified' && diff.oldText === 'Here’s some text.'); @@ -167,10 +181,11 @@ describe('Diff', () => { }); it('Compare another set of two documents with only formatting changes', async () => { - const docBefore = await getDocument('diff_before4.docx'); - const docAfter = await getDocument('diff_after4.docx'); + const { doc: docBefore, schema } = await getDocument('diff_before4.docx'); + const { doc: docAfter } = await getDocument('diff_after4.docx'); - const diffs = computeDiff(docBefore, docAfter); + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; expect(diffs).toHaveLength(1); const diff = diffs[0]; @@ -178,10 +193,11 @@ describe('Diff', () => { }); it('Compare another set of two documents with only formatting changes', async () => { - const docBefore = await getDocument('diff_before5.docx'); - const docAfter = await getDocument('diff_after5.docx'); + const { doc: docBefore, schema } = await getDocument('diff_before5.docx'); + const { doc: docAfter } = await getDocument('diff_after5.docx'); - const diffs = computeDiff(docBefore, docAfter); + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; expect(diffs).toHaveLength(1); const diff = diffs[0]; @@ -189,10 +205,11 @@ describe('Diff', () => { }); it('Compare another set of two documents where an image was added', async () => { - const docBefore = await getDocument('diff_before6.docx'); - const docAfter = await getDocument('diff_after6.docx'); + const { doc: docBefore, schema } = await getDocument('diff_before6.docx'); + const { doc: docAfter } = await getDocument('diff_after6.docx'); - const diffs = computeDiff(docBefore, docAfter); + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; expect(diffs).toHaveLength(1); const diff = diffs[0]; expect(diff.action).toBe('modified'); @@ -206,10 +223,11 @@ describe('Diff', () => { }); it('Compare a complex document with table edits and tracked formatting', async () => { - const docBefore = await getDocument('diff_before7.docx'); - const docAfter = await getDocument('diff_after7.docx'); + const { doc: docBefore, schema } = await getDocument('diff_before7.docx'); + const { doc: docAfter } = await getDocument('diff_after7.docx'); - const diffs = computeDiff(docBefore, docAfter); + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; expect(diffs).toHaveLength(9); expect(diffs.filter((diff) => diff.action === 'modified')).toHaveLength(6); expect(diffs.filter((diff) => diff.action === 'added')).toHaveLength(2); @@ -268,4 +286,13 @@ describe('Diff', () => { ); expect(firstCellDiff?.contentDiff?.[0]?.text).toBe('First '); }); + + it('Compare a complex document with table edits and tracked formatting', async () => { + const { doc: docBefore, schema } = await getDocument('diff_before8.docx'); + const { doc: docAfter } = await getDocument('diff_after8.docx'); + + const { docDiffs } = computeDiff(docBefore, docAfter, schema); + const diffs = docDiffs; + console.log(JSON.stringify(diffs, null, 2)); + }); }); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index feb84642a..5c5ebc17e 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -1,6 +1,21 @@ -import type { Node as PMNode } from 'prosemirror-model'; +import type { Node as PMNode, Schema } from 'prosemirror-model'; import { diffNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; +/** + * Placeholder type for comment diffs until comment diffing is implemented. + */ +export type CommentDiff = Record; + +/** + * Result payload for document diffing. + */ +export interface DiffResult { + /** Diffs computed from the ProseMirror document structure. */ + docDiffs: NodeDiff[]; + /** Diffs computed from comment content and metadata. */ + commentDiffs: CommentDiff[]; +} + /** * Computes structural diffs between two ProseMirror documents, emitting insert/delete/modify operations for any block * node (paragraphs, images, tables, etc.). Paragraph mutations include inline text and inline-node diffs so consumers @@ -13,8 +28,13 @@ import { diffNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; * * @param oldPmDoc The previous ProseMirror document. * @param newPmDoc The updated ProseMirror document. - * @returns List of diff objects describing added, deleted or modified nodes (with inline-level diffs for paragraphs). + * @param schema The schema used to interpret document nodes (unused for now). + * @returns Object containing document and comment diffs. */ -export function computeDiff(oldPmDoc: PMNode, newPmDoc: PMNode): NodeDiff[] { - return diffNodes(oldPmDoc, newPmDoc); +export function computeDiff(oldPmDoc: PMNode, newPmDoc: PMNode, schema: Schema): DiffResult { + void schema; + return { + docDiffs: diffNodes(oldPmDoc, newPmDoc), + commentDiffs: [], + }; } diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index 7765a38c7..30815e058 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -16,12 +16,12 @@ export const Diffing = Extension.create({ * `pos` anchor in the correct order. * * @param {import('prosemirror-model').Node} updatedDocument - * @returns {import('./computeDiff.ts').NodeDiff[]} + * @returns {import('./computeDiff.ts').DiffResult} */ compareDocuments: (updatedDocument) => ({ state }) => { - const diffs = computeDiff(state.doc, updatedDocument); + const diffs = computeDiff(state.doc, updatedDocument, state.schema); return diffs; }, }; From 50aee807537409110766ac32e86286a50b1cdad6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 12:18:19 -0300 Subject: [PATCH 45/53] feat: add generic inline content tokenization function --- .../diffing/algorithm/inline-diffing.test.js | 225 +++++++++++++++++- .../diffing/algorithm/inline-diffing.ts | 55 +++++ .../algorithm/paragraph-diffing.test.js | 198 +++------------ 3 files changed, 307 insertions(+), 171 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 96584ba14..2a5c2bcc9 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -5,8 +5,16 @@ vi.mock('./myers-diff.ts', async () => { myersDiff: vi.fn(actual.myersDiff), }; }); -import { getInlineDiff } from './inline-diffing.ts'; +import { getInlineDiff, tokenizeInlineContent } from './inline-diffing.ts'; +/** + * Builds text tokens with offsets for inline diff tests. + * + * @param {string} text Text content to tokenize. + * @param {Record} runAttrs Run attributes to attach. + * @param {number} offsetStart Offset base for the first token. + * @returns {import('./inline-diffing.ts').InlineTextToken[]} + */ const buildTextRuns = (text, runAttrs = {}, offsetStart = 0) => text.split('').map((char, index) => ({ char, @@ -15,6 +23,15 @@ const buildTextRuns = (text, runAttrs = {}, offsetStart = 0) => offset: offsetStart + index, })); +/** + * Builds marked text tokens with offsets for inline diff tests. + * + * @param {string} text Text content to tokenize. + * @param {Array>} marks Marks to attach. + * @param {Record} runAttrs Run attributes to attach. + * @param {number} offsetStart Offset base for the first token. + * @returns {import('./inline-diffing.ts').InlineTextToken[]} + */ const buildMarkedTextRuns = (text, marks, runAttrs = {}, offsetStart = 0) => text.split('').map((char, index) => ({ char, @@ -24,6 +41,14 @@ const buildMarkedTextRuns = (text, marks, runAttrs = {}, offsetStart = 0) => marks, })); +/** + * Builds a mock inline-node token for diff tests. + * + * @param {Record} attrs Node attributes. + * @param {{ name: string }} type Node type descriptor. + * @param {number} pos Position offset for the inline node. + * @returns {import('./inline-diffing.ts').InlineNodeToken} + */ const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }, pos = 0) => { const nodeAttrs = { ...attrs }; return { @@ -39,6 +64,122 @@ const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }, pos = 0) => { }; }; +/** + * Builds text tokens without offsets for tokenizer assertions. + * + * @param {string} text Text content to tokenize. + * @param {Record} runAttrs Run attributes to attach. + * @param {Array>} marks Marks to attach. + * @returns {import('./inline-diffing.ts').InlineTextToken[]} + */ +const buildTextTokens = (text, runAttrs = {}, marks = []) => + text.split('').map((char) => ({ + char, + runAttrs, + kind: 'text', + marks, + })); + +/** + * Creates a mock inline container with configurable segments for tokenizer tests. + * + * @param {Array>} segments Inline segments to emit. + * @param {number | null} contentSize Optional content size override. + * @returns {import('prosemirror-model').Node} + */ +const createInlineContainer = (segments, contentSize) => { + const computedSegments = segments.map((segment) => { + if (segment.inlineNode) { + return { + ...segment, + kind: 'inline', + length: segment.length ?? 1, + start: segment.start ?? 0, + attrs: segment.attrs ?? segment.inlineNode.attrs ?? {}, + inlineNode: { + typeName: segment.inlineNode.typeName ?? 'inline', + attrs: segment.inlineNode.attrs ?? {}, + isLeaf: segment.inlineNode.isLeaf ?? true, + toJSON: + segment.inlineNode.toJSON ?? + (() => ({ + type: segment.inlineNode.typeName ?? 'inline', + attrs: segment.inlineNode.attrs ?? {}, + })), + }, + }; + } + + const segmentText = segment.text ?? segment.leafText(); + const length = segmentText.length; + return { + ...segment, + kind: segment.text != null ? 'text' : 'leaf', + length, + start: segment.start ?? 0, + attrs: segment.attrs ?? {}, + }; + }); + const size = + contentSize ?? computedSegments.reduce((max, segment) => Math.max(max, segment.start + segment.length), 0); + const attrsMap = new Map(); + computedSegments.forEach((segment) => { + const key = segment.kind === 'inline' ? segment.start : segment.start - 1; + attrsMap.set(key, segment.attrs); + }); + + return { + content: { size }, + nodesBetween: (from, to, callback) => { + computedSegments.forEach((segment) => { + if (segment.kind === 'text') { + callback({ isText: true, text: segment.text, marks: segment.marks ?? [] }, segment.start); + } else if (segment.kind === 'leaf') { + callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); + } else { + callback( + { + isInline: true, + isLeaf: segment.inlineNode.isLeaf, + type: { name: segment.inlineNode.typeName, spec: {} }, + attrs: segment.inlineNode.attrs, + toJSON: () => ({ + type: segment.inlineNode.typeName, + attrs: segment.inlineNode.attrs, + }), + }, + segment.start, + ); + } + }); + }, + nodeAt: (pos) => ({ attrs: attrsMap.get(pos) ?? {} }), + }; +}; + +/** + * Strips positional fields from tokens for assertions. + * + * @param {import('./inline-diffing.ts').InlineDiffToken[]} tokens Tokens to normalize. + * @returns {Array>} + */ +const stripTokenOffsets = (tokens) => + tokens.map((token) => { + if (token.kind === 'text') { + return { + kind: token.kind, + char: token.char, + runAttrs: token.runAttrs, + marks: token.marks, + }; + } + return { + kind: token.kind, + nodeType: token.nodeType, + nodeJSON: token.nodeJSON, + }; + }); + describe('getInlineDiff', () => { it('returns an empty diff list when both strings are identical', () => { const oldRuns = buildTextRuns('unchanged'); @@ -194,3 +335,85 @@ describe('getInlineDiff', () => { ]); }); }); + +describe('tokenizeInlineContent', () => { + it('handles basic text nodes', () => { + const mockParagraph = createInlineContainer([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); + + const tokens = tokenizeInlineContent(mockParagraph, 1); + expect(stripTokenOffsets(tokens)).toEqual(buildTextTokens('Hello', { bold: true }, [])); + expect(tokens[0]?.offset).toBe(1); + expect(tokens[4]?.offset).toBe(5); + }); + + it('handles leaf nodes with leafText', () => { + const mockParagraph = createInlineContainer([{ leafText: () => 'Leaf', start: 0, attrs: { type: 'leaf' } }], 4); + + const tokens = tokenizeInlineContent(mockParagraph, 1); + expect(stripTokenOffsets(tokens)).toEqual(buildTextTokens('Leaf', { type: 'leaf' }, [])); + expect(tokens[0]?.offset).toBe(1); + expect(tokens[3]?.offset).toBe(4); + }); + + it('handles mixed content', () => { + const mockParagraph = createInlineContainer([ + { text: 'Hello', start: 0, attrs: { bold: true } }, + { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, + ]); + + const tokens = tokenizeInlineContent(mockParagraph, 1); + expect(stripTokenOffsets(tokens)).toEqual([ + ...buildTextTokens('Hello', { bold: true }, []), + ...buildTextTokens('Leaf', { italic: true }, []), + ]); + expect(tokens[0]?.offset).toBe(1); + expect(tokens[5]?.offset).toBe(6); + expect(tokens[tokens.length - 1]?.offset).toBe(9); + }); + + it('handles empty content', () => { + const mockParagraph = createInlineContainer([], 0); + + const tokens = tokenizeInlineContent(mockParagraph, 1); + expect(tokens).toEqual([]); + }); + + it('includes inline nodes that have no textual content', () => { + const inlineAttrs = { kind: 'tab', width: 120 }; + const mockParagraph = createInlineContainer([ + { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 0 }, + { text: 'Text', start: 1, attrs: { bold: false } }, + ]); + + const tokens = tokenizeInlineContent(mockParagraph, 1); + expect(tokens[0]).toMatchObject({ + kind: 'inlineNode', + nodeType: 'tab', + nodeJSON: { + type: 'tab', + attrs: inlineAttrs, + }, + pos: 1, + }); + expect(stripTokenOffsets(tokens.slice(1))).toEqual(buildTextTokens('Text', { bold: false }, [])); + expect(tokens[1]?.offset).toBe(2); + }); + + it('captures marks from text nodes', () => { + const boldMark = { toJSON: () => ({ type: 'bold', attrs: { level: 2 } }) }; + const mockParagraph = createInlineContainer([{ text: 'Hi', start: 0, marks: [boldMark] }], 2); + + const tokens = tokenizeInlineContent(mockParagraph, 1); + expect(tokens[0]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); + expect(tokens[1]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); + }); + + it('applies the base offset to token positions', () => { + const mockParagraph = createInlineContainer([{ text: 'Nested', start: 0 }], 6); + + const tokens = tokenizeInlineContent(mockParagraph, 11); + expect(stripTokenOffsets(tokens)).toEqual(buildTextTokens('Nested', {}, [])); + expect(tokens[0]?.offset).toBe(11); + expect(tokens[5]?.offset).toBe(16); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index 0579e7d55..c52cffeeb 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -110,6 +110,61 @@ export interface InlineDiffResult { attrsDiff?: AttributesDiff | null; } +/** + * Tokenizes inline content into diffable text and inline-node tokens. + * + * @param pmNode ProseMirror node containing inline content. + * @param baseOffset Offset applied to every token position (default: 0). + * @returns Flattened inline tokens with offsets relative to the base offset. + */ +export function tokenizeInlineContent(pmNode: PMNode, baseOffset = 0): InlineDiffToken[] { + const content: InlineDiffToken[] = []; + pmNode.nodesBetween( + 0, + pmNode.content.size, + (node, pos) => { + let nodeText = ''; + + if (node.isText) { + nodeText = node.text ?? ''; + } else if (node.isLeaf) { + const leafTextFn = (node.type.spec as { leafText?: (node: PMNode) => string } | undefined)?.leafText; + if (leafTextFn) { + nodeText = leafTextFn(node); + } + } + + if (nodeText) { + const runNode = pmNode.nodeAt(pos - 1); + const runAttrs = runNode?.attrs ?? {}; + const tokenOffset = baseOffset + pos; + for (let i = 0; i < nodeText.length; i += 1) { + content.push({ + kind: 'text', + char: nodeText[i] ?? '', + runAttrs, + offset: tokenOffset + i, + marks: node.marks?.map((mark) => mark.toJSON()) ?? [], + }); + } + return; + } + + if (node.type.name !== 'run' && node.isInline) { + content.push({ + kind: 'inlineNode', + node, + nodeType: node.type.name, + nodeJSON: node.toJSON(), + pos: baseOffset + pos, + }); + } + }, + 0, + ); + return content; +} + /** * Computes text-level additions and deletions between two sequences using the generic sequence diff, mapping back to document positions. * diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js index e7ed1cb5c..20db466b7 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.test.js @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { - createParagraphSnapshot, shouldProcessEqualAsModification, paragraphComparator, buildAddedParagraphDiff, @@ -9,8 +8,24 @@ import { canTreatAsModification, } from './paragraph-diffing.ts'; +/** + * Builds text tokens without offsets for paragraph diff tests. + * + * @param {string} text Text content to tokenize. + * @param {Record} attrs Run attributes to attach. + * @returns {Array>} + */ const buildRuns = (text, attrs = {}) => text.split('').map((char) => ({ char, runAttrs: attrs, kind: 'text' })); +/** + * Builds marked text tokens with offsets for paragraph diff tests. + * + * @param {string} text Text content to tokenize. + * @param {Array>} marks Marks to attach. + * @param {Record} attrs Run attributes to attach. + * @param {number} offsetStart Offset base for the first token. + * @returns {Array>} + */ const buildMarkedRuns = (text, marks, attrs = {}, offsetStart = 0) => text.split('').map((char, index) => ({ char, @@ -20,6 +35,12 @@ const buildMarkedRuns = (text, marks, attrs = {}, offsetStart = 0) => offset: offsetStart + index, })); +/** + * Creates a mock paragraph node with default attributes. + * + * @param {Record} overrides Overrides for the mock node. + * @returns {Record} + */ const createParagraphNode = (overrides = {}) => { const node = { type: { name: 'paragraph', ...(overrides.type || {}) }, @@ -33,6 +54,12 @@ const createParagraphNode = (overrides = {}) => { return node; }; +/** + * Creates a paragraph snapshot stub for diff builder tests. + * + * @param {Record} overrides Overrides for the snapshot. + * @returns {Record} + */ const createParagraphInfo = (overrides = {}) => { const fullText = overrides.fullText ?? 'text'; const paragraphPos = overrides.pos ?? 0; @@ -63,175 +90,6 @@ const createParagraphInfo = (overrides = {}) => { }; }; -const createParagraphWithSegments = (segments, contentSize) => { - const computedSegments = segments.map((segment) => { - if (segment.inlineNode) { - return { - ...segment, - kind: 'inline', - length: segment.length ?? 1, - start: segment.start ?? 0, - attrs: segment.attrs ?? segment.inlineNode.attrs ?? {}, - inlineNode: { - typeName: segment.inlineNode.typeName ?? 'inline', - attrs: segment.inlineNode.attrs ?? {}, - isLeaf: segment.inlineNode.isLeaf ?? true, - toJSON: - segment.inlineNode.toJSON ?? - (() => ({ - type: segment.inlineNode.typeName ?? 'inline', - attrs: segment.inlineNode.attrs ?? {}, - })), - }, - }; - } - - const segmentText = segment.text ?? segment.leafText(); - const length = segmentText.length; - return { - ...segment, - kind: segment.text != null ? 'text' : 'leaf', - length, - start: segment.start ?? 0, - attrs: segment.attrs ?? {}, - }; - }); - const size = - contentSize ?? computedSegments.reduce((max, segment) => Math.max(max, segment.start + segment.length), 0); - const attrsMap = new Map(); - computedSegments.forEach((segment) => { - const key = segment.kind === 'inline' ? segment.start : segment.start - 1; - attrsMap.set(key, segment.attrs); - }); - - return { - content: { size }, - nodesBetween: (from, to, callback) => { - computedSegments.forEach((segment) => { - if (segment.kind === 'text') { - callback({ isText: true, text: segment.text, marks: segment.marks ?? [] }, segment.start); - } else if (segment.kind === 'leaf') { - callback({ isLeaf: true, type: { spec: { leafText: segment.leafText } } }, segment.start); - } else { - callback( - { - isInline: true, - isLeaf: segment.inlineNode.isLeaf, - type: { name: segment.inlineNode.typeName, spec: {} }, - attrs: segment.inlineNode.attrs, - toJSON: () => ({ - type: segment.inlineNode.typeName, - attrs: segment.inlineNode.attrs, - }), - }, - segment.start, - ); - } - }); - }, - nodeAt: (pos) => ({ attrs: attrsMap.get(pos) ?? {} }), - }; -}; - -const stripOffsets = (tokens) => - tokens.map((token) => - token.kind === 'text' ? { kind: token.kind, char: token.char, runAttrs: token.runAttrs } : token, - ); - -describe('createParagraphSnapshot', () => { - it('handles basic text nodes', () => { - const mockParagraph = createParagraphWithSegments([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); - - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(stripOffsets(result.text)).toEqual(buildRuns('Hello', { bold: true })); - expect(result.text[0]?.offset).toBe(1); - expect(result.text[4]?.offset).toBe(5); - }); - - it('handles leaf nodes with leafText', () => { - const mockParagraph = createParagraphWithSegments( - [{ leafText: () => 'Leaf', start: 0, attrs: { type: 'leaf' } }], - 4, - ); - - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(stripOffsets(result.text)).toEqual(buildRuns('Leaf', { type: 'leaf' })); - expect(result.text[0]?.offset).toBe(1); - expect(result.text[3]?.offset).toBe(4); - }); - - it('handles mixed content', () => { - const mockParagraph = createParagraphWithSegments([ - { text: 'Hello', start: 0, attrs: { bold: true } }, - { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, - ]); - - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(stripOffsets(result.text)).toEqual([ - ...buildRuns('Hello', { bold: true }), - ...buildRuns('Leaf', { italic: true }), - ]); - expect(result.text[0]?.offset).toBe(1); - expect(result.text[5]?.offset).toBe(6); - expect(result.text[result.text.length - 1]?.offset).toBe(9); - expect(result.endPos).toBe(10); - }); - - it('handles empty content', () => { - const mockParagraph = createParagraphWithSegments([], 0); - - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.text).toEqual([]); - expect(result.endPos).toBe(1); - }); - - it('includes inline nodes that have no textual content', () => { - const inlineAttrs = { kind: 'tab', width: 120 }; - const mockParagraph = createParagraphWithSegments([ - { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 0 }, - { text: 'Text', start: 1, attrs: { bold: false } }, - ]); - - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.text[0]).toMatchObject({ - kind: 'inlineNode', - nodeType: 'tab', - nodeJSON: { - type: 'tab', - attrs: inlineAttrs, - }, - pos: 1, - }); - expect(stripOffsets(result.text.slice(1))).toEqual(buildRuns('Text', { bold: false })); - expect(result.text[1]?.offset).toBe(2); - }); - - it('captures marks from text nodes in the snapshot', () => { - const boldMark = { toJSON: () => ({ type: 'bold', attrs: { level: 2 } }) }; - const mockParagraph = createParagraphWithSegments([{ text: 'Hi', start: 0, marks: [boldMark] }], 2); - - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.text[0]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); - expect(result.text[1]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); - }); - - it('applies paragraph position offsets to the resolver', () => { - const mockParagraph = createParagraphWithSegments([{ text: 'Nested', start: 0 }], 6); - - const result = createParagraphSnapshot(mockParagraph, 10, 0); - expect(stripOffsets(result.text)).toEqual(buildRuns('Nested', {})); - expect(result.text[0]?.offset).toBe(11); - expect(result.text[5]?.offset).toBe(16); - expect(result.endPos).toBe(17); - }); - - it('returns null when index is outside the flattened text array', () => { - const mockParagraph = createParagraphWithSegments([{ text: 'Hi', start: 0 }], 2); - const result = createParagraphSnapshot(mockParagraph, 0, 0); - expect(result.endPos).toBe(3); - }); -}); - describe('shouldProcessEqualAsModification', () => { it('returns true when node JSON differs', () => { const baseNode = { toJSON: () => ({ attrs: { bold: true } }) }; From a8115e9b301cf841715d9a5cc5a7c5e9c0b7e1ba Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 12:21:13 -0300 Subject: [PATCH 46/53] refactor: use inline content tokenization function for paragraphs --- .../diffing/algorithm/paragraph-diffing.ts | 60 +------------------ 1 file changed, 2 insertions(+), 58 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index ab5064f1b..2b15154de 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -1,5 +1,5 @@ import type { Node as PMNode } from 'prosemirror-model'; -import { getInlineDiff, type InlineDiffToken, type InlineDiffResult } from './inline-diffing.ts'; +import { getInlineDiff, tokenizeInlineContent, type InlineDiffToken, type InlineDiffResult } from './inline-diffing.ts'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; import { getInsertionPos } from './diff-utils.ts'; import { levenshteinDistance } from './similarity.ts'; @@ -70,7 +70,7 @@ export type ParagraphDiff = AddedParagraphDiff | DeletedParagraphDiff | Modified * @returns Snapshot containing tokens (with offsets) and derived metadata. */ export function createParagraphSnapshot(paragraph: PMNode, paragraphPos: number, depth: number): ParagraphNodeInfo { - const text = buildParagraphContent(paragraph, paragraphPos); + const text = tokenizeInlineContent(paragraph, paragraphPos + 1); return { node: paragraph, pos: paragraphPos, @@ -81,62 +81,6 @@ export function createParagraphSnapshot(paragraph: PMNode, paragraphPos: number, }; } -/** - * Flattens a paragraph node into inline diff tokens, embedding absolute document offsets. - * - * @param paragraph Paragraph node being tokenized. - * @param paragraphPos Absolute document position for the paragraph; used to offset resolver results. - * @returns Flattened tokens enriched with document offsets. - */ -function buildParagraphContent(paragraph: PMNode, paragraphPos = 0): InlineDiffToken[] { - const content: InlineDiffToken[] = []; - const paragraphOffset = paragraphPos + 1; - paragraph.nodesBetween( - 0, - paragraph.content.size, - (node, pos) => { - let nodeText = ''; - - if (node.isText) { - nodeText = node.text ?? ''; - } else if (node.isLeaf) { - const leafTextFn = (node.type.spec as { leafText?: (node: PMNode) => string } | undefined)?.leafText; - if (leafTextFn) { - nodeText = leafTextFn(node); - } - } - - if (nodeText) { - const runNode = paragraph.nodeAt(pos - 1); - const runAttrs = runNode?.attrs ?? {}; - const baseOffset = paragraphOffset + pos; - for (let i = 0; i < nodeText.length; i += 1) { - content.push({ - kind: 'text', - char: nodeText[i] ?? '', - runAttrs, - offset: baseOffset + i, - marks: node.marks?.map((mark) => mark.toJSON()) ?? [], - }); - } - return; - } - - if (node.type.name !== 'run' && node.isInline) { - content.push({ - kind: 'inlineNode', - node, - nodeType: node.type.name, - nodeJSON: node.toJSON(), - pos: paragraphOffset + pos, - }); - } - }, - 0, - ); - return content; -} - /** * Determines whether equal paragraph nodes should still be marked as modified because their serialized structure differs. * From 2132421adfb69493021e803eba22ff360997403b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 13:53:53 -0300 Subject: [PATCH 47/53] feat: implement tokenization of comment data --- .../diffing/algorithm/comment-diffing.test.ts | 97 +++++++++++++++++++ .../diffing/algorithm/comment-diffing.ts | 89 +++++++++++++++++ .../diffing/algorithm/generic-diffing.ts | 4 +- .../diffing/algorithm/inline-diffing.test.js | 30 +++--- .../diffing/algorithm/inline-diffing.ts | 2 +- 5 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts diff --git a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts new file mode 100644 index 000000000..9d0be9e34 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { buildCommentTokens } from './comment-diffing.ts'; + +/** + * Builds a minimal schema suitable for comment text tokenization. + * + * @returns {Schema} + */ +const createSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'inline*', group: 'block' }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +/** + * Builds a basic comment body JSON payload. + * + * @param {string} text Comment text content. + * @returns {Record} + */ +const buildCommentTextJson = (text) => ({ + type: 'paragraph', + content: [{ type: 'text', text }], +}); + +describe('buildCommentTokens', () => { + it('builds tokens and text for comments with commentId', () => { + const schema = createSchema(); + const comment = { + commentId: 'c-1', + textJson: buildCommentTextJson('Hello'), + isInternal: true, + }; + + const tokens = buildCommentTokens([comment], schema); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.commentId).toBe('c-1'); + expect(tokens[0]?.content?.fullText).toBe('Hello'); + expect(tokens[0]?.content?.text).toHaveLength(5); + expect(tokens[0]?.commentJSON).toBe(comment); + }); + + it('falls back to importedId when commentId is missing', () => { + const schema = createSchema(); + const comment = { + importedId: 'import-1', + textJson: buildCommentTextJson('Import'), + }; + + const tokens = buildCommentTokens([comment], schema); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.commentId).toBe('import-1'); + }); + + it('returns empty text when textJson is missing', () => { + const schema = createSchema(); + const comment = { + commentId: 'c-2', + textJson: null, + }; + + const tokens = buildCommentTokens([comment], schema); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.content).toBeNull(); + }); + + it('returns a base node info when the root node is not a paragraph', () => { + const schema = createSchema(); + const comment = { + commentId: 'c-3', + textJson: { type: 'text', text: 'Inline' }, + }; + + const tokens = buildCommentTokens([comment], schema); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.content).toMatchObject({ + pos: 0, + depth: 0, + }); + expect(tokens[0]?.content?.node?.type?.name).toBe('text'); + }); + + it('skips comments without a resolvable id', () => { + const schema = createSchema(); + const comment = { + textJson: buildCommentTextJson('No id'), + }; + + const tokens = buildCommentTokens([comment], schema); + expect(tokens).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts new file mode 100644 index 000000000..02e8f5221 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts @@ -0,0 +1,89 @@ +import type { Schema } from 'prosemirror-model'; +import type { NodeInfo } from './generic-diffing.ts'; +import { createParagraphSnapshot } from './paragraph-diffing.ts'; + +/** + * Raw comment data used for diffing comment content and metadata. + */ +export interface CommentInput { + /** Primary comment identifier when available. */ + commentId?: string; + /** Imported comment identifier used as a fallback. */ + importedId?: string; + /** Alternate identifier used by some integrations. */ + id?: string; + /** ProseMirror-compatible JSON for the comment body (expected to be a paragraph node). */ + textJson?: unknown; + /** Additional comment metadata fields. */ + [key: string]: unknown; +} + +/** + * Normalized token representation for a single comment. + */ +export interface CommentToken { + /** Resolved identifier for the comment. */ + commentId: string; + /** Original comment payload. */ + commentJSON: CommentInput; + /** Parsed comment body content when available. */ + content: NodeInfo | null; +} + +/** + * Builds normalized tokens for diffing comment content. + * + * @param comments Comment payloads to normalize. + * @param schema Schema used to build ProseMirror nodes from comment JSON. + * @returns Normalized comment tokens. + */ +export function buildCommentTokens(comments: CommentInput[], schema: Schema): CommentToken[] { + return comments + .map((comment) => { + const commentId = resolveCommentId(comment); + if (!commentId) { + return null; + } + const content = tokenizeCommentText(comment, schema); + return { + commentId, + commentJSON: comment, + content, + }; + }) + .filter((token): token is CommentToken => token !== null); +} + +/** + * Resolves a stable comment identifier from a comment payload. + * + * @param comment Comment payload to inspect. + * @returns Resolved comment id or null when unavailable. + */ +function resolveCommentId(comment: CommentInput): string | null { + return comment.importedId ?? comment.id ?? comment.commentId ?? null; +} + +/** + * Tokenizes a comment body into inline tokens and a flattened text string. + * + * @param comment Comment payload containing `textJson`. + * @param schema Schema used to build ProseMirror nodes. + * @returns Tokenization output for the comment body. + */ +function tokenizeCommentText(comment: CommentInput, schema: Schema): NodeInfo | null { + if (!comment.textJson) { + return null; + } + + const node = schema.nodeFromJSON(comment.textJson as Record); + if (node.type.name !== 'paragraph') { + return { + node, + pos: 0, + depth: 0, + }; + } + + return createParagraphSnapshot(node, 0, 0); +} diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index 415193d78..1b1942a43 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -19,7 +19,7 @@ type NodeJSON = ReturnType; /** * Minimal node metadata extracted during document traversal. */ -type BaseNodeInfo = { +export type BaseNodeInfo = { node: PMNode; pos: number; depth: number; @@ -28,7 +28,7 @@ type BaseNodeInfo = { /** * Union describing every node processed by the generic diff. */ -type NodeInfo = BaseNodeInfo | ParagraphNodeInfo; +export type NodeInfo = BaseNodeInfo | ParagraphNodeInfo; interface NodeDiffBase { action: Action; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js index 2a5c2bcc9..e237fc99e 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js @@ -338,18 +338,18 @@ describe('getInlineDiff', () => { describe('tokenizeInlineContent', () => { it('handles basic text nodes', () => { - const mockParagraph = createInlineContainer([{ text: 'Hello', start: 0, attrs: { bold: true } }], 5); + const mockParagraph = createInlineContainer([{ text: 'Hello', start: 1, attrs: { bold: true } }], 6); - const tokens = tokenizeInlineContent(mockParagraph, 1); + const tokens = tokenizeInlineContent(mockParagraph, 0); expect(stripTokenOffsets(tokens)).toEqual(buildTextTokens('Hello', { bold: true }, [])); expect(tokens[0]?.offset).toBe(1); expect(tokens[4]?.offset).toBe(5); }); it('handles leaf nodes with leafText', () => { - const mockParagraph = createInlineContainer([{ leafText: () => 'Leaf', start: 0, attrs: { type: 'leaf' } }], 4); + const mockParagraph = createInlineContainer([{ leafText: () => 'Leaf', start: 1, attrs: { type: 'leaf' } }], 5); - const tokens = tokenizeInlineContent(mockParagraph, 1); + const tokens = tokenizeInlineContent(mockParagraph, 0); expect(stripTokenOffsets(tokens)).toEqual(buildTextTokens('Leaf', { type: 'leaf' }, [])); expect(tokens[0]?.offset).toBe(1); expect(tokens[3]?.offset).toBe(4); @@ -357,11 +357,11 @@ describe('tokenizeInlineContent', () => { it('handles mixed content', () => { const mockParagraph = createInlineContainer([ - { text: 'Hello', start: 0, attrs: { bold: true } }, - { leafText: () => 'Leaf', start: 5, attrs: { italic: true } }, + { text: 'Hello', start: 1, attrs: { bold: true } }, + { leafText: () => 'Leaf', start: 6, attrs: { italic: true } }, ]); - const tokens = tokenizeInlineContent(mockParagraph, 1); + const tokens = tokenizeInlineContent(mockParagraph, 0); expect(stripTokenOffsets(tokens)).toEqual([ ...buildTextTokens('Hello', { bold: true }, []), ...buildTextTokens('Leaf', { italic: true }, []), @@ -374,18 +374,18 @@ describe('tokenizeInlineContent', () => { it('handles empty content', () => { const mockParagraph = createInlineContainer([], 0); - const tokens = tokenizeInlineContent(mockParagraph, 1); + const tokens = tokenizeInlineContent(mockParagraph, 0); expect(tokens).toEqual([]); }); it('includes inline nodes that have no textual content', () => { const inlineAttrs = { kind: 'tab', width: 120 }; const mockParagraph = createInlineContainer([ - { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 0 }, - { text: 'Text', start: 1, attrs: { bold: false } }, + { inlineNode: { typeName: 'tab', attrs: inlineAttrs }, start: 1 }, + { text: 'Text', start: 2, attrs: { bold: false } }, ]); - const tokens = tokenizeInlineContent(mockParagraph, 1); + const tokens = tokenizeInlineContent(mockParagraph, 0); expect(tokens[0]).toMatchObject({ kind: 'inlineNode', nodeType: 'tab', @@ -401,17 +401,17 @@ describe('tokenizeInlineContent', () => { it('captures marks from text nodes', () => { const boldMark = { toJSON: () => ({ type: 'bold', attrs: { level: 2 } }) }; - const mockParagraph = createInlineContainer([{ text: 'Hi', start: 0, marks: [boldMark] }], 2); + const mockParagraph = createInlineContainer([{ text: 'Hi', start: 1, marks: [boldMark] }], 3); - const tokens = tokenizeInlineContent(mockParagraph, 1); + const tokens = tokenizeInlineContent(mockParagraph, 0); expect(tokens[0]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); expect(tokens[1]?.marks).toEqual([{ type: 'bold', attrs: { level: 2 } }]); }); it('applies the base offset to token positions', () => { - const mockParagraph = createInlineContainer([{ text: 'Nested', start: 0 }], 6); + const mockParagraph = createInlineContainer([{ text: 'Nested', start: 1 }], 7); - const tokens = tokenizeInlineContent(mockParagraph, 11); + const tokens = tokenizeInlineContent(mockParagraph, 10); expect(stripTokenOffsets(tokens)).toEqual(buildTextTokens('Nested', {}, [])); expect(tokens[0]?.offset).toBe(11); expect(tokens[5]?.offset).toBe(16); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index c52cffeeb..acde7820b 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -135,7 +135,7 @@ export function tokenizeInlineContent(pmNode: PMNode, baseOffset = 0): InlineDif } if (nodeText) { - const runNode = pmNode.nodeAt(pos - 1); + const runNode = pos > 0 ? pmNode.nodeAt(pos - 1) : null; const runAttrs = runNode?.attrs ?? {}; const tokenOffset = baseOffset + pos; for (let i = 0; i < nodeText.length; i += 1) { From b28ca39b42ea940393dfe65467008899a1fd4e9d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 14:23:17 -0300 Subject: [PATCH 48/53] refactor: change diffNodes signature to facilitate reuse --- .../diffing/algorithm/generic-diffing.test.js | 52 +++++++++++-------- .../diffing/algorithm/generic-diffing.ts | 13 ++--- .../src/extensions/diffing/computeDiff.ts | 4 +- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js index bb2af561e..f57bd11d9 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { diffNodes } from './generic-diffing.ts'; +import { diffNodes, normalizeNodes } from './generic-diffing.ts'; const createDocFromNodes = (nodes = []) => { const docNode = { @@ -79,7 +79,7 @@ describe('diffParagraphs', () => { const oldRoot = createDocFromNodes(oldParagraphs); const newRoot = createDocFromNodes(newParagraphs); - const diffs = diffNodes(oldRoot, newRoot); + const diffs = diffNodes(normalizeNodes(oldRoot), normalizeNodes(newRoot)); expect(diffs).toHaveLength(1); expect(diffs[0].action).toBe('modified'); @@ -92,7 +92,7 @@ describe('diffParagraphs', () => { const oldRoot = createDocFromNodes(oldParagraphs); const newRoot = createDocFromNodes(newParagraphs); - const diffs = diffNodes(oldRoot, newRoot); + const diffs = diffNodes(normalizeNodes(oldRoot), normalizeNodes(newRoot)); expect(diffs).toHaveLength(2); expect(diffs[0].action).toBe('deleted'); @@ -111,7 +111,7 @@ describe('diffParagraphs', () => { const oldRoot = createDocFromNodes(oldParagraphs); const newRoot = createDocFromNodes(newParagraphs); - const diffs = diffNodes(oldRoot, newRoot); + const diffs = diffNodes(normalizeNodes(oldRoot), normalizeNodes(newRoot)); expect(diffs).toHaveLength(3); expect(diffs[0].action).toBe('modified'); @@ -123,7 +123,10 @@ describe('diffParagraphs', () => { it('treats paragraph attribute-only changes as modifications', () => { const oldParagraph = createParagraph('Consistent text', { align: 'left' }); const newParagraph = createParagraph('Consistent text', { align: 'right' }); - const diffs = diffNodes(createDocFromNodes([oldParagraph]), createDocFromNodes([newParagraph])); + const diffs = diffNodes( + normalizeNodes(createDocFromNodes([oldParagraph])), + normalizeNodes(createDocFromNodes([newParagraph])), + ); expect(diffs).toHaveLength(1); expect(diffs[0].action).toBe('modified'); @@ -134,7 +137,10 @@ describe('diffParagraphs', () => { it('emits attribute diffs for non-paragraph nodes', () => { const oldHeading = { node: buildSimpleNode('heading', { level: 1 }), pos: 0, depth: 1 }; const newHeading = { node: buildSimpleNode('heading', { level: 2 }), pos: 0, depth: 1 }; - const diffs = diffNodes(createDocFromNodes([oldHeading]), createDocFromNodes([newHeading])); + const diffs = diffNodes( + normalizeNodes(createDocFromNodes([oldHeading])), + normalizeNodes(createDocFromNodes([newHeading])), + ); expect(diffs).toHaveLength(1); expect(diffs[0]).toMatchObject({ @@ -151,12 +157,14 @@ describe('diffParagraphs', () => { const newParagraph = createParagraph('Base paragraph', {}, { pos: 0 }); const insertionPos = oldParagraph.pos + oldParagraph.node.nodeSize; const diffs = diffNodes( - createDocFromNodes([oldParagraph]), - createDocFromNodes([ - newParagraph, - { node: parentNode, pos: insertionPos, depth: 1 }, - { node: childNode, pos: insertionPos + 1, depth: 2 }, - ]), + normalizeNodes(createDocFromNodes([oldParagraph])), + normalizeNodes( + createDocFromNodes([ + newParagraph, + { node: parentNode, pos: insertionPos, depth: 1 }, + { node: childNode, pos: insertionPos + 1, depth: 2 }, + ]), + ), ); const additions = diffs.filter((diff) => diff.action === 'added'); @@ -171,12 +179,14 @@ describe('diffParagraphs', () => { const figurePos = paragraph.pos + paragraph.node.nodeSize; const diffs = diffNodes( - createDocFromNodes([ - paragraph, - { node: parentNode, pos: figurePos, depth: 1 }, - { node: childNode, pos: figurePos + 1, depth: 2 }, - ]), - createDocFromNodes([paragraph]), + normalizeNodes( + createDocFromNodes([ + paragraph, + { node: parentNode, pos: figurePos, depth: 1 }, + { node: childNode, pos: figurePos + 1, depth: 2 }, + ]), + ), + normalizeNodes(createDocFromNodes([paragraph])), ); const deletions = diffs.filter((diff) => diff.action === 'deleted'); @@ -201,7 +211,7 @@ describe('diffParagraphs', () => { { node: persistedRow, pos: 1 + insertedRow.nodeSize, depth: 2 }, ]); - const diffs = diffNodes(oldDoc, newDoc); + const diffs = diffNodes(normalizeNodes(oldDoc), normalizeNodes(newDoc)); const addition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'tableRow'); expect(addition).toBeDefined(); @@ -215,8 +225,8 @@ describe('diffParagraphs', () => { const expectedPos = oldParagraph.pos + oldParagraph.node.nodeSize; const diffs = diffNodes( - createDocFromNodes([oldParagraph]), - createDocFromNodes([newParagraph, { node: headingNode, pos: expectedPos, depth: 1 }]), + normalizeNodes(createDocFromNodes([oldParagraph])), + normalizeNodes(createDocFromNodes([newParagraph, { node: headingNode, pos: expectedPos, depth: 1 }])), ); const addition = diffs.find((diff) => diff.action === 'added' && diff.nodeType === 'heading'); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index 1b1942a43..1c7f4b1aa 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -65,12 +65,13 @@ interface NodeModifiedDiff extends NodeDiffBase<'modified'> { export type NodeDiff = ParagraphDiff | NodeAddedDiff | NodeDeletedDiff | NodeModifiedDiff; /** - * Produces a sequence diff between two ProseMirror documents, flattening paragraphs for inline-aware comparisons. + * Produces a sequence diff between two normalized node lists. + * + * @param oldNodes Normalized nodes from the old document. + * @param newNodes Normalized nodes from the new document. + * @returns List of node diffs describing the changes. */ -export function diffNodes(oldRoot: PMNode, newRoot: PMNode): NodeDiff[] { - const oldNodes = normalizeNodes(oldRoot); - const newNodes = normalizeNodes(newRoot); - +export function diffNodes(oldNodes: NodeInfo[], newNodes: NodeInfo[]): NodeDiff[] { const addedNodesSet = new Set(); const deletedNodesSet = new Set(); return diffSequences(oldNodes, newNodes, { @@ -88,7 +89,7 @@ export function diffNodes(oldRoot: PMNode, newRoot: PMNode): NodeDiff[] { /** * Traverses a ProseMirror document and converts paragraphs to richer node info objects. */ -function normalizeNodes(pmDoc: PMNode): NodeInfo[] { +export function normalizeNodes(pmDoc: PMNode): NodeInfo[] { const nodes: NodeInfo[] = []; const depthMap = new WeakMap(); depthMap.set(pmDoc, -1); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 5c5ebc17e..fadb897ca 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -1,5 +1,5 @@ import type { Node as PMNode, Schema } from 'prosemirror-model'; -import { diffNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; +import { diffNodes, normalizeNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; /** * Placeholder type for comment diffs until comment diffing is implemented. @@ -34,7 +34,7 @@ export interface DiffResult { export function computeDiff(oldPmDoc: PMNode, newPmDoc: PMNode, schema: Schema): DiffResult { void schema; return { - docDiffs: diffNodes(oldPmDoc, newPmDoc), + docDiffs: diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)), commentDiffs: [], }; } From ee1f0d9ee6238f054e06e1f3aad8dcf691d18b94 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 14:39:11 -0300 Subject: [PATCH 49/53] feat: allow specifying keys to be ignored when diffing attributes --- .../diffing/algorithm/attributes-diffing.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts index 26b828615..7eaf74074 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts @@ -32,11 +32,13 @@ export interface MarksDiff { * * @param objectA Baseline attributes to compare. * @param objectB Updated attributes to compare. + * @param ignoreKeys Additional attribute keys to ignore. * @returns Structured diff or null when objects are effectively equal. */ export function getAttributesDiff( objectA: Record | null | undefined = {}, objectB: Record | null | undefined = {}, + ignoreKeys: string[] = [], ): AttributesDiff | null { const diff: AttributesDiff = { added: {}, @@ -44,7 +46,8 @@ export function getAttributesDiff( modified: {}, }; - diffObjects(objectA ?? {}, objectB ?? {}, '', diff); + const ignored = new Set([...IGNORED_ATTRIBUTE_KEYS, ...ignoreKeys]); + diffObjects(objectA ?? {}, objectB ?? {}, '', diff, ignored); const hasChanges = Object.keys(diff.added).length > 0 || Object.keys(diff.deleted).length > 0 || Object.keys(diff.modified).length > 0; @@ -126,17 +129,19 @@ export function getMarksDiff( * @param objectB Updated attributes being inspected. * @param basePath Dotted path prefix used for nested keys. * @param diff Aggregated diff being mutated. + * @param ignoreKeys Set of attribute keys to ignore. */ function diffObjects( objectA: Record, objectB: Record, basePath: string, diff: AttributesDiff, + ignoreKeys: Set, ): void { const keys = new Set([...Object.keys(objectA || {}), ...Object.keys(objectB || {})]); for (const key of keys) { - if (IGNORED_ATTRIBUTE_KEYS.has(key)) { + if (ignoreKeys.has(key)) { continue; } @@ -145,12 +150,12 @@ function diffObjects( const hasB = Object.prototype.hasOwnProperty.call(objectB, key); if (hasA && !hasB) { - recordDeletedValue(objectA[key], path, diff); + recordDeletedValue(objectA[key], path, diff, ignoreKeys); continue; } if (!hasA && hasB) { - recordAddedValue(objectB[key], path, diff); + recordAddedValue(objectB[key], path, diff, ignoreKeys); continue; } @@ -158,7 +163,7 @@ function diffObjects( const valueB = objectB[key]; if (isPlainObject(valueA) && isPlainObject(valueB)) { - diffObjects(valueA, valueB, path, diff); + diffObjects(valueA, valueB, path, diff, ignoreKeys); continue; } @@ -183,14 +188,20 @@ function diffObjects( * @param value Value being marked as added. * @param path Dotted attribute path for the value. * @param diff Bucket used to capture additions. + * @param ignoreKeys Set of attribute keys to ignore. */ -function recordAddedValue(value: unknown, path: string, diff: Pick): void { +function recordAddedValue( + value: unknown, + path: string, + diff: Pick, + ignoreKeys: Set, +): void { if (isPlainObject(value)) { for (const [childKey, childValue] of Object.entries(value)) { - if (IGNORED_ATTRIBUTE_KEYS.has(childKey)) { + if (ignoreKeys.has(childKey)) { continue; } - recordAddedValue(childValue, joinPath(path, childKey), diff); + recordAddedValue(childValue, joinPath(path, childKey), diff, ignoreKeys); } return; } @@ -203,14 +214,20 @@ function recordAddedValue(value: unknown, path: string, diff: Pick): void { +function recordDeletedValue( + value: unknown, + path: string, + diff: Pick, + ignoreKeys: Set, +): void { if (isPlainObject(value)) { for (const [childKey, childValue] of Object.entries(value)) { - if (IGNORED_ATTRIBUTE_KEYS.has(childKey)) { + if (ignoreKeys.has(childKey)) { continue; } - recordDeletedValue(childValue, joinPath(path, childKey), diff); + recordDeletedValue(childValue, joinPath(path, childKey), diff, ignoreKeys); } return; } From 761bc9575d420924e1ff48c95dc847287de33d39 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 14:43:43 -0300 Subject: [PATCH 50/53] feat: implement comments diffing hooks --- .../diffing/algorithm/comment-diffing.test.ts | 178 ++++++++++++++++- .../diffing/algorithm/comment-diffing.ts | 189 +++++++++++++++++- 2 files changed, 364 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts index 9d0be9e34..6dc000f31 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; import { Schema } from 'prosemirror-model'; -import { buildCommentTokens } from './comment-diffing.ts'; +import { + buildAddedCommentDiff, + buildCommentTokens, + buildDeletedCommentDiff, + buildModifiedCommentDiff, + canTreatAsModification, + commentComparator, + diffComments, + shouldProcessEqualAsModification, +} from './comment-diffing.ts'; /** * Builds a minimal schema suitable for comment text tokenization. @@ -28,6 +37,14 @@ const buildCommentTextJson = (text) => ({ content: [{ type: 'text', text }], }); +/** + * Returns the first token for convenience in tests. + * + * @param {Array} tokens + * @returns {import('./comment-diffing.ts').CommentToken} + */ +const getFirstToken = (tokens) => tokens[0]; + describe('buildCommentTokens', () => { it('builds tokens and text for comments with commentId', () => { const schema = createSchema(); @@ -95,3 +112,162 @@ describe('buildCommentTokens', () => { expect(tokens).toEqual([]); }); }); + +describe('comment diff helpers', () => { + it('matches comments by id', () => { + const schema = createSchema(); + const oldToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('A') }], schema), + ); + const newToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('B') }], schema), + ); + + expect(commentComparator(oldToken, newToken)).toBe(true); + }); + + it('treats metadata changes as modifications', () => { + const schema = createSchema(); + const oldToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Text'), isDone: false }], schema), + ); + const newToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Text'), isDone: true }], schema), + ); + + expect(shouldProcessEqualAsModification(oldToken, newToken)).toBe(true); + }); + + it('treats content changes as modifications', () => { + const schema = createSchema(); + const oldToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Old') }], schema), + ); + const newToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('New') }], schema), + ); + + expect(shouldProcessEqualAsModification(oldToken, newToken)).toBe(true); + }); + + it('returns false for identical comments', () => { + const schema = createSchema(); + const oldToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Same') }], schema), + ); + const newToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Same') }], schema), + ); + + expect(shouldProcessEqualAsModification(oldToken, newToken)).toBe(false); + }); + + it('does not treat insert/delete pairs as modifications', () => { + expect(canTreatAsModification()).toBe(false); + }); + + it('builds added comment diffs with text', () => { + const schema = createSchema(); + const token = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Added') }], schema), + ); + + expect(buildAddedCommentDiff(token)).toEqual({ + action: 'added', + nodeType: 'comment', + commentId: 'c-1', + commentJSON: token.commentJSON, + text: 'Added', + }); + }); + + it('builds deleted comment diffs with old text', () => { + const schema = createSchema(); + const token = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Deleted') }], schema), + ); + + expect(buildDeletedCommentDiff(token)).toEqual({ + action: 'deleted', + nodeType: 'comment', + commentId: 'c-1', + commentJSON: token.commentJSON, + oldText: 'Deleted', + }); + }); + + it('builds modified comment diffs when content changes', () => { + const schema = createSchema(); + const oldToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('Old') }], schema), + ); + const newToken = getFirstToken( + buildCommentTokens([{ commentId: 'c-1', textJson: buildCommentTextJson('New') }], schema), + ); + + const diff = buildModifiedCommentDiff(oldToken, newToken); + expect(diff).toMatchObject({ + action: 'modified', + nodeType: 'comment', + commentId: 'c-1', + oldText: 'Old', + newText: 'New', + }); + expect(diff?.contentDiff).not.toEqual([]); + expect(diff?.attrsDiff).toBeNull(); + }); +}); + +describe('diffComments', () => { + it('returns added comment diffs for new comments', () => { + const schema = createSchema(); + const diffs = diffComments([], [{ commentId: 'c-1', textJson: buildCommentTextJson('Added') }], schema); + + expect(diffs).toHaveLength(1); + expect(diffs[0]).toMatchObject({ + action: 'added', + nodeType: 'comment', + commentId: 'c-1', + }); + }); + + it('returns deleted comment diffs for removed comments', () => { + const schema = createSchema(); + const diffs = diffComments([{ commentId: 'c-1', textJson: buildCommentTextJson('Removed') }], [], schema); + + expect(diffs).toHaveLength(1); + expect(diffs[0]).toMatchObject({ + action: 'deleted', + nodeType: 'comment', + commentId: 'c-1', + }); + }); + + it('returns modified comment diffs for content changes', () => { + const schema = createSchema(); + const diffs = diffComments( + [{ commentId: 'c-1', textJson: buildCommentTextJson('Old') }], + [{ commentId: 'c-1', textJson: buildCommentTextJson('New') }], + schema, + ); + + expect(diffs).toHaveLength(1); + expect(diffs[0]).toMatchObject({ + action: 'modified', + nodeType: 'comment', + commentId: 'c-1', + }); + expect(diffs[0].contentDiff).not.toEqual([]); + }); + + it('returns empty diffs for identical comments', () => { + const schema = createSchema(); + const diffs = diffComments( + [{ commentId: 'c-1', textJson: buildCommentTextJson('Same') }], + [{ commentId: 'c-1', textJson: buildCommentTextJson('Same') }], + schema, + ); + + expect(diffs).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts index 02e8f5221..4f1314f14 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts @@ -1,6 +1,8 @@ import type { Schema } from 'prosemirror-model'; -import type { NodeInfo } from './generic-diffing.ts'; -import { createParagraphSnapshot } from './paragraph-diffing.ts'; +import { diffNodes, type NodeDiff, type NodeInfo } from './generic-diffing.ts'; +import { getAttributesDiff, type AttributesDiff } from './attributes-diffing.ts'; +import { createParagraphSnapshot, type ParagraphNodeInfo } from './paragraph-diffing.ts'; +import { diffSequences } from './sequence-diffing.ts'; /** * Raw comment data used for diffing comment content and metadata. @@ -30,6 +32,48 @@ export interface CommentToken { content: NodeInfo | null; } +/** + * Base shape shared by every comment diff payload. + */ +export interface CommentDiffBase { + action: Action; + nodeType: 'comment'; + commentId: string; +} + +/** + * Diff payload describing an added comment. + */ +export type CommentAddedDiff = CommentDiffBase<'added'> & { + commentJSON: CommentInput; + text: string; +}; + +/** + * Diff payload describing a deleted comment. + */ +export type CommentDeletedDiff = CommentDiffBase<'deleted'> & { + commentJSON: CommentInput; + oldText: string; +}; + +/** + * Diff payload describing a modified comment. + */ +export type CommentModifiedDiff = CommentDiffBase<'modified'> & { + oldCommentJSON: CommentInput; + newCommentJSON: CommentInput; + oldText: string; + newText: string; + contentDiff: NodeDiff[]; + attrsDiff: AttributesDiff | null; +}; + +/** + * Union of every diff variant the comment diffing logic can produce. + */ +export type CommentDiff = CommentAddedDiff | CommentDeletedDiff | CommentModifiedDiff; + /** * Builds normalized tokens for diffing comment content. * @@ -54,6 +98,130 @@ export function buildCommentTokens(comments: CommentInput[], schema: Schema): Co .filter((token): token is CommentToken => token !== null); } +/** + * Computes diffs between two comment lists. + * + * @param oldComments Previous comment list. + * @param newComments Updated comment list. + * @param schema Schema used to parse comment bodies. + * @returns Comment diff payloads. + */ +export function diffComments(oldComments: CommentInput[], newComments: CommentInput[], schema: Schema): CommentDiff[] { + const oldTokens = buildCommentTokens(oldComments, schema); + const newTokens = buildCommentTokens(newComments, schema); + + return diffSequences(oldTokens, newTokens, { + comparator: commentComparator, + shouldProcessEqualAsModification, + canTreatAsModification: () => false, + buildAdded: (token) => buildAddedCommentDiff(token), + buildDeleted: (token) => buildDeletedCommentDiff(token), + buildModified: (oldToken, newToken) => buildModifiedCommentDiff(oldToken, newToken), + }); +} + +/** + * Compares two comment tokens to determine if they represent the same comment. + * + * @param oldToken Comment token from the old list. + * @param newToken Comment token from the new list. + * @returns True when comment ids match. + */ +export function commentComparator(oldToken: CommentToken, newToken: CommentToken): boolean { + return oldToken.commentId === newToken.commentId; +} + +/** + * Determines whether equal comment tokens should still be treated as modified. + * + * @param oldToken Comment token from the old list. + * @param newToken Comment token from the new list. + * @returns True when content or metadata differs. + */ +export function shouldProcessEqualAsModification(oldToken: CommentToken, newToken: CommentToken): boolean { + const attrsDiff = getAttributesDiff(oldToken.commentJSON, newToken.commentJSON, ['textJson', 'commentId']); + if (attrsDiff) { + return true; + } + + const oldSignature = oldToken.content ? JSON.stringify(oldToken.content.node.toJSON()) : ''; + const newSignature = newToken.content ? JSON.stringify(newToken.content.node.toJSON()) : ''; + return oldSignature !== newSignature; +} + +/** + * Determines whether delete/insert pairs should be treated as modifications. + * + * @returns False because comment ids are treated as stable identities. + */ +export function canTreatAsModification(): boolean { + return false; +} + +/** + * Builds a normalized payload describing a comment addition. + * + * @param comment Comment token being added. + * @returns Diff payload for the added comment. + */ +export function buildAddedCommentDiff(comment: CommentToken): CommentAddedDiff { + return { + action: 'added', + nodeType: 'comment', + commentId: comment.commentId, + commentJSON: comment.commentJSON, + text: getCommentText(comment.content), + }; +} + +/** + * Builds a normalized payload describing a comment deletion. + * + * @param comment Comment token being deleted. + * @returns Diff payload for the deleted comment. + */ +export function buildDeletedCommentDiff(comment: CommentToken): CommentDeletedDiff { + return { + action: 'deleted', + nodeType: 'comment', + commentId: comment.commentId, + commentJSON: comment.commentJSON, + oldText: getCommentText(comment.content), + }; +} + +/** + * Builds the payload for a comment modification, including inline diffs when possible. + * + * @param oldComment Comment token from the old list. + * @param newComment Comment token from the new list. + * @returns Diff payload or null when no changes exist. + */ +export function buildModifiedCommentDiff( + oldComment: CommentToken, + newComment: CommentToken, +): CommentModifiedDiff | null { + const contentDiff = + oldComment.content && newComment.content ? diffNodes([oldComment.content], [newComment.content]) : []; + const attrsDiff = getAttributesDiff(oldComment.commentJSON, newComment.commentJSON, ['textJson', 'commentId']); + + if (contentDiff.length === 0 && !attrsDiff) { + return null; + } + + return { + action: 'modified', + nodeType: 'comment', + commentId: oldComment.commentId, + oldCommentJSON: oldComment.commentJSON, + newCommentJSON: newComment.commentJSON, + oldText: getCommentText(oldComment.content), + newText: getCommentText(newComment.content), + contentDiff, + attrsDiff, + }; +} + /** * Resolves a stable comment identifier from a comment payload. * @@ -64,6 +232,23 @@ function resolveCommentId(comment: CommentInput): string | null { return comment.importedId ?? comment.id ?? comment.commentId ?? null; } +/** + * Returns the flattened comment text when the content is a paragraph. + * + * @param content Comment content payload. + * @returns Flattened text string. + */ +function getCommentText(content: NodeInfo | null): string { + if (!content) { + return ''; + } + if (content.node.type.name === 'paragraph') { + const paragraphContent = content as ParagraphNodeInfo; + return paragraphContent.fullText; + } + return ''; +} + /** * Tokenizes a comment body into inline tokens and a flattened text string. * From f5e454ec59c6392760bd9db12dbb29138aa89b6e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 15:03:39 -0300 Subject: [PATCH 51/53] feat: update compareDocuments command to support diffing comments --- .../extensions/diffing/computeDiff.test.js | 63 +++++++++++++++--- .../src/extensions/diffing/computeDiff.ts | 16 +++-- .../src/extensions/diffing/diffing.js | 11 ++- .../src/tests/data/diff_after8.docx | Bin 0 -> 17887 bytes .../src/tests/data/diff_before8.docx | Bin 0 -> 17648 bytes 5 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 packages/super-editor/src/tests/data/diff_after8.docx create mode 100644 packages/super-editor/src/tests/data/diff_before8.docx diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index 512c12ec5..f0cbaaa95 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -9,7 +9,7 @@ import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers * Loads a DOCX fixture and returns the ProseMirror document and schema. * * @param {string} name DOCX fixture filename. - * @returns {Promise<{ doc: import('prosemirror-model').Node; schema: import('prosemirror-model').Schema }>} + * @returns {Promise<{ doc: import('prosemirror-model').Node; schema: import('prosemirror-model').Schema; comments: Array> }>} */ const getDocument = async (name) => { const buffer = await getTestDataAsBuffer(name); @@ -27,7 +27,7 @@ const getDocument = async (name) => { annotations: true, }); - return { doc: editor.state.doc, schema: editor.schema }; + return { doc: editor.state.doc, schema: editor.schema, comments: editor.converter.comments }; }; /** @@ -287,12 +287,59 @@ describe('Diff', () => { expect(firstCellDiff?.contentDiff?.[0]?.text).toBe('First '); }); - it('Compare a complex document with table edits and tracked formatting', async () => { - const { doc: docBefore, schema } = await getDocument('diff_before8.docx'); - const { doc: docAfter } = await getDocument('diff_after8.docx'); + it('Compare documents with comments and tracked changes', async () => { + const { doc: docBefore, schema, comments: commentsBefore } = await getDocument('diff_before8.docx'); + const { doc: docAfter, comments: commentsAfter } = await getDocument('diff_after8.docx'); - const { docDiffs } = computeDiff(docBefore, docAfter, schema); - const diffs = docDiffs; - console.log(JSON.stringify(diffs, null, 2)); + const { docDiffs, commentDiffs } = computeDiff(docBefore, docAfter, schema, commentsBefore, commentsAfter); + + expect(docDiffs.length).toBeGreaterThan(0); + expect(docDiffs.filter((diff) => diff.action === 'modified')).toHaveLength(2); + expect(commentDiffs).toHaveLength(2); + + const commentAnchorDiff = docDiffs.find( + (diff) => diff.action === 'modified' && diff.oldText === 'Here’s some text. It has a comment.', + ); + expect(commentAnchorDiff).toBeDefined(); + expect(commentAnchorDiff?.contentDiff?.some((change) => change.kind === 'inlineNode')).toBe(true); + expect( + commentAnchorDiff?.contentDiff?.some( + (change) => change.kind === 'inlineNode' && change.nodeType === 'commentRangeStart', + ), + ).toBe(true); + expect( + commentAnchorDiff?.contentDiff?.some( + (change) => change.kind === 'text' && change.marksDiff?.deleted?.some((mark) => mark.name === 'commentMark'), + ), + ).toBe(true); + + const trackedChangeDiff = docDiffs.find( + (diff) => diff.action === 'modified' && diff.oldText === 'I will add a comment to this one too.', + ); + expect(trackedChangeDiff).toBeDefined(); + expect( + trackedChangeDiff?.contentDiff?.some( + (change) => change.kind === 'text' && change.marksDiff?.added?.some((mark) => mark.name === 'commentMark'), + ), + ).toBe(true); + expect( + trackedChangeDiff?.contentDiff?.some( + (change) => change.kind === 'text' && change.marksDiff?.added?.some((mark) => mark.name === 'trackDelete'), + ), + ).toBe(true); + + const modifiedComment = commentDiffs.find( + (diff) => diff.action === 'modified' && diff.nodeType === 'comment' && diff.commentId === '0', + ); + expect(modifiedComment).toBeDefined(); + expect(modifiedComment?.oldText).toBe('Old comment.'); + expect(modifiedComment?.newText).toBe('Old comment.'); + expect(modifiedComment?.attrsDiff?.modified?.isDone).toEqual({ from: false, to: true }); + + const addedComment = commentDiffs.find( + (diff) => diff.action === 'added' && diff.nodeType === 'comment' && diff.commentId === '1', + ); + expect(addedComment).toBeDefined(); + expect(addedComment?.text).toBe('New comment'); }); }); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index fadb897ca..8d6822d0d 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -1,4 +1,5 @@ import type { Node as PMNode, Schema } from 'prosemirror-model'; +import { diffComments, type CommentInput, type CommentDiff } from './algorithm/comment-diffing.ts'; import { diffNodes, normalizeNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; /** @@ -28,13 +29,20 @@ export interface DiffResult { * * @param oldPmDoc The previous ProseMirror document. * @param newPmDoc The updated ProseMirror document. - * @param schema The schema used to interpret document nodes (unused for now). + * @param schema The schema used to interpret document nodes. + * @param oldComments Comment list from the old document. + * @param newComments Comment list from the new document. * @returns Object containing document and comment diffs. */ -export function computeDiff(oldPmDoc: PMNode, newPmDoc: PMNode, schema: Schema): DiffResult { - void schema; +export function computeDiff( + oldPmDoc: PMNode, + newPmDoc: PMNode, + schema: Schema, + oldComments: CommentInput[] = [], + newComments: CommentInput[] = [], +): DiffResult { return { docDiffs: diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)), - commentDiffs: [], + commentDiffs: diffComments(oldComments, newComments, schema), }; } diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index 30815e058..9317a7e38 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -16,12 +16,19 @@ export const Diffing = Extension.create({ * `pos` anchor in the correct order. * * @param {import('prosemirror-model').Node} updatedDocument + * @param {import('./algorithm/comment-diffing.ts').CommentInput[]} [updatedComments] * @returns {import('./computeDiff.ts').DiffResult} */ compareDocuments: - (updatedDocument) => + (updatedDocument, updatedComments = []) => ({ state }) => { - const diffs = computeDiff(state.doc, updatedDocument, state.schema); + const diffs = computeDiff( + state.doc, + updatedDocument, + state.schema, + this.editor.converter?.comments ?? [], + updatedComments, + ); return diffs; }, }; diff --git a/packages/super-editor/src/tests/data/diff_after8.docx b/packages/super-editor/src/tests/data/diff_after8.docx new file mode 100644 index 0000000000000000000000000000000000000000..1202888efd0ed1dced2ad4110b3d42f1cb09c4f1 GIT binary patch literal 17887 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 Z4=Kq)0FCW8jzR_?0W<$YLcjg@e*j Date: Wed, 31 Dec 2025 15:23:58 -0300 Subject: [PATCH 52/53] test: move test files to separate directory --- .../src/extensions/diffing/computeDiff.test.js | 2 +- .../src/tests/data/{ => diffing}/diff_after.docx | Bin .../src/tests/data/{ => diffing}/diff_after2.docx | Bin .../src/tests/data/{ => diffing}/diff_after3.docx | Bin .../src/tests/data/{ => diffing}/diff_after4.docx | Bin .../src/tests/data/{ => diffing}/diff_after5.docx | Bin .../src/tests/data/{ => diffing}/diff_after6.docx | Bin .../src/tests/data/{ => diffing}/diff_after7.docx | Bin .../src/tests/data/{ => diffing}/diff_after8.docx | Bin .../src/tests/data/{ => diffing}/diff_before.docx | Bin .../src/tests/data/{ => diffing}/diff_before2.docx | Bin .../src/tests/data/{ => diffing}/diff_before3.docx | Bin .../src/tests/data/{ => diffing}/diff_before4.docx | Bin .../src/tests/data/{ => diffing}/diff_before5.docx | Bin .../src/tests/data/{ => diffing}/diff_before6.docx | Bin .../src/tests/data/{ => diffing}/diff_before7.docx | Bin .../src/tests/data/{ => diffing}/diff_before8.docx | Bin 17 files changed, 1 insertion(+), 1 deletion(-) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after2.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after3.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after4.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after5.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after6.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after7.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_after8.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before2.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before3.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before4.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before5.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before6.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before7.docx (100%) rename packages/super-editor/src/tests/data/{ => diffing}/diff_before8.docx (100%) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.test.js b/packages/super-editor/src/extensions/diffing/computeDiff.test.js index f0cbaaa95..605fef5ae 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.test.js +++ b/packages/super-editor/src/extensions/diffing/computeDiff.test.js @@ -12,7 +12,7 @@ import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers * @returns {Promise<{ doc: import('prosemirror-model').Node; schema: import('prosemirror-model').Schema; comments: Array> }>} */ const getDocument = async (name) => { - const buffer = await getTestDataAsBuffer(name); + const buffer = await getTestDataAsBuffer(`diffing/${name}`); const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); const editor = new Editor({ diff --git a/packages/super-editor/src/tests/data/diff_after.docx b/packages/super-editor/src/tests/data/diffing/diff_after.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after.docx rename to packages/super-editor/src/tests/data/diffing/diff_after.docx diff --git a/packages/super-editor/src/tests/data/diff_after2.docx b/packages/super-editor/src/tests/data/diffing/diff_after2.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after2.docx rename to packages/super-editor/src/tests/data/diffing/diff_after2.docx diff --git a/packages/super-editor/src/tests/data/diff_after3.docx b/packages/super-editor/src/tests/data/diffing/diff_after3.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after3.docx rename to packages/super-editor/src/tests/data/diffing/diff_after3.docx diff --git a/packages/super-editor/src/tests/data/diff_after4.docx b/packages/super-editor/src/tests/data/diffing/diff_after4.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after4.docx rename to packages/super-editor/src/tests/data/diffing/diff_after4.docx diff --git a/packages/super-editor/src/tests/data/diff_after5.docx b/packages/super-editor/src/tests/data/diffing/diff_after5.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after5.docx rename to packages/super-editor/src/tests/data/diffing/diff_after5.docx diff --git a/packages/super-editor/src/tests/data/diff_after6.docx b/packages/super-editor/src/tests/data/diffing/diff_after6.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after6.docx rename to packages/super-editor/src/tests/data/diffing/diff_after6.docx diff --git a/packages/super-editor/src/tests/data/diff_after7.docx b/packages/super-editor/src/tests/data/diffing/diff_after7.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after7.docx rename to packages/super-editor/src/tests/data/diffing/diff_after7.docx diff --git a/packages/super-editor/src/tests/data/diff_after8.docx b/packages/super-editor/src/tests/data/diffing/diff_after8.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_after8.docx rename to packages/super-editor/src/tests/data/diffing/diff_after8.docx diff --git a/packages/super-editor/src/tests/data/diff_before.docx b/packages/super-editor/src/tests/data/diffing/diff_before.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before.docx rename to packages/super-editor/src/tests/data/diffing/diff_before.docx diff --git a/packages/super-editor/src/tests/data/diff_before2.docx b/packages/super-editor/src/tests/data/diffing/diff_before2.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before2.docx rename to packages/super-editor/src/tests/data/diffing/diff_before2.docx diff --git a/packages/super-editor/src/tests/data/diff_before3.docx b/packages/super-editor/src/tests/data/diffing/diff_before3.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before3.docx rename to packages/super-editor/src/tests/data/diffing/diff_before3.docx diff --git a/packages/super-editor/src/tests/data/diff_before4.docx b/packages/super-editor/src/tests/data/diffing/diff_before4.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before4.docx rename to packages/super-editor/src/tests/data/diffing/diff_before4.docx diff --git a/packages/super-editor/src/tests/data/diff_before5.docx b/packages/super-editor/src/tests/data/diffing/diff_before5.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before5.docx rename to packages/super-editor/src/tests/data/diffing/diff_before5.docx diff --git a/packages/super-editor/src/tests/data/diff_before6.docx b/packages/super-editor/src/tests/data/diffing/diff_before6.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before6.docx rename to packages/super-editor/src/tests/data/diffing/diff_before6.docx diff --git a/packages/super-editor/src/tests/data/diff_before7.docx b/packages/super-editor/src/tests/data/diffing/diff_before7.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before7.docx rename to packages/super-editor/src/tests/data/diffing/diff_before7.docx diff --git a/packages/super-editor/src/tests/data/diff_before8.docx b/packages/super-editor/src/tests/data/diffing/diff_before8.docx similarity index 100% rename from packages/super-editor/src/tests/data/diff_before8.docx rename to packages/super-editor/src/tests/data/diffing/diff_before8.docx From d62919150a40962b65d1c23351894a02ab89ca04 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 31 Dec 2025 15:37:51 -0300 Subject: [PATCH 53/53] docs: improve TSDoc comments for interfaces --- .../diffing/algorithm/attributes-diffing.ts | 6 ++++++ .../diffing/algorithm/comment-diffing.ts | 13 +++++++++++++ .../diffing/algorithm/diff-utils.ts | 3 +++ .../diffing/algorithm/generic-diffing.ts | 11 +++++++++++ .../diffing/algorithm/inline-diffing.ts | 16 ++++++++++++++++ .../diffing/algorithm/paragraph-diffing.ts | 19 +++++++++++++++++++ .../diffing/algorithm/sequence-diffing.ts | 7 +++++++ .../src/extensions/diffing/computeDiff.ts | 5 ----- 8 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts index 7eaf74074..06285514d 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/attributes-diffing.ts @@ -12,8 +12,11 @@ export interface AttributeChange { * Aggregated attribute diff broken down into added, deleted, and modified dotted paths. */ export interface AttributesDiff { + /** Attributes added in the new payload. */ added: Record; + /** Attributes removed from the old payload. */ deleted: Record; + /** Attributes that changed values between old and new payloads. */ modified: Record; } @@ -21,8 +24,11 @@ export interface AttributesDiff { * Aggregated marks diff broken down into added, deleted, and modified marks. */ export interface MarksDiff { + /** Marks added in the new payload. */ added: { name: string; attrs: Record }[]; + /** Marks removed from the old payload. */ deleted: { name: string; attrs: Record }[]; + /** Marks whose attributes changed between old and new payloads. */ modified: { name: string; oldAttrs: Record; newAttrs: Record }[]; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts index 4f1314f14..3357cc484 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts @@ -36,8 +36,11 @@ export interface CommentToken { * Base shape shared by every comment diff payload. */ export interface CommentDiffBase { + /** Change type for this comment. */ action: Action; + /** Node type identifier for comment diffs. */ nodeType: 'comment'; + /** Resolved comment identifier (importedId → id → commentId). */ commentId: string; } @@ -45,7 +48,9 @@ export interface CommentDiffBase & { + /** Serialized comment payload inserted into the document. */ commentJSON: CommentInput; + /** Plain-text representation of the comment body. */ text: string; }; @@ -53,7 +58,9 @@ export type CommentAddedDiff = CommentDiffBase<'added'> & { * Diff payload describing a deleted comment. */ export type CommentDeletedDiff = CommentDiffBase<'deleted'> & { + /** Serialized comment payload removed from the document. */ commentJSON: CommentInput; + /** Plain-text representation of the removed comment body. */ oldText: string; }; @@ -61,11 +68,17 @@ export type CommentDeletedDiff = CommentDiffBase<'deleted'> & { * Diff payload describing a modified comment. */ export type CommentModifiedDiff = CommentDiffBase<'modified'> & { + /** Serialized comment payload before the change. */ oldCommentJSON: CommentInput; + /** Serialized comment payload after the change. */ newCommentJSON: CommentInput; + /** Plain-text content before the change. */ oldText: string; + /** Plain-text content after the change. */ newText: string; + /** Node-level diff for the comment body content. */ contentDiff: NodeDiff[]; + /** Attribute-level diff for comment metadata. */ attrsDiff: AttributesDiff | null; }; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts b/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts index 3d92e934f..0276dc42b 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/diff-utils.ts @@ -1,8 +1,11 @@ import type { Node as PMNode } from 'prosemirror-model'; interface NodePositionInfo { + /** ProseMirror node reference. */ node: PMNode; + /** Absolute position of the node in the document. */ pos: number; + /** Depth of the node within the document tree. */ depth: number; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts index 1c7f4b1aa..7af4418cb 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/generic-diffing.ts @@ -20,8 +20,11 @@ type NodeJSON = ReturnType; * Minimal node metadata extracted during document traversal. */ export type BaseNodeInfo = { + /** ProseMirror node reference. */ node: PMNode; + /** Absolute position of the node in the document. */ pos: number; + /** Depth of the node within the document tree. */ depth: number; }; @@ -31,8 +34,11 @@ export type BaseNodeInfo = { export type NodeInfo = BaseNodeInfo | ParagraphNodeInfo; interface NodeDiffBase { + /** Change type for this node. */ action: Action; + /** ProseMirror node type name. */ nodeType: string; + /** Anchor position in the old document for replaying diffs. */ pos: number; } @@ -40,6 +46,7 @@ interface NodeDiffBase { * Diff payload describing an inserted non-paragraph node. */ interface NodeAddedDiff extends NodeDiffBase<'added'> { + /** Serialized node payload inserted into the document. */ nodeJSON: NodeJSON; } @@ -47,6 +54,7 @@ interface NodeAddedDiff extends NodeDiffBase<'added'> { * Diff payload describing a deleted non-paragraph node. */ interface NodeDeletedDiff extends NodeDiffBase<'deleted'> { + /** Serialized node payload removed from the document. */ nodeJSON: NodeJSON; } @@ -54,8 +62,11 @@ interface NodeDeletedDiff extends NodeDiffBase<'deleted'> { * Diff payload describing an attribute-only change on non-paragraph nodes. */ interface NodeModifiedDiff extends NodeDiffBase<'modified'> { + /** Serialized node payload before the change. */ oldNodeJSON: NodeJSON; + /** Serialized node payload after the change. */ newNodeJSON: NodeJSON; + /** Attribute-level diff for the node. */ attrsDiff: AttributesDiff; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts index acde7820b..d540ce8e7 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts @@ -92,21 +92,37 @@ type RawDiff = RawTextDiff | RawInlineNodeDiff; * Final grouped inline diff exposed to downstream consumers. */ export interface InlineDiffResult { + /** Change type for this inline segment. */ action: InlineAction; + /** Token kind associated with the diff. */ kind: 'text' | 'inlineNode'; + /** Start position in the old document (or null when unknown). */ startPos: number | null; + /** End position in the old document (or null when unknown). */ endPos: number | null; + /** Inserted text for additions. */ text?: string; + /** Removed text for deletions/modifications. */ oldText?: string; + /** Inserted text for modifications. */ newText?: string; + /** Run attributes for added/deleted text. */ runAttrs?: Record; + /** Attribute diff for modified runs. */ runAttrsDiff?: AttributesDiff | null; + /** Marks applied to added/deleted text. */ marks?: Record[]; + /** Mark diff for modified text. */ marksDiff?: MarksDiff | null; + /** Inline node type name for node diffs. */ nodeType?: string; + /** Serialized inline node payload for additions/deletions. */ nodeJSON?: NodeJSON; + /** Serialized inline node payload before the change. */ oldNodeJSON?: NodeJSON; + /** Serialized inline node payload after the change. */ newNodeJSON?: NodeJSON; + /** Attribute diff for modified inline nodes. */ attrsDiff?: AttributesDiff | null; } diff --git a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts index 2b15154de..20d30b876 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/paragraph-diffing.ts @@ -11,11 +11,17 @@ const MIN_LENGTH_FOR_SIMILARITY = 4; type NodeJSON = ReturnType; export interface ParagraphNodeInfo { + /** ProseMirror paragraph node reference. */ node: PMNode; + /** Absolute position of the paragraph in the document. */ pos: number; + /** Depth of the paragraph within the document tree. */ depth: number; + /** Flattened inline tokens for inline diffing. */ text: InlineDiffToken[]; + /** Absolute end position used for trailing inserts. */ endPos: number; + /** Plain-text representation of the paragraph content. */ fullText: string; } @@ -23,8 +29,11 @@ export interface ParagraphNodeInfo { * Base shape shared by every paragraph diff payload. */ interface ParagraphDiffBase { + /** Change type for this paragraph. */ action: Action; + /** Node type name (always `paragraph`). */ nodeType: string; + /** Anchor position in the old document for replaying diffs. */ pos: number; } @@ -32,7 +41,9 @@ interface ParagraphDiffBase { * Diff payload produced when a paragraph is inserted. */ type AddedParagraphDiff = ParagraphDiffBase<'added'> & { + /** Serialized paragraph payload inserted into the document. */ nodeJSON: NodeJSON; + /** Plain-text content of the inserted paragraph. */ text: string; }; @@ -40,7 +51,9 @@ type AddedParagraphDiff = ParagraphDiffBase<'added'> & { * Diff payload produced when a paragraph is deleted. */ type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { + /** Serialized paragraph payload removed from the document. */ nodeJSON: NodeJSON; + /** Plain-text content of the removed paragraph. */ oldText: string; }; @@ -48,11 +61,17 @@ type DeletedParagraphDiff = ParagraphDiffBase<'deleted'> & { * Diff payload emitted when a paragraph changes, including inline edits. */ type ModifiedParagraphDiff = ParagraphDiffBase<'modified'> & { + /** Serialized paragraph payload before the change. */ oldNodeJSON: NodeJSON; + /** Serialized paragraph payload after the change. */ newNodeJSON: NodeJSON; + /** Plain-text content before the change. */ oldText: string; + /** Plain-text content after the change. */ newText: string; + /** Inline diff operations within the paragraph. */ contentDiff: InlineDiffResult[]; + /** Attribute-level diff for the paragraph. */ attrsDiff: AttributesDiff | null; }; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts index 136184bdc..81e8b2099 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/sequence-diffing.ts @@ -17,12 +17,19 @@ type OperationStep = * Hooks and comparators used to translate raw Myers operations into domain-specific diffs. */ export interface SequenceDiffOptions { + /** Comparator to determine whether two items are equivalent. */ comparator?: Comparator; + /** Builder invoked for insertions in the new sequence. */ buildAdded: (item: T, oldIdx: number, previousOldItem: T | undefined, newIdx: number) => Added | null | undefined; + /** Builder invoked for deletions in the old sequence. */ buildDeleted: (item: T, oldIdx: number, newIdx: number) => Deleted | null | undefined; + /** Builder invoked for modifications between old and new items. */ buildModified: (oldItem: T, newItem: T, oldIdx: number, newIdx: number) => Modified | null | undefined; + /** Predicate to emit modifications even when items compare equal. */ shouldProcessEqualAsModification?: (oldItem: T, newItem: T, oldIdx: number, newIdx: number) => boolean; + /** Predicate to treat delete+insert pairs as a modification. */ canTreatAsModification?: (deletedItem: T, insertedItem: T, oldIdx: number, newIdx: number) => boolean; + /** Optional reordering hook for Myers operations before mapping. */ reorderOperations?: (operations: MyersOperation[]) => MyersOperation[]; } diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 8d6822d0d..6b686905d 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -2,11 +2,6 @@ import type { Node as PMNode, Schema } from 'prosemirror-model'; import { diffComments, type CommentInput, type CommentDiff } from './algorithm/comment-diffing.ts'; import { diffNodes, normalizeNodes, type NodeDiff } from './algorithm/generic-diffing.ts'; -/** - * Placeholder type for comment diffs until comment diffing is implemented. - */ -export type CommentDiff = Record; - /** * Result payload for document diffing. */