@haklex/rich-diff-core 0.1.1 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.mjs +503 -544
  2. 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
- const n = node;
3
- if (n.type === "text" && typeof n.text === "string") {
4
- return n.text;
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
- return state.root.children.map(extractText).join("\n");
9
+ return state.root.children.map(extractText).join("\n");
13
10
  }
14
11
  function countWords(text) {
15
- if (!text.trim()) return 0;
16
- const cjkPattern = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g;
17
- const cjkMatches = text.match(cjkPattern);
18
- const cjkCount = cjkMatches ? cjkMatches.length : 0;
19
- const nonCjkText = text.replaceAll(cjkPattern, " ");
20
- const wordPattern = /[\p{L}\p{N}]+/gu;
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
- return state.root.children.length;
20
+ return state.root.children.length;
27
21
  }
28
22
  function computeCharDiff(oldText, newText) {
29
- const oldChars = Array.from(oldText);
30
- const newChars = Array.from(newText);
31
- const m = oldChars.length;
32
- const n = newChars.length;
33
- let prefix = 0;
34
- while (prefix < m && prefix < n && oldChars[prefix] === newChars[prefix]) {
35
- prefix++;
36
- }
37
- let oldSuffix = m - 1;
38
- let newSuffix = n - 1;
39
- while (oldSuffix >= prefix && newSuffix >= prefix && oldChars[oldSuffix] === newChars[newSuffix]) {
40
- oldSuffix--;
41
- newSuffix--;
42
- }
43
- const oldMiddle = oldChars.slice(prefix, oldSuffix + 1);
44
- const newMiddle = newChars.slice(prefix, newSuffix + 1);
45
- const MAX_MATRIX_CELLS = 1e5;
46
- if (oldMiddle.length * newMiddle.length > MAX_MATRIX_CELLS) {
47
- return {
48
- removed: oldMiddle.join(""),
49
- added: newMiddle.join("")
50
- };
51
- }
52
- if (oldMiddle.length === 0) {
53
- return { added: newMiddle.join(""), removed: "" };
54
- }
55
- if (newMiddle.length === 0) {
56
- return { added: "", removed: oldMiddle.join("") };
57
- }
58
- const om = oldMiddle.length;
59
- const nm = newMiddle.length;
60
- const dp = Array.from(
61
- { length: om + 1 },
62
- () => Array.from({ length: nm + 1 }).fill(0)
63
- );
64
- for (let i2 = 1; i2 <= om; i2++) {
65
- for (let j2 = 1; j2 <= nm; j2++) {
66
- if (oldMiddle[i2 - 1] === newMiddle[j2 - 1]) {
67
- dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
68
- } else {
69
- dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
70
- }
71
- }
72
- }
73
- const removedChars = [];
74
- const addedChars = [];
75
- let i = om;
76
- let j = nm;
77
- while (i > 0 && j > 0) {
78
- if (oldMiddle[i - 1] === newMiddle[j - 1]) {
79
- i--;
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
- const oldText = extractTextFromState(oldState);
102
- const newText = extractTextFromState(newState);
103
- const oldLines = countLines(oldState);
104
- const newLines = countLines(newState);
105
- const { added: addedText, removed: removedText } = computeCharDiff(oldText, newText);
106
- return {
107
- chars: {
108
- added: Array.from(addedText).length,
109
- removed: Array.from(removedText).length
110
- },
111
- words: {
112
- added: countWords(addedText),
113
- removed: countWords(removedText)
114
- },
115
- lines: {
116
- added: Math.max(0, newLines - oldLines),
117
- removed: Math.max(0, oldLines - newLines)
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
- const parts = [];
123
- const formatNumber = (n) => n.toLocaleString();
124
- const formatDelta = (added, removed, unit) => {
125
- if (added > 0 && removed > 0) {
126
- return `+${formatNumber(added)}/-${formatNumber(removed)} ${unit}`;
127
- }
128
- if (added > 0) {
129
- return `+${formatNumber(added)} ${unit}`;
130
- }
131
- if (removed > 0) {
132
- return `-${formatNumber(removed)} ${unit}`;
133
- }
134
- return "";
135
- };
136
- const linesStr = formatDelta(stats.lines.added, stats.lines.removed, "lines");
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
- const CHAR_DIFF_MAX_MATRIX_CELLS = 5e4;
145
- const DELETE_MARK_STYLE = "background-color: color-mix(in srgb, var(--rc-alert-caution) 22%, transparent); text-decoration: line-through;";
146
- const INSERT_MARK_STYLE = "background-color: color-mix(in srgb, var(--rc-alert-tip) 22%, transparent);";
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
- const n = node;
149
- if (n.type === "heading") return `heading:${n.tag}`;
150
- if (n.type === "list") return `list:${n.listType}`;
151
- return n.type || "unknown";
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
- return JSON.stringify(a) === JSON.stringify(b);
148
+ return JSON.stringify(a) === JSON.stringify(b);
155
149
  }
156
150
  function isTextNode(node) {
157
- const n = node;
158
- return n.type === "text" && typeof n.text === "string";
151
+ const n = node;
152
+ return n.type === "text" && typeof n.text === "string";
159
153
  }
160
154
  function getChildren(node) {
161
- const n = node;
162
- return Array.isArray(n.children) ? n.children : null;
155
+ const n = node;
156
+ return Array.isArray(n.children) ? n.children : null;
163
157
  }
164
158
  function appendStyle(baseStyle, extraStyle) {
165
- const normalizedBase = typeof baseStyle === "string" ? baseStyle.trim() : "";
166
- if (!normalizedBase) return extraStyle;
167
- return `${normalizedBase}${normalizedBase.endsWith(";") ? "" : ";"} ${extraStyle}`;
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
- let { style } = node;
171
- if (markKind === "delete") {
172
- style = appendStyle(style, DELETE_MARK_STYLE);
173
- } else if (markKind === "insert") {
174
- style = appendStyle(style, INSERT_MARK_STYLE);
175
- }
176
- const cloned = {
177
- ...node,
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
- return {
185
- ...node,
186
- children
187
- };
174
+ return {
175
+ ...node,
176
+ children
177
+ };
188
178
  }
189
179
  function decorateSubtree(node, kind) {
190
- if (isTextNode(node)) {
191
- return cloneTextNode(node, node.text, kind);
192
- }
193
- const children = getChildren(node);
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
- return Array.from(value).reverse().join("");
186
+ return Array.from(value).reverse().join("");
202
187
  }
203
188
  function mergeTextOps(ops) {
204
- const merged = [];
205
- for (const op of ops) {
206
- if (!op.text) continue;
207
- const last = merged.at(-1);
208
- if (last && last.kind === op.kind) {
209
- last.text += op.text;
210
- } else {
211
- merged.push({ ...op });
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
- const m = oldChars.length;
218
- const n = newChars.length;
219
- if (m === 0 && n === 0) return [];
220
- if (m === 0) return [{ kind: "insert", text: newChars.join("") }];
221
- if (n === 0) return [{ kind: "delete", text: oldChars.join("") }];
222
- if (m * n > CHAR_DIFF_MAX_MATRIX_CELLS) {
223
- return [
224
- { kind: "delete", text: oldChars.join("") },
225
- { kind: "insert", text: newChars.join("") }
226
- ];
227
- }
228
- const dp = Array.from(
229
- { length: m + 1 },
230
- () => Array.from({ length: n + 1 }).fill(0)
231
- );
232
- for (let i2 = 1; i2 <= m; i2++) {
233
- for (let j2 = 1; j2 <= n; j2++) {
234
- if (oldChars[i2 - 1] === newChars[j2 - 1]) {
235
- dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
236
- } else {
237
- dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
238
- }
239
- }
240
- }
241
- const reversedOps = [];
242
- let i = m;
243
- let j = n;
244
- while (i > 0 && j > 0) {
245
- if (oldChars[i - 1] === newChars[j - 1]) {
246
- const last = reversedOps.at(-1);
247
- if (last && last.kind === "equal") {
248
- last.text += oldChars[i - 1];
249
- } else {
250
- reversedOps.push({ kind: "equal", text: oldChars[i - 1] });
251
- }
252
- i--;
253
- j--;
254
- continue;
255
- }
256
- if (dp[i - 1][j] >= dp[i][j - 1]) {
257
- const last = reversedOps.at(-1);
258
- if (last && last.kind === "delete") {
259
- last.text += oldChars[i - 1];
260
- } else {
261
- reversedOps.push({ kind: "delete", text: oldChars[i - 1] });
262
- }
263
- i--;
264
- } else {
265
- const last = reversedOps.at(-1);
266
- if (last && last.kind === "insert") {
267
- last.text += newChars[j - 1];
268
- } else {
269
- reversedOps.push({ kind: "insert", text: newChars[j - 1] });
270
- }
271
- j--;
272
- }
273
- }
274
- while (i > 0) {
275
- const last = reversedOps.at(-1);
276
- if (last && last.kind === "delete") {
277
- last.text += oldChars[i - 1];
278
- } else {
279
- reversedOps.push({ kind: "delete", text: oldChars[i - 1] });
280
- }
281
- i--;
282
- }
283
- while (j > 0) {
284
- const last = reversedOps.at(-1);
285
- if (last && last.kind === "insert") {
286
- last.text += newChars[j - 1];
287
- } else {
288
- reversedOps.push({ kind: "insert", text: newChars[j - 1] });
289
- }
290
- j--;
291
- }
292
- const forwardOps = reversedOps.reverse().map((op) => ({ kind: op.kind, text: reverseText(op.text) }));
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
- const oldChars = Array.from(oldText);
297
- const newChars = Array.from(newText);
298
- let prefix = 0;
299
- while (prefix < oldChars.length && prefix < newChars.length && oldChars[prefix] === newChars[prefix]) {
300
- prefix++;
301
- }
302
- let oldSuffix = oldChars.length - 1;
303
- let newSuffix = newChars.length - 1;
304
- while (oldSuffix >= prefix && newSuffix >= prefix && oldChars[oldSuffix] === newChars[newSuffix]) {
305
- oldSuffix--;
306
- newSuffix--;
307
- }
308
- const ops = [];
309
- if (prefix > 0) {
310
- ops.push({
311
- kind: "equal",
312
- text: oldChars.slice(0, prefix).join("")
313
- });
314
- }
315
- ops.push(
316
- ...diffMiddleChars(
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
- const ops = diffTextByChar(oldNode.text, newNode.text);
331
- const oldNodes = [];
332
- const newNodes = [];
333
- let changed = false;
334
- for (const op of ops) {
335
- if (!op.text) continue;
336
- if (op.kind === "equal") {
337
- oldNodes.push(cloneTextNode(oldNode, op.text));
338
- newNodes.push(cloneTextNode(newNode, op.text));
339
- continue;
340
- }
341
- changed = true;
342
- if (op.kind === "delete") {
343
- oldNodes.push(cloneTextNode(oldNode, op.text, "delete"));
344
- continue;
345
- }
346
- newNodes.push(cloneTextNode(newNode, op.text, "insert"));
347
- }
348
- return {
349
- oldNodes: oldNodes.length > 0 ? oldNodes : [oldNode],
350
- newNodes: newNodes.length > 0 ? newNodes : [newNode],
351
- changed
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
- const m = oldNodes.length;
356
- const n = newNodes.length;
357
- const dp = Array.from(
358
- { length: m + 1 },
359
- () => Array.from({ length: n + 1 }).fill(0)
360
- );
361
- for (let i2 = 1; i2 <= m; i2++) {
362
- for (let j2 = 1; j2 <= n; j2++) {
363
- const skip = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
364
- if (getNodeTypeKey(oldNodes[i2 - 1]) === getNodeTypeKey(newNodes[j2 - 1])) {
365
- const score = nodesEqual(oldNodes[i2 - 1], newNodes[j2 - 1]) ? 2 : 1;
366
- dp[i2][j2] = Math.max(skip, dp[i2 - 1][j2 - 1] + score);
367
- } else {
368
- dp[i2][j2] = skip;
369
- }
370
- }
371
- }
372
- const ops = [];
373
- let i = m;
374
- let j = n;
375
- while (i > 0 && j > 0) {
376
- const typeMatch = getNodeTypeKey(oldNodes[i - 1]) === getNodeTypeKey(newNodes[j - 1]);
377
- if (typeMatch) {
378
- const exact = nodesEqual(oldNodes[i - 1], newNodes[j - 1]);
379
- const score = exact ? 2 : 1;
380
- if (dp[i][j] === dp[i - 1][j - 1] + score) {
381
- ops.push(
382
- exact ? { kind: "equal", node: oldNodes[i - 1] } : {
383
- kind: "modify",
384
- oldNode: oldNodes[i - 1],
385
- newNode: newNodes[j - 1]
386
- }
387
- );
388
- i--;
389
- j--;
390
- continue;
391
- }
392
- }
393
- if (dp[i - 1][j] >= dp[i][j - 1]) {
394
- ops.push({ kind: "delete", node: oldNodes[i - 1] });
395
- i--;
396
- } else {
397
- ops.push({ kind: "insert", node: newNodes[j - 1] });
398
- j--;
399
- }
400
- }
401
- while (i > 0) {
402
- ops.push({ kind: "delete", node: oldNodes[--i] });
403
- }
404
- while (j > 0) {
405
- ops.push({ kind: "insert", node: newNodes[--j] });
406
- }
407
- ops.reverse();
408
- return ops;
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
- const ops = alignNodes(oldChildren, newChildren);
412
- const nextOldChildren = [];
413
- const nextNewChildren = [];
414
- let changed = false;
415
- for (const op of ops) {
416
- switch (op.kind) {
417
- case "equal": {
418
- nextOldChildren.push(op.node);
419
- nextNewChildren.push(op.node);
420
- break;
421
- }
422
- case "delete": {
423
- changed = true;
424
- nextOldChildren.push(decorateSubtree(op.node, "delete"));
425
- break;
426
- }
427
- case "insert": {
428
- changed = true;
429
- nextNewChildren.push(decorateSubtree(op.node, "insert"));
430
- break;
431
- }
432
- case "modify": {
433
- changed = true;
434
- if (isTextNode(op.oldNode) && isTextNode(op.newNode)) {
435
- const textDiff = splitTextNodeByCharDiff(op.oldNode, op.newNode);
436
- nextOldChildren.push(...textDiff.oldNodes);
437
- nextNewChildren.push(...textDiff.newNodes);
438
- break;
439
- }
440
- const nested = diffNodeInline(op.oldNode, op.newNode);
441
- if (nested) {
442
- nextOldChildren.push(nested.oldNode);
443
- nextNewChildren.push(nested.newNode);
444
- } else {
445
- nextOldChildren.push(decorateSubtree(op.oldNode, "delete"));
446
- nextNewChildren.push(decorateSubtree(op.newNode, "insert"));
447
- }
448
- break;
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
- if (getNodeTypeKey(oldNode) !== getNodeTypeKey(newNode)) return null;
460
- if (nodesEqual(oldNode, newNode)) {
461
- return { oldNode, newNode, changed: false };
462
- }
463
- const oldChildren = getChildren(oldNode);
464
- const newChildren = getChildren(newNode);
465
- if (!oldChildren || !newChildren) return null;
466
- const childDiff = diffChildrenInline(oldChildren, newChildren);
467
- if (!childDiff.changed) {
468
- return { oldNode, newNode, changed: false };
469
- }
470
- return {
471
- oldNode: cloneNodeWithChildren(oldNode, childDiff.oldChildren),
472
- newNode: cloneNodeWithChildren(newNode, childDiff.newChildren),
473
- changed: true
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
- const nested = diffNodeInline(oldNode, newNode);
478
- if (nested) {
479
- return { oldNode: nested.oldNode, newNode: nested.newNode };
480
- }
481
- return {
482
- oldNode: decorateSubtree(oldNode, "delete"),
483
- newNode: decorateSubtree(newNode, "insert")
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
- const ops = diffTextByChar(oldNode.text, newNode.text);
488
- const merged = [];
489
- for (const op of ops) {
490
- if (!op.text) continue;
491
- if (op.kind === "equal") {
492
- merged.push(cloneTextNode(newNode, op.text));
493
- } else if (op.kind === "delete") {
494
- merged.push(cloneTextNode(oldNode, op.text, "delete"));
495
- } else {
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
- const ops = alignNodes(oldChildren, newChildren);
503
- const merged = [];
504
- for (const op of ops) {
505
- switch (op.kind) {
506
- case "equal": {
507
- merged.push(op.node);
508
- break;
509
- }
510
- case "delete": {
511
- merged.push(decorateSubtree(op.node, "delete"));
512
- break;
513
- }
514
- case "insert": {
515
- merged.push(decorateSubtree(op.node, "insert"));
516
- break;
517
- }
518
- case "modify": {
519
- if (isTextNode(op.oldNode) && isTextNode(op.newNode)) {
520
- merged.push(...mergeTextDiffNodes(op.oldNode, op.newNode));
521
- break;
522
- }
523
- const nestedOld = getChildren(op.oldNode);
524
- const nestedNew = getChildren(op.newNode);
525
- if (nestedOld && nestedNew) {
526
- merged.push(cloneNodeWithChildren(op.newNode, diffChildrenMerged(nestedOld, nestedNew)));
527
- } else {
528
- merged.push(decorateSubtree(op.oldNode, "delete"));
529
- merged.push(decorateSubtree(op.newNode, "insert"));
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
- if (getNodeTypeKey(oldNode) !== getNodeTypeKey(newNode)) {
539
- return decorateSubtree(newNode, "insert");
540
- }
541
- if (nodesEqual(oldNode, newNode)) {
542
- return newNode;
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
- const ops = alignNodes(oldState.root.children, newState.root.children);
553
- const hunks = [];
554
- for (const op of ops) {
555
- switch (op.kind) {
556
- case "equal": {
557
- const last = hunks.at(-1);
558
- if (last && last.type === "equal") {
559
- last.nodes.push(op.node);
560
- } else {
561
- hunks.push({ type: "equal", nodes: [op.node] });
562
- }
563
- break;
564
- }
565
- case "modify": {
566
- {
567
- const inline = diffModifiedNode(op.oldNode, op.newNode);
568
- hunks.push(
569
- { type: "delete", nodes: [inline.oldNode] },
570
- { type: "insert", nodes: [inline.newNode] }
571
- );
572
- }
573
- break;
574
- }
575
- case "delete": {
576
- hunks.push({
577
- type: "delete",
578
- nodes: [decorateSubtree(op.node, "delete")]
579
- });
580
- break;
581
- }
582
- case "insert": {
583
- hunks.push({
584
- type: "insert",
585
- nodes: [decorateSubtree(op.node, "insert")]
586
- });
587
- break;
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
- export {
594
- computeDeltaStats,
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.1.1",
3
+ "version": "0.2.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.43.0",
23
+ "lexical": "^0.44.0",
25
24
  "typescript": "^5.9.3",
26
- "vite": "^7.3.1",
25
+ "vite": "^8.0.10",
27
26
  "vite-plugin-dts": "^4.5.4"
28
27
  },
29
28
  "peerDependencies": {
30
- "lexical": "^0.43.0"
29
+ "lexical": "^0.44.0"
31
30
  },
32
31
  "publishConfig": {
33
32
  "access": "public"