UNPKG

@atlaskit/editor-plugin-list

Version:

List plugin for @atlaskit/editor-core

272 lines (256 loc) 9.3 kB
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) }); }