@atlaskit/editor-plugin-list
Version:
List plugin for @atlaskit/editor-core
272 lines (256 loc) • 9.3 kB
JavaScript
import { buildReplacementFragment as buildReplacementFragmentBase, flattenList as flattenListBase } from '@atlaskit/editor-common/lists';
import { isListItemNode, isListNode } from '@atlaskit/editor-common/utils';
import { fg } from '@atlaskit/platform-feature-flags';
/**
* Returns true if a listItem has at least one non-list child (paragraph, etc.).
*/
function hasContentChildren(listItem) {
return listItem.children.some(child => !isListNode(child));
}
/**
* Compute the size of non-list (content) children of a listItem, which
* represents the "visible" bounds of the item for selection purposes.
*/
function contentSize(listItem) {
return listItem.children.reduce((size, child) => {
return size + (isListNode(child) ? 0 : child.nodeSize);
}, 0);
}
/**
* Flattens a list, splitting each list item into one item per run of content.
* An item like `li(p, ul, p, ul)` becomes separate sibling items, so content
* placed between sub-lists keeps its order. Also records which items the
* selection touches.
*/
function flattenListImpl(options) {
const {
doc,
rootListStart,
selectionFrom,
selectionTo,
indentDelta,
maxDepth
} = options;
const rootList = doc.nodeAt(rootListStart);
if (!rootList) {
return null;
}
const schema = rootList.type.schema;
const items = [];
let startIndex = -1;
let endIndex = -1;
let exceedsMaxDepth = false;
const isPointSelection = selectionFrom === selectionTo;
const pushSegment = (segmentNode, contentStart, contentEnd, depth, parentList) => {
const isSelected = isPointSelection ? selectionFrom >= contentStart && selectionFrom <= contentEnd : contentStart < selectionTo && contentEnd > selectionFrom;
const effectiveDepth = depth + (isSelected ? indentDelta : 0);
items.push({
node: segmentNode,
pos: contentStart - 1,
depth: effectiveDepth,
isSelected,
listType: parentList.type.name,
parentListAttrs: parentList.attrs
});
if (isSelected) {
const index = items.length - 1;
if (startIndex === -1) {
startIndex = index;
}
endIndex = index;
if (maxDepth != null && effectiveDepth >= maxDepth) {
exceedsMaxDepth = true;
}
}
};
const walkList = (listNode, listPos, depth) => {
let childPos = listPos + 1;
listNode.forEach(listItemNode => {
if (!isListItemNode(listItemNode)) {
childPos += listItemNode.nodeSize;
return;
}
const itemStart = childPos;
let innerPos = itemStart + 1;
// Content collected since the last sub-list. Each run becomes its own item.
let segmentContent = [];
let segmentContentStart = innerPos;
let segmentContentSize = 0;
const emitSegment = parentList => {
if (segmentContent.length === 0) {
return;
}
const segmentItem = schema.nodes.listItem.create(listItemNode.attrs, segmentContent);
pushSegment(segmentItem, segmentContentStart, segmentContentStart + segmentContentSize, depth, parentList);
};
listItemNode.forEach(child => {
if (isListNode(child)) {
// A sub-list ends the current run: emit it, then walk the sub-list deeper.
emitSegment(listNode);
segmentContent = [];
segmentContentSize = 0;
walkList(child, innerPos, depth + 1);
innerPos += child.nodeSize;
segmentContentStart = innerPos;
} else {
if (segmentContent.length === 0) {
segmentContentStart = innerPos;
}
segmentContent.push(child);
segmentContentSize += child.nodeSize;
innerPos += child.nodeSize;
}
});
// Emit any remaining content after the last sub-list.
emitSegment(listNode);
childPos += listItemNode.nodeSize;
});
};
walkList(rootList, rootListStart, 0);
if (items.length === 0 || startIndex === -1 || exceedsMaxDepth) {
return null;
}
return {
items,
startIndex,
endIndex
};
}
export function flattenList(options) {
if (fg('platform_editor_flexible_list_normalization_fix')) {
return flattenListImpl(options);
}
return flattenListBase(options, {
isContentNode: (node, parent) => isListItemNode(node) && hasContentChildren(node) && isListNode(parent),
// +1 shifts from the listItem node boundary to the start of its content children
getSelectionBounds: (node, pos) => ({
start: pos + 1,
end: pos + 1 + contentSize(node)
}),
getDepth: (resolvedDepth, rootDepth) => (resolvedDepth - rootDepth - 1) / 2
});
}
/**
* Extract non-list (content) children from a listItem node.
*/
function extractContentChildren(listItem) {
const children = [];
for (let i = 0; i < listItem.childCount; i++) {
const child = listItem.child(i);
if (!isListNode(child)) {
children.push(child);
}
}
return children;
}
/**
* Rebuild a ProseMirror list tree from a flat array of `FlattenedItem` objects
* using a bottom-up stack approach.
*
* The algorithm tracks open list/listItem wrappers on a stack. As depth
* transitions occur between consecutive elements, wrapper nodes are opened
* (depth increase) or closed (depth decrease).
*/
function rebuildPMList(elements, schema) {
if (elements.length === 0) {
return null;
}
// Each stack frame represents an open list at a given depth.
// items[] accumulates the PMNode children (listItem nodes) for that list.
const stack = [];
function openList(listType, listAttrs) {
stack.push({
listType,
listAttrs,
items: []
});
}
/**
* Close lists on the stack down to `targetDepth`, wrapping each closed
* list into the last listItem of its parent.
*/
function closeToDepth(targetDepth) {
while (stack.length > targetDepth + 1) {
const closed = stack.pop();
if (!closed) {
break;
}
const listNode = schema.nodes[closed.listType].create(closed.listAttrs, closed.items);
// Attach the closed list to the last listItem on the parent frame
const parentFrame = stack[stack.length - 1];
const lastItem = parentFrame.items[parentFrame.items.length - 1];
if (lastItem) {
// Append the nested list to this listItem's children
const newContent = [];
lastItem.forEach(child => newContent.push(child));
newContent.push(listNode);
parentFrame.items[parentFrame.items.length - 1] = schema.nodes.listItem.create(lastItem.attrs, newContent);
} else {
// Edge case: no listItem to attach to. Create a wrapper.
const wrapperItem = schema.nodes.listItem.create(null, [listNode]);
parentFrame.items.push(wrapperItem);
}
}
}
// Seed the root list with the first element's parent list attributes
openList(elements[0].listType, elements[0].parentListAttrs);
for (const el of elements) {
const targetDepth = el.depth;
// Close lists if we're going shallower
if (stack.length > targetDepth + 1) {
closeToDepth(targetDepth);
}
// Open lists if we need to go deeper.
// We do NOT create wrapper listItems here — closeToDepth handles
// creating wrappers that contain only the nested list (no empty paragraph).
// For unselected elements, the list structure already existed so we
// preserve the parent list's attributes. For selected (moved) elements,
// this is a new nesting level so we use null (the localId plugin will
// backfill a fresh UUID).
while (stack.length < targetDepth + 1) {
openList(el.listType, el.isSelected ? null : el.parentListAttrs);
}
// Build the listItem for this element using its content children
const contentChildren = extractContentChildren(el.node);
const listItem = schema.nodes.listItem.create(el.node.attrs, contentChildren);
stack[stack.length - 1].items.push(listItem);
}
// Close all remaining open lists
closeToDepth(0);
const root = stack[0];
const rebuilt = schema.nodes[root.listType].create(root.listAttrs, root.items);
// Compute content start offsets by walking the rebuilt tree.
const contentStartOffsets = new Array(elements.length);
let segIdx = 0;
rebuilt.descendants((node, pos) => {
if (isListItemNode(node) && hasContentChildren(node)) {
// +1 for rebuilt's opening tag, +1 for listItem's opening tag
contentStartOffsets[segIdx] = 1 + pos + 1;
segIdx++;
}
return true;
});
return {
node: rebuilt,
contentStartOffsets
};
}
/**
* Build a replacement Fragment from a flat array of `FlattenedItem` objects.
*
* Elements with depth >= 0 are grouped into consecutive list segments
* and rebuilt via `rebuildPMList`. Elements with depth < 0 (extracted
* past the root) are converted to their content children (paragraphs).
* The result interleaves list nodes and extracted content in document order.
*
* Delegates to the shared `buildReplacementFragment` with list-specific
* rebuild and extraction functions.
*/
export function buildReplacementFragment(elements, schema) {
return buildReplacementFragmentBase({
items: elements,
schema,
rebuildFn: rebuildPMList,
extractContentFn: item => extractContentChildren(item.node)
});
}