UNPKG

@atlaskit/editor-plugin-collab-edit

Version:

Collab Edit plugin for @atlaskit/editor-core

114 lines (108 loc) • 5.21 kB
import { STREAMING_COLLAPSIBLE_STEPS } from '@atlaskit/editor-common/collab-streaming-step-collapse'; import { Transaction } from '@atlaskit/editor-prosemirror/state'; import { Rebaseable } from '@atlaskit/prosemirror-collab'; import { mergeOverlappingSteps } from './mergeOverlappingSteps'; /** * Is this step an intermediate frame that its producer has marked as collapsible, and is it still * free to be composed? */ const isCollapsibleFrame = rebaseable => { const { origin } = rebaseable; if (!(origin instanceof Transaction) || origin.getMeta(STREAMING_COLLAPSIBLE_STEPS) !== true) { return false; } // Already sent to (or queued for) the collab service — composing it now would change a step the // backend has already seen. Mirrors the `mergeIsLocked` contract in `mergeUnconfirmedSteps`. return !origin.getMeta('mergeIsLocked'); }; /** * Starting document of each composed step this module has produced. * * A composed step is synthesised rather than taken from a transaction, so it cannot be found in * `origin.steps` and its starting document cannot be recovered the way an original frame's can. * `transformUnconfirmed` runs once per transaction and is handed back the steps it returned last * time, so the composed step needs to carry its document forward to keep composing against later * frames. * * Keyed on the step rather than its Rebaseable wrapper. `mergeUnconfirmedSteps` runs immediately * after this module in `transformUnconfirmed` and rebuilds every wrapper it is given — merging * enabled or not — so a wrapper-keyed entry would be unreachable by the very next pass. The step * survives that rebuild untouched, and is still replaced whenever the association genuinely * becomes stale: `rebaseSteps` maps steps into new objects, and a real merge composes a new one. */ const composedStepDocs = new WeakMap(); /** * The document this step was applied to. * * `transformUnconfirmed` is handed steps and nothing else, but a Rebaseable keeps a reference to * the transaction that produced it, and a transaction records the document before each of its * steps. That is enough for `mergeOverlappingSteps` to resolve merges it cannot derive from the * steps alone — notably a frame that replaces more than just what the previous frame inserted. * * Rebasing maps a step into a new object while keeping the original transaction, so a rebased step * is not found in `origin.steps` and correctly yields no document. `mergeOverlappingSteps` then * falls back to the merges it can prove without one. */ const docBeforeStep = rebaseable => { const { origin, step } = rebaseable; const composed = composedStepDocs.get(step); if (composed) { return composed; } const index = origin.steps.indexOf(step); return index >= 0 ? origin.docs[index] : undefined; }; /** * Compose adjacent streaming frames in the unconfirmed queue so the collab service receives one * step per flush instead of one step per rendered chunk. * * This runs in the collab plugin's `transformUnconfirmed` hook, which fires *after* each frame has * already been applied to the document. Collapsing therefore has no effect on what the local user * sees, on undo history, or on any other plugin state — it only changes what is sent to the collab * service, and what is used as the rebase buffer when remote steps arrive. * * Because the unconfirmed steps double as the rebase buffer (`rebaseSteps` applies their inverses * to the live document), the composition must be exact. `mergeOverlappingSteps` only returns a * merge it can prove exact, and this function degrades to a no-op whenever it declines. * * @param steps Rebaseable steps * @returns Rebaseable steps, with adjacent streaming frames composed */ export function collapseStreamingSteps(steps) { // Fast path: nothing to do unless at least two frames could possibly compose. if (steps.length < 2) { return steps; } const collapsed = []; // The document the run currently at the tail of `collapsed` started from. A composed step // still applies to the document its first frame applied to, so this survives each merge. let runDocBefore; for (const rebaseable of steps) { const previous = collapsed[collapsed.length - 1]; if (!previous || !isCollapsibleFrame(previous) || !isCollapsibleFrame(rebaseable)) { collapsed.push(rebaseable); runDocBefore = docBeforeStep(rebaseable); continue; } const merged = mergeOverlappingSteps(previous, rebaseable, runDocBefore); if (!merged) { collapsed.push(rebaseable); runDocBefore = docBeforeStep(rebaseable); continue; } // Keep the earliest origin, matching `mergeUnconfirmedSteps`. `commitUnconfirmedSteps` in // the collab provider tracks completion by origin identity, so keeping the earliest means // the composed step is not considered confirmed until the whole run has been acknowledged. const composed = new Rebaseable(merged.step, merged.inverted, previous.origin); if (runDocBefore) { composedStepDocs.set(composed.step, runDocBefore); } collapsed[collapsed.length - 1] = composed; } return collapsed; }