UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

309 lines (296 loc) • 11.5 kB
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; };