From 38564682d31ef7d38e09299acd98f394ed548635 Mon Sep 17 00:00:00 2001 From: aorlov Date: Tue, 14 Apr 2026 17:24:04 -0700 Subject: [PATCH] feat(word-diff): implement word-level diffing for granular text changes --- .../__tests__/editor/word-diff.test.ts | 122 ++++++++ .../src/ai-actions/editor/editor-adapter.ts | 101 +++++++ .../ai/src/ai-actions/editor/word-diff.ts | 279 ++++++++++++++++++ .../plan-engine/executor.ts | 105 ++++++- .../plan-engine/word-diff.ts | 125 ++++++++ 5 files changed, 730 insertions(+), 2 deletions(-) create mode 100644 packages/ai/src/ai-actions/__tests__/editor/word-diff.test.ts create mode 100644 packages/ai/src/ai-actions/editor/word-diff.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts diff --git a/packages/ai/src/ai-actions/__tests__/editor/word-diff.test.ts b/packages/ai/src/ai-actions/__tests__/editor/word-diff.test.ts new file mode 100644 index 0000000000..2148801c2e --- /dev/null +++ b/packages/ai/src/ai-actions/__tests__/editor/word-diff.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { tokenizeWords, computeWordDiff, getWordChanges } from '../../editor/word-diff'; + +describe('tokenizeWords', () => { + it('should tokenize a basic sentence', () => { + const tokens = tokenizeWords('The quick fox'); + expect(tokens).toEqual([ + { text: 'The', offset: 0 }, + { text: ' ', offset: 3 }, + { text: 'quick', offset: 4 }, + { text: ' ', offset: 9 }, + { text: 'fox', offset: 10 }, + ]); + }); + + it('should handle multiple spaces between words', () => { + const tokens = tokenizeWords('hello world'); + expect(tokens).toEqual([ + { text: 'hello', offset: 0 }, + { text: ' ', offset: 5 }, + { text: 'world', offset: 7 }, + ]); + }); + + it('should handle leading and trailing whitespace', () => { + const tokens = tokenizeWords(' hello '); + expect(tokens).toEqual([ + { text: ' ', offset: 0 }, + { text: 'hello', offset: 2 }, + { text: ' ', offset: 7 }, + ]); + }); + + it('should return empty array for empty string', () => { + expect(tokenizeWords('')).toEqual([]); + }); + + it('should handle a single word', () => { + expect(tokenizeWords('hello')).toEqual([{ text: 'hello', offset: 0 }]); + }); + + it('should handle punctuation attached to words', () => { + const tokens = tokenizeWords('Hello, world!'); + expect(tokens).toEqual([ + { text: 'Hello,', offset: 0 }, + { text: ' ', offset: 6 }, + { text: 'world!', offset: 7 }, + ]); + }); +}); + +describe('computeWordDiff', () => { + it('should return empty array for identical strings', () => { + expect(computeWordDiff('hello world', 'hello world')).toEqual([]); + }); + + it('should detect a single word replacement', () => { + const changes = getWordChanges('The quick fox', 'The fast fox'); + expect(changes).toEqual([{ type: 'replace', oldFrom: 4, oldTo: 9, newText: 'fast' }]); + }); + + it('should detect multiple word replacements', () => { + const changes = getWordChanges( + 'The quick brown fox jumps over the lazy dog', + 'The fast brown fox leaps over the lazy cat', + ); + expect(changes).toEqual([ + { type: 'replace', oldFrom: 4, oldTo: 9, newText: 'fast' }, + { type: 'replace', oldFrom: 20, oldTo: 25, newText: 'leaps' }, + { type: 'replace', oldFrom: 40, oldTo: 43, newText: 'cat' }, + ]); + }); + + it('should detect word insertion', () => { + const changes = getWordChanges('The fox', 'The quick fox'); + expect(changes).toHaveLength(1); + expect(changes[0].type).toBe('insert'); + // "quick " is inserted (word + trailing space before "fox") + expect(changes[0]).toHaveProperty('newText', 'quick '); + }); + + it('should detect word deletion', () => { + const changes = getWordChanges('The quick fox', 'The fox'); + expect(changes).toHaveLength(1); + expect(changes[0].type).toBe('delete'); + // "quick " (word + space) is removed as a contiguous block + expect(changes[0]).toHaveProperty('oldFrom', 4); + expect(changes[0]).toHaveProperty('oldTo', 10); + }); + + it('should handle complete rewrite', () => { + const changes = getWordChanges('hello world', 'goodbye earth'); + // Each word is replaced separately since the space is a shared separator + expect(changes.length).toBeGreaterThanOrEqual(1); + expect(changes.every((c) => c.type === 'replace')).toBe(true); + }); + + it('should handle empty old text', () => { + const diff = computeWordDiff('', 'hello'); + expect(diff).toEqual([{ type: 'insert', insertAt: 0, newText: 'hello' }]); + }); + + it('should handle empty new text', () => { + const diff = computeWordDiff('hello', ''); + expect(diff).toEqual([{ type: 'delete', oldFrom: 0, oldTo: 5 }]); + }); + + it('should handle both empty', () => { + expect(computeWordDiff('', '')).toEqual([]); + }); + + it('should preserve whitespace tokens as equal', () => { + const diff = computeWordDiff('a b c', 'a x c'); + const changes = diff.filter((op) => op.type !== 'equal'); + expect(changes).toEqual([{ type: 'replace', oldFrom: 2, oldTo: 3, newText: 'x' }]); + }); + + it('should handle sentence with punctuation changes', () => { + const changes = getWordChanges('The company shall provide services.', 'The company must provide services.'); + expect(changes).toEqual([{ type: 'replace', oldFrom: 12, oldTo: 17, newText: 'must' }]); + }); +}); diff --git a/packages/ai/src/ai-actions/editor/editor-adapter.ts b/packages/ai/src/ai-actions/editor/editor-adapter.ts index 55a53c4929..d66cd71494 100644 --- a/packages/ai/src/ai-actions/editor/editor-adapter.ts +++ b/packages/ai/src/ai-actions/editor/editor-adapter.ts @@ -1,6 +1,7 @@ import type { Editor, FoundMatch, MarkType } from '../../shared'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import { generateId, stripListPrefix } from '../../shared'; +import { getWordChanges } from './word-diff'; /** * Default highlight color for text selections. @@ -630,6 +631,17 @@ export class EditorAdapter { const replacementEnd = suggestedText.length - suffix; const replacementText = suggestedText.slice(prefix, replacementEnd); + // Try word-level diff for more granular tracked changes + const wordChanges = getWordChanges(originalText.slice(prefix, originalTextLength - suffix), replacementText); + + if (wordChanges.length > 1) { + // Multiple word-level changes: apply each separately in reverse order + // so that earlier positions remain valid while we modify later ones. + this.applyGranularChanges(changeFrom, changeTo, wordChanges); + return; + } + + // 0 or 1 word change: use existing single-replacement logic (better mark handling) const segments = this.collectTextSegments(changeFrom, changeTo); const nodes = this.buildTextNodes(changeFrom, changeTo, replacementText, segments); const tr = state.tr.delete(changeFrom, changeTo); @@ -642,6 +654,95 @@ export class EditorAdapter { this.editor.dispatch(tr); } + /** + * Applies multiple word-level changes in a single transaction. + * Changes are applied in reverse document order to preserve positions. + * + * @param rangeFrom - Start of the overall change range in document positions + * @param rangeTo - End of the overall change range in document positions + * @param changes - Word diff operations with character offsets relative to the range text + * @private + */ + private applyGranularChanges( + rangeFrom: number, + rangeTo: number, + changes: Array< + | { type: 'replace'; oldFrom: number; oldTo: number; newText: string } + | { type: 'delete'; oldFrom: number; oldTo: number } + | { type: 'insert'; insertAt: number; newText: string } + >, + ): void { + const { state } = this.editor; + if (!state) { + return; + } + + // Pre-compute all document positions from the current (unmodified) state. + // Character offsets in changes are relative to the range text (rangeFrom..rangeTo). + const mappedChanges = changes.map((change) => { + if (change.type === 'insert') { + return { + ...change, + docPos: this.mapCharOffsetToPosition(rangeFrom, rangeTo, change.insertAt), + }; + } + return { + ...change, + docFrom: this.mapCharOffsetToPosition(rangeFrom, rangeTo, change.oldFrom), + docTo: this.mapCharOffsetToPosition(rangeFrom, rangeTo, change.oldTo), + }; + }); + + // Apply changes in forward order, remapping pre-computed positions through + // steps added during this loop so that length changes from earlier + // replacements are accounted for. + const tr = state.tr; + const baseSteps = tr.steps.length; + for (let i = 0; i < mappedChanges.length; i++) { + const change = mappedChanges[i]; + + // Remap pre-computed positions through steps added in this loop + const remap = (pos: number) => { + for (let s = baseSteps; s < tr.steps.length; s++) { + const step = tr.steps[s] as { getMap?: () => { map: (p: number) => number } } | undefined; + if (step && typeof step.getMap === 'function') { + pos = step.getMap().map(pos); + } + } + return pos; + }; + + if (change.type === 'delete') { + tr.delete(remap(change.docFrom), remap(change.docTo)); + } else if (change.type === 'insert') { + const marks = this.getMarksAtPosition(change.docPos); + const node = state.schema.text(change.newText, marks); + tr.insert(remap(change.docPos), node); + } else { + // replace: use replaceWith for a single atomic step when available, + // fall back to delete+insert for test mocks that lack replaceWith. + const from = remap(change.docFrom); + const to = remap(change.docTo); + const segments = this.collectTextSegments(from, to); + const nodes = this.buildTextNodes(from, to, change.newText, segments); + if (typeof (tr as Record).replaceWith === 'function') { + ( + tr as unknown as { replaceWith: (from: number, to: number, content: ProseMirrorNode[]) => void } + ).replaceWith(from, to, nodes); + } else { + tr.delete(from, to); + let insertPos = from; + for (const node of nodes) { + tr.insert(insertPos, node); + insertPos += node.nodeSize; + } + } + } + } + + this.editor.dispatch(tr); + } + /** * Replaces text in the document while intelligently preserving ProseMirror marks. * Uses a diffing algorithm to minimize document changes by only replacing changed portions. diff --git a/packages/ai/src/ai-actions/editor/word-diff.ts b/packages/ai/src/ai-actions/editor/word-diff.ts new file mode 100644 index 0000000000..93d8e0c403 --- /dev/null +++ b/packages/ai/src/ai-actions/editor/word-diff.ts @@ -0,0 +1,279 @@ +/** + * Word-level diff for granular tracked changes. + * + * Uses Myers diff on word tokens to produce multiple fine-grained change + * operations instead of one large replacement. This lets track-changes show + * individual word edits rather than a single paragraph-level deletion+insertion. + * + * Myers diff algorithm adapted from: + * packages/super-editor/src/editors/v1/extensions/diffing/algorithm/myers-diff.ts + */ + +// --------------------------------------------------------------------------- +// Myers diff (copied from super-editor — no cross-package dependency) +// --------------------------------------------------------------------------- + +type MyersOperation = 'equal' | 'insert' | 'delete'; +type Sequence = ArrayLike; +type Comparator = (a: T, b: T) => boolean; + +function myersDiff(oldSeq: Sequence, newSeq: Sequence, isEqual: Comparator): MyersOperation[] { + const oldLen = oldSeq.length; + const newLen = newSeq.length; + + if (oldLen === 0 && newLen === 0) { + return []; + } + + 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: 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: number; + + 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); +} + +function backtrackMyers(trace: number[][], oldLen: number, newLen: number, offset: number): MyersOperation[] { + const operations: MyersOperation[] = []; + 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: number; + 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(); +} + +// --------------------------------------------------------------------------- +// Word tokenizer +// --------------------------------------------------------------------------- + +export interface WordToken { + text: string; + offset: number; +} + +/** + * Splits text into alternating word and whitespace tokens. + * Each token carries its character offset within the source string. + */ +export function tokenizeWords(text: string): WordToken[] { + const tokens: WordToken[] = []; + const regex = /(\s+|\S+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + tokens.push({ text: match[0], offset: match.index }); + } + return tokens; +} + +// --------------------------------------------------------------------------- +// Word-level diff +// --------------------------------------------------------------------------- + +export type WordDiffOp = + | { type: 'equal'; oldFrom: number; oldTo: number } + | { type: 'replace'; oldFrom: number; oldTo: number; newText: string } + | { type: 'delete'; oldFrom: number; oldTo: number } + | { type: 'insert'; insertAt: number; newText: string }; + +/** + * Computes word-level diff operations between two text strings. + * + * Returns an array of operations with character offsets into the old text. + * Only non-equal operations need to be applied; equal ops are included for + * completeness but can be filtered out. + */ +export function computeWordDiff(oldText: string, newText: string): WordDiffOp[] { + if (oldText === newText) { + return []; + } + + const oldTokens = tokenizeWords(oldText); + const newTokens = tokenizeWords(newText); + + if (oldTokens.length === 0 && newTokens.length === 0) { + return []; + } + + if (oldTokens.length === 0) { + return [{ type: 'insert', insertAt: 0, newText }]; + } + + if (newTokens.length === 0) { + return [{ type: 'delete', oldFrom: 0, oldTo: oldText.length }]; + } + + const ops = myersDiff(oldTokens, newTokens, (a, b) => a.text === b.text); + + // Walk the operations and build indexed steps + const steps: Array<{ type: MyersOperation; oldIdx: number; newIdx: number }> = []; + let oldIdx = 0; + let newIdx = 0; + for (const op of ops) { + steps.push({ type: op, oldIdx, newIdx }); + if (op === 'equal') { + oldIdx++; + newIdx++; + } else if (op === 'delete') { + oldIdx++; + } else { + newIdx++; + } + } + + // Group consecutive operations and pair adjacent delete+insert as replace + return groupWordOps(steps, oldTokens, newTokens); +} + +/** + * Groups raw Myers operations into high-level word diff ops. + * Adjacent delete+insert sequences become a single replace op. + */ +function groupWordOps( + steps: Array<{ type: MyersOperation; oldIdx: number; newIdx: number }>, + oldTokens: WordToken[], + newTokens: WordToken[], +): WordDiffOp[] { + const result: WordDiffOp[] = []; + let i = 0; + + while (i < steps.length) { + const step = steps[i]; + + if (step.type === 'equal') { + const token = oldTokens[step.oldIdx]; + result.push({ + type: 'equal', + oldFrom: token.offset, + oldTo: token.offset + token.text.length, + }); + i++; + continue; + } + + // Collect contiguous delete+insert block + let deleteStart = -1; + let deleteEnd = -1; + let insertText = ''; + + while (i < steps.length && (steps[i].type === 'delete' || steps[i].type === 'insert')) { + const s = steps[i]; + if (s.type === 'delete') { + const token = oldTokens[s.oldIdx]; + if (deleteStart === -1) { + deleteStart = token.offset; + } + deleteEnd = token.offset + token.text.length; + } else { + const token = newTokens[s.newIdx]; + insertText += token.text; + } + i++; + } + + if (deleteStart !== -1 && insertText.length > 0) { + result.push({ type: 'replace', oldFrom: deleteStart, oldTo: deleteEnd, newText: insertText }); + } else if (deleteStart !== -1) { + result.push({ type: 'delete', oldFrom: deleteStart, oldTo: deleteEnd }); + } else if (insertText.length > 0) { + // Find insertion point: either at the end of previous old token or start of text + const prevStep = i > 0 ? steps[i - 1] : null; + let insertAt: number; + if (prevStep && prevStep.type === 'equal') { + const prevToken = oldTokens[prevStep.oldIdx]; + insertAt = prevToken.offset + prevToken.text.length; + } else if (result.length > 0) { + const lastOp = result[result.length - 1]; + insertAt = 'oldTo' in lastOp ? lastOp.oldTo : 'insertAt' in lastOp ? lastOp.insertAt : 0; + } else { + insertAt = 0; + } + result.push({ type: 'insert', insertAt, newText: insertText }); + } + } + + return result; +} + +/** + * Returns only the non-equal operations from a word diff. + */ +export function getWordChanges(oldText: string, newText: string): WordDiffOp[] { + return computeWordDiff(oldText, newText).filter((op) => op.type !== 'equal'); +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 4062e51741..670edefeeb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -52,11 +52,44 @@ import { TOGGLE_MARK_SPECS } from './mark-directives.js'; import { mapBlockNodeType } from '../helpers/node-address-resolver.js'; import { resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js'; import { normalizeReplacementText } from './replacement-normalizer.js'; +import { getWordChanges } from './word-diff.js'; import { Fragment, Slice } from 'prosemirror-model'; import type { Mark as ProseMirrorMark, MarkType, Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import type { Transaction } from 'prosemirror-state'; import type { Mapping } from 'prosemirror-transform'; +// --------------------------------------------------------------------------- +// Character-offset → document-position mapping +// --------------------------------------------------------------------------- + +/** + * Maps a character offset (within the text content of a range) to the + * corresponding ProseMirror document position. Needed because inline + * node boundaries (run open/close) create gaps in the position space + * that `textBetween` hides. + */ +function charOffsetToDocPos(doc: ProseMirrorNode, rangeFrom: number, rangeTo: number, charOffset: number): number { + let count = 0; + let foundPos = -1; + + doc.nodesBetween(rangeFrom, rangeTo, (node, pos) => { + if (foundPos >= 0) return false; + if (!node.isText) return true; // descend into non-text nodes + + const textStart = Math.max(pos, rangeFrom); + const textEnd = Math.min(pos + node.nodeSize, rangeTo); + const textLen = textEnd - textStart; + + if (count + textLen >= charOffset) { + foundPos = textStart + (charOffset - count); + } + count += textLen; + return false; + }); + + return foundPos >= 0 ? foundPos : rangeTo; +} + // --------------------------------------------------------------------------- // Style resolution helpers // --------------------------------------------------------------------------- @@ -804,8 +837,76 @@ export function executeTextRewrite( } } - const textNode = editor.state.schema.text(replacementText, asProseMirrorMarks(marks)); - tr.replaceWith(absFrom, absTo, textNode); + // 1. Character-level prefix/suffix trim to narrow the replacement range. + // This handles cases where only a few characters differ (e.g., a "(" added + // before a URL, or "YoY" → "year over year") without replacing the full range. + const originalText = tr.doc.textBetween(absFrom, absTo, '', ''); + const origLen = originalText.length; + const replLen = replacementText.length; + + let prefix = 0; + while (prefix < origLen && prefix < replLen && originalText[prefix] === replacementText[prefix]) { + prefix++; + } + if (prefix === origLen && prefix === replLen) { + return { changed: false }; // texts are identical + } + let suffix = 0; + while ( + suffix < origLen - prefix && + suffix < replLen - prefix && + originalText[origLen - 1 - suffix] === replacementText[replLen - 1 - suffix] + ) { + suffix++; + } + + const trimmedFrom = charOffsetToDocPos(tr.doc, absFrom, absTo, prefix); + const trimmedTo = charOffsetToDocPos(tr.doc, absFrom, absTo, origLen - suffix); + const trimmedOld = originalText.slice(prefix, origLen - suffix); + const trimmedNew = replacementText.slice(prefix, replLen - suffix); + + // 2. Word-level diff on the trimmed range for multi-word granularity. + const wordChanges = getWordChanges(trimmedOld, trimmedNew); + + if (wordChanges.length > 1) { + // Multiple word-level changes: apply each granularly. + const doc = tr.doc; + const baseSteps = tr.steps.length; + const mapped = wordChanges.map((change) => { + if (change.type === 'insert') { + return { ...change, docPos: charOffsetToDocPos(doc, trimmedFrom, trimmedTo, change.insertAt) }; + } + return { + ...change, + docFrom: charOffsetToDocPos(doc, trimmedFrom, trimmedTo, change.oldFrom), + docTo: charOffsetToDocPos(doc, trimmedFrom, trimmedTo, change.oldTo), + }; + }); + + for (let i = 0; i < mapped.length; i++) { + const change = mapped[i]; + const remap = (pos: number) => { + for (let s = baseSteps; s < tr.steps.length; s++) { + pos = tr.steps[s].getMap().map(pos); + } + return pos; + }; + + if (change.type === 'delete') { + tr.delete(remap(change.docFrom), remap(change.docTo)); + } else if (change.type === 'insert') { + const node = editor.state.schema.text(change.newText, asProseMirrorMarks(marks)); + tr.insert(remap(change.docPos), node); + } else { + const node = editor.state.schema.text(change.newText, asProseMirrorMarks(marks)); + tr.replaceWith(remap(change.docFrom), remap(change.docTo), node); + } + } + } else { + // 0 or 1 word change: replace just the trimmed range. + const textNode = editor.state.schema.text(trimmedNew, asProseMirrorMarks(marks)); + tr.replaceWith(trimmedFrom, trimmedTo, textNode); + } return { changed: replacementText !== target.text }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts new file mode 100644 index 0000000000..6c94df3daa --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/word-diff.ts @@ -0,0 +1,125 @@ +/** + * Word-level diff for granular text replacements. + * + * Produces multiple fine-grained change operations instead of one large + * replacement, so that tracked changes show individual word edits. + */ + +import { myersDiff } from '../../extensions/diffing/algorithm/myers-diff.js'; + +// --------------------------------------------------------------------------- +// Word tokenizer +// --------------------------------------------------------------------------- + +interface WordToken { + text: string; + offset: number; +} + +function tokenizeWords(text: string): WordToken[] { + const tokens: WordToken[] = []; + const regex = /(\s+|\S+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + tokens.push({ text: match[0], offset: match.index }); + } + return tokens; +} + +// --------------------------------------------------------------------------- +// Word-level diff +// --------------------------------------------------------------------------- + +export type WordDiffOp = + | { type: 'replace'; oldFrom: number; oldTo: number; newText: string } + | { type: 'delete'; oldFrom: number; oldTo: number } + | { type: 'insert'; insertAt: number; newText: string }; + +/** + * Computes word-level diff and returns only the non-equal (change) operations. + * Character offsets are relative to the input strings. + */ +export function getWordChanges(oldText: string, newText: string): WordDiffOp[] { + if (oldText === newText) { + return []; + } + + const oldTokens = tokenizeWords(oldText); + const newTokens = tokenizeWords(newText); + + if (oldTokens.length === 0 && newTokens.length === 0) { + return []; + } + if (oldTokens.length === 0) { + return [{ type: 'insert', insertAt: 0, newText }]; + } + if (newTokens.length === 0) { + return [{ type: 'delete', oldFrom: 0, oldTo: oldText.length }]; + } + + const ops = myersDiff(oldTokens, newTokens, (a, b) => a.text === b.text); + + // Build indexed steps + const steps: Array<{ type: 'equal' | 'insert' | 'delete'; oldIdx: number; newIdx: number }> = []; + let oldIdx = 0; + let newIdx = 0; + for (const op of ops) { + steps.push({ type: op, oldIdx, newIdx }); + if (op === 'equal') { + oldIdx++; + newIdx++; + } else if (op === 'delete') { + oldIdx++; + } else { + newIdx++; + } + } + + // Group consecutive operations, pairing adjacent delete+insert as replace + const result: WordDiffOp[] = []; + let i = 0; + + while (i < steps.length) { + const step = steps[i]; + + if (step.type === 'equal') { + i++; + continue; + } + + let deleteStart = -1; + let deleteEnd = -1; + let insertText = ''; + + while (i < steps.length && (steps[i].type === 'delete' || steps[i].type === 'insert')) { + const s = steps[i]; + if (s.type === 'delete') { + const token = oldTokens[s.oldIdx]; + if (deleteStart === -1) deleteStart = token.offset; + deleteEnd = token.offset + token.text.length; + } else { + insertText += newTokens[s.newIdx].text; + } + i++; + } + + if (deleteStart !== -1 && insertText.length > 0) { + result.push({ type: 'replace', oldFrom: deleteStart, oldTo: deleteEnd, newText: insertText }); + } else if (deleteStart !== -1) { + result.push({ type: 'delete', oldFrom: deleteStart, oldTo: deleteEnd }); + } else if (insertText.length > 0) { + const prevStep = i > 0 ? steps[i - 1] : null; + let insertAt = 0; + if (prevStep && prevStep.type === 'equal') { + const prevToken = oldTokens[prevStep.oldIdx]; + insertAt = prevToken.offset + prevToken.text.length; + } else if (result.length > 0) { + const lastOp = result[result.length - 1]; + insertAt = 'oldTo' in lastOp ? lastOp.oldTo : 'insertAt' in lastOp ? lastOp.insertAt : 0; + } + result.push({ type: 'insert', insertAt, newText: insertText }); + } + } + + return result; +}