UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

415 lines (405 loc) 19.9 kB
import { defaultSchema } from '@atlaskit/adf-schema/schema-default'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE } from '@atlaskit/editor-common/analytics'; import { copyDomNodeWithResult, toDOM } from '@atlaskit/editor-common/copy-button'; import { DOMSerializer, Fragment } from '@atlaskit/editor-prosemirror/model'; import { NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { findSelectedNodeOfType, removeParentNodeOfType, removeSelectedNode, safeInsert } from '@atlaskit/editor-prosemirror/utils'; import { getSourceProductFromResourceIdSafe } from '@atlaskit/editor-synced-block-provider/utils'; import { fg } from '@atlaskit/platform-feature-flags/fg'; import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure'; import { creationMetaKey, deleteMechanismMetaKey, syncedBlockPluginKey } from '../pm-plugins/main'; import { canBeConvertedToSyncBlock, deferDispatch, findSyncBlock, findSyncBlockOrBodiedSyncBlock, isBodiedSyncBlockNode } from '../pm-plugins/utils/utils'; import { FLAG_ID } from '../types'; import { findBodiedSyncBlockByLocalId, pasteSyncBlockHTMLContent } from './utils'; /** * Place the caret on the first editable position inside the newly created bodied * sync block, identified by its unique localId. * * - Empty selection: lands inside the block's empty paragraph. * - Converted content: lands at the start of the first editable child. * * `createSyncedBlock` builds the node but neither `safeInsert` (empty case) nor * `replaceWith` (convert case) leaves the selection inside the new block, so * without this the caret ends up outside/adjacent to the block after creation * from the block menu or toolbar (EDITOR-7949). The typeahead path avoids this * because `typeAheadInsert` positions the caret for us. * * Note: when created from the block menu, block-controls' selection preservation * is active and will restore a whole-node NodeSelection over this caret. The * caller (block-menu item) is responsible for calling `stopPreservingSelection` * in the same flow so the caret survives — mirroring the block-menu delete item. */ const placeCaretInsideBodiedSyncBlock = (tr, localId) => { const bodiedSyncBlockType = tr.doc.type.schema.nodes.bodiedSyncBlock; let blockPos; tr.doc.descendants((node, pos) => { if (blockPos !== undefined) { return false; } if (node.type === bodiedSyncBlockType && node.attrs.localId === localId) { blockPos = pos; return false; } return true; }); if (blockPos === undefined) { return tr; } // Find the first valid text position at or after the block's start. `findFrom` // with textOnly=true descends into the first editable child (empty paragraph or // start of the converted content), which is exactly what we want for both cases. const selection = Selection.findFrom(tr.doc.resolve(blockPos), 1, true); if (selection) { tr.setSelection(selection).scrollIntoView(); } else { // Fallback: `bodiedSyncBlock` is always created with a paragraph as its first // child, so a text position should always be found above. This guards against // future schema changes where the first child isn't immediately text-editable // — place the caret just inside the block rather than leaving it outside. // `blockPos + 1` is the position immediately after the block's opening token, // which is a node boundary rather than a valid text position, so use // `TextSelection.near` to snap forward to the nearest selectable cursor. It // also clamps to the document bounds internally, so no explicit end-of-doc // check is required. tr.setSelection(TextSelection.near(tr.doc.resolve(blockPos + 1), 1)).scrollIntoView(); } return tr; }; export const createSyncedBlock = ({ tr, syncBlockStore, typeAheadInsert, fireAnalyticsEvent, inputMethod }) => { const { schema: { nodes: { bodiedSyncBlock, paragraph } } } = tr.doc.type; // Capture createdEmpty before any insertion mutates the selection. The meta is // set on the final transaction before returning (see below), since the // typeahead path may reassign `tr` and drop meta set here. const createdEmpty = tr.selection.empty; // If the selection is empty, we want to insert the sync block on a new line if (tr.selection.empty) { const attrs = syncBlockStore.sourceManager.generateBodiedSyncBlockAttrs(); const paragraphNode = paragraph.createAndFill({}); const newBodiedSyncBlockNode = bodiedSyncBlock.createAndFill(attrs, paragraphNode ? [paragraphNode] : []); if (!newBodiedSyncBlockNode) { fireAnalyticsEvent === null || fireAnalyticsEvent === void 0 ? void 0 : fireAnalyticsEvent({ action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, attributes: { error: 'Create and fill for empty content failed' }, eventType: EVENT_TYPE.OPERATIONAL }); return false; } if (typeAheadInsert) { tr = typeAheadInsert(newBodiedSyncBlockNode); } else { tr = safeInsert(newBodiedSyncBlockNode)(tr).scrollIntoView(); // safeInsert does not move the selection into the new block, so place the // caret inside the block's empty paragraph so typing continues inside the // synced block (EDITOR-7949). tr = placeCaretInsideBodiedSyncBlock(tr, newBodiedSyncBlockNode.attrs.localId); } } else { const conversionInfo = canBeConvertedToSyncBlock(tr.selection); if (!conversionInfo) { fireAnalyticsEvent === null || fireAnalyticsEvent === void 0 ? void 0 : fireAnalyticsEvent({ action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, attributes: { error: 'Content cannot be converted to sync block' }, eventType: EVENT_TYPE.OPERATIONAL }); return false; } const attrs = syncBlockStore.sourceManager.generateBodiedSyncBlockAttrs(); const newBodiedSyncBlockNode = bodiedSyncBlock.createAndFill(attrs, conversionInfo.contentToInclude, fg('platform_editor_blocks_patch_8') && conversionInfo.breakoutMark ? [conversionInfo.breakoutMark] : undefined); if (!newBodiedSyncBlockNode) { fireAnalyticsEvent === null || fireAnalyticsEvent === void 0 ? void 0 : fireAnalyticsEvent({ action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, attributes: { error: 'Create and fill for content failed' }, eventType: EVENT_TYPE.OPERATIONAL }); return false; } tr.replaceWith(conversionInfo.from, conversionInfo.to, newBodiedSyncBlockNode).scrollIntoView(); // Place the caret on the first editable position inside the converted // content so typing continues inside the synced block (EDITOR-7949). tr = placeCaretInsideBodiedSyncBlock(tr, newBodiedSyncBlockNode.attrs.localId); } // Stash creation-type signals on the final transaction (after any typeahead // reassignment). Set unconditionally — the store manager only reads it behind // the feature gate. tr.setMeta(creationMetaKey, { createdEmpty, inputMethod }); return tr; }; export const copySyncedBlockReferenceToClipboardEditorCommand = (syncBlockStore, inputMethod, api, isLivePage) => ({ tr }) => { if (copySyncedBlockReferenceToClipboardInternal(tr.doc.type.schema, tr.selection, syncBlockStore, inputMethod, api, isLivePage)) { return tr; } return null; }; export const copySyncedBlockReferenceToClipboard = (syncBlockStore, inputMethod, api, isLivePage) => (state, _dispatch, _view) => copySyncedBlockReferenceToClipboardInternal(state.tr.doc.type.schema, state.tr.selection, syncBlockStore, inputMethod, api, isLivePage); const copySyncedBlockReferenceToClipboardInternal = (schema, selection, syncBlockStore, inputMethod, api, isLivePage) => { var _syncBlockStore$refer, _syncBlockStore$refer2; const syncBlockFindResult = findSyncBlockOrBodiedSyncBlock(schema, selection); if (!syncBlockFindResult) { 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({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_COPY, attributes: { error: 'No sync block found in selection', inputMethod } }); return false; } const isBodiedSyncBlock = isBodiedSyncBlockNode(syncBlockFindResult.node, schema.nodes.bodiedSyncBlock); let referenceSyncBlockNode = null; if (isBodiedSyncBlock) { const { nodes: { syncBlock } } = schema; // create sync block reference node referenceSyncBlockNode = syncBlock.createAndFill({ resourceId: syncBlockStore.referenceManager.generateResourceIdForReference(syncBlockFindResult.node.attrs.resourceId) }); if (!referenceSyncBlockNode) { 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({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_COPY, attributes: { error: 'Failed to create reference sync block node', resourceId: syncBlockFindResult.node.attrs.resourceId, inputMethod } }); return false; } } else { referenceSyncBlockNode = syncBlockFindResult.node; } if (!referenceSyncBlockNode) { var _api$analytics3, _api$analytics3$actio; api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : (_api$analytics3$actio = _api$analytics3.actions) === null || _api$analytics3$actio === void 0 ? void 0 : _api$analytics3$actio.fireAnalyticsEvent({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_COPY, attributes: { error: 'No reference sync block node available', inputMethod } }); return false; } const domNode = toDOM(referenceSyncBlockNode, schema); // Bare-uuid join key shared with the create/delete events: for a source // bodiedSyncBlock its `localId` is the source uuid (copy was page-form only). const sourceJoinKey = isBodiedSyncBlock ? syncBlockFindResult.node.attrs.localId : undefined; const isSourceContentUnpublished = isBodiedSyncBlock ? syncBlockStore.sourceManager.getStatus(syncBlockFindResult.node.attrs.resourceId) !== 'active' : ((_syncBlockStore$refer = syncBlockStore.referenceManager.getFromCache(referenceSyncBlockNode.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'; const sourceProduct = getSourceProductFromResourceIdSafe(referenceSyncBlockNode.attrs.resourceId); const copyResult = copyDomNodeWithResult(domNode, referenceSyncBlockNode.type, selection); if (copyResult === false) { var _api$analytics4, _api$analytics4$actio; api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : (_api$analytics4$actio = _api$analytics4.actions) === null || _api$analytics4$actio === void 0 ? void 0 : _api$analytics4$actio.fireAnalyticsEvent({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_COPY, attributes: { error: 'Failed to copy synced block to clipboard', resourceId: referenceSyncBlockNode.attrs.resourceId, inputMethod } }); return false; } deferDispatch(() => { api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => { var _api$analytics5, _api$analytics5$actio; api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : (_api$analytics5$actio = _api$analytics5.actions) === null || _api$analytics5$actio === void 0 ? void 0 : _api$analytics5$actio.fireAnalyticsEvent({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.COPIED, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_COPY, attributes: { resourceId: referenceSyncBlockNode.attrs.resourceId, inputMethod, ...(sourceJoinKey && { blockInstanceId: sourceJoinKey }) } }); return tr.setMeta(syncedBlockPluginKey, { activeFlag: { id: FLAG_ID.SYNC_BLOCK_COPIED, isLivePage, isSourceContentUnpublished, sourceProduct } }); }); }); return true; }; export const editSyncedBlockSource = (syncBlockStore, api) => (state, dispatch, _view) => { var _syncBlock$node, _syncBlock$node$attrs, _syncBlockStore$refer3; const syncBlock = findSyncBlock(state.schema, state.selection); const resourceId = syncBlock === null || syncBlock === void 0 ? void 0 : (_syncBlock$node = syncBlock.node) === null || _syncBlock$node === void 0 ? void 0 : (_syncBlock$node$attrs = _syncBlock$node.attrs) === null || _syncBlock$node$attrs === void 0 ? void 0 : _syncBlock$node$attrs.resourceId; if (!resourceId) { return false; } const syncBlockURL = syncBlockStore.referenceManager.getSyncBlockURL(resourceId); const syncBlockData = (_syncBlockStore$refer3 = syncBlockStore.referenceManager.getFromCache(resourceId)) === null || _syncBlockStore$refer3 === void 0 ? void 0 : _syncBlockStore$refer3.data; const isOnSameDocument = (syncBlockData === null || syncBlockData === void 0 ? void 0 : syncBlockData.onSameDocument) === true; const sourceBlock = isOnSameDocument && syncBlockData ? findBodiedSyncBlockByLocalId(state, syncBlockData.blockInstanceId) : undefined; if (syncBlockURL) { var _api$analytics6; api === null || api === void 0 ? void 0 : (_api$analytics6 = api.analytics) === null || _api$analytics6 === void 0 ? void 0 : _api$analytics6.actions.fireAnalyticsEvent({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.SYNCED_BLOCK_EDIT_SOURCE, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_SOURCE_URL, attributes: { resourceId: resourceId, sameDocument: isOnSameDocument } }); if (sourceBlock) { const tr = state.tr.setSelection(NodeSelection.create(state.doc, sourceBlock.pos)).scrollIntoView(); dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr); return true; } window.open(syncBlockURL, '_blank'); } else { var _api$analytics7, _api$analytics7$actio; const { tr } = state; api === null || api === void 0 ? void 0 : (_api$analytics7 = api.analytics) === null || _api$analytics7 === void 0 ? void 0 : (_api$analytics7$actio = _api$analytics7.actions) === null || _api$analytics7$actio === void 0 ? void 0 : _api$analytics7$actio.attachAnalyticsEvent({ eventType: EVENT_TYPE.OPERATIONAL, action: ACTION.ERROR, actionSubject: ACTION_SUBJECT.SYNCED_BLOCK, actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_SOURCE_URL, attributes: { error: 'No URL resolved for synced block' } })(tr); dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr); } return true; }; export const removeSyncedBlock = api => (state, dispatch, _view) => { const { schema: { nodes }, tr } = state; if (!dispatch) { return false; } let removeTr = tr; if (findSelectedNodeOfType(nodes.syncBlock)(tr.selection) || findSelectedNodeOfType(nodes.bodiedSyncBlock)(tr.selection)) { removeTr = removeSelectedNode(tr); } else { removeTr = removeParentNodeOfType(nodes.bodiedSyncBlock)(tr); } if (!removeTr) { return false; } // Tag the transaction so analytics can report this as `deleteButton` rather // than a keyboard delete (both produce a plain ReplaceStep). removeTr.setMeta(deleteMechanismMetaKey, 'deleteButton'); dispatch(removeTr); api === null || api === void 0 ? void 0 : api.core.actions.focus(); return true; }; export const removeSyncedBlockAtPos = (api, pos) => { api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => { const node = tr.doc.nodeAt(pos); if ((node === null || node === void 0 ? void 0 : node.type.name) === 'syncBlock') { var _node$nodeSize; const removeTr = tr.replace(pos, pos + ((_node$nodeSize = node === null || node === void 0 ? void 0 : node.nodeSize) !== null && _node$nodeSize !== void 0 ? _node$nodeSize : 0)); if (expValEqualsNoExposure('platform_editor_sync_block_activation', 'isEnabled', true)) { removeTr.setMeta(deleteMechanismMetaKey, 'deleteButton'); } return removeTr; } return tr; }); }; /** * Deletes (bodied)SyncBlock node and paste its content to the editor */ export const unsync = (storeManager, isBodiedSyncBlock, view) => { var _storeManager$referen, _storeManager$referen2; if (!view) { return false; } const { state } = view; const syncBlock = findSyncBlockOrBodiedSyncBlock(state.schema, state.selection); if (!syncBlock) { return false; } if (isBodiedSyncBlock) { // Signal the unsync intent. This transaction is intercepted by the plugin's // filterTransaction, which shows the deletion-confirmation modal and, on confirm, // recomputes the actual document change from the live document keyed off the // `deletionReason: 'source-block-unsynced'` meta (see handleBodiedSyncBlockRemoval / // recomputeUnsyncTransaction). The real unwrap-vs-delete decision therefore lives in the // confirm path, not here (EDITOR-8230). const content = syncBlock === null || syncBlock === void 0 ? void 0 : syncBlock.node.content; const { tr } = state; tr.replaceWith(syncBlock.pos, syncBlock.pos + syncBlock.node.nodeSize, content).setMeta('deletionReason', 'source-block-unsynced'); view.dispatch(tr); return true; } // handle syncBlock unsync const syncBlockContent = (_storeManager$referen = storeManager.referenceManager.getFromCache(syncBlock.node.attrs.resourceId)) === null || _storeManager$referen === void 0 ? void 0 : (_storeManager$referen2 = _storeManager$referen.data) === null || _storeManager$referen2 === void 0 ? void 0 : _storeManager$referen2.content; if (!syncBlockContent) { return false; } // use defaultSchema for serialization so we can serialize any type of nodes and marks despite current editor's schema might not allow it const contentFragment = Fragment.fromJSON(defaultSchema, syncBlockContent); const contentDOM = DOMSerializer.fromSchema(defaultSchema).serializeFragment(contentFragment); return pasteSyncBlockHTMLContent(contentDOM, view); };