UNPKG

@atlaskit/editor-plugin-collab-edit

Version:

Collab Edit plugin for @atlaskit/editor-core

296 lines (286 loc) 12.5 kB
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; };