UNPKG

@atlaskit/editor-plugin-list

Version:

List plugin for @atlaskit/editor-core

175 lines (160 loc) 6.2 kB
import { buildReplacementFragment as buildReplacementFragmentBase, flattenList as flattenListBase } from '@atlaskit/editor-common/lists'; import { isListItemNode, isListNode } from '@atlaskit/editor-common/utils'; /** * 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); } /** * Flatten a root list into a flat array of content-bearing items * and simultaneously determine which elements intersect the user's selection. * * Delegates to the shared `flattenListLike` with list-specific callbacks. * Selection intersection is checked against each item's content-only * span (excluding nested lists). */ export function flattenList(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) }); }