@haklex/rich-diff-core 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +503 -544
- package/package.json +4 -5
package/dist/index.mjs
CHANGED
|
@@ -1,600 +1,559 @@
|
|
|
1
|
+
//#region src/compute-delta-stats.ts
|
|
1
2
|
function extractText(node) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if (Array.isArray(n.children)) {
|
|
7
|
-
return n.children.map(extractText).join("");
|
|
8
|
-
}
|
|
9
|
-
return "";
|
|
3
|
+
const n = node;
|
|
4
|
+
if (n.type === "text" && typeof n.text === "string") return n.text;
|
|
5
|
+
if (Array.isArray(n.children)) return n.children.map(extractText).join("");
|
|
6
|
+
return "";
|
|
10
7
|
}
|
|
11
8
|
function extractTextFromState(state) {
|
|
12
|
-
|
|
9
|
+
return state.root.children.map(extractText).join("\n");
|
|
13
10
|
}
|
|
14
11
|
function countWords(text) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const wordMatches = nonCjkText.match(wordPattern);
|
|
22
|
-
const wordCount = wordMatches ? wordMatches.length : 0;
|
|
23
|
-
return cjkCount + wordCount;
|
|
12
|
+
if (!text.trim()) return 0;
|
|
13
|
+
const cjkPattern = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g;
|
|
14
|
+
const cjkMatches = text.match(cjkPattern);
|
|
15
|
+
const cjkCount = cjkMatches ? cjkMatches.length : 0;
|
|
16
|
+
const wordMatches = text.replaceAll(cjkPattern, " ").match(/[\p{L}\p{N}]+/gu);
|
|
17
|
+
return cjkCount + (wordMatches ? wordMatches.length : 0);
|
|
24
18
|
}
|
|
25
19
|
function countLines(state) {
|
|
26
|
-
|
|
20
|
+
return state.root.children.length;
|
|
27
21
|
}
|
|
28
22
|
function computeCharDiff(oldText, newText) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
j--;
|
|
81
|
-
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
82
|
-
removedChars.push(oldMiddle[i - 1]);
|
|
83
|
-
i--;
|
|
84
|
-
} else {
|
|
85
|
-
addedChars.push(newMiddle[j - 1]);
|
|
86
|
-
j--;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
while (i > 0) {
|
|
90
|
-
removedChars.push(oldMiddle[--i]);
|
|
91
|
-
}
|
|
92
|
-
while (j > 0) {
|
|
93
|
-
addedChars.push(newMiddle[--j]);
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
added: addedChars.reverse().join(""),
|
|
97
|
-
removed: removedChars.reverse().join("")
|
|
98
|
-
};
|
|
23
|
+
const oldChars = Array.from(oldText);
|
|
24
|
+
const newChars = Array.from(newText);
|
|
25
|
+
const m = oldChars.length;
|
|
26
|
+
const n = newChars.length;
|
|
27
|
+
let prefix = 0;
|
|
28
|
+
while (prefix < m && prefix < n && oldChars[prefix] === newChars[prefix]) prefix++;
|
|
29
|
+
let oldSuffix = m - 1;
|
|
30
|
+
let newSuffix = n - 1;
|
|
31
|
+
while (oldSuffix >= prefix && newSuffix >= prefix && oldChars[oldSuffix] === newChars[newSuffix]) {
|
|
32
|
+
oldSuffix--;
|
|
33
|
+
newSuffix--;
|
|
34
|
+
}
|
|
35
|
+
const oldMiddle = oldChars.slice(prefix, oldSuffix + 1);
|
|
36
|
+
const newMiddle = newChars.slice(prefix, newSuffix + 1);
|
|
37
|
+
if (oldMiddle.length * newMiddle.length > 1e5) return {
|
|
38
|
+
removed: oldMiddle.join(""),
|
|
39
|
+
added: newMiddle.join("")
|
|
40
|
+
};
|
|
41
|
+
if (oldMiddle.length === 0) return {
|
|
42
|
+
added: newMiddle.join(""),
|
|
43
|
+
removed: ""
|
|
44
|
+
};
|
|
45
|
+
if (newMiddle.length === 0) return {
|
|
46
|
+
added: "",
|
|
47
|
+
removed: oldMiddle.join("")
|
|
48
|
+
};
|
|
49
|
+
const om = oldMiddle.length;
|
|
50
|
+
const nm = newMiddle.length;
|
|
51
|
+
const dp = Array.from({ length: om + 1 }, () => Array.from({ length: nm + 1 }).fill(0));
|
|
52
|
+
for (let i = 1; i <= om; i++) for (let j = 1; j <= nm; j++) if (oldMiddle[i - 1] === newMiddle[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
53
|
+
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
54
|
+
const removedChars = [];
|
|
55
|
+
const addedChars = [];
|
|
56
|
+
let i = om;
|
|
57
|
+
let j = nm;
|
|
58
|
+
while (i > 0 && j > 0) if (oldMiddle[i - 1] === newMiddle[j - 1]) {
|
|
59
|
+
i--;
|
|
60
|
+
j--;
|
|
61
|
+
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
62
|
+
removedChars.push(oldMiddle[i - 1]);
|
|
63
|
+
i--;
|
|
64
|
+
} else {
|
|
65
|
+
addedChars.push(newMiddle[j - 1]);
|
|
66
|
+
j--;
|
|
67
|
+
}
|
|
68
|
+
while (i > 0) removedChars.push(oldMiddle[--i]);
|
|
69
|
+
while (j > 0) addedChars.push(newMiddle[--j]);
|
|
70
|
+
return {
|
|
71
|
+
added: addedChars.reverse().join(""),
|
|
72
|
+
removed: removedChars.reverse().join("")
|
|
73
|
+
};
|
|
99
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Computes delta statistics between two Lexical editor states.
|
|
77
|
+
*
|
|
78
|
+
* Returns counts of added/removed characters, words, and lines.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const stats = computeDeltaStats(oldState, newState);
|
|
83
|
+
* console.log(`+${stats.chars.added} / -${stats.chars.removed} chars`);
|
|
84
|
+
* console.log(`+${stats.words.added} / -${stats.words.removed} words`);
|
|
85
|
+
* console.log(`+${stats.lines.added} / -${stats.lines.removed} lines`);
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
100
88
|
function computeDeltaStats(oldState, newState) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
89
|
+
const oldText = extractTextFromState(oldState);
|
|
90
|
+
const newText = extractTextFromState(newState);
|
|
91
|
+
const oldLines = countLines(oldState);
|
|
92
|
+
const newLines = countLines(newState);
|
|
93
|
+
const { added: addedText, removed: removedText } = computeCharDiff(oldText, newText);
|
|
94
|
+
return {
|
|
95
|
+
chars: {
|
|
96
|
+
added: Array.from(addedText).length,
|
|
97
|
+
removed: Array.from(removedText).length
|
|
98
|
+
},
|
|
99
|
+
words: {
|
|
100
|
+
added: countWords(addedText),
|
|
101
|
+
removed: countWords(removedText)
|
|
102
|
+
},
|
|
103
|
+
lines: {
|
|
104
|
+
added: Math.max(0, newLines - oldLines),
|
|
105
|
+
removed: Math.max(0, oldLines - newLines)
|
|
106
|
+
}
|
|
107
|
+
};
|
|
120
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Formats delta stats as a human-readable string.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* const stats = computeDeltaStats(oldState, newState);
|
|
115
|
+
* console.log(formatDeltaStats(stats));
|
|
116
|
+
* // "+91 lines, +2,122 words, +12,345 chars"
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
121
119
|
function formatDeltaStats(stats) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const wordsStr = formatDelta(stats.words.added, stats.words.removed, "words");
|
|
138
|
-
const charsStr = formatDelta(stats.chars.added, stats.chars.removed, "chars");
|
|
139
|
-
if (linesStr) parts.push(linesStr);
|
|
140
|
-
if (wordsStr) parts.push(wordsStr);
|
|
141
|
-
if (charsStr) parts.push(charsStr);
|
|
142
|
-
return parts.join(", ") || "No changes";
|
|
120
|
+
const parts = [];
|
|
121
|
+
const formatNumber = (n) => n.toLocaleString();
|
|
122
|
+
const formatDelta = (added, removed, unit) => {
|
|
123
|
+
if (added > 0 && removed > 0) return `+${formatNumber(added)}/-${formatNumber(removed)} ${unit}`;
|
|
124
|
+
if (added > 0) return `+${formatNumber(added)} ${unit}`;
|
|
125
|
+
if (removed > 0) return `-${formatNumber(removed)} ${unit}`;
|
|
126
|
+
return "";
|
|
127
|
+
};
|
|
128
|
+
const linesStr = formatDelta(stats.lines.added, stats.lines.removed, "lines");
|
|
129
|
+
const wordsStr = formatDelta(stats.words.added, stats.words.removed, "words");
|
|
130
|
+
const charsStr = formatDelta(stats.chars.added, stats.chars.removed, "chars");
|
|
131
|
+
if (linesStr) parts.push(linesStr);
|
|
132
|
+
if (wordsStr) parts.push(wordsStr);
|
|
133
|
+
if (charsStr) parts.push(charsStr);
|
|
134
|
+
return parts.join(", ") || "No changes";
|
|
143
135
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/compute-diff.ts
|
|
138
|
+
var CHAR_DIFF_MAX_MATRIX_CELLS = 5e4;
|
|
139
|
+
var DELETE_MARK_STYLE = "background-color: color-mix(in srgb, var(--rc-alert-caution) 22%, transparent); text-decoration: line-through;";
|
|
140
|
+
var INSERT_MARK_STYLE = "background-color: color-mix(in srgb, var(--rc-alert-tip) 22%, transparent);";
|
|
147
141
|
function getNodeTypeKey(node) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
142
|
+
const n = node;
|
|
143
|
+
if (n.type === "heading") return `heading:${n.tag}`;
|
|
144
|
+
if (n.type === "list") return `list:${n.listType}`;
|
|
145
|
+
return n.type || "unknown";
|
|
152
146
|
}
|
|
153
147
|
function nodesEqual(a, b) {
|
|
154
|
-
|
|
148
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
155
149
|
}
|
|
156
150
|
function isTextNode(node) {
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
const n = node;
|
|
152
|
+
return n.type === "text" && typeof n.text === "string";
|
|
159
153
|
}
|
|
160
154
|
function getChildren(node) {
|
|
161
|
-
|
|
162
|
-
|
|
155
|
+
const n = node;
|
|
156
|
+
return Array.isArray(n.children) ? n.children : null;
|
|
163
157
|
}
|
|
164
158
|
function appendStyle(baseStyle, extraStyle) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
const normalizedBase = typeof baseStyle === "string" ? baseStyle.trim() : "";
|
|
160
|
+
if (!normalizedBase) return extraStyle;
|
|
161
|
+
return `${normalizedBase}${normalizedBase.endsWith(";") ? "" : ";"} ${extraStyle}`;
|
|
168
162
|
}
|
|
169
163
|
function cloneTextNode(node, text, markKind) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
text,
|
|
179
|
-
style
|
|
180
|
-
};
|
|
181
|
-
return cloned;
|
|
164
|
+
let { style } = node;
|
|
165
|
+
if (markKind === "delete") style = appendStyle(style, DELETE_MARK_STYLE);
|
|
166
|
+
else if (markKind === "insert") style = appendStyle(style, INSERT_MARK_STYLE);
|
|
167
|
+
return {
|
|
168
|
+
...node,
|
|
169
|
+
text,
|
|
170
|
+
style
|
|
171
|
+
};
|
|
182
172
|
}
|
|
183
173
|
function cloneNodeWithChildren(node, children) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
174
|
+
return {
|
|
175
|
+
...node,
|
|
176
|
+
children
|
|
177
|
+
};
|
|
188
178
|
}
|
|
189
179
|
function decorateSubtree(node, kind) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!children) return node;
|
|
195
|
-
return cloneNodeWithChildren(
|
|
196
|
-
node,
|
|
197
|
-
children.map((child) => decorateSubtree(child, kind))
|
|
198
|
-
);
|
|
180
|
+
if (isTextNode(node)) return cloneTextNode(node, node.text, kind);
|
|
181
|
+
const children = getChildren(node);
|
|
182
|
+
if (!children) return node;
|
|
183
|
+
return cloneNodeWithChildren(node, children.map((child) => decorateSubtree(child, kind)));
|
|
199
184
|
}
|
|
200
185
|
function reverseText(value) {
|
|
201
|
-
|
|
186
|
+
return Array.from(value).reverse().join("");
|
|
202
187
|
}
|
|
203
188
|
function mergeTextOps(ops) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return merged;
|
|
189
|
+
const merged = [];
|
|
190
|
+
for (const op of ops) {
|
|
191
|
+
if (!op.text) continue;
|
|
192
|
+
const last = merged.at(-1);
|
|
193
|
+
if (last && last.kind === op.kind) last.text += op.text;
|
|
194
|
+
else merged.push({ ...op });
|
|
195
|
+
}
|
|
196
|
+
return merged;
|
|
215
197
|
}
|
|
216
198
|
function diffMiddleChars(oldChars, newChars) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return mergeTextOps(forwardOps);
|
|
199
|
+
const m = oldChars.length;
|
|
200
|
+
const n = newChars.length;
|
|
201
|
+
if (m === 0 && n === 0) return [];
|
|
202
|
+
if (m === 0) return [{
|
|
203
|
+
kind: "insert",
|
|
204
|
+
text: newChars.join("")
|
|
205
|
+
}];
|
|
206
|
+
if (n === 0) return [{
|
|
207
|
+
kind: "delete",
|
|
208
|
+
text: oldChars.join("")
|
|
209
|
+
}];
|
|
210
|
+
if (m * n > CHAR_DIFF_MAX_MATRIX_CELLS) return [{
|
|
211
|
+
kind: "delete",
|
|
212
|
+
text: oldChars.join("")
|
|
213
|
+
}, {
|
|
214
|
+
kind: "insert",
|
|
215
|
+
text: newChars.join("")
|
|
216
|
+
}];
|
|
217
|
+
const dp = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }).fill(0));
|
|
218
|
+
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) if (oldChars[i - 1] === newChars[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
219
|
+
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
220
|
+
const reversedOps = [];
|
|
221
|
+
let i = m;
|
|
222
|
+
let j = n;
|
|
223
|
+
while (i > 0 && j > 0) {
|
|
224
|
+
if (oldChars[i - 1] === newChars[j - 1]) {
|
|
225
|
+
const last = reversedOps.at(-1);
|
|
226
|
+
if (last && last.kind === "equal") last.text += oldChars[i - 1];
|
|
227
|
+
else reversedOps.push({
|
|
228
|
+
kind: "equal",
|
|
229
|
+
text: oldChars[i - 1]
|
|
230
|
+
});
|
|
231
|
+
i--;
|
|
232
|
+
j--;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
236
|
+
const last = reversedOps.at(-1);
|
|
237
|
+
if (last && last.kind === "delete") last.text += oldChars[i - 1];
|
|
238
|
+
else reversedOps.push({
|
|
239
|
+
kind: "delete",
|
|
240
|
+
text: oldChars[i - 1]
|
|
241
|
+
});
|
|
242
|
+
i--;
|
|
243
|
+
} else {
|
|
244
|
+
const last = reversedOps.at(-1);
|
|
245
|
+
if (last && last.kind === "insert") last.text += newChars[j - 1];
|
|
246
|
+
else reversedOps.push({
|
|
247
|
+
kind: "insert",
|
|
248
|
+
text: newChars[j - 1]
|
|
249
|
+
});
|
|
250
|
+
j--;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
while (i > 0) {
|
|
254
|
+
const last = reversedOps.at(-1);
|
|
255
|
+
if (last && last.kind === "delete") last.text += oldChars[i - 1];
|
|
256
|
+
else reversedOps.push({
|
|
257
|
+
kind: "delete",
|
|
258
|
+
text: oldChars[i - 1]
|
|
259
|
+
});
|
|
260
|
+
i--;
|
|
261
|
+
}
|
|
262
|
+
while (j > 0) {
|
|
263
|
+
const last = reversedOps.at(-1);
|
|
264
|
+
if (last && last.kind === "insert") last.text += newChars[j - 1];
|
|
265
|
+
else reversedOps.push({
|
|
266
|
+
kind: "insert",
|
|
267
|
+
text: newChars[j - 1]
|
|
268
|
+
});
|
|
269
|
+
j--;
|
|
270
|
+
}
|
|
271
|
+
return mergeTextOps(reversedOps.reverse().map((op) => ({
|
|
272
|
+
kind: op.kind,
|
|
273
|
+
text: reverseText(op.text)
|
|
274
|
+
})));
|
|
294
275
|
}
|
|
295
276
|
function diffTextByChar(oldText, newText) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
oldChars.slice(prefix, oldSuffix + 1),
|
|
318
|
-
newChars.slice(prefix, newSuffix + 1)
|
|
319
|
-
)
|
|
320
|
-
);
|
|
321
|
-
if (oldSuffix < oldChars.length - 1) {
|
|
322
|
-
ops.push({
|
|
323
|
-
kind: "equal",
|
|
324
|
-
text: oldChars.slice(oldSuffix + 1).join("")
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
return mergeTextOps(ops);
|
|
277
|
+
const oldChars = Array.from(oldText);
|
|
278
|
+
const newChars = Array.from(newText);
|
|
279
|
+
let prefix = 0;
|
|
280
|
+
while (prefix < oldChars.length && prefix < newChars.length && oldChars[prefix] === newChars[prefix]) prefix++;
|
|
281
|
+
let oldSuffix = oldChars.length - 1;
|
|
282
|
+
let newSuffix = newChars.length - 1;
|
|
283
|
+
while (oldSuffix >= prefix && newSuffix >= prefix && oldChars[oldSuffix] === newChars[newSuffix]) {
|
|
284
|
+
oldSuffix--;
|
|
285
|
+
newSuffix--;
|
|
286
|
+
}
|
|
287
|
+
const ops = [];
|
|
288
|
+
if (prefix > 0) ops.push({
|
|
289
|
+
kind: "equal",
|
|
290
|
+
text: oldChars.slice(0, prefix).join("")
|
|
291
|
+
});
|
|
292
|
+
ops.push(...diffMiddleChars(oldChars.slice(prefix, oldSuffix + 1), newChars.slice(prefix, newSuffix + 1)));
|
|
293
|
+
if (oldSuffix < oldChars.length - 1) ops.push({
|
|
294
|
+
kind: "equal",
|
|
295
|
+
text: oldChars.slice(oldSuffix + 1).join("")
|
|
296
|
+
});
|
|
297
|
+
return mergeTextOps(ops);
|
|
328
298
|
}
|
|
329
299
|
function splitTextNodeByCharDiff(oldNode, newNode) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
300
|
+
const ops = diffTextByChar(oldNode.text, newNode.text);
|
|
301
|
+
const oldNodes = [];
|
|
302
|
+
const newNodes = [];
|
|
303
|
+
let changed = false;
|
|
304
|
+
for (const op of ops) {
|
|
305
|
+
if (!op.text) continue;
|
|
306
|
+
if (op.kind === "equal") {
|
|
307
|
+
oldNodes.push(cloneTextNode(oldNode, op.text));
|
|
308
|
+
newNodes.push(cloneTextNode(newNode, op.text));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
changed = true;
|
|
312
|
+
if (op.kind === "delete") {
|
|
313
|
+
oldNodes.push(cloneTextNode(oldNode, op.text, "delete"));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
newNodes.push(cloneTextNode(newNode, op.text, "insert"));
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
oldNodes: oldNodes.length > 0 ? oldNodes : [oldNode],
|
|
320
|
+
newNodes: newNodes.length > 0 ? newNodes : [newNode],
|
|
321
|
+
changed
|
|
322
|
+
};
|
|
353
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* LCS-based structural alignment.
|
|
326
|
+
*
|
|
327
|
+
* Scoring:
|
|
328
|
+
* exact match → +2 (always align)
|
|
329
|
+
* type match → +1 (prefer align over skip)
|
|
330
|
+
* type mismatch → not considered (never align different types)
|
|
331
|
+
*/
|
|
354
332
|
function alignNodes(oldNodes, newNodes) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
333
|
+
const m = oldNodes.length;
|
|
334
|
+
const n = newNodes.length;
|
|
335
|
+
const dp = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }).fill(0));
|
|
336
|
+
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) {
|
|
337
|
+
const skip = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
338
|
+
if (getNodeTypeKey(oldNodes[i - 1]) === getNodeTypeKey(newNodes[j - 1])) {
|
|
339
|
+
const score = nodesEqual(oldNodes[i - 1], newNodes[j - 1]) ? 2 : 1;
|
|
340
|
+
dp[i][j] = Math.max(skip, dp[i - 1][j - 1] + score);
|
|
341
|
+
} else dp[i][j] = skip;
|
|
342
|
+
}
|
|
343
|
+
const ops = [];
|
|
344
|
+
let i = m;
|
|
345
|
+
let j = n;
|
|
346
|
+
while (i > 0 && j > 0) {
|
|
347
|
+
if (getNodeTypeKey(oldNodes[i - 1]) === getNodeTypeKey(newNodes[j - 1])) {
|
|
348
|
+
const exact = nodesEqual(oldNodes[i - 1], newNodes[j - 1]);
|
|
349
|
+
const score = exact ? 2 : 1;
|
|
350
|
+
if (dp[i][j] === dp[i - 1][j - 1] + score) {
|
|
351
|
+
ops.push(exact ? {
|
|
352
|
+
kind: "equal",
|
|
353
|
+
node: oldNodes[i - 1]
|
|
354
|
+
} : {
|
|
355
|
+
kind: "modify",
|
|
356
|
+
oldNode: oldNodes[i - 1],
|
|
357
|
+
newNode: newNodes[j - 1]
|
|
358
|
+
});
|
|
359
|
+
i--;
|
|
360
|
+
j--;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
365
|
+
ops.push({
|
|
366
|
+
kind: "delete",
|
|
367
|
+
node: oldNodes[i - 1]
|
|
368
|
+
});
|
|
369
|
+
i--;
|
|
370
|
+
} else {
|
|
371
|
+
ops.push({
|
|
372
|
+
kind: "insert",
|
|
373
|
+
node: newNodes[j - 1]
|
|
374
|
+
});
|
|
375
|
+
j--;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
while (i > 0) ops.push({
|
|
379
|
+
kind: "delete",
|
|
380
|
+
node: oldNodes[--i]
|
|
381
|
+
});
|
|
382
|
+
while (j > 0) ops.push({
|
|
383
|
+
kind: "insert",
|
|
384
|
+
node: newNodes[--j]
|
|
385
|
+
});
|
|
386
|
+
ops.reverse();
|
|
387
|
+
return ops;
|
|
409
388
|
}
|
|
410
389
|
function diffChildrenInline(oldChildren, newChildren) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
return {
|
|
453
|
-
oldChildren: nextOldChildren,
|
|
454
|
-
newChildren: nextNewChildren,
|
|
455
|
-
changed
|
|
456
|
-
};
|
|
390
|
+
const ops = alignNodes(oldChildren, newChildren);
|
|
391
|
+
const nextOldChildren = [];
|
|
392
|
+
const nextNewChildren = [];
|
|
393
|
+
let changed = false;
|
|
394
|
+
for (const op of ops) switch (op.kind) {
|
|
395
|
+
case "equal":
|
|
396
|
+
nextOldChildren.push(op.node);
|
|
397
|
+
nextNewChildren.push(op.node);
|
|
398
|
+
break;
|
|
399
|
+
case "delete":
|
|
400
|
+
changed = true;
|
|
401
|
+
nextOldChildren.push(decorateSubtree(op.node, "delete"));
|
|
402
|
+
break;
|
|
403
|
+
case "insert":
|
|
404
|
+
changed = true;
|
|
405
|
+
nextNewChildren.push(decorateSubtree(op.node, "insert"));
|
|
406
|
+
break;
|
|
407
|
+
case "modify": {
|
|
408
|
+
changed = true;
|
|
409
|
+
if (isTextNode(op.oldNode) && isTextNode(op.newNode)) {
|
|
410
|
+
const textDiff = splitTextNodeByCharDiff(op.oldNode, op.newNode);
|
|
411
|
+
nextOldChildren.push(...textDiff.oldNodes);
|
|
412
|
+
nextNewChildren.push(...textDiff.newNodes);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
const nested = diffNodeInline(op.oldNode, op.newNode);
|
|
416
|
+
if (nested) {
|
|
417
|
+
nextOldChildren.push(nested.oldNode);
|
|
418
|
+
nextNewChildren.push(nested.newNode);
|
|
419
|
+
} else {
|
|
420
|
+
nextOldChildren.push(decorateSubtree(op.oldNode, "delete"));
|
|
421
|
+
nextNewChildren.push(decorateSubtree(op.newNode, "insert"));
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
oldChildren: nextOldChildren,
|
|
428
|
+
newChildren: nextNewChildren,
|
|
429
|
+
changed
|
|
430
|
+
};
|
|
457
431
|
}
|
|
458
432
|
function diffNodeInline(oldNode, newNode) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
433
|
+
if (getNodeTypeKey(oldNode) !== getNodeTypeKey(newNode)) return null;
|
|
434
|
+
if (nodesEqual(oldNode, newNode)) return {
|
|
435
|
+
oldNode,
|
|
436
|
+
newNode,
|
|
437
|
+
changed: false
|
|
438
|
+
};
|
|
439
|
+
const oldChildren = getChildren(oldNode);
|
|
440
|
+
const newChildren = getChildren(newNode);
|
|
441
|
+
if (!oldChildren || !newChildren) return null;
|
|
442
|
+
const childDiff = diffChildrenInline(oldChildren, newChildren);
|
|
443
|
+
if (!childDiff.changed) return {
|
|
444
|
+
oldNode,
|
|
445
|
+
newNode,
|
|
446
|
+
changed: false
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
oldNode: cloneNodeWithChildren(oldNode, childDiff.oldChildren),
|
|
450
|
+
newNode: cloneNodeWithChildren(newNode, childDiff.newChildren),
|
|
451
|
+
changed: true
|
|
452
|
+
};
|
|
475
453
|
}
|
|
476
454
|
function diffModifiedNode(oldNode, newNode) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
455
|
+
const nested = diffNodeInline(oldNode, newNode);
|
|
456
|
+
if (nested) return {
|
|
457
|
+
oldNode: nested.oldNode,
|
|
458
|
+
newNode: nested.newNode
|
|
459
|
+
};
|
|
460
|
+
return {
|
|
461
|
+
oldNode: decorateSubtree(oldNode, "delete"),
|
|
462
|
+
newNode: decorateSubtree(newNode, "insert")
|
|
463
|
+
};
|
|
485
464
|
}
|
|
486
465
|
function mergeTextDiffNodes(oldNode, newNode) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
merged.push(cloneTextNode(newNode, op.text, "insert"));
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
return merged;
|
|
466
|
+
const ops = diffTextByChar(oldNode.text, newNode.text);
|
|
467
|
+
const merged = [];
|
|
468
|
+
for (const op of ops) {
|
|
469
|
+
if (!op.text) continue;
|
|
470
|
+
if (op.kind === "equal") merged.push(cloneTextNode(newNode, op.text));
|
|
471
|
+
else if (op.kind === "delete") merged.push(cloneTextNode(oldNode, op.text, "delete"));
|
|
472
|
+
else merged.push(cloneTextNode(newNode, op.text, "insert"));
|
|
473
|
+
}
|
|
474
|
+
return merged;
|
|
500
475
|
}
|
|
501
476
|
function diffChildrenMerged(oldChildren, newChildren) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
break;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return merged;
|
|
477
|
+
const ops = alignNodes(oldChildren, newChildren);
|
|
478
|
+
const merged = [];
|
|
479
|
+
for (const op of ops) switch (op.kind) {
|
|
480
|
+
case "equal":
|
|
481
|
+
merged.push(op.node);
|
|
482
|
+
break;
|
|
483
|
+
case "delete":
|
|
484
|
+
merged.push(decorateSubtree(op.node, "delete"));
|
|
485
|
+
break;
|
|
486
|
+
case "insert":
|
|
487
|
+
merged.push(decorateSubtree(op.node, "insert"));
|
|
488
|
+
break;
|
|
489
|
+
case "modify": {
|
|
490
|
+
if (isTextNode(op.oldNode) && isTextNode(op.newNode)) {
|
|
491
|
+
merged.push(...mergeTextDiffNodes(op.oldNode, op.newNode));
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
const nestedOld = getChildren(op.oldNode);
|
|
495
|
+
const nestedNew = getChildren(op.newNode);
|
|
496
|
+
if (nestedOld && nestedNew) merged.push(cloneNodeWithChildren(op.newNode, diffChildrenMerged(nestedOld, nestedNew)));
|
|
497
|
+
else {
|
|
498
|
+
merged.push(decorateSubtree(op.oldNode, "delete"));
|
|
499
|
+
merged.push(decorateSubtree(op.newNode, "insert"));
|
|
500
|
+
}
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return merged;
|
|
536
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* Produces a **single** merged node with both deletions (strikethrough) and
|
|
508
|
+
* insertions (green highlight) interleaved inline – like VS Code's inline diff.
|
|
509
|
+
*/
|
|
537
510
|
function diffMergedNode(oldNode, newNode) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const oldChildren = getChildren(oldNode);
|
|
545
|
-
const newChildren = getChildren(newNode);
|
|
546
|
-
if (!oldChildren || !newChildren) {
|
|
547
|
-
return decorateSubtree(newNode, "insert");
|
|
548
|
-
}
|
|
549
|
-
return cloneNodeWithChildren(newNode, diffChildrenMerged(oldChildren, newChildren));
|
|
511
|
+
if (getNodeTypeKey(oldNode) !== getNodeTypeKey(newNode)) return decorateSubtree(newNode, "insert");
|
|
512
|
+
if (nodesEqual(oldNode, newNode)) return newNode;
|
|
513
|
+
const oldChildren = getChildren(oldNode);
|
|
514
|
+
const newChildren = getChildren(newNode);
|
|
515
|
+
if (!oldChildren || !newChildren) return decorateSubtree(newNode, "insert");
|
|
516
|
+
return cloneNodeWithChildren(newNode, diffChildrenMerged(oldChildren, newChildren));
|
|
550
517
|
}
|
|
551
518
|
function computeDiff(oldState, newState) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
591
|
-
return hunks;
|
|
519
|
+
const ops = alignNodes(oldState.root.children, newState.root.children);
|
|
520
|
+
const hunks = [];
|
|
521
|
+
for (const op of ops) switch (op.kind) {
|
|
522
|
+
case "equal": {
|
|
523
|
+
const last = hunks.at(-1);
|
|
524
|
+
if (last && last.type === "equal") last.nodes.push(op.node);
|
|
525
|
+
else hunks.push({
|
|
526
|
+
type: "equal",
|
|
527
|
+
nodes: [op.node]
|
|
528
|
+
});
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
case "modify":
|
|
532
|
+
{
|
|
533
|
+
const inline = diffModifiedNode(op.oldNode, op.newNode);
|
|
534
|
+
hunks.push({
|
|
535
|
+
type: "delete",
|
|
536
|
+
nodes: [inline.oldNode]
|
|
537
|
+
}, {
|
|
538
|
+
type: "insert",
|
|
539
|
+
nodes: [inline.newNode]
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
case "delete":
|
|
544
|
+
hunks.push({
|
|
545
|
+
type: "delete",
|
|
546
|
+
nodes: [decorateSubtree(op.node, "delete")]
|
|
547
|
+
});
|
|
548
|
+
break;
|
|
549
|
+
case "insert":
|
|
550
|
+
hunks.push({
|
|
551
|
+
type: "insert",
|
|
552
|
+
nodes: [decorateSubtree(op.node, "insert")]
|
|
553
|
+
});
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
return hunks;
|
|
592
557
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
computeDiff,
|
|
596
|
-
decorateSubtree,
|
|
597
|
-
diffMergedNode,
|
|
598
|
-
diffModifiedNode,
|
|
599
|
-
formatDeltaStats
|
|
600
|
-
};
|
|
558
|
+
//#endregion
|
|
559
|
+
export { computeDeltaStats, computeDiff, decorateSubtree, diffMergedNode, diffModifiedNode, formatDeltaStats };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haklex/rich-diff-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Lexical diff engine — character-level and structural diff with node decoration",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,15 +19,14 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
22
|
-
"dependencies": {},
|
|
23
22
|
"devDependencies": {
|
|
24
|
-
"lexical": "^0.
|
|
23
|
+
"lexical": "^0.44.0",
|
|
25
24
|
"typescript": "^5.9.3",
|
|
26
|
-
"vite": "^
|
|
25
|
+
"vite": "^8.0.10",
|
|
27
26
|
"vite-plugin-dts": "^4.5.4"
|
|
28
27
|
},
|
|
29
28
|
"peerDependencies": {
|
|
30
|
-
"lexical": "^0.
|
|
29
|
+
"lexical": "^0.44.0"
|
|
31
30
|
},
|
|
32
31
|
"publishConfig": {
|
|
33
32
|
"access": "public"
|