UNPKG

@atlaskit/editor-plugin-collab-edit

Version:

Collab Edit plugin for @atlaskit/editor-core

304 lines (293 loc) 13.2 kB
"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; };