Document change system with immutable change sets, position mapping, and change composition for collaborative editing.
Immutable representation of document changes that can be applied to text documents.
/**
* A change set represents a number of changes to a document. It stores the
* document length, and can update a document or be composed with other change sets.
*/
class ChangeSet extends ChangeDesc {
/** Create a change set from a change specification */
static of(changes: ChangeSpec, length: number, lineSep?: string): ChangeSet;
/** Create an empty change set for a document of the given length */
static empty(length: number): ChangeSet;
/** Apply this change set to a document */
apply(doc: Text): Text;
/** Apply this change set to a JSON representation of a document */
applyToJSON(json: any): any;
/** Create the inverse of this change set */
invert(doc: Text): ChangeSet;
/** Compose this change set with another change set */
compose(other: ChangeSet): ChangeSet;
/** Map this change set through another change, producing a new change set */
map(other: ChangeDesc, before?: boolean): ChangeSet;
/** Iterate over the changed ranges */
iterChanges(f: (fromA: number, toA: number, fromB: number, toB: number, inserted: string) => void, individual?: boolean): void;
/** Get a JSON representation of this change set */
toJSON(): any;
/** Create a change set from a JSON representation */
static fromJSON(json: any): ChangeSet;
}Usage Examples:
import { ChangeSet, Text } from "@codemirror/state";
const doc = Text.of(["Hello, world!", "Second line"]);
// Create a change set
const changes = ChangeSet.of([
{ from: 7, to: 12, insert: "CodeMirror" }, // Replace "world" with "CodeMirror"
{ from: 25, insert: " - Added text" } // Insert at end of second line
], doc.length);
// Apply changes to document
const newDoc = changes.apply(doc);
console.log(newDoc.toString()); // "Hello, CodeMirror!\nSecond line - Added text"
// Create inverse changes
const inverse = changes.invert(doc);
const restored = inverse.apply(newDoc);
console.log(restored.eq(doc)); // true - document is restoredBase class for change descriptions without the actual replacement text.
/**
* A change description is a variant of change set that doesn't store the inserted text.
* As such, it can't be applied, but is cheaper to store and manipulate.
*/
class ChangeDesc {
/** The length of the document before the change */
readonly length: number;
/** The length of the document after the change */
readonly newLength: number;
/** False when there are actual changes in this set */
readonly empty: boolean;
/** Get a description of the inverted form of these changes */
get invertedDesc(): ChangeDesc;
/** Compute the combined effect of applying another set of changes after this one */
composeDesc(other: ChangeDesc): ChangeDesc;
/** Map this description over another set of changes */
mapDesc(other: ChangeDesc, before?: boolean): ChangeDesc;
/** Map a given position through these changes */
mapPos(pos: number, assoc?: number, mode?: MapMode): number | null;
/** Check whether these changes touch a given range */
touchesRange(from: number, to?: number): boolean | "cover";
/** Iterate over the unchanged parts left by these changes */
iterGaps(f: (posA: number, posB: number, length: number) => void): void;
/** Iterate over the ranges changed by these changes */
iterChangedRanges(f: (fromA: number, toA: number, fromB: number, toB: number) => void, individual?: boolean): void;
}Usage Examples:
const changes = ChangeSet.of([
{ from: 5, to: 10, insert: "replacement" },
{ from: 15, insert: "insertion" }
], 20);
console.log(changes.length); // 20 (original document length)
console.log(changes.newLength); // 29 (new document length)
console.log(changes.empty); // false (has changes)
// Map positions through changes
const mappedPos = changes.mapPos(8); // Position 8 becomes 13 (after "replacement")
const mappedPos2 = changes.mapPos(17); // Position 17 becomes 31 (after both changes)
// Check if changes affect a range
const touches = changes.touchesRange(6, 9); // true (range overlaps with first change)
const covers = changes.touchesRange(6, 8); // "cover" (range is completely covered)
// Iterate over unchanged parts
changes.iterGaps((posA, posB, length) => {
console.log(`Unchanged: ${posA}-${posA + length} maps to ${posB}-${posB + length}`);
});Types and interfaces for specifying document changes.
/**
* Change specifications can be provided in several formats
*/
type ChangeSpec =
| {from: number, to?: number, insert?: string | Text}
| readonly {from: number, to?: number, insert?: string | Text}[]
| ChangeSet;
/**
* Individual change specification
*/
interface ChangeSpecification {
/** Start position of the change */
from: number;
/** End position of the change (defaults to from for insertions) */
to?: number;
/** Text to insert at this position */
insert?: string | Text;
}Usage Examples:
// Single change specification
const singleChange: ChangeSpec = {
from: 5,
to: 10,
insert: "replacement"
};
// Multiple changes
const multipleChanges: ChangeSpec = [
{ from: 0, to: 5, insert: "Hello" },
{ from: 10, insert: " world" },
{ from: 20, to: 25 } // Deletion (no insert)
];
// Pure insertion
const insertion: ChangeSpec = {
from: 5,
insert: "inserted text"
};
// Pure deletion
const deletion: ChangeSpec = {
from: 5,
to: 10
};
// Create change sets from specifications
const changeSet1 = ChangeSet.of(singleChange, 30);
const changeSet2 = ChangeSet.of(multipleChanges, 30);System for tracking position changes through document modifications.
/**
* Distinguishes different ways in which positions can be mapped
*/
enum MapMode {
/** Map a position to a valid new position, even when its context was deleted */
Simple,
/** Return null if deletion happens across the position */
TrackDel,
/** Return null if the character before the position is deleted */
TrackBefore,
/** Return null if the character after the position is deleted */
TrackAfter
}
/**
* Map a position through changes
* @param pos Position to map
* @param assoc Association direction (-1 for before, 1 for after, 0 for default)
* @param mode Mapping mode
*/
mapPos(pos: number, assoc?: number, mode?: MapMode): number | null;Usage Examples:
const changes = ChangeSet.of([
{ from: 5, to: 10, insert: "NEW" }, // Replace 5 characters with 3
{ from: 15, insert: "+" } // Insert 1 character
], 20);
// Simple mapping (default)
console.log(changes.mapPos(3)); // 3 (before first change)
console.log(changes.mapPos(7)); // 7 (mapped to middle of replacement)
console.log(changes.mapPos(12)); // 10 (after first change, adjusted for length difference)
console.log(changes.mapPos(16)); // 14 (after both changes)
// Track deletions
console.log(changes.mapPos(7, 0, MapMode.TrackDel)); // null (position was deleted)
console.log(changes.mapPos(3, 0, MapMode.TrackDel)); // 3 (position not deleted)
// Association affects boundary behavior
console.log(changes.mapPos(5, -1)); // 5 (stay before insertion)
console.log(changes.mapPos(5, 1)); // 8 (move after insertion)Methods for combining and manipulating change sets.
/**
* Compose this change set with another change set
* @param other The change set to compose with
*/
compose(other: ChangeSet): ChangeSet;
/**
* Map this change set through another change
* @param other The change to map through
* @param before Whether to map as if this change happened before other
*/
map(other: ChangeDesc, before?: boolean): ChangeSet;
/**
* Create the inverse of this change set
* @param doc The document this change set was applied to
*/
invert(doc: Text): ChangeSet;Usage Examples:
const doc = Text.of(["Original text"]);
// First change: replace "Original" with "Modified"
const change1 = ChangeSet.of([
{ from: 0, to: 8, insert: "Modified" }
], doc.length);
// Second change: append " content"
const change2 = ChangeSet.of([
{ from: 13, insert: " content" }
], change1.newLength);
// Compose changes
const composed = change1.compose(change2);
const result = composed.apply(doc);
console.log(result.toString()); // "Modified text content"
// Map changes through each other (for collaborative editing)
const parallel1 = ChangeSet.of([{ from: 9, insert: "extra " }], doc.length);
const parallel2 = ChangeSet.of([{ from: 0, to: 8, insert: "New" }], doc.length);
const mapped1 = parallel1.map(parallel2, false); // Map parallel1 after parallel2
const mapped2 = parallel2.map(parallel1, true); // Map parallel2 before parallel1
// Invert changes
const inverted = change1.invert(doc);
const restored = inverted.apply(change1.apply(doc));
console.log(restored.eq(doc)); // trueMethods for examining the content of changes.
/**
* Iterate over the changed ranges, providing both old and new content
* @param f Callback function receiving change information
* @param individual Whether to report adjacent changes separately
*/
iterChanges(f: (fromA: number, toA: number, fromB: number, toB: number, inserted: string) => void, individual?: boolean): void;
/**
* Iterate over the ranges changed by these changes (ChangeDesc version)
* @param f Callback function receiving position information
* @param individual Whether to report adjacent changes separately
*/
iterChangedRanges(f: (fromA: number, toA: number, fromB: number, toB: number) => void, individual?: boolean): void;
/**
* Iterate over the unchanged parts left by these changes
* @param f Callback function receiving gap information
*/
iterGaps(f: (posA: number, posB: number, length: number) => void): void;Usage Examples:
const changes = ChangeSet.of([
{ from: 5, to: 10, insert: "HELLO" },
{ from: 15, to: 17, insert: "XX" }
], 25);
// Iterate over changes with content
changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
console.log(`Change: ${fromA}-${toA} -> ${fromB}-${toB}, inserted: "${inserted}"`);
});
// Output:
// Change: 5-10 -> 5-10, inserted: "HELLO"
// Change: 15-17 -> 20-22, inserted: "XX"
// Iterate over changed ranges (no content)
changes.iterChangedRanges((fromA, toA, fromB, toB) => {
console.log(`Range: ${fromA}-${toA} -> ${fromB}-${toB}`);
});
// Iterate over unchanged gaps
changes.iterGaps((posA, posB, length) => {
console.log(`Gap: ${posA} -> ${posB}, length: ${length}`);
});
// Shows the parts of the document that weren't changed/**
* Interface for objects that have a length and can have changes applied
*/
interface Changeable {
readonly length: number;
}
/**
* Result of applying a change to a document
*/
interface ChangeResult<T> {
doc: T;
changes: ChangeSet;
}