@haklex/rich-diff-core 0.0.94

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.
@@ -0,0 +1,19 @@
1
+ import { SerializedEditorState, SerializedLexicalNode } from 'lexical';
2
+ export type DiffOpType = 'equal' | 'insert' | 'delete';
3
+ export interface DiffHunk {
4
+ nodes: SerializedLexicalNode[];
5
+ type: DiffOpType;
6
+ }
7
+ export type MarkKind = 'insert' | 'delete';
8
+ export declare function decorateSubtree(node: SerializedLexicalNode, kind: MarkKind): SerializedLexicalNode;
9
+ export declare function diffModifiedNode(oldNode: SerializedLexicalNode, newNode: SerializedLexicalNode): {
10
+ oldNode: SerializedLexicalNode;
11
+ newNode: SerializedLexicalNode;
12
+ };
13
+ /**
14
+ * Produces a **single** merged node with both deletions (strikethrough) and
15
+ * insertions (green highlight) interleaved inline – like VS Code's inline diff.
16
+ */
17
+ export declare function diffMergedNode(oldNode: SerializedLexicalNode, newNode: SerializedLexicalNode): SerializedLexicalNode;
18
+ export declare function computeDiff(oldState: SerializedEditorState, newState: SerializedEditorState): DiffHunk[];
19
+ //# sourceMappingURL=compute-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compute-diff.d.ts","sourceRoot":"","sources":["../src/compute-diff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAE5E,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,IAAI,EAAE,UAAU,CAAC;CAClB;AAUD,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AA+E3C,wBAAgB,eAAe,CAC7B,IAAI,EAAE,qBAAqB,EAC3B,IAAI,EAAE,QAAQ,GACb,qBAAqB,CAYvB;AAyXD,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,qBAAqB,GAC7B;IACD,OAAO,EAAE,qBAAqB,CAAC;IAC/B,OAAO,EAAE,qBAAqB,CAAC;CAChC,CAUA;AAkED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAgBvB;AAED,wBAAgB,WAAW,CACzB,QAAQ,EAAE,qBAAqB,EAC/B,QAAQ,EAAE,qBAAqB,GAC9B,QAAQ,EAAE,CA6CZ"}
@@ -0,0 +1,3 @@
1
+ export type { DiffHunk, DiffOpType, MarkKind } from './compute-diff';
2
+ export { computeDiff, decorateSubtree, diffMergedNode, diffModifiedNode } from './compute-diff';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.mjs ADDED
@@ -0,0 +1,455 @@
1
+ const CHAR_DIFF_MAX_MATRIX_CELLS = 5e4;
2
+ const DELETE_MARK_STYLE = "background-color: color-mix(in srgb, var(--rc-alert-caution) 22%, transparent); text-decoration: line-through;";
3
+ const INSERT_MARK_STYLE = "background-color: color-mix(in srgb, var(--rc-alert-tip) 22%, transparent);";
4
+ function getNodeTypeKey(node) {
5
+ const n = node;
6
+ if (n.type === "heading") return `heading:${n.tag}`;
7
+ if (n.type === "list") return `list:${n.listType}`;
8
+ return n.type || "unknown";
9
+ }
10
+ function nodesEqual(a, b) {
11
+ return JSON.stringify(a) === JSON.stringify(b);
12
+ }
13
+ function isTextNode(node) {
14
+ const n = node;
15
+ return n.type === "text" && typeof n.text === "string";
16
+ }
17
+ function getChildren(node) {
18
+ const n = node;
19
+ return Array.isArray(n.children) ? n.children : null;
20
+ }
21
+ function appendStyle(baseStyle, extraStyle) {
22
+ const normalizedBase = typeof baseStyle === "string" ? baseStyle.trim() : "";
23
+ if (!normalizedBase) return extraStyle;
24
+ return `${normalizedBase}${normalizedBase.endsWith(";") ? "" : ";"} ${extraStyle}`;
25
+ }
26
+ function cloneTextNode(node, text, markKind) {
27
+ let { style } = node;
28
+ if (markKind === "delete") {
29
+ style = appendStyle(style, DELETE_MARK_STYLE);
30
+ } else if (markKind === "insert") {
31
+ style = appendStyle(style, INSERT_MARK_STYLE);
32
+ }
33
+ const cloned = {
34
+ ...node,
35
+ text,
36
+ style
37
+ };
38
+ return cloned;
39
+ }
40
+ function cloneNodeWithChildren(node, children) {
41
+ return {
42
+ ...node,
43
+ children
44
+ };
45
+ }
46
+ function decorateSubtree(node, kind) {
47
+ if (isTextNode(node)) {
48
+ return cloneTextNode(node, node.text, kind);
49
+ }
50
+ const children = getChildren(node);
51
+ if (!children) return node;
52
+ return cloneNodeWithChildren(
53
+ node,
54
+ children.map((child) => decorateSubtree(child, kind))
55
+ );
56
+ }
57
+ function reverseText(value) {
58
+ return Array.from(value).reverse().join("");
59
+ }
60
+ function mergeTextOps(ops) {
61
+ const merged = [];
62
+ for (const op of ops) {
63
+ if (!op.text) continue;
64
+ const last = merged.at(-1);
65
+ if (last && last.kind === op.kind) {
66
+ last.text += op.text;
67
+ } else {
68
+ merged.push({ ...op });
69
+ }
70
+ }
71
+ return merged;
72
+ }
73
+ function diffMiddleChars(oldChars, newChars) {
74
+ const m = oldChars.length;
75
+ const n = newChars.length;
76
+ if (m === 0 && n === 0) return [];
77
+ if (m === 0) return [{ kind: "insert", text: newChars.join("") }];
78
+ if (n === 0) return [{ kind: "delete", text: oldChars.join("") }];
79
+ if (m * n > CHAR_DIFF_MAX_MATRIX_CELLS) {
80
+ return [
81
+ { kind: "delete", text: oldChars.join("") },
82
+ { kind: "insert", text: newChars.join("") }
83
+ ];
84
+ }
85
+ const dp = Array.from(
86
+ { length: m + 1 },
87
+ () => Array.from({ length: n + 1 }).fill(0)
88
+ );
89
+ for (let i2 = 1; i2 <= m; i2++) {
90
+ for (let j2 = 1; j2 <= n; j2++) {
91
+ if (oldChars[i2 - 1] === newChars[j2 - 1]) {
92
+ dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
93
+ } else {
94
+ dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
95
+ }
96
+ }
97
+ }
98
+ const reversedOps = [];
99
+ let i = m;
100
+ let j = n;
101
+ while (i > 0 && j > 0) {
102
+ if (oldChars[i - 1] === newChars[j - 1]) {
103
+ const last = reversedOps.at(-1);
104
+ if (last && last.kind === "equal") {
105
+ last.text += oldChars[i - 1];
106
+ } else {
107
+ reversedOps.push({ kind: "equal", text: oldChars[i - 1] });
108
+ }
109
+ i--;
110
+ j--;
111
+ continue;
112
+ }
113
+ if (dp[i - 1][j] >= dp[i][j - 1]) {
114
+ const last = reversedOps.at(-1);
115
+ if (last && last.kind === "delete") {
116
+ last.text += oldChars[i - 1];
117
+ } else {
118
+ reversedOps.push({ kind: "delete", text: oldChars[i - 1] });
119
+ }
120
+ i--;
121
+ } else {
122
+ const last = reversedOps.at(-1);
123
+ if (last && last.kind === "insert") {
124
+ last.text += newChars[j - 1];
125
+ } else {
126
+ reversedOps.push({ kind: "insert", text: newChars[j - 1] });
127
+ }
128
+ j--;
129
+ }
130
+ }
131
+ while (i > 0) {
132
+ const last = reversedOps.at(-1);
133
+ if (last && last.kind === "delete") {
134
+ last.text += oldChars[i - 1];
135
+ } else {
136
+ reversedOps.push({ kind: "delete", text: oldChars[i - 1] });
137
+ }
138
+ i--;
139
+ }
140
+ while (j > 0) {
141
+ const last = reversedOps.at(-1);
142
+ if (last && last.kind === "insert") {
143
+ last.text += newChars[j - 1];
144
+ } else {
145
+ reversedOps.push({ kind: "insert", text: newChars[j - 1] });
146
+ }
147
+ j--;
148
+ }
149
+ const forwardOps = reversedOps.reverse().map((op) => ({ kind: op.kind, text: reverseText(op.text) }));
150
+ return mergeTextOps(forwardOps);
151
+ }
152
+ function diffTextByChar(oldText, newText) {
153
+ const oldChars = Array.from(oldText);
154
+ const newChars = Array.from(newText);
155
+ let prefix = 0;
156
+ while (prefix < oldChars.length && prefix < newChars.length && oldChars[prefix] === newChars[prefix]) {
157
+ prefix++;
158
+ }
159
+ let oldSuffix = oldChars.length - 1;
160
+ let newSuffix = newChars.length - 1;
161
+ while (oldSuffix >= prefix && newSuffix >= prefix && oldChars[oldSuffix] === newChars[newSuffix]) {
162
+ oldSuffix--;
163
+ newSuffix--;
164
+ }
165
+ const ops = [];
166
+ if (prefix > 0) {
167
+ ops.push({
168
+ kind: "equal",
169
+ text: oldChars.slice(0, prefix).join("")
170
+ });
171
+ }
172
+ ops.push(
173
+ ...diffMiddleChars(
174
+ oldChars.slice(prefix, oldSuffix + 1),
175
+ newChars.slice(prefix, newSuffix + 1)
176
+ )
177
+ );
178
+ if (oldSuffix < oldChars.length - 1) {
179
+ ops.push({
180
+ kind: "equal",
181
+ text: oldChars.slice(oldSuffix + 1).join("")
182
+ });
183
+ }
184
+ return mergeTextOps(ops);
185
+ }
186
+ function splitTextNodeByCharDiff(oldNode, newNode) {
187
+ const ops = diffTextByChar(oldNode.text, newNode.text);
188
+ const oldNodes = [];
189
+ const newNodes = [];
190
+ let changed = false;
191
+ for (const op of ops) {
192
+ if (!op.text) continue;
193
+ if (op.kind === "equal") {
194
+ oldNodes.push(cloneTextNode(oldNode, op.text));
195
+ newNodes.push(cloneTextNode(newNode, op.text));
196
+ continue;
197
+ }
198
+ changed = true;
199
+ if (op.kind === "delete") {
200
+ oldNodes.push(cloneTextNode(oldNode, op.text, "delete"));
201
+ continue;
202
+ }
203
+ newNodes.push(cloneTextNode(newNode, op.text, "insert"));
204
+ }
205
+ return {
206
+ oldNodes: oldNodes.length > 0 ? oldNodes : [oldNode],
207
+ newNodes: newNodes.length > 0 ? newNodes : [newNode],
208
+ changed
209
+ };
210
+ }
211
+ function alignNodes(oldNodes, newNodes) {
212
+ const m = oldNodes.length;
213
+ const n = newNodes.length;
214
+ const dp = Array.from(
215
+ { length: m + 1 },
216
+ () => Array.from({ length: n + 1 }).fill(0)
217
+ );
218
+ for (let i2 = 1; i2 <= m; i2++) {
219
+ for (let j2 = 1; j2 <= n; j2++) {
220
+ const skip = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
221
+ if (getNodeTypeKey(oldNodes[i2 - 1]) === getNodeTypeKey(newNodes[j2 - 1])) {
222
+ const score = nodesEqual(oldNodes[i2 - 1], newNodes[j2 - 1]) ? 2 : 1;
223
+ dp[i2][j2] = Math.max(skip, dp[i2 - 1][j2 - 1] + score);
224
+ } else {
225
+ dp[i2][j2] = skip;
226
+ }
227
+ }
228
+ }
229
+ const ops = [];
230
+ let i = m;
231
+ let j = n;
232
+ while (i > 0 && j > 0) {
233
+ const typeMatch = getNodeTypeKey(oldNodes[i - 1]) === getNodeTypeKey(newNodes[j - 1]);
234
+ if (typeMatch) {
235
+ const exact = nodesEqual(oldNodes[i - 1], newNodes[j - 1]);
236
+ const score = exact ? 2 : 1;
237
+ if (dp[i][j] === dp[i - 1][j - 1] + score) {
238
+ ops.push(
239
+ exact ? { kind: "equal", node: oldNodes[i - 1] } : {
240
+ kind: "modify",
241
+ oldNode: oldNodes[i - 1],
242
+ newNode: newNodes[j - 1]
243
+ }
244
+ );
245
+ i--;
246
+ j--;
247
+ continue;
248
+ }
249
+ }
250
+ if (dp[i - 1][j] >= dp[i][j - 1]) {
251
+ ops.push({ kind: "delete", node: oldNodes[i - 1] });
252
+ i--;
253
+ } else {
254
+ ops.push({ kind: "insert", node: newNodes[j - 1] });
255
+ j--;
256
+ }
257
+ }
258
+ while (i > 0) {
259
+ ops.push({ kind: "delete", node: oldNodes[--i] });
260
+ }
261
+ while (j > 0) {
262
+ ops.push({ kind: "insert", node: newNodes[--j] });
263
+ }
264
+ ops.reverse();
265
+ return ops;
266
+ }
267
+ function diffChildrenInline(oldChildren, newChildren) {
268
+ const ops = alignNodes(oldChildren, newChildren);
269
+ const nextOldChildren = [];
270
+ const nextNewChildren = [];
271
+ let changed = false;
272
+ for (const op of ops) {
273
+ switch (op.kind) {
274
+ case "equal": {
275
+ nextOldChildren.push(op.node);
276
+ nextNewChildren.push(op.node);
277
+ break;
278
+ }
279
+ case "delete": {
280
+ changed = true;
281
+ nextOldChildren.push(decorateSubtree(op.node, "delete"));
282
+ break;
283
+ }
284
+ case "insert": {
285
+ changed = true;
286
+ nextNewChildren.push(decorateSubtree(op.node, "insert"));
287
+ break;
288
+ }
289
+ case "modify": {
290
+ changed = true;
291
+ if (isTextNode(op.oldNode) && isTextNode(op.newNode)) {
292
+ const textDiff = splitTextNodeByCharDiff(op.oldNode, op.newNode);
293
+ nextOldChildren.push(...textDiff.oldNodes);
294
+ nextNewChildren.push(...textDiff.newNodes);
295
+ break;
296
+ }
297
+ const nested = diffNodeInline(op.oldNode, op.newNode);
298
+ if (nested) {
299
+ nextOldChildren.push(nested.oldNode);
300
+ nextNewChildren.push(nested.newNode);
301
+ } else {
302
+ nextOldChildren.push(decorateSubtree(op.oldNode, "delete"));
303
+ nextNewChildren.push(decorateSubtree(op.newNode, "insert"));
304
+ }
305
+ break;
306
+ }
307
+ }
308
+ }
309
+ return {
310
+ oldChildren: nextOldChildren,
311
+ newChildren: nextNewChildren,
312
+ changed
313
+ };
314
+ }
315
+ function diffNodeInline(oldNode, newNode) {
316
+ if (getNodeTypeKey(oldNode) !== getNodeTypeKey(newNode)) return null;
317
+ if (nodesEqual(oldNode, newNode)) {
318
+ return { oldNode, newNode, changed: false };
319
+ }
320
+ const oldChildren = getChildren(oldNode);
321
+ const newChildren = getChildren(newNode);
322
+ if (!oldChildren || !newChildren) return null;
323
+ const childDiff = diffChildrenInline(oldChildren, newChildren);
324
+ if (!childDiff.changed) {
325
+ return { oldNode, newNode, changed: false };
326
+ }
327
+ return {
328
+ oldNode: cloneNodeWithChildren(oldNode, childDiff.oldChildren),
329
+ newNode: cloneNodeWithChildren(newNode, childDiff.newChildren),
330
+ changed: true
331
+ };
332
+ }
333
+ function diffModifiedNode(oldNode, newNode) {
334
+ const nested = diffNodeInline(oldNode, newNode);
335
+ if (nested) {
336
+ return { oldNode: nested.oldNode, newNode: nested.newNode };
337
+ }
338
+ return {
339
+ oldNode: decorateSubtree(oldNode, "delete"),
340
+ newNode: decorateSubtree(newNode, "insert")
341
+ };
342
+ }
343
+ function mergeTextDiffNodes(oldNode, newNode) {
344
+ const ops = diffTextByChar(oldNode.text, newNode.text);
345
+ const merged = [];
346
+ for (const op of ops) {
347
+ if (!op.text) continue;
348
+ if (op.kind === "equal") {
349
+ merged.push(cloneTextNode(newNode, op.text));
350
+ } else if (op.kind === "delete") {
351
+ merged.push(cloneTextNode(oldNode, op.text, "delete"));
352
+ } else {
353
+ merged.push(cloneTextNode(newNode, op.text, "insert"));
354
+ }
355
+ }
356
+ return merged;
357
+ }
358
+ function diffChildrenMerged(oldChildren, newChildren) {
359
+ const ops = alignNodes(oldChildren, newChildren);
360
+ const merged = [];
361
+ for (const op of ops) {
362
+ switch (op.kind) {
363
+ case "equal": {
364
+ merged.push(op.node);
365
+ break;
366
+ }
367
+ case "delete": {
368
+ merged.push(decorateSubtree(op.node, "delete"));
369
+ break;
370
+ }
371
+ case "insert": {
372
+ merged.push(decorateSubtree(op.node, "insert"));
373
+ break;
374
+ }
375
+ case "modify": {
376
+ if (isTextNode(op.oldNode) && isTextNode(op.newNode)) {
377
+ merged.push(...mergeTextDiffNodes(op.oldNode, op.newNode));
378
+ break;
379
+ }
380
+ const nestedOld = getChildren(op.oldNode);
381
+ const nestedNew = getChildren(op.newNode);
382
+ if (nestedOld && nestedNew) {
383
+ merged.push(cloneNodeWithChildren(op.newNode, diffChildrenMerged(nestedOld, nestedNew)));
384
+ } else {
385
+ merged.push(decorateSubtree(op.oldNode, "delete"));
386
+ merged.push(decorateSubtree(op.newNode, "insert"));
387
+ }
388
+ break;
389
+ }
390
+ }
391
+ }
392
+ return merged;
393
+ }
394
+ function diffMergedNode(oldNode, newNode) {
395
+ if (getNodeTypeKey(oldNode) !== getNodeTypeKey(newNode)) {
396
+ return decorateSubtree(newNode, "insert");
397
+ }
398
+ if (nodesEqual(oldNode, newNode)) {
399
+ return newNode;
400
+ }
401
+ const oldChildren = getChildren(oldNode);
402
+ const newChildren = getChildren(newNode);
403
+ if (!oldChildren || !newChildren) {
404
+ return decorateSubtree(newNode, "insert");
405
+ }
406
+ return cloneNodeWithChildren(newNode, diffChildrenMerged(oldChildren, newChildren));
407
+ }
408
+ function computeDiff(oldState, newState) {
409
+ const ops = alignNodes(oldState.root.children, newState.root.children);
410
+ const hunks = [];
411
+ for (const op of ops) {
412
+ switch (op.kind) {
413
+ case "equal": {
414
+ const last = hunks.at(-1);
415
+ if (last && last.type === "equal") {
416
+ last.nodes.push(op.node);
417
+ } else {
418
+ hunks.push({ type: "equal", nodes: [op.node] });
419
+ }
420
+ break;
421
+ }
422
+ case "modify": {
423
+ {
424
+ const inline = diffModifiedNode(op.oldNode, op.newNode);
425
+ hunks.push(
426
+ { type: "delete", nodes: [inline.oldNode] },
427
+ { type: "insert", nodes: [inline.newNode] }
428
+ );
429
+ }
430
+ break;
431
+ }
432
+ case "delete": {
433
+ hunks.push({
434
+ type: "delete",
435
+ nodes: [decorateSubtree(op.node, "delete")]
436
+ });
437
+ break;
438
+ }
439
+ case "insert": {
440
+ hunks.push({
441
+ type: "insert",
442
+ nodes: [decorateSubtree(op.node, "insert")]
443
+ });
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ return hunks;
449
+ }
450
+ export {
451
+ computeDiff,
452
+ decorateSubtree,
453
+ diffMergedNode,
454
+ diffModifiedNode
455
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@haklex/rich-diff-core",
3
+ "version": "0.0.94",
4
+ "description": "Lexical diff engine — character-level and structural diff with node decoration",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/Innei/haklex.git",
8
+ "directory": "packages/rich-diff-core"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.mjs",
15
+ "types": "./dist/index.d.ts"
16
+ }
17
+ },
18
+ "main": "./dist/index.mjs",
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "dependencies": {},
23
+ "devDependencies": {
24
+ "lexical": "^0.42.0",
25
+ "typescript": "^5.9.3",
26
+ "vite": "^7.3.1",
27
+ "vite-plugin-dts": "^4.5.4"
28
+ },
29
+ "peerDependencies": {
30
+ "lexical": "^0.42.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "vite build",
37
+ "dev:build": "vite build --watch"
38
+ },
39
+ "types": "./dist/index.d.ts"
40
+ }