@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
309 lines (296 loc) • 11.5 kB
JavaScript
import { simplifyChanges } from 'prosemirror-changeset';
import { getAttributionKey } from '../decorations/colorSchemes/attributions';
import { optimizeChanges } from './optimizeChanges';
/**
* A change, plus the block nodes it owns when it is one piece of a split — a piece's own range no
* longer covers the container it opened. Absent on an unsplit change, which decorates the blocks
* inside its own range as before.
*/
const getAttributionIdentity = change => {
const identities = new Set();
for (const span of [...change.deleted, ...change.inserted]) {
identities.add(getAttributionKey(span.data));
}
if (identities.size > 1) {
return {
key: undefined,
mergeable: false
};
}
return {
key: identities.values().next().value,
mergeable: true
};
};
const hasContributor = change => [...change.deleted, ...change.inserted].some(span => getAttributionKey(span.data) !== undefined);
const byNewDocRange = (left, right) => left.fromB - right.fromB || left.toB - right.toB;
/** Union of the given ranges, with overlapping and touching ones merged. */
const mergeRanges = ranges => [...ranges].sort((left, right) => left.from - right.from).reduce((merged, range) => {
const last = merged[merged.length - 1];
if (last && range.from <= last.to) {
last.to = Math.max(last.to, range.to);
return merged;
}
return [...merged, {
...range
}];
}, []);
/** Whether `inner` sits wholly inside `outer` and covers less of the document. */
const isContained = (outer, inner) => inner.toB > inner.fromB && outer.fromB <= inner.fromB && inner.toB <= outer.toB && inner.toB - inner.fromB < outer.toB - outer.fromB;
/**
* The block nodes in `change` whose opening position `ownsPosition` claims. Ownership is by opening
* position so a block another contributor added inside a container is not claimed by whoever opened
* the container. `undefined` for a range this document cannot resolve, which falls the piece back
* to decorating its own range.
*/
const blockNodeRanges = (doc, change, ownsPosition) => {
if (!doc || change.from >= change.to || change.to > doc.content.size) {
return undefined;
}
const ranges = [];
doc.nodesBetween(change.from, change.to, (node, pos) => {
if (node.isBlock && ownsPosition(pos) && pos + node.nodeSize <= change.to) {
ranges.push({
from: pos,
to: pos + node.nodeSize
});
}
return true;
});
return ranges;
};
/**
* Splits a change whose spans name more than one contributor into one change per run of consecutive
* spans sharing a key.
*
* `prosemirror-changeset` merges contiguous inserted content into one change while keeping per-span
* data, so a panel inserted by one contributor and edited inside by another arrives as a single
* change holding every span — and the latest-writer rule credited all of it, panel included, to
* whoever wrote last (EDITOR-8932).
*/
const splitMixedIdentityChanges = (changes, doc) => changes.flatMap(change => {
var _change$inserted$;
// A replacement can carry an anonymous deletion and an authored insertion. The deleted side
// needs its own zero-width change or the insertion's actor claims the deletion too.
const deletedKeys = new Set(change.deleted.map(span => getAttributionKey(span.data)));
const firstInsertedKey = getAttributionKey((_change$inserted$ = change.inserted[0]) === null || _change$inserted$ === void 0 ? void 0 : _change$inserted$.data);
const detachDeleted = change.deleted.length > 0 && change.inserted.length > 0 && deletedKeys.size === 1 && !deletedKeys.has(firstInsertedKey) && (deletedKeys.has(undefined) || firstInsertedKey === undefined);
const deletedChange = detachDeleted ? [{
...change,
toB: change.fromB,
inserted: []
}] : [];
const contentChange = detachDeleted ? {
...change,
fromA: change.toA,
deleted: []
} : change;
if (getAttributionIdentity(contentChange).mergeable) {
return [...deletedChange, contentChange];
}
const runs = [];
let offset = contentChange.fromB;
for (const span of contentChange.inserted) {
const key = getAttributionKey(span.data);
const last = runs[runs.length - 1];
if (last && last.key === key) {
last.to = offset + span.length;
last.spans.push(span);
} else {
runs.push({
key,
from: offset,
to: offset + span.length,
spans: [span]
});
}
offset += span.length;
}
// The offsets above are only positions if the spans partition the inserted range.
if (runs.length < 2 || offset !== contentChange.toB) {
return [...deletedChange, contentChange];
}
return [...deletedChange, ...runs.map((run, index) => {
const owned = blockNodeRanges(doc, {
from: contentChange.fromB,
to: contentChange.toB
}, pos => pos >= run.from && pos < run.to);
return {
...contentChange,
fromA: index === 0 ? contentChange.fromA : contentChange.toA,
fromB: run.from,
toB: run.to,
deleted: index === 0 ? contentChange.deleted : [],
inserted: run.spans,
...(owned ? {
blockNodeRangesB: owned
} : {})
};
})];
});
/**
* Subtracts every differently attributed contained change from a change's range, including when
* one has no contributor. Applies when the changes arrive separately rather than as spans of one —
* the `step` diff type. A contained change by the same author, and one that only partially
* overlaps, are left for the collapse below.
*
* The leading fragment keeps the deleted side, so one deletion is not drawn once per fragment.
*/
const splitContainedChanges = (changes, doc) => changes.flatMap(change => {
const {
key,
mergeable
} = getAttributionIdentity(change);
const inner = mergeRanges(changes.filter(other => {
const otherIdentity = getAttributionIdentity(other);
return other !== change && mergeable && otherIdentity.mergeable && otherIdentity.key !== key && isContained(change, other);
}).map(({
fromB,
toB
}) => ({
from: fromB,
to: toB
})));
if (inner.length === 0) {
return [change];
}
const gaps = [];
let cursor = change.fromB;
for (const {
from,
to
} of inner) {
if (from > cursor) {
gaps.push({
from: cursor,
to: from
});
}
cursor = Math.max(cursor, to);
}
if (change.toB > cursor) {
gaps.push({
from: cursor,
to: change.toB
});
}
// Subtracted end to end, so nothing is left to highlight — but the blocks are still owned.
const fragments = gaps.length > 0 ? gaps : [{
from: change.toB,
to: change.toB
}];
// The outer author's: every block in their range that no inner change opened.
const owned = blockNodeRanges(doc, {
from: change.fromB,
to: change.toB
}, pos => !inner.some(({
from,
to
}) => pos >= from && pos < to));
return fragments.map((fragment, index) => ({
...change,
fromA: index === 0 ? change.fromA : change.toA,
fromB: fragment.from,
toB: fragment.to,
deleted: index === 0 ? change.deleted : [],
...(owned ? {
blockNodeRangesB: index === 0 ? owned : []
} : {})
}));
});
/**
* The blocks a merged change owns. Both sides must name theirs, or the merge falls back to its own
* range — narrowing it could drop a block altogether.
*/
const mergeBlockNodeRanges = (left, right) => left.blockNodeRangesB && right.blockNodeRangesB ? [...left.blockNodeRangesB, ...right.blockNodeRangesB] : undefined;
/** Unions overlapping changes, except when only one side names a contributor. */
const collapse = changes => {
// A zero-length new-document range paints no inline decoration, so it is kept aside rather than
// folded into a neighbouring insertion.
const emptyRanges = changes.filter(change => change.toB <= change.fromB);
const collapsed = [];
for (const change of changes.filter(change => change.toB > change.fromB).sort(byNewDocRange)) {
const current = collapsed[collapsed.length - 1];
const currentHasContributor = current && hasContributor(current);
const changeHasContributor = hasContributor(change);
if (current && change.fromB < current.toB && currentHasContributor === changeHasContributor) {
const blockNodeRangesB = mergeBlockNodeRanges(current, change);
collapsed[collapsed.length - 1] = {
fromA: Math.min(current.fromA, change.fromA),
toA: Math.max(current.toA, change.toA),
fromB: Math.min(current.fromB, change.fromB),
toB: Math.max(current.toB, change.toB),
deleted: [...current.deleted, ...change.deleted],
inserted: [...current.inserted, ...change.inserted],
...(blockNodeRangesB ? {
blockNodeRangesB
} : {})
};
} else {
collapsed.push({
...change
});
}
}
return [...collapsed, ...emptyRanges].sort(byNewDocRange);
};
/**
* Collapses changes whose new-document ranges strictly overlap. Attribution runs are simplified
* independently, so runs either side of an unattributed step can come back covering the same
* characters — which stacks two inline decorations and renders one contributor tag per copy.
*
* Merging keeps every span from both sides, so the latest-writer rule still decides whose tag is
* shown among attributed edits. An unattributed change never merges with an attributed one.
* Ranges that merely touch are left alone.
*
* Contributors are separated first, by `splitMixedIdentityChanges` and `splitContainedChanges`.
* Both leave ranges that only touch, so they survive the collapse.
*
* Carries no gate of its own: the only caller reaches it through the attributed changeset, which
* already requires `confluence_ncs_step_diffing_version_history`.
*/
export const collapseOverlappingChanges = (changes, doc) => collapse(splitContainedChanges(splitMixedIdentityChanges(changes, doc), doc));
/**
* Simplifies and optimizes consecutive changes without crossing an attribution boundary.
* A change which already contains more than one identity is deliberately kept isolated.
*/
export const simplifyChangesWithAttribution = (changes, doc) => {
const result = [];
let run = [];
let runIdentity;
const flush = () => {
if (run.length > 0) {
// Some show-diff producers use structurally compatible Change objects rather than the
// library class. `simplifyChanges` reads runtime `lenA`/`lenB` getters even though they
// are intentionally omitted from its public declarations, so add equivalent getters
// without calling the library's internal constructor.
const normalizedRun = run.map(change => ({
...change,
get lenA() {
return change.toA - change.fromA;
},
get lenB() {
return change.toB - change.fromB;
}
}));
result.push(...optimizeChanges(simplifyChanges(normalizedRun, doc)));
}
run = [];
runIdentity = undefined;
};
for (const change of changes) {
var _runIdentity;
const identity = getAttributionIdentity(change);
const canJoinRun = ((_runIdentity = runIdentity) === null || _runIdentity === void 0 ? void 0 : _runIdentity.mergeable) === true && identity.mergeable && runIdentity.key === identity.key;
if (run.length > 0 && !canJoinRun) {
flush();
}
run.push(change);
runIdentity = identity;
if (!identity.mergeable) {
flush();
}
}
flush();
return result;
};