@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
466 lines (444 loc) • 17.4 kB
JavaScript
import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model';
import { findChildrenByType, findParentNodeOfType, findSelectedNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { mapSlice } from '../utils/slice';
import { getSupportedListTypesSet, isBulletOrOrderedList, isTaskList, convertBlockToInlineContent } from './list-utils';
const getContentSupportChecker = targetNodeType => {
return node => {
try {
return targetNodeType.validContent(Fragment.from(node));
} catch {
return false;
}
};
};
export const createBlockTaskItem = ({
attrs,
content,
marks,
schema
}) => {
const {
blockTaskItem,
paragraph
} = schema.nodes;
const newParagraph = paragraph.createChecked(null, content, marks === null || marks === void 0 ? void 0 : marks.filter(mark => blockTaskItem.allowsMarkType(mark.type)));
return blockTaskItem.create(attrs !== null && attrs !== void 0 ? attrs : null, newParagraph);
};
export const transformListRecursively = (props, onhandleUnsupportedContent) => {
const transformedItems = [];
const {
listNode,
isSourceBulletOrOrdered,
isTargetBulletOrOrdered,
isSourceTask,
isTargetTask,
supportedListTypes,
schema,
targetNodeType
} = props;
const {
taskList,
listItem,
taskItem,
paragraph,
blockTaskItem
} = schema.nodes;
// gating behind platform_editor_small_font_size to support task lists with font size applied,
// but keep this solution general
const isBlockTaskEnabled = !!blockTaskItem && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
/**
* Extracts paragraph children from a blockTaskItem, preserving their marks.
*/
const extractParagraphsFromBlockTaskItem = node => {
const paragraphs = [];
node.forEach(child => {
if (child.type === paragraph) {
paragraphs.push(child);
}
});
return paragraphs;
};
listNode.forEach(child => {
if (isSourceBulletOrOrdered && isTargetTask) {
// Convert bullet/ordered => task
if (child.type === listItem) {
const inlineContent = [];
const nestedTaskLists = [];
let blockMarks = [];
child.forEach(grandChild => {
if (supportedListTypes.has(grandChild.type) && grandChild.type !== taskList) {
nestedTaskLists.push(transformListRecursively({
...props,
listNode: grandChild
}, onhandleUnsupportedContent));
} else if (!getContentSupportChecker(taskItem)(grandChild) && !grandChild.isTextblock) {
onhandleUnsupportedContent === null || onhandleUnsupportedContent === void 0 ? void 0 : onhandleUnsupportedContent(grandChild);
} else {
if (isBlockTaskEnabled && grandChild.type === paragraph && grandChild.marks.length > 0) {
blockMarks = grandChild.marks;
}
inlineContent.push(...convertBlockToInlineContent(grandChild, schema));
}
});
if (isBlockTaskEnabled && blockMarks.length > 0) {
transformedItems.push(createBlockTaskItem({
content: inlineContent,
marks: blockMarks,
schema
}));
} else {
transformedItems.push(taskItem.create(null, inlineContent.length > 0 ? inlineContent : null));
}
transformedItems.push(...nestedTaskLists);
}
} else if (isSourceTask && isTargetBulletOrOrdered) {
// Convert task => bullet/ordered
if (child.type === taskItem) {
const inlineContent = [...child.content.content];
// Transfer taskItem's block marks to the paragraph.
// Use listItem.allowsMarkType since the paragraph will be inside a listItem
// (which uses ParagraphWithFontSizeStage0 that allows fontSize).
const paragraphMarks = isBlockTaskEnabled && child.marks.length > 0 ? child.marks.filter(mark => listItem.allowsMarkType(mark.type)) : undefined;
const paragraphNode = paragraph.create(null, inlineContent.length > 0 ? inlineContent : null, paragraphMarks);
transformedItems.push(listItem.create(null, [paragraphNode]));
} else if (isBlockTaskEnabled && child.type === blockTaskItem) {
// blockTaskItem wraps content in paragraphs — extract them directly,
// preserving their fontSize marks
const paragraphs = extractParagraphsFromBlockTaskItem(child);
if (paragraphs.length > 0) {
transformedItems.push(listItem.create(null, paragraphs));
}
} else if (child.type === taskList) {
const transformedNestedList = transformListRecursively({
...props,
listNode: child
}, onhandleUnsupportedContent);
const lastItem = transformedItems[transformedItems.length - 1];
if ((lastItem === null || lastItem === void 0 ? void 0 : lastItem.type) === listItem) {
// Attach nested list to previous item
const updatedContent = [...lastItem.content.content, transformedNestedList];
transformedItems[transformedItems.length - 1] = listItem.create(lastItem.attrs, updatedContent);
} else {
// No previous item, flatten nested items
transformedItems.push(...transformedNestedList.content.content);
}
}
} else if (isSourceBulletOrOrdered && isTargetBulletOrOrdered) {
if (child.type === listItem) {
const convertedNestedLists = [];
child.forEach(grandChild => {
if (supportedListTypes.has(grandChild.type) && grandChild.type !== targetNodeType) {
const convertedNode = transformListRecursively({
...props,
listNode: grandChild
}, onhandleUnsupportedContent);
convertedNestedLists.push(convertedNode);
} else {
convertedNestedLists.push(grandChild);
}
});
transformedItems.push(listItem.create(null, convertedNestedLists));
}
}
});
return targetNodeType.create(null, transformedItems);
};
/**
* Transform list structure between different list types
*/
export const transformListStructure = context => {
const {
tr,
sourceNode,
sourcePos,
targetNodeType
} = context;
const nodes = tr.doc.type.schema.nodes;
const unsupportedContent = [];
const onhandleUnsupportedContent = content => {
unsupportedContent.push(content);
};
try {
const listNode = {
node: sourceNode,
pos: sourcePos
};
const {
node: sourceList,
pos: listPos
} = listNode;
// const { taskList, listItem, taskItem, paragraph } = nodes;
const isSourceBulletOrOrdered = isBulletOrOrderedList(sourceList.type);
const isTargetTask = isTaskList(targetNodeType);
const isSourceTask = isTaskList(sourceList.type);
const isTargetBulletOrOrdered = isBulletOrOrderedList(targetNodeType);
const supportedListTypes = getSupportedListTypesSet(nodes);
const newList = transformListRecursively({
isSourceBulletOrOrdered,
isSourceTask,
isTargetBulletOrOrdered,
isTargetTask,
listNode: sourceList,
schema: tr.doc.type.schema,
supportedListTypes,
targetNodeType
}, onhandleUnsupportedContent);
tr.replaceWith(listPos, listPos + sourceList.nodeSize, [newList, ...unsupportedContent]);
return tr;
} catch {
return tr;
}
};
/**
* Transform between different list types
*/
export const transformBetweenListTypes = context => {
const {
tr,
sourceNode,
sourcePos,
targetNodeType
} = context;
const {
nodes
} = tr.doc.type.schema;
const sourceListType = sourceNode.type;
const isSourceBulletOrOrdered = isBulletOrOrderedList(sourceListType);
const isTargetTask = isTaskList(targetNodeType);
const isSourceTask = isTaskList(sourceListType);
const isTargetBulletOrOrdered = isBulletOrOrderedList(targetNodeType);
// Check if we need structure transformation
const needsStructureTransform = isSourceBulletOrOrdered && isTargetTask || isSourceTask && isTargetBulletOrOrdered;
try {
if (!needsStructureTransform) {
// Simple type change for same structure lists (bullet <-> ordered)
// Apply to the main list
tr.setNodeMarkup(sourcePos, targetNodeType);
// Apply to nested lists
const listStart = sourcePos;
const listEnd = sourcePos + sourceNode.nodeSize;
const supportedListTypesSet = getSupportedListTypesSet(nodes);
tr.doc.nodesBetween(listStart, listEnd, (node, pos, parent) => {
// Only process nested lists (not the root list we already handled)
if (supportedListTypesSet.has(node.type) && pos !== sourcePos) {
const isNestedList = parent && (supportedListTypesSet.has(parent.type) || parent.type === nodes.listItem);
if (isNestedList) {
const shouldTransformNode = node.type === sourceListType || isBulletOrOrderedList(node.type) && isTargetBulletOrOrdered;
if (shouldTransformNode) {
tr.setNodeMarkup(pos, targetNodeType);
}
}
}
return true; // Continue traversing
});
return tr;
} else {
return transformListStructure(context);
}
} catch {
return null;
}
};
/**
* Transform selection to task list
* Handles the special structure where taskItem contains text directly (no paragraph wrapper)
*/
export const transformToTaskList = (tr, range, targetNodeType, targetAttrs, nodes) => {
try {
const {
taskItem,
paragraph,
blockTaskItem
} = nodes;
// gating behind platform_editor_small_font_size to support task lists with font size applied,
// but keep this solution general
const isBlockTaskItemEnabled = !!blockTaskItem && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
const listItems = [];
// Process each block in the range
tr.doc.nodesBetween(range.start, range.end, node => {
if (node.isBlock) {
const inlineContent = [...node.content.content];
if (inlineContent.length > 0) {
if (isBlockTaskItemEnabled && node.type === paragraph && node.marks.length > 0) {
listItems.push(createBlockTaskItem({
attrs: targetAttrs,
content: inlineContent,
marks: node.marks,
schema: tr.doc.type.schema
}));
} else {
listItems.push(taskItem.create(targetAttrs, inlineContent));
}
}
}
return false;
});
if (listItems.length === 0) {
return null;
}
// Create the new task list
const newList = targetNodeType.create(targetAttrs, listItems);
// Replace the range with the new list
tr.replaceWith(range.start, range.end, newList);
return tr;
} catch {
return null;
}
};
export const transformTaskListToBlockNodes = context => {
const {
tr,
targetNodeType,
targetAttrs,
sourceNode,
sourcePos
} = context;
const {
selection
} = tr;
const schema = selection.$from.doc.type.schema;
const {
blockTaskItem
} = schema.nodes;
// gating behind platform_editor_small_font_size to support task lists with font size applied,
// but keep this solution general
const isBlockTaskItemEnabled = !!blockTaskItem && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
if (isBlockTaskItemEnabled) {
const blockTaskItemsResult = findChildrenByType(sourceNode, blockTaskItem);
if (blockTaskItemsResult.length > 0 && targetNodeType === schema.nodes.paragraph) {
// blockTaskItem content is (paragraph | extension)+
// Extract paragraph children directly — they may carry block marks (e.g. fontSize)
const targetNodes = [];
for (const {
node: blockItem
} of blockTaskItemsResult) {
blockItem.forEach(child => {
if (child.type === schema.nodes.paragraph) {
targetNodes.push(child);
}
});
}
if (targetNodes.length === 0) {
return null;
}
const slice = new Slice(Fragment.fromArray(targetNodes), 0, 0);
const rangeStart = sourcePos !== null ? sourcePos : selection.from;
tr.replaceRange(rangeStart, rangeStart + sourceNode.nodeSize, slice);
return tr;
}
}
// Original logic for regular taskItem children
const taskItemsResult = findChildrenByType(sourceNode, schema.nodes.taskItem);
const taskItems = taskItemsResult.map(item => item.node);
const taskItemFragments = taskItems.map(taskItem => taskItem.content);
let targetNodes = [];
// Convert fragments to headings if target is heading
if (targetNodeType === schema.nodes.heading && targetAttrs) {
// convert the fragments to headings
const targetHeadingLevel = targetAttrs.level;
targetNodes = taskItemFragments.map(fragment => schema.nodes.heading.createChecked({
level: targetHeadingLevel
}, fragment.content));
}
// Convert fragments to paragraphs if target is paragraphs
if (targetNodeType === schema.nodes.paragraph) {
// convert the fragments to paragraphs
targetNodes = taskItemFragments.map(fragment => schema.nodes.paragraph.createChecked({}, fragment.content));
}
// Convert fragments to code block if target is code block
if (targetNodeType === schema.nodes.codeBlock) {
// convert the fragments to one code block
const codeBlockContent = taskItemFragments.map(fragment => fragment.textBetween(0, fragment.size, '\n')).join('\n');
targetNodes = [schema.nodes.codeBlock.createChecked({}, schema.text(codeBlockContent))];
}
// Replace the task list node with the new content in the transaction
const slice = new Slice(Fragment.fromArray(targetNodes), 0, 0);
const rangeStart = sourcePos !== null ? sourcePos : selection.from;
tr.replaceRange(rangeStart, rangeStart + sourceNode.nodeSize, slice);
return tr;
};
export const getFormattedNode = tr => {
const {
selection
} = tr;
const {
nodes
} = tr.doc.type.schema;
// gating behind platform_editor_small_font_size to support task lists with font size applied,
// but keep this solution general
const isBlockTaskItemEnabled = !!nodes.blockTaskItem && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
// Find the node to format from the current selection
let nodeToFormat;
let nodePos = selection.from;
// Try to find the current node from selection
const selectedNode = findSelectedNodeOfType([nodes.paragraph, nodes.heading, nodes.blockquote, nodes.panel, nodes.expand, nodes.codeBlock, nodes.bulletList, nodes.orderedList, nodes.taskList, nodes.layoutSection])(selection);
if (selectedNode) {
nodeToFormat = selectedNode.node;
nodePos = selectedNode.pos;
} else {
// Try to find parent node (including list parents)
const parentNodeTypes = [nodes.blockquote, nodes.panel, nodes.expand, nodes.codeBlock, nodes.listItem, nodes.taskItem, nodes.layoutSection];
if (isBlockTaskItemEnabled) {
parentNodeTypes.push(nodes.blockTaskItem);
}
const parentNode = findParentNodeOfType(parentNodeTypes)(selection);
if (parentNode) {
nodeToFormat = parentNode.node;
nodePos = parentNode.pos;
const paragraphOrHeadingNode = findParentNodeOfType([nodes.paragraph, nodes.heading])(selection);
// Special case: if we found a listItem/taskItem/blockTaskItem, check if we need the parent list instead
if (parentNode.node.type === nodes.listItem || parentNode.node.type === nodes.taskItem || isBlockTaskItemEnabled && parentNode.node.type === nodes.blockTaskItem) {
const listParent = findParentNodeOfType([nodes.bulletList, nodes.orderedList, nodes.taskList])(selection);
if (listParent) {
// For list transformations, we want the list parent, not the listItem
nodeToFormat = listParent.node;
nodePos = listParent.pos;
}
} else if (parentNode.node.type !== nodes.blockquote && paragraphOrHeadingNode) {
nodeToFormat = paragraphOrHeadingNode.node;
nodePos = paragraphOrHeadingNode.pos;
}
}
}
if (!nodeToFormat) {
nodeToFormat = selection.$from.node();
nodePos = selection.$from.pos;
}
return {
node: nodeToFormat,
pos: nodePos
};
};
/**
* Ensures every `listItem` in a slice starts with a paragraph.
*
* @param slice - The slice to transform
* @param schema - The editor schema, used to create new paragraph nodes
* @returns A new slice with the transformation applied
*/
export const transformSliceEnsureListItemParagraphFirst = (slice, schema) => {
const {
listItem,
paragraph
} = schema.nodes;
if (!listItem || !paragraph) {
return slice;
}
return mapSlice(slice, node => {
if (node.type === listItem) {
const firstChild = node.firstChild;
if (firstChild && firstChild.type !== paragraph) {
const emptyParagraph = paragraph.createAndFill();
if (emptyParagraph) {
const children = [emptyParagraph];
for (let i = 0; i < node.childCount; i++) {
children.push(node.child(i));
}
return node.copy(Fragment.from(children));
}
}
}
return node;
});
};