@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
296 lines (286 loc) • 12.5 kB
JavaScript
import _typeof from "@babel/runtime/helpers/typeof";
import { AnalyticsStep, BatchAttrsStep, SetAttrsStep } from '@atlaskit/adf-schema/steps';
import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
import { TELEPOINTER_DATA_SESSION_ID_ATTR, TELEPOINTER_PULSE_DURING_TR_CLASS, TELEPOINTER_PULSE_DURING_TR_DURATION_MS } from '@atlaskit/editor-common/collab';
import { processRawValueWithoutValidation } from '@atlaskit/editor-common/process-raw-value';
import { ZERO_WIDTH_JOINER } from '@atlaskit/editor-common/whitespace';
import { Transaction, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { AttrStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { getParticipantColor } from '@atlaskit/editor-shared-styles';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { preserveNodeIdentity } from './preserve-node-identity';
export var findPointers = function findPointers(id, decorations) {
return decorations.find().reduce(function (arr, deco) {
return deco.spec.pointer.presenceId === id ? arr.concat(deco) : arr;
}, []);
};
function style(options) {
var color = options && options.color || "var(--ds-border, #0B120E24)";
var borderWidth = "var(--ds-border-width-focused, 2px)";
return "border-right: ".concat(borderWidth, " solid ").concat(color, "; margin-right: calc(-1 * ").concat(borderWidth, "); z-index: 1");
}
export function getAvatarColor(str) {
var participantColor = getParticipantColor(str);
return {
index: participantColor.index,
backgroundColor: participantColor.color.backgroundColor,
textColor: participantColor.color.textColor
};
}
export var createTelepointers = function createTelepointers(from, to, sessionId, isSelection, initial, presenceId, fullName, isNudged) {
var decorations = [];
var avatarColor = getAvatarColor(presenceId);
var color = avatarColor.index.toString();
if (isSelection) {
var className = "telepointer color-".concat(color, " telepointer-selection");
decorations.push(Decoration.inline(from, to, {
class: className
}, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
}
}));
}
var spaceJoinerBefore = document.createElement('span');
spaceJoinerBefore.textContent = ZERO_WIDTH_JOINER;
var spaceJoinerAfter = document.createElement('span');
spaceJoinerAfter.textContent = ZERO_WIDTH_JOINER;
var cursor = document.createElement('span');
cursor.textContent = ZERO_WIDTH_JOINER;
cursor.className = "telepointer color-".concat(color, " telepointer-selection-badge");
cursor.style.cssText = "".concat(style({
color: avatarColor.backgroundColor
}), ";");
cursor.setAttribute('aria-label', "".concat(fullName, " cursor position"));
cursor.setAttribute('role', 'button');
cursor.setAttribute(TELEPOINTER_DATA_SESSION_ID_ATTR, sessionId);
// If there is an ongoing expand animation, we'll keep the telepointer expanded
// until the keyframe animation is complete. Please note that this will restart the anim timer
// from 0 everytime it's re-added.
if (isNudged) {
cursor.classList.add(TELEPOINTER_PULSE_DURING_TR_CLASS);
}
var fullNameEl = document.createElement('span');
fullNameEl.textContent = fullName;
fullNameEl.className = 'telepointer-fullname';
fullNameEl.style.backgroundColor = avatarColor.backgroundColor;
fullNameEl.style.color = avatarColor.textColor;
fullNameEl.setAttribute('aria-hidden', 'true');
cursor.appendChild(fullNameEl);
var initialEl = document.createElement('span');
initialEl.textContent = initial;
initialEl.className = 'telepointer-initial';
initialEl.style.backgroundColor = avatarColor.backgroundColor;
initialEl.style.color = avatarColor.textColor;
initialEl.setAttribute('aria-hidden', 'true');
cursor.appendChild(initialEl);
return decorations.concat(Decoration.widget(to, spaceJoinerAfter, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
},
key: "telepointer-".concat(sessionId, "-zero")
})).concat(Decoration.widget(to, cursor, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
},
key: "telepointer-".concat(sessionId)
})).concat(Decoration.widget(to, spaceJoinerBefore, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
},
key: "telepointer-".concat(sessionId, "-zero")
}));
};
export var replaceDocument = function replaceDocument(doc, state, version, options, reserveCursor, editorAnalyticsAPI) {
var schema = state.schema,
tr = state.tr;
var parsedDoc = processRawValueWithoutValidation(schema, doc, editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.fireAnalyticsEvent);
var hasContent = !!(parsedDoc !== null && parsedDoc !== void 0 && parsedDoc.childCount);
var content = parsedDoc === null || parsedDoc === void 0 ? void 0 : parsedDoc.content;
if (hasContent && content && editorExperiment('platform_editor_preserve_node_identity', true, {
exposure: true
})) {
var preservedContent = preserveNodeIdentity(state.doc.content, content);
// If the entire content is identical, skip the replaceWith entirely
// and just update collab metadata. This avoids triggering a full
// document reconciliation for no-op replacements.
if (preservedContent === state.doc.content) {
tr.setMeta('addToHistory', false);
if (version !== undefined && options && options.useNativePlugin) {
var collabState = {
version: version,
unconfirmed: []
};
tr.setMeta('collab$', collabState);
}
return tr;
}
// Use the preserved fragment (reuses old node references for unchanged nodes)
// rather than the raw parsed content, so ProseMirror's view reconciliation
// can fast-match unchanged subtrees via referential identity (===).
tr.setMeta('addToHistory', false);
tr.replaceWith(0, state.doc.nodeSize - 2, preservedContent);
var selection = state.selection;
if (reserveCursor) {
if (selection.to < tr.doc.content.size - 2) {
var $from = tr.doc.resolve(selection.from);
var $to = tr.doc.resolve(selection.to);
var newselection = new TextSelection($from, $to);
tr.setSelection(newselection);
}
} else {
tr.setSelection(Selection.atStart(tr.doc));
}
tr.setMeta('replaceDocument', true);
if (version !== undefined && options && options.useNativePlugin) {
var _collabState = {
version: version,
unconfirmed: []
};
tr.setMeta('collab$', _collabState);
}
return tr;
}
if (hasContent) {
tr.setMeta('addToHistory', false);
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
tr.replaceWith(0, state.doc.nodeSize - 2, content);
var _selection = state.selection;
if (reserveCursor) {
// If the cursor is still in the range of the new document,
// keep where it was.
if (_selection.to < tr.doc.content.size - 2) {
var _$from = tr.doc.resolve(_selection.from);
var _$to = tr.doc.resolve(_selection.to);
var _newselection = new TextSelection(_$from, _$to);
tr.setSelection(_newselection);
}
} else {
tr.setSelection(Selection.atStart(tr.doc));
}
tr.setMeta('replaceDocument', true);
if (_typeof(version) !== undefined && options && options.useNativePlugin) {
var _collabState2 = {
version: version,
unconfirmed: []
};
tr.setMeta('collab$', _collabState2);
}
}
return tr;
};
export var scrollToCollabCursor = function scrollToCollabCursor(editorView, participants, sessionId, index, editorAnalyticsAPI) {
var selectedUser = participants[index];
if (selectedUser && selectedUser.cursorPos !== undefined && selectedUser.sessionId !== sessionId) {
var state = editorView.state;
var tr = state.tr;
var analyticsPayload = {
action: ACTION.MATCHED,
actionSubject: ACTION_SUBJECT.SELECTION,
eventType: EVENT_TYPE.TRACK
};
tr.setSelection(Selection.near(tr.doc.resolve(selectedUser.cursorPos)));
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 || editorAnalyticsAPI.attachAnalyticsEvent(analyticsPayload)(tr);
tr.scrollIntoView();
editorView.dispatch(tr);
if (!editorView.hasFocus()) {
editorView.focus();
}
}
};
export var getPositionOfTelepointer = function getPositionOfTelepointer(sessionId, decorationSet) {
var scrollPosition;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
decorationSet.find().forEach(function (deco) {
if (deco.type.spec.pointer.sessionId === sessionId) {
scrollPosition = deco.from;
}
});
return scrollPosition;
};
export var isReplaceStep = function isReplaceStep(step) {
return step instanceof ReplaceStep;
};
var _originalTransactionHasMeta = function originalTransactionHasMeta(transaction, metaTag) {
var hasMetaTag = Boolean(transaction.getMeta(metaTag));
if (hasMetaTag) {
return true;
}
var appendedTransaction = transaction.getMeta('appendedTransaction');
if (appendedTransaction instanceof Transaction) {
return _originalTransactionHasMeta(appendedTransaction, metaTag);
}
return false;
};
/**
* This list contains step attributes that do not result from a user action.
* All steps that contain ONLY the blocked attribute are considered automated steps
* and should not be recognised as organic change.
*
* `attr_colwidth` is an exception to above explanation. Resizing the column
* currently creates too many steps and is therefore also on this list.
*
* Steps analycs dashboard: https://atlassian-discover.cloud.databricks.com/dashboardsv3/01ef4d3c8aa916c8b0cb5332a9f37caf/published?o=4482001201517624
*/
export { _originalTransactionHasMeta as originalTransactionHasMeta };
var blockedAttrsList = ['__contextId', 'localId', '__autoSize', 'attr_colwidth', 'originalHeight', 'originalWidth'];
/**
* Takes the transaction and editor state and checks if the transaction is considered organic change
* @param tr Transaction
* @returns boolean
*/
export var isOrganicChange = function isOrganicChange(tr) {
// If document has not been marked as `docChanged` by PM, skip the rest of the logic
if (!tr.docChanged) {
return false;
}
return tr.steps.some(function (step) {
// If a step is an instance of AnalyticsStep, it is not considered organic
if (step instanceof AnalyticsStep) {
return false;
}
// editor-plugin-local-id uses AttrStep to set the localId attribute
if (step instanceof AttrStep && step.attr === 'localId') {
return false;
}
// editor-plugin-local-id uses BatchAttrStep to set the localId attribute
if (step instanceof BatchAttrsStep) {
var _allAttributes = step.data.map(function (data) {
return Object.keys(data.attrs);
}).flat();
return _allAttributes.some(function (attr) {
return !blockedAttrsList.includes(attr);
}) && !tr.doc.eq(tr.before);
}
// If a step is not an instance of SetAttrsStep, it is considered organic
if (!(step instanceof SetAttrsStep)) {
return true;
}
var allAttributes = Object.keys(step.attrs);
// If a step is an instance of SetAttrsStep, it checks if the attributes in the step
// are not in the `blockedAttributes`. If one of the attributes not on the list, it considers the change
// organic but only if the entire document is not equal to the previous state.
return allAttributes.some(function (attr) {
return !blockedAttrsList.includes(attr);
}) && !tr.doc.eq(tr.before);
});
};
// If we receive a transaction while there is an ongoing CSS expand animation in the telepointer,
// it will be cut off due to the removal of the element. We'll persist the animation state in the plugin,
// so we can keep the expanded version showing even when the telepointer element is recreated.
export var hasExistingNudge = function hasExistingNudge(sessionId, nudgeAnimations) {
var nudgeAnimStartTime = nudgeAnimations.get(sessionId);
var hasExistingNudge = false;
if (nudgeAnimStartTime) {
var timeElapsed = Date.now() - nudgeAnimStartTime;
hasExistingNudge = timeElapsed < TELEPOINTER_PULSE_DURING_TR_DURATION_MS;
}
return hasExistingNudge;
};