diff --git a/src/extensions/markdown/Lists/index.ts b/src/extensions/markdown/Lists/index.ts index 496bf3a9a..0875c81da 100644 --- a/src/extensions/markdown/Lists/index.ts +++ b/src/extensions/markdown/Lists/index.ts @@ -8,6 +8,7 @@ import {actions} from './actions'; import {joinPrevList, toList} from './commands'; import {ListAction} from './const'; import {ListsInputRulesExtension, type ListsInputRulesOptions} from './inputrules'; +import {collapseListsPlugin} from './plugins/CollapseListsPlugin'; import {mergeListsPlugin} from './plugins/MergeListsPlugin'; export {ListNode, ListsAttr, blType, liType, olType} from './ListsSpecs'; @@ -50,6 +51,8 @@ export const Lists: ExtensionAuto = (builder, opts) => { builder.addPlugin(mergeListsPlugin); + builder.addPlugin(collapseListsPlugin); + builder .addAction(ListAction.ToBulletList, actions.toBulletList) .addAction(ListAction.ToOrderedList, actions.toOrderedList) diff --git a/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.test.ts b/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.test.ts new file mode 100644 index 000000000..5bdb02a85 --- /dev/null +++ b/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.test.ts @@ -0,0 +1,142 @@ +import {EditorState, TextSelection} from 'prosemirror-state'; +import {builders} from 'prosemirror-test-builder'; +import {EditorView} from 'prosemirror-view'; + +import {ExtensionsManager} from '../../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../../base/BaseSchema/BaseSchemaSpecs'; +import {ListsSpecs} from '../ListsSpecs'; +import {ListNode} from '../const'; + +import {collapseListsPlugin} from './CollapseListsPlugin'; + +const {schema} = new ExtensionsManager({ + extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(ListsSpecs), +}).buildDeps(); + +const {doc, p, li, ul} = builders<'doc' | 'p' | 'li' | 'ul'>(schema, { + doc: {nodeType: BaseNode.Doc}, + p: {nodeType: BaseNode.Paragraph}, + li: {nodeType: ListNode.ListItem}, + ul: {nodeType: ListNode.BulletList}, +}); + +describe('CollapseListsPlugin', () => { + it('should collapse nested bullet list without remaining content and move selection to the end of the first text node', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins: [collapseListsPlugin()]}), + }); + + const initialDoc = doc(ul(li(ul(li(p('Nested item')))))); + + view.dispatch( + view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content), + ); + + expect(view.state.doc).toMatchNode(doc(ul(li(p('Nested item'))))); + + const textStartPos = view.state.doc.resolve(3); + const textEndPos = textStartPos.pos + textStartPos.nodeAfter!.nodeSize; + + expect(view.state.selection.from).toBe(textEndPos); + }); + + it('should collapse nested bullet list with remaining content', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins: [collapseListsPlugin()]}), + }); + + const initialDoc = doc(ul(li(ul(li(p('Nested item'))), p('Remaining text')))); + + view.dispatch( + view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content), + ); + + expect(view.state.doc).toMatchNode(doc(ul(li(p('Nested item')), li(p('Remaining text'))))); + }); + + it('should collapse deeply nested bullet lists', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins: [collapseListsPlugin()]}), + }); + const initialDoc = doc(ul(li(ul(li(ul(li(p('Deep nested item')))))))); + + view.dispatch( + view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content), + ); + + expect(view.state.doc).toMatchNode(doc(ul(li(p('Deep nested item'))))); + }); + + it('should collapse multiple nested lists in a single document', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins: [collapseListsPlugin()]}), + }); + const initialDoc = doc( + ul(li(ul(li(p('Item 1 nested')))), li(p('Item 1 plain'))), + p('Between lists'), + ul(li(ul(li(p('Item 2 nested'))), p('Item 2 remaining'))), + ); + + view.dispatch( + view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content), + ); + + expect(view.state.doc).toMatchNode( + doc( + ul(li(p('Item 1 nested')), li(p('Item 1 plain'))), + p('Between lists'), + ul(li(p('Item 2 nested')), li(p('Item 2 remaining'))), + ), + ); + }); + + it('should correctly handle list items with mixed nested and non-nested content and move selection to the closest text node', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins: [collapseListsPlugin()]}), + }); + const initialDoc = doc( + ul( + li(p('No nested list')), + li(ul(li(p('Nested item 1'))), p('Extra text'), ul(li(p('Nested item 2')))), + ), + ); + + view.dispatch( + view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content), + ); + + expect(view.state.doc).toMatchNode( + doc( + ul( + li(p('No nested list')), + li(p('Nested item 1')), + li(p('Extra text'), ul(li(p('Nested item 2')))), + ), + ), + ); + + const textStartPos = view.state.doc.resolve(38); + expect(view.state.selection.from).toBe(textStartPos.pos); + }); + + it('should not collapse list item without nested bullet list and not change selection if no collapse happened', () => { + const view = new EditorView(null, { + state: EditorState.create({schema, plugins: [collapseListsPlugin()]}), + }); + + const initialDoc = doc(ul(li(p('Simple item')), li(p('Another item')))); + + view.dispatch( + view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content), + ); + + expect(view.state.doc).toMatchNode(doc(ul(li(p('Simple item')), li(p('Another item'))))); + + const selectionPos = view.state.doc.resolve(6); + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, selectionPos.pos)), + ); + + expect(view.state.selection.from).toBe(selectionPos.pos); + }); +}); diff --git a/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.ts b/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.ts new file mode 100644 index 000000000..17c15f6ef --- /dev/null +++ b/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.ts @@ -0,0 +1,94 @@ +import {Fragment, type Node} from 'prosemirror-model'; +import {Plugin, TextSelection, type Transaction} from 'prosemirror-state'; +import {findChildren, hasParentNode} from 'prosemirror-utils'; + +import {getChildrenOfNode} from '../../../../utils'; +import {isListItemNode, isListNode, liType} from '../utils'; + +export const collapseListsPlugin = () => + new Plugin({ + appendTransaction(trs, oldState, newState) { + const docChanged = trs.some((tr) => tr.docChanged); + if (!docChanged) return null; + + const hasParentList = + hasParentNode(isListNode)(newState.selection) || + hasParentNode(isListNode)(oldState.selection); + if (!hasParentList) return null; + + const {tr} = newState; + let prevStepsCount = -1; + let currentStepsCount = 0; + + // execute until there are no nested lists. + while (prevStepsCount !== currentStepsCount) { + const listNodes = findChildren(tr.doc, isListNode, true); + prevStepsCount = currentStepsCount; + currentStepsCount = collapseEmptyListItems(tr, listNodes); + } + + return tr.docChanged ? tr : null; + }, + }); + +export function collapseEmptyListItems( + tr: Transaction, + nodes: ReturnType, +): number { + const stepsCountBefore = tr.steps.length; + nodes.reverse().forEach((list) => { + const listNode = list.node; + const listPos = list.pos; + const childrenOfList = getChildrenOfNode(listNode).reverse(); + + childrenOfList.forEach(({node: itemNode, offset}) => { + if (isListItemNode(itemNode)) { + const {firstChild} = itemNode; + const listItemNodePos = listPos + 1 + offset; + + // if the first child of a list element is a list, + // then collapse is required + if (firstChild && isListNode(firstChild)) { + const nestedList = firstChild.content; + + // nodes at the same level as the list + const remainingNodes = itemNode.content.content.slice(1); + + const listItems = remainingNodes.length + ? nestedList.append( + Fragment.from( + liType(tr.doc.type.schema).create(null, remainingNodes), + ), + ) + : nestedList; + + const mappedStart = tr.mapping.map(listItemNodePos); + const mappedEnd = tr.mapping.map(listItemNodePos + itemNode.nodeSize); + + tr.replaceWith(mappedStart, mappedEnd, listItems); + + const closestTextNodePos = findClosestTextNodePos( + tr.doc, + mappedStart + nestedList.size, + ); + if (closestTextNodePos) { + tr.setSelection(TextSelection.create(tr.doc, closestTextNodePos)); + } + } + } + }); + }); + + return tr.steps.length - stepsCountBefore; +} + +function findClosestTextNodePos(doc: Node, pos: number): number | null { + while (pos < doc.content.size) { + const node = doc.nodeAt(pos); + if (node && node.isText) { + return pos; + } + pos++; + } + return null; +}