@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
147 lines (136 loc) • 5.73 kB
JavaScript
import { ReplaceStep, Transform } from '@atlaskit/editor-prosemirror/transform';
/**
* A step paired with its inverse, as stored in the collab unconfirmed queue.
*/
/**
* `structure` is `@internal` on ReplaceStep but is part of the serialised form, so this is the
* supported way to read it. Structural replaces exist to stop rebased steps from overwriting
* content they should not, so they are never merged.
*/
var isStructural = function isStructural(step) {
return step.toJSON().structure === true;
};
var asPlainReplaceStep = function asPlainReplaceStep(step) {
return (
// Deliberately excludes ReplaceAroundStep — the position arithmetic below is only sound for a
// plain range replace.
step instanceof ReplaceStep && !isStructural(step) ? step : undefined
);
};
/**
* Do the two steps touch the same region of the document?
*
* `second` is expressed in the coordinate space *after* `first` has been applied, so `first`'s
* footprint there is the range its slice occupies: `[from, from + slice.size]`.
*/
var overlaps = function overlaps(first, second) {
var firstInsertedEnd = first.from + first.slice.size;
return second.from <= firstInsertedEnd && second.to >= first.from;
};
/**
* Merge using ProseMirror's own step merging, which handles adjacent insertions and adjacent
* deletions. Both the steps and their inverses have to merge for the result to be usable as a
* rebase buffer entry — inverses compose in reverse order.
*/
var mergeNatively = function mergeNatively(first, second) {
var step = first.step.merge(second.step);
if (!step) {
return null;
}
var inverted = second.inverted.merge(first.inverted);
if (!inverted) {
return null;
}
return {
step: step,
inverted: inverted
};
};
/**
* Merge any two overlapping steps by applying both to the document and describing the net change
* as a single minimal replacement.
*
* This is the general case: it copes with `second` reaching past `first` on either side, with
* partial overlaps, and with slices whose open depths differ — none of which can be resolved from
* the steps alone, because the composed slice has to be cut out of a real document.
*/
var mergeViaDocument = function mergeViaDocument(first, second, docBefore) {
var docAfter;
try {
var transform = new Transform(docBefore);
transform.step(first.step);
transform.step(second.step);
docAfter = transform.doc;
} catch (_unused) {
// `docBefore` did not match the steps (for example the steps have since been rebased).
return null;
}
var start = docBefore.content.findDiffStart(docAfter.content);
if (start === null) {
// The steps cancel each other out. There is no single step representing "no change", so
// leave the pair alone rather than inventing one.
return null;
}
var diffEnd = docBefore.content.findDiffEnd(docAfter.content);
if (!diffEnd) {
return null;
}
// findDiffStart and findDiffEnd can overshoot each other when the same text appears on both
// sides of the change; push the end out so the range stays well formed.
var endBefore = diffEnd.a,
endAfter = diffEnd.b;
var overshoot = start - Math.min(endBefore, endAfter);
if (overshoot > 0) {
endBefore += overshoot;
endAfter += overshoot;
}
var step = new ReplaceStep(start, endBefore, docAfter.slice(start, endAfter));
// Cheap guarantee that the composition is exact. If it is not, keep the original pair.
var applied = step.apply(docBefore);
if (applied.failed !== null || !applied.doc || !applied.doc.eq(docAfter)) {
return null;
}
return {
step: step,
inverted: step.invert(docBefore)
};
};
/**
* Merge two consecutive steps into the single step they are collectively equivalent to.
*
* `second` must be expressed in the coordinate space produced by `first` — that is, the two must be
* consecutive steps of the same document. The returned step maps the document from before `first`
* to after `second`, and the returned inverse maps it back, so the result is safe to use both on
* the wire and as a rebase buffer entry.
*
* Returns `null` whenever the steps do not overlap, or when the merge cannot be proven exact. A
* `null` result always means "keep the two steps as they are", never "the steps are equivalent to
* nothing".
*
* @param first The earlier step and its inverse.
* @param second The later step and its inverse.
* @param docBefore The document `first` applies to. Optional, but without it only ProseMirror's own
* adjacent-step merges can be resolved, because the composed slice otherwise has to be cut out of a
* real document. Callers lose it when the queue has been rebased over a remote step.
*/
export function mergeOverlappingSteps(first, second, docBefore) {
var firstStep = asPlainReplaceStep(first.step);
var secondStep = asPlainReplaceStep(second.step);
// Non-plain replaces are only ever merged by ProseMirror itself, which knows the rules for them.
if (!firstStep || !secondStep) {
return mergeNatively(first, second);
}
if (!overlaps(firstStep, secondStep)) {
return null;
}
// Try the O(1) strategy before the one that has to replay both steps against a document.
// `transformUnconfirmed` runs on every transaction, and adjacent insertions — which is what
// consecutive streaming frames often are — are exactly what ProseMirror merges natively.
var natively = mergeNatively(first, second);
if (natively) {
return natively;
}
// Anything else needs the document to cut the composed slice out of, so without one there is
// no merge that can be proven exact.
return docBefore ? mergeViaDocument(first, second, docBefore) : null;
}