@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
1,162 lines • 58.7 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
import { isDirtyTransaction } from '@atlaskit/editor-common/collab';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { createSelectionClickHandler } from '@atlaskit/editor-common/selection';
import { BodiedSyncBlockSharedCssClassName, SyncBlockStateCssClassName } from '@atlaskit/editor-common/sync-block';
import { mapSlice, pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
import { isOfflineMode } from '@atlaskit/editor-plugin-connectivity';
import { NodeSelection, PluginKey } from '@atlaskit/editor-prosemirror/state';
import { ReplaceAroundStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
import { DecorationSet, Decoration } from '@atlaskit/editor-prosemirror/view';
import { convertPMNodesToSyncBlockNodes } from '@atlaskit/editor-synced-block-provider';
import { getSourceProductFromResourceIdSafe } from '@atlaskit/editor-synced-block-provider/utils';
import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
import { bodiedSyncBlockNodeView } from '../nodeviews/bodiedSyncedBlock';
import { SyncBlock as SyncBlockView } from '../nodeviews/syncedBlock';
import { FLAG_ID } from '../types';
import { handleBodiedSyncBlockCreation } from './utils/handle-bodied-sync-block-creation';
import { handleBodiedSyncBlockRemoval } from './utils/handle-bodied-sync-block-removal';
import { hasSyncedBlocks } from './utils/has-synced-blocks';
import { shouldIgnoreDomEvent } from './utils/ignore-dom-event';
import { calculateDecorations } from './utils/selection-decorations';
import { hasEditInSyncBlock, trackSyncBlocks } from './utils/track-sync-blocks';
import { transactionInsertsSyncedBlock } from './utils/transaction-inserts-synced-block';
import { deferDispatch, wasExtensionInsertedInBodiedSyncBlock, sliceFullyContainsNode } from './utils/utils';
export const syncedBlockPluginKey = new 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.
*/
export const deleteMechanismMetaKey = new PluginKey('syncedBlockDeleteMechanism');
const syncedBlockPromptedFeedbackMetaKey = new 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.
*/
export const creationMetaKey = new PluginKey('syncedBlockCreationMeta');
const mapRetryCreationPosMap = (oldMap, newRetryCreationPos, mapPos) => {
const 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;
}
const newMap = new Map(oldMap);
if (resourceId) {
const {
pos
} = newRetryCreationPos;
if (!pos) {
newMap.delete(resourceId);
} else {
newMap.set(resourceId, pos);
}
}
if (newMap.size === 0) {
return newMap;
}
for (const [id, pos] of newMap.entries()) {
newMap.set(id, {
from: mapPos(pos.from),
to: mapPos(pos.to)
});
}
return newMap;
};
const showCopiedFlag = (api, isLivePage, isSourceContentUnpublished, sourceProduct) => {
deferDispatch(() => {
api === null || api === void 0 ? void 0 : api.core.actions.execute(({
tr
}) => tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: FLAG_ID.SYNC_BLOCK_COPIED,
isLivePage,
isSourceContentUnpublished,
sourceProduct
}
}));
});
};
const showExtensionInSyncBlockWarningIfNeeded = (tr, state, api, extensionFlagShown) => {
var _api$connectivity, _api$connectivity$sha;
if (!tr.docChanged || tr.getMeta('isRemote') || Boolean(tr.getMeta(pmHistoryPluginKey)) || isOfflineMode(api === null || api === void 0 ? void 0 : (_api$connectivity = api.connectivity) === null || _api$connectivity === void 0 ? void 0 : (_api$connectivity$sha = _api$connectivity.sharedState.currentState()) === null || _api$connectivity$sha === void 0 ? void 0 : _api$connectivity$sha.mode)) {
return;
}
const resourceId = 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);
deferDispatch(() => {
api === null || api === void 0 ? void 0 : api.core.actions.execute(({
tr
}) => tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: FLAG_ID.EXTENSION_IN_SYNC_BLOCK
}
}));
});
}
};
const getDeleteReason = tr => {
const reason = tr.getMeta('deletionReason');
if (!reason) {
return 'source-block-deleted';
}
return reason;
};
/** Narrows the history plugin meta, matching the editor-plugin-card pattern. */
const isHistoryMeta = meta => typeof 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.
*/
export const 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.
const isSyncBlockActivationEnabled = expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true);
const historyMeta = tr.getMeta(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';
}
const hasStructuralStep = isSyncBlockActivationEnabled && tr.steps.some(step => step instanceof ReplaceAroundStep);
const hasReplaceStep = tr.steps.some(step => step instanceof ReplaceStep);
if (hasStructuralStep || !hasReplaceStep) {
return 'other';
}
const {
selection
} = state;
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');
return isNodeSelected ? 'selectionReplaced' : 'keyboardDelete';
};
export const 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).
*/
const bodiedSyncBlockHasContent = node => {
if (node.childCount === 0) {
return false;
}
if (node.childCount === 1) {
const 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.
*/
const emitFirstContentAddedForEditedBlocks = (tr, state, syncBlockStore) => {
const {
bodiedSyncBlock
} = state.schema.nodes;
tr.doc.descendants(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;
});
};
const filterTransactionOnline = ({
tr,
state,
syncBlockStore,
api,
confirmationTransactionRef,
ctx,
bodiedSyncBlockRemoved,
bodiedSyncBlockAdded,
extensionFlagShown
}) => {
var _tr$getMeta;
const {
removed: syncBlockRemoved,
added: syncBlockAdded
} = trackSyncBlocks(syncBlockStore.referenceManager.isReferenceBlock, tr, state);
syncBlockRemoved.forEach(syncBlock => {
var _api$analytics, _api$analytics$action;
api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : (_api$analytics$action = _api$analytics.actions) === null || _api$analytics$action === void 0 ? void 0 : _api$analytics$action.fireAnalyticsEvent({
action: ACTION.DELETED,
actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
actionSubjectId: ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_DELETE,
attributes: {
resourceId: syncBlock.attrs.resourceId,
blockInstanceId: syncBlock.attrs.localId
},
eventType: 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.
const isPaste = Boolean((_tr$getMeta = tr.getMeta('paste')) !== null && _tr$getMeta !== void 0 ? _tr$getMeta : tr.getMeta('uiEvent') === 'paste');
const isPasteOrDrop = isPaste || tr.getMeta('uiEvent') === 'drop';
const hasRemovedSyncBlock = syncBlockRemoved.length > 0 || bodiedSyncBlockRemoved.length > 0;
const isCutRemoval = hasRemovedSyncBlock && ctx.consumeCutRemoval();
const canPromptForRemoval = Boolean(ctx.onGiveFeedback) && hasRemovedSyncBlock && !isDirtyTransaction(tr) && !isCutRemoval && !isPasteOrDrop;
const promptedFeedbackEntryPoint = canPromptForRemoval ? getPromptedFeedbackEntryPoint(getDeleteMechanism(tr, state)) : undefined;
const entryPoint = promptedFeedbackEntryPoint && 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
}
});
}
syncBlockAdded.forEach(syncBlock => {
var _api$analytics2, _api$analytics2$actio;
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({
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.DOCUMENT,
actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK,
attributes: {
resourceId: syncBlock.attrs.resourceId,
blockInstanceId: syncBlock.attrs.localId,
sourceProduct: getSourceProductFromResourceIdSafe(syncBlock.attrs.resourceId),
isPaste
},
eventType: EVENT_TYPE.TRACK
});
});
if (bodiedSyncBlockRemoved.length > 0) {
// Recompute the delete fresh from the live document on confirm
// (see handleBodiedSyncBlockRemoval). See EDITOR-7889.
const mechanism = getDeleteMechanism(tr, state);
const feedbackContext = entryPoint && getDeleteReason(tr) !== 'source-block-unsynced' ? {
blockType: 'source',
entryPoint
} : undefined;
if (feedbackContext) {
const sourceAttempt = ctx.queueSourceFeedback(feedbackContext);
return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism, {
onDeleteCompleted: success => ctx.completeSourceDeletion(sourceAttempt, success),
onDeleteTransaction: deleteTr => deleteTr.setMeta(syncedBlockPromptedFeedbackMetaKey, {
context: feedbackContext,
sourceAttempt
}),
onDestroy: () => ctx.clearSourceFeedback(sourceAttempt)
});
}
return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr), mechanism, undefined);
}
if (bodiedSyncBlockAdded.length > 0) {
if (tr.getMeta(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.
const creationMeta = tr.getMeta(creationMetaKey);
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 (hasEditInSyncBlock(tr, state)) {
emitFirstContentAddedForEditedBlocks(tr, state, syncBlockStore);
}
showExtensionInSyncBlockWarningIfNeeded(tr, state, api, extensionFlagShown);
return true;
};
const filterTransactionOffline = ({
tr,
state,
syncBlockStore,
api,
isConfirmedSyncBlockDeletion,
bodiedSyncBlockRemoved,
bodiedSyncBlockAdded
}) => {
const {
removed: syncBlockRemoved,
added: syncBlockAdded
} = trackSyncBlocks(syncBlockStore.referenceManager.isReferenceBlock, tr, state);
let errorFlag = false;
if (isConfirmedSyncBlockDeletion || bodiedSyncBlockRemoved.length > 0 || syncBlockRemoved.length > 0) {
errorFlag = FLAG_ID.CANNOT_DELETE_WHEN_OFFLINE;
} else if (bodiedSyncBlockAdded.length > 0 || syncBlockAdded.length > 0) {
errorFlag = FLAG_ID.CANNOT_CREATE_WHEN_OFFLINE;
} else if (hasEditInSyncBlock(tr, state)) {
errorFlag = FLAG_ID.CANNOT_EDIT_WHEN_OFFLINE;
}
if (errorFlag) {
deferDispatch(() => {
api === null || api === void 0 ? void 0 : api.core.actions.execute(({
tr
}) => 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).
*/
const 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 DecorationSet.empty;
}
const offlineDecorations = [];
const viewModeDecorations = [];
const loadingDecorations = [];
const dragDecorations = [];
doc.descendants((node, pos) => {
if (node.type.name === 'bodiedSyncBlock' && isOffline) {
offlineDecorations.push(Decoration.node(pos, pos + node.nodeSize, {
class: SyncBlockStateCssClassName.disabledClassName
}));
}
if (syncBlockStore.isSyncBlock(node) && isViewMode) {
viewModeDecorations.push(Decoration.node(pos, pos + node.nodeSize, {
class: SyncBlockStateCssClassName.viewModeClassName
}));
}
if (node.type.name === 'bodiedSyncBlock' && syncBlockStore.sourceManager.isPendingCreation(node.attrs.resourceId)) {
loadingDecorations.push(Decoration.node(pos, pos + node.nodeSize, {
class: SyncBlockStateCssClassName.creationLoadingClassName
}));
}
if (isDragging && syncBlockStore.isSyncBlock(node)) {
dragDecorations.push(Decoration.node(pos, pos + node.nodeSize, {
class: SyncBlockStateCssClassName.draggingClassName
}));
}
// only traverse the top-level node of the document, as syncBlock and bodiedSyncBlock are top-level nodes
return false;
});
return DecorationSet.create(doc, [...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.
*/
class SyncedBlockPluginContext {
constructor(onGiveFeedback, shouldShowFeedbackPrompt, api) {
_defineProperty(this, "confirmationTransactionRef", {
current: undefined
});
_defineProperty(this, "_cutRemovalPending", false);
_defineProperty(this, "cutRemovalGeneration", 0);
_defineProperty(this, "_isCopyEvent", false);
_defineProperty(this, "_isCutEvent", false);
_defineProperty(this, "feedbackPromptConsumed", false);
_defineProperty(this, "unpublishedFlagShown", new Set());
_defineProperty(this, "extensionFlagShown", new Set());
this.onGiveFeedback = onGiveFeedback;
this.shouldShowFeedbackPrompt = shouldShowFeedbackPrompt;
this.api = api;
}
get isCopyEvent() {
return this._isCopyEvent;
}
markCopyEvent() {
this._isCopyEvent = true;
}
markCutEvent() {
this._isCutEvent = true;
}
consumeCopyEvent() {
const was = this._isCopyEvent;
this._isCopyEvent = false;
return was;
}
consumeCutEvent() {
const was = this._isCutEvent;
this._isCutEvent = false;
return was;
}
markCutRemovalPending() {
this._cutRemovalPending = true;
const generation = ++this.cutRemovalGeneration;
queueMicrotask(() => {
if (this.cutRemovalGeneration === generation) {
this._cutRemovalPending = false;
}
});
}
consumeCutRemoval() {
const wasPending = this._cutRemovalPending;
this._cutRemovalPending = false;
this.cutRemovalGeneration += 1;
return wasPending;
}
queueSourceFeedback(context) {
const sourceAttempt = Symbol('syncedBlockSourceFeedbackAttempt');
this.pendingSourceFeedback = {
context,
deletionSucceeded: false,
sourceAttempt,
transactionApplied: false
};
return sourceAttempt;
}
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);
}
clearSourceFeedback(sourceAttempt) {
var _this$pendingSourceFe2;
if (((_this$pendingSourceFe2 = this.pendingSourceFeedback) === null || _this$pendingSourceFe2 === void 0 ? void 0 : _this$pendingSourceFe2.sourceAttempt) === sourceAttempt) {
this.pendingSourceFeedback = undefined;
}
}
handleAppliedFeedback({
context,
sourceAttempt
}) {
var _this$pendingSourceFe3;
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);
}
}
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) {
const {
context
} = this.pendingSourceFeedback;
this.pendingSourceFeedback = undefined;
this.requestFeedbackPrompt(context);
}
}
requestFeedbackPrompt(context) {
if (this.feedbackPromptConsumed) {
return;
}
this.feedbackPromptConsumed = true;
deferDispatch(() => {
try {
var _this$shouldShowFeedb, _this$shouldShowFeedb2;
const shouldShow = (_this$shouldShowFeedb = (_this$shouldShowFeedb2 = this.shouldShowFeedbackPrompt) === null || _this$shouldShowFeedb2 === void 0 ? void 0 : _this$shouldShowFeedb2.call(this)) !== null && _this$shouldShowFeedb !== void 0 ? _this$shouldShowFeedb : true;
void Promise.resolve(shouldShow).then(isEligible => {
var _this$api;
if (!isEligible) {
return;
}
(_this$api = this.api) === null || _this$api === void 0 ? void 0 : _this$api.core.actions.execute(({
tr
}) => tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: FLAG_ID.SYNC_BLOCK_FEEDBACK_PROMPT,
feedbackContext: context
}
}));
}).catch(() => {});
} catch {
// Persisted feedback eligibility is optional product UI.
}
});
}
}
export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api) => {
const {
useLongPressSelection = false
} = options || {};
const ctx = new SyncedBlockPluginContext(options === null || options === void 0 ? void 0 : options.onGiveFeedback, options === null || options === void 0 ? void 0 : options.shouldShowFeedbackPrompt, api);
const confirmationTransactionRef = ctx.confirmationTransactionRef;
const unpublishedFlagShown = ctx.unpublishedFlagShown;
const 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(() => {
deferDispatch(() => {
api === null || api === void 0 ? void 0 : api.core.actions.execute(({
tr
}) => 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(() => {
deferDispatch(() => {
syncBlockStore.sourceManager.flush();
});
});
// Set up callback to detect unpublished sync blocks when they're fetched
syncBlockStore.referenceManager.setOnUnpublishedSyncBlockDetected(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.
const sourceProduct = getSourceProductFromResourceIdSafe(resourceId);
deferDispatch(() => {
api === null || api === void 0 ? void 0 : api.core.actions.execute(({
tr
}) => tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: FLAG_ID.UNPUBLISHED_SYNC_BLOCK_PASTED,
sourceProduct
}
}));
});
}
});
return new SafePlugin({
key: syncedBlockPluginKey,
state: {
init(_, instance) {
var _api$connectivity2, _api$connectivity2$sh, _api$editorViewMode, _api$editorViewMode$s, _api$userIntent, _api$userIntent$share;
// 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).
const docHasSyncedBlocks = hasSyncedBlocks(instance.doc);
if (docHasSyncedBlocks) {
const syncBlockNodes = instance.doc.children.filter(syncBlockStore.referenceManager.isReferenceBlock);
syncBlockStore.referenceManager.fetchSyncBlocksData(convertPMNodesToSyncBlockNodes(syncBlockNodes));
// Populate source sync block cache from initial document.
// This replaces the constructor call in the nodeview.
instance.doc.forEach(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
const initIsOffline = isOfflineMode(api === null || api === void 0 ? void 0 : (_api$connectivity2 = api.connectivity) === null || _api$connectivity2 === void 0 ? void 0 : (_api$connectivity2$sh = _api$connectivity2.sharedState.currentState()) === null || _api$connectivity2$sh === void 0 ? void 0 : _api$connectivity2$sh.mode);
const initIsViewMode = (api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode) === 'view';
const initIsDragging = (api === null || api === void 0 ? void 0 : (_api$userIntent = api.userIntent) === null || _api$userIntent === void 0 ? void 0 : (_api$userIntent$share = _api$userIntent.sharedState.currentState()) === null || _api$userIntent$share === void 0 ? void 0 : _api$userIntent$share.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.
const initStatusDecorationSet = docHasSyncedBlocks ? buildStatusDecorations(instance.doc, syncBlockStore, initIsOffline, initIsViewMode, initIsDragging) : DecorationSet.empty;
return {
selectionDecorationSet: 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: (tr, currentPluginState, oldEditorState) => {
var _api$userIntent2, _api$userIntent2$shar, _api$connectivity3, _api$connectivity3$sh, _api$editorViewMode2, _api$editorViewMode2$, _meta$activeFlag, _meta$bodiedSyncBlock;
const meta = tr.getMeta(syncedBlockPluginKey);
const {
activeFlag,
selectionDecorationSet,
bodiedSyncBlockDeletionStatus,
retryCreationPosMap,
hasSyncedBlocks: prevHasSyncedBlocks,
statusDecorationSet: prevStatusDecorationSet,
prevIsOffline: prevOffline,
prevIsViewMode: prevViewMode,
prevIsDragging: prevDragging
} = currentPluginState;
// 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).
const 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 ? void 0 : (_api$userIntent2 = api.userIntent) === null || _api$userIntent2 === void 0 ? void 0 : (_api$userIntent2$shar = _api$userIntent2.sharedState.currentState()) === null || _api$userIntent2$shar === void 0 ? void 0 : _api$userIntent2$shar.currentUserIntent) === 'dragging' === prevDragging && isOfflineMode(api === null || api === void 0 ? void 0 : (_api$connectivity3 = api.connectivity) === null || _api$connectivity3 === void 0 ? void 0 : (_api$connectivity3$sh = _api$connectivity3.sharedState.currentState()) === null || _api$connectivity3$sh === void 0 ? void 0 : _api$connectivity3$sh.mode) === prevOffline && (api === null || api === void 0 ? void 0 : (_api$editorViewMode2 = api.editorViewMode) === null || _api$editorViewMode2 === void 0 ? 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
let nextHasSyncedBlocks = prevHasSyncedBlocks;
if (!prevHasSyncedBlocks && tr.docChanged) {
if (transactionInsertsSyncedBlock(tr)) {
nextHasSyncedBlocks = true;
}
}
let newDecorationSet = tr.docChanged ? selectionDecorationSet.map(tr.mapping, tr.doc) // only map if document changed
: selectionDecorationSet;
if (!tr.selection.eq(oldEditorState.selection)) {
newDecorationSet = calculateDecorations(tr.doc, tr.selection, tr.doc.type.schema);
} else if (tr.docChanged) {
const existingDecorationsLength = selectionDecorationSet.find().length;
const 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 = 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.
let nextStatusDecorationSet = prevStatusDecorationSet;
let nextIsOffline = prevOffline;
let nextIsViewMode = prevViewMode;
let nextIsDragging = prevDragging;
if (!nextHasSyncedBlocks) {
// No synced blocks → keep empty status decorations
nextStatusDecorationSet = DecorationSet.empty;
} else {
var _api$connectivity4, _api$connectivity4$sh, _api$editorViewMode3, _api$editorViewMode3$, _api$userIntent3, _api$userIntent3$shar;
// Read current shared-state signals
nextIsOffline = isOfflineMode(api === null || api === void 0 ? void 0 : (_api$connectivity4 = api.connectivity) === null || _api$connectivity4 === void 0 ? void 0 : (_api$connectivity4$sh = _api$connectivity4.sharedState.currentState()) === null || _api$connectivity4$sh === void 0 ? void 0 : _api$connectivity4$sh.mode);
nextIsViewMode = (api === null || api === void 0 ? void 0 : (_api$editorViewMode3 = api.editorViewMode) === null || _api$editorViewMode3 === void 0 ? 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 ? void 0 : (_api$userIntent3 = api.userIntent) === null || _api$userIntent3 === void 0 ? void 0 : (_api$userIntent3$shar = _api$userIntent3.sharedState.currentState()) === null || _api$userIntent3$shar === void 0 ? void 0 : _api$userIntent3$shar.currentUserIntent) === 'dragging';
// Determine whether we need a full rebuild or a cheap map
const hasSyncedBlocksJustFlipped = nextHasSyncedBlocks && !prevHasSyncedBlocks;
const statusSignalChanged = nextIsOffline !== prevOffline || nextIsViewMode !== prevViewMode || nextIsDragging !== prevDragging;
// Meta-driven status changes (e.g. pending creation
// completed, retry creation pos updated)
const 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
}
const newPosEntry = meta === null || meta === void 0 ? void 0 : meta.retryCreationPos;
const newRetryCreationPosMap = mapRetryCreationPosMap(retryCreationPosMap, newPosEntry, tr.mapping.map.bind(tr.mapping));
const nextActiveFlag = (_meta$activeFlag = meta === null || meta === void 0 ? void 0 : meta.activeFlag) !== null && _meta$activeFlag !== void 0 ? _meta$activeFlag : activeFlag;
const 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: (node, view, getPos, _decorations) =>
// 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 SyncBlockView({
api,
options,
node,
view,
getPos,
portalProviderAPI: pmPluginFactoryParams.portalProviderAPI,
eventDispatcher: pmPluginFactoryParams.eventDispatcher,
syncBlockStore: syncBlockStore,
intl: pmPluginFactoryParams.getIntl()
}).init(),
bodiedSyncBlock: bodiedSyncBlockNodeView({
pluginOptions: options,
pmPluginFactoryParams,
api,
syncBlockStore
})
},
decorations: state => {
var _api$focus$sharedStat, _api$focus, _api$focus$sharedStat2, _api$focus$sharedStat3;
const currentPluginState = syncedBlockPluginKey.getState(state);
if (!currentPluginState) {
return DecorationSet.empty;
}
const {
selectionDecorationSet,
statusDecorationSet,
hasSyncedBlocks: docHasSyncedBlocks
} = currentPluginState;
// 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.
const hasFocus = (_api$focus$sharedStat = api === null || api === void 0 ? void 0 : (_api$focus = api.focus) === null || _api$focus === void 0 ? void 0 : (_api$focus$sharedStat2 = _api$focus.sharedState) === null || _api$focus$sharedStat2 === void 0 ? void 0 : (_api$focus$sharedStat3 = _api$focus$sharedStat2.currentState()) === null || _api$focus$sharedStat3 === void 0 ? void 0 : _api$focus$sharedStat3.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).
const statusDecorations = statusDecorationSet.find();
if (statusDecorations.length === 0) {
return hasFocus ? selectionDecorationSet : DecorationSet.empty;
} else {
return hasFocus ? selectionDecorationSet.add(state.doc, statusDecorations) : statusDecorationSet;
}
},
handleClickOn: createSelectionClickHandler(['bodiedSyncBlock'], target => !!target.closest(`.${BodiedSyncBlockSharedCssClassName.prefix}`), {
useLongPressSelection
}),
handleDOMEvents: {
mouseover(view, event) {
return shouldIgnoreDomEvent(view, event, api);
},
mousedown(view, event) {
return shouldIgnoreDomEvent(view, event, api);
},
copy: () => {
ctx.markCopyEvent();
return false;
},
cut: () => {
ctx.consumeCutEvent();
ctx.markCutEvent();
return false;
}
},
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 mapSlice(slice, 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: (slice, {
state
}) => {
const pluginState = syncedBlockPluginKey.getState(state);
const syncBlockStore = pluginState === null || pluginState === void 0 ? void 0 : pluginState.syncBlockStore;
const {
schema
} = state;
const isCopy = ctx.consumeCopyEvent();
const isCut = ctx.consumeCutEvent();
// A prompted feedback cut is always a cut, so the two never need combining.
const isPromptedFeedbackCut = Boolean(syncBlockStore && (options === null || options === void 0 ? void 0 : options.onGiveFeedback) && isCut) && expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true);
if (!syncBlockStore || !isCopy && !isCut) {
return slice;
}
let containsCutSyncBlock = false;
const transformedSlice = mapSlice(slice, node => {
if (syncBlockStore.referenceManager.isReferenceBlock(node)) {
var _syncBlockStore$refer, _syncBlockStore$refer2;
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 ? void 0 : (_syncBlockStore$refer2 = _syncBlockStore$refer.data) === null || _syncBlockStore$refer2 === void 0 ? void 0 : _syncBlockStore$refer2.status) === 'unpublished', getSourceProductFromResourceIdSafe(node.attrs.resourceId));
return node;
}
if (node.type.name === 'bodiedSyncBlock' && node.attrs.resourceId) {
if (isPromptedFeedbackCut && 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 (!sliceFullyContainsNode(slice, node)) {
return node.content;
}
if (isCut) {
return node;
}
const 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', getSourceProductFromResourceIdSafe(newResourceId));
// Convert bodiedSyncBlock to syncBlock
// The paste transformation will regenrate the localId
const newAttrs = {
...node.attrs,
resourceId: newResourceId
};
const newMarks = schema.nodes.syncBlock.markSet ? node.marks.filter(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: (tr, state) => {
var _api$editorViewMode4, _api$editorViewMode4$, _api$connectivity5, _api$connectivity5$sh;
// 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.
const pluginState = syncedBlockPluginKey.getState(state);
if (pluginState && !pluginState.hasSyncedBlocks && !transactionInsertsSyncedBlock(tr)) {
return true;
}
const viewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode4 = api.editorViewMode) === null || _api$editorViewMode4 === void 0 ? void 0 : (_api$editorViewMode4$ = _api$editorViewMode4.sharedState.currentState()) === null || _api$editorViewMode4$ === void 0 ? void 0 : _api$editorViewMode4$.mode;
if (viewMode === 'view') {
return true;
}
const isOffline = isOfflineMode(api === null || api === void 0 ? void 0 : (_api$connectivity5 = api.connectivity) === null || _api$connectivity5 === void 0 ? void 0 : (_api$connectivity5$sh = _api$connectivity5.sharedState.currentState()) === null || _api$connectivity5$sh === void 0 ? void 0 : _api$connectivity5$sh.mode);
const isConfirmedSyncBlockDeletion = Boolean(tr.getMeta('isConfirmedSyncBlockDeletion'));
// Track newly added reference sync blocks before processing the transaction
if (tr.docChanged && !tr.getMeta('isRemote')) {
const {
added
} = trackSyncBlocks(syncBlockStore.referenceManager.isReferenceBlock, tr, state);
added.forEach(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')) {
const {
added
} = trackSyncBlocks(node => syncBlockStore.isSyncBlock(node), tr, state);
const sourceSyncBlockNodes = added.filter(nodeInfo => nodeInfo.node && syncBlockStore.sourceManager.isSourceBlock(nodeInfo.node));
const referenceSyncBlockNodes = added.filter(nodeInfo => nodeInfo.node && syncBlockStore.referenceManager.isReferenceBlock(nodeInfo.node));
sourceSyncBlockNodes.forEach(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();
}
const syncBlockNodes = referenceSyncBlockNodes.map(nodeInfo => nodeInfo.node).filter(node => node !== undefined);
syncBlockStore.referenceManager.fetchSyncBlocksData(convertPMNodesToSyncBlockNodes(syncBlockNodes));
}
if (!tr.docChanged || Boolean(tr.getMeta('isRemote')) || !isOffline && isConfirmedSyncBlockDeletion) {
return true;
}
const {
removed: bodiedSyncBlockRemoved,
added: bodiedSyncBlockAdded
} = trackSyncBlocks(syncBlockStore.sourceManager.isSourceBlock, tr, state);
return isOffline ? filterTransactionOffline({
tr,
state,
syncBlockStore,
api,
isConfirmedSyncBlockDeletion,
bodiedSyncBlockRemoved,
bodiedSyncBlockAdded
}) : filterTransactionOnline({
tr,
state,
syncBlockStore,
api,
confirmationTransactionRef,
ctx,
bodiedSyncBlockRemoved,
bodiedSyncBlockAdded,
extensionFlagShown
});
},
appendTransaction: (trs, oldState, newState) => {
var _api$editorViewMode5, _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).
const oldPluginState = syncedBlockPluginKey.getState(oldState);
const newPluginState = syncedBlockPluginKey.getState(newState);
const hadOrHasSyncedBlocks = !!(oldPluginState !== null && oldPluginState !== void 0 && oldPluginState.hasSyncedBlocks) || !!(newPluginState !== null && newPluginState !== void 0 && newPluginState.hasSyncedBlocks);
if (!hadOrHasSyncedBlocks) {
return null;
}
const viewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode5 = api.editorViewMode) === null || _api$editorViewMode5 === void 0 ? 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) {
const feedbackContexts = trs.flatMap(tr => {
const feedbackContext = tr.getMeta(syncedBlockPromptedFeedbackMetaKey);
return feedbackContext ? [feedbackContext] : [];
});
if (feedbackContexts.length > 0 && expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true)) {
feedbackContexts.forEach(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).
const isUserChange = tr => tr.docChanged && !isDirtyTransaction(tr) && !tr.getMeta('isRemote');
const hasSourceBlockEdit = trs.some(tr => isUserChange(tr) && hasEditInSyncBlock(tr, oldState));
if (hasSourceBlockEdit) {
newState.doc.forEach(node => {
if (syncBlockStore.sourceManager.isSourceBlock(node)) {
syncBlockStore.sourceManager.updateSyncBlockData(node, false);
}
});
}
for (const tr of trs) {
if (tr.getMeta(pmHistoryPluginKey)) {
const {
added
} = trackSyncBlocks(syncBlockStore.sourceManager.isSourceBlock, tr, oldState);
if (added.length > 0) {
// Delete bodiedSyncBlock if it's originated from history, i.e. redo creation
// See filterTransaction above for more details
const {
tr
} = newState;
added.forEach(node => {
if (node.from !== undefined && node.to !== undefined) {
tr.delete(node.from, node.to);
}
});
return tr;
}
}
}
// 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.
if (trs.some(tr => 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.
const hasInsertedSourceBlock = trs.some(tr => {
if (!tr.docChanged || tr.getMeta('isRemote')) {
return false;
}
return tr.steps.some(step => {
if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep) || !('slice' in step)) {
return false;
}
const {
slice
} = step;
let found = false;
slice.content.descendants(node => {
if (syncBlockStore.sourceManager.isSourceBlock(node) && node.attrs.resourceId) {
found = true;
}
return false;
});
return found;
});
});
if (!hasInsertedSourceBlock) {
return null;
}
const seenResourceIds = new Set();
const duplicates = [];
newState.doc.descendants((node, pos) => {
if (syncBlockStore.sourceManager.isSourceBlock(node) && node.attrs.resourceId) {
if (seenResourceIds.has(node.attrs.resourceId)) {
duplicates.push({
pos,
nodeSize: node.nodeSize
});
} else {
seenResourceIds.add(node.attrs.resourceId);
}
return false;
}
});
if (duplicates.length > 0) {
const {
tr
} = newState;
// Delete in reverse document order so positions remain valid
for (let i = duplicates.length - 1; i >= 0; i--) {
const dup = duplicates[i];
tr.delete(dup.pos, dup.pos + dup.nodeSize);
}
tr.setMeta('addToHistory', false);
deferDispatch(() => {
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
}) => tr.setMeta(syncedBlockPluginKey, {
activeFlag: {
id: FLAG_ID.DUPLICATE_SOURCE_SYNC_BLOCK
}
}));
});
return tr;
}
}
return null;
}
});
};