@type-editor/changeset 0.0.2

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,578 @@
1
+ import type {Node} from '@type-editor/model';
2
+ import type {StepMap} from '@type-editor/transform';
3
+
4
+ import {Change} from './Change';
5
+ import {computeDiff} from './compute-diff';
6
+ import {DefaultEncoder} from './default-encoder';
7
+ import {Span} from './Span';
8
+ import type {ChangeSetConfig} from './types/ChangeSetConfig';
9
+ import type {TokenEncoder} from './types/TokenEncoder';
10
+
11
+
12
+ /**
13
+ * Represents a range that spans both the old (A) and new (B) coordinate systems.
14
+ */
15
+ interface TouchedRange {
16
+ /** Start position in the old document (A coordinates). */
17
+ fromA: number;
18
+ /** End position in the old document (A coordinates). */
19
+ toA: number;
20
+ /** Start position in the new document (B coordinates). */
21
+ fromB: number;
22
+ /** End position in the new document (B coordinates). */
23
+ toB: number;
24
+ }
25
+
26
+
27
+ /**
28
+ * A change set tracks the changes to a document from a given point in the past.
29
+ *
30
+ * It condenses a number of step maps down to a flat sequence of replacements,
31
+ * and simplifies replacements that partially undo themselves by comparing their content.
32
+ *
33
+ * The ChangeSet maintains two coordinate systems:
34
+ * - **A coordinates**: Positions in the original (starting) document
35
+ * - **B coordinates**: Positions in the current (modified) document
36
+ *
37
+ * @template Data - The type of metadata associated with changes (default: any).
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * // Create a changeset tracking from a document
42
+ * const changeSet = ChangeSet.create(startDoc);
43
+ *
44
+ * // Add steps as they occur
45
+ * const updated = changeSet.addSteps(newDoc, stepMaps, metadata);
46
+ *
47
+ * // Access the tracked changes
48
+ * for (const change of updated.changes) {
49
+ * console.log(`Replaced ${change.fromA}-${change.toA} with content at ${change.fromB}-${change.toB}`);
50
+ * }
51
+ * ```
52
+ */
53
+ export class ChangeSet<Data = any> {
54
+
55
+ /**
56
+ * Computes a diff between document fragments within a change range.
57
+ * Exposed for testing purposes.
58
+ *
59
+ * @internal
60
+ */
61
+ public static computeDiff = computeDiff;
62
+ /**
63
+ * Maximum position value used as a sentinel for uninitialized range boundaries.
64
+ * Using 2e8 (200 million) as a practical upper limit for document positions.
65
+ */
66
+ private static readonly MAX_POSITION = 2e8;
67
+ /**
68
+ * Minimum position value used as a sentinel for uninitialized range boundaries.
69
+ */
70
+ private static readonly MIN_POSITION = -2e8;
71
+ private readonly _changes: ReadonlyArray<Change<Data>>;
72
+ private readonly config: ChangeSetConfig<Data>;
73
+
74
+ /**
75
+ * Creates a new ChangeSet instance.
76
+ *
77
+ * @param config - The configuration object containing the starting document,
78
+ * combine function, and token encoder.
79
+ * @param changes - The array of changes tracked from the starting document.
80
+ * These represent replaced regions in the document.
81
+ */
82
+ constructor(config: ChangeSetConfig<Data>, changes: ReadonlyArray<Change<Data>>) {
83
+ this.config = config;
84
+ this._changes = changes;
85
+ }
86
+
87
+ /**
88
+ * The array of changes tracked from the starting document to the current state.
89
+ *
90
+ * Each change represents a replaced region, containing information about
91
+ * what was deleted and what was inserted.
92
+ */
93
+ get changes(): ReadonlyArray<Change<Data>> {
94
+ return this._changes;
95
+ }
96
+
97
+ /**
98
+ * The starting document of the change set.
99
+ *
100
+ * This is the document that all changes are tracked relative to.
101
+ * The A coordinates in all changes refer to positions in this document.
102
+ */
103
+ get startDoc(): Node {
104
+ return this.config.doc;
105
+ }
106
+
107
+ /**
108
+ * Creates a changeset with the given base document and configuration.
109
+ *
110
+ * The `combine` function is used to compare and combine metadata—it
111
+ * should return null when metadata isn't compatible, and a combined
112
+ * version for a merged range when it is.
113
+ *
114
+ * When given, a token encoder determines how document tokens are
115
+ * serialized and compared when diffing the content produced by
116
+ * changes. The default is to just compare nodes by name and text
117
+ * by character, ignoring marks and attributes.
118
+ *
119
+ * @template Data - The type of metadata associated with changes.
120
+ * @param doc - The starting document from which changes will be tracked.
121
+ * @param combine - Function to combine metadata from adjacent spans.
122
+ * Returns the combined data, or null if incompatible.
123
+ * Defaults to strict equality comparison.
124
+ * @param tokenEncoder - Encoder for tokenizing document content during diffs.
125
+ * Defaults to {@link DefaultEncoder}.
126
+ * @param changes To serialize a change set, you can store its document and
127
+ * change array as JSON, and then pass the deserialized (via
128
+ * [`Change.fromJSON`](#changes.Change^fromJSON)) set of changes.
129
+ * @returns A new empty ChangeSet ready to track changes.
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * // Simple usage with default settings
134
+ * const changeSet = ChangeSet.create(doc);
135
+ *
136
+ * // With custom metadata combining
137
+ * const changeSet = ChangeSet.create<string>(doc, (a, b) => a === b ? a : null);
138
+ * ```
139
+ */
140
+ public static create<Data = any>(doc: Node,
141
+ combine: (dataA: Data, dataB: Data) => Data = (a, b) => a === b ? a : null as any,
142
+ tokenEncoder: TokenEncoder<any> = DefaultEncoder,
143
+ changes: ReadonlyArray<Change<Data>> = []): ChangeSet<Data> {
144
+ return new ChangeSet({combine, doc, encoder: tokenEncoder}, changes);
145
+ }
146
+
147
+ /**
148
+ * Computes a new changeset by adding the given step maps and
149
+ * metadata (either as an array, per-map, or as a single value to be
150
+ * associated with all maps) to the current set. Will not mutate the
151
+ * old set.
152
+ *
153
+ * Note that due to simplification that happens after each add,
154
+ * incrementally adding steps might create a different final set
155
+ * than adding all those changes at once, since different document
156
+ * tokens might be matched during simplification depending on the
157
+ * boundaries of the current changed ranges.
158
+ *
159
+ * @param newDoc - The document after applying all the steps.
160
+ * @param maps - The step maps representing the document transformations.
161
+ * @param data - Metadata to associate with the changes. Can be a single value
162
+ * (applied to all maps) or an array (one per map).
163
+ * @returns A new ChangeSet containing the merged changes.
164
+ */
165
+ public addSteps(newDoc: Node,
166
+ maps: ReadonlyArray<StepMap>,
167
+ data: Data | ReadonlyArray<Data>): ChangeSet<Data> {
168
+ // This works by inspecting the position maps for the changes,
169
+ // which indicate what parts of the document were replaced by new
170
+ // content, and the size of that new content. It uses these to
171
+ // build up Change objects.
172
+ //
173
+ // These change objects are put in sets and merged together using
174
+ // Change.merge, giving us the changes created by the new steps.
175
+ // Those changes can then be merged with the existing set of
176
+ // changes.
177
+ //
178
+ // For each change that was touched by the new steps, we recompute
179
+ // a diff to try to minimize the change by dropping matching
180
+ // pieces of the old and new document from the change.
181
+
182
+ const stepChanges: Array<Change<Data>> = this.buildChangesFromStepMaps(maps, data);
183
+
184
+ if (stepChanges.length === 0) {
185
+ return this;
186
+ }
187
+
188
+ const newChanges: ReadonlyArray<Change<Data>> = this.mergeAll(stepChanges, this.config.combine);
189
+ const mergedChanges: ReadonlyArray<Change<Data>> = Change.merge(this._changes, newChanges, this.config.combine);
190
+ const minimizedChanges: ReadonlyArray<Change<Data>> =
191
+ this.minimizeChanges(
192
+ mergedChanges,
193
+ newChanges,
194
+ newDoc
195
+ );
196
+
197
+ return new ChangeSet(this.config, minimizedChanges);
198
+ }
199
+
200
+ /**
201
+ * Map the span's data values in the given set through a function
202
+ * and construct a new set with the resulting data.
203
+ *
204
+ * This is useful for transforming the metadata associated with changes,
205
+ * such as updating user references or converting between data formats.
206
+ *
207
+ * @param callbackFunc - A function that receives a span and returns the new data value.
208
+ * If the returned data is the same as the original, the span is reused.
209
+ * @returns A new ChangeSet with the transformed data values.
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * // Convert user IDs to usernames
214
+ * const mapped = changeSet.map(span => userLookup[span.data.userId]);
215
+ * ```
216
+ */
217
+ public map<NewData = Data>(callbackFunc: (range: Span<Data>) => NewData): ChangeSet<NewData> {
218
+ const mapSpan = (span: Span<Data>): Span<NewData> => {
219
+ const newData: NewData = callbackFunc(span);
220
+ return newData === (span.data as unknown) ? (span as unknown as Span<NewData>) : new Span(span.length, newData);
221
+ };
222
+
223
+ // Create a new config with the same settings but typed for NewData
224
+ const newConfig: ChangeSetConfig<NewData> = {
225
+ doc: this.config.doc,
226
+ combine: this.config.combine as unknown as (dataA: NewData, dataB: NewData) => NewData,
227
+ encoder: this.config.encoder
228
+ };
229
+
230
+ return new ChangeSet<NewData>(newConfig, this._changes.map((ch: Change<Data>): Change<NewData> => {
231
+ return new Change(
232
+ ch.fromA,
233
+ ch.toA,
234
+ ch.fromB,
235
+ ch.toB,
236
+ ch.deleted.map(mapSpan),
237
+ ch.inserted.map(mapSpan));
238
+ }));
239
+ }
240
+
241
+ /**
242
+ * Compare two changesets and return the range in which they are
243
+ * changed, if any. If the document changed between the maps, pass
244
+ * the maps for the steps that changed it as second argument, and
245
+ * make sure the method is called on the old set and passed the new
246
+ * set. The returned positions will be in new document coordinates.
247
+ *
248
+ * @param changeSet - The changeset to compare against.
249
+ * @param maps - Optional step maps representing document transformations between the two changesets.
250
+ * @returns An object with `from` and `to` properties indicating the changed range,
251
+ * or `null` if the changesets are identical.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const range = oldChangeSet.changedRange(newChangeSet, stepMaps);
256
+ * if (range) {
257
+ * console.log(`Content changed from position ${range.from} to ${range.to}`);
258
+ * }
259
+ * ```
260
+ */
261
+ public changedRange(changeSet: ChangeSet, maps?: ReadonlyArray<StepMap>): { from: number, to: number; } | null {
262
+ if (changeSet === this) {
263
+ return null;
264
+ }
265
+
266
+ const touched: TouchedRange | null = maps ? this.computeTouchedRange(maps) : null;
267
+ const moved: number = touched ? (touched.toB - touched.fromB) - (touched.toA - touched.fromA) : 0;
268
+
269
+ /** Maps a position from A coordinates to B coordinates, accounting for offset. */
270
+ const mapPosition = (p: number): number => {
271
+ return !touched || p <= touched.fromA ? p : p + moved;
272
+ };
273
+
274
+ let from: number = touched ? touched.fromB : ChangeSet.MAX_POSITION;
275
+ let to: number = touched ? touched.toB : ChangeSet.MIN_POSITION;
276
+
277
+ /** Expands the result range to include the given positions. */
278
+ const expandRange = (start: number, end = start): void => {
279
+ from = Math.min(start, from);
280
+ to = Math.max(end, to);
281
+ };
282
+
283
+ const changesA: ReadonlyArray<Change<Data>> = this._changes;
284
+ const changesB: ReadonlyArray<Change> = changeSet.changes;
285
+
286
+ // Walk through both change arrays in parallel, continue until both are exhausted
287
+ for (let iA = 0, iB = 0; iA < changesA.length || iB < changesB.length;) {
288
+ const rangeA: Change<Data> | undefined = changesA[iA];
289
+ const rangeB: Change | undefined = changesB[iB];
290
+
291
+ if (rangeA && rangeB && this.sameRanges(rangeA, rangeB, mapPosition)) {
292
+ // Ranges are identical - skip both
293
+ iA++;
294
+ iB++;
295
+ } else if (rangeB && (!rangeA || mapPosition(rangeA.fromB) >= rangeB.fromB)) {
296
+ // Range B comes first or A is exhausted
297
+ expandRange(rangeB.fromB, rangeB.toB);
298
+ iB++;
299
+ } else if (rangeA) {
300
+ // Range A comes first
301
+ expandRange(mapPosition(rangeA.fromB), mapPosition(rangeA.toB));
302
+ iA++;
303
+ }
304
+ }
305
+
306
+ return from <= to ? {from, to} : null;
307
+ }
308
+
309
+ /**
310
+ * Builds Change objects from step maps and their associated metadata.
311
+ *
312
+ * @param maps - The step maps to process.
313
+ * @param data - The metadata to associate with changes.
314
+ * @returns An array of Change objects representing the step transformations.
315
+ */
316
+ private buildChangesFromStepMaps(maps: ReadonlyArray<StepMap>,
317
+ data: Data | ReadonlyArray<Data>): Array<Change<Data>> {
318
+ const stepChanges: Array<Change<Data>> = [];
319
+ const isDataArray = Array.isArray(data);
320
+
321
+ for (let i = 0; i < maps.length; i++) {
322
+ const stepData: Data = isDataArray ? data[i] : data;
323
+ let offset = 0;
324
+
325
+ maps[i].forEach((fromA: number, toA: number, fromB: number, toB: number): void => {
326
+ const deletedSpans: ReadonlyArray<Span<Data>> = fromA === toA
327
+ ? Span.none
328
+ : [new Span(toA - fromA, stepData)];
329
+ const insertedSpans: ReadonlyArray<Span<Data>> = fromB === toB
330
+ ? Span.none
331
+ : [new Span(toB - fromB, stepData)];
332
+
333
+ stepChanges.push(new Change(
334
+ fromA + offset,
335
+ toA + offset,
336
+ fromB,
337
+ toB,
338
+ deletedSpans,
339
+ insertedSpans
340
+ ));
341
+
342
+ offset += (toB - fromB) - (toA - fromA);
343
+ });
344
+ }
345
+
346
+ return stepChanges;
347
+ }
348
+
349
+ /**
350
+ * Minimizes changes by computing diffs to remove redundant content.
351
+ *
352
+ * For each change that overlaps with newly added changes, we compute
353
+ * a diff between the old and new document content to potentially
354
+ * split the change into smaller, more precise changes.
355
+ *
356
+ * @param changes - The merged changes to minimize.
357
+ * @param newChanges - The newly added changes (used to determine which changes to minimize).
358
+ * @param newDoc - The new document for diff computation.
359
+ * @returns The minimized array of changes.
360
+ */
361
+ private minimizeChanges(changes: ReadonlyArray<Change<Data>>,
362
+ newChanges: ReadonlyArray<Change<Data>>,
363
+ newDoc: Node): ReadonlyArray<Change<Data>> {
364
+ let result: Array<Change<Data>> | ReadonlyArray<Change<Data>> = changes;
365
+
366
+ for (let i = 0; i < result.length; i++) {
367
+ const change: Change<Data> = result[i];
368
+
369
+ if (!this.shouldMinimizeChange(change, newChanges)) {
370
+ continue;
371
+ }
372
+
373
+ const diff: ReadonlyArray<Change<Data>> = computeDiff(
374
+ this.config.doc.content,
375
+ newDoc.content,
376
+ change,
377
+ this.config.encoder
378
+ );
379
+
380
+ // Fast path: If the content is completely different, keep the original change
381
+ if (this.isCompletelyDifferent(diff, change)) {
382
+ continue;
383
+ }
384
+
385
+ // Lazily copy the array when we need to modify it
386
+ if (result === changes) {
387
+ result = changes.slice();
388
+ }
389
+
390
+ this.applyDiffToChanges(result as Array<Change<Data>>, i, diff);
391
+ i += diff.length - 1;
392
+ }
393
+
394
+ return result;
395
+ }
396
+
397
+ /**
398
+ * Determines if a change should be minimized via diff computation.
399
+ *
400
+ * Changes are minimized only if they have both deleted and inserted content,
401
+ * and they overlap with at least one of the newly added changes.
402
+ *
403
+ * @param change - The change to check.
404
+ * @param newChanges - The newly added changes.
405
+ * @returns True if the change should be minimized.
406
+ */
407
+ private shouldMinimizeChange(change: Change<Data>,
408
+ newChanges: ReadonlyArray<Change<Data>>): boolean {
409
+ // Skip pure insertions or pure deletions
410
+ if (change.fromA === change.toA || change.fromB === change.toB) {
411
+ return false;
412
+ }
413
+
414
+ // Only minimize changes that overlap with newly added changes
415
+ return newChanges.some(newChange =>
416
+ newChange.toB > change.fromB && newChange.fromB < change.toB
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Checks if a diff result indicates the content is completely different.
422
+ *
423
+ * @param diff - The diff result.
424
+ * @param originalChange - The original change being diffed.
425
+ * @returns True if the diff covers the entire range (no matches found).
426
+ */
427
+ private isCompletelyDifferent(diff: ReadonlyArray<Change<Data>>,
428
+ originalChange: Change<Data>): boolean {
429
+ return diff.length === 1
430
+ && diff[0].fromB === 0
431
+ && diff[0].toB === originalChange.toB - originalChange.fromB;
432
+ }
433
+
434
+ /**
435
+ * Applies a diff result to the changes array, replacing or splicing as needed.
436
+ *
437
+ * @param changes - The mutable changes array.
438
+ * @param index - The index of the change being replaced.
439
+ * @param diff - The diff result to apply.
440
+ */
441
+ private applyDiffToChanges(changes: Array<Change<Data>>,
442
+ index: number,
443
+ diff: ReadonlyArray<Change<Data>>): void {
444
+ if (diff.length === 1) {
445
+ changes[index] = diff[0];
446
+ } else {
447
+ changes.splice(index, 1, ...diff);
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Merges an array of changes using a divide-and-conquer approach.
453
+ *
454
+ * This recursively splits the array in half and merges the halves together,
455
+ * providing O(n log n) complexity instead of O(n²) for sequential merging.
456
+ *
457
+ * @param ranges - The array of changes to merge.
458
+ * @param combine - Function to combine metadata when spans are merged.
459
+ * @param start - The start index of the range to process (default: 0).
460
+ * @param end - The end index of the range to process (default: ranges.length).
461
+ * @returns A merged array of changes.
462
+ */
463
+ private mergeAll<Data>(ranges: ReadonlyArray<Change<Data>>,
464
+ combine: (dA: Data, dB: Data) => Data,
465
+ start = 0,
466
+ end = ranges.length): ReadonlyArray<Change<Data>> {
467
+ if (end === start + 1) {
468
+ return [ranges[start]];
469
+ }
470
+
471
+ const mid: number = (start + end) >> 1;
472
+ return Change.merge(
473
+ this.mergeAll(ranges, combine, start, mid),
474
+ this.mergeAll(ranges, combine, mid, end), combine);
475
+ }
476
+
477
+ /**
478
+ * Computes the range touched by a sequence of step maps in both A and B coordinates.
479
+ *
480
+ * This determines the bounding box of all changes in both the old and new
481
+ * coordinate systems.
482
+ *
483
+ * @param maps - The step maps to analyze.
484
+ * @returns A TouchedRange with boundaries in both coordinate systems, or null if no changes.
485
+ */
486
+ private computeTouchedRange(maps: ReadonlyArray<StepMap>): TouchedRange | null {
487
+ const rangeB: { from: number; to: number } | null = this.computeEndRange(maps);
488
+ if (!rangeB) {
489
+ return null;
490
+ }
491
+
492
+ // Compute the range in A coordinates by inverting and reversing the maps
493
+ const invertedMaps: ReadonlyArray<StepMap> = maps
494
+ .map((stepMap: StepMap): StepMap => stepMap.invert())
495
+ .reverse();
496
+ const rangeA: { from: number; to: number } | null = this.computeEndRange(invertedMaps);
497
+
498
+ if (!rangeA) {
499
+ return null;
500
+ }
501
+
502
+ return {
503
+ fromA: rangeA.from,
504
+ toA: rangeA.to,
505
+ fromB: rangeB.from,
506
+ toB: rangeB.to
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Computes the cumulative range affected by a sequence of step maps.
512
+ *
513
+ * Maps each position through all subsequent maps to find the final
514
+ * range boundaries.
515
+ *
516
+ * @param maps - The step maps to analyze.
517
+ * @returns The range boundaries, or null if no positions were affected.
518
+ */
519
+ private computeEndRange(maps: ReadonlyArray<StepMap>): { from: number; to: number } | null {
520
+ let from = ChangeSet.MAX_POSITION;
521
+ let to = ChangeSet.MIN_POSITION;
522
+
523
+ for (const map of maps) {
524
+ // If we already have a range, map it through this step
525
+ if (from !== ChangeSet.MAX_POSITION) {
526
+ from = map.map(from, -1);
527
+ to = map.map(to, 1);
528
+ }
529
+
530
+ // Expand the range to include any changes in this step
531
+ map.forEach((_oldStart: number, _oldEnd: number, newStart: number, newEnd: number): void => {
532
+ from = Math.min(from, newStart);
533
+ to = Math.max(to, newEnd);
534
+ });
535
+ }
536
+
537
+ return from === ChangeSet.MAX_POSITION ? null : {from, to};
538
+ }
539
+
540
+ /**
541
+ * Compares two changes for equality after applying a position mapping.
542
+ *
543
+ * @param a - The first change.
544
+ * @param b - The second change.
545
+ * @param mapPosition - Function to map positions from A coordinates.
546
+ * @returns True if the changes represent the same range and content.
547
+ */
548
+ private sameRanges<Data>(a: Change<Data>,
549
+ b: Change<Data>,
550
+ mapPosition: (pos: number) => number): boolean {
551
+ return mapPosition(a.fromB) === b.fromB
552
+ && mapPosition(a.toB) === b.toB
553
+ && this.sameSpans(a.deleted, b.deleted)
554
+ && this.sameSpans(a.inserted, b.inserted);
555
+ }
556
+
557
+ /**
558
+ * Compares two span arrays for deep equality.
559
+ *
560
+ * @param a - The first span array.
561
+ * @param b - The second span array.
562
+ * @returns True if the arrays contain equivalent spans.
563
+ */
564
+ private sameSpans<Data>(a: ReadonlyArray<Span<Data>>,
565
+ b: ReadonlyArray<Span<Data>>): boolean {
566
+ if (a.length !== b.length) {
567
+ return false;
568
+ }
569
+
570
+ for (let i = 0; i < a.length; i++) {
571
+ if (a[i].length !== b[i].length || a[i].data !== b[i].data) {
572
+ return false;
573
+ }
574
+ }
575
+
576
+ return true;
577
+ }
578
+ }