@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
174 lines (168 loc) • 6.21 kB
JavaScript
// delete this file when cleaning up platform_editor_remove_collab_step_metrics
import { AnalyticsStep, SetAttrsStep, BatchAttrsStep } from '@atlaskit/adf-schema/steps';
import { AddMarkStep, AddNodeMarkStep, AttrStep, DocAttrStep, RemoveMarkStep, RemoveNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
import { sendableSteps } from '@atlaskit/prosemirror-collab';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { updateNcsSessionStepMetrics } from './track-step-metrics';
function groupBy(array, keyGetter) {
// Check group by exists, and that it's a function. If so, use the native browser code
if ('groupBy' in Object && typeof Object.groupBy === 'function') {
// @ts-ignore TS2322 - Type 'Partial<Record<string, T[]>>' is not assignable to type 'Record<string, T[]>'.
return Object.groupBy(array, keyGetter);
}
// Fallback to custom implementation
const map = {};
array.forEach(item => {
const key = keyGetter(item);
if (!map[key]) {
map[key] = [];
}
map[key].push(item);
});
return map;
}
/**
* Sanitizes a given ProseMirror step by extracting its type and non-UCG relevant attributes.
*
* @param {Step} step - The ProseMirror step to be sanitized.
* @returns {SanitizedStep} - The sanitized step with only necessary information.
*
* @example
* ```
* const step = new AttrStep(10, 'colwidth', [123, 451] );
* const sanitized = sanitizeStep(step);
*
* // Output: { stepType: 'attr', attr: 'example' }
* ```
*/
export const sanitizeStep = step => {
const serializedStep = step.toJSON();
const sanitizedStep = {
stepType: serializedStep.stepType
};
if (step instanceof AttrStep || step instanceof DocAttrStep) {
sanitizedStep.attr = step.attr;
} else if (step instanceof SetAttrsStep) {
// Combines all attrs keys separated by _ to one single string
sanitizedStep.attr = Object.keys(step.attrs).sort().join('_');
} else if (step instanceof AddMarkStep || step instanceof RemoveMarkStep || step instanceof RemoveNodeMarkStep || step instanceof AddNodeMarkStep) {
sanitizedStep.markType = step.mark.type.name;
} else if (step instanceof BatchAttrsStep) {
const batched = step.data.map(({
nodeType,
attrs
}) => `${nodeType}_${Object.keys(attrs).sort().join('_')}`);
sanitizedStep.attr = batched.sort().join('_');
}
return sanitizedStep;
};
/**
* Groups sanitized steps by their type and counts their occurrences.
*
* @param {SanitizedStep[]} sanitizedSteps - An array of sanitized steps.
* @returns {Record<string, number>} - An object where keys are step types and values are their counts.
*
* @example
* ```
* const input = [
* { stepType: 'attr', attr: 'colwidth' },
* { stepType: 'mark', markType: 'bold' },
* { stepType: 'attr', attr: 'colwidth' }
* ];
*
* const grouped = groupSteps(input);
* // Output: { 'attr_example': 2, 'mark_bold': 1 }
* ```
*/
export const groupSteps = sanitizedSteps => {
const grouped = groupBy(sanitizedSteps, e => Object.values(e).join('_'));
return Object.entries(grouped).reduce((acc, [key, value]) => {
acc[key] = Array.isArray(value) ? value.length : 0;
return acc;
}, {});
};
/**
* Processes the steps metadata from the cache and calls the callback function with the processed data.
*
* @param {CacheType} cache - A cache containing steps metadata.
* @param {(data: StepMetadataAnalytics[]) => void} onTrackDataProcessed - Callback function to be called with the processed data.
*/
export const task = (cache, onTrackDataProcessed) => {
const stepsMetadata = [];
for (const entry of cache.values()) {
const {
startedAt,
endedAt,
steps
} = entry;
const stepTypesAmount = groupSteps(steps.map(sanitizeStep));
stepsMetadata.push({
startedAt,
endedAt,
stepTypesAmount
});
}
cache.clear();
if (stepsMetadata.length > 0) {
onTrackDataProcessed(stepsMetadata);
}
};
const stepsSentCache = new Map();
// Every ten seconds we will try to process the step data.
const LOW_PRIORITY_DELAY = 10000;
// See https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/
const getScheduler = obj => {
if (!obj) {
return null;
}
if ('scheduler' in obj) {
return obj.scheduler;
}
return null;
};
/**
* Tracks the steps sent by the client by storing them in a cache and scheduling a task to process them. Once the steps are processed, the onTrackDataProcessed callabck will be called.
*
* This is a non-critical code. If the browser doesn't support the Scheduler API https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/
*
* @param {TrackProps} props - The properties required for tracking steps.
* @param {ExtractInjectionAPI<CollabEditPlugin> | undefined} props.api - The API for the CollabEdit plugin.
* @param {EditorState} props.newEditorState - The new editor state.
* @param {Readonly<Transaction[]>} props.transactions - The transactions that contain the steps.
* @param {(data: StepMetadataAnalytics[]) => void} props.onTrackDataProcessed - Callback function to be called with the processed data.
*/
export const track = ({
api,
newEditorState,
transactions,
onTrackDataProcessed
}) => {
const newSteps = transactions.flatMap(t => t.steps);
const collabState = sendableSteps(newEditorState);
const scheduler = getScheduler(window);
if (!newSteps.length || !scheduler || !collabState) {
return;
}
const {
version
} = collabState;
const buffer = stepsSentCache.get(version);
const startedAt = (buffer === null || buffer === void 0 ? void 0 : buffer.startedAt) || Date.now();
const endedAt = Date.now();
const steps = ((buffer === null || buffer === void 0 ? void 0 : buffer.steps) || []).concat(newSteps);
stepsSentCache.set(version, {
startedAt,
endedAt,
steps
});
updateNcsSessionStepMetrics({
api,
steps: editorExperiment('platform_editor_reduce_noisy_steps_ncs', true) ? newSteps.filter(step => !(step instanceof AnalyticsStep)) : newSteps
});
scheduler.postTask(() => {
task(stepsSentCache, onTrackDataProcessed);
}, {
priority: 'background',
delay: LOW_PRIORITY_DELAY
});
};