Preview: changeset.ts
Size: 8.35 KB
/home/byroehnu/.trash/node_modules11/prosemirror-changeset/src/changeset.ts
import {Node} from "prosemirror-model"
import {StepMap} from "prosemirror-transform"
import {computeDiff, TokenEncoder, DefaultEncoder} from "./diff"
import {Change, Span, ChangeJSON} from "./change"
export {Change, Span, ChangeJSON}
export {simplifyChanges} from "./simplify"
export {TokenEncoder}
/// A change set tracks the changes to a document from a given point
/// in the past. It condenses a number of step maps down to a flat
/// sequence of replacements, and simplifies replacments that
/// partially undo themselves by comparing their content.
export class ChangeSet<Data = any> {
/// @internal
constructor(
/// @internal
readonly config: {
doc: Node,
combine: (dataA: Data, dataB: Data) => Data,
encoder: TokenEncoder<any>
},
/// Replaced regions.
readonly changes: readonly Change<Data>[]
) {}
/// Computes a new changeset by adding the given step maps and
/// metadata (either as an array, per-map, or as a single value to be
/// associated with all maps) to the current set. Will not mutate the
/// old set.
///
/// Note that due to simplification that happens after each add,
/// incrementally adding steps might create a different final set
/// than adding all those changes at once, since different document
/// tokens might be matched during simplification depending on the
/// boundaries of the current changed ranges.
addSteps(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]): ChangeSet<Data> {
// This works by inspecting the position maps for the changes,
// which indicate what parts of the document were replaced by new
// content, and the size of that new content. It uses these to
// build up Change objects.
//
// These change objects are put in sets and merged together using
// Change.merge, giving us the changes created by the new steps.
// Those changes can then be merged with the existing set of
// changes.
//
// For each change that was touched by the new steps, we recompute
// a diff to try to minimize the change by dropping matching
// pieces of the old and new document from the change.
let stepChanges: Change<Data>[] = []
// Add spans for new steps.
for (let i = 0; i < maps.length; i++) {
let d = Array.isArray(data) ? data[i] : data
let off = 0
maps[i].forEach((fromA, toA, fromB, toB) => {
stepChanges.push(new Change(fromA + off, toA + off, fromB, toB,
fromA == toA ? Span.none : [new Span(toA - fromA, d)],
fromB == toB ? Span.none : [new Span(toB - fromB, d)]))
off = (toB - fromB) - (toA - fromA)
})
}
if (stepChanges.length == 0) return this
let newChanges = mergeAll(stepChanges, this.config.combine)
let changes = Change.merge(this.changes, newChanges, this.config.combine)
let updated: Change<Data>[] = changes as Change<Data>[]
// Minimize changes when possible
for (let i = 0; i < updated.length; i++) {
let change = updated[i]
if (change.fromA == change.toA || change.fromB == change.toB ||
// Only look at changes that touch newly added changed ranges
!newChanges.some(r => r.toB > change.fromB && r.fromB < change.toB)) continue
let diff = computeDiff(this.config.doc.content, newDoc.content, change, this.config.encoder)
// Fast path: If they are completely different, don't do anything
if (diff.length == 1 && diff[0].fromB == 0 && diff[0].toB == change.toB - change.fromB)
continue
if (updated == changes) updated = changes.slice()
if (diff.length == 1) {
updated[i] = diff[0]
} else {
updated.splice(i, 1, ...diff)
i += diff.length - 1
}
}
return new ChangeSet(this.config, updated)
}
/// The starting document of the change set.
get startDoc(): Node { return this.config.doc }
/// Map the span's data values in the given set through a function
/// and construct a new set with the resulting data.
map(f: (range: Span<Data>) => Data): ChangeSet<Data> {
let mapSpan = (span: Span<Data>) => {
let newData = f(span)
return newData === span.data ? span : new Span(span.length, newData)
}
return new ChangeSet(this.config, this.changes.map((ch: Change<Data>) => {
return new Change(ch.fromA, ch.toA, ch.fromB, ch.toB, ch.deleted.map(mapSpan), ch.inserted.map(mapSpan))
}))
}
/// Compare two changesets and return the range in which they are
/// changed, if any. If the document changed between the maps, pass
/// the maps for the steps that changed it as second argument, and
/// make sure the method is called on the old set and passed the new
/// set. The returned positions will be in new document coordinates.
changedRange(b: ChangeSet, maps?: readonly StepMap[]): {from: number, to: number} | null {
if (b == this) return null
let touched = maps && touchedRange(maps)
let moved = touched ? (touched.toB - touched.fromB) - (touched.toA - touched.fromA) : 0
function map(p: number) {
return !touched || p <= touched.fromA ? p : p + moved
}
let from = touched ? touched.fromB : 2e8, to = touched ? touched.toB : -2e8
function add(start: number, end = start) {
from = Math.min(start, from); to = Math.max(end, to)
}
let rA = this.changes, rB = b.changes
for (let iA = 0, iB = 0; iA < rA.length && iB < rB.length;) {
let rangeA = rA[iA], rangeB = rB[iB]
if (rangeA && rangeB && sameRanges(rangeA, rangeB, map)) { iA++; iB++ }
else if (rangeB && (!rangeA || map(rangeA.fromB) >= rangeB.fromB)) { add(rangeB.fromB, rangeB.toB); iB++ }
else { add(map(rangeA.fromB), map(rangeA.toB)); iA++ }
}
return from <= to ? {from, to} : null
}
/// Create a changeset with the given base object and configuration.
///
/// The `combine` function is used to compare and combine metadata—it
/// should return null when metadata isn't compatible, and a combined
/// version for a merged range when it is.
///
/// When given, a token encoder determines how document tokens are
/// serialized and compared when diffing the content produced by
/// changes. The default is to just compare nodes by name and text
/// by character, ignoring marks and attributes.
///
/// To serialize a change set, you can store its document and
/// change array as JSON, and then pass the deserialized (via
/// [`Change.fromJSON`](#changes.Change^fromJSON)) set of changes
/// as fourth argument to `create` to recreate the set.
static create<Data = any>(
doc: Node,
combine: (dataA: Data, dataB: Data) => Data = (a, b) => a === b ? a : null as any,
tokenEncoder: TokenEncoder<any> = DefaultEncoder,
changes: readonly Change<Data>[] = []
) {
return new ChangeSet({combine, doc, encoder: tokenEncoder}, changes)
}
/// Exported for testing @internal
static computeDiff = computeDiff
}
// Divide-and-conquer approach to merging a series of ranges.
function mergeAll<Data>(
ranges: readonly Change<Data>[],
combine: (dA: Data, dB: Data) => Data,
start = 0, end = ranges.length
): readonly Change<Data>[] {
if (end == start + 1) return [ranges[start]]
let mid = (start + end) >> 1
return Change.merge(mergeAll(ranges, combine, start, mid),
mergeAll(ranges, combine, mid, end), combine)
}
function endRange(maps: readonly StepMap[]) {
let from = 2e8, to = -2e8
for (let i = 0; i < maps.length; i++) {
let map = maps[i]
if (from != 2e8) {
from = map.map(from, -1)
to = map.map(to, 1)
}
map.forEach((_s, _e, start, end) => {
from = Math.min(from, start)
to = Math.max(to, end)
})
}
return from == 2e8 ? null : {from, to}
}
function touchedRange(maps: readonly StepMap[]) {
let b = endRange(maps)
if (!b) return null
let a = endRange(maps.map(m => m.invert()).reverse())!
return {fromA: a.from, toA: a.to, fromB: b.from, toB: b.to}
}
function sameRanges<Data>(a: Change<Data>, b: Change<Data>, map: (pos: number) => number) {
return map(a.fromB) == b.fromB && map(a.toB) == b.toB &&
sameSpans(a.deleted, b.deleted) && sameSpans(a.inserted, b.inserted)
}
function sameSpans<Data>(a: readonly Span<Data>[], b: readonly Span<Data>[]) {
if (a.length != b.length) return false
for (let i = 0; i < a.length; i++)
if (a[i].length != b[i].length || a[i].data !== b[i].data) return false
return true
}
Directory Contents
Dirs: 0 × Files: 5