@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
201 lines (194 loc) • 9.2 kB
JavaScript
import { pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { FLAG_ID } from '../../types';
import { syncedBlockPluginKey } from '../main';
import { matchesRequestedBlock, recomputeDeleteTransaction, recomputeUnsyncTransaction } from './recompute-delete-transaction';
const onRetry = (api, syncBlockStore) => () => {
var _api$core;
api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(({
tr
}) => {
return tr.setMeta(syncedBlockPluginKey, {
bodiedSyncBlockDeletionStatus: 'processing',
activeFlag: false
});
});
syncBlockStore.sourceManager.retryDeletion();
};
const onDismissed = syncBlockStore => tr => {
syncBlockStore.sourceManager.clearPendingDeletion();
return tr.setMeta(syncedBlockPluginKey, {
bodiedSyncBlockDeletionStatus: 'none'
});
};
const topLevelSourceBlocks = (doc, isSourceBlock) => {
const blocks = [];
doc.forEach(node => {
if (isSourceBlock(node)) {
blocks.push(node);
}
});
return blocks;
};
/**
* Whether `tr` removes every block in `wanted` from the document. Identity follows
* `matchesRequestedBlock` (localId, falling back to resourceId) so this agrees with the
* recompute helpers.
*/
const removesRequestedBlocks = (tr, isSourceBlock, wanted) => {
const before = topLevelSourceBlocks(tr.before, isSourceBlock);
const after = topLevelSourceBlocks(tr.doc, isSourceBlock);
return wanted.every(block => before.some(node => matchesRequestedBlock(node, block)) && !after.some(node => matchesRequestedBlock(node, block)));
};
/**
* Replay the undo that removed `wanted` once the source deletion has been confirmed and
* persisted (EDITOR-8863).
*
* The original undo transaction was rejected by `filterTransaction` so the confirmation could
* run, which leaves the history event on top of the undo stack. Building the undo again from
* the live state (via the history plugin) restores whatever the block was converted from
* (e.g. a table), unlike a recomputed whole-node delete which drops that content.
*
* Guard: only dispatch if that undo still removes the same source block(s). If the user
* edited in the meantime the top of the stack is no longer that event, so return `false` and
* let the caller fall back to the recomputed unwrap.
*/
const replayConfirmedUndo = (api, isSourceBlock, wanted, onTransaction) => {
var _api$history;
const undo = api === null || api === void 0 ? void 0 : (_api$history = api.history) === null || _api$history === void 0 ? void 0 : _api$history.commands.undo;
if (!undo || !(api !== null && api !== void 0 && api.core)) {
return false;
}
let applied = false;
api.core.actions.execute(({
tr
}) => {
const undoTr = undo({
tr
});
if (!undoTr || !removesRequestedBlocks(undoTr, isSourceBlock, wanted)) {
return null;
}
undoTr.setMeta('isConfirmedSyncBlockDeletion', true);
onTransaction(undoTr);
applied = true;
return undoTr;
});
return applied;
};
export const handleBodiedSyncBlockRemoval = ({
api,
confirmationTransactionRef,
deletionReason,
feedbackCallbacks,
mechanism,
removed,
syncBlockStore
}) => {
const removedAttrs = removed.map(node => node.attrs);
const {
isSourceBlock
} = syncBlockStore.sourceManager;
// Clear potential old pending deletion to retreat the deletion as first attempt
syncBlockStore.sourceManager.clearPendingDeletion();
// If there are source sync blocks being removed, and we need to confirm with user before deleting,
// we block the transaction here, and wait for user confirmation to proceed with deletion.
// See editor-common/src/sync-block/sync-block-store-manager.ts for how we handle user confirmation and
// proceed with deletion.
syncBlockStore.sourceManager.deleteSyncBlocksWithConfirmation(removedAttrs, deletionReason, () => {
var _api$core2;
const closeBlockMenu = confirmedTr => {
if (isExperimentEnabled('platform_editor_blocks_exp_patch_10')) {
var _api$blockControls, _api$blockControls$co;
// The original transaction's closeMenu meta was rejected along with it above,
// so re-apply it here to close the block menu once the change actually lands.
api === null || api === void 0 ? void 0 : (_api$blockControls = api.blockControls) === null || _api$blockControls === void 0 ? void 0 : (_api$blockControls$co = _api$blockControls.commands) === null || _api$blockControls$co === void 0 ? void 0 : _api$blockControls$co.toggleBlockMenu({
closeMenu: true
})({
tr: confirmedTr
});
}
};
// Any undo that removes a source block (creation, paste, ...) must restore what was
// there before. A recomputed whole-node delete drops the converted content and leaves
// the event on the undo stack, so replay the undo instead (EDITOR-8863).
const shouldReplayUndo = mechanism === 'undo' && isExperimentEnabled('platform_editor_blocks_exp_patch_10');
if (shouldReplayUndo && replayConfirmedUndo(api, isSourceBlock, removedAttrs, undoTr => {
closeBlockMenu(undoTr);
feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDeleteTransaction(undoTr);
})) {
return;
}
// Recompute the delete fresh from the live document instead of
// replaying a stashed-and-rebased transaction. The stash/rebase
// approach produced stale, schema-invalid transactions when the
// document changed shape (local edits or remote collab) while the
// confirmation modal was open — the dominant signature being
// "Invalid content for node bodiedSyncBlock: <>". See EDITOR-7889.
api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.execute(({
tr
}) => {
// EDITOR-8230: unsync must remove only the sync wrapper and keep the block's
// content inline, whereas delete removes the whole block. Before this branch,
// the confirm path always recomputed a full delete, so unsyncing a source block
// silently deleted its content. When the deletion was actually triggered by
// unsync, recompute an unwrap (replaceWith content) instead of a delete.
// The same applies when the undo could not be replayed (the undo stack changed
// while the backend delete was in flight): the backend delete has already
// succeeded, so drop the wrapper but keep the content (EDITOR-8863).
const recomputedTr = deletionReason === 'source-block-unsynced' || shouldReplayUndo ? recomputeUnsyncTransaction(tr, isSourceBlock, removedAttrs) : recomputeDeleteTransaction(tr, isSourceBlock, removedAttrs);
// The target node(s) no longer exist in the live document (e.g.
// a remote collaborator already removed them). There is nothing
// to delete locally, so return `null` to skip the dispatch
// entirely — dispatching the untouched transaction would be a
// no-op that still runs the plugin's filter/append hooks. The
// backend deletion has already been issued by the store manager.
if (!recomputedTr) {
return null;
}
closeBlockMenu(recomputedTr);
recomputedTr.setMeta('isConfirmedSyncBlockDeletion', true);
feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDeleteTransaction(recomputedTr);
if (!recomputedTr.getMeta(pmHistoryPluginKey)) {
// bodiedSyncBlock deletion is expected to be permanent (cannot undo)
// For a normal deletion (not triggered by undo), remove it from history so that it cannot be undone
recomputedTr.setMeta('addToHistory', false);
}
return recomputedTr;
});
}, success => {
var _api$core3;
feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDeleteCompleted(success);
api === null || api === void 0 ? void 0 : (_api$core3 = api.core) === null || _api$core3 === void 0 ? void 0 : _api$core3.actions.execute(({
tr
}) => {
let newState;
if (!success) {
newState = {
activeFlag: {
id: FLAG_ID.FAIL_TO_DELETE,
onRetry: onRetry(api, syncBlockStore),
onDismissed: onDismissed(syncBlockStore)
}
};
} else {
newState = {
activeFlag: false
};
}
newState = {
...newState,
bodiedSyncBlockDeletionStatus: syncBlockStore.sourceManager.isRetryingDeletion() ?
// For retry, reset to none directly to clean up the status
'none' :
// For the first attempt, set to completed for deletion modal can close the modal
'completed'
};
return tr.setMeta(syncedBlockPluginKey, newState);
});
}, () => {
confirmationTransactionRef.current = undefined;
feedbackCallbacks === null || feedbackCallbacks === void 0 ? void 0 : feedbackCallbacks.onDestroy();
}, mechanism);
return false;
};