@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
181 lines (174 loc) • 7.27 kB
JavaScript
import { expandSelectionToBlockRange } from '@atlaskit/editor-common/selection';
import { Fragment } from '@atlaskit/editor-prosemirror/model';
import { ReplaceAroundStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
import { findParentNodeOfType, findParentNodeOfTypeClosestToPos, findSelectedNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
/**
* Defers a callback to the next microtask (when gated) or next macrotask via setTimeout(0).
* Used to avoid re-entrant ProseMirror dispatch cycles.
*/
export const deferDispatch = fn => {
queueMicrotask(fn);
};
export const findSyncBlock = (schema, selection) => {
const {
syncBlock
} = schema.nodes;
return findSelectedNodeOfType(syncBlock)(selection);
};
export const findBodiedSyncBlock = (schema, selection) => {
return findSelectedNodeOfType(schema.nodes.bodiedSyncBlock)(selection) || findParentNodeOfType(schema.nodes.bodiedSyncBlock)(selection);
};
export const findSyncBlockOrBodiedSyncBlock = (schema, selection) => {
return findSyncBlock(schema, selection) || findBodiedSyncBlock(schema, selection);
};
export const isBodiedSyncBlockNode = (node, bodiedSyncBlock) => node.type === bodiedSyncBlock;
const UNSUPPORTED_NODE_TYPES = new Set(['inlineExtension', 'extension', 'bodiedExtension', 'syncBlock', 'bodiedSyncBlock']);
/**
* Checks whether the selection can be converted to sync block
*
* @param selection - the current editor selection to validate for sync block conversion
* @returns A fragment containing the content to include in the synced block,
* stripping out unsupported marks (breakout on codeblock/expand/layout), as well as from and to positions,
* or false if conversion is not possible
*/
export const canBeConvertedToSyncBlock = selection => {
const {
$from,
range
} = expandSelectionToBlockRange(selection);
if (!range) {
return false;
}
const from = range.start;
const to = range.end;
let canBeConverted = true;
$from.doc.nodesBetween(from, to, node => {
if (UNSUPPORTED_NODE_TYPES.has(node.type.name)) {
canBeConverted = false;
return false;
}
});
if (!canBeConverted) {
return false;
}
const contentToInclude = removeBreakoutMarks($from.doc.slice(from, to).content);
return {
contentToInclude,
from,
to
};
};
const removeBreakoutMarks = content => {
const nodes = [];
// we only need to recurse at the top level, because breakout has to be on a top level
content.forEach(node => {
const filteredMarks = node.marks.filter(mark => mark.type.name !== 'breakout');
if (node.isText) {
nodes.push(node);
} else {
const newNode = node.type.create(node.attrs, node.content, filteredMarks);
nodes.push(newNode);
}
});
return Fragment.from(nodes);
};
export const sliceFullyContainsNode = (slice, node) => {
var _slice$content$firstC, _slice$content$firstC2, _slice$content$lastCh, _slice$content$lastCh2;
const isFirstChild = ((_slice$content$firstC = slice.content.firstChild) === null || _slice$content$firstC === void 0 ? void 0 : _slice$content$firstC.type) === node.type && ((_slice$content$firstC2 = slice.content.firstChild) === null || _slice$content$firstC2 === void 0 ? void 0 : _slice$content$firstC2.attrs.resourceId) === node.attrs.resourceId;
const isLastChild = ((_slice$content$lastCh = slice.content.lastChild) === null || _slice$content$lastCh === void 0 ? void 0 : _slice$content$lastCh.type) === node.type && ((_slice$content$lastCh2 = slice.content.lastChild) === null || _slice$content$lastCh2 === void 0 ? void 0 : _slice$content$lastCh2.attrs.resourceId) === node.attrs.resourceId;
const isOpenAtStart = isFirstChild && slice.openStart > 0;
const isOpenAtEnd = isLastChild && slice.openEnd > 0;
if (isOpenAtStart || isOpenAtEnd) {
return false;
}
return true;
};
// even though extension and bodiedExtension are explicitly not allowed by the schema, they can still be inserted nested inside other nodes e.g. layouts
const EXTENSION_NODES = new Set(['inlineExtension', 'extension', 'bodiedExtension']);
const fragmentContainsExtension = fragment => {
let found = false;
fragment.forEach(node => {
if (found) {
return;
}
if (editorExperiment('platform_synced_block_patch_6', true, {
exposure: true
}) ? EXTENSION_NODES.has(node.type.name) : node.type.name === 'inlineExtension') {
found = true;
} else if (node.content.size) {
if (fragmentContainsExtension(node.content)) {
found = true;
}
}
});
return found;
};
const sliceContainsExtension = slice => fragmentContainsExtension(slice.content);
/**
* Returns the resourceId of the bodied sync block where an inline extension was inserted, or undefined.
* Used to show a warning flag only on the first instance per sync block.
*/
export const wasExtensionInsertedInBodiedSyncBlock = (tr, state) => {
if (!tr.docChanged || tr.getMeta('isRemote')) {
return undefined;
}
const {
bodiedSyncBlock
} = state.schema.nodes;
if (!bodiedSyncBlock) {
return undefined;
}
const docs = tr.docs;
// When docs is available (e.g. from history plugin), check each replace step
if (docs && docs.length > 0) {
for (let i = 0; i < tr.steps.length; i++) {
var _docs;
const step = tr.steps[i];
const isReplaceStep = step instanceof ReplaceStep || step instanceof ReplaceAroundStep;
if (!isReplaceStep || !('slice' in step) || !('from' in step)) {
continue;
}
const replaceStep = step;
if (!sliceContainsExtension(replaceStep.slice)) {
continue;
}
const docAfterStep = (_docs = docs[i + 1]) !== null && _docs !== void 0 ? _docs : tr.doc;
try {
const $pos = docAfterStep.resolve(replaceStep.from);
const parent = findParentNodeOfTypeClosestToPos($pos, bodiedSyncBlock);
if (parent !== null && parent !== void 0 && parent.node.attrs.resourceId) {
return parent.node.attrs.resourceId;
}
} catch {
// resolve() can throw if position is invalid
}
}
return undefined;
}
// Fallback: scan final doc for inline extensions inside bodied sync block that were added
let resourceId;
tr.doc.descendants((node, pos) => {
if (resourceId !== undefined) {
return false;
}
if (editorExperiment('platform_synced_block_patch_6', true, {
exposure: true
}) ? EXTENSION_NODES.has(node.type.name) : node.type.name === 'inlineExtension') {
const $pos = tr.doc.resolve(pos);
const parent = findParentNodeOfTypeClosestToPos($pos, bodiedSyncBlock);
if (parent !== null && parent !== void 0 && parent.node.attrs.resourceId) {
const mappedPos = tr.mapping.invert().map(pos);
const nodeBefore = state.doc.nodeAt(mappedPos);
if (!nodeBefore || (editorExperiment('platform_synced_block_patch_6', true, {
exposure: true
}) ? EXTENSION_NODES.has(nodeBefore.type.name) : nodeBefore.type.name !== 'inlineExtension')) {
resourceId = parent.node.attrs.resourceId;
return false;
}
}
}
return true;
});
return resourceId;
};