@atlaskit/editor-plugin-synced-block 10.4.7 → 10.6.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.
@@ -39,6 +39,7 @@ var getToolbarConfig = exports.getToolbarConfig = function getToolbarConfig(stat
39
39
  if (syncBlockStore.sourceManager.isPendingCreation(syncBlockObject.node.attrs.resourceId)) {
40
40
  return;
41
41
  }
42
+ var isSyncBlockActivationEnabled = (0, _expValEquals.expValEquals)('platform_editor_sync_block_activation', 'isEnabled', true);
42
43
  var syncBlockInstance = syncBlockStore.referenceManager.getFromCache(syncBlockObject.node.attrs.resourceId);
43
44
  var isUnsyncedBlock = (syncBlockInstance === null || syncBlockInstance === void 0 || (_syncBlockInstance$er = syncBlockInstance.error) === null || _syncBlockInstance$er === void 0 ? void 0 : _syncBlockInstance$er.type) === _editorSyncedBlockProvider.SyncBlockError.NotFound;
44
45
  var isErroredBlock = syncBlockInstance === null || syncBlockInstance === void 0 ? void 0 : syncBlockInstance.error;
@@ -59,7 +60,6 @@ var getToolbarConfig = exports.getToolbarConfig = function getToolbarConfig(stat
59
60
  };
60
61
  };
61
62
  var items = [];
62
- var isSyncBlockActivationEnabled = (0, _expValEquals.expValEquals)('platform_editor_sync_block_activation', 'isEnabled', true);
63
63
  if (isUnsyncedBlock) {
64
64
  var deleteButton = _objectSpread({
65
65
  type: 'button',
@@ -197,7 +197,8 @@ var getToolbarConfig = exports.getToolbarConfig = function getToolbarConfig(stat
197
197
  return tr.setSelection(new _selection.GapCursorSelection(tr.doc.resolve(syncBlockObject.pos + syncBlockObject.node.nodeSize), _selection.Side.RIGHT));
198
198
  });
199
199
  onGiveFeedback({
200
- blockType: isBodiedSyncBlock ? 'source' : 'reference'
200
+ blockType: isBodiedSyncBlock ? 'source' : 'reference',
201
+ entryPoint: 'overflow-menu'
201
202
  });
202
203
  return true;
203
204
  }
@@ -6,6 +6,7 @@ import { NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosem
6
6
  import { findSelectedNodeOfType, removeParentNodeOfType, removeSelectedNode, safeInsert } from '@atlaskit/editor-prosemirror/utils';
7
7
  import { getSourceProductFromResourceIdSafe } from '@atlaskit/editor-synced-block-provider/utils';
8
8
  import { fg } from '@atlaskit/platform-feature-flags';
9
+ import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
9
10
  import { creationMetaKey, deleteMechanismMetaKey, syncedBlockPluginKey } from '../pm-plugins/main';
10
11
  import { canBeConvertedToSyncBlock, deferDispatch, findSyncBlock, findSyncBlockOrBodiedSyncBlock, isBodiedSyncBlockNode } from '../pm-plugins/utils/utils';
11
12
  import { FLAG_ID } from '../types';
@@ -355,7 +356,11 @@ export const removeSyncedBlockAtPos = (api, pos) => {
355
356
  const node = tr.doc.nodeAt(pos);
356
357
  if ((node === null || node === void 0 ? void 0 : node.type.name) === 'syncBlock') {
357
358
  var _node$nodeSize;
358
- return tr.replace(pos, pos + ((_node$nodeSize = node === null || node === void 0 ? void 0 : node.nodeSize) !== null && _node$nodeSize !== void 0 ? _node$nodeSize : 0));
359
+ const removeTr = tr.replace(pos, pos + ((_node$nodeSize = node === null || node === void 0 ? void 0 : node.nodeSize) !== null && _node$nodeSize !== void 0 ? _node$nodeSize : 0));
360
+ if (expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true)) {
361
+ removeTr.setMeta(deleteMechanismMetaKey, 'deleteButton');
362
+ }
363
+ return removeTr;
359
364
  }
360
365
  return tr;
361
366
  });
@@ -376,6 +381,12 @@ export const unsync = (storeManager, isBodiedSyncBlock, view) => {
376
381
  return false;
377
382
  }
378
383
  if (isBodiedSyncBlock) {
384
+ // Signal the unsync intent. This transaction is intercepted by the plugin's
385
+ // filterTransaction, which shows the deletion-confirmation modal and, on confirm,
386
+ // recomputes the actual document change from the live document keyed off the
387
+ // `deletionReason: 'source-block-unsynced'` meta (see handleBodiedSyncBlockRemoval /
388
+ // recomputeUnsyncTransaction). The real unwrap-vs-delete decision therefore lives in the
389
+ // confirm path, not here (EDITOR-8230).
379
390
  const content = syncBlock === null || syncBlock === void 0 ? void 0 : syncBlock.node.content;
380
391
  const {
381
392
  tr
@@ -13,6 +13,7 @@ import { convertPMNodesToSyncBlockNodes, rebaseTransaction } from '@atlaskit/edi
13
13
  import { getSourceProductFromResourceIdSafe } from '@atlaskit/editor-synced-block-provider/utils';
14
14
  import { fg } from '@atlaskit/platform-feature-flags';
15
15
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
16
+ import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
16
17
  import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
17
18
  import { bodiedSyncBlockNodeView, bodiedSyncBlockNodeViewOld } from '../nodeviews/bodiedSyncedBlock';
18
19
  import { SyncBlock as SyncBlockView } from '../nodeviews/syncedBlock';
@@ -37,6 +38,7 @@ export const syncedBlockPluginKey = new PluginKey('syncedBlockPlugin');
37
38
  * `platform_editor_blocks_patch_4`.
38
39
  */
39
40
  export const deleteMechanismMetaKey = new PluginKey('syncedBlockDeleteMechanism');
41
+ const syncedBlockPromptedFeedbackMetaKey = new PluginKey('syncedBlockPromptedFeedback');
40
42
 
41
43
  /**
42
44
  * Creation-type signals set by {@link createSyncedBlock} on the creating
@@ -121,7 +123,7 @@ const getDeleteReason = tr => {
121
123
  };
122
124
 
123
125
  /** Narrows the history plugin meta, matching the editor-plugin-card pattern. */
124
- const isHistoryMeta = meta => typeof meta === 'object' && meta !== null && 'redo' in meta;
126
+ const isHistoryMeta = meta => typeof meta === 'object' && meta !== null && 'redo' in meta && typeof meta.redo === 'boolean';
125
127
 
126
128
  /**
127
129
  * Derive how a source bodiedSyncBlock removal was performed, for the `mechanism`
@@ -145,9 +147,16 @@ const isHistoryMeta = meta => typeof meta === 'object' && meta !== null && 'redo
145
147
  * selected when the edit was made.
146
148
  */
147
149
  export const getDeleteMechanism = (tr, state) => {
148
- var _selection$node;
150
+ var _selection$node, _selection$node2;
151
+ // Keep the legacy analytics classification unchanged while activation is off.
152
+ // The stricter history, structural-step, and reference-selection handling is
153
+ // only needed by the prompted feedback rollout.
154
+ const isSyncBlockActivationEnabled = expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true);
149
155
  const historyMeta = tr.getMeta(pmHistoryPluginKey);
150
156
  if (historyMeta) {
157
+ if (isSyncBlockActivationEnabled && !isHistoryMeta(historyMeta)) {
158
+ return 'other';
159
+ }
151
160
  return isHistoryMeta(historyMeta) && historyMeta.redo ? 'redo' : 'undo';
152
161
  }
153
162
 
@@ -156,16 +165,26 @@ export const getDeleteMechanism = (tr, state) => {
156
165
  if (tr.getMeta(deleteMechanismMetaKey) === 'deleteButton') {
157
166
  return 'deleteButton';
158
167
  }
168
+ const hasStructuralStep = isSyncBlockActivationEnabled && tr.steps.some(step => step instanceof ReplaceAroundStep);
159
169
  const hasReplaceStep = tr.steps.some(step => step instanceof ReplaceStep);
160
- if (!hasReplaceStep) {
170
+ if (hasStructuralStep || !hasReplaceStep) {
161
171
  return 'other';
162
172
  }
163
173
  const {
164
174
  selection
165
175
  } = state;
166
- const isNodeSelected = selection instanceof NodeSelection && ((_selection$node = selection.node) === null || _selection$node === void 0 ? void 0 : _selection$node.type.name) === 'bodiedSyncBlock';
176
+ const isNodeSelected = selection instanceof NodeSelection && (((_selection$node = selection.node) === null || _selection$node === void 0 ? void 0 : _selection$node.type.name) === 'bodiedSyncBlock' || isSyncBlockActivationEnabled && ((_selection$node2 = selection.node) === null || _selection$node2 === void 0 ? void 0 : _selection$node2.type.name) === 'syncBlock');
167
177
  return isNodeSelected ? 'selectionReplaced' : 'keyboardDelete';
168
178
  };
179
+ export const getPromptedFeedbackEntryPoint = mechanism => {
180
+ if (mechanism === 'undo') {
181
+ return 'prompted-undo';
182
+ }
183
+ if (mechanism === 'deleteButton' || mechanism === 'keyboardDelete' || mechanism === 'selectionReplaced') {
184
+ return 'prompted-delete';
185
+ }
186
+ return undefined;
187
+ };
169
188
  /**
170
189
  * True when a source `bodiedSyncBlock` holds user content — i.e. not just the
171
190
  * single empty paragraph it is created with (used to detect empty→content).
@@ -211,6 +230,7 @@ const filterTransactionOnline = ({
211
230
  syncBlockStore,
212
231
  api,
213
232
  confirmationTransactionRef,
233
+ ctx,
214
234
  bodiedSyncBlockRemoved,
215
235
  bodiedSyncBlockAdded,
216
236
  extensionFlagShown
@@ -241,6 +261,19 @@ const filterTransactionOnline = ({
241
261
  // dedicated `cross_product_paste` event subject.
242
262
  const isPaste = Boolean((_tr$getMeta = tr.getMeta('paste')) !== null && _tr$getMeta !== void 0 ? _tr$getMeta : tr.getMeta('uiEvent') === 'paste');
243
263
  const isPasteOrDrop = isPaste || tr.getMeta('uiEvent') === 'drop';
264
+ const hasRemovedSyncBlock = syncBlockRemoved.length > 0 || bodiedSyncBlockRemoved.length > 0;
265
+ const isCutRemoval = hasRemovedSyncBlock && ctx.consumeCutRemoval();
266
+ const canPromptForRemoval = Boolean(ctx.onGiveFeedback) && hasRemovedSyncBlock && !isDirtyTransaction(tr) && !isCutRemoval && !isPasteOrDrop;
267
+ const promptedFeedbackEntryPoint = canPromptForRemoval ? getPromptedFeedbackEntryPoint(getDeleteMechanism(tr, state)) : undefined;
268
+ const entryPoint = promptedFeedbackEntryPoint && expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true) ? promptedFeedbackEntryPoint : undefined;
269
+ if (entryPoint && syncBlockRemoved.length > 0 && bodiedSyncBlockRemoved.length === 0) {
270
+ tr.setMeta(syncedBlockPromptedFeedbackMetaKey, {
271
+ context: {
272
+ blockType: 'reference',
273
+ entryPoint
274
+ }
275
+ });
276
+ }
244
277
  syncBlockAdded.forEach(syncBlock => {
245
278
  var _api$analytics2, _api$analytics2$actio;
246
279
  api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : (_api$analytics2$actio = _api$analytics2.actions) === null || _api$analytics2$actio === void 0 ? void 0 : _api$analytics2$actio.fireAnalyticsEvent({
@@ -268,7 +301,22 @@ const filterTransactionOnline = ({
268
301
  }
269
302
  // Only derive the mechanism behind the gate — discarded gate-off.
270
303
  const mechanism = fg('platform_editor_blocks_patch_4') ? getDeleteMechanism(tr, state) : undefined;
271
- return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism);
304
+ const feedbackContext = entryPoint && getDeleteReason(tr) !== 'source-block-unsynced' ? {
305
+ blockType: 'source',
306
+ entryPoint
307
+ } : undefined;
308
+ if (feedbackContext) {
309
+ const sourceAttempt = ctx.queueSourceFeedback(feedbackContext);
310
+ return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism, {
311
+ onDeleteCompleted: success => ctx.completeSourceDeletion(sourceAttempt, success),
312
+ onDeleteTransaction: deleteTr => deleteTr.setMeta(syncedBlockPromptedFeedbackMetaKey, {
313
+ context: feedbackContext,
314
+ sourceAttempt
315
+ }),
316
+ onDestroy: () => ctx.clearSourceFeedback(sourceAttempt)
317
+ });
318
+ }
319
+ return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism, undefined);
272
320
  }
273
321
  if (bodiedSyncBlockAdded.length > 0) {
274
322
  if (tr.getMeta(pmHistoryPluginKey)) {
@@ -391,14 +439,17 @@ const buildStatusDecorations = (doc, syncBlockStore, isOffline, isViewMode, isDr
391
439
  * is explicitly scoped to a single plugin instance.
392
440
  */
393
441
  class SyncedBlockPluginContext {
394
- constructor() {
442
+ constructor(onGiveFeedback) {
395
443
  _defineProperty(this, "confirmationTransactionRef", {
396
444
  current: undefined
397
445
  });
446
+ _defineProperty(this, "_cutRemovalPending", false);
447
+ _defineProperty(this, "cutRemovalGeneration", 0);
398
448
  _defineProperty(this, "_isCopyEvent", false);
399
449
  _defineProperty(this, "_isCutEvent", false);
400
450
  _defineProperty(this, "unpublishedFlagShown", new Set());
401
451
  _defineProperty(this, "extensionFlagShown", new Set());
452
+ this.onGiveFeedback = onGiveFeedback;
402
453
  }
403
454
  get isCopyEvent() {
404
455
  return this._isCopyEvent;
@@ -419,6 +470,86 @@ class SyncedBlockPluginContext {
419
470
  this._isCutEvent = false;
420
471
  return was;
421
472
  }
473
+ markCutRemovalPending() {
474
+ this._cutRemovalPending = true;
475
+ const generation = ++this.cutRemovalGeneration;
476
+ queueMicrotask(() => {
477
+ if (this.cutRemovalGeneration === generation) {
478
+ this._cutRemovalPending = false;
479
+ }
480
+ });
481
+ }
482
+ consumeCutRemoval() {
483
+ const wasPending = this._cutRemovalPending;
484
+ this._cutRemovalPending = false;
485
+ this.cutRemovalGeneration += 1;
486
+ return wasPending;
487
+ }
488
+ queueSourceFeedback(context) {
489
+ const sourceAttempt = Symbol('syncedBlockSourceFeedbackAttempt');
490
+ this.pendingSourceFeedback = {
491
+ context,
492
+ deletionSucceeded: false,
493
+ sourceAttempt,
494
+ transactionApplied: false
495
+ };
496
+ return sourceAttempt;
497
+ }
498
+ completeSourceDeletion(sourceAttempt, success) {
499
+ var _this$pendingSourceFe;
500
+ if (((_this$pendingSourceFe = this.pendingSourceFeedback) === null || _this$pendingSourceFe === void 0 ? void 0 : _this$pendingSourceFe.sourceAttempt) !== sourceAttempt) {
501
+ return;
502
+ }
503
+ if (!success) {
504
+ // Keep the attempt pending because retryDeletion reuses these callbacks.
505
+ // Dismissal or cancellation clears it through the destroy callback.
506
+ return;
507
+ }
508
+ this.pendingSourceFeedback.deletionSucceeded = true;
509
+ this.flushSourceFeedback(sourceAttempt);
510
+ }
511
+ clearSourceFeedback(sourceAttempt) {
512
+ var _this$pendingSourceFe2;
513
+ if (((_this$pendingSourceFe2 = this.pendingSourceFeedback) === null || _this$pendingSourceFe2 === void 0 ? void 0 : _this$pendingSourceFe2.sourceAttempt) === sourceAttempt) {
514
+ this.pendingSourceFeedback = undefined;
515
+ }
516
+ }
517
+ handleAppliedFeedback({
518
+ context,
519
+ sourceAttempt
520
+ }) {
521
+ var _this$pendingSourceFe3;
522
+ if (context.blockType === 'source' && sourceAttempt && ((_this$pendingSourceFe3 = this.pendingSourceFeedback) === null || _this$pendingSourceFe3 === void 0 ? void 0 : _this$pendingSourceFe3.sourceAttempt) === sourceAttempt) {
523
+ this.pendingSourceFeedback.transactionApplied = true;
524
+ this.flushSourceFeedback(sourceAttempt);
525
+ return;
526
+ }
527
+ if (context.blockType === 'reference') {
528
+ this.invokeFeedback(context);
529
+ }
530
+ }
531
+ flushSourceFeedback(sourceAttempt) {
532
+ var _this$pendingSourceFe4, _this$pendingSourceFe5;
533
+ if (((_this$pendingSourceFe4 = this.pendingSourceFeedback) === null || _this$pendingSourceFe4 === void 0 ? void 0 : _this$pendingSourceFe4.sourceAttempt) === sourceAttempt && (_this$pendingSourceFe5 = this.pendingSourceFeedback) !== null && _this$pendingSourceFe5 !== void 0 && _this$pendingSourceFe5.deletionSucceeded && this.pendingSourceFeedback.transactionApplied) {
534
+ const {
535
+ context
536
+ } = this.pendingSourceFeedback;
537
+ this.pendingSourceFeedback = undefined;
538
+ this.invokeFeedback(context);
539
+ }
540
+ }
541
+ invokeFeedback(context) {
542
+ deferDispatch(() => {
543
+ try {
544
+ var _this$onGiveFeedback;
545
+ const feedbackResult = (_this$onGiveFeedback = this.onGiveFeedback) === null || _this$onGiveFeedback === void 0 ? void 0 : _this$onGiveFeedback.call(this, context);
546
+ void Promise.resolve(feedbackResult).catch(() => {});
547
+ } catch {
548
+ // Product feedback collectors are optional UI. Their failures must
549
+ // never affect the already-applied editor transaction.
550
+ }
551
+ });
552
+ }
422
553
  }
423
554
  export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api) => {
424
555
  const {
@@ -430,7 +561,7 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
430
561
  // and avoids ~10 redundant Statsig SDK evaluations per keystroke in hot paths
431
562
  // (apply, filterTransaction, appendTransaction, decorations).
432
563
  const isPerfExperimentOn = expValEquals('editor_synced_block_perf', 'isEnabled', true);
433
- const ctx = new SyncedBlockPluginContext();
564
+ const ctx = new SyncedBlockPluginContext(options === null || options === void 0 ? void 0 : options.onGiveFeedback);
434
565
  const confirmationTransactionRef = ctx.confirmationTransactionRef;
435
566
  const unpublishedFlagShown = ctx.unpublishedFlagShown;
436
567
  const extensionFlagShown = ctx.extensionFlagShown;
@@ -781,7 +912,7 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
781
912
  return false;
782
913
  },
783
914
  cut: () => {
784
- if (fg('platform_synced_block_patch_13')) {
915
+ if (fg('platform_synced_block_patch_13') || Boolean(options === null || options === void 0 ? void 0 : options.onGiveFeedback) && expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true)) {
785
916
  ctx.consumeCutEvent();
786
917
  ctx.markCutEvent();
787
918
  }
@@ -822,20 +953,40 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
822
953
  } = state;
823
954
  const isCopy = ctx.consumeCopyEvent();
824
955
  const isSyncedBlockPatch13Enabled = fg('platform_synced_block_patch_13');
825
- const isCut = isSyncedBlockPatch13Enabled && ctx.consumeCutEvent();
826
- if (!syncBlockStore || !isCopy && !isCut) {
956
+ const wasCut = ctx.consumeCutEvent();
957
+ const isCut = isSyncedBlockPatch13Enabled && wasCut;
958
+ const isPromptedFeedbackCut = Boolean(syncBlockStore && (options === null || options === void 0 ? void 0 : options.onGiveFeedback) && wasCut) && expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true);
959
+ if (!syncBlockStore || !isCopy && !isCut && !isPromptedFeedbackCut) {
827
960
  return slice;
828
961
  }
829
- return mapSlice(slice, node => {
962
+ if (isPromptedFeedbackCut && !isCut) {
963
+ let containsCutSyncBlock = false;
964
+ mapSlice(slice, node => {
965
+ if (syncBlockStore.referenceManager.isReferenceBlock(node) || node.type.name === 'bodiedSyncBlock' && sliceFullyContainsNode(slice, node)) {
966
+ containsCutSyncBlock = true;
967
+ }
968
+ return node;
969
+ });
970
+ if (containsCutSyncBlock) {
971
+ ctx.markCutRemovalPending();
972
+ }
973
+ return slice;
974
+ }
975
+ let containsCutSyncBlock = false;
976
+ const transformedSlice = mapSlice(slice, node => {
830
977
  if (syncBlockStore.referenceManager.isReferenceBlock(node)) {
831
978
  var _syncBlockStore$refer, _syncBlockStore$refer2;
832
979
  if (isCut) {
980
+ containsCutSyncBlock = isPromptedFeedbackCut;
833
981
  return node;
834
982
  }
835
983
  showCopiedFlag(api, options === null || options === void 0 ? void 0 : options.__livePage, ((_syncBlockStore$refer = syncBlockStore.referenceManager.getFromCache(node.attrs.resourceId)) === null || _syncBlockStore$refer === void 0 ? void 0 : (_syncBlockStore$refer2 = _syncBlockStore$refer.data) === null || _syncBlockStore$refer2 === void 0 ? void 0 : _syncBlockStore$refer2.status) === 'unpublished', getSourceProductFromResourceIdSafe(node.attrs.resourceId));
836
984
  return node;
837
985
  }
838
986
  if (node.type.name === 'bodiedSyncBlock' && node.attrs.resourceId) {
987
+ if (isPromptedFeedbackCut && isCut && sliceFullyContainsNode(slice, node)) {
988
+ containsCutSyncBlock = true;
989
+ }
839
990
  // if we only selected part of the bodied sync block content,
840
991
  // remove the sync block node and only keep the content
841
992
  if (!sliceFullyContainsNode(slice, node)) {
@@ -860,6 +1011,10 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
860
1011
  }
861
1012
  return node;
862
1013
  });
1014
+ if (containsCutSyncBlock) {
1015
+ ctx.markCutRemovalPending();
1016
+ }
1017
+ return transformedSlice;
863
1018
  }
864
1019
  },
865
1020
  filterTransaction: (tr, state) => {
@@ -939,6 +1094,7 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
939
1094
  syncBlockStore,
940
1095
  api,
941
1096
  confirmationTransactionRef,
1097
+ ctx,
942
1098
  bodiedSyncBlockRemoved,
943
1099
  bodiedSyncBlockAdded,
944
1100
  extensionFlagShown
@@ -962,6 +1118,17 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
962
1118
  if (viewMode === 'view') {
963
1119
  return null;
964
1120
  }
1121
+ if (options !== null && options !== void 0 && options.onGiveFeedback) {
1122
+ const feedbackContexts = trs.flatMap(tr => {
1123
+ const feedbackContext = tr.getMeta(syncedBlockPromptedFeedbackMetaKey);
1124
+ return feedbackContext ? [feedbackContext] : [];
1125
+ });
1126
+ if (feedbackContexts.length > 0 && expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true)) {
1127
+ feedbackContexts.forEach(feedbackContext => {
1128
+ ctx.handleAppliedFeedback(feedbackContext);
1129
+ });
1130
+ }
1131
+ }
965
1132
 
966
1133
  // Update source sync block cache for user-initiated changes only.
967
1134
  // Cache updates are handled here instead of in the nodeview update() so we
@@ -2,7 +2,7 @@ import { pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
2
2
  import { fg } from '@atlaskit/platform-feature-flags';
3
3
  import { FLAG_ID } from '../../types';
4
4
  import { syncedBlockPluginKey } from '../main';
5
- import { recomputeDeleteTransaction } from './recompute-delete-transaction';
5
+ import { recomputeDeleteTransaction, recomputeUnsyncTransaction } from './recompute-delete-transaction';
6
6
  const onRetry = (api, syncBlockStore) => () => {
7
7
  var _api$core;
8
8
  api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(({
@@ -21,7 +21,7 @@ const onDismissed = syncBlockStore => tr => {
21
21
  bodiedSyncBlockDeletionStatus: 'none'
22
22
  });
23
23
  };
24
- export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, deletionReason, mechanism) => {
24
+ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, deletionReason, mechanism, feedbackCallbacks) => {
25
25
  // Clear potential old pending deletion to retreat the deletion as first attempt
26
26
  syncBlockStore.sourceManager.clearPendingDeletion();
27
27
 
@@ -42,7 +42,13 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
42
42
  api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.execute(({
43
43
  tr
44
44
  }) => {
45
- const recomputedTr = recomputeDeleteTransaction(tr, syncBlockStore.sourceManager.isSourceBlock, bodiedSyncBlockRemoved.map(node => node.attrs));
45
+ // EDITOR-8230: unsync must remove only the sync wrapper and keep the block's
46
+ // content inline, whereas delete removes the whole block. Before this branch,
47
+ // the confirm path always recomputed a full delete, so unsyncing a source block
48
+ // silently deleted its content. When the deletion was actually triggered by
49
+ // unsync, recompute an unwrap (replaceWith content) instead of a delete.
50
+ const isUnsync = fg('platform_editor_blocks_patch_6') && deletionReason === 'source-block-unsynced';
51
+ const recomputedTr = isUnsync ? recomputeUnsyncTransaction(tr, syncBlockStore.sourceManager.isSourceBlock, bodiedSyncBlockRemoved.map(node => node.attrs)) : recomputeDeleteTransaction(tr, syncBlockStore.sourceManager.isSourceBlock, bodiedSyncBlockRemoved.map(node => node.attrs));
46
52
 
47
53
  // The target node(s) no longer exist in the live document (e.g.
48
54
  // a remote collaborator already removed them). There is nothing
@@ -54,6 +60,7 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
54
60
  return null;
55
61
  }
56
62
  recomputedTr.setMeta('isConfirmedSyncBlockDeletion', true);
63
+ feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDeleteTransaction(recomputedTr);
57
64
  if (!recomputedTr.getMeta(pmHistoryPluginKey)) {
58
65
  // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
59
66
  // For a normal deletion (not triggered by undo), remove it from history so that it cannot be undone
@@ -69,6 +76,7 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
69
76
  }
70
77
  api === null || api === void 0 ? void 0 : (_api$core3 = api.core) === null || _api$core3 === void 0 ? void 0 : _api$core3.actions.execute(() => {
71
78
  const trToDispatch = confirmationTransaction.setMeta('isConfirmedSyncBlockDeletion', true);
79
+ feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDeleteTransaction(trToDispatch);
72
80
  if (!trToDispatch.getMeta(pmHistoryPluginKey)) {
73
81
  // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
74
82
  // For a normal deletion (not triggered by undo), remove it from history so that it cannot be undone
@@ -78,6 +86,7 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
78
86
  });
79
87
  }, success => {
80
88
  var _api$core4;
89
+ feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDeleteCompleted(success);
81
90
  api === null || api === void 0 ? void 0 : (_api$core4 = api.core) === null || _api$core4 === void 0 ? void 0 : _api$core4.actions.execute(({
82
91
  tr
83
92
  }) => {
@@ -107,6 +116,7 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
107
116
  });
108
117
  }, () => {
109
118
  confirmationTransactionRef.current = undefined;
119
+ feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDestroy();
110
120
  }, mechanism);
111
121
  return false;
112
122
  };
@@ -1,3 +1,4 @@
1
+ import { Slice } from '@atlaskit/editor-prosemirror/model';
1
2
  /**
2
3
  * Whether `node` is the source block requested by `wanted`.
3
4
  *
@@ -87,4 +88,55 @@ export const recomputeDeleteTransaction = (tr, isSourceBlock, syncBlockIds) => {
87
88
  tr.delete(pos, pos + node.nodeSize);
88
89
  });
89
90
  return tr;
91
+ };
92
+
93
+ /**
94
+ * Recompute a source `bodiedSyncBlock` *unsync* from the live document.
95
+ *
96
+ * Unsync differs from delete: the sync wrapper must be removed while its content
97
+ * is preserved inline in the document. Instead of `tr.delete(pos, pos +
98
+ * nodeSize)` (which drops the content too — EDITOR-8230), this replaces each
99
+ * matched source block with its own content.
100
+ *
101
+ * The replacement uses a raw `tr.replace(pos, pos + nodeSize, new Slice(content,
102
+ * 0, 0))` — an explicit zero-open slice inserted verbatim — rather than
103
+ * `tr.replaceWith(...)`. `replaceWith` routes through ProseMirror's
104
+ * `replaceRange`, whose range-fitting heuristics can collapse or drop a trailing
105
+ * block (e.g. the block's final panel/empty paragraph) when the unwrapped
106
+ * content meets a document boundary, silently losing content (EDITOR-8230). A
107
+ * zero-open `replace` drops the wrapper's own open ends while keeping every child
108
+ * of the fragment as its own top-level node.
109
+ *
110
+ * Like `recomputeDeleteTransaction`, targets are located in the *current*
111
+ * document (`tr.doc`) so the operation is robust to intervening local edits and
112
+ * remote collab changes while the confirmation modal is open, and it handles
113
+ * partial matches gracefully (missing blocks are skipped).
114
+ *
115
+ * Replacements are applied in reverse document order so earlier replacements do
116
+ * not invalidate the positions of later ones.
117
+ *
118
+ * @returns the mutated transaction when at least one target node was found and
119
+ * unwrapped, otherwise `undefined` when none of the targets exist any more.
120
+ */
121
+ export const recomputeUnsyncTransaction = (tr, isSourceBlock, syncBlockIds) => {
122
+ const matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
123
+ if (matches.length === 0) {
124
+ return undefined;
125
+ }
126
+
127
+ // Unwrap in reverse document order so positions remain valid across edits.
128
+ // Use a raw zero-open Slice replace (not `replaceWith`) so the block's content
129
+ // is inserted verbatim as top-level nodes without ProseMirror's replaceRange
130
+ // heuristics collapsing a trailing block (EDITOR-8230).
131
+ const orderedMatches = matches.sort((a, b) => b.pos - a.pos);
132
+ for (const {
133
+ node,
134
+ pos
135
+ } of orderedMatches) {
136
+ // This is ProseMirror's Transaction.replace (a document edit), not String.replace — the
137
+ // perf rule misfires on the method name, and there is nothing to hoist.
138
+ // eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
139
+ tr.replace(pos, pos + node.nodeSize, new Slice(node.content, 0, 0));
140
+ }
141
+ return tr;
90
142
  };
@@ -26,6 +26,7 @@ export const getToolbarConfig = (state, intl, api, syncBlockStore, isLivePage, o
26
26
  if (syncBlockStore.sourceManager.isPendingCreation(syncBlockObject.node.attrs.resourceId)) {
27
27
  return;
28
28
  }
29
+ const isSyncBlockActivationEnabled = expValEquals('platform_editor_sync_block_activation', 'isEnabled', true);
29
30
  const syncBlockInstance = syncBlockStore.referenceManager.getFromCache(syncBlockObject.node.attrs.resourceId);
30
31
  const isUnsyncedBlock = (syncBlockInstance === null || syncBlockInstance === void 0 ? void 0 : (_syncBlockInstance$er = syncBlockInstance.error) === null || _syncBlockInstance$er === void 0 ? void 0 : _syncBlockInstance$er.type) === SyncBlockError.NotFound;
31
32
  const isErroredBlock = syncBlockInstance === null || syncBlockInstance === void 0 ? void 0 : syncBlockInstance.error;
@@ -53,7 +54,6 @@ export const getToolbarConfig = (state, intl, api, syncBlockStore, isLivePage, o
53
54
  onBlur: hoverDecoration === null || hoverDecoration === void 0 ? void 0 : hoverDecoration(nodeType, false, className)
54
55
  });
55
56
  const items = [];
56
- const isSyncBlockActivationEnabled = expValEquals('platform_editor_sync_block_activation', 'isEnabled', true);
57
57
  if (isUnsyncedBlock) {
58
58
  const deleteButton = {
59
59
  type: 'button',
@@ -192,7 +192,8 @@ export const getToolbarConfig = (state, intl, api, syncBlockStore, isLivePage, o
192
192
  tr
193
193
  }) => tr.setSelection(new GapCursorSelection(tr.doc.resolve(syncBlockObject.pos + syncBlockObject.node.nodeSize), Side.RIGHT)));
194
194
  onGiveFeedback({
195
- blockType: isBodiedSyncBlock ? 'source' : 'reference'
195
+ blockType: isBodiedSyncBlock ? 'source' : 'reference',
196
+ entryPoint: 'overflow-menu'
196
197
  });
197
198
  return true;
198
199
  }
@@ -9,6 +9,7 @@ import { NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosem
9
9
  import { findSelectedNodeOfType, removeParentNodeOfType, removeSelectedNode, safeInsert } from '@atlaskit/editor-prosemirror/utils';
10
10
  import { getSourceProductFromResourceIdSafe } from '@atlaskit/editor-synced-block-provider/utils';
11
11
  import { fg } from '@atlaskit/platform-feature-flags';
12
+ import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
12
13
  import { creationMetaKey, deleteMechanismMetaKey, syncedBlockPluginKey } from '../pm-plugins/main';
13
14
  import { canBeConvertedToSyncBlock, deferDispatch, findSyncBlock, findSyncBlockOrBodiedSyncBlock, isBodiedSyncBlockNode } from '../pm-plugins/utils/utils';
14
15
  import { FLAG_ID } from '../types';
@@ -348,7 +349,11 @@ export var removeSyncedBlockAtPos = function removeSyncedBlockAtPos(api, pos) {
348
349
  var node = tr.doc.nodeAt(pos);
349
350
  if ((node === null || node === void 0 ? void 0 : node.type.name) === 'syncBlock') {
350
351
  var _node$nodeSize;
351
- return tr.replace(pos, pos + ((_node$nodeSize = node === null || node === void 0 ? void 0 : node.nodeSize) !== null && _node$nodeSize !== void 0 ? _node$nodeSize : 0));
352
+ var removeTr = tr.replace(pos, pos + ((_node$nodeSize = node === null || node === void 0 ? void 0 : node.nodeSize) !== null && _node$nodeSize !== void 0 ? _node$nodeSize : 0));
353
+ if (expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true)) {
354
+ removeTr.setMeta(deleteMechanismMetaKey, 'deleteButton');
355
+ }
356
+ return removeTr;
352
357
  }
353
358
  return tr;
354
359
  });
@@ -367,6 +372,12 @@ export var unsync = function unsync(storeManager, isBodiedSyncBlock, view) {
367
372
  return false;
368
373
  }
369
374
  if (isBodiedSyncBlock) {
375
+ // Signal the unsync intent. This transaction is intercepted by the plugin's
376
+ // filterTransaction, which shows the deletion-confirmation modal and, on confirm,
377
+ // recomputes the actual document change from the live document keyed off the
378
+ // `deletionReason: 'source-block-unsynced'` meta (see handleBodiedSyncBlockRemoval /
379
+ // recomputeUnsyncTransaction). The real unwrap-vs-delete decision therefore lives in the
380
+ // confirm path, not here (EDITOR-8230).
370
381
  var content = syncBlock === null || syncBlock === void 0 ? void 0 : syncBlock.node.content;
371
382
  var tr = state.tr;
372
383
  tr.replaceWith(syncBlock.pos, syncBlock.pos + syncBlock.node.nodeSize, content).setMeta('deletionReason', 'source-block-unsynced');