UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

181 lines (174 loc) 7.27 kB
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; };