Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/extensions/markdown/Lists/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +51,8 @@ export const Lists: ExtensionAuto<ListsOptions> = (builder, opts) => {

builder.addPlugin(mergeListsPlugin);

builder.addPlugin(collapseListsPlugin);

builder
.addAction(ListAction.ToBulletList, actions.toBulletList)
.addAction(ListAction.ToOrderedList, actions.toOrderedList)
Expand Down
142 changes: 142 additions & 0 deletions src/extensions/markdown/Lists/plugins/CollapseListsPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
94 changes: 94 additions & 0 deletions src/extensions/markdown/Lists/plugins/CollapseListsPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<typeof findChildren>,
): 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;
}
Loading