Skip to content

Commit 6dca6ae

Browse files
authored
fix(Lists): improve nested list collapse for deep structures (#1041)
1 parent 6372f3e commit 6dca6ae

3 files changed

Lines changed: 320 additions & 79 deletions

File tree

packages/editor/src/extensions/markdown/Lists/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ export const Lists: ExtensionAuto<ListsOptions> = (builder, opts) => {
5050

5151
builder.use(ListsInputRulesExtension, {bulletListInputRule: opts?.ulInputRules});
5252

53+
// Order matters: merge must run before collapse (see CollapseListsPlugin.ts)
5354
builder.addPlugin(mergeListsPlugin);
54-
5555
builder.addPlugin(collapseListsPlugin);
5656

5757
builder

packages/editor/src/extensions/markdown/Lists/plugins/CollapseListsPlugin.test.ts

Lines changed: 200 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {EditorState, TextSelection} from 'prosemirror-state';
1+
import {type Node, Slice} from 'prosemirror-model';
2+
import {AllSelection, EditorState, TextSelection} from 'prosemirror-state';
23
import {builders} from 'prosemirror-test-builder';
34
import {EditorView} from 'prosemirror-view';
45

@@ -7,9 +8,10 @@ import {BaseNode, BaseSchemaSpecs} from '../../../base/BaseSchema/BaseSchemaSpec
78
import {ListsSpecs} from '../ListsSpecs';
89
import {ListNode} from '../const';
910

10-
import {collapseListsPlugin} from './CollapseListsPlugin';
11+
import {collapseAllNestedListItems, collapseListsPlugin} from './CollapseListsPlugin';
12+
import {mergeListsPlugin} from './MergeListsPlugin';
1113

12-
const {schema} = new ExtensionsManager({
14+
const {schema, markupParser: parser} = new ExtensionsManager({
1315
extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(ListsSpecs),
1416
}).buildDeps();
1517

@@ -21,7 +23,7 @@ const {doc, p, li, ul} = builders<'doc' | 'p' | 'li' | 'ul'>(schema, {
2123
});
2224

2325
describe('CollapseListsPlugin', () => {
24-
it('should collapse nested bullet list without remaining content and move selection to the end of the first text node', () => {
26+
it('should collapse nested bullet list without remaining content and place cursor on text', () => {
2527
const view = new EditorView(null, {
2628
state: EditorState.create({schema, plugins: [collapseListsPlugin()]}),
2729
});
@@ -34,10 +36,9 @@ describe('CollapseListsPlugin', () => {
3436

3537
expect(view.state.doc).toMatchNode(doc(ul(li(p('Nested item')))));
3638

37-
const textStartPos = view.state.doc.resolve(3);
38-
const textEndPos = textStartPos.pos + textStartPos.nodeAfter!.nodeSize;
39-
40-
expect(view.state.selection.from).toBe(textEndPos);
39+
const sel = view.state.selection;
40+
const $from = view.state.doc.resolve(sel.from);
41+
expect($from.parent.type.name).toBe('paragraph');
4142
});
4243

4344
it('should collapse nested bullet list with remaining content', () => {
@@ -115,8 +116,8 @@ describe('CollapseListsPlugin', () => {
115116
),
116117
);
117118

118-
const textStartPos = view.state.doc.resolve(38);
119-
expect(view.state.selection.from).toBe(textStartPos.pos);
119+
const $from = view.state.doc.resolve(view.state.selection.from);
120+
expect($from.parent.type.name).toBe('paragraph');
120121
});
121122

122123
it('should not collapse list item without nested bullet list and not change selection if no collapse happened', () => {
@@ -139,4 +140,193 @@ describe('CollapseListsPlugin', () => {
139140

140141
expect(view.state.selection.from).toBe(selectionPos.pos);
141142
});
143+
144+
it('should not crash on a large bullet list with 3-level nesting from "N. M." items', () => {
145+
const markdown = [
146+
'- 1. Replied in the original ticket.',
147+
'- 2. Fixed the macro processing for table of contents.',
148+
'- 3. The heading is already H2 after import, everything is correct.',
149+
'- 4. Added support for this macro.',
150+
'- 5. Fixed processing of such quotes.',
151+
'- 6. Fixed processing of em dashes.',
152+
'- 7. 8. Replied in the original ticket.',
153+
'- 9. Could not reproduce, apparently the screenshot shows a placeholder for a magic link logo.',
154+
'- 10. In the source data items are presented as text not a list. In markdown this is considered a numbered list and indentation is automatically added. The inner list is also formatted as a first-level indented list because it is not a second-level list at the markup level. Accordingly the indentation is the same. In this case this is expected behavior.',
155+
'- 11. Email highlighting is standard editor behavior.',
156+
'- 12. Added escaping of backslashes.',
157+
'- 13. The code contains links as anchor tags and such links are not mapped. Only internal Confluence links are mapped.',
158+
'- 14. 15. Replied in the original ticket.',
159+
'- 16. This is a feature of how the editor works. Possibly a bug, will discuss with the team.',
160+
'- 17. Duplicates of previous errors.',
161+
'- 18. The image was inserted by link and either was not found or is not accessible.',
162+
'- 19. 20. Expected behavior and link highlighting.',
163+
].join('\n');
164+
165+
const parsedDoc = parser.parse(markdown);
166+
167+
const view = new EditorView(null, {
168+
state: EditorState.create({schema, plugins: [collapseListsPlugin()]}),
169+
});
170+
171+
expect(() => {
172+
view.dispatch(
173+
view.state.tr
174+
.setSelection(new AllSelection(view.state.doc))
175+
.replaceSelection(new Slice(parsedDoc.content, 0, 0)),
176+
);
177+
}).not.toThrow();
178+
179+
const resultDoc = view.state.doc;
180+
expect(hasRedundantNesting(resultDoc)).toBe(false);
181+
182+
let listItemCount = 0;
183+
resultDoc.descendants((node) => {
184+
if (node.type.name === ListNode.ListItem) listItemCount++;
185+
return true;
186+
});
187+
expect(listItemCount).toBe(17);
188+
});
189+
190+
it('collapseAllNestedListItems should return null when no collapsible items exist', () => {
191+
const view = new EditorView(null, {
192+
state: EditorState.create({schema, plugins: [collapseListsPlugin()]}),
193+
});
194+
195+
const initialDoc = doc(ul(li(p('Plain item')), li(p('Another item'))));
196+
view.dispatch(
197+
view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content),
198+
);
199+
200+
const {tr} = view.state;
201+
expect(collapseAllNestedListItems(tr)).toBeNull();
202+
expect(tr.docChanged).toBe(false);
203+
});
204+
205+
it('should correctly collapse adjacent wrapped siblings', () => {
206+
const view = new EditorView(null, {
207+
state: EditorState.create({schema, plugins: [collapseListsPlugin()]}),
208+
});
209+
210+
const initialDoc = doc(
211+
ul(
212+
li(ul(li(p('First wrapped')))),
213+
li(ul(li(p('Second wrapped')))),
214+
li(ul(li(p('Third wrapped')))),
215+
),
216+
);
217+
218+
view.dispatch(
219+
view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content),
220+
);
221+
222+
expect(view.state.doc).toMatchNode(
223+
doc(ul(li(p('First wrapped')), li(p('Second wrapped')), li(p('Third wrapped')))),
224+
);
225+
});
226+
227+
it('should correctly collapse adjacent wrapped items with remaining content', () => {
228+
const view = new EditorView(null, {
229+
state: EditorState.create({schema, plugins: [collapseListsPlugin()]}),
230+
});
231+
232+
const initialDoc = doc(
233+
ul(li(ul(li(p('A'))), p('A-extra')), li(ul(li(p('B'))), p('B-extra'))),
234+
);
235+
236+
view.dispatch(
237+
view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content),
238+
);
239+
240+
expect(view.state.doc).toMatchNode(
241+
doc(ul(li(p('A')), li(p('A-extra')), li(p('B')), li(p('B-extra')))),
242+
);
243+
});
142244
});
245+
246+
describe('CollapseListsPlugin + MergeListsPlugin integration', () => {
247+
it('should collapse wrapped nesting and merge resulting adjacent lists', () => {
248+
const markdown = [
249+
'- 1. First item',
250+
'- 2. Second item',
251+
'',
252+
'Some text between',
253+
'',
254+
'- 3. Third item',
255+
].join('\n');
256+
257+
const parsedDoc = parser.parse(markdown);
258+
259+
// register merge BEFORE collapse — same order as production (index.ts)
260+
const view = new EditorView(null, {
261+
state: EditorState.create({
262+
schema,
263+
plugins: [mergeListsPlugin(), collapseListsPlugin()],
264+
}),
265+
});
266+
267+
view.dispatch(
268+
view.state.tr
269+
.setSelection(new AllSelection(view.state.doc))
270+
.replaceSelection(new Slice(parsedDoc.content, 0, 0)),
271+
);
272+
273+
const resultDoc = view.state.doc;
274+
275+
expect(hasRedundantNesting(resultDoc)).toBe(false);
276+
277+
// count top-level bullet_list nodes — the first two items should
278+
// be in one list (merged), the third after the paragraph in another
279+
let bulletListCount = 0;
280+
resultDoc.forEach((child) => {
281+
if (child.type.name === ListNode.BulletList) bulletListCount++;
282+
});
283+
expect(bulletListCount).toBe(2);
284+
});
285+
286+
it('should merge adjacent same-type lists produced by collapse', () => {
287+
// two separate bullet lists that each contain redundant nesting
288+
// after collapse, the lists are adjacent and should be merged
289+
const view = new EditorView(null, {
290+
state: EditorState.create({
291+
schema,
292+
plugins: [mergeListsPlugin(), collapseListsPlugin()],
293+
}),
294+
});
295+
296+
// two separate top-level bullet lists, each with redundant nesting
297+
const initialDoc = doc(ul(li(ul(li(p('A'))))), ul(li(ul(li(p('B'))))));
298+
299+
view.dispatch(
300+
view.state.tr.replaceWith(0, view.state.doc.nodeSize - 2, initialDoc.content),
301+
);
302+
303+
const resultDoc = view.state.doc;
304+
expect(hasRedundantNesting(resultDoc)).toBe(false);
305+
306+
// after collapse both items are flat, and merge should
307+
// combine the two adjacent bullet_lists into one
308+
let bulletListCount = 0;
309+
resultDoc.forEach((child) => {
310+
if (child.type.name === ListNode.BulletList) bulletListCount++;
311+
});
312+
expect(bulletListCount).toBe(1);
313+
314+
expect(resultDoc).toMatchNode(doc(ul(li(p('A')), li(p('B')))));
315+
});
316+
});
317+
318+
function hasRedundantNesting(node: Node): boolean {
319+
let found = false;
320+
node.descendants((child) => {
321+
if (found) return false;
322+
if (child.type.name === ListNode.ListItem && child.firstChild) {
323+
const fc = child.firstChild;
324+
if (fc.type.name === ListNode.BulletList || fc.type.name === ListNode.OrderedList) {
325+
found = true;
326+
return false;
327+
}
328+
}
329+
return true;
330+
});
331+
return found;
332+
}

0 commit comments

Comments
 (0)