@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
1,245 lines • 67.2 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.syncedBlockPluginKey = exports.getPromptedFeedbackEntryPoint = exports.getDeleteMechanism = exports.deleteMechanismMetaKey = exports.creationMetaKey = exports.createPlugin = void 0;
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
var _analytics = require("@atlaskit/editor-common/analytics");
var _collab = require("@atlaskit/editor-common/collab");
var _safePlugin = require("@atlaskit/editor-common/safe-plugin");
var _selection = require("@atlaskit/editor-common/selection");
var _syncBlock = require("@atlaskit/editor-common/sync-block");
var _utils = require("@atlaskit/editor-common/utils");
var _editorPluginConnectivity = require("@atlaskit/editor-plugin-connectivity");
var _state = require("@atlaskit/editor-prosemirror/state");
var _transform = require("@atlaskit/editor-prosemirror/transform");
var _view2 = require("@atlaskit/editor-prosemirror/view");
var _editorSyncedBlockProvider = require("@atlaskit/editor-synced-block-provider");
var _utils2 = require("@atlaskit/editor-synced-block-provider/utils");
var _expValEqualsNoExposure = require("@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure");
var _bodiedSyncedBlock = require("../nodeviews/bodiedSyncedBlock");
var _syncedBlock = require("../nodeviews/syncedBlock");
var _types = require("../types");
var _handleBodiedSyncBlockCreation = require("./utils/handle-bodied-sync-block-creation");
var _handleBodiedSyncBlockRemoval = require("./utils/handle-bodied-sync-block-removal");
var _hasSyncedBlocks = require("./utils/has-synced-blocks");
var _ignoreDomEvent = require("./utils/ignore-dom-event");
var _selectionDecorations = require("./utils/selection-decorations");
var _trackSyncBlocks7 = require("./utils/track-sync-blocks");
var _transactionInsertsSyncedBlock = require("./utils/transaction-inserts-synced-block");
var _utils3 = require("./utils/utils");
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
var syncedBlockPluginKey = exports.syncedBlockPluginKey = new _state.PluginKey('syncedBlockPlugin');
/**
* Dedicated transaction-meta {@link PluginKey} the synced-block removal command
* sets so {@link getDeleteMechanism} can report a toolbar Delete as
* `deleteButton` (otherwise indistinguishable from a keyboard delete — both are
* plain ReplaceSteps). A separate key (rather than `syncedBlockPluginKey`, which
* carries plugin-state transitions read in `apply()`, or a bare string) keeps
* this transient signal uniquely namespaced and isolated.
*/
var deleteMechanismMetaKey = exports.deleteMechanismMetaKey = new _state.PluginKey('syncedBlockDeleteMechanism');
var syncedBlockPromptedFeedbackMetaKey = new _state.PluginKey('syncedBlockPromptedFeedback');
/**
* Creation-type signals set by {@link createSyncedBlock} on the creating
* transaction. The async creation handler forwards them to the store manager,
* which attaches them to the `syncedBlockCreate` event. A dedicated key keeps
* this transient signal isolated from plugin state.
*/
var creationMetaKey = exports.creationMetaKey = new _state.PluginKey('syncedBlockCreationMeta');
var mapRetryCreationPosMap = function mapRetryCreationPosMap(oldMap, newRetryCreationPos, mapPos) {
var resourceId = newRetryCreationPos === null || newRetryCreationPos === void 0 ? void 0 : newRetryCreationPos.resourceId;
// Fast path: no new entry and nothing to remap — return the same reference.
// This is critical for PR-E (EDITOR-6929) which relies on reference equality
// to short-circuit SharedStateAPI deep-equality checks.
if (!resourceId && oldMap.size === 0) {
return oldMap;
}
var newMap = new Map(oldMap);
if (resourceId) {
var pos = newRetryCreationPos.pos;
if (!pos) {
newMap.delete(resourceId);
} else {
newMap.set(resourceId, pos);
}
}
if (newMap.size === 0) {
return newMap;
}
var _iterator = _createForOfIteratorHelper(newMap.entries()),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var _step$value = (0, _slicedToArray2.default)(_step.value, 2),
id = _step$value[0],
_pos = _step$value[1];
newMap.set(id, {
from: mapPos(_pos.from),
to: mapPos(_pos.to)
});
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return newMap;
};
var showCopiedFlag = function showCopiedFlag(api, isLivePage, isSourceContentUnpublished, sourceProduct) {
(0, _utils3.deferDispatch)(function () {
api === null || api === void 0 || api.core.actions.execute(function (_ref) {
var tr = _ref.tr;
return tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: _types.FLAG_ID.SYNC_BLOCK_COPIED,
isLivePage: isLivePage,
isSourceContentUnpublished: isSourceContentUnpublished,
sourceProduct: sourceProduct
}
});
});
});
};
var showExtensionInSyncBlockWarningIfNeeded = function showExtensionInSyncBlockWarningIfNeeded(tr, state, api, extensionFlagShown) {
var _api$connectivity;
if (!tr.docChanged || tr.getMeta('isRemote') || Boolean(tr.getMeta(_utils.pmHistoryPluginKey)) || (0, _editorPluginConnectivity.isOfflineMode)(api === null || api === void 0 || (_api$connectivity = api.connectivity) === null || _api$connectivity === void 0 || (_api$connectivity = _api$connectivity.sharedState.currentState()) === null || _api$connectivity === void 0 ? void 0 : _api$connectivity.mode)) {
return;
}
var resourceId = (0, _utils3.wasExtensionInsertedInBodiedSyncBlock)(tr, state);
// Only show the flag on the first instance per sync block (same as UNPUBLISHED_SYNC_BLOCK_PASTED)
if (resourceId && !extensionFlagShown.has(resourceId)) {
extensionFlagShown.add(resourceId);
(0, _utils3.deferDispatch)(function () {
api === null || api === void 0 || api.core.actions.execute(function (_ref2) {
var tr = _ref2.tr;
return tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: _types.FLAG_ID.EXTENSION_IN_SYNC_BLOCK
}
});
});
});
}
};
var getDeleteReason = function getDeleteReason(tr) {
var reason = tr.getMeta('deletionReason');
if (!reason) {
return 'source-block-deleted';
}
return reason;
};
/** Narrows the history plugin meta, matching the editor-plugin-card pattern. */
var isHistoryMeta = function isHistoryMeta(meta) {
return (0, _typeof2.default)(meta) === 'object' && meta !== null && 'redo' in meta && typeof meta.redo === 'boolean';
};
/**
* Derive how a source bodiedSyncBlock removal was performed, for the `mechanism`
* analytics dimension. Each value names the user action; most-specific wins:
* - `undo` / `redo` — history transaction.
* - `deleteButton` — the explicit "Delete" control in the synced-block toolbar
* (the removal command tags its transaction with {@link deleteMechanismMetaKey}).
* - `selectionReplaced` — ReplaceStep on a node-selected block (the
* accidental-overwrite path: block selected, then typed/pasted over).
* - `keyboardDelete` — ReplaceStep removal at a caret/range selection
* (Backspace/Delete key).
* - `other` — anything else (unsync, conversion, code-dispatched, or a
* structural `ReplaceAroundStep` such as wrap/lift/unwrap).
*
* Only a plain `ReplaceStep` counts as a direct user deletion. `ReplaceAroundStep`
* is used for wrapping/lifting/unwrapping content, not direct removal, so a
* removal carried by one is classified as `other` rather than misreported as a
* keyboard delete.
*
* `state` is the pre-transaction state, so its selection reflects what was
* selected when the edit was made.
*/
var getDeleteMechanism = exports.getDeleteMechanism = function getDeleteMechanism(tr, state) {
var _selection$node, _selection$node2;
// Keep the legacy analytics classification unchanged while activation is off.
// The stricter history, structural-step, and reference-selection handling is
// only needed by the prompted feedback rollout.
var isSyncBlockActivationEnabled = (0, _expValEqualsNoExposure.expValEqualsNoExposure)('platform_editor_sync_block_activation', 'isEnabled', true);
var historyMeta = tr.getMeta(_utils.pmHistoryPluginKey);
if (historyMeta) {
if (isSyncBlockActivationEnabled && !isHistoryMeta(historyMeta)) {
return 'other';
}
return isHistoryMeta(historyMeta) && historyMeta.redo ? 'redo' : 'undo';
}
// The toolbar Delete control tags its transaction so we can report it
// distinctly from a keyboard delete (both produce a plain ReplaceStep).
if (tr.getMeta(deleteMechanismMetaKey) === 'deleteButton') {
return 'deleteButton';
}
var hasStructuralStep = isSyncBlockActivationEnabled && tr.steps.some(function (step) {
return step instanceof _transform.ReplaceAroundStep;
});
var hasReplaceStep = tr.steps.some(function (step) {
return step instanceof _transform.ReplaceStep;
});
if (hasStructuralStep || !hasReplaceStep) {
return 'other';
}
var selection = state.selection;
var isNodeSelected = selection instanceof _state.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');
return isNodeSelected ? 'selectionReplaced' : 'keyboardDelete';
};
var getPromptedFeedbackEntryPoint = exports.getPromptedFeedbackEntryPoint = function getPromptedFeedbackEntryPoint(mechanism) {
if (mechanism === 'undo') {
return 'prompted-undo';
}
if (mechanism === 'deleteButton' || mechanism === 'keyboardDelete' || mechanism === 'selectionReplaced') {
return 'prompted-delete';
}
return undefined;
};
/**
* True when a source `bodiedSyncBlock` holds user content — i.e. not just the
* single empty paragraph it is created with (used to detect empty→content).
*/
var bodiedSyncBlockHasContent = function bodiedSyncBlockHasContent(node) {
if (node.childCount === 0) {
return false;
}
if (node.childCount === 1) {
var child = node.firstChild;
// A freshly-created block is a single empty paragraph; anything else
// (non-empty paragraph, or a different/first block) counts as content.
return !((child === null || child === void 0 ? void 0 : child.type.name) === 'paragraph' && child.content.size === 0);
}
return true;
};
/**
* Ask the store manager to fire the one-shot first-content-added event for every
* populated source block. The manager only emits for blocks it created empty
* this session and dedupes per block, so iterating all populated blocks is safe
* and self-limiting. Called only when `hasEditInSyncBlock` flagged an in-block
* edit, so this walk runs rarely.
*/
var emitFirstContentAddedForEditedBlocks = function emitFirstContentAddedForEditedBlocks(tr, state, syncBlockStore) {
var bodiedSyncBlock = state.schema.nodes.bodiedSyncBlock;
tr.doc.descendants(function (node) {
if (node.type === bodiedSyncBlock) {
if (bodiedSyncBlockHasContent(node)) {
syncBlockStore.sourceManager.maybeEmitFirstContentAdded(node.attrs.resourceId, node.attrs.localId);
}
// bodiedSyncBlocks are top-level and not nested, so no need to descend.
return false;
}
return true;
});
};
var filterTransactionOnline = function filterTransactionOnline(_ref3) {
var _tr$getMeta;
var tr = _ref3.tr,
state = _ref3.state,
syncBlockStore = _ref3.syncBlockStore,
api = _ref3.api,
confirmationTransactionRef = _ref3.confirmationTransactionRef,
ctx = _ref3.ctx,
bodiedSyncBlockRemoved = _ref3.bodiedSyncBlockRemoved,
bodiedSyncBlockAdded = _ref3.bodiedSyncBlockAdded,
extensionFlagShown = _ref3.extensionFlagShown;
var _trackSyncBlocks = (0, _trackSyncBlocks7.trackSyncBlocks)(syncBlockStore.referenceManager.isReferenceBlock, tr, state),
syncBlockRemoved = _trackSyncBlocks.removed,
syncBlockAdded = _trackSyncBlocks.added;
syncBlockRemoved.forEach(function (syncBlock) {
var _api$analytics;
api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 || (_api$analytics = _api$analytics.actions) === null || _api$analytics === void 0 || _api$analytics.fireAnalyticsEvent({
action: _analytics.ACTION.DELETED,
actionSubject: _analytics.ACTION_SUBJECT.SYNCED_BLOCK,
actionSubjectId: _analytics.ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_DELETE,
attributes: {
resourceId: syncBlock.attrs.resourceId,
blockInstanceId: syncBlock.attrs.localId
},
eventType: _analytics.EVENT_TYPE.OPERATIONAL
});
});
// Annotate every reference-block insertion with `sourceProduct`
// (derived from the resourceId) and `isPaste` (when the originating transaction was a
// paste). Together with the host product reported by the embedding product's pageview
// events this lets us triangulate cross-product paste behaviour without standing up a
// dedicated `cross_product_paste` event subject.
var isPaste = Boolean((_tr$getMeta = tr.getMeta('paste')) !== null && _tr$getMeta !== void 0 ? _tr$getMeta : tr.getMeta('uiEvent') === 'paste');
var isPasteOrDrop = isPaste || tr.getMeta('uiEvent') === 'drop';
var hasRemovedSyncBlock = syncBlockRemoved.length > 0 || bodiedSyncBlockRemoved.length > 0;
var isCutRemoval = hasRemovedSyncBlock && ctx.consumeCutRemoval();
var canPromptForRemoval = Boolean(ctx.onGiveFeedback) && hasRemovedSyncBlock && !(0, _collab.isDirtyTransaction)(tr) && !isCutRemoval && !isPasteOrDrop;
var promptedFeedbackEntryPoint = canPromptForRemoval ? getPromptedFeedbackEntryPoint(getDeleteMechanism(tr, state)) : undefined;
var entryPoint = promptedFeedbackEntryPoint && (0, _expValEqualsNoExposure.expValEqualsNoExposure)('platform_editor_sync_block_activation', 'isEnabled', true) ? promptedFeedbackEntryPoint : undefined;
if (entryPoint && syncBlockRemoved.length > 0 && bodiedSyncBlockRemoved.length === 0) {
tr.setMeta(syncedBlockPromptedFeedbackMetaKey, {
context: {
blockType: 'reference',
entryPoint: entryPoint
}
});
}
syncBlockAdded.forEach(function (syncBlock) {
var _api$analytics2;
api === null || api === void 0 || (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 || (_api$analytics2 = _api$analytics2.actions) === null || _api$analytics2 === void 0 || _api$analytics2.fireAnalyticsEvent({
action: _analytics.ACTION.INSERTED,
actionSubject: _analytics.ACTION_SUBJECT.DOCUMENT,
actionSubjectId: _analytics.ACTION_SUBJECT_ID.SYNCED_BLOCK,
attributes: {
resourceId: syncBlock.attrs.resourceId,
blockInstanceId: syncBlock.attrs.localId,
sourceProduct: (0, _utils2.getSourceProductFromResourceIdSafe)(syncBlock.attrs.resourceId),
isPaste: isPaste
},
eventType: _analytics.EVENT_TYPE.TRACK
});
});
if (bodiedSyncBlockRemoved.length > 0) {
// Recompute the delete fresh from the live document on confirm
// (see handleBodiedSyncBlockRemoval). See EDITOR-7889.
var mechanism = getDeleteMechanism(tr, state);
var feedbackContext = entryPoint && getDeleteReason(tr) !== 'source-block-unsynced' ? {
blockType: 'source',
entryPoint: entryPoint
} : undefined;
if (feedbackContext) {
var sourceAttempt = ctx.queueSourceFeedback(feedbackContext);
return (0, _handleBodiedSyncBlockRemoval.handleBodiedSyncBlockRemoval)(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism, {
onDeleteCompleted: function onDeleteCompleted(success) {
return ctx.completeSourceDeletion(sourceAttempt, success);
},
onDeleteTransaction: function onDeleteTransaction(deleteTr) {
return deleteTr.setMeta(syncedBlockPromptedFeedbackMetaKey, {
context: feedbackContext,
sourceAttempt: sourceAttempt
});
},
onDestroy: function onDestroy() {
return ctx.clearSourceFeedback(sourceAttempt);
}
});
}
return (0, _handleBodiedSyncBlockRemoval.handleBodiedSyncBlockRemoval)(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism, undefined);
}
if (bodiedSyncBlockAdded.length > 0) {
if (tr.getMeta(_utils.pmHistoryPluginKey)) {
// We don't allow bodiedSyncBlock creation via redo, however, we need to return true here to let transaction through so history can be updated properly.
// If we simply returns false, creation from redo is blocked as desired, but this results in editor showing redo as possible even though it's not.
// After true is returned here and the node is created, we delete the node in the filterTransaction immediately, which cancels out the creation
return true;
}
// Defense-in-depth: if a bodiedSyncBlock arrives via paste or drag-and-drop,
// it may have bypassed transformCopied (e.g. cut, browser clipboard).
// Do not call createBlock — it would use this page's parentId which may
// be wrong. The bodiedSyncBlock content will still be inserted but will
// not be registered as a source block in Block Service, which is safer
// than creating a zombie block under the wrong ARI.
if (isPasteOrDrop) {
return true;
}
// Forward creation-type signals captured by createSyncedBlock onto the async
// `syncedBlockCreate` success event.
var creationMeta = tr.getMeta(creationMetaKey);
(0, _handleBodiedSyncBlockCreation.handleBodiedSyncBlockCreation)(bodiedSyncBlockAdded, state, api, creationMeta);
return true;
}
// On an in-block edit, let the store manager fire the one-shot first-content
// event (dedupe + empty→content scoping live there). After the add/remove
// branches so it never runs for the creation transaction itself.
if ((0, _trackSyncBlocks7.hasEditInSyncBlock)(tr, state)) {
emitFirstContentAddedForEditedBlocks(tr, state, syncBlockStore);
}
showExtensionInSyncBlockWarningIfNeeded(tr, state, api, extensionFlagShown);
return true;
};
var filterTransactionOffline = function filterTransactionOffline(_ref4) {
var tr = _ref4.tr,
state = _ref4.state,
syncBlockStore = _ref4.syncBlockStore,
api = _ref4.api,
isConfirmedSyncBlockDeletion = _ref4.isConfirmedSyncBlockDeletion,
bodiedSyncBlockRemoved = _ref4.bodiedSyncBlockRemoved,
bodiedSyncBlockAdded = _ref4.bodiedSyncBlockAdded;
var _trackSyncBlocks2 = (0, _trackSyncBlocks7.trackSyncBlocks)(syncBlockStore.referenceManager.isReferenceBlock, tr, state),
syncBlockRemoved = _trackSyncBlocks2.removed,
syncBlockAdded = _trackSyncBlocks2.added;
var errorFlag = false;
if (isConfirmedSyncBlockDeletion || bodiedSyncBlockRemoved.length > 0 || syncBlockRemoved.length > 0) {
errorFlag = _types.FLAG_ID.CANNOT_DELETE_WHEN_OFFLINE;
} else if (bodiedSyncBlockAdded.length > 0 || syncBlockAdded.length > 0) {
errorFlag = _types.FLAG_ID.CANNOT_CREATE_WHEN_OFFLINE;
} else if ((0, _trackSyncBlocks7.hasEditInSyncBlock)(tr, state)) {
errorFlag = _types.FLAG_ID.CANNOT_EDIT_WHEN_OFFLINE;
}
if (errorFlag) {
(0, _utils3.deferDispatch)(function () {
api === null || api === void 0 || api.core.actions.execute(function (_ref5) {
var tr = _ref5.tr;
return tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: errorFlag
}
});
});
});
return false;
}
return true;
};
/**
* Build the status decoration set for sync-block nodes. This performs a full
* `doc.descendants()` walk so it must only be called when a status signal
* actually changes (offline, view-mode, dragging, pending-creation). Between
* status changes the caller should use `decorationSet.map(tr.mapping, tr.doc)`
* instead (see EDITOR-6930).
*/
var buildStatusDecorations = function buildStatusDecorations(doc, syncBlockStore, isOffline, isViewMode, isDragging) {
// Fast path: when all status flags are off and no creations are in flight,
// no node can produce a decoration — skip the full doc traversal.
if (!isOffline && !isViewMode && !isDragging && !syncBlockStore.sourceManager.hasPendingCreations()) {
return _view2.DecorationSet.empty;
}
var offlineDecorations = [];
var viewModeDecorations = [];
var loadingDecorations = [];
var dragDecorations = [];
doc.descendants(function (node, pos) {
if (node.type.name === 'bodiedSyncBlock' && isOffline) {
offlineDecorations.push(_view2.Decoration.node(pos, pos + node.nodeSize, {
class: _syncBlock.SyncBlockStateCssClassName.disabledClassName
}));
}
if (syncBlockStore.isSyncBlock(node) && isViewMode) {
viewModeDecorations.push(_view2.Decoration.node(pos, pos + node.nodeSize, {
class: _syncBlock.SyncBlockStateCssClassName.viewModeClassName
}));
}
if (node.type.name === 'bodiedSyncBlock' && syncBlockStore.sourceManager.isPendingCreation(node.attrs.resourceId)) {
loadingDecorations.push(_view2.Decoration.node(pos, pos + node.nodeSize, {
class: _syncBlock.SyncBlockStateCssClassName.creationLoadingClassName
}));
}
if (isDragging && syncBlockStore.isSyncBlock(node)) {
dragDecorations.push(_view2.Decoration.node(pos, pos + node.nodeSize, {
class: _syncBlock.SyncBlockStateCssClassName.draggingClassName
}));
}
// only traverse the top-level node of the document, as syncBlock and bodiedSyncBlock are top-level nodes
return false;
});
return _view2.DecorationSet.create(doc, [].concat(offlineDecorations, viewModeDecorations, loadingDecorations, dragDecorations));
};
/**
* Encapsulates mutable state that persists across transactions in the
* synced block plugin. Replaces module-level closure variables so state
* is explicitly scoped to a single plugin instance.
*/
var SyncedBlockPluginContext = /*#__PURE__*/function () {
function SyncedBlockPluginContext(onGiveFeedback, shouldShowFeedbackPrompt, api) {
(0, _classCallCheck2.default)(this, SyncedBlockPluginContext);
(0, _defineProperty2.default)(this, "confirmationTransactionRef", {
current: undefined
});
(0, _defineProperty2.default)(this, "_cutRemovalPending", false);
(0, _defineProperty2.default)(this, "cutRemovalGeneration", 0);
(0, _defineProperty2.default)(this, "_isCopyEvent", false);
(0, _defineProperty2.default)(this, "_isCutEvent", false);
(0, _defineProperty2.default)(this, "feedbackPromptConsumed", false);
(0, _defineProperty2.default)(this, "unpublishedFlagShown", new Set());
(0, _defineProperty2.default)(this, "extensionFlagShown", new Set());
this.onGiveFeedback = onGiveFeedback;
this.shouldShowFeedbackPrompt = shouldShowFeedbackPrompt;
this.api = api;
}
return (0, _createClass2.default)(SyncedBlockPluginContext, [{
key: "isCopyEvent",
get: function get() {
return this._isCopyEvent;
}
}, {
key: "markCopyEvent",
value: function markCopyEvent() {
this._isCopyEvent = true;
}
}, {
key: "markCutEvent",
value: function markCutEvent() {
this._isCutEvent = true;
}
}, {
key: "consumeCopyEvent",
value: function consumeCopyEvent() {
var was = this._isCopyEvent;
this._isCopyEvent = false;
return was;
}
}, {
key: "consumeCutEvent",
value: function consumeCutEvent() {
var was = this._isCutEvent;
this._isCutEvent = false;
return was;
}
}, {
key: "markCutRemovalPending",
value: function markCutRemovalPending() {
var _this = this;
this._cutRemovalPending = true;
var generation = ++this.cutRemovalGeneration;
queueMicrotask(function () {
if (_this.cutRemovalGeneration === generation) {
_this._cutRemovalPending = false;
}
});
}
}, {
key: "consumeCutRemoval",
value: function consumeCutRemoval() {
var wasPending = this._cutRemovalPending;
this._cutRemovalPending = false;
this.cutRemovalGeneration += 1;
return wasPending;
}
}, {
key: "queueSourceFeedback",
value: function queueSourceFeedback(context) {
var sourceAttempt = Symbol('syncedBlockSourceFeedbackAttempt');
this.pendingSourceFeedback = {
context: context,
deletionSucceeded: false,
sourceAttempt: sourceAttempt,
transactionApplied: false
};
return sourceAttempt;
}
}, {
key: "completeSourceDeletion",
value: function completeSourceDeletion(sourceAttempt, success) {
var _this$pendingSourceFe;
if (((_this$pendingSourceFe = this.pendingSourceFeedback) === null || _this$pendingSourceFe === void 0 ? void 0 : _this$pendingSourceFe.sourceAttempt) !== sourceAttempt) {
return;
}
if (!success) {
// Keep the attempt pending because retryDeletion reuses these callbacks.
// Dismissal or cancellation clears it through the destroy callback.
return;
}
this.pendingSourceFeedback.deletionSucceeded = true;
this.flushSourceFeedback(sourceAttempt);
}
}, {
key: "clearSourceFeedback",
value: function clearSourceFeedback(sourceAttempt) {
var _this$pendingSourceFe2;
if (((_this$pendingSourceFe2 = this.pendingSourceFeedback) === null || _this$pendingSourceFe2 === void 0 ? void 0 : _this$pendingSourceFe2.sourceAttempt) === sourceAttempt) {
this.pendingSourceFeedback = undefined;
}
}
}, {
key: "handleAppliedFeedback",
value: function handleAppliedFeedback(_ref6) {
var _this$pendingSourceFe3;
var context = _ref6.context,
sourceAttempt = _ref6.sourceAttempt;
if (context.blockType === 'source' && sourceAttempt && ((_this$pendingSourceFe3 = this.pendingSourceFeedback) === null || _this$pendingSourceFe3 === void 0 ? void 0 : _this$pendingSourceFe3.sourceAttempt) === sourceAttempt) {
this.pendingSourceFeedback.transactionApplied = true;
this.flushSourceFeedback(sourceAttempt);
return;
}
if (context.blockType === 'reference') {
this.requestFeedbackPrompt(context);
}
}
}, {
key: "flushSourceFeedback",
value: function flushSourceFeedback(sourceAttempt) {
var _this$pendingSourceFe4, _this$pendingSourceFe5;
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) {
var context = this.pendingSourceFeedback.context;
this.pendingSourceFeedback = undefined;
this.requestFeedbackPrompt(context);
}
}
}, {
key: "requestFeedbackPrompt",
value: function requestFeedbackPrompt(context) {
var _this2 = this;
if (this.feedbackPromptConsumed) {
return;
}
this.feedbackPromptConsumed = true;
(0, _utils3.deferDispatch)(function () {
try {
var _this2$shouldShowFeed, _this2$shouldShowFeed2;
var shouldShow = (_this2$shouldShowFeed = (_this2$shouldShowFeed2 = _this2.shouldShowFeedbackPrompt) === null || _this2$shouldShowFeed2 === void 0 ? void 0 : _this2$shouldShowFeed2.call(_this2)) !== null && _this2$shouldShowFeed !== void 0 ? _this2$shouldShowFeed : true;
void Promise.resolve(shouldShow).then(function (isEligible) {
var _this2$api;
if (!isEligible) {
return;
}
(_this2$api = _this2.api) === null || _this2$api === void 0 || _this2$api.core.actions.execute(function (_ref7) {
var tr = _ref7.tr;
return tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: _types.FLAG_ID.SYNC_BLOCK_FEEDBACK_PROMPT,
feedbackContext: context
}
});
});
}).catch(function () {});
} catch (_unused) {
// Persisted feedback eligibility is optional product UI.
}
});
}
}]);
}();
var createPlugin = exports.createPlugin = function createPlugin(options, pmPluginFactoryParams, syncBlockStore, api) {
var _ref8 = options || {},
_ref8$useLongPressSel = _ref8.useLongPressSelection,
useLongPressSelection = _ref8$useLongPressSel === void 0 ? false : _ref8$useLongPressSel;
var ctx = new SyncedBlockPluginContext(options === null || options === void 0 ? void 0 : options.onGiveFeedback, options === null || options === void 0 ? void 0 : options.shouldShowFeedbackPrompt, api);
var confirmationTransactionRef = ctx.confirmationTransactionRef;
var unpublishedFlagShown = ctx.unpublishedFlagShown;
var extensionFlagShown = ctx.extensionFlagShown;
// Update plugin state post-flush to sync hasUnsavedBodiedSyncBlockChanges.
// It prevents false "Changes may not be saved" warnings when publishing
// Classic pages with sync blocks.
syncBlockStore.sourceManager.registerFlushCompletionCallback(function () {
(0, _utils3.deferDispatch)(function () {
api === null || api === void 0 || api.core.actions.execute(function (_ref9) {
var tr = _ref9.tr;
return tr;
});
});
});
// Trigger a deferred flush when block creation completes after the initial
// flush timed out waiting for it. This ensures content created from
// existing text is eventually persisted even if no further edits are made.
// See EDITOR-7112.
syncBlockStore.sourceManager.registerPostCreationFlushCallback(function () {
(0, _utils3.deferDispatch)(function () {
syncBlockStore.sourceManager.flush();
});
});
// Set up callback to detect unpublished sync blocks when they're fetched
syncBlockStore.referenceManager.setOnUnpublishedSyncBlockDetected(function (resourceId) {
// Only show the flag once per sync block
if (!unpublishedFlagShown.has(resourceId)) {
unpublishedFlagShown.add(resourceId);
// Surface the source product so the flag's copy can be tailored — Jira work
// items get "Pasted from unsaved item" / "...when the item's description is
// saved" rather than the Confluence page-flavoured default. Falls back to
// undefined when the resourceId can't be parsed (legacy shapes), in which
// case the Confluence default is used.
var sourceProduct = (0, _utils2.getSourceProductFromResourceIdSafe)(resourceId);
(0, _utils3.deferDispatch)(function () {
api === null || api === void 0 || api.core.actions.execute(function (_ref0) {
var tr = _ref0.tr;
return tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: _types.FLAG_ID.UNPUBLISHED_SYNC_BLOCK_PASTED,
sourceProduct: sourceProduct
}
});
});
});
}
});
return new _safePlugin.SafePlugin({
key: syncedBlockPluginKey,
state: {
init: function init(_, instance) {
var _api$connectivity2, _api$editorViewMode, _api$userIntent;
// When the document has no synced blocks, we skip the eager fetch +
// cache walks. They will be re-run lazily by `apply` the first time a
// synced block enters the document (paste, collab insert, or
// programmatic insert).
var docHasSyncedBlocks = (0, _hasSyncedBlocks.hasSyncedBlocks)(instance.doc);
if (docHasSyncedBlocks) {
var syncBlockNodes = instance.doc.children.filter(syncBlockStore.referenceManager.isReferenceBlock);
syncBlockStore.referenceManager.fetchSyncBlocksData((0, _editorSyncedBlockProvider.convertPMNodesToSyncBlockNodes)(syncBlockNodes));
// Populate source sync block cache from initial document.
// This replaces the constructor call in the nodeview.
instance.doc.forEach(function (node) {
if (syncBlockStore.sourceManager.isSourceBlock(node)) {
syncBlockStore.sourceManager.updateSyncBlockData(node, false);
}
});
// Fetch statuses from the backend so we can identify unpublished blocks on cancel
syncBlockStore.sourceManager.fetchAndCacheStatuses();
}
// Read initial shared-state signals for status decorations
var initIsOffline = (0, _editorPluginConnectivity.isOfflineMode)(api === null || api === void 0 || (_api$connectivity2 = api.connectivity) === null || _api$connectivity2 === void 0 || (_api$connectivity2 = _api$connectivity2.sharedState.currentState()) === null || _api$connectivity2 === void 0 ? void 0 : _api$connectivity2.mode);
var initIsViewMode = (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) === 'view';
var initIsDragging = (api === null || api === void 0 || (_api$userIntent = api.userIntent) === null || _api$userIntent === void 0 || (_api$userIntent = _api$userIntent.sharedState.currentState()) === null || _api$userIntent === void 0 ? void 0 : _api$userIntent.currentUserIntent) === 'dragging';
// Build initial status decoration set (EDITOR-6930).
// When the perf gate is ON and the doc has synced blocks we do a
// single traversal here; afterwards `apply()` will map or rebuild
// only when a status signal changes.
var initStatusDecorationSet = docHasSyncedBlocks ? buildStatusDecorations(instance.doc, syncBlockStore, initIsOffline, initIsViewMode, initIsDragging) : _view2.DecorationSet.empty;
return {
selectionDecorationSet: (0, _selectionDecorations.calculateDecorations)(instance.doc, instance.selection, instance.schema),
activeFlag: false,
syncBlockStore: syncBlockStore,
retryCreationPosMap: new Map(),
hasSyncedBlocks: docHasSyncedBlocks,
hasUnsavedBodiedSyncBlockChanges: syncBlockStore.sourceManager.hasUnsavedChanges(),
statusDecorationSet: initStatusDecorationSet,
prevIsOffline: initIsOffline,
prevIsViewMode: initIsViewMode,
prevIsDragging: initIsDragging
};
},
apply: function apply(tr, currentPluginState, oldEditorState) {
var _api$userIntent2, _api$connectivity3, _api$editorViewMode2, _meta$activeFlag, _meta$bodiedSyncBlock;
var meta = tr.getMeta(syncedBlockPluginKey);
var activeFlag = currentPluginState.activeFlag,
selectionDecorationSet = currentPluginState.selectionDecorationSet,
bodiedSyncBlockDeletionStatus = currentPluginState.bodiedSyncBlockDeletionStatus,
retryCreationPosMap = currentPluginState.retryCreationPosMap,
prevHasSyncedBlocks = currentPluginState.hasSyncedBlocks,
prevStatusDecorationSet = currentPluginState.statusDecorationSet,
prevOffline = currentPluginState.prevIsOffline,
prevViewMode = currentPluginState.prevIsViewMode,
prevDragging = currentPluginState.prevIsDragging;
// Pre-compute once so the fast-path probe and the bottom ref-equality
// short-circuit share the same source-manager snapshot. Track C will make this O(1).
var nextHasUnsavedBodiedSyncBlockChanges = syncBlockStore.sourceManager.hasUnsavedChanges();
// --- Fast path (EDITOR-6934 follow-up, see Confluence 7050540723 §9.3 / §9.9):
// when no plugin meta is set, and neither the document nor selection changed,
// most synced-block plugin state cannot change. The source manager is mutable
// outside ProseMirror transactions, though, so preserve existing empty-transaction
// sync semantics by checking that snapshot first.
//
// Note: selection-only transactions intentionally fall through to the normal
// apply path because calculateDecorations depends on drag/view-mode state
// that is not captured here.
//
// We also need to ensure shared-state signals (drag, offline, view-mode)
// haven't changed — these are propagated via empty transactions and drive
// status decoration rebuilds (e.g. drag border). Without this check the
// fast path would swallow drag-start transactions and the synced block
// border would never appear (see VR test: synced-block-drag-selection).
if (!meta && !tr.docChanged && tr.selection.eq(oldEditorState.selection) && nextHasUnsavedBodiedSyncBlockChanges === currentPluginState.hasUnsavedBodiedSyncBlockChanges && (api === null || api === void 0 || (_api$userIntent2 = api.userIntent) === null || _api$userIntent2 === void 0 || (_api$userIntent2 = _api$userIntent2.sharedState.currentState()) === null || _api$userIntent2 === void 0 ? void 0 : _api$userIntent2.currentUserIntent) === 'dragging' === prevDragging && (0, _editorPluginConnectivity.isOfflineMode)(api === null || api === void 0 || (_api$connectivity3 = api.connectivity) === null || _api$connectivity3 === void 0 || (_api$connectivity3 = _api$connectivity3.sharedState.currentState()) === null || _api$connectivity3 === void 0 ? void 0 : _api$connectivity3.mode) === prevOffline && (api === null || api === void 0 || (_api$editorViewMode2 = api.editorViewMode) === null || _api$editorViewMode2 === void 0 || (_api$editorViewMode2 = _api$editorViewMode2.sharedState.currentState()) === null || _api$editorViewMode2 === void 0 ? void 0 : _api$editorViewMode2.mode) === 'view' === prevViewMode) {
return currentPluginState;
}
// Lazy-init bookkeeping: once a synced block enters the document we
// flip `hasSyncedBlocks` to `true` for the lifetime of this editor
var nextHasSyncedBlocks = prevHasSyncedBlocks;
if (!prevHasSyncedBlocks && tr.docChanged) {
if ((0, _transactionInsertsSyncedBlock.transactionInsertsSyncedBlock)(tr)) {
nextHasSyncedBlocks = true;
}
}
var newDecorationSet = tr.docChanged ? selectionDecorationSet.map(tr.mapping, tr.doc) // only map if document changed
: selectionDecorationSet;
if (!tr.selection.eq(oldEditorState.selection)) {
newDecorationSet = (0, _selectionDecorations.calculateDecorations)(tr.doc, tr.selection, tr.doc.type.schema);
} else if (tr.docChanged) {
var existingDecorationsLength = selectionDecorationSet.find().length;
var newDecorationsLength = newDecorationSet.find().length;
// Edge case: When document nodes are replaced, the mapping can lose decorations
// We rebuild decorations when the document changes but the selection hasn't.
// We can do this check because we only expect 1 decoration for the selection
if (existingDecorationsLength !== newDecorationsLength) {
newDecorationSet = (0, _selectionDecorations.calculateDecorations)(tr.doc, tr.selection, tr.doc.type.schema);
}
}
// --- Status decoration set (EDITOR-6930) ---
// When the perf gate is ON we maintain `statusDecorationSet` in
// plugin state so the `decorations` prop becomes an O(1) lookup.
var nextStatusDecorationSet = prevStatusDecorationSet;
var nextIsOffline = prevOffline;
var nextIsViewMode = prevViewMode;
var nextIsDragging = prevDragging;
if (!nextHasSyncedBlocks) {
// No synced blocks → keep empty status decorations
nextStatusDecorationSet = _view2.DecorationSet.empty;
} else {
var _api$connectivity4, _api$editorViewMode3, _api$userIntent3;
// Read current shared-state signals
nextIsOffline = (0, _editorPluginConnectivity.isOfflineMode)(api === null || api === void 0 || (_api$connectivity4 = api.connectivity) === null || _api$connectivity4 === void 0 || (_api$connectivity4 = _api$connectivity4.sharedState.currentState()) === null || _api$connectivity4 === void 0 ? void 0 : _api$connectivity4.mode);
nextIsViewMode = (api === null || api === void 0 || (_api$editorViewMode3 = api.editorViewMode) === null || _api$editorViewMode3 === void 0 || (_api$editorViewMode3 = _api$editorViewMode3.sharedState.currentState()) === null || _api$editorViewMode3 === void 0 ? void 0 : _api$editorViewMode3.mode) === 'view';
nextIsDragging = (api === null || api === void 0 || (_api$userIntent3 = api.userIntent) === null || _api$userIntent3 === void 0 || (_api$userIntent3 = _api$userIntent3.sharedState.currentState()) === null || _api$userIntent3 === void 0 ? void 0 : _api$userIntent3.currentUserIntent) === 'dragging';
// Determine whether we need a full rebuild or a cheap map
var hasSyncedBlocksJustFlipped = nextHasSyncedBlocks && !prevHasSyncedBlocks;
var statusSignalChanged = nextIsOffline !== prevOffline || nextIsViewMode !== prevViewMode || nextIsDragging !== prevDragging;
// Meta-driven status changes (e.g. pending creation
// completed, retry creation pos updated)
var hasMetaStatusChange = !!(meta !== null && meta !== void 0 && meta.retryCreationPos) || !!(meta !== null && meta !== void 0 && meta.activeFlag);
if (hasSyncedBlocksJustFlipped || statusSignalChanged || hasMetaStatusChange) {
// Full rebuild — a status signal changed
nextStatusDecorationSet = buildStatusDecorations(tr.doc, syncBlockStore, nextIsOffline, nextIsViewMode, nextIsDragging);
} else if (tr.docChanged) {
// Cheap map — positions shifted but status unchanged
nextStatusDecorationSet = prevStatusDecorationSet.map(tr.mapping, tr.doc);
}
// else: nothing changed, keep same reference
}
var newPosEntry = meta === null || meta === void 0 ? void 0 : meta.retryCreationPos;
var newRetryCreationPosMap = mapRetryCreationPosMap(retryCreationPosMap, newPosEntry, tr.mapping.map.bind(tr.mapping));
var nextActiveFlag = (_meta$activeFlag = meta === null || meta === void 0 ? void 0 : meta.activeFlag) !== null && _meta$activeFlag !== void 0 ? _meta$activeFlag : activeFlag;
var nextBodiedSyncBlockDeletionStatus = (_meta$bodiedSyncBlock = meta === null || meta === void 0 ? void 0 : meta.bodiedSyncBlockDeletionStatus) !== null && _meta$bodiedSyncBlock !== void 0 ? _meta$bodiedSyncBlock : bodiedSyncBlockDeletionStatus;
// --- Reference equality (EDITOR-6929): return the same object
// when ALL fields are reference-equal to avoid SharedStateAPI
// notifying subscribers and triggering React re-renders. ---
if (nextActiveFlag === activeFlag && newDecorationSet === selectionDecorationSet && newRetryCreationPosMap === retryCreationPosMap && nextHasSyncedBlocks === prevHasSyncedBlocks && nextBodiedSyncBlockDeletionStatus === bodiedSyncBlockDeletionStatus && nextHasUnsavedBodiedSyncBlockChanges === currentPluginState.hasUnsavedBodiedSyncBlockChanges && nextStatusDecorationSet === prevStatusDecorationSet && nextIsOffline === prevOffline && nextIsViewMode === prevViewMode && nextIsDragging === prevDragging) {
return currentPluginState;
}
return {
activeFlag: nextActiveFlag,
selectionDecorationSet: newDecorationSet,
syncBlockStore: syncBlockStore,
retryCreationPosMap: newRetryCreationPosMap,
hasSyncedBlocks: nextHasSyncedBlocks,
bodiedSyncBlockDeletionStatus: nextBodiedSyncBlockDeletionStatus,
hasUnsavedBodiedSyncBlockChanges: nextHasUnsavedBodiedSyncBlockChanges,
statusDecorationSet: nextStatusDecorationSet,
prevIsOffline: nextIsOffline,
prevIsViewMode: nextIsViewMode,
prevIsDragging: nextIsDragging
};
}
},
props: {
nodeViews: {
syncBlock: function syncBlock(node, view, getPos, _decorations) {
return (
// To support SSR, pass `syncBlockStore` here
// and do not use lazy loading.
// We cannot start rendering and then load `syncBlockStore` asynchronously,
// because obtaining it is asynchronous (sharedPluginState.currentState() is delayed).
new _syncedBlock.SyncBlock({
api: api,
options: options,
node: node,
view: view,
getPos: getPos,
portalProviderAPI: pmPluginFactoryParams.portalProviderAPI,
eventDispatcher: pmPluginFactoryParams.eventDispatcher,
syncBlockStore: syncBlockStore,
intl: pmPluginFactoryParams.getIntl()
}).init()
);
},
bodiedSyncBlock: (0, _bodiedSyncedBlock.bodiedSyncBlockNodeView)({
pluginOptions: options,
pmPluginFactoryParams: pmPluginFactoryParams,
api: api,
syncBlockStore: syncBlockStore
})
},
decorations: function decorations(state) {
var _api$focus$sharedStat, _api$focus;
var currentPluginState = syncedBlockPluginKey.getState(state);
if (!currentPluginState) {
return _view2.DecorationSet.empty;
}
var selectionDecorationSet = currentPluginState.selectionDecorationSet,
statusDecorationSet = currentPluginState.statusDecorationSet,
docHasSyncedBlocks = currentPluginState.hasSyncedBlocks;
// When the perf gate is ON, both `selectionDecorationSet` and
// `statusDecorationSet` are maintained in plugin state by
// `apply()`. The `decorations` prop is now an O(1) merge of
// the two cached sets — no `doc.descendants()` walk, no
// shared-state reads.
if (!docHasSyncedBlocks) {
return selectionDecorationSet;
}
// Focus state is read live here (single cheap read) because
// it only gates whether selection decorations are included —
// it does not affect the status decoration set and can change
// within the same transaction cycle.
var hasFocus = (_api$focus$sharedStat = api === null || api === void 0 || (_api$focus = api.focus) === null || _api$focus === void 0 || (_api$focus = _api$focus.sharedState) === null || _api$focus === void 0 || (_api$focus = _api$focus.currentState()) === null || _api$focus === void 0 ? void 0 : _api$focus.hasFocus) !== null && _api$focus$sharedStat !== void 0 ? _api$focus$sharedStat : true;
// Merge selection + status decorations.
// When the editor is unfocused,
// omit selection decorations (matches old behaviour).
var statusDecorations = statusDecorationSet.find();
if (statusDecorations.length === 0) {
return hasFocus ? selectionDecorationSet : _view2.DecorationSet.empty;
} else {
return hasFocus ? selectionDecorationSet.add(state.doc, statusDecorations) : statusDecorationSet;
}
},
handleClickOn: (0, _selection.createSelectionClickHandler)(['bodiedSyncBlock'], function (target) {
return !!target.closest(".".concat(_syncBlock.BodiedSyncBlockSharedCssClassName.prefix));
}, {
useLongPressSelection: useLongPressSelection
}),
handleDOMEvents: {
mouseover: function mouseover(view, event) {
return (0, _ignoreDomEvent.shouldIgnoreDomEvent)(view, event, api);
},
mousedown: function mousedown(view, event) {
return (0, _ignoreDomEvent.shouldIgnoreDomEvent)(view, event, api);
},
copy: function copy() {
ctx.markCopyEvent();
return false;
},
cut: function cut() {
ctx.consumeCutEvent();
ctx.markCutEvent();
return false;
}
},
transformPasted: function transformPasted(slice, _view) {
// Defense against bodiedSyncBlock nodes arriving via paste
// (e.g. drag-and-drop, cut-paste, or browser-level clipboard
// operations that bypass the transformCopied handler).
// We cannot convert to a syncBlock reference here because we don't
// know the source page's parentId (generateResourceIdForReference
// would use the current page's parentId which is wrong).
// Instead, strip the bodiedSyncBlock wrapper and keep only the
// inner content to prevent createBlock being called with the
// wrong parentId.
return (0, _utils.mapSlice)(slice, function (node) {
if (node.type.name === 'bodiedSyncBlock' && node.attrs.resourceId) {
// Return the inner content without the sync block wrapper.
// This is the same behavior as when a partial selection of
// a bodiedSyncBlock is copied (see transformCopied line 937).
return node.content;
}
return node;
});
},
transformCopied: function transformCopied(slice, _ref1) {
var state = _ref1.state;
var pluginState = syncedBlockPluginKey.getState(state);
var syncBlockStore = pluginState === null || pluginState === void 0 ? void 0 : pluginState.syncBlockStore;
var schema = state.schema;
var isCopy = ctx.consumeCopyEvent();
var isCut = ctx.consumeCutEvent();
// A prompted feedback cut is always a cut, so the two never need combining.
var isPromptedFeedbackCut = Boolean(syncBlockStore && (options === null || options === void 0 ? void 0 : options.onGiveFeedback) && isCut) && (0, _expValEqualsNoExposure.expValEqualsNoExposure)('platform_editor_sync_block_activation', 'isEnabled', true);
if (!syncBlockStore || !isCopy && !isCut) {
return slice;
}
var containsCutSyncBlock = false;
var transformedSlice = (0, _utils.mapSlice)(slice, function (node) {
if (syncBlockStore.referenceManager.isReferenceBlock(node)) {
var _syncBlockStore$refer;
if (isCut) {
containsCutSyncBlock = isPromptedFeedbackCut;
return node;
}
showCopiedFlag(api, options === null || options === void 0 ? void 0 : options.__livePage, ((_syncBlockStore$refer = syncBlockStore.referenceManager.getFromCache(node.attrs.resourceId)) === null || _syncBlockStore$refer === void 0 || (_syncBlockStore$refer = _syncBlockStore$refer.data) === null || _syncBlockStore$refer === void 0 ? void 0 : _syncBlockStore$refer.status) === 'unpublished', (0, _utils2.getSourceProductFromResourceIdSafe)(node.attrs.resourceId));
return node;
}
if (node.type.name === 'bodiedSyncBlock' && node.attrs.resourceId) {
if (isPromptedFeedbackCut && (0, _utils3.sliceFullyContainsNode)(slice, node)) {
containsCutSyncBlock = true;
}
// if we only selected part of the bodied sync block content,
// remove the sync block node and only keep the content
if (!(0, _utils3.sliceFullyContainsNode)(slice, node)) {
return node.content;
}
if (isCut) {
return node;
}
var newResourceId = syncBlockStore.referenceManager.generateResourceIdForReference(node.attrs.resourceId);
showCopiedFlag(api, options === null || options === void 0 ? void 0 : options.__livePage, syncBlockStore.sourceManager.getStatus(node.attrs.resourceId) !== 'active', (0, _utils2.getSourceProductFromResourceIdSafe)(newResourceId));
// Convert bodiedSyncBlock to syncBlock
// The paste transformation will regenrate the localId
var newAttrs = _objectSpread(_objectSpread({}, node.attrs), {}, {
resourceId: newResourceId
});
var newMarks = schema.nodes.syncBlock.markSet ? node.marks.filter(function (mark) {
var _schema$nodes$syncBlo;
return (_schema$nodes$syncBlo = schema.nodes.syncBlock.markSet) === null || _schema$nodes$syncBlo === void 0 ? void 0 : _schema$nodes$syncBlo.includes(mark.type);
}) : node.marks;
return schema.nodes.syncBlock.create(newAttrs, null, newMarks);
}
return node;
});
if (containsCutSyncBlock) {
ctx.markCutRemovalPending();
}
return transformedSlice;
}
},
filterTransaction: function filterTransaction(tr, state) {
var _api$editorViewMode4, _api$connectivity5;
// Lazy-init: when no synced block currently exists in the doc and the
// transaction does not insert one, all downstream filter logic is a
// no-op. Avoid both the shared-state reads and the `trackSyncBlocks`
// walks for the ~99.97% of pages that have no synced blocks.
var pluginState = syncedBlockPluginKey.getState(state);
if (pluginState && !pluginState.hasSyncedBlocks && !(0, _transactionInsertsSyncedBlock.transactionInsertsSyncedBlock)(tr)) {
return true;
}
var viewMode = api === null || api === void 0 || (_api$editorViewMode4 = api.editorViewMode) === null || _api$editorViewMode4 === void 0 || (_api$editorViewMode4 = _api$editorViewMode4.sharedState.currentState()) === null || _api$editorViewMode4 === void 0 ? void 0 : _api$editorViewMode4.mode;
if (viewMode === 'view') {
return true;
}
var isOffline = (0, _editorPluginConnectivity.isOfflineMode)(api === null || api === void 0 || (_api$connectivity5 = api.connectivity) === null || _api$connectivity5 === void 0 || (_api$connectivity5 = _api$connectivity5.sharedState.currentState()) === null || _api$connectivity5 === void 0 ? void 0 : _api$connectivity5.mode);
var isConfirmedSyncBlockDeletion = Boolean(tr.getMeta('isConfirmedSyncBlockDeletion'));
// Track newly added reference sync blocks before processing the transaction
if (tr.docChanged && !tr.getMeta('isRemote')) {
var _trackSyncBlocks3 = (0, _trackSyncBlocks7.trackSyncBlocks)(syncBlockStore.referenceManager.isReferenceBlock, tr, state),
added = _trackSyncBlocks3.added;
added.forEach(function (nodeInfo) {
var _nodeInfo$attrs;
if ((_nodeInfo$attrs = nodeInfo.attrs) !== null && _nodeInfo$attrs !== void 0 && _nodeInfo$attrs.resourceId) {
syncBlockStore.referenceManager.markAsNewlyAdded(nodeInfo.attrs.resourceId);
}
});
}
// if doc changed and it's a remote transaction, check if any synced block were added,
// and if so, for source synced blocks, ensure we update the cache with them
// and for reference synced blocks, ensure we fetch the data from the server
if (tr.docChanged && tr.getMeta('isRemote')) {
var _trackSyncBlocks4 = (0, _trackSyncBlocks7.trackSyncBlocks)(function (node) {
return syncBlockStore.isSyncBlock(node);
}, tr, state),
_added = _trackSyncBlocks4.added;
var sourceSyncBlockNodes = _added.filter(function (nodeInfo) {
return nodeInfo.node && syncBlockStore.sourceManager.isSourceBlock(nodeInfo.node);
});
var referenceSyncBlockNodes = _added.filter(function (nodeInfo) {
return nodeInfo.node && syncBlockStore.referenceManager.isReferenceBlock(nodeInfo.node);
});
sourceSyncBlockNodes.forEach(function (nodeInfo) {
var _nodeInfo$attrs2;
if ((_nodeInfo$attrs2 = nodeInfo.attrs) !== null && _nodeInfo$attrs2 !== void 0 && _nodeInfo$attrs2.resourceId && nodeInfo.node) {
syncBlockStore.sourceManager.updateSyncBlockData(nodeInfo.node, tr.getMeta('isRemote'));
}
});
// Fetch statuses for remotely-added source sync blocks
// so we can identify unpublished blocks on cancel
if (sourceSyncBlockNodes.length > 0) {
syncBlockStore.sourceManager.fetchAndCacheStatuses();
}
var syncBlockNodes = referenceSyncBlockNodes.map(function (nodeInfo) {
return nodeInfo.node;
}).filter(function (node) {
return node !== undefined;
});
syncBlockStore.referenceManager.fetchSyncBlocksData((0, _editorSyncedBlockProvider.convertPMNodesToSyncBlockNodes)(syncBlockNodes));
}
if (!tr.docChanged || Boolean(tr.getMeta('isRemote')) || !isOffline && isConfirmedSyncBlockDeletion) {
return true;
}
var _trackSyncBlocks5 = (0, _trackSyncBlocks7.trackSyncBlocks)(syncBlockStore.sourceManager.isSourceBlock, tr, state),
bodiedSyncBlockRemoved = _trackSyncBlocks5.removed,
bodiedSyncBlockAdded = _trackSyncBlocks5.added;
return isOffline ? filterTransactionOffline({
tr: tr,
state: state,
syncBlockStore: syncBlockStore,
api: api,
isConfirmedSyncBlockDeletion: isConfirmedSyncBlockDeletion,
bodiedSyncBlockRemoved: bodiedSyncBlockRemoved,
bodiedSyncBlockAdded: bodiedSyncBlockAdded
}) : filterTransactionOnline({
tr: tr,
state: state,
syncBlockStore: syncBlockStore,
api: api,
confirmationTransactionRef: confirmationTransactionRef,
ctx: ctx,
bodiedSyncBlockRemoved: bodiedSyncBlockRemoved,
bodiedSyncBlockAdded: bodiedSyncBlockAdded,
extensionFlagShown: extensionFlagShown
});
},
appendTransaction: function appendTransaction(trs, oldState, newState) {
var _api$editorViewMode5;
// Lazy-init: when neither the previous nor the new state contains a
// synced block (and none of the dispatched transactions inserts one),
// skip all downstream work. This is the hot path on the ~99.97% of
// pages that don't use synced blocks (see EDITOR-6586).
var oldPluginState = syncedBlockPluginKey.getState(oldState);
var newPluginState = syncedBlockPluginKey.getState(newState);
var hadOrHasSyncedBlocks = !!(oldPluginState !== null && oldPluginState !== void 0 && oldPluginState.hasSyncedBlocks) || !!(newPluginState !== null && newPluginState !== void 0 && newPluginState.hasSyncedBlocks);
if (!hadOrHasSyncedBlocks) {
return null;
}
var viewMode = api === null || api === void 0 || (_api$editorViewMode5 = api.editorViewMode) === null || _api$editorViewMode5 === void 0 || (_api$editorViewMode5 = _api$editorViewMode5.sharedState.currentState()) === null || _api$editorViewMode5 === void 0 ? void 0 : _api$editorViewMode5.mode;
if (viewMode === 'view') {
return null;
}
if (options !== null && options !== void 0 && options.onGiveFeedback) {
var feedbackContexts = trs.flatMap(function (tr) {
var feedbackContext = tr.getMeta(syncedBlockPromptedFeedbackMetaKey);
return feedbackContext ? [feedbackContext] : [];
});
if (feedbackContexts.length > 0 && (0, _expValEqualsNoExposure.expValEqualsNoExposure)('platform_editor_sync_block_activation', 'isEnabled', true)) {
feedbackContexts.forEach(function (feedbackContext) {
ctx.handleAppliedFeedback(feedbackContext);
});
}
}
// Update source sync block cache for user-initiated changes only.
// Cache updates are handled here instead of in the nodeview update() so we
// can filter out non-user changes (remote collab, dirty programmatic txns).
var isUserChange = function isUserChange(tr) {
return tr.docChanged && !(0, _collab.isDirtyTransaction)(tr) && !tr.getMeta('isRemote');
};
var hasSourceBlockEdit = trs.some(function (tr) {
return isUserChange(tr) && (0, _trackSyncBlocks7.hasEditInSyncBlock)(tr, oldState);
});
if (hasSourceBlockEdit) {
newState.doc.forEach(function (node) {
if (syncBlockStore.sourceManager.isSourceBlock(node)) {
syncBlockStore.sourceManager.updateSyncBlockData(node, false);
}
});
}
var _iterator2 = _createForOfIteratorHelper(trs),
_step2;
try {
var _loop = function _loop() {
var tr = _step2.value;
if (tr.getMeta(_utils.pmHistoryPluginKey)) {
var _trackSyncBlocks6 = (0, _trackSyncBlocks7.trackSyncBlocks)(syncBlockStore.sourceManager.isSourceBlock, tr, oldState),
added = _trackSyncBlocks6.added;
if (added.length > 0) {
// Delete bodiedSyncBlock if it's originated from history, i.e. redo creation
// See filterTransaction above for more details
var _tr = newState.tr;
added.forEach(function (node) {
if (node.from !== undefined && node.to !== undefined) {
_tr.delete(node.from, node.to);
}
});
return {
v: _tr
};
}
}
},
_ret;
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
_ret = _loop();
if (_ret) return _ret.v;
}
// Detect and remove duplicate bodiedSyncBlock resourceIds.
// When a block template containing a source sync block is inserted into the
// same document, it creates a duplicate with the same resourceId. We keep the
// first occurrence and delete subsequent duplicates entirely (including their
// contents), since a document must not contain two source sync blocks with the
// same resourceId.
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
if (trs.some(function (tr) {
return tr.docChanged && !tr.getMeta('isRemote');
})) {
// Quick check: only walk the full document when at least one
// transaction inserted a source synced block. This avoids an
// expensive descendants() traversal on every local edit.
var hasInsertedSourceBlock = trs.some(function (tr) {
if (!tr.docChanged || tr.getMeta('isRemote')) {
return false;
}
return tr.steps.some(function (step) {
if (!(step instanceof _transform.ReplaceStep || step instanceof _transform.ReplaceAroundStep) || !('slice' in step)) {
return false;
}
var _ref10 = step,
slice = _ref10.slice;
var found = false;
slice.content.descendants(function (node) {
if (syncBlockStore.sourceManager.isSourceBlock(node) && node.attrs.resourceId) {
found = true;
}
return false;
});
return found;
});
});
if (!hasInsertedSourceBlock) {
return null;
}
var seenResourceIds = new Set();
var duplicates = [];
newState.doc.descendants(function (node, pos) {
if (syncBlockStore.sourceManager.isSourceBlock(node) && node.attrs.resourceId) {
if (seenResourceIds.has(node.attrs.resourceId)) {
duplicates.push({
pos: pos,
nodeSize: node.nodeSize
});
} else {
seenResourceIds.add(node.attrs.resourceId);
}
return false;
}
});
if (duplicates.length > 0) {
var tr = newState.tr;
// Delete in reverse document order so positions remain valid
for (var i = duplicates.length - 1; i >= 0; i--) {
var dup = duplicates[i];
tr.delete(dup.pos, dup.pos + dup.nodeSize);
}
tr.setMeta('addToHistory', false);
(0, _utils3.deferDispatch)(function () {
var _api$core;
api === null || api === void 0 || (_api$core = api.core) === null || _api$core === void 0 || _api$core.actions.execute(function (_ref11) {
var tr = _ref11.tr;
return tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: _types.FLAG_ID.DUPLICATE_SOURCE_SYNC_BLOCK
}
});
});
});
return tr;
}
}
return null;
}
});
};