/// There are several things that positions can be mapped through. /// Such objects conform to this interface. export interface Mappable { /// Map a position through this object. When given, `assoc` (should /// be -1 or 1, defaults to 1) determines with which side the /// position is associated, which determines in which direction to /// move when a chunk of content is inserted at the mapped position. map: (pos: number, assoc?: number) => number /// Map a position, and return an object containing additional /// information about the mapping. The result's `deleted` field tells /// you whether the position was deleted (completely enclosed in a /// replaced range) during the mapping. When content on only one side /// is deleted, the position itself is only considered deleted when /// `assoc` points in the direction of the deleted content. mapResult: (pos: number, assoc?: number) => MapResult } // Recovery values encode a range index and an offset. They are // represented as numbers, because tons of them will be created when // mapping, for example, a large number of decorations. The number's // lower 16 bits provide the index, the remaining bits the offset. // // Note: We intentionally don't use bit shift operators to en- and // decode these, since those clip to 32 bits, which we might in rare // cases want to overflow. A 64-bit float can represent 48-bit // integers precisely. const lower16 = 0xffff const factor16 = Math.pow(2, 16) function makeRecover(index: number, offset: number) { return index + offset * factor16 } function recoverIndex(value: number) { return value & lower16 } function recoverOffset(value: number) { return (value - (value & lower16)) / factor16 } const DEL_BEFORE = 1, DEL_AFTER = 2, DEL_ACROSS = 4, DEL_SIDE = 8 /// An object representing a mapped position with extra /// information. export class MapResult { /// @internal constructor( /// The mapped version of the position. readonly pos: number, /// @internal readonly delInfo: number, /// @internal readonly recover: number | null ) {} /// Tells you whether the position was deleted, that is, whether the /// step removed the token on the side queried (via the `assoc`) /// argument from the document. get deleted() { return (this.delInfo & DEL_SIDE) > 0 } /// Tells you whether the token before the mapped position was deleted. get deletedBefore() { return (this.delInfo & (DEL_BEFORE | DEL_ACROSS)) > 0 } /// True when the token after the mapped position was deleted. get deletedAfter() { return (this.delInfo & (DEL_AFTER | DEL_ACROSS)) > 0 } /// Tells whether any of the steps mapped through deletes across the /// position (including both the token before and after the /// position). get deletedAcross() { return (this.delInfo & DEL_ACROSS) > 0 } } /// A map describing the deletions and insertions made by a step, which /// can be used to find the correspondence between positions in the /// pre-step version of a document and the same position in the /// post-step version. export class StepMap implements Mappable { /// Create a position map. The modifications to the document are /// represented as an array of numbers, in which each group of three /// represents a modified chunk as `[start, oldSize, newSize]`. constructor( /// @internal readonly ranges: readonly number[], /// @internal readonly inverted = false ) { if (!ranges.length && StepMap.empty) return StepMap.empty } /// @internal recover(value: number) { let diff = 0, index = recoverIndex(value) if (!this.inverted) for (let i = 0; i < index; i++) diff += this.ranges[i * 3 + 2] - this.ranges[i * 3 + 1] return this.ranges[index * 3] + diff + recoverOffset(value) } mapResult(pos: number, assoc = 1): MapResult { return this._map(pos, assoc, false) as MapResult } map(pos: number, assoc = 1): number { return this._map(pos, assoc, true) as number } /// @internal _map(pos: number, assoc: number, simple: boolean) { let diff = 0, oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2 for (let i = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i] - (this.inverted ? diff : 0) if (start > pos) break let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex], end = start + oldSize if (pos <= end) { let side = !oldSize ? assoc : pos == start ? -1 : pos == end ? 1 : assoc let result = start + diff + (side < 0 ? 0 : newSize) if (simple) return result let recover = pos == (assoc < 0 ? start : end) ? null : makeRecover(i / 3, pos - start) let del = pos == start ? DEL_AFTER : pos == end ? DEL_BEFORE : DEL_ACROSS if (assoc < 0 ? pos != start : pos != end) del |= DEL_SIDE return new MapResult(result, del, recover) } diff += newSize - oldSize } return simple ? pos + diff : new MapResult(pos + diff, 0, null) } /// @internal touches(pos: number, recover: number) { let diff = 0, index = recoverIndex(recover) let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2 for (let i = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i] - (this.inverted ? diff : 0) if (start > pos) break let oldSize = this.ranges[i + oldIndex], end = start + oldSize if (pos <= end && i == index * 3) return true diff += this.ranges[i + newIndex] - oldSize } return false } /// Calls the given function on each of the changed ranges included in /// this map. forEach(f: (oldStart: number, oldEnd: number, newStart: number, newEnd: number) => void) { let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2 for (let i = 0, diff = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i], oldStart = start - (this.inverted ? diff : 0), newStart = start + (this.inverted ? 0 : diff) let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex] f(oldStart, oldStart + oldSize, newStart, newStart + newSize) diff += newSize - oldSize } } /// Create an inverted version of this map. The result can be used to /// map positions in the post-step document to the pre-step document. invert() { return new StepMap(this.ranges, !this.inverted) } /// @internal toString() { return (this.inverted ? "-" : "") + JSON.stringify(this.ranges) } /// Create a map that moves all positions by offset `n` (which may be /// negative). This can be useful when applying steps meant for a /// sub-document to a larger document, or vice-versa. static offset(n: number) { return n == 0 ? StepMap.empty : new StepMap(n < 0 ? [0, -n, 0] : [0, 0, n]) } /// A StepMap that contains no changed ranges. static empty = new StepMap([]) } /// A mapping represents a pipeline of zero or more [step /// maps](#transform.StepMap). It has special provisions for losslessly /// handling mapping positions through a series of steps in which some /// steps are inverted versions of earlier steps. (This comes up when /// ‘[rebasing](/docs/guide/#transform.rebasing)’ steps for /// collaboration or history management.) export class Mapping implements Mappable { /// Create a new mapping with the given position maps. constructor( /// The step maps in this mapping. readonly maps: StepMap[] = [], /// @internal public mirror?: number[], /// The starting position in the `maps` array, used when `map` or /// `mapResult` is called. public from = 0, /// The end position in the `maps` array. public to = maps.length ) {} /// Create a mapping that maps only through a part of this one. slice(from = 0, to = this.maps.length) { return new Mapping(this.maps, this.mirror, from, to) } /// @internal copy() { return new Mapping(this.maps.slice(), this.mirror && this.mirror.slice(), this.from, this.to) } /// Add a step map to the end of this mapping. If `mirrors` is /// given, it should be the index of the step map that is the mirror /// image of this one. appendMap(map: StepMap, mirrors?: number) { this.to = this.maps.push(map) if (mirrors != null) this.setMirror(this.maps.length - 1, mirrors) } /// Add all the step maps in a given mapping to this one (preserving /// mirroring information). appendMapping(mapping: Mapping) { for (let i = 0, startSize = this.maps.length; i < mapping.maps.length; i++) { let mirr = mapping.getMirror(i) this.appendMap(mapping.maps[i], mirr != null && mirr < i ? startSize + mirr : undefined) } } /// Finds the offset of the step map that mirrors the map at the /// given offset, in this mapping (as per the second argument to /// `appendMap`). getMirror(n: number): number | undefined { if (this.mirror) for (let i = 0; i < this.mirror.length; i++) if (this.mirror[i] == n) return this.mirror[i + (i % 2 ? -1 : 1)] } /// @internal setMirror(n: number, m: number) { if (!this.mirror) this.mirror = [] this.mirror.push(n, m) } /// Append the inverse of the given mapping to this one. appendMappingInverted(mapping: Mapping) { for (let i = mapping.maps.length - 1, totalSize = this.maps.length + mapping.maps.length; i >= 0; i--) { let mirr = mapping.getMirror(i) this.appendMap(mapping.maps[i].invert(), mirr != null && mirr > i ? totalSize - mirr - 1 : undefined) } } /// Create an inverted version of this mapping. invert() { let inverse = new Mapping inverse.appendMappingInverted(this) return inverse } /// Map a position through this mapping. map(pos: number, assoc = 1) { if (this.mirror) return this._map(pos, assoc, true) as number for (let i = this.from; i < this.to; i++) pos = this.maps[i].map(pos, assoc) return pos } /// Map a position through this mapping, returning a mapping /// result. mapResult(pos: number, assoc = 1) { return this._map(pos, assoc, false) as MapResult } /// @internal _map(pos: number, assoc: number, simple: boolean) { let delInfo = 0 for (let i = this.from; i < this.to; i++) { let map = this.maps[i], result = map.mapResult(pos, assoc) if (result.recover != null) { let corr = this.getMirror(i) if (corr != null && corr > i && corr < this.to) { i = corr pos = this.maps[corr].recover(result.recover) continue } } delInfo |= result.delInfo pos = result.pos } return simple ? pos : new MapResult(pos, delInfo, null) } }