@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
151 lines (142 loc) • 6.9 kB
JavaScript
import { Decoration } from '@atlaskit/editor-prosemirror/view';
// Kept out of `plugin-state.ts` so all the agent-shimmer plumbing lives together and is easy to
// remove if the approach changes. `plugin-state` just reduces the active ranges and asks this module
// to build the decorations.
/** Default time the skeleton shimmer stays on the agent-authored content (ms). */
export const AGENT_SHIMMER_DEFAULT_DURATION_MS = 3000;
/**
* Default time the purple "just edited" highlight stays after the skeleton clears (ms); `0` disables
* the highlight phase so the shimmer just reveals the content.
*/
export const AGENT_EDIT_HIGHLIGHT_DEFAULT_DURATION_MS = 2000;
// Skeleton-loader bar over the agent-authored range, plus a Rovo AI telepointer at the end.
export const AGENT_SHIMMER_CLASS = 'collab-agent-shimmer';
// Purple "just edited" highlight shown over the range after the skeleton clears — same style as the
// editor AI "improve writing" in-editor highlight (subtle purple background + dashed purple underline).
export const AGENT_EDIT_HIGHLIGHT_CLASS = 'collab-agent-edit-highlight';
export const ROVO_AGENT_TELEPOINTER_CLASS = 'ai-in-editor-telepointer';
export const ROVO_AGENT_TELEPOINTER_LABEL_CLASS = 'ai-in-editor-telepointer-label';
export const ADD_AGENT_SHIMMER_META = 'addAgentShimmer'; // register the shimmer decorations
export const HIGHLIGHT_AGENT_SHIMMER_META = 'highlightAgentShimmer'; // skeleton → purple highlight phase
export const REMOVE_AGENT_SHIMMER_META = 'removeAgentShimmer'; // remove them once the shimmer ends
// A shimmer runs in two phases: the `skeleton` loader, then (optionally) the purple `highlight` over
// the revealed content, before removal.
// A range an agent step wrote; the shimmer decorations are drawn over `from`..`to` and kept until
// removal (so positions can be re-mapped). Pure data only. `phase` selects skeleton vs purple
// highlight. `telepointerLabel` is the label for the trailing agent telepointer (shown through both
// phases); when absent, no telepointer is shown. `highlightDurationMs` is the purple-highlight
// lifetime, used to size its ease in/out animation so it matches the removal timer.
// Rovo AI in-editor telepointer/cursor shown at the end of an agent-authored range (same DOM/style
// pattern as editor-plugin-ai's in-editor direct-streaming telepointer).
const createRovoAgentTelepointer = label => {
const element = document.createElement('span');
element.setAttribute('data-testid', 'ai-in-editor-telepointer-widget');
element.className = ROVO_AGENT_TELEPOINTER_CLASS;
const labelElement = document.createElement('span');
labelElement.setAttribute('data-testid', 'ai-in-editor-telepointer-widget-label');
labelElement.className = ROVO_AGENT_TELEPOINTER_LABEL_CLASS;
labelElement.append(label);
element.appendChild(labelElement);
return element;
};
/**
* Pure reducer for the active shimmer ranges from a transaction's changes. Maps existing ranges
* forward as the doc changes, replaces them wholesale when a new agent batch lands (a new batch
* supersedes any still-in-flight shimmer), and drops a range when its removal timer fires. Returns a
* fresh array (never mutates in place) plus whether anything changed.
*/
export const reduceAgentShimmers = (current, tr, added, removedShimmerId, highlightShimmerId) => {
let next = current;
let changed = false;
// Ranges added in THIS transaction are already in post-change coords, so map the pre-existing
// ones BEFORE replacing with any new batch.
if (tr.docChanged && next.length) {
next = next.map(shimmer => ({
...shimmer,
from: tr.mapping.map(shimmer.from, -1),
to: tr.mapping.map(shimmer.to, 1)
}));
changed = true;
}
if (added !== null && added !== void 0 && added.length) {
next = added.map(shimmer => ({
...shimmer
}));
changed = true;
}
// Transition a shimmer from the skeleton phase to the purple highlight phase (skeleton timer fired).
// Guard on an actual match so a timer firing after the shimmer was already removed (superseded by a
// new batch, or cleared) doesn't rebuild the array and trigger a pointless decoration rebuild.
if (highlightShimmerId && next.some(shimmer => shimmer.shimmerId === highlightShimmerId)) {
next = next.map(shimmer => shimmer.shimmerId === highlightShimmerId ? {
...shimmer,
phase: 'highlight'
} : shimmer);
changed = true;
}
if (removedShimmerId) {
next = next.filter(shimmer => shimmer.shimmerId !== removedShimmerId);
changed = true;
}
return {
changed,
next
};
};
/**
* Builds the inline decorations (skeleton bar or purple highlight, per phase) plus the trailing
* telepointer for the active shimmer ranges. `getValidPos` clamps a raw position to a valid
* decoration position (owned by `plugin-state`). One bad range is isolated via `onError` so it can't
* kill the others.
*/
export const buildAgentShimmerDecorations = (tr, shimmers, getValidPos, onError) => {
const decorations = [];
shimmers.forEach(({
shimmerId,
from,
to,
telepointerLabel,
phase,
highlightDurationMs
}) => {
try {
// `getValidPos` already clamps to the last valid position, so only the lower bound needs
// guarding here (a raw `from < 1` would throw in `doc.resolve`).
const validFrom = getValidPos(tr, Math.max(from, 1));
const validTo = getValidPos(tr, to);
if (validTo <= validFrom) {
return;
}
// Inline decoration over the whole range: the grey skeleton loader, or (once revealed) the
// purple "just edited" highlight. The highlight eases in and out over its lifetime via a CSS
// animation whose duration is set inline so it matches the removal timer.
const isHighlight = phase === 'highlight';
const inlineAttrs = {
class: isHighlight ? AGENT_EDIT_HIGHLIGHT_CLASS : AGENT_SHIMMER_CLASS
};
if (isHighlight && highlightDurationMs > 0) {
inlineAttrs.style = `animation-duration: ${highlightDurationMs}ms`;
}
decorations.push(Decoration.inline(validFrom, validTo, inlineAttrs, {
isAgentShimmer: true,
shimmerId
}));
// Rovo AI telepointer/cursor (labelled with the agent's type) at the end of the range, shown
// through BOTH the skeleton and purple-highlight phases so the agent's cursor stays put until
// the edit is fully revealed.
if (telepointerLabel) {
decorations.push(Decoration.widget(validTo, createRovoAgentTelepointer(telepointerLabel), {
isAgentShimmer: true,
shimmerId,
class: ROVO_AGENT_TELEPOINTER_CLASS,
key: `agent-telepointer-${shimmerId}`,
side: 1
}));
}
} catch (err) {
// One bad range must not kill the others.
onError(err);
}
});
return decorations;
};