@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
269 lines (261 loc) • 13.9 kB
JavaScript
import { slicesEqualIgnoringLocalId } from '@atlaskit/editor-common/collab-agent-review-slice-compare';
import { logException } from '@atlaskit/editor-common/monitoring';
import { AddMarkStep, RemoveMarkStep } from '@atlaskit/editor-prosemirror/transform';
import { getCollabState } from '@atlaskit/prosemirror-collab';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
// When an agent step lands we cover the top-level block(s) it wrote with a skeleton-loader shimmer
// (plus a Rovo agent telepointer at the end of the range), then remove it on a timer to reveal the
// content. Gated behind the `platform_editor_agent_be_streaming` experiment. `shimmerDurationMs` and
// `highlightDurationMs` size the skeleton and purple-highlight phases and toggle independently: `0` on
// either skips that phase, `0` on both shows nothing.
var agentShimmerIdCounter = 0;
// A step is position-neutral when its StepMap changes no range's length — i.e. it shifts no
// positions. Attribute-only steps (e.g. `localId` assignment) produce an empty StepMap, and
// same-size replacements preserve lengths, so both are position-neutral. Used to decide whether a
// rebase over local unconfirmed steps could have invalidated our index-based range math.
export var isPositionNeutralStep = function isPositionNeutralStep(step) {
var neutral = true;
step.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) {
if (oldEnd - oldStart !== newEnd - newStart) {
neutral = false;
}
});
return neutral;
};
// Mark-only steps (AddMarkStep / RemoveMarkStep) change no positions, so their StepMap is empty and
// `getMap().forEach` never yields a range. Their affected span is carried directly on `step.from` /
// `step.to` (valid in the step's input-doc coordinates). Returning it lets the range builders below
// treat a formatting-only agent edit (e.g. bold) as a real change rather than silently dropping it.
export var getMarkStepRange = function getMarkStepRange(step) {
if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) {
return {
from: step.from,
to: step.to
};
}
return null;
};
// Top-level block/position helpers over a doc. Kept at module scope (rather than re-created as
// closures on every call) so they are defined once, reusable, and unit-testable. Each clamps into
// valid document coordinates before resolving.
var clampToDoc = function clampToDoc(doc, pos) {
return Math.min(Math.max(pos, 1), doc.content.size);
};
var topLevelBlockIndexAt = function topLevelBlockIndexAt(doc, pos) {
return doc.resolve(clampToDoc(doc, pos)).index(0);
};
// Start of the content of the top-level block containing `pos`, so the whole block is covered.
var topLevelBlockContentStart = function topLevelBlockContentStart(doc, pos) {
var $pos = doc.resolve(clampToDoc(doc, pos));
return $pos.depth >= 1 ? $pos.start(1) : pos;
};
// End of the content of the top-level block containing `pos`, so the whole block is covered.
var topLevelBlockContentEnd = function topLevelBlockContentEnd(doc, pos) {
var $pos = doc.resolve(clampToDoc(doc, pos));
return $pos.depth >= 1 ? $pos.end(1) : pos;
};
/**
* Derive the shimmer ranges for the agent-authored steps in a received batch, in final-doc
* coordinates. `agentType` present ⇒ agent-authored (per the NCS↔Editor steps contract). Each
* emitted range is expanded to the whole top-level block(s) the agent touched, which the plugin
* covers with the skeleton shimmer. Ranges with no new content (pure deletions) are dropped.
*
* Steps whose new content is in the same or directly-adjacent top-level block are coalesced into one
* range, so an edit that arrives as several steps in a region shimmers as a single unit. Edits
* separated by an untouched block stay independent.
*
* Correctness: the range math assumes `tr` is a linear 1:1 apply of `steps`. Under the native collab
* plugin, `receiveTransaction` rebases incoming steps over unconfirmed local steps when they exist;
* that only invalidates our positions if a local step shifted positions, so we skip solely when a
* rebased local step changed sizes (a rare, safe degrade). Any unexpected error also degrades to no
* shimmer, so this never throws into the shared remote-step handler.
*/
var deriveAgentShimmerRanges = function deriveAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown) {
var _json$find;
// The two phases toggle independently: `shimmerDurationMs` sizes the skeleton, `highlightDurationMs`
// sizes the purple highlight, and `0` on either skips just that phase. Nothing to reveal only when
// both are off.
if (shimmerDurationMs <= 0 && highlightDurationMs <= 0) {
return [];
}
// Start in the skeleton phase when it's enabled, otherwise straight into the purple highlight phase
// (skeleton toggled off, highlight on).
var initialPhase = shimmerDurationMs > 0 ? 'skeleton' : 'highlight';
// Telepointer label = the agent's type upper-cased (e.g. `mcp` → "MCP"), falling back to a generic
// "Agent"; `undefined` when the telepointer is disabled, so the plugin skips it. `agentType` is the
// same on every step of an agent batch, so read it from the first agent-authored step.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var agentType = (_json$find = json.find(function (step) {
return typeof (step === null || step === void 0 ? void 0 : step.agentType) === 'string';
})) === null || _json$find === void 0 ? void 0 : _json$find.agentType;
// No agent-authored steps in this batch — nothing to shimmer, and not a "not shown" case.
if (agentType === undefined) {
return [];
}
var telepointerLabel = telepointerEnabled ? agentType.toUpperCase() || 'Agent' : undefined;
// When the batch was rebased over local unconfirmed steps, our index-based range math is only
// valid if those local steps shifted no positions. Attribute-only steps (e.g. `localId`) and
// same-size replacements are position-neutral, so the shimmer stays correct. Skip only when a
// local step actually changed sizes (a genuine concurrent content edit).
if (Number(tr.getMeta('rebased')) > 0) {
var _getCollabState$uncon, _getCollabState;
var unconfirmed = (_getCollabState$uncon = (_getCollabState = getCollabState(view.state)) === null || _getCollabState === void 0 ? void 0 : _getCollabState.unconfirmed) !== null && _getCollabState$uncon !== void 0 ? _getCollabState$uncon : [];
if (unconfirmed.some(function (entry) {
return !isPositionNeutralStep(entry.step);
})) {
onNotShown === null || onNotShown === void 0 || onNotShown('rebasedConcurrentEdit', agentType);
return [];
}
}
try {
// Map an inserted range in doc_{i+1} forward through the remaining steps to final-doc coords.
// (A later step's own inserted content starts at its `from`; only subsequent steps shift it.)
var mapToFinalDoc = function mapToFinalDoc(pos, stepIndex, bias) {
var p = pos;
for (var j = stepIndex + 1; j < steps.length; j++) {
p = steps[j].getMap().map(p, bias);
}
return p;
};
// Derive each agent step's changed ranges from its StepMap — the canonical, step-type-agnostic
// source of what a step wrote. We only need the NEW extent (the content now in the document), in
// final-doc coords, since the highlight decorates content that is already present.
var infos = [];
json.forEach(function (rawStep, index) {
var _tr$docs$index2;
if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var pmStep = steps[index];
if (typeof (pmStep === null || pmStep === void 0 ? void 0 : pmStep.getMap) !== 'function') {
return;
}
// Mark-only steps have an empty StepMap, so the getMap().forEach below never fires for them.
// Derive their range from step.from/.to instead so formatting-only agent edits still shimmer.
var markRange = getMarkStepRange(steps[index]);
if (markRange) {
var _tr$docs$index;
var beforeDocForMark = (_tr$docs$index = tr.docs[index]) !== null && _tr$docs$index !== void 0 ? _tr$docs$index : tr.before;
var from = mapToFinalDoc(markRange.from, index, -1);
var to = mapToFinalDoc(markRange.to, index, 1);
if (to > from) {
try {
var before = beforeDocForMark.slice(markRange.from, markRange.to);
var after = tr.doc.slice(from, to);
if (!slicesEqualIgnoringLocalId(before, after)) {
infos.push({
from: from,
to: to
});
}
} catch (error) {
// Degrade gracefully (no shimmer for this step) but track the error so a silent
// fall-through does not hide a systemic slicing problem.
logException(error, {
location: 'editor-plugin-collab-edit/agent-shimmer-ranges/markStepSlice'
});
}
}
return;
}
// The doc this step was applied to (its `old` coords resolve here). `tr.docs[index]` is the
// document state before step `index`; fall back to the batch's before-doc for the first step.
var beforeDoc = (_tr$docs$index2 = tr.docs[index]) !== null && _tr$docs$index2 !== void 0 ? _tr$docs$index2 : tr.before;
pmStep.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) {
if (newEnd <= newStart) {
return; // pure deletion / attribute-only — no new content to highlight
}
var from = mapToFinalDoc(newStart, index, -1);
var to = mapToFinalDoc(newEnd, index, 1);
// Skip phantom same-size re-writes (e.g. a `localId`/breakout re-stamp on an untouched
// panel): their StepMap reports new content but nothing visibly changed. Drop only when
// before/after are equal ignoring `localId` and layout marks, so real same-size edits
// still shimmer. Mirrors the Review-moment segment producer's guard.
if (oldEnd - oldStart === newEnd - newStart) {
try {
var _before = beforeDoc.slice(oldStart, oldEnd);
var _after = tr.doc.slice(from, to);
if (slicesEqualIgnoringLocalId(_before, _after)) {
return;
}
} catch (_unused) {
// Comparison unsafe: keep the range; whole-block expansion still renders it.
}
}
infos.push({
from: from,
to: to
});
});
});
if (!infos.length) {
onNotShown === null || onNotShown === void 0 || onNotShown('nothingToShow', agentType);
return [];
}
// Group the changed ranges by the TOP-LEVEL block they landed in, then highlight each touched
// block IN FULL. NCS emits a single agent edit as many small replace fragments and often keeps a
// common prefix/suffix untouched, so the changed sub-ranges cover only part of a block (e.g. half
// a rewritten heading). Expanding to the whole block's content makes the entire heading/paragraph
// shimmer as one unit rather than leaving the unchanged half undecorated. Directly-adjacent
// touched blocks (index n and n+1) merge into one group so a multi-block rewrite reveals together;
// a genuinely untouched block in between (index gap > 1) splits the run, so far-apart edits stay
// independent.
var sorted = [].concat(infos).sort(function (a, b) {
return a.from - b.from || a.to - b.to;
});
var groups = [];
sorted.forEach(function (info) {
var block = topLevelBlockIndexAt(tr.doc, info.from);
var current = groups[groups.length - 1];
if (current && block <= current.maxBlock + 1) {
current.to = Math.max(current.to, info.to);
current.maxBlock = Math.max(current.maxBlock, block);
} else {
groups.push({
from: info.from,
to: info.to,
maxBlock: block
});
}
});
return groups.map(function (group) {
return {
shimmerId: "agent-shimmer-".concat(agentShimmerIdCounter++),
from: topLevelBlockContentStart(tr.doc, group.from),
to: topLevelBlockContentEnd(tr.doc, group.to),
telepointerLabel: telepointerLabel,
phase: initialPhase,
highlightDurationMs: highlightDurationMs
};
});
} catch (err) {
// Never let shimmer range derivation throw into the shared remote-step handler; degrade to no
// shimmer.
onNotShown === null || onNotShown === void 0 || onNotShown('captureThrew', agentType, err);
return [];
}
};
export var getAgentShimmerRanges = function getAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown) {
if (!expValEquals('platform_editor_agent_be_streaming', 'isEnabled', true)) {
return [];
}
return deriveAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown);
};
/**
* Produces only the neutral changed ranges needed by the shared AI chrome.
* The caller supplies lifetime configuration and any presentation override separately,
* together with these ranges, in `AgentEditChromeData`.
*/
export var getAgentEditChromeRanges = function getAgentEditChromeRanges(json, steps, tr, view, onNotShown) {
// Reuse legacy range derivation with skeleton and telepointer output disabled. A non-zero
// highlight duration keeps capture enabled; the returned legacy phase and duration are discarded.
return deriveAgentShimmerRanges(json, steps, tr, view, 0, 1, false, onNotShown).map(function (_ref) {
var from = _ref.from,
to = _ref.to;
return {
from: from,
to: to
};
});
};