@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
275 lines (273 loc) • 13.8 kB
JavaScript
import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics/types/enums';
import { isEmptyDocument } from '@atlaskit/editor-common/utils';
import { JSONTransformer } from '@atlaskit/editor-json-transformer/JSONTransformer-2';
import { AddMarkStep, AddNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { collab, getCollabState, sendableSteps } from '@atlaskit/prosemirror-collab';
import { addSynchronyErrorAnalytics } from './pm-plugins/analytics';
import { createCollabSendHold } from './pm-plugins/collabSendHold';
import { collapseStreamingSteps } from './pm-plugins/collapseStreamingSteps';
import { sendTransaction } from './pm-plugins/events/send-transaction';
import { filterAnalyticsSteps } from './pm-plugins/filterAnalytics';
import { createPlugin } from './pm-plugins/main';
import { pluginKey as mainPluginKey } from './pm-plugins/main/plugin-key';
import { mergeUnconfirmedSteps } from './pm-plugins/mergeUnconfirmed';
import { monitorOrganic } from './pm-plugins/monitor-organic-changes';
import { nativeCollabProviderPlugin } from './pm-plugins/native-collab-provider-plugin';
import { sanitizeFilteredStep, createPlugin as trackSpammingStepsPlugin } from './pm-plugins/track-and-filter-spamming-steps';
import { createPlugin as createLastOrganicChangePlugin, trackLastOrganicChangePluginKey } from './pm-plugins/track-last-organic-change';
import { createPlugin as createTrackNCSInitializationPlugin, trackNCSInitializationPluginKey } from './pm-plugins/track-ncs-initialization';
import { createPlugin as createTrackReconnectionConflictPlugin, trackLastRemoteConflictPluginKey } from './pm-plugins/track-reconnection-conflict';
import { getAvatarColor } from './pm-plugins/utils';
const providerBuilder = collabEditProviderPromise => async (codeToExecute, onError) => {
try {
const provider = await collabEditProviderPromise;
if (provider) {
return codeToExecute(provider);
}
} catch (err) {
if (onError) {
onError(err);
} else {
// eslint-disable-next-line no-console
console.error(err);
}
}
};
const createAddInlineCommentMark = providerPromise => ({
from,
to,
mark
}) => {
providerPromise.then(provider => {
var _provider$api;
const commentMark = new AddMarkStep(Math.min(from, to), Math.max(from, to), mark);
// @ts-expect-error 2339: Property 'api' does not exist on type 'CollabEditProvider<CollabEvents>'.
(_provider$api = provider.api) === null || _provider$api === void 0 ? void 0 : _provider$api.addComment([commentMark]);
});
return false;
};
const createAddInlineCommentNodeMark = providerPromise => ({
pos,
mark
}) => {
providerPromise.then(provider => {
var _provider$api2;
const commentMark = new AddNodeMarkStep(pos, mark);
// @ts-expect-error 2339: Property 'api' does not exist on type 'CollabEditProvider<CollabEvents>'.
(_provider$api2 = provider.api) === null || _provider$api2 === void 0 ? void 0 : _provider$api2.addComment([commentMark]);
});
return false;
};
export const collabEditPlugin = ({
config: options,
api
}) => {
var _api$featureFlags;
const featureFlags = (api === null || api === void 0 ? void 0 : (_api$featureFlags = api.featureFlags) === null || _api$featureFlags === void 0 ? void 0 : _api$featureFlags.sharedState.currentState()) || {};
let providerResolver = () => {};
const editorViewRef = {
current: null
};
const collabEditProviderPromise = new Promise(_providerResolver => {
providerResolver = _providerResolver;
});
const executeProviderCode = providerBuilder(collabEditProviderPromise);
// Spans transactions: a streaming producer holds the flush across many state updates, not just
// the ones it dispatches itself. See `createCollabSendHold`.
const sendHold = createCollabSendHold();
return {
name: 'collabEdit',
getSharedState(state) {
var _lastOrganicChangeSta;
if (!state) {
return {
initialised: {
...(fg('confluence_ncs_step_diffing_version_history') && {
localHumanBodyChangeCount: 0
}),
collabInitialisedAt: null,
firstChangeAfterInitAt: null,
firstContentBodyChangeAfterInitAt: null,
lastLocalOrganicChangeAt: null,
lastRemoteOrganicChangeAt: null,
lastLocalOrganicBodyChangeAt: null,
lastRemoteOrganicBodyChangeAt: null
},
activeParticipants: undefined,
sessionId: undefined,
lastReconnectionConflictMetadata: undefined
};
}
const collabPluginState = mainPluginKey.getState(state);
const metadata = trackNCSInitializationPluginKey.getState(state);
const lastOrganicChangeState = trackLastOrganicChangePluginKey.getState(state);
const lastRemoteConflict = trackLastRemoteConflictPluginKey.getState(state);
return {
activeParticipants: collabPluginState === null || collabPluginState === void 0 ? void 0 : collabPluginState.activeParticipants,
sessionId: collabPluginState === null || collabPluginState === void 0 ? void 0 : collabPluginState.sessionId,
initialised: {
...(fg('confluence_ncs_step_diffing_version_history') && {
localHumanBodyChangeCount: (_lastOrganicChangeSta = lastOrganicChangeState === null || lastOrganicChangeState === void 0 ? void 0 : lastOrganicChangeState.localHumanBodyChangeCount) !== null && _lastOrganicChangeSta !== void 0 ? _lastOrganicChangeSta : 0
}),
collabInitialisedAt: (metadata === null || metadata === void 0 ? void 0 : metadata.collabInitialisedAt) || null,
firstChangeAfterInitAt: (metadata === null || metadata === void 0 ? void 0 : metadata.firstChangeAfterInitAt) || null,
firstContentBodyChangeAfterInitAt: (metadata === null || metadata === void 0 ? void 0 : metadata.firstContentBodyChangeAfterInitAt) || null,
lastLocalOrganicChangeAt: (lastOrganicChangeState === null || lastOrganicChangeState === void 0 ? void 0 : lastOrganicChangeState.lastLocalOrganicChangeAt) || null,
lastRemoteOrganicChangeAt: (lastOrganicChangeState === null || lastOrganicChangeState === void 0 ? void 0 : lastOrganicChangeState.lastRemoteOrganicChangeAt) || null,
lastLocalOrganicBodyChangeAt: (lastOrganicChangeState === null || lastOrganicChangeState === void 0 ? void 0 : lastOrganicChangeState.lastLocalOrganicBodyChangeAt) || null,
lastRemoteOrganicBodyChangeAt: (lastOrganicChangeState === null || lastOrganicChangeState === void 0 ? void 0 : lastOrganicChangeState.lastRemoteOrganicBodyChangeAt) || null
},
lastReconnectionConflictMetadata: lastRemoteConflict
};
},
actions: {
getAvatarColor,
addInlineCommentMark: createAddInlineCommentMark(collabEditProviderPromise),
addInlineCommentNodeMark: createAddInlineCommentNodeMark(collabEditProviderPromise),
isRemoteReplaceDocumentTransaction: tr => tr.getMeta('isRemote') && tr.getMeta('replaceDocument'),
getCurrentCollabState: () => {
var _getCollabState;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const adfDocument = new JSONTransformer().encode(editorViewRef.current.state.doc);
return {
content: adfDocument,
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
version: ((_getCollabState = getCollabState(editorViewRef.current.state)) === null || _getCollabState === void 0 ? void 0 : _getCollabState.version) || 0,
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sendableSteps: sendableSteps(editorViewRef.current.state)
};
},
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validatePMJSONDocument: doc => {
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (doc.content || []).map(child =>
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
editorViewRef.current.state.schema.nodeFromJSON(child));
return content.every(node => {
try {
node.check(); // this will throw an error if the node is invalid
} catch {
return false;
}
return true;
});
}
},
pmPlugins() {
const {
useNativePlugin = false,
userId = null
} = options || {};
const transformUnconfirmed = steps => {
// Analytics steps are filtered first so they cannot break up an otherwise adjacent
// run of streaming frames.
const filtered = filterAnalyticsSteps(steps);
const transformed = collapseStreamingSteps(filtered);
return mergeUnconfirmedSteps(transformed, api);
};
const plugins = [...(useNativePlugin ? [{
name: 'pmCollab',
plugin: () => collab({
clientID: userId,
transformUnconfirmed
})
}, {
name: 'nativeCollabProviderPlugin',
plugin: () => nativeCollabProviderPlugin({
providerPromise: collabEditProviderPromise
})
}] : []), {
name: 'collab',
plugin: ({
dispatch,
providerFactory
}) => {
return createPlugin(dispatch, providerFactory, providerResolver, executeProviderCode, options, featureFlags, api);
}
}, {
name: 'collabTrackNCSInitializationPlugin',
plugin: createTrackNCSInitializationPlugin
}];
plugins.push({
name: 'trackAndFilterSpammingSteps',
plugin: () => trackSpammingStepsPlugin(tr => {
var _api$analytics, _api$analytics$action;
const sanitizedSteps = tr.steps.map(step => sanitizeFilteredStep(step));
api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : (_api$analytics$action = _api$analytics.actions) === null || _api$analytics$action === void 0 ? void 0 : _api$analytics$action.fireAnalyticsEvent({
action: ACTION.STEPS_FILTERED,
actionSubject: ACTION_SUBJECT.COLLAB,
attributes: {
steps: sanitizedSteps
},
eventType: EVENT_TYPE.OPERATIONAL
});
})
});
plugins.push({
name: 'collabTrackLastOrganicChangePlugin',
plugin: createLastOrganicChangePlugin
});
plugins.push({
name: 'trackLastRemoteConflictPlugin',
plugin: createTrackReconnectionConflictPlugin
});
return plugins;
},
onEditorViewStateUpdated(props) {
var _api$analytics2, _api$editorViewMode, _api$editorViewMode$s, _options$useNativePlu, _options$hideTelecurs;
// `props.transactions` is what `applyTransaction` produced: the dispatched transaction
// followed by everything `appendTransaction` added. Streaming frames are built in an
// appendTransaction, so their hold metadata is never on `originalTransaction`.
props.transactions.forEach(transaction => sendHold.observe(transaction));
const addErrorAnalytics = addSynchronyErrorAnalytics(props.newEditorState, props.newEditorState.tr, featureFlags, api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions);
const isEmptyDoc = isEmptyDocument(props.newEditorState.doc);
const viewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode;
executeProviderCode(sendTransaction({
originalTransaction: props.originalTransaction,
transactions: props.transactions,
oldEditorState: props.oldEditorState,
newEditorState: props.newEditorState,
useNativePlugin: (_options$useNativePlu = options.useNativePlugin) !== null && _options$useNativePlu !== void 0 ? _options$useNativePlu : false,
hideTelecursorOnLoad: !isEmptyDoc && ((_options$hideTelecurs = options.hideTelecursorOnLoad) !== null && _options$hideTelecurs !== void 0 ? _options$hideTelecurs : false),
viewMode,
isSendHeld: sendHold.isHeld()
}), addErrorAnalytics);
if (fg('platform_editor_collab_organic_change_reporting')) {
monitorOrganic({
api,
...props,
onDataProcessed: data => {
var _api$analytics3, _api$analytics3$actio;
api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : (_api$analytics3$actio = _api$analytics3.actions) === null || _api$analytics3$actio === void 0 ? void 0 : _api$analytics3$actio.fireAnalyticsEvent({
action: ACTION.ORGANIC_CHANGES_TRACKED,
actionSubject: ACTION_SUBJECT.COLLAB,
attributes: {
organicChanges: data
},
eventType: EVENT_TYPE.OPERATIONAL
});
}
});
}
},
commands: {
nudgeTelepointer: sessionId => ({
tr
}) => {
tr.setMeta('nudgeTelepointer', {
sessionId
});
return tr;
}
}
};
};