UNPKG

@atlaskit/editor-plugin-block-menu

Version:

BlockMenu plugin for @atlaskit/editor-core

209 lines (203 loc) 9.89 kB
import { getDefaultCodeBlockAttrs } from '@atlaskit/editor-common/code-block'; import { breakoutResizableNodes } from '@atlaskit/editor-common/utils'; import { Fragment } from '@atlaskit/editor-prosemirror/model'; import { removeDisallowedMarks } from '../marks'; import { NODE_CATEGORY_BY_TYPE } from '../types'; import { convertTextNodeToParagraph, createTextContent, isTextNode } from '../utils'; /** * Creates a layout section with two columns, where the first column contains the provided content. * Preserves breakout marks if provided. */ const createLayoutSection = (content, layoutSection, layoutColumn, marks) => { const columnOne = layoutColumn.createAndFill({}, removeDisallowedMarks(content, layoutColumn)); const columnTwo = layoutColumn.createAndFill(); if (!columnOne || !columnTwo) { return null; } return layoutSection.createAndFill({}, [columnOne, columnTwo], marks); }; /** * Creates a container with text content (for codeblocks). */ const createTextContentContainer = (textContentArray, targetNodeType, schema) => { const textContent = textContentArray.join('\n'); const textNode = textContent ? schema.text(textContent) : null; const attrs = targetNodeType.name === 'codeBlock' ? getDefaultCodeBlockAttrs() : {}; return targetNodeType.createAndFill(attrs, textNode); }; /** * Creates a regular container with node content. */ const createNodeContentContainer = (nodeContent, targetNodeType) => { const isExpandType = targetNodeType.name === 'expand' || targetNodeType.name === 'nestedExpand'; const nodeAttrs = isExpandType ? { localId: crypto.randomUUID() } : {}; return targetNodeType.createAndFill(nodeAttrs, nodeContent); }; /** * Handles the edge case where transforming from a container to another container results in * all content breaking out (no valid children for the target). In this case, creates an empty * container to ensure the target container type is created. * * We can determine if there were no valid children by checking if no container was created * (`!hasCreatedContainer`) and there are nodes in the result (`result.length > 0`), which * means all content broke out rather than being wrapped. * * @param result - The current result nodes after processing * @param hasCreatedContainer - Whether a container was already created during processing * @param fromNode - The original source node (before unwrapping) * @param targetNodeType - The target container type * @param targetNodeTypeName - The target container type name * @param schema - The schema * @returns The result nodes with an empty container prepended if needed, or the original result */ const handleEmptyContainerEdgeCase = (result, hasCreatedContainer, fromNode, targetNodeType, targetNodeTypeName, schema) => { const isFromContainer = NODE_CATEGORY_BY_TYPE[fromNode.type.name] === 'container'; const isTargetContainer = NODE_CATEGORY_BY_TYPE[targetNodeTypeName] === 'container'; // If no container was created but we have nodes in result, all content broke out // (meaning there were no valid children that could be wrapped) const allContentBrokeOut = !hasCreatedContainer && result.length > 0; const shouldCreateEmptyTarget = isFromContainer && isTargetContainer && allContentBrokeOut; if (!shouldCreateEmptyTarget) { return result; } if (targetNodeTypeName === schema.nodes.codeBlock.name) { const emptyCodeBlock = createTextContentContainer([], schema.nodes.codeBlock, schema); return emptyCodeBlock ? [emptyCodeBlock, ...result] : result; } const emptyParagraph = schema.nodes.paragraph.create(); const isExpandType = targetNodeTypeName === 'expand' || targetNodeTypeName === 'nestedExpand'; const emptyContainerAttrs = isExpandType ? { localId: crypto.randomUUID() } : {}; const emptyContainer = targetNodeType.create(emptyContainerAttrs, emptyParagraph); return [emptyContainer, ...result]; }; /** * A wrap step that handles mixed content according to the Compatibility Matrix: * - Wraps consecutive compatible nodes into the target container * - Same-type containers break out as separate containers (preserved as-is) * - NestedExpands break out as regular expands (converted since nestedExpand can't exist outside expand) * - Container structures that can't be nested in target break out (not flattened) * - Text/list nodes that can't be wrapped are converted to paragraphs and merged into the container * - Atomic nodes (tables, media, macros) break out * * Special handling for layouts: * - Layout sections break out as separate layouts (preserved as-is, not wrapped) * - Other nodes (including headings, paragraphs, lists) are wrapped into layout columns within a layout section * - Layouts always require layoutColumns as children (never paragraphs directly) * - Layout columns can contain most block content including headings, paragraphs, lists, etc. * * Special handling for codeblocks: * - Text nodes are converted to plain text and added to the codeblock * * Edge case handling: * - For regular containers: If all content breaks out (container → container transform with no * valid children), an empty container with a paragraph is created to ensure the target type exists * - For layouts: Edge case handling is skipped because layouts require columns, not direct paragraphs. * If all content breaks out, only the broken-out nodes are returned (no empty layout created) * * What can be wrapped depends on the target container's schema: * - expand → panel: tables break out, nestedExpands convert to expands and break out * - expand → blockquote: tables/media break out, nestedExpands convert to expands and break out, headings converted to paragraphs * - expand → expand: tables/media stay inside (expands can contain them) * - multi → layoutSection: layout sections break out, headings/paragraphs/lists wrapped into layout columns * * Example: expand(p('a'), table(), p('b')) → panel: [panel(p('a')), table(), panel(p('b'))] * Example: expand(p('a'), panel(p('x')), p('b')) → panel: [panel(p('a')), panel(p('x')), panel(p('b'))] * Example: expand(p('a'), nestedExpand({title: 'inner'})(p('x')), p('b')) → panel: [panel(p('a')), expand({title: 'inner'})(p('x')), panel(p('b'))] * Example: expand(nestedExpand()(p())) → panel: [panel(), expand()(p())] (empty panel when all content breaks out) * Example: [p('a'), layoutSection(...), p('b')] → layoutSection: [layoutSection(layoutColumn(p('a'))), layoutSection(...), layoutSection(layoutColumn(p('b')))] * Example: [h1('heading'), p('para')] → layoutSection: [layoutSection(layoutColumn(h1('heading'), p('para')))] (headings stay as headings in layouts) */ export const wrapMixedContentStep = (nodes, context) => { const { schema, targetNodeTypeName, fromNode } = context; const targetNodeType = schema.nodes[targetNodeTypeName]; if (!targetNodeType) { return nodes; } const isLayout = targetNodeTypeName === 'layoutSection'; const isCodeblock = targetNodeTypeName === 'codeBlock'; const { layoutSection, layoutColumn } = schema.nodes; const sourceSupportsBreakout = breakoutResizableNodes.includes(fromNode.type.name); const targetSupportsBreakout = breakoutResizableNodes.includes(targetNodeTypeName); const shouldPreserveBreakout = sourceSupportsBreakout && targetSupportsBreakout; let breakoutMark; if (shouldPreserveBreakout) { breakoutMark = fromNode.marks.find(mark => mark.type.name === 'breakout'); } const result = []; let currentContainerContent = []; let hasCreatedContainer = false; const flushCurrentContainer = () => { if (currentContainerContent.length === 0) { return; } let container = null; if (isLayout) { container = createLayoutSection(currentContainerContent, layoutSection, layoutColumn, breakoutMark ? [breakoutMark] : undefined); } else if (isCodeblock) { container = createTextContentContainer(currentContainerContent, targetNodeType, schema); } else { container = createNodeContentContainer(currentContainerContent, targetNodeType); } if (container) { result.push(container); hasCreatedContainer = true; } currentContainerContent = []; }; const canNodeBeWrapped = node => { const validationType = isLayout ? layoutColumn : targetNodeType; return validationType.validContent(Fragment.from(removeDisallowedMarks([node], validationType))); }; const handleWrappableNode = node => { const validationType = isLayout ? layoutColumn : targetNodeType; currentContainerContent.push(...removeDisallowedMarks([node], validationType)); }; const handleCodeblockTextNode = node => { currentContainerContent.push(createTextContent(node)); }; const handleConvertibleTextNode = node => { const paragraph = convertTextNodeToParagraph(node, schema); if (paragraph) { currentContainerContent.push(paragraph); } }; const handleUnsupportedNode = node => { flushCurrentContainer(); result.push(node); }; const processNode = node => { if (canNodeBeWrapped(node)) { handleWrappableNode(node); return; } if (isTextNode(node) && isCodeblock) { handleCodeblockTextNode(node); return; } if (isTextNode(node)) { handleConvertibleTextNode(node); return; } // All other nodes that cannot be wrapped in the target node - break out // Examples: same-type containers, tables in panels, layoutSections in layouts handleUnsupportedNode(node); }; nodes.forEach(processNode); flushCurrentContainer(); if (isLayout) { return result.length > 0 ? result : nodes; } const finalResult = handleEmptyContainerEdgeCase(result, hasCreatedContainer, fromNode, targetNodeType, targetNodeTypeName, schema); return finalResult.length > 0 ? finalResult : nodes; };