@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
304 lines (293 loc) • 13.2 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.findPointers = exports.createTelepointers = void 0;
exports.getAvatarColor = getAvatarColor;
exports.scrollToCollabCursor = exports.replaceDocument = exports.originalTransactionHasMeta = exports.isReplaceStep = exports.isOrganicChange = exports.hasExistingNudge = exports.getPositionOfTelepointer = void 0;
var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
var _steps = require("@atlaskit/adf-schema/steps");
var _analytics = require("@atlaskit/editor-common/analytics");
var _collab = require("@atlaskit/editor-common/collab");
var _processRawValue = require("@atlaskit/editor-common/process-raw-value");
var _whitespace = require("@atlaskit/editor-common/whitespace");
var _state = require("@atlaskit/editor-prosemirror/state");
var _transform = require("@atlaskit/editor-prosemirror/transform");
var _view = require("@atlaskit/editor-prosemirror/view");
var _editorSharedStyles = require("@atlaskit/editor-shared-styles");
var _experiments = require("@atlaskit/tmp-editor-statsig/experiments");
var _preserveNodeIdentity = require("./preserve-node-identity");
var findPointers = exports.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");
}
function getAvatarColor(str) {
var participantColor = (0, _editorSharedStyles.getParticipantColor)(str);
return {
index: participantColor.index,
backgroundColor: participantColor.color.backgroundColor,
textColor: participantColor.color.textColor
};
}
var createTelepointers = exports.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(_view.Decoration.inline(from, to, {
class: className
}, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
}
}));
}
var spaceJoinerBefore = document.createElement('span');
spaceJoinerBefore.textContent = _whitespace.ZERO_WIDTH_JOINER;
var spaceJoinerAfter = document.createElement('span');
spaceJoinerAfter.textContent = _whitespace.ZERO_WIDTH_JOINER;
var cursor = document.createElement('span');
cursor.textContent = _whitespace.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(_collab.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(_collab.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(_view.Decoration.widget(to, spaceJoinerAfter, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
},
key: "telepointer-".concat(sessionId, "-zero")
})).concat(_view.Decoration.widget(to, cursor, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
},
key: "telepointer-".concat(sessionId)
})).concat(_view.Decoration.widget(to, spaceJoinerBefore, {
pointer: {
sessionId: sessionId,
presenceId: presenceId
},
key: "telepointer-".concat(sessionId, "-zero")
}));
};
var replaceDocument = exports.replaceDocument = function replaceDocument(doc, state, version, options, reserveCursor, editorAnalyticsAPI) {
var schema = state.schema,
tr = state.tr;
var parsedDoc = (0, _processRawValue.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 && (0, _experiments.editorExperiment)('platform_editor_preserve_node_identity', true, {
exposure: true
})) {
var preservedContent = (0, _preserveNodeIdentity.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 _state.TextSelection($from, $to);
tr.setSelection(newselection);
}
} else {
tr.setSelection(_state.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 _state.TextSelection(_$from, _$to);
tr.setSelection(_newselection);
}
} else {
tr.setSelection(_state.Selection.atStart(tr.doc));
}
tr.setMeta('replaceDocument', true);
if ((0, _typeof2.default)(version) !== undefined && options && options.useNativePlugin) {
var _collabState2 = {
version: version,
unconfirmed: []
};
tr.setMeta('collab$', _collabState2);
}
}
return tr;
};
var scrollToCollabCursor = exports.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: _analytics.ACTION.MATCHED,
actionSubject: _analytics.ACTION_SUBJECT.SELECTION,
eventType: _analytics.EVENT_TYPE.TRACK
};
tr.setSelection(_state.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();
}
}
};
var getPositionOfTelepointer = exports.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;
};
var isReplaceStep = exports.isReplaceStep = function isReplaceStep(step) {
return step instanceof _transform.ReplaceStep;
};
var _originalTransactionHasMeta = exports.originalTransactionHasMeta = function originalTransactionHasMeta(transaction, metaTag) {
var hasMetaTag = Boolean(transaction.getMeta(metaTag));
if (hasMetaTag) {
return true;
}
var appendedTransaction = transaction.getMeta('appendedTransaction');
if (appendedTransaction instanceof _state.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
*/
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
*/
var isOrganicChange = exports.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 _steps.AnalyticsStep) {
return false;
}
// editor-plugin-local-id uses AttrStep to set the localId attribute
if (step instanceof _transform.AttrStep && step.attr === 'localId') {
return false;
}
// editor-plugin-local-id uses BatchAttrStep to set the localId attribute
if (step instanceof _steps.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 _steps.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.
var hasExistingNudge = exports.hasExistingNudge = function hasExistingNudge(sessionId, nudgeAnimations) {
var nudgeAnimStartTime = nudgeAnimations.get(sessionId);
var hasExistingNudge = false;
if (nudgeAnimStartTime) {
var timeElapsed = Date.now() - nudgeAnimStartTime;
hasExistingNudge = timeElapsed < _collab.TELEPOINTER_PULSE_DURING_TR_DURATION_MS;
}
return hasExistingNudge;
};