@atlaskit/editor-plugin-local-id 5.0.2 → 5.1.0

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.
@@ -0,0 +1,497 @@
1
+ import { BatchAttrsStep, SetAttrsStep } from '@atlaskit/adf-schema/steps';
2
+ import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
3
+ import { PluginKey } from '@atlaskit/editor-prosemirror/state';
4
+ import { AttrStep, DocAttrStep, ReplaceAroundStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
5
+ /**
6
+ * This is a safeguard limit to avoid tracking localIds in extremely large documents with too many localIds.
7
+ * If the number of unique localIds exceeds this limit, the watchmen plugin will disable itself to avoid performance issues.
8
+ * Reminder: The Map has a hard limit of 2^24 (16 million) entries in V8, please keep this value well below that
9
+ * to avoid any potential memory issues.
10
+ */
11
+ const MAX_LOCAL_ID_MAP_SIZE = 2097152; // 2^21
12
+
13
+ /**
14
+ * Plugin state tracking all localIds in the document
15
+ */
16
+
17
+ export const localIdWatchmenPluginKey = new PluginKey('localIdWatchmenPlugin');
18
+
19
+ /**
20
+ * Scans the entire document to find all active localIds
21
+ */
22
+ const scanDocumentForLocalIds = doc => {
23
+ const localIds = new Set();
24
+ doc.descendants(node => {
25
+ var _node$attrs;
26
+ if ((_node$attrs = node.attrs) !== null && _node$attrs !== void 0 && _node$attrs.localId) {
27
+ localIds.add(node.attrs.localId);
28
+ }
29
+
30
+ // Check marks for localIds
31
+ if (node.marks) {
32
+ node.marks.forEach(mark => {
33
+ var _mark$attrs;
34
+ if ((_mark$attrs = mark.attrs) !== null && _mark$attrs !== void 0 && _mark$attrs.localId) {
35
+ localIds.add(mark.attrs.localId);
36
+ }
37
+ });
38
+ }
39
+ return localIds.size < MAX_LOCAL_ID_MAP_SIZE; // Continue traversing
40
+ });
41
+ return localIds;
42
+ };
43
+ const getReplacementStatusCode = (tr, step) => {
44
+ let method;
45
+ if (step instanceof AttrStep || step instanceof DocAttrStep) {
46
+ method = 'ByAttr';
47
+ } else if (step instanceof SetAttrsStep) {
48
+ method = 'BySetAttrs';
49
+ } else if (step instanceof BatchAttrsStep) {
50
+ method = 'ByBatchAttrs';
51
+ } else if (step instanceof ReplaceStep) {
52
+ const isDeleting = step.from < step.to; // range has content to remove
53
+ const isInserting = step.slice.content.size > 0; // slice has content to insert
54
+ if (isDeleting && !isInserting) {
55
+ method = 'ByDelete'; // removing content, inserting nothing
56
+ //} else if (!isDeleting && isInserting) {
57
+ //method = 'ByInsert'; // This situation cannot be tracked since this would be part of the "current" status
58
+ } else {
59
+ // isDeleting && isInserting
60
+ method = 'ByReplace';
61
+ }
62
+ } else if (step instanceof ReplaceAroundStep) {
63
+ method = 'ByReplaceAround';
64
+ } else {
65
+ method = 'ByUnknown';
66
+ }
67
+ if (tr.getMeta('isAIStreamingTransformation')) {
68
+ return `AIChange${method}`;
69
+ }
70
+ if (tr.getMeta('replaceDocument')) {
71
+ return `docChange${method}`;
72
+ }
73
+ if (tr.getMeta('isRemote')) {
74
+ return `remoteChange${method}`;
75
+ }
76
+ return `localChange${method}`;
77
+ };
78
+
79
+ /**
80
+ * Handles AttrStep and DocAttrStep which modify a single attribute
81
+ */
82
+ const handleAttrStep = (tr, step, localIdStatus, preDoc) => {
83
+ if (step.attr !== 'localId') {
84
+ return {
85
+ localIdStatus,
86
+ modified: false
87
+ };
88
+ }
89
+ let modified = false;
90
+ const newlocalIdStatus = new Map(localIdStatus);
91
+
92
+ // Get the old value if it exists
93
+ let oldLocalId;
94
+ if (step instanceof AttrStep) {
95
+ try {
96
+ var _node$attrs2;
97
+ const node = preDoc.nodeAt(step.pos);
98
+ oldLocalId = node === null || node === void 0 ? void 0 : (_node$attrs2 = node.attrs) === null || _node$attrs2 === void 0 ? void 0 : _node$attrs2.localId;
99
+ } catch {
100
+ // Position might be invalid
101
+ }
102
+ }
103
+
104
+ // Handle the new value
105
+ const newLocalId = step.value;
106
+ if (oldLocalId && oldLocalId !== newLocalId) {
107
+ // Old localId is being replaced or removed
108
+ newlocalIdStatus.set(oldLocalId, getReplacementStatusCode(tr, step));
109
+ modified = true;
110
+ }
111
+ if (newLocalId) {
112
+ newlocalIdStatus.set(newLocalId, 'current');
113
+ modified = true;
114
+ }
115
+ return {
116
+ localIdStatus: newlocalIdStatus,
117
+ modified
118
+ };
119
+ };
120
+
121
+ /**
122
+ * Handles SetAttrsStep which sets multiple attributes at once
123
+ */
124
+ const handleSetAttrsStep = (tr, step, localIdStatus, preDoc) => {
125
+ const attrs = step.attrs;
126
+ if (!attrs || !attrs.hasOwnProperty('localId')) {
127
+ return {
128
+ localIdStatus,
129
+ modified: false
130
+ };
131
+ }
132
+ let modified = false;
133
+ const newlocalIdStatus = new Map(localIdStatus);
134
+
135
+ // Get old localId from the node being modified
136
+ try {
137
+ var _node$attrs3;
138
+ const node = preDoc.nodeAt(step.pos);
139
+ const oldLocalId = node === null || node === void 0 ? void 0 : (_node$attrs3 = node.attrs) === null || _node$attrs3 === void 0 ? void 0 : _node$attrs3.localId;
140
+ if (oldLocalId && oldLocalId !== attrs.localId) {
141
+ newlocalIdStatus.set(oldLocalId, getReplacementStatusCode(tr, step));
142
+ modified = true;
143
+ }
144
+ } catch {
145
+ // Position might be invalid
146
+ }
147
+ const newLocalId = attrs.localId;
148
+ if (newLocalId) {
149
+ newlocalIdStatus.set(newLocalId, 'current');
150
+ modified = true;
151
+ }
152
+ return {
153
+ localIdStatus: newlocalIdStatus,
154
+ modified
155
+ };
156
+ };
157
+
158
+ /**
159
+ * Handles BatchAttrsStep which applies multiple attribute changes
160
+ */
161
+ const handleBatchAttrsStep = (tr, step, localIdStatus, preDoc) => {
162
+ let modified = false;
163
+ const newlocalIdStatus = new Map(localIdStatus);
164
+ step.data.forEach(change => {
165
+ var _change$attrs;
166
+ if (!((_change$attrs = change.attrs) !== null && _change$attrs !== void 0 && _change$attrs.hasOwnProperty('localId'))) {
167
+ return;
168
+ }
169
+
170
+ // Get old localId from the node being modified
171
+ try {
172
+ var _node$attrs4;
173
+ const node = preDoc.nodeAt(change.position);
174
+ const oldLocalId = node === null || node === void 0 ? void 0 : (_node$attrs4 = node.attrs) === null || _node$attrs4 === void 0 ? void 0 : _node$attrs4.localId;
175
+ const newLocalId = change.attrs.localId;
176
+ if (oldLocalId && oldLocalId !== newLocalId) {
177
+ newlocalIdStatus.set(oldLocalId, getReplacementStatusCode(tr, step));
178
+ modified = true;
179
+ }
180
+ if (newLocalId) {
181
+ newlocalIdStatus.set(newLocalId, 'current');
182
+ modified = true;
183
+ }
184
+ } catch {
185
+ // Position might be invalid
186
+ }
187
+ });
188
+ return {
189
+ localIdStatus: newlocalIdStatus,
190
+ modified
191
+ };
192
+ };
193
+
194
+ /**
195
+ * Handles ReplaceStep which inserts or deletes content
196
+ */
197
+ const handleReplaceStep = (tr, step, localIdStatus, preDoc, postDoc) => {
198
+ let modified = false;
199
+ try {
200
+ // Create a temporary set to collect new localIds
201
+ const changedLocaleIds = new Map();
202
+ const replaceCode = getReplacementStatusCode(tr, step);
203
+ step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
204
+ // For each step map item we can just look at the nodes in the old doc and mark them as "inactive" and then
205
+ // look at the nodes in the doc after the step has been applied and mark them as "active"
206
+ // then lastly we can compare these to the current states and only update what's different.
207
+ preDoc.nodesBetween(oldStart, oldEnd, node => {
208
+ var _node$attrs5;
209
+ if ((_node$attrs5 = node.attrs) !== null && _node$attrs5 !== void 0 && _node$attrs5.localId) {
210
+ changedLocaleIds.set(node.attrs.localId, replaceCode);
211
+ }
212
+ if (node.marks) {
213
+ node.marks.forEach(mark => {
214
+ var _mark$attrs2;
215
+ if ((_mark$attrs2 = mark.attrs) !== null && _mark$attrs2 !== void 0 && _mark$attrs2.localId) {
216
+ changedLocaleIds.set(node.attrs.localId, replaceCode);
217
+ }
218
+ });
219
+ }
220
+ });
221
+ postDoc.nodesBetween(newStart, newEnd, node => {
222
+ var _node$attrs6;
223
+ if ((_node$attrs6 = node.attrs) !== null && _node$attrs6 !== void 0 && _node$attrs6.localId) {
224
+ changedLocaleIds.set(node.attrs.localId, 'current');
225
+ }
226
+ if (node.marks) {
227
+ node.marks.forEach(mark => {
228
+ var _mark$attrs3;
229
+ if ((_mark$attrs3 = mark.attrs) !== null && _mark$attrs3 !== void 0 && _mark$attrs3.localId) {
230
+ changedLocaleIds.set(node.attrs.localId, 'current');
231
+ }
232
+ });
233
+ }
234
+ });
235
+ });
236
+ if (!!changedLocaleIds.size) {
237
+ const newlocalIdStatus = new Map(localIdStatus);
238
+ for (const [key, value] of changedLocaleIds) {
239
+ if (!localIdStatus.has(key) || localIdStatus.get(key) !== value) {
240
+ modified = true;
241
+ newlocalIdStatus.set(key, value);
242
+ }
243
+ }
244
+ return {
245
+ localIdStatus: newlocalIdStatus,
246
+ modified
247
+ };
248
+ }
249
+ } catch {
250
+ // If position calculation fails, do a full document rescan as fallback
251
+ // This shouldn't happen often but provides safety
252
+ }
253
+ return {
254
+ localIdStatus,
255
+ modified
256
+ };
257
+ };
258
+
259
+ /**
260
+ * Handles ReplaceAroundStep which wraps or unwraps content
261
+ */
262
+ const handleReplaceAroundStep = (tr, step, localIdStatus, preDoc, postDoc) => {
263
+ let modified = false;
264
+ const newlocalIdStatus = new Map(localIdStatus);
265
+
266
+ // Scan the affected region before and after the step
267
+ const from = step.from;
268
+ const to = step.to;
269
+ try {
270
+ // Collect localIds from the old region
271
+ const oldLocalIds = new Set();
272
+ if (from < to && from >= 0 && to <= preDoc.content.size) {
273
+ preDoc.nodesBetween(from, to, node => {
274
+ var _node$attrs7;
275
+ if ((_node$attrs7 = node.attrs) !== null && _node$attrs7 !== void 0 && _node$attrs7.localId) {
276
+ oldLocalIds.add(node.attrs.localId);
277
+ }
278
+ if (node.marks) {
279
+ node.marks.forEach(mark => {
280
+ var _mark$attrs4;
281
+ if ((_mark$attrs4 = mark.attrs) !== null && _mark$attrs4 !== void 0 && _mark$attrs4.localId) {
282
+ oldLocalIds.add(mark.attrs.localId);
283
+ }
284
+ });
285
+ }
286
+ });
287
+ }
288
+
289
+ // Collect localIds from the new region
290
+ const map = step.getMap();
291
+ const newFrom = map.map(from, -1);
292
+ const newTo = map.map(to, 1);
293
+ const newLocalIds = new Set();
294
+ if (newFrom < newTo && newFrom >= 0 && newTo <= postDoc.content.size) {
295
+ postDoc.nodesBetween(newFrom, newTo, node => {
296
+ var _node$attrs8;
297
+ if ((_node$attrs8 = node.attrs) !== null && _node$attrs8 !== void 0 && _node$attrs8.localId) {
298
+ newLocalIds.add(node.attrs.localId);
299
+ }
300
+ if (node.marks) {
301
+ node.marks.forEach(mark => {
302
+ var _mark$attrs5;
303
+ if ((_mark$attrs5 = mark.attrs) !== null && _mark$attrs5 !== void 0 && _mark$attrs5.localId) {
304
+ newLocalIds.add(mark.attrs.localId);
305
+ }
306
+ });
307
+ }
308
+ });
309
+ }
310
+
311
+ // Find localIds that were removed
312
+ oldLocalIds.forEach(localId => {
313
+ if (!newLocalIds.has(localId) && newlocalIdStatus.get(localId) === 'current') {
314
+ newlocalIdStatus.set(localId, getReplacementStatusCode(tr, step));
315
+ modified = true;
316
+ }
317
+ });
318
+
319
+ // Find localIds that were added
320
+ newLocalIds.forEach(localId => {
321
+ if (!oldLocalIds.has(localId)) {
322
+ newlocalIdStatus.set(localId, 'current');
323
+ modified = true;
324
+ }
325
+ });
326
+ } catch {
327
+ // Position might be invalid, skip this step
328
+ }
329
+ return {
330
+ localIdStatus: newlocalIdStatus,
331
+ modified
332
+ };
333
+ };
334
+
335
+ /**
336
+ * Processes a transaction to update localId tracking state
337
+ */
338
+ const processTransaction = (tr, currentState) => {
339
+ let localIdStatus = currentState.localIdStatus;
340
+ let modified = false;
341
+
342
+ // Process each step in the transaction
343
+ try {
344
+ tr.steps.forEach((step, index) => {
345
+ var _tr$docs$index, _tr$docs, _tr$docs2, _tr$docs3;
346
+ let result;
347
+ // steps are relative to their docs, so we ensure we reference the doc before/after the step was applied.
348
+ const preDoc = (_tr$docs$index = (_tr$docs = tr.docs) === null || _tr$docs === void 0 ? void 0 : _tr$docs[index]) !== null && _tr$docs$index !== void 0 ? _tr$docs$index : tr.doc;
349
+ const postDoc = (_tr$docs2 = (_tr$docs3 = tr.docs) === null || _tr$docs3 === void 0 ? void 0 : _tr$docs3[index + 1]) !== null && _tr$docs2 !== void 0 ? _tr$docs2 : tr.doc;
350
+ if (step instanceof AttrStep || step instanceof DocAttrStep) {
351
+ result = handleAttrStep(tr, step, localIdStatus, preDoc);
352
+ } else if (step instanceof SetAttrsStep) {
353
+ result = handleSetAttrsStep(tr, step, localIdStatus, preDoc);
354
+ } else if (step instanceof BatchAttrsStep) {
355
+ result = handleBatchAttrsStep(tr, step, localIdStatus, preDoc);
356
+ } else if (step instanceof ReplaceStep) {
357
+ result = handleReplaceStep(tr, step, localIdStatus, preDoc, postDoc);
358
+ } else if (step instanceof ReplaceAroundStep) {
359
+ result = handleReplaceAroundStep(tr, step, localIdStatus, preDoc, postDoc);
360
+ } else {
361
+ // Unknown step type, no changes
362
+ result = {
363
+ localIdStatus,
364
+ modified: false
365
+ };
366
+ }
367
+ localIdStatus = result.localIdStatus;
368
+ modified = modified || result.modified;
369
+ });
370
+ } catch {
371
+ // If any error occurs during step processing, we fallback to disabling the plugin
372
+ return {
373
+ enabled: false,
374
+ initLocalIdSize: currentState.initLocalIdSize,
375
+ localIdStatus: new Map(),
376
+ lastUpdated: Date.now()
377
+ };
378
+ }
379
+
380
+ // If nothing changed, return the same state object
381
+ if (!modified) {
382
+ return currentState;
383
+ }
384
+
385
+ // If we exceeded the max size while processing the steps, we need to disable the watchmen from further processing.
386
+ // If a Map size limit of 2^24 is exceeded then it's more than likely an error would have been thrown during processing
387
+ // which would also disable this plugin.
388
+ if (localIdStatus.size >= MAX_LOCAL_ID_MAP_SIZE) {
389
+ return {
390
+ enabled: false,
391
+ initLocalIdSize: currentState.initLocalIdSize,
392
+ localIdStatus: new Map(),
393
+ lastUpdated: Date.now()
394
+ };
395
+ }
396
+
397
+ // Return new state with updated sets
398
+ return {
399
+ enabled: true,
400
+ initLocalIdSize: currentState.initLocalIdSize,
401
+ localIdStatus,
402
+ lastUpdated: Date.now()
403
+ };
404
+ };
405
+
406
+ /**
407
+ * Creates the localId watchmen plugin
408
+ */
409
+ export const createWatchmenPlugin = api => {
410
+ // Ensure limited mode is initialized
411
+ return new SafePlugin({
412
+ key: localIdWatchmenPluginKey,
413
+ state: {
414
+ init(_config, state) {
415
+ var _api$limitedMode$shar, _api$limitedMode, _api$limitedMode$shar2;
416
+ const isLimitedModeEnabled = (_api$limitedMode$shar = api === null || api === void 0 ? void 0 : (_api$limitedMode = api.limitedMode) === null || _api$limitedMode === void 0 ? void 0 : (_api$limitedMode$shar2 = _api$limitedMode.sharedState.currentState()) === null || _api$limitedMode$shar2 === void 0 ? void 0 : _api$limitedMode$shar2.enabled) !== null && _api$limitedMode$shar !== void 0 ? _api$limitedMode$shar : false;
417
+ if (isLimitedModeEnabled) {
418
+ return {
419
+ enabled: false,
420
+ initLocalIdSize: -1,
421
+ localIdStatus: new Map(),
422
+ lastUpdated: Date.now()
423
+ };
424
+ }
425
+
426
+ // Initialize by scanning the entire document
427
+ const activeLocalIds = scanDocumentForLocalIds(state.doc);
428
+ if (activeLocalIds.size >= MAX_LOCAL_ID_MAP_SIZE) {
429
+ return {
430
+ enabled: false,
431
+ initLocalIdSize: activeLocalIds.size,
432
+ localIdStatus: new Map(),
433
+ lastUpdated: Date.now()
434
+ };
435
+ }
436
+ return {
437
+ enabled: true,
438
+ initLocalIdSize: activeLocalIds.size,
439
+ localIdStatus: new Map(Array.from(activeLocalIds).map(key => [key, 'current'])),
440
+ lastUpdated: Date.now()
441
+ };
442
+ },
443
+ apply(tr, currentPluginState) {
444
+ const {
445
+ enabled
446
+ } = tr.getMeta(localIdWatchmenPluginKey) || {
447
+ enabled: currentPluginState.enabled
448
+ };
449
+ const newPluginState = currentPluginState;
450
+ if (enabled !== currentPluginState.enabled) {
451
+ // If this plugin enabled state is changing and it's being disabled at runtime then we will kill this plugin
452
+ // to avoid tracking localIds when in limited mode or there after.
453
+ // Once disabled it cannot be re-enabled without a full editor reload.
454
+ if (!enabled) {
455
+ return {
456
+ enabled: false,
457
+ initLocalIdSize: currentPluginState.initLocalIdSize,
458
+ localIdStatus: currentPluginState.localIdStatus,
459
+ lastUpdated: Date.now()
460
+ };
461
+ }
462
+ }
463
+ if (!newPluginState.enabled) {
464
+ // If this plugin has been disabled, do not track localIds.
465
+ return newPluginState;
466
+ }
467
+
468
+ // If no steps, nothing changed
469
+ if (tr.steps.length === 0 || !tr.docChanged) {
470
+ return newPluginState;
471
+ }
472
+
473
+ // Process the transaction to update state
474
+ return processTransaction(tr, newPluginState);
475
+ }
476
+ },
477
+ view(editorView) {
478
+ var _api$limitedMode2;
479
+ // If limited mode changes, for example if we start not limited but then all of a sudden become limited, we kill
480
+ // the watchment plugin to avoid tracking localIds when in limited mode. We also don't want/need to re-enable it once it's disabled.
481
+ const unsub = api === null || api === void 0 ? void 0 : (_api$limitedMode2 = api.limitedMode) === null || _api$limitedMode2 === void 0 ? void 0 : _api$limitedMode2.sharedState.onChange(({
482
+ nextSharedState
483
+ }) => {
484
+ const watchmentPluginState = localIdWatchmenPluginKey.getState(editorView.state);
485
+ if (nextSharedState.enabled && (watchmentPluginState === null || watchmentPluginState === void 0 ? void 0 : watchmentPluginState.enabled) === true) {
486
+ // if nextSharedState.enabled === true, then we need to disable the watchmen plugin, if not already disabled
487
+ editorView.dispatch(editorView.state.tr.setMeta(localIdWatchmenPluginKey, {
488
+ enabled: false
489
+ }));
490
+ }
491
+ });
492
+ return {
493
+ destroy: unsub
494
+ };
495
+ }
496
+ });
497
+ };
@@ -1,5 +1,7 @@
1
+ import { fg } from '@atlaskit/platform-feature-flags';
1
2
  import { replaceNode, getNode } from './editor-actions';
2
3
  import { createPlugin } from './pm-plugins/main';
4
+ import { createWatchmenPlugin, localIdWatchmenPluginKey } from './pm-plugins/watchmen';
3
5
  export var localIdPlugin = function localIdPlugin(_ref) {
4
6
  var api = _ref.api;
5
7
  return {
@@ -14,7 +16,22 @@ export var localIdPlugin = function localIdPlugin(_ref) {
14
16
  plugin: function plugin() {
15
17
  return createPlugin(api);
16
18
  }
19
+ }, {
20
+ name: 'localId-watchmen',
21
+ plugin: function plugin() {
22
+ return fg('platform_editor_ai_aifc_localid_error_reporting') ? createWatchmenPlugin(api) : undefined;
23
+ }
17
24
  }];
25
+ },
26
+ getSharedState: function getSharedState(editorState) {
27
+ if (!editorState) {
28
+ return undefined;
29
+ }
30
+ var watchmentPluginState = localIdWatchmenPluginKey.getState(editorState);
31
+ return {
32
+ localIdWatchmenEnabled: !!(watchmentPluginState !== null && watchmentPluginState !== void 0 && watchmentPluginState.enabled),
33
+ localIdStatus: new Map(watchmentPluginState === null || watchmentPluginState === void 0 ? void 0 : watchmentPluginState.localIdStatus)
34
+ };
18
35
  }
19
36
  };
20
37
  };