@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
415 lines (405 loc) • 19.9 kB
JavaScript
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);
};