@atlaskit/editor-plugin-collab-edit 23.0.0 → 23.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/dist/cjs/collabEditPlugin.js +25 -23
- package/dist/cjs/pm-plugins/collabSendHold.js +76 -0
- package/dist/cjs/pm-plugins/collapseStreamingSteps.js +127 -0
- package/dist/cjs/pm-plugins/events/send-transaction.js +13 -2
- package/dist/cjs/pm-plugins/mergeOverlappingSteps.js +152 -0
- package/dist/cjs/pm-plugins/track-steps.js +1 -104
- package/dist/es2019/collabEditPlugin.js +23 -23
- package/dist/es2019/pm-plugins/collabSendHold.js +68 -0
- package/dist/es2019/pm-plugins/collapseStreamingSteps.js +114 -0
- package/dist/es2019/pm-plugins/events/send-transaction.js +12 -2
- package/dist/es2019/pm-plugins/mergeOverlappingSteps.js +144 -0
- package/dist/es2019/pm-plugins/track-steps.js +0 -93
- package/dist/esm/collabEditPlugin.js +25 -23
- package/dist/esm/pm-plugins/collabSendHold.js +70 -0
- package/dist/esm/pm-plugins/collapseStreamingSteps.js +122 -0
- package/dist/esm/pm-plugins/events/send-transaction.js +13 -2
- package/dist/esm/pm-plugins/mergeOverlappingSteps.js +147 -0
- package/dist/esm/pm-plugins/track-steps.js +0 -104
- package/dist/types/pm-plugins/collabSendHold.d.ts +51 -0
- package/dist/types/pm-plugins/collapseStreamingSteps.d.ts +18 -0
- package/dist/types/pm-plugins/events/send-transaction.d.ts +7 -1
- package/dist/types/pm-plugins/mergeOverlappingSteps.d.ts +28 -0
- package/dist/types/pm-plugins/track-steps.d.ts +0 -41
- package/package.json +8 -5
- package/tsconfig.typecheck-typecheck-dev.tsbuildinfo +1 -0
- package/dist/cjs/pm-plugins/track-step-metrics.js +0 -82
- package/dist/es2019/pm-plugins/track-step-metrics.js +0 -79
- package/dist/esm/pm-plugins/track-step-metrics.js +0 -75
- package/dist/types/pm-plugins/track-step-metrics.d.ts +0 -48
|
@@ -4,8 +4,9 @@ import { JSONTransformer } from '@atlaskit/editor-json-transformer/JSONTransform
|
|
|
4
4
|
import { AddMarkStep, AddNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
|
|
5
5
|
import { fg } from '@atlaskit/platform-feature-flags/fg';
|
|
6
6
|
import { collab, getCollabState, sendableSteps } from '@atlaskit/prosemirror-collab';
|
|
7
|
-
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
|
|
8
7
|
import { addSynchronyErrorAnalytics } from './pm-plugins/analytics';
|
|
8
|
+
import { createCollabSendHold } from './pm-plugins/collabSendHold';
|
|
9
|
+
import { collapseStreamingSteps } from './pm-plugins/collapseStreamingSteps';
|
|
9
10
|
import { sendTransaction } from './pm-plugins/events/send-transaction';
|
|
10
11
|
import { filterAnalyticsSteps } from './pm-plugins/filterAnalytics';
|
|
11
12
|
import { createPlugin } from './pm-plugins/main';
|
|
@@ -17,7 +18,6 @@ import { sanitizeFilteredStep, createPlugin as trackSpammingStepsPlugin } from '
|
|
|
17
18
|
import { createPlugin as createLastOrganicChangePlugin, trackLastOrganicChangePluginKey } from './pm-plugins/track-last-organic-change';
|
|
18
19
|
import { createPlugin as createTrackNCSInitializationPlugin, trackNCSInitializationPluginKey } from './pm-plugins/track-ncs-initialization';
|
|
19
20
|
import { createPlugin as createTrackReconnectionConflictPlugin, trackLastRemoteConflictPluginKey } from './pm-plugins/track-reconnection-conflict';
|
|
20
|
-
import { track } from './pm-plugins/track-steps';
|
|
21
21
|
import { getAvatarColor } from './pm-plugins/utils';
|
|
22
22
|
const providerBuilder = collabEditProviderPromise => async (codeToExecute, onError) => {
|
|
23
23
|
try {
|
|
@@ -75,6 +75,9 @@ export const collabEditPlugin = ({
|
|
|
75
75
|
providerResolver = _providerResolver;
|
|
76
76
|
});
|
|
77
77
|
const executeProviderCode = providerBuilder(collabEditProviderPromise);
|
|
78
|
+
// Spans transactions: a streaming producer holds the flush across many state updates, not just
|
|
79
|
+
// the ones it dispatches itself. See `createCollabSendHold`.
|
|
80
|
+
const sendHold = createCollabSendHold();
|
|
78
81
|
return {
|
|
79
82
|
name: 'collabEdit',
|
|
80
83
|
getSharedState(state) {
|
|
@@ -160,7 +163,10 @@ export const collabEditPlugin = ({
|
|
|
160
163
|
userId = null
|
|
161
164
|
} = options || {};
|
|
162
165
|
const transformUnconfirmed = steps => {
|
|
163
|
-
|
|
166
|
+
// Analytics steps are filtered first so they cannot break up an otherwise adjacent
|
|
167
|
+
// run of streaming frames.
|
|
168
|
+
const filtered = filterAnalyticsSteps(steps);
|
|
169
|
+
const transformed = fg('platform_editor_ai_collapse_streaming_steps') ? collapseStreamingSteps(filtered) : filtered;
|
|
164
170
|
return mergeUnconfirmedSteps(transformed, api);
|
|
165
171
|
};
|
|
166
172
|
const plugins = [...(useNativePlugin ? [{
|
|
@@ -213,6 +219,16 @@ export const collabEditPlugin = ({
|
|
|
213
219
|
},
|
|
214
220
|
onEditorViewStateUpdated(props) {
|
|
215
221
|
var _api$analytics2, _api$editorViewMode, _api$editorViewMode$s, _options$useNativePlu, _options$hideTelecurs;
|
|
222
|
+
// `props.transactions` is what `applyTransaction` produced: the dispatched transaction
|
|
223
|
+
// followed by everything `appendTransaction` added. Streaming frames are built in an
|
|
224
|
+
// appendTransaction, so their hold metadata is never on `originalTransaction`.
|
|
225
|
+
// Evaluated once and used for both the observation and the send decision. If the gate
|
|
226
|
+
// is turned off mid-stream, an already-active hold must stop applying immediately
|
|
227
|
+
// rather than persisting until the backstop expires and blocking every send.
|
|
228
|
+
const collapseStreamingStepsEnabled = fg('platform_editor_ai_collapse_streaming_steps');
|
|
229
|
+
if (collapseStreamingStepsEnabled) {
|
|
230
|
+
props.transactions.forEach(transaction => sendHold.observe(transaction));
|
|
231
|
+
}
|
|
216
232
|
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);
|
|
217
233
|
const isEmptyDoc = isEmptyDocument(props.newEditorState.doc);
|
|
218
234
|
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;
|
|
@@ -223,32 +239,16 @@ export const collabEditPlugin = ({
|
|
|
223
239
|
newEditorState: props.newEditorState,
|
|
224
240
|
useNativePlugin: (_options$useNativePlu = options.useNativePlugin) !== null && _options$useNativePlu !== void 0 ? _options$useNativePlu : false,
|
|
225
241
|
hideTelecursorOnLoad: !isEmptyDoc && ((_options$hideTelecurs = options.hideTelecursorOnLoad) !== null && _options$hideTelecurs !== void 0 ? _options$hideTelecurs : false),
|
|
226
|
-
viewMode
|
|
242
|
+
viewMode,
|
|
243
|
+
isSendHeld: collapseStreamingStepsEnabled && sendHold.isHeld()
|
|
227
244
|
}), addErrorAnalytics);
|
|
228
|
-
if (!expValEquals('platform_editor_remove_collab_step_metrics', 'isEnabled', true)) {
|
|
229
|
-
track({
|
|
230
|
-
api,
|
|
231
|
-
...props,
|
|
232
|
-
onTrackDataProcessed: steps => {
|
|
233
|
-
var _api$analytics3, _api$analytics3$actio;
|
|
234
|
-
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({
|
|
235
|
-
action: ACTION.STEPS_TRACKED,
|
|
236
|
-
actionSubject: ACTION_SUBJECT.COLLAB,
|
|
237
|
-
attributes: {
|
|
238
|
-
steps
|
|
239
|
-
},
|
|
240
|
-
eventType: EVENT_TYPE.OPERATIONAL
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
245
|
if (fg('platform_editor_collab_organic_change_reporting')) {
|
|
246
246
|
monitorOrganic({
|
|
247
247
|
api,
|
|
248
248
|
...props,
|
|
249
249
|
onDataProcessed: data => {
|
|
250
|
-
var _api$
|
|
251
|
-
api === null || api === void 0 ? void 0 : (_api$
|
|
250
|
+
var _api$analytics3, _api$analytics3$actio;
|
|
251
|
+
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({
|
|
252
252
|
action: ACTION.ORGANIC_CHANGES_TRACKED,
|
|
253
253
|
actionSubject: ACTION_SUBJECT.COLLAB,
|
|
254
254
|
attributes: {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DEFER_COLLAB_SEND } from '@atlaskit/editor-common/collab-defer-collab-send';
|
|
2
|
+
/**
|
|
3
|
+
* Upper bound on how long the send may stay held, measured from the transaction that *started* the
|
|
4
|
+
* hold rather than the most recent one.
|
|
5
|
+
*
|
|
6
|
+
* This is the only time bound on the hold. Producers decide when to release on semantic grounds —
|
|
7
|
+
* a completed block, the end of a stream — and this bounds how long that can take. It therefore
|
|
8
|
+
* doubles as the guarantee that a producer which never releases, because it aborted or threw
|
|
9
|
+
* between hold and release, cannot stop the editor sending for the rest of the session.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_SEND_HOLD_MS = 2000;
|
|
12
|
+
/**
|
|
13
|
+
* Tracks whether sending to the collab service is currently held back.
|
|
14
|
+
*
|
|
15
|
+
* A producer that streams into the document needs several of its transactions to accumulate in the
|
|
16
|
+
* unconfirmed queue before anything is sent, so they can be composed into one step. Marking only
|
|
17
|
+
* the producer's own transactions is not enough: `sendTransaction` runs for *every* state update,
|
|
18
|
+
* and `provider.send` flushes and locks the entire queue regardless of which transaction triggered
|
|
19
|
+
* it. Acknowledgements from previous commits, remote steps, selection changes and the user's own
|
|
20
|
+
* typing all trigger it.
|
|
21
|
+
*
|
|
22
|
+
* So the hold has to be a state that spans transactions rather than a flag on one:
|
|
23
|
+
*
|
|
24
|
+
* - `tr.setMeta(DEFER_COLLAB_SEND, true)` starts the hold, and does *not* extend one already in
|
|
25
|
+
* progress
|
|
26
|
+
* - `tr.setMeta(DEFER_COLLAB_SEND, false)` releases it
|
|
27
|
+
* - anything else leaves it as it is
|
|
28
|
+
*
|
|
29
|
+
* Not extending matters. A producer marks every frame it wants held, so re-arming on each one
|
|
30
|
+
* would push the deadline back indefinitely and the bound would never be reached during an active
|
|
31
|
+
* stream. Measuring from the first held transaction instead makes the hold flush at least every
|
|
32
|
+
* {@link MAX_SEND_HOLD_MS}, with the next held frame starting a fresh window.
|
|
33
|
+
*
|
|
34
|
+
* While held, nothing is sent. Note this includes the local user's own edits: `sendableSteps`
|
|
35
|
+
* returns the queue in version order, so there is no way to send a step that sits behind held
|
|
36
|
+
* steps. Producers should keep hold windows short for that reason.
|
|
37
|
+
*/
|
|
38
|
+
export const createCollabSendHold = () => {
|
|
39
|
+
let heldSince;
|
|
40
|
+
return {
|
|
41
|
+
observe: (transaction, now = Date.now()) => {
|
|
42
|
+
const meta = transaction.getMeta(DEFER_COLLAB_SEND);
|
|
43
|
+
if (meta === true) {
|
|
44
|
+
// Only start a window, never extend one, so a continuous stream of held frames
|
|
45
|
+
// still reaches the bound. See the note on this type about why.
|
|
46
|
+
if (heldSince === undefined) {
|
|
47
|
+
heldSince = now;
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (meta === false) {
|
|
52
|
+
heldSince = undefined;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
isHeld: (now = Date.now()) => {
|
|
56
|
+
if (heldSince === undefined) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (now - heldSince >= MAX_SEND_HOLD_MS) {
|
|
60
|
+
// Clear as well as report, so the next held frame opens a fresh window instead of
|
|
61
|
+
// being judged against the expired one.
|
|
62
|
+
heldSince = undefined;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { STREAMING_COLLAPSIBLE_STEPS } from '@atlaskit/editor-common/collab-streaming-step-collapse';
|
|
2
|
+
import { Transaction } from '@atlaskit/editor-prosemirror/state';
|
|
3
|
+
import { Rebaseable } from '@atlaskit/prosemirror-collab';
|
|
4
|
+
import { mergeOverlappingSteps } from './mergeOverlappingSteps';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Is this step an intermediate frame that its producer has marked as collapsible, and is it still
|
|
8
|
+
* free to be composed?
|
|
9
|
+
*/
|
|
10
|
+
const isCollapsibleFrame = rebaseable => {
|
|
11
|
+
const {
|
|
12
|
+
origin
|
|
13
|
+
} = rebaseable;
|
|
14
|
+
if (!(origin instanceof Transaction) || origin.getMeta(STREAMING_COLLAPSIBLE_STEPS) !== true) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Already sent to (or queued for) the collab service — composing it now would change a step the
|
|
19
|
+
// backend has already seen. Mirrors the `mergeIsLocked` contract in `mergeUnconfirmedSteps`.
|
|
20
|
+
return !origin.getMeta('mergeIsLocked');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Starting document of each composed step this module has produced.
|
|
25
|
+
*
|
|
26
|
+
* A composed step is synthesised rather than taken from a transaction, so it cannot be found in
|
|
27
|
+
* `origin.steps` and its starting document cannot be recovered the way an original frame's can.
|
|
28
|
+
* `transformUnconfirmed` runs once per transaction and is handed back the steps it returned last
|
|
29
|
+
* time, so the composed step needs to carry its document forward to keep composing against later
|
|
30
|
+
* frames.
|
|
31
|
+
*
|
|
32
|
+
* Keyed on the step rather than its Rebaseable wrapper. `mergeUnconfirmedSteps` runs immediately
|
|
33
|
+
* after this module in `transformUnconfirmed` and rebuilds every wrapper it is given — merging
|
|
34
|
+
* enabled or not — so a wrapper-keyed entry would be unreachable by the very next pass. The step
|
|
35
|
+
* survives that rebuild untouched, and is still replaced whenever the association genuinely
|
|
36
|
+
* becomes stale: `rebaseSteps` maps steps into new objects, and a real merge composes a new one.
|
|
37
|
+
*/
|
|
38
|
+
const composedStepDocs = new WeakMap();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The document this step was applied to.
|
|
42
|
+
*
|
|
43
|
+
* `transformUnconfirmed` is handed steps and nothing else, but a Rebaseable keeps a reference to
|
|
44
|
+
* the transaction that produced it, and a transaction records the document before each of its
|
|
45
|
+
* steps. That is enough for `mergeOverlappingSteps` to resolve merges it cannot derive from the
|
|
46
|
+
* steps alone — notably a frame that replaces more than just what the previous frame inserted.
|
|
47
|
+
*
|
|
48
|
+
* Rebasing maps a step into a new object while keeping the original transaction, so a rebased step
|
|
49
|
+
* is not found in `origin.steps` and correctly yields no document. `mergeOverlappingSteps` then
|
|
50
|
+
* falls back to the merges it can prove without one.
|
|
51
|
+
*/
|
|
52
|
+
const docBeforeStep = rebaseable => {
|
|
53
|
+
const {
|
|
54
|
+
origin,
|
|
55
|
+
step
|
|
56
|
+
} = rebaseable;
|
|
57
|
+
const composed = composedStepDocs.get(step);
|
|
58
|
+
if (composed) {
|
|
59
|
+
return composed;
|
|
60
|
+
}
|
|
61
|
+
const index = origin.steps.indexOf(step);
|
|
62
|
+
return index >= 0 ? origin.docs[index] : undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compose adjacent streaming frames in the unconfirmed queue so the collab service receives one
|
|
67
|
+
* step per flush instead of one step per rendered chunk.
|
|
68
|
+
*
|
|
69
|
+
* This runs in the collab plugin's `transformUnconfirmed` hook, which fires *after* each frame has
|
|
70
|
+
* already been applied to the document. Collapsing therefore has no effect on what the local user
|
|
71
|
+
* sees, on undo history, or on any other plugin state — it only changes what is sent to the collab
|
|
72
|
+
* service, and what is used as the rebase buffer when remote steps arrive.
|
|
73
|
+
*
|
|
74
|
+
* Because the unconfirmed steps double as the rebase buffer (`rebaseSteps` applies their inverses
|
|
75
|
+
* to the live document), the composition must be exact. `mergeOverlappingSteps` only returns a
|
|
76
|
+
* merge it can prove exact, and this function degrades to a no-op whenever it declines.
|
|
77
|
+
*
|
|
78
|
+
* @param steps Rebaseable steps
|
|
79
|
+
* @returns Rebaseable steps, with adjacent streaming frames composed
|
|
80
|
+
*/
|
|
81
|
+
export function collapseStreamingSteps(steps) {
|
|
82
|
+
// Fast path: nothing to do unless at least two frames could possibly compose.
|
|
83
|
+
if (steps.length < 2) {
|
|
84
|
+
return steps;
|
|
85
|
+
}
|
|
86
|
+
const collapsed = [];
|
|
87
|
+
// The document the run currently at the tail of `collapsed` started from. A composed step
|
|
88
|
+
// still applies to the document its first frame applied to, so this survives each merge.
|
|
89
|
+
let runDocBefore;
|
|
90
|
+
for (const rebaseable of steps) {
|
|
91
|
+
const previous = collapsed[collapsed.length - 1];
|
|
92
|
+
if (!previous || !isCollapsibleFrame(previous) || !isCollapsibleFrame(rebaseable)) {
|
|
93
|
+
collapsed.push(rebaseable);
|
|
94
|
+
runDocBefore = docBeforeStep(rebaseable);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const merged = mergeOverlappingSteps(previous, rebaseable, runDocBefore);
|
|
98
|
+
if (!merged) {
|
|
99
|
+
collapsed.push(rebaseable);
|
|
100
|
+
runDocBefore = docBeforeStep(rebaseable);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Keep the earliest origin, matching `mergeUnconfirmedSteps`. `commitUnconfirmedSteps` in
|
|
105
|
+
// the collab provider tracks completion by origin identity, so keeping the earliest means
|
|
106
|
+
// the composed step is not considered confirmed until the whole run has been acknowledged.
|
|
107
|
+
const composed = new Rebaseable(merged.step, merged.inverted, previous.origin);
|
|
108
|
+
if (runDocBefore) {
|
|
109
|
+
composedStepDocs.set(composed.step, runDocBefore);
|
|
110
|
+
}
|
|
111
|
+
collapsed[collapsed.length - 1] = composed;
|
|
112
|
+
}
|
|
113
|
+
return collapsed;
|
|
114
|
+
}
|
|
@@ -8,7 +8,8 @@ export const sendTransaction = ({
|
|
|
8
8
|
newEditorState,
|
|
9
9
|
useNativePlugin,
|
|
10
10
|
viewMode,
|
|
11
|
-
hideTelecursorOnLoad
|
|
11
|
+
hideTelecursorOnLoad,
|
|
12
|
+
isSendHeld = false
|
|
12
13
|
}) => provider => {
|
|
13
14
|
const docChangedTransaction = transactions.find(tr => tr.docChanged);
|
|
14
15
|
const currentPluginState = pluginKey.getState(newEditorState);
|
|
@@ -25,7 +26,16 @@ export const sendTransaction = ({
|
|
|
25
26
|
// TODO: ED-8995 - We need to do this check to reduce the number of race conditions when working with tables.
|
|
26
27
|
// This metadata is coming from the scaleTable command in table-resizing plugin
|
|
27
28
|
!originalTransaction.getMeta('scaleTable') && trNoAnalytics.docChanged;
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
// A streaming producer can hold the flush back so several of its frames accumulate in the
|
|
31
|
+
// unconfirmed queue and collapse into one step before being sent. The steps are not
|
|
32
|
+
// dropped: `sendableSteps` returns the whole queue, so the first unheld state update sends
|
|
33
|
+
// everything that accumulated. Skipping `send` also skips `lockStepOrigins`, which is what
|
|
34
|
+
// keeps the accumulated frames composable.
|
|
35
|
+
//
|
|
36
|
+
// Telepointer messages below are intentionally left alone — holding them would freeze
|
|
37
|
+
// other participants' cursors for the length of the hold, and they carry no steps.
|
|
38
|
+
if (!isSendHeld && (useNativePlugin || shouldSendStepForSynchronyCollabProvider)) {
|
|
29
39
|
provider.send(trNoAnalytics, oldEditorState, newEditorState);
|
|
30
40
|
}
|
|
31
41
|
const prevPluginState = pluginKey.getState(oldEditorState);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ReplaceStep, Transform } from '@atlaskit/editor-prosemirror/transform';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A step paired with its inverse, as stored in the collab unconfirmed queue.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `structure` is `@internal` on ReplaceStep but is part of the serialised form, so this is the
|
|
9
|
+
* supported way to read it. Structural replaces exist to stop rebased steps from overwriting
|
|
10
|
+
* content they should not, so they are never merged.
|
|
11
|
+
*/
|
|
12
|
+
const isStructural = step => step.toJSON().structure === true;
|
|
13
|
+
const asPlainReplaceStep = step =>
|
|
14
|
+
// Deliberately excludes ReplaceAroundStep — the position arithmetic below is only sound for a
|
|
15
|
+
// plain range replace.
|
|
16
|
+
step instanceof ReplaceStep && !isStructural(step) ? step : undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Do the two steps touch the same region of the document?
|
|
20
|
+
*
|
|
21
|
+
* `second` is expressed in the coordinate space *after* `first` has been applied, so `first`'s
|
|
22
|
+
* footprint there is the range its slice occupies: `[from, from + slice.size]`.
|
|
23
|
+
*/
|
|
24
|
+
const overlaps = (first, second) => {
|
|
25
|
+
const firstInsertedEnd = first.from + first.slice.size;
|
|
26
|
+
return second.from <= firstInsertedEnd && second.to >= first.from;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge using ProseMirror's own step merging, which handles adjacent insertions and adjacent
|
|
31
|
+
* deletions. Both the steps and their inverses have to merge for the result to be usable as a
|
|
32
|
+
* rebase buffer entry — inverses compose in reverse order.
|
|
33
|
+
*/
|
|
34
|
+
const mergeNatively = (first, second) => {
|
|
35
|
+
const step = first.step.merge(second.step);
|
|
36
|
+
if (!step) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const inverted = second.inverted.merge(first.inverted);
|
|
40
|
+
if (!inverted) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
step,
|
|
45
|
+
inverted
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merge any two overlapping steps by applying both to the document and describing the net change
|
|
51
|
+
* as a single minimal replacement.
|
|
52
|
+
*
|
|
53
|
+
* This is the general case: it copes with `second` reaching past `first` on either side, with
|
|
54
|
+
* partial overlaps, and with slices whose open depths differ — none of which can be resolved from
|
|
55
|
+
* the steps alone, because the composed slice has to be cut out of a real document.
|
|
56
|
+
*/
|
|
57
|
+
const mergeViaDocument = (first, second, docBefore) => {
|
|
58
|
+
let docAfter;
|
|
59
|
+
try {
|
|
60
|
+
const transform = new Transform(docBefore);
|
|
61
|
+
transform.step(first.step);
|
|
62
|
+
transform.step(second.step);
|
|
63
|
+
docAfter = transform.doc;
|
|
64
|
+
} catch {
|
|
65
|
+
// `docBefore` did not match the steps (for example the steps have since been rebased).
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const start = docBefore.content.findDiffStart(docAfter.content);
|
|
69
|
+
if (start === null) {
|
|
70
|
+
// The steps cancel each other out. There is no single step representing "no change", so
|
|
71
|
+
// leave the pair alone rather than inventing one.
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const diffEnd = docBefore.content.findDiffEnd(docAfter.content);
|
|
75
|
+
if (!diffEnd) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// findDiffStart and findDiffEnd can overshoot each other when the same text appears on both
|
|
80
|
+
// sides of the change; push the end out so the range stays well formed.
|
|
81
|
+
let {
|
|
82
|
+
a: endBefore,
|
|
83
|
+
b: endAfter
|
|
84
|
+
} = diffEnd;
|
|
85
|
+
const overshoot = start - Math.min(endBefore, endAfter);
|
|
86
|
+
if (overshoot > 0) {
|
|
87
|
+
endBefore += overshoot;
|
|
88
|
+
endAfter += overshoot;
|
|
89
|
+
}
|
|
90
|
+
const step = new ReplaceStep(start, endBefore, docAfter.slice(start, endAfter));
|
|
91
|
+
|
|
92
|
+
// Cheap guarantee that the composition is exact. If it is not, keep the original pair.
|
|
93
|
+
const applied = step.apply(docBefore);
|
|
94
|
+
if (applied.failed !== null || !applied.doc || !applied.doc.eq(docAfter)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
step,
|
|
99
|
+
inverted: step.invert(docBefore)
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Merge two consecutive steps into the single step they are collectively equivalent to.
|
|
105
|
+
*
|
|
106
|
+
* `second` must be expressed in the coordinate space produced by `first` — that is, the two must be
|
|
107
|
+
* consecutive steps of the same document. The returned step maps the document from before `first`
|
|
108
|
+
* to after `second`, and the returned inverse maps it back, so the result is safe to use both on
|
|
109
|
+
* the wire and as a rebase buffer entry.
|
|
110
|
+
*
|
|
111
|
+
* Returns `null` whenever the steps do not overlap, or when the merge cannot be proven exact. A
|
|
112
|
+
* `null` result always means "keep the two steps as they are", never "the steps are equivalent to
|
|
113
|
+
* nothing".
|
|
114
|
+
*
|
|
115
|
+
* @param first The earlier step and its inverse.
|
|
116
|
+
* @param second The later step and its inverse.
|
|
117
|
+
* @param docBefore The document `first` applies to. Optional, but without it only ProseMirror's own
|
|
118
|
+
* adjacent-step merges can be resolved, because the composed slice otherwise has to be cut out of a
|
|
119
|
+
* real document. Callers lose it when the queue has been rebased over a remote step.
|
|
120
|
+
*/
|
|
121
|
+
export function mergeOverlappingSteps(first, second, docBefore) {
|
|
122
|
+
const firstStep = asPlainReplaceStep(first.step);
|
|
123
|
+
const secondStep = asPlainReplaceStep(second.step);
|
|
124
|
+
|
|
125
|
+
// Non-plain replaces are only ever merged by ProseMirror itself, which knows the rules for them.
|
|
126
|
+
if (!firstStep || !secondStep) {
|
|
127
|
+
return mergeNatively(first, second);
|
|
128
|
+
}
|
|
129
|
+
if (!overlaps(firstStep, secondStep)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Try the O(1) strategy before the one that has to replay both steps against a document.
|
|
134
|
+
// `transformUnconfirmed` runs on every transaction, and adjacent insertions — which is what
|
|
135
|
+
// consecutive streaming frames often are — are exactly what ProseMirror merges natively.
|
|
136
|
+
const natively = mergeNatively(first, second);
|
|
137
|
+
if (natively) {
|
|
138
|
+
return natively;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Anything else needs the document to cut the composed slice out of, so without one there is
|
|
142
|
+
// no merge that can be proven exact.
|
|
143
|
+
return docBefore ? mergeViaDocument(first, second, docBefore) : null;
|
|
144
|
+
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
// delete this file when cleaning up platform_editor_remove_collab_step_metrics
|
|
2
|
-
import { AnalyticsStep } from '@atlaskit/adf-schema/steps/analytics';
|
|
3
1
|
import { BatchAttrsStep } from '@atlaskit/adf-schema/steps/batch-attrs-step';
|
|
4
2
|
import { SetAttrsStep } from '@atlaskit/adf-schema/steps/set-attrs';
|
|
5
3
|
import { AddMarkStep, AddNodeMarkStep, AttrStep, DocAttrStep, RemoveMarkStep, RemoveNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
|
|
6
|
-
import { sendableSteps } from '@atlaskit/prosemirror-collab';
|
|
7
|
-
import { updateNcsSessionStepMetrics } from './track-step-metrics';
|
|
8
4
|
function groupBy(array, keyGetter) {
|
|
9
5
|
// Check group by exists, and that it's a function. If so, use the native browser code
|
|
10
6
|
if ('groupBy' in Object && typeof Object.groupBy === 'function') {
|
|
@@ -82,93 +78,4 @@ export const groupSteps = sanitizedSteps => {
|
|
|
82
78
|
acc[key] = Array.isArray(value) ? value.length : 0;
|
|
83
79
|
return acc;
|
|
84
80
|
}, {});
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Processes the steps metadata from the cache and calls the callback function with the processed data.
|
|
89
|
-
*
|
|
90
|
-
* @param {CacheType} cache - A cache containing steps metadata.
|
|
91
|
-
* @param {(data: StepMetadataAnalytics[]) => void} onTrackDataProcessed - Callback function to be called with the processed data.
|
|
92
|
-
*/
|
|
93
|
-
export const task = (cache, onTrackDataProcessed) => {
|
|
94
|
-
const stepsMetadata = [];
|
|
95
|
-
for (const entry of cache.values()) {
|
|
96
|
-
const {
|
|
97
|
-
startedAt,
|
|
98
|
-
endedAt,
|
|
99
|
-
steps
|
|
100
|
-
} = entry;
|
|
101
|
-
const stepTypesAmount = groupSteps(steps.map(sanitizeStep));
|
|
102
|
-
stepsMetadata.push({
|
|
103
|
-
startedAt,
|
|
104
|
-
endedAt,
|
|
105
|
-
stepTypesAmount
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
cache.clear();
|
|
109
|
-
if (stepsMetadata.length > 0) {
|
|
110
|
-
onTrackDataProcessed(stepsMetadata);
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
const stepsSentCache = new Map();
|
|
114
|
-
// Every ten seconds we will try to process the step data.
|
|
115
|
-
const LOW_PRIORITY_DELAY = 10000;
|
|
116
|
-
|
|
117
|
-
// See https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/
|
|
118
|
-
|
|
119
|
-
const getScheduler = obj => {
|
|
120
|
-
if (!obj) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
if ('scheduler' in obj) {
|
|
124
|
-
return obj.scheduler;
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* 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.
|
|
131
|
-
*
|
|
132
|
-
* 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/
|
|
133
|
-
*
|
|
134
|
-
* @param {TrackProps} props - The properties required for tracking steps.
|
|
135
|
-
* @param {ExtractInjectionAPI<CollabEditPlugin> | undefined} props.api - The API for the CollabEdit plugin.
|
|
136
|
-
* @param {EditorState} props.newEditorState - The new editor state.
|
|
137
|
-
* @param {Readonly<Transaction[]>} props.transactions - The transactions that contain the steps.
|
|
138
|
-
* @param {(data: StepMetadataAnalytics[]) => void} props.onTrackDataProcessed - Callback function to be called with the processed data.
|
|
139
|
-
*/
|
|
140
|
-
export const track = ({
|
|
141
|
-
api,
|
|
142
|
-
newEditorState,
|
|
143
|
-
transactions,
|
|
144
|
-
onTrackDataProcessed
|
|
145
|
-
}) => {
|
|
146
|
-
const newSteps = transactions.flatMap(t => t.steps);
|
|
147
|
-
const collabState = sendableSteps(newEditorState);
|
|
148
|
-
const scheduler = getScheduler(window);
|
|
149
|
-
if (!newSteps.length || !scheduler || !collabState) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const {
|
|
153
|
-
version
|
|
154
|
-
} = collabState;
|
|
155
|
-
const buffer = stepsSentCache.get(version);
|
|
156
|
-
const startedAt = (buffer === null || buffer === void 0 ? void 0 : buffer.startedAt) || Date.now();
|
|
157
|
-
const endedAt = Date.now();
|
|
158
|
-
const steps = ((buffer === null || buffer === void 0 ? void 0 : buffer.steps) || []).concat(newSteps);
|
|
159
|
-
stepsSentCache.set(version, {
|
|
160
|
-
startedAt,
|
|
161
|
-
endedAt,
|
|
162
|
-
steps
|
|
163
|
-
});
|
|
164
|
-
updateNcsSessionStepMetrics({
|
|
165
|
-
api,
|
|
166
|
-
steps: newSteps.filter(step => !(step instanceof AnalyticsStep))
|
|
167
|
-
});
|
|
168
|
-
scheduler.postTask(() => {
|
|
169
|
-
task(stepsSentCache, onTrackDataProcessed);
|
|
170
|
-
}, {
|
|
171
|
-
priority: 'background',
|
|
172
|
-
delay: LOW_PRIORITY_DELAY
|
|
173
|
-
});
|
|
174
81
|
};
|
|
@@ -10,8 +10,9 @@ import { JSONTransformer } from '@atlaskit/editor-json-transformer/JSONTransform
|
|
|
10
10
|
import { AddMarkStep, AddNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
|
|
11
11
|
import { fg } from '@atlaskit/platform-feature-flags/fg';
|
|
12
12
|
import { collab, getCollabState, sendableSteps } from '@atlaskit/prosemirror-collab';
|
|
13
|
-
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
|
|
14
13
|
import { addSynchronyErrorAnalytics } from './pm-plugins/analytics';
|
|
14
|
+
import { createCollabSendHold } from './pm-plugins/collabSendHold';
|
|
15
|
+
import { collapseStreamingSteps } from './pm-plugins/collapseStreamingSteps';
|
|
15
16
|
import { sendTransaction } from './pm-plugins/events/send-transaction';
|
|
16
17
|
import { filterAnalyticsSteps } from './pm-plugins/filterAnalytics';
|
|
17
18
|
import { createPlugin } from './pm-plugins/main';
|
|
@@ -23,7 +24,6 @@ import { sanitizeFilteredStep, createPlugin as trackSpammingStepsPlugin } from '
|
|
|
23
24
|
import { createPlugin as createLastOrganicChangePlugin, trackLastOrganicChangePluginKey } from './pm-plugins/track-last-organic-change';
|
|
24
25
|
import { createPlugin as createTrackNCSInitializationPlugin, trackNCSInitializationPluginKey } from './pm-plugins/track-ncs-initialization';
|
|
25
26
|
import { createPlugin as createTrackReconnectionConflictPlugin, trackLastRemoteConflictPluginKey } from './pm-plugins/track-reconnection-conflict';
|
|
26
|
-
import { track } from './pm-plugins/track-steps';
|
|
27
27
|
import { getAvatarColor } from './pm-plugins/utils';
|
|
28
28
|
var providerBuilder = function providerBuilder(collabEditProviderPromise) {
|
|
29
29
|
return /*#__PURE__*/function () {
|
|
@@ -107,6 +107,9 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
|
|
|
107
107
|
providerResolver = _providerResolver;
|
|
108
108
|
});
|
|
109
109
|
var executeProviderCode = providerBuilder(collabEditProviderPromise);
|
|
110
|
+
// Spans transactions: a streaming producer holds the flush across many state updates, not just
|
|
111
|
+
// the ones it dispatches itself. See `createCollabSendHold`.
|
|
112
|
+
var sendHold = createCollabSendHold();
|
|
110
113
|
return {
|
|
111
114
|
name: 'collabEdit',
|
|
112
115
|
getSharedState: function getSharedState(state) {
|
|
@@ -198,7 +201,10 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
|
|
|
198
201
|
_ref5$userId = _ref5.userId,
|
|
199
202
|
userId = _ref5$userId === void 0 ? null : _ref5$userId;
|
|
200
203
|
var transformUnconfirmed = function transformUnconfirmed(steps) {
|
|
201
|
-
|
|
204
|
+
// Analytics steps are filtered first so they cannot break up an otherwise adjacent
|
|
205
|
+
// run of streaming frames.
|
|
206
|
+
var filtered = filterAnalyticsSteps(steps);
|
|
207
|
+
var transformed = fg('platform_editor_ai_collapse_streaming_steps') ? collapseStreamingSteps(filtered) : filtered;
|
|
202
208
|
return mergeUnconfirmedSteps(transformed, api);
|
|
203
209
|
};
|
|
204
210
|
var plugins = [].concat(_toConsumableArray(useNativePlugin ? [{
|
|
@@ -258,6 +264,18 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
|
|
|
258
264
|
},
|
|
259
265
|
onEditorViewStateUpdated: function onEditorViewStateUpdated(props) {
|
|
260
266
|
var _api$analytics2, _api$editorViewMode, _options$useNativePlu, _options$hideTelecurs;
|
|
267
|
+
// `props.transactions` is what `applyTransaction` produced: the dispatched transaction
|
|
268
|
+
// followed by everything `appendTransaction` added. Streaming frames are built in an
|
|
269
|
+
// appendTransaction, so their hold metadata is never on `originalTransaction`.
|
|
270
|
+
// Evaluated once and used for both the observation and the send decision. If the gate
|
|
271
|
+
// is turned off mid-stream, an already-active hold must stop applying immediately
|
|
272
|
+
// rather than persisting until the backstop expires and blocking every send.
|
|
273
|
+
var collapseStreamingStepsEnabled = fg('platform_editor_ai_collapse_streaming_steps');
|
|
274
|
+
if (collapseStreamingStepsEnabled) {
|
|
275
|
+
props.transactions.forEach(function (transaction) {
|
|
276
|
+
return sendHold.observe(transaction);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
261
279
|
var addErrorAnalytics = addSynchronyErrorAnalytics(props.newEditorState, props.newEditorState.tr, featureFlags, api === null || api === void 0 || (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions);
|
|
262
280
|
var isEmptyDoc = isEmptyDocument(props.newEditorState.doc);
|
|
263
281
|
var viewMode = api === null || api === void 0 || (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 || (_api$editorViewMode = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode === void 0 ? void 0 : _api$editorViewMode.mode;
|
|
@@ -268,32 +286,16 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
|
|
|
268
286
|
newEditorState: props.newEditorState,
|
|
269
287
|
useNativePlugin: (_options$useNativePlu = options.useNativePlugin) !== null && _options$useNativePlu !== void 0 ? _options$useNativePlu : false,
|
|
270
288
|
hideTelecursorOnLoad: !isEmptyDoc && ((_options$hideTelecurs = options.hideTelecursorOnLoad) !== null && _options$hideTelecurs !== void 0 ? _options$hideTelecurs : false),
|
|
271
|
-
viewMode: viewMode
|
|
289
|
+
viewMode: viewMode,
|
|
290
|
+
isSendHeld: collapseStreamingStepsEnabled && sendHold.isHeld()
|
|
272
291
|
}), addErrorAnalytics);
|
|
273
|
-
if (!expValEquals('platform_editor_remove_collab_step_metrics', 'isEnabled', true)) {
|
|
274
|
-
track(_objectSpread(_objectSpread({
|
|
275
|
-
api: api
|
|
276
|
-
}, props), {}, {
|
|
277
|
-
onTrackDataProcessed: function onTrackDataProcessed(steps) {
|
|
278
|
-
var _api$analytics3;
|
|
279
|
-
api === null || api === void 0 || (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 || (_api$analytics3 = _api$analytics3.actions) === null || _api$analytics3 === void 0 || _api$analytics3.fireAnalyticsEvent({
|
|
280
|
-
action: ACTION.STEPS_TRACKED,
|
|
281
|
-
actionSubject: ACTION_SUBJECT.COLLAB,
|
|
282
|
-
attributes: {
|
|
283
|
-
steps: steps
|
|
284
|
-
},
|
|
285
|
-
eventType: EVENT_TYPE.OPERATIONAL
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}));
|
|
289
|
-
}
|
|
290
292
|
if (fg('platform_editor_collab_organic_change_reporting')) {
|
|
291
293
|
monitorOrganic(_objectSpread(_objectSpread({
|
|
292
294
|
api: api
|
|
293
295
|
}, props), {}, {
|
|
294
296
|
onDataProcessed: function onDataProcessed(data) {
|
|
295
|
-
var _api$
|
|
296
|
-
api === null || api === void 0 || (_api$
|
|
297
|
+
var _api$analytics3;
|
|
298
|
+
api === null || api === void 0 || (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 || (_api$analytics3 = _api$analytics3.actions) === null || _api$analytics3 === void 0 || _api$analytics3.fireAnalyticsEvent({
|
|
297
299
|
action: ACTION.ORGANIC_CHANGES_TRACKED,
|
|
298
300
|
actionSubject: ACTION_SUBJECT.COLLAB,
|
|
299
301
|
attributes: {
|