UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

629 lines (619 loc) 29.6 kB
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 { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { ReplaceAroundStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform'; import { DecorationSet, Decoration } from '@atlaskit/editor-prosemirror/view'; import { convertPMNodesToSyncBlockNodes, rebaseTransaction } from '@atlaskit/editor-synced-block-provider'; import { fg } from '@atlaskit/platform-feature-flags'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { bodiedSyncBlockNodeView, bodiedSyncBlockNodeViewOld } 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 { shouldIgnoreDomEvent } from './utils/ignore-dom-event'; import { calculateDecorations } from './utils/selection-decorations'; import { hasEditInSyncBlock, trackSyncBlocks } from './utils/track-sync-blocks'; import { deferDispatch, wasExtensionInsertedInBodiedSyncBlock, sliceFullyContainsNode } from './utils/utils'; export const syncedBlockPluginKey = new PluginKey('syncedBlockPlugin'); const mapRetryCreationPosMap = (oldMap, newRetryCreationPos, mapPos) => { const resourceId = newRetryCreationPos === null || newRetryCreationPos === void 0 ? void 0 : newRetryCreationPos.resourceId; 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 => { deferDispatch(() => { api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => tr.setMeta(syncedBlockPluginKey, { activeFlag: { id: FLAG_ID.SYNC_BLOCK_COPIED } })); }); }; 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: editorExperiment('platform_synced_block_patch_6', true, { exposure: true }) ? FLAG_ID.EXTENSION_IN_SYNC_BLOCK : FLAG_ID.INLINE_EXTENSION_IN_SYNC_BLOCK } })); }); } }; const getDeleteReason = tr => { const reason = tr.getMeta('deletionReason'); if (!reason) { return 'source-block-deleted'; } return reason; }; const filterTransactionOnline = ({ tr, state, syncBlockStore, api, confirmationTransactionRef, bodiedSyncBlockRemoved, bodiedSyncBlockAdded, extensionFlagShown }) => { 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 }); }); 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 }, eventType: EVENT_TYPE.TRACK }); }); if (bodiedSyncBlockRemoved.length > 0) { // eslint-disable-next-line no-param-reassign confirmationTransactionRef.current = tr; return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr)); } 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; } handleBodiedSyncBlockCreation(bodiedSyncBlockAdded, state, api); return true; } 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; }; /** * 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() { _defineProperty(this, "confirmationTransactionRef", { current: undefined }); _defineProperty(this, "_isCopyEvent", false); _defineProperty(this, "unpublishedFlagShown", new Set()); _defineProperty(this, "extensionFlagShown", new Set()); } get isCopyEvent() { return this._isCopyEvent; } markCopyEvent() { this._isCopyEvent = true; } consumeCopyEvent() { const was = this._isCopyEvent; this._isCopyEvent = false; return was; } } export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api) => { const { useLongPressSelection = false } = options || {}; const ctx = new SyncedBlockPluginContext(); 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); }); }); // 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); deferDispatch(() => { api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => tr.setMeta(syncedBlockPluginKey, { activeFlag: { id: FLAG_ID.UNPUBLISHED_SYNC_BLOCK_PASTED } })); }); } }); return new SafePlugin({ key: syncedBlockPluginKey, state: { init(_, instance) { const syncBlockNodes = instance.doc.children.filter(syncBlockStore.referenceManager.isReferenceBlock); syncBlockStore.referenceManager.fetchSyncBlocksData(convertPMNodesToSyncBlockNodes(syncBlockNodes)); // Populate source sync block cache from initial document // When fg is ON, this replaces the constructor call in the nodeview if (fg('platform_synced_block_update_refactor')) { instance.doc.forEach(node => { if (syncBlockStore.sourceManager.isSourceBlock(node)) { syncBlockStore.sourceManager.updateSyncBlockData(node, false); } }); } return { selectionDecorationSet: calculateDecorations(instance.doc, instance.selection, instance.schema), activeFlag: false, syncBlockStore: syncBlockStore, retryCreationPosMap: new Map(), hasUnsavedBodiedSyncBlockChanges: syncBlockStore.sourceManager.hasUnsavedChanges() }; }, apply: (tr, currentPluginState, oldEditorState) => { var _meta$activeFlag, _meta$bodiedSyncBlock; const meta = tr.getMeta(syncedBlockPluginKey); const { activeFlag, selectionDecorationSet, bodiedSyncBlockDeletionStatus, retryCreationPosMap } = currentPluginState; 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); } } const newPosEntry = meta === null || meta === void 0 ? void 0 : meta.retryCreationPos; const newRetryCreationPosMap = mapRetryCreationPosMap(retryCreationPosMap, newPosEntry, tr.mapping.map.bind(tr.mapping)); return { activeFlag: (_meta$activeFlag = meta === null || meta === void 0 ? void 0 : meta.activeFlag) !== null && _meta$activeFlag !== void 0 ? _meta$activeFlag : activeFlag, selectionDecorationSet: newDecorationSet, syncBlockStore: syncBlockStore, retryCreationPosMap: newRetryCreationPosMap, bodiedSyncBlockDeletionStatus: (_meta$bodiedSyncBlock = meta === null || meta === void 0 ? void 0 : meta.bodiedSyncBlockDeletionStatus) !== null && _meta$bodiedSyncBlock !== void 0 ? _meta$bodiedSyncBlock : bodiedSyncBlockDeletionStatus, hasUnsavedBodiedSyncBlockChanges: syncBlockStore.sourceManager.hasUnsavedChanges() }; } }, 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 }).init(), bodiedSyncBlock: editorExperiment('platform_synced_block_use_new_source_nodeview', true, { exposure: true }) ? bodiedSyncBlockNodeView({ pluginOptions: options, pmPluginFactoryParams, api, syncBlockStore }) : bodiedSyncBlockNodeViewOld({ pluginOptions: options, pmPluginFactoryParams, api, syncBlockStore }) }, decorations: state => { var _currentPluginState$s, _api$connectivity2, _api$connectivity2$sh, _api$editorViewMode, _api$editorViewMode$s, _api$userIntent, _api$userIntent$share, _api$focus, _api$focus$sharedStat, _api$focus$sharedStat2; const currentPluginState = syncedBlockPluginKey.getState(state); const selectionDecorationSet = (_currentPluginState$s = currentPluginState === null || currentPluginState === void 0 ? void 0 : currentPluginState.selectionDecorationSet) !== null && _currentPluginState$s !== void 0 ? _currentPluginState$s : DecorationSet.empty; const syncBlockStore = currentPluginState === null || currentPluginState === void 0 ? void 0 : currentPluginState.syncBlockStore; const { doc } = state; const isOffline = 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 isViewMode = (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 isDragging = (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'; const offlineDecorations = []; const viewModeDecorations = []; const loadingDecorations = []; const dragDecorations = []; state.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 })); } // Show sync block border while the user is dragging if (isDragging && syncBlockStore.isSyncBlock(node)) { dragDecorations.push(Decoration.node(pos, pos + node.nodeSize, { class: SyncBlockStateCssClassName.draggingClassName })); } }); if (api !== null && api !== void 0 && (_api$focus = api.focus) !== null && _api$focus !== void 0 && (_api$focus$sharedStat = _api$focus.sharedState) !== null && _api$focus$sharedStat !== void 0 && (_api$focus$sharedStat2 = _api$focus$sharedStat.currentState()) !== null && _api$focus$sharedStat2 !== void 0 && _api$focus$sharedStat2.hasFocus || !editorExperiment('platform_synced_block_patch_6', true, { exposure: true })) { // Don't show decorations if the editor is not focused return selectionDecorationSet.add(doc, offlineDecorations).add(doc, viewModeDecorations).add(doc, loadingDecorations).add(doc, dragDecorations); } else { return DecorationSet.empty.add(doc, offlineDecorations).add(doc, viewModeDecorations).add(doc, loadingDecorations).add(doc, dragDecorations); } }, 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; } }, 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(); if (!syncBlockStore || !isCopy) { return slice; } return mapSlice(slice, node => { if (syncBlockStore.referenceManager.isReferenceBlock(node)) { showCopiedFlag(api); return node; } if (node.type.name === 'bodiedSyncBlock' && node.attrs.resourceId) { // 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; } showCopiedFlag(api); const newResourceId = syncBlockStore.referenceManager.generateResourceIdForReference(node.attrs.resourceId); // 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; }); } }, filterTransaction: (tr, state) => { var _api$editorViewMode2, _api$editorViewMode2$, _api$connectivity3, _api$connectivity3$sh; const viewMode = 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; if (viewMode === 'view' && fg('platform_synced_block_patch_8')) { return true; } const isOffline = 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); 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 (fg('platform_synced_block_update_refactor')) { // 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')); } }); 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, bodiedSyncBlockRemoved, bodiedSyncBlockAdded, extensionFlagShown }); }, appendTransaction: (trs, oldState, newState) => { var _api$editorViewMode3, _api$editorViewMode3$; const viewMode = 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; if (viewMode === 'view' && fg('platform_synced_block_patch_8')) { return null; } // Update source sync block cache for user-initiated changes only // When fg is ON, cache updates are handled here instead of in the nodeview update() if (fg('platform_synced_block_update_refactor')) { 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); } }); } } trs.filter(tr => tr.docChanged).forEach(tr => { if (confirmationTransactionRef.current) { confirmationTransactionRef.current = rebaseTransaction(confirmationTransactionRef.current, tr, newState); } }); 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')) && fg('platform_synced_block_patch_8')) { // 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; } }); };