UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

201 lines (194 loc) • 9.2 kB
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; };