@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
285 lines (283 loc) • 13.8 kB
JavaScript
import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
import { isEmptyDocument } from '@atlaskit/editor-common/utils';
import { JSONTransformer } from '@atlaskit/editor-json-transformer';
import { AddMarkStep, AddNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
import { fg } from '@atlaskit/platform-feature-flags';
import { collab, getCollabState, sendableSteps } from '@atlaskit/prosemirror-collab';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { addSynchronyErrorAnalytics } from './pm-plugins/analytics';
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 { track } from './pm-plugins/track-steps';
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);
return {
name: 'collabEdit',
getSharedState(state) {
if (!state) {
return {
initialised: {
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: {
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 => {
let transformed = steps;
if (editorExperiment('platform_editor_reduce_noisy_steps_ncs', true, {
exposure: true
})) {
transformed = filterAnalyticsSteps(transformed);
}
if (editorExperiment('platform_editor_offline_editing_web', true) || expValEquals('platform_editor_enable_single_player_step_merging', 'isEnabled', true)) {
transformed = mergeUnconfirmedSteps(transformed, api);
}
return transformed;
};
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
});
if (editorExperiment('platform_editor_offline_editing_web', true)) {
plugins.push({
name: 'trackLastRemoteConflictPlugin',
plugin: createTrackReconnectionConflictPlugin
});
}
return plugins;
},
onEditorViewStateUpdated(props) {
var _api$analytics2, _api$editorViewMode, _api$editorViewMode$s, _options$useNativePlu, _options$hideTelecurs;
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
}), addErrorAnalytics);
if (!expValEquals('platform_editor_remove_collab_step_metrics', 'isEnabled', true)) {
track({
api,
...props,
onTrackDataProcessed: steps => {
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.STEPS_TRACKED,
actionSubject: ACTION_SUBJECT.COLLAB,
attributes: {
steps
},
eventType: EVENT_TYPE.OPERATIONAL
});
}
});
}
if (fg('platform_editor_collab_organic_change_reporting')) {
monitorOrganic({
api,
...props,
onDataProcessed: data => {
var _api$analytics4, _api$analytics4$actio;
api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : (_api$analytics4$actio = _api$analytics4.actions) === null || _api$analytics4$actio === void 0 ? void 0 : _api$analytics4$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;
}
}
};
};